Skip to content

fix(core): keep UUIDv7 valid by sanitizing generator inputs#4064

Draft
shahidrogers wants to merge 3 commits into
PostHog:mainfrom
shahidrogers:fix/uuidv7-fallback-uuidv4
Draft

fix(core): keep UUIDv7 valid by sanitizing generator inputs#4064
shahidrogers wants to merge 3 commits into
PostHog:mainfrom
shahidrogers:fix/uuidv7-fallback-uuidv4

Conversation

@shahidrogers

@shahidrogers shahidrogers commented Jul 3, 2026

Copy link
Copy Markdown

Problem

uuidv7() in @posthog/core can throw RangeError. V7Generator.generate() reads Date.now() and Math.random() and feeds them to UUID.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:

  • a page where another script clobbers Date with a legacy polyfill — the case in Uncaught RangeError: invalid field value #710 (datejs via clickfunnels) — trips the `unixTsMs` must be a 48-bit positive integer guard, and
  • a device with a broken RNG (React Native's default generator always uses Math.random(), no buffered crypto) makes nextUint32() return an out-of-range value and trips RangeError: 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-side try/catch around posthog.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):

  • ClockclampUnixTsMs() coerces Date.now() into the 48-bit positive range fromFieldsV7 accepts (non-finite / < 11; > 0xffff_ffff_ffff → the max). Applied only at the two clock-reading entry points (generate(), generateOrAbort()); the low-level generateOr*Core primitives keep their documented throw-on-bad-input contract.
  • RNGgetDefaultRandom().nextUint32() coerces its result with >>> 0 (ToUint32), so a Math.random() that returns NaN or a value outside [0, 1) can't yield an out-of-range field. With a valid uint32 the counter-derived randA/randB fields are provably in range too, so fromFieldsV7 never 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, and uuid7ToTimestampMs-based id bootstrapping in packages/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

  • Ran the equivalent fallback as a patch-package patch in our production React Native app since mid-June 2026: the uuidv7 RangeError crash 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.
  • Unit tests cover the healthy path plus four broken-environment cases (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 pass
  • pnpm lint (packages/core): pass
  • pnpm build (packages/core, after building workspace dep @posthog/types): pass

Note on packages/browser

packages/browser has its own vendored copy of this file with a uuid7ToTimestampMs helper. 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.

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.
@greptile-apps

greptile-apps Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Reviews (1): Last reviewed commit: "fix(core): fall back to UUIDv4 when the ..." | Re-trigger Greptile

Comment thread packages/core/src/__tests__/uuidv7.spec.ts Outdated
Comment thread packages/core/src/__tests__/uuidv7.spec.ts
@pauldambra

Copy link
Copy Markdown
Member

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).
@shahidrogers shahidrogers changed the title fix(core): fall back to UUIDv4 when the UUIDv7 generator throws fix(core): keep UUIDv7 valid by sanitizing generator inputs Jul 6, 2026
@shahidrogers

shahidrogers commented Jul 6, 2026

Copy link
Copy Markdown
Author

@pauldambra — good call, thanks. You're right that the v4 fallback breaks the v7 invariant (session tracking, and the uuid7ToTimestampMs bootstrap path in packages/browser), so I've reworked the PR to keep the id a valid v7 instead of degrading the version.

Rather than catch the throw, I sanitize the two untrusted inputs at the source so V7Generator structurally can't throw:

  • clockclampUnixTsMs() coerces Date.now() into the 48-bit range fromFieldsV7 accepts (this is the Uncaught RangeError: invalid field value #710 Date-polyfill case), applied only at the generate() / generateOrAbort() entry points so the low-level generateOr*Core primitives keep their documented throw-on-bad-input contract;
  • RNGnextUint32() coerces with >>> 0 (ToUint32), so a Math.random() returning NaN/out-of-[0,1) can't produce an out-of-range field (this is the broken-Android-RNG case). With a valid uint32 the counter-derived randA/randB fields are provably in range, so fromFieldsV7 never trips RangeError: invalid field value.

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 packages/browser's vendored copy alone for now

Comment on lines +448 to +449
* `Date` is replaced by a legacy polyfill (see
* https://github.com/PostHog/posthog-js/issues/710) or a device whose system

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

@shahidrogers
shahidrogers marked this pull request as draft July 17, 2026 02:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants