feat(browser): opt-in snippet unload fallback + $sent_send_beacon tagging#4116
feat(browser): opt-in snippet unload fallback + $sent_send_beacon tagging#4116pauldambra wants to merge 4 commits into
Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 63403ce5fd
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| var consent = local(consentKey) | ||
| if (consent == null) { | ||
| consent = cookie(consentKey) |
There was a problem hiding this comment.
Honor cookie-backed opt-out before beaconing
When opt_out_capturing_persistence_type: 'cookie' is configured, ConsentManager reads the cookie store as the source of truth, but this fallback checks localStorage first and only reads the cookie when local storage is missing. If a visitor has a stale local opt-in value and a current cookie opt-out (0/false), the unload fallback treats consent as granted and sends queued captures anyway, bypassing an explicit opt-out. Please choose the same consent store as the SDK, or at least let any no-like stored consent veto the beacon.
Useful? React with 👍 / 👎.
| var stored = | ||
| json(local(storageKey)) || json(session(storageKey)) || json(cookie(storageKey)) |
There was a problem hiding this comment.
Use configured persistence when recovering distinct_id
This always probes localStorage, then sessionStorage, then cookies for the persisted distinct_id, regardless of the instance's persistence config. For sites initialized with persistence: 'sessionStorage', 'cookie', or 'memory', a stale localStorage entry from a prior/default setup can cause a bounced event to be attributed to the wrong user and then be removed from the snippet queue, so array.js never gets a chance to send it with the SDK's configured identity semantics. Please select the same backend as PostHogPersistence or avoid persisted IDs when the configured backend is not durable local storage.
Useful? React with 👍 / 👎.
posthog-js Compliance ReportDate: 2026-07-08 19:06:31 UTC ✅ All Tests Passed!26/26 tests passed Capture Tests✅ 26/26 tests passed View Details
|
|
Size Change: -22.4 kB (-0.13%) Total Size: 17.1 MB
ℹ️ View Unchanged
|
…sent events Two changes for events at risk of loss on page unload: - the reference snippet (new canonical copy at packages/browser/snippet/snippet.js) gains a pagehide fallback: if array.js has not loaded yet, queued capture calls are sent via sendBeacon to /e/ marked $sent_by_snippet_fallback_on_unload, and spliced from the queue on successful hand-off so a late array.js drain can never double-process them. Consent/DNT/cookieless/disable_beacon all skip. The loader script is now appended to head instead of inserted before the first script element, so the snippet works in script-less documents too. - the SDK's sendBeacon transport now tags /e/ payloads with $sent_send_beacon so fire-and-forget deliveries are identifiable in analysis. A jest size test guards the snippet byte budget (currently 2944 B minified / 1529 B gzipped) and jsdom + playwright suites cover payload shape, identity resolution, consent gating, and the dedupe race against the real drain. The production snippet lives in posthog/posthog; grafting this fallback onto it is a follow-up PR there. Generated-By: PostHog Code Task-Id: 5b4a3349-ee3e-4280-9742-b1584372e9ba
…in block The fallback is deliberately not part of the default snippet: it adds ~1.3KB gzipped to every page's HTML and trades away correctness guarantees (identity joining, pipeline hooks) that only make sense for sites measurably losing events to bounces. snippet/unload-fallback.js is self-contained - paste it in any order near the snippet, twice is harmless - so it can be shared with the particular customers who need it. snippet/snippet.js reverts to loader-only. Hardenings over the previous embedded version: - a queued opt_out_capturing/opt_in_capturing is respected, last call wins, mirroring the real drain's replay order - sites customizing the event pipeline (before_send, sanitize_properties, property_blacklist/denylist, request_headers, queued set_config) are skipped entirely - bypassing redaction is worse than losing events - bot traffic is skipped (navigator.webdriver or bot/headless user agents), honoring opt_out_useragent_filter like the SDK - payloads are bounded: at most 50 events, and oversized bodies are left queued rather than rejected by the 64KB beacon limit snippet/README.md documents installation, guarantees, and caveats for sharing. New jsdom tests prove the opt-in property in both directions, the queued-consent semantics, and the payload bounds. Generated-By: PostHog Code Task-Id: 5b4a3349-ee3e-4280-9742-b1584372e9ba
63403ce to
fff4e09
Compare
This stack of pull requests is managed by Graphite. Learn more about stacking. |
The file is source customers audit before pasting - terser does the shrinking, so golfed helper names and one monolithic handler optimized the wrong thing. The logic is now decomposed into named functions (visitorHasOptedOut, resolveIdentity, collectQueuedCaptures, ...) with descriptive variables, still ES5 and behavior-identical: the jsdom suite executes the file verbatim and the minified-parity test covers the terser output. Legibility costs 448 B minified (2962 B / 1455 B gz, budget raised to 3300/1600) - the right trade for an example file. Generated-By: PostHog Code Task-Id: 5b4a3349-ee3e-4280-9742-b1584372e9ba
# Conflicts: # packages/browser/src/request.ts
|
this isn't it |

Problem
On slow connections the snippet queues
posthog.capture(...)calls in memory whilearray.jsdownloads. If the visitor bounces first, those events are silently lost — customers on high-bounce landing pages perceive this as data loss, and today there is no delivery marker to even measure it.Separately, nothing marks events that left the browser via the
sendBeaconfallback in the SDK's unload flush, so fire-and-forget deliveries are indistinguishable in analysis.Changes
1. Opt-in snippet companion block:
packages/browser/snippet/unload-fallback.jsA self-contained ES5 block customers paste near the snippet (either order; double-paste is harmless). On
pagehide, ifarray.jshas not loaded, queuedcapturecalls are sent withnavigator.sendBeaconto/e/(base64data=body — CORS-safelisted, no preflight at unload), marked$sent_by_snippet_fallback_on_unload: true, and spliced from the queue only after the browser accepts the beacon, so a late-arrivingarray.jscan never double-process them (the drain and handler are both synchronous, so they cannot interleave).It is deliberately not part of the default snippet — it adds ~1.3 KB gz to every page's HTML, and it trades away guarantees (identity joining, pipeline hooks) that only make sense for sites measurably losing events to bounces. The default snippet is size-guarded to stay untouched (869 B min / 517 B gz).
It refuses to send when the SDK would not, or when it cannot be sure:
opt_out_capturing_by_default; a queuedopt_out_capturing()(lastopt_in/opt_outcall wins, mirroring the real drain's replay order); DNT withrespect_dnt; anycookieless_modebefore_send,sanitize_properties,property_blacklist/property_denylist,request_headers, or a queuedset_config— bypassing redaction would be worse than losing eventsnavigator.webdriver, bot/headless UA), honoringopt_out_useragent_filter: truelike the SDKdisable_beacon, nosendBeacon, payload over the 64 KB beacon limit (max 50 events; oversize left queued)Identity: queued
identifyid > persisteddistinct_id(localStorage/sessionStorage/cookie, token-sanitized key,persistence_namehonored) > generated personlesssnippet-id with$process_person_profile: falseso bouncing first-time visitors never create junk person profiles. It never writes storage, never logs, and every path is wrapped so it cannot throw into page code.snippet/README.mddocuments installation, guarantees, and caveats — the artifact to share with customers.2.
$sent_send_beacon: trueon the SDK's beacon transport (src/request.ts)Tagged once at the transport level, covering all producers (
RequestQueue.unload(),RetryQueue.unload(), explicittransport: 'sendBeacon'captures). Gated to/e/payloads so/s/snapshots and logs are untouched. Zero existing snapshots changed.3. Reference snippet + loader hardening
snippet/snippet.jsis a readable canonical copy of the classic snippet (tested as the exact file), now appending the loader<script>toheadinstead ofinsertBeforethe first script element — works in script-less documents (e.g. programmatically created iframes). Production source of truth stays in posthog/posthog; grafting head-append there is a follow-up.Tests: 70 jsdom tests execute the shipped files verbatim (
new Function), covering the opt-in property in both directions, wire format, identity matrix, no-send matrix, queued-consent semantics, dedupe against the realinit_from_snippet()drain, payload bounds, and minified-parity; a terser+gzip size test enforces byte budgets for both files; 2 chromium Playwright tests cover the slow-array.jsfallback + no-double-processing end to end, and the$sent_send_beacontag on real unload flushes.Why this lives in posthog-js rather than posthog/posthog: the fallback's contract is entirely SDK internals (the
_ituple shape,__loaded,_execute_arrayreplay order, persistence/consent key formats, the/e/wire shape), and the key dedupe test imports the realinit_from_snippet()from source - a change to any of those internals breaks CI in the same PR here, instead of silently breaking pasted customer code and only surfacing against a released bundle. posthog/posthog gets the distribution surface (docs/UI embedding the minified block) and the production snippet head-append in a follow-up.Release info Sub-libraries affected
Libraries affected
Checklist
If releasing new changes
pnpm changesetto generate a changeset file🤖 Agent context
Autonomy: Human-driven (agent-assisted)
opt_out_useragent_filteris set (bit both the e2e fixture and, later, our own bot guard); Playwright served a staledist/bundle predating the request.ts change; beacon bodies are only exposed viapostDataBuffer()in Chromium.Created with PostHog Code