Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion frontend/instrumentation-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
14 changes: 12 additions & 2 deletions frontend/lib/posthog-privacy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ type EventInput = {
};

type SanitizedEvent = {
event: "$pageview" | "cta activated" | "$web_vitals";
event: "$pageview" | "$pageleave" | "cta activated" | "$web_vitals";
properties: Record<string, boolean | number | string>;
timestamp?: Date;
uuid?: string;
Expand Down Expand Up @@ -138,10 +138,20 @@ export function sanitizeEvent(input: unknown): SanitizedEvent | null {
const values = properties as Record<string, unknown>;
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,
});
}

Expand Down
1 change: 1 addition & 0 deletions frontend/test/posthog-source.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"',
Expand Down
96 changes: 96 additions & 0 deletions frontend/test/posthog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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,
},
});
Expand Down
Loading