From b6683a11c553620251421fa6e398d8e48d0778a1 Mon Sep 17 00:00:00 2001 From: scttbnsn <80784472+scttbnsn@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:09:18 -0400 Subject: [PATCH] feat(analytics): capture $pageleave and send $pathname Measured over the shared PostHog project, 208 of 432 sessions across the five instrumented sites record zero duration, and PostHog's built-in Web analytics Page/Entry page/Exit page tables return zero rows. capture_pageleave was false, so a session's last recorded timestamp is its last pageview, and a five-minute read of one page scores as zero seconds. Flipping the option alone fixes nothing: sanitizeEvent allowlisted only $pageview, cta activated, and $web_vitals, so every $pageleave posthog-js emitted would have been dropped silently with no error and no ingestion warning. This adds a $pageleave branch that rebuilds the event the same way $pageview does. capture_pageview is false here (pageviews are captured by hand), so posthog-js's _shouldCapturePageleave gate needs an explicit true rather than the default. $pathname is the property PostHog's page tables actually key off, and it was never sent. It's bound to the already-sanitized `path` value, never the raw pathname, so it can't carry a route outside ALLOWED_ROUTES and adds no information the event wasn't already sending. A regression test asserts the two never diverge. No privacy option changes: cookieless_mode, person_profiles, persistence, disable_persistence, respect_dnt, save_referrer, and save_campaign_params are untouched. Part of X16 in the ops execution plan. --- frontend/instrumentation-client.ts | 10 ++- frontend/lib/posthog-privacy.ts | 14 +++- frontend/test/posthog-source.test.mjs | 1 + frontend/test/posthog.test.ts | 96 +++++++++++++++++++++++++++ 4 files changed, 118 insertions(+), 3 deletions(-) diff --git a/frontend/instrumentation-client.ts b/frontend/instrumentation-client.ts index 2b5cb09..ee29658 100644 --- a/frontend/instrumentation-client.ts +++ b/frontend/instrumentation-client.ts @@ -16,7 +16,15 @@ if (typeof window !== "undefined" && posthogConfig) { api_host: posthogConfig.apiHost, ui_host: posthogConfig.uiHost, capture_pageview: false, - capture_pageleave: false, + // posthog-js only emits $pageleave when capture_pageleave === true, or + // when it's "if_capture_pageview" AND capture_pageview is enabled + // (posthog-js posthog-core.ts, _shouldCapturePageleave). capture_pageview + // is false here because pageviews are captured by hand above, so this has + // to be an explicit true rather than the default. sanitizeEvent in + // lib/posthog-privacy.ts rebuilds $pageleave the same way it rebuilds + // $pageview; without that branch flipping this alone would drop every + // $pageleave silently. + capture_pageleave: true, autocapture: false, rageclick: false, disable_session_recording: true, diff --git a/frontend/lib/posthog-privacy.ts b/frontend/lib/posthog-privacy.ts index 4809f28..c241757 100644 --- a/frontend/lib/posthog-privacy.ts +++ b/frontend/lib/posthog-privacy.ts @@ -51,7 +51,7 @@ type EventInput = { }; type SanitizedEvent = { - event: "$pageview" | "cta activated" | "$web_vitals"; + event: "$pageview" | "$pageleave" | "cta activated" | "$web_vitals"; properties: Record; timestamp?: Date; uuid?: string; @@ -138,10 +138,20 @@ export function sanitizeEvent(input: unknown): SanitizedEvent | null { const values = properties as Record; const common = createCommonProperties(values); if (common === null) return null; - if (event === "$pageview") { + // posthog-js emits $pageleave itself once capture_pageleave is true; + // nothing in this codebase calls it directly. It has to be rebuilt here + // like every other envelope — before this branch existed, $pageleave fell + // through to the `return null` below and was dropped silently, which is + // why flipping capture_pageleave on the init options alone fixes nothing. + // $pathname is set to the already-sanitized `path` rather than the raw + // pathname so PostHog's Web analytics Page / Entry page / Exit page + // tables — which key off $pathname — resolve without leaking any route + // outside ALLOWED_ROUTES. + if (event === "$pageview" || event === "$pageleave") { return createSanitizedEvent(eventInput, event, { ...common, $current_url: `${PRODUCTION_ORIGIN}${common.path}`, + $pathname: common.path, }); } diff --git a/frontend/test/posthog-source.test.mjs b/frontend/test/posthog-source.test.mjs index 7660607..ea1f6ce 100644 --- a/frontend/test/posthog-source.test.mjs +++ b/frontend/test/posthog-source.test.mjs @@ -27,6 +27,7 @@ test("privacy posture disables persistence, recording, autocapture, and automati "NEXT_PUBLIC_POSTHOG_HOST", "NEXT_PUBLIC_POSTHOG_UI_HOST", "capture_pageview: false", + "capture_pageleave: true", "autocapture: false", "disable_session_recording: true", 'persistence: "memory"', diff --git a/frontend/test/posthog.test.ts b/frontend/test/posthog.test.ts index c2c064d..de2c021 100644 --- a/frontend/test/posthog.test.ts +++ b/frontend/test/posthog.test.ts @@ -73,12 +73,107 @@ test("pageview events keep only the sanitized pathname", () => { surface: "marketing", path: "/", $current_url: "https://codeswhat.com/", + $pathname: "/", ...COOKIELESS_HASH_PROPERTIES, }, }, ); }); +test("pageleave events mirror the pageview contract", () => { + // posthog-js emits $pageleave itself once capture_pageleave is true, so it + // reaches sanitizeEvent carrying PostHog's own automatic properties + // ($pathname among them) rather than the hand-built "path" property that + // capturePageview() passes for $pageview. sanitizeEvent has to rebuild it + // from the same allowlist; before that branch existed it fell through to + // the closing `return null` and every $pageleave was dropped silently, + // which is why flipping capture_pageleave on its own fixes nothing. + assert.deepEqual( + sanitizeEvent({ + event: "$pageleave", + properties: { + $pathname: "/?secret=1#fragment", + $current_url: "https://codeswhat.com/?secret=1#fragment", + token: "phc_public-token_123", + distinct_id: "$posthog_cookieless", + $cookieless_mode: true, + $process_person_profile: false, + ...COOKIELESS_HASH_PROPERTIES, + }, + }), + { + event: "$pageleave", + properties: { + token: "phc_public-token_123", + distinct_id: "$posthog_cookieless", + $cookieless_mode: true, + $process_person_profile: false, + schema_version: 1, + site: "codeswhat", + surface: "marketing", + path: "/", + $current_url: "https://codeswhat.com/", + $pathname: "/", + ...COOKIELESS_HASH_PROPERTIES, + }, + }, + ); +}); + +test("$pageleave requires and forwards the cookieless server-hash fields, same as $pageview", () => { + const validProperties = { + $pathname: "/", + token: "phc_public-token_123", + $cookieless_mode: true, + $process_person_profile: false, + ...COOKIELESS_HASH_PROPERTIES, + }; + + const result = sanitizeEvent({ event: "$pageleave", properties: validProperties }); + assert.ok(result); + assert.equal(result.properties.$raw_user_agent, COOKIELESS_HASH_PROPERTIES.$raw_user_agent); + assert.equal(result.properties.$host, COOKIELESS_HASH_PROPERTIES.$host); + + for (const missingKey of Object.keys(COOKIELESS_HASH_PROPERTIES)) { + const withoutField = { ...validProperties }; + delete withoutField[missingKey as keyof typeof withoutField]; + assert.equal( + sanitizeEvent({ event: "$pageleave", properties: withoutField }), + null, + `sanitizeEvent must drop $pageleave events missing ${missingKey}`, + ); + } +}); + +test("$pathname never diverges from the allowlisted path", () => { + // $pathname exists so PostHog's Web analytics Page / Entry page / Exit + // page tables resolve at all; those tables read $pathname and nothing + // else. It must stay bound to the sanitized `path`: if it ever carried the + // raw pathname instead, every unlisted route would leak into the + // analytics project past ALLOWED_ROUTES. + for (const rawPath of ["/", "/about", "/pricing?secret=1#fragment", "//evil.example/"]) { + for (const event of ["$pageview", "$pageleave"] as const) { + const result = sanitizeEvent({ + event, + properties: { + path: rawPath, + token: "phc_public-token_123", + $cookieless_mode: true, + $process_person_profile: false, + ...COOKIELESS_HASH_PROPERTIES, + }, + }); + assert.ok(result, `sanitizeEvent should accept ${event} for ${rawPath}`); + assert.equal(result.properties.$pathname, result.properties.path); + assert.equal( + String(result.properties.$pathname).includes("secret"), + false, + `unlisted route leaked into $pathname for ${rawPath}`, + ); + } + } +}); + test("CTA events are limited to the initial GitHub placements", () => { assert.deepEqual(ALLOWED_CTA_IDS, ["github_org"]); assert.deepEqual( @@ -208,6 +303,7 @@ test("the pinned PostHog before_send pipeline keeps the required cookieless enve surface: "marketing", path: "/", $current_url: "https://codeswhat.com/", + $pathname: "/", ...COOKIELESS_HASH_PROPERTIES, }, });