feat(browser): warn when volatile persistence creates a new anonymous person per page load - #4796
Conversation
… person per page load `memory` and `sessionStorage` persistence drop the distinct ID, so PostHog mints a new one on each page load. With `person_profiles: 'always'` each new ID becomes its own anonymous person, and no `identify()` call is needed — so the existing volatile-identity warning, which fires from `_requirePersonProcessing()`, never ran. Check at init and on `set_config` when `person_profiles` is `always`. `reuseAnonymousId` no longer suppresses the warning in that case, because it only stops `identify()` from merging IDs. Also document the cost on the `persistence` config JSDoc. Generated-By: PostHog Desktop Task-Id: 406c89f8-06a7-4f15-a097-1761a5b651c4
🦔 PostHog Review reviewed this pull requestFound 0 must fix, 0 should fix, 1 consider. Published 1 finding (view the review). Resolved comments: 1 fixed |
posthog-js Compliance ReportDate: 2026-09-05 00:18:00 UTC ✅ All Tests Passed!26/26 tests passed Capture Tests✅ 26/26 tests passed View Details
|
|
Size Change: +9.17 kB (+0.04%) Total Size: 20.5 MB 📦 View Changed
ℹ️ View Unchanged
|
|
PostHog Review alpha 🦔 If you find any issues helpful - please reply "valid", "invalid", etc., for evaluation purposes 🙏 |
sessionStorage survives same-tab reloads and navigation, so it mints a new distinct ID per tab or window rather than per page load. Split the lifetimes in the changeset so the release note matches the shipped warning text. Generated-By: PostHog Desktop Task-Id: 1167d033-4f81-4a81-b691-e29560cb7ed3
…he identity warning
disable_persistence removes the store and blocks every write, so the distinct
ID is dropped on each page load whatever `persistence` is set to. The warning
tested the storage mode first, so `{ persistence: 'sessionStorage',
disable_persistence: true }` reported the slower per-tab lifetime and offered
'localStorage+cookie', a remedy that stabilizes nothing while persistence stays
off. Check disable_persistence first, and name both changes in the
durable-storage remedy when the configured mode is itself volatile.
Generated-By: PostHog Desktop
Task-Id: 1167d033-4f81-4a81-b691-e29560cb7ed3
…rences The two new private members on PostHog need entries in terser-mangled-names.json, and the persistence JSDoc addition changes the generated public API reference. Generated-By: PostHog Desktop Task-Id: 406c89f8-06a7-4f15-a097-1761a5b651c4
| private _warnIfVolatileIdentityCreatesAnonymousPersons(): void { | ||
| if (this._hasResolvedInitialDistinctId && this.config.person_profiles === 'always') { | ||
| this._warnIfVolatileIdentityWithoutStableId() |
There was a problem hiding this comment.
Segment anonymous users bypass the new warning
Why we think it's a valid issue
- Checked: the guard list in
_warnIfVolatileIdentityWithoutStableId()(packages/browser/src/posthog-core.ts:552-554), the init order in_init(),setupPostHogFromSegment()/bootstrapUser()inpackages/browser/src/extensions/segment-integration.ts, thecapture_pageviewdefault,_hasPersonProcessing(), and the guard's state onmainviagit show main:packages/browser/src/posthog-core.ts. - Found: the premise holds.
_init()registersdistinct_idfromthis.config.get_device_id(uuidv7())atpackages/browser/src/posthog-core.ts:1033-1046, beforesetupSegmentIntegration(this, () => this._loaded())runs atpackages/browser/src/posthog-core.ts:1059. PostHog mints its own ID first. - Found:
bootstrapUser()(packages/browser/src/extensions/segment-integration.ts:93-108) replacesposthog.config.get_device_idwith Segment's anonymous-ID getter, but it registersdistinct_id: user.id()only whenuser.id()exists. For a visitor who never identifies, it registers no distinct ID, so PostHog keeps the volatile uuid. The comment atpackages/browser/src/posthog-core.ts:552— "Segment ... supplies its stable user/anonymous ID before events load" — is true only for an identified Segment user. - Found: PostHog still sends its own events on that ID.
capture_pageviewdefaults to'history_change'ortrue(packages/browser/src/posthog-core.ts:259) and_loaded()captures the initial pageview (packages/browser/src/posthog-core.ts:1340-1345)._hasPersonProcessing()returnstruefor'always'(packages/browser/src/posthog-core.ts:4432), so each page load creates a separate anonymous person while the warning stays silent. - Found: the Segment guard itself is unchanged from
main, so the diff causes no regression. Onmainthe warning ran only from_requirePersonProcessing(), which a Segment flow reaches throughidentify()— the path whereuser.id()does supply a stable ID. The new init path is the first caller for which the exemption's rationale does not hold. - Impact: the confirmed consequence is a missing console warning in the configuration Segment +
memory/sessionStorage+person_profiles: 'always'+ a visitor who never identifies. No captured value changes and no data is lost. - Priority: lowered to
consider. The gap is verified and sits on the PR's own target case, but the loss is one diagnostic message in a narrow configuration. The primary remedy in the suggestion — adopt Segment's anonymous ID before_loaded()— would change the distinct ID for every anonymous Segment user, far past a warning-only change, and the existingruntimeAnonymousIdpath shows that the current design avoids that adoption on purpose. Even the narrow remedy needs restructuring:segment.user()can return a promise (packages/browser/src/extensions/segment-integration.ts:110-115), so the bootstrap finishes after the synchronous check atpackages/browser/src/posthog-core.ts:1076, and any correct exemption test must move into the Segmentdone()callback.
Issue description
The new init check calls a helper that exits whenever config.segment exists. Segment bootstrap registers user.id(), but it does not register user.anonymousId() for an anonymous user. PostHog has already generated its own volatile ID before Segment setup. The default $pageview can use that ID and create a person. A memory client repeats this on each load, but the warning never appears.
Suggested fix
Adopt Segment's anonymous ID before _loaded() when no user ID exists. Apply the Segment exemption only when Segment supplied a stable user or anonymous ID. Add coverage for an anonymous Segment user with default pageview capture, person_profiles: 'always', and volatile persistence.
Prompt to fix with AI (copy-paste)
## Context
@packages/browser/src/posthog-core.ts#L608-610
<issue_description>
The new init check calls a helper that exits whenever `config.segment` exists. Segment bootstrap registers `user.id()`, but it does not register `user.anonymousId()` for an anonymous user. PostHog has already generated its own volatile ID before Segment setup. The default `$pageview` can use that ID and create a person. A `memory` client repeats this on each load, but the warning never appears.
</issue_description>
<issue_validation>
- **Checked:** the guard list in `_warnIfVolatileIdentityWithoutStableId()` (`packages/browser/src/posthog-core.ts:552-554`), the init order in `_init()`, `setupPostHogFromSegment()` / `bootstrapUser()` in `packages/browser/src/extensions/segment-integration.ts`, the `capture_pageview` default, `_hasPersonProcessing()`, and the guard's state on `main` via `git show main:packages/browser/src/posthog-core.ts`.
- **Found:** the premise holds. `_init()` registers `distinct_id` from `this.config.get_device_id(uuidv7())` at `packages/browser/src/posthog-core.ts:1033-1046`, before `setupSegmentIntegration(this, () => this._loaded())` runs at `packages/browser/src/posthog-core.ts:1059`. PostHog mints its own ID first.
- **Found:** `bootstrapUser()` (`packages/browser/src/extensions/segment-integration.ts:93-108`) replaces `posthog.config.get_device_id` with Segment's anonymous-ID getter, but it registers `distinct_id: user.id()` only when `user.id()` exists. For a visitor who never identifies, it registers no distinct ID, so PostHog keeps the volatile uuid. The comment at `packages/browser/src/posthog-core.ts:552` — "Segment ... supplies its stable user/anonymous ID before events load" — is true only for an identified Segment user.
- **Found:** PostHog still sends its own events on that ID. `capture_pageview` defaults to `'history_change'` or `true` (`packages/browser/src/posthog-core.ts:259`) and `_loaded()` captures the initial pageview (`packages/browser/src/posthog-core.ts:1340-1345`). `_hasPersonProcessing()` returns `true` for `'always'` (`packages/browser/src/posthog-core.ts:4432`), so each page load creates a separate anonymous person while the warning stays silent.
- **Found:** the Segment guard itself is unchanged from `main`, so the diff causes no regression. On `main` the warning ran only from `_requirePersonProcessing()`, which a Segment flow reaches through `identify()` — the path where `user.id()` does supply a stable ID. The new init path is the first caller for which the exemption's rationale does not hold.
- **Impact:** the confirmed consequence is a missing console warning in the configuration Segment + `memory`/`sessionStorage` + `person_profiles: 'always'` + a visitor who never identifies. No captured value changes and no data is lost.
- **Priority:** lowered to `consider`. The gap is verified and sits on the PR's own target case, but the loss is one diagnostic message in a narrow configuration. The primary remedy in the suggestion — adopt Segment's anonymous ID before `_loaded()` — would change the distinct ID for every anonymous Segment user, far past a warning-only change, and the existing `runtimeAnonymousId` path shows that the current design avoids that adoption on purpose. Even the narrow remedy needs restructuring: `segment.user()` can return a promise (`packages/browser/src/extensions/segment-integration.ts:110-115`), so the bootstrap finishes after the synchronous check at `packages/browser/src/posthog-core.ts:1076`, and any correct exemption test must move into the Segment `done()` callback.
</issue_validation>
## Task
Investigate the issue and solve it
<potential_solution>
Adopt Segment's anonymous ID before `_loaded()` when no user ID exists. Apply the Segment exemption only when Segment supplied a stable user or anonymous ID. Add coverage for an anonymous Segment user with default pageview capture, `person_profiles: 'always'`, and volatile persistence.
</potential_solution>
There was a problem hiding this comment.
Confirmed the gap, but escalating rather than fixing it here — it needs a decision I should not make unattended.
What I verified: the warning helper bails out as soon as a Segment instance is configured, and that bail-out is unchanged from main. For a Segment visitor who never identifies, PostHog registers its own throwaway ID during init, before Segment setup runs. Segment's bootstrap then only swaps the device-ID getter and registers a distinct ID when Segment already has a user ID — so the anonymous case keeps PostHog's throwaway ID. Since pageview capture is on by default, PostHog's own first pageview goes out on that ID, and with volatile persistence plus person_profiles: 'always' that is a fresh anonymous person on every load, silently. Segment's stable anonymous ID does eventually take over, but only after the first Segment-originated event resets PostHog — which is after that initial pageview.
Why I did not implement it:
- The suggested primary fix (adopt Segment's anonymous ID before load) would change the distinct ID for every anonymous Segment user. That is an identity-behaviour change, well beyond a PR that only adds a console warning, and the existing runtime-anonymous-ID handling suggests the current design avoids that adoption on purpose.
- The narrow "only exempt Segment when it supplied a stable ID" fix has no clean shape either. Segment's user lookup can be asynchronous, so at the point init runs the check we do not yet know whether a user ID existed; making it correct means deferring the check into Segment's completion callback and threading new state out of the Segment integration. And even a stable Segment anonymous ID would not make suppression correct, because PostHog's first pageview happens before that ID is adopted. There are at least three defensible answers there.
- This exemption is pre-existing and untouched by this PR, so it is not a regression from these changes.
What a human needs to decide: whether the Segment exemption should be narrowed at all for the person_profiles: 'always' + volatile-persistence case, and if so which of the three readings to take — (a) warn for any Segment client with volatile persistence and always (simplest, but fires for identified Segment users whose distinct ID is in fact stable), (b) defer the check into Segment's completion callback and suppress only when Segment supplied a user ID (correct but restructures init ordering and adds state to the Segment integration), or (c) adopt Segment's anonymous ID at init (fixes the underlying person-per-load behaviour, not just the warning, and is a much larger change). My read is that (b) or (c) belongs in a follow-up scoped to Segment identity rather than in this warning-only PR.
…ity check The comment claimed _requirePersonProcessing() never runs under person_profiles: 'always'. It does — calculateEventProperties() calls it on the first captured event, so the event path already reaches the warning. State the real reason the init/set_config check exists instead: that trigger only fires once the first event is captured, which a consent gate or capture_pageview: false can defer long past init. Comment only, no behaviour change. Generated-By: PostHog Desktop Task-Id: 96fe2251-005e-49c6-ba3b-c2662e07e921
…g text Both branches of the warning open with the same sentence opener, so build it once and append the branch-specific tail. The emitted text is unchanged; this trims the duplicated literal from every bundle. Also correct the comment above the init-time check. The warning's only other call site is _requirePersonProcessing(), not calculateEventProperties(), and nothing on the anonymous-profile path reaches it. Generated-By: PostHog Desktop Task-Id: 406c89f8-06a7-4f15-a097-1761a5b651c4
The volatile-identity warning wrote through the global console, which the Logs console recorder and session replay both patch. Under person_profiles: 'always' the warning now fires at init, so every page load of a misconfigured site would ingest one SDK-authored log record billed to the customer. Write it through the unpatched method with the existing originalConsoleMethod helper, the same escape logger._log already uses, so the warning stays visible in the browser console without being captured. Generated-By: PostHog Desktop Task-Id: ae59a390-e50e-45b3-bf76-e6a805df5fe8
Problem
persistence: 'memory'withperson_profiles: 'always', then spent weeks and a full support cycle finding out why their anonymous person count and feature-flag estimate kept growing. The SDK never said a word.memoryonly lasts one page load andsessionStorageonly lasts one tab. Neither keeps the distinct ID, so PostHog mints a new one on every load — and underperson_profiles: 'always'each new ID becomes its own anonymous person._requirePersonProcessing(). Underperson_profiles: 'always'a person is created without anyidentify()call, so that path never runs and the warning never fires. This is exactly the configuration that produces junk persons.identified_onlyidentify()alwaysChanges
set_config) whenperson_profilesisalways, instead of waiting for a person-processing request that never comes.bootstrap.distinctID, or theperson_profiles: 'identified_only'default so anonymous visitors get no profile.reuseAnonymousIdno longer suppresses the warning underperson_profiles: 'always'. It only stopsidentify()from merging IDs; a fresh anonymous person is still created on each load, so it is not an escape here.memoryandsessionStorageon thepersistenceconfig JSDoc.No behavior change beyond the warning. The existing
identified_onlymessage and its remedies are unchanged.Release info Sub-libraries affected
Libraries affected
Checklist
If releasing new changes
pnpm changesetto generate a changeset file🤖 Agent context
Autonomy: Fully autonomous
vitest,oxlint,oxfmt.memorybranch of_buildStorage. Rejected: a warning at storage selection cannot see whether person profiles are created, and would be a false positive for the many users onmemorywithperson_profiles: 'never'or a bootstrapped ID. The volatile-identity warning added in #4662 already has all those guards, so this extends it instead of adding a second one.main; the real remaining gap was theperson_profiles: 'always'path, which that warning does not reach._hasResolvedInitialDistinctId._init()callsset_config()before it resolves the initial distinct ID, so without it theset_confighook would warn during init even when abootstrap.distinctIDwas passed.Test run
vitest run src/__tests__/config.test.ts— 111 passed, including 6 new cases. The fullvitest run srcsuite has 2 failures on this branch that also fail on a cleanmaincheckout (a missingdist/build artifact and a pre-existingpersistence-key-policyfinding inbrowser-client.ts); no manual browser testing was done.Created with PostHog Desktop from this inbox report.