fix(core): keep UUIDv7 valid by sanitizing generator inputs#4064
fix(core): keep UUIDv7 valid by sanitizing generator inputs#4064shahidrogers wants to merge 3 commits into
Conversation
V7Generator validates its timestamp and random fields and throws RangeError: invalid field value when Date.now() or Math.random() misbehaves (legacy Date polyfills as in PostHog#710, or broken RNG/clock on some Android devices). Every SDK call funnels through uuidv7() via session/anonymous id generation, so the throw crashes the host app. The v4 generator performs no field validation and never reads the clock, so it cannot throw; fall back to it instead of crashing.
|
Reviews (1): Last reviewed commit: "fix(core): fall back to UUIDv4 when the ..." | Re-trigger Greptile |
…ry assertion into fallback test
|
we actually rely on the UUID being a v7 UUID in session tracking particularly so this might avoid the crash but I think we should have a different solution here cc @PostHog/team-client-libraries |
Rework the crash fix per @pauldambra's review: keep the id a valid v7 UUID rather than degrading to v4, which session tracking and id bootstrapping rely on. V7Generator throws RangeError when Date.now() or Math.random() misbehaves (legacy Date polyfills PostHog#710, broken RNG/clock on some Android devices), fataling every SDK id generation. Instead of catching and returning a v4, sanitize the two inputs at the source: - clamp Date.now() into the 48-bit positive range (clampUnixTsMs) - coerce the RNG output with `>>> 0` (ToUint32) in getDefaultRandom Generation now always yields a valid v7 UUID and never throws. Healthy environments are byte-for-byte unaffected (both operations are no-ops on in-range integers).
|
@pauldambra — good call, thanks. You're right that the v4 fallback breaks the v7 invariant (session tracking, and the Rather than catch the throw, I sanitize the two untrusted inputs at the source so
Both are no-ops on healthy inputs, so normal environments are byte-for-byte unchanged; broken ones now get a well-formed v7 (meaningless-but-in-range timestamp on a broken clock) and the generator self-recovers once the environment does. Tests cover the healthy path + four broken-env cases, each asserting a valid v7 and no throw. Left |
| * `Date` is replaced by a legacy polyfill (see | ||
| * https://github.com/PostHog/posthog-js/issues/710) or a device whose system |
There was a problem hiding this comment.
Hey @shahidrogers, thanks for the pull request!
Could you clarify which @posthog/core consumer reproduces this issue? The comments cite legacy polyfills and link to #710, but that issue was closed after being attributed to a third-party polyfill affecting the browser SDK (which uses a separate uuidv7 implementation).
Since this PR changes core, I don’t think it addresses that particular failure. Do we have a reproduction from Node, React Native, or some other @posthog/core consumer?
Problem
uuidv7()in@posthog/corecan throwRangeError.V7Generator.generate()readsDate.now()andMath.random()and feeds them toUUID.fromFieldsV7(...)/generateOrAbortCore(...), both of which validate their fields — so any environment where the clock or RNG returns out-of-spec values makes id generation itself throw:Datewith a legacy polyfill — the case in Uncaught RangeError: invalid field value #710 (datejs via clickfunnels) — trips the`unixTsMs` must be a 48-bit positive integerguard, andMath.random(), no buffered crypto) makesnextUint32()return an out-of-range value and tripsRangeError: invalid field value. We've reproduced this in production on real Android hardware (OnePlus 8 Pro / Android 11, Xiaomi 13T Pro / Android 16).The blast radius is much bigger than one lost id: every SDK call funnels through session/anonymous id generation (
getSessionId()→uuidv7(),getAnonymousId()→uuidv7()), including SDK-internal captures such as$app_opened. A user-sidetry/catcharoundposthog.capture(...)cannot protect the lifecycle captures the SDK fires on its own, so on affected devices the app crash-loops on every launch. This was a recurring, measurable crash signature in our production Crashlytics data across multiple device models before we patched it locally.Fix
Sanitize the two untrusted inputs at the source so the generator can no longer throw and still returns a valid v7 UUID (superseding the earlier v4-fallback approach — see below):
clampUnixTsMs()coercesDate.now()into the 48-bit positive rangefromFieldsV7accepts (non-finite /< 1→1;> 0xffff_ffff_ffff→ the max). Applied only at the two clock-reading entry points (generate(),generateOrAbort()); the low-levelgenerateOr*Coreprimitives keep their documented throw-on-bad-input contract.getDefaultRandom().nextUint32()coerces its result with>>> 0(ToUint32), so aMath.random()that returnsNaNor a value outside[0, 1)can't yield an out-of-range field. With a valid uint32 the counter-derivedrandA/randBfields are provably in range too, sofromFieldsV7never throws.Both operations are no-ops on healthy inputs (an in-range integer clamps to itself; a valid uint32 is unchanged by
>>> 0), so healthy environments are byte-for-byte identical.Why sanitize instead of falling back to v4 (previous approach)
The first version of this PR caught the throw and returned a
uuidv4(). @pauldambra pointed out that PostHog relies on these ids being v7 (session tracking, anduuid7ToTimestampMs-based id bootstrapping inpackages/browser). Emitting a v4 would break that version invariant. Sanitizing the inputs keeps the id a real v7 while still never crashing — the equivalent graceful degradation without changing the UUID version. On a broken clock the embedded timestamp is meaningless either way, but the id stays a well-formed, monotonic-friendly v7 and the generator self-recovers once the environment does.Validation
patch-packagepatch in our production React Native app since mid-June 2026: the uuidv7RangeErrorcrash flavor disappeared from Crashlytics while event ingestion from those devices continued normally. This PR's sanitize-at-source patch targets the exact same crash surface and additionally preserves v7.Math.random()≥ 1,Math.random()=NaN,Date.now()=NaN,Date.now()out of 48-bit range): each yields a valid v7 id, doesn't throw, and recovers.Checks
pnpm test:unit(packages/core, uuidv7): 5 tests passpnpm lint(packages/core): passpnpm build(packages/core, after building workspace dep@posthog/types): passNote on
packages/browserpackages/browserhas its own vendored copy of this file with auuid7ToTimestampMshelper. This PR keeps the fix scoped to@posthog/core; happy to mirror the same input-sanitization there in a follow-up if you'd like.