From 98386a2022bf2dbbdc03d84773021ec060f10661 Mon Sep 17 00:00:00 2001 From: scttbnsn <80784472+scttbnsn@users.noreply.github.com> Date: Sat, 15 Aug 2026 18:35:00 -0400 Subject: [PATCH] fix(analytics): forward $raw_user_agent and $host for cookieless ingestion PostHog's cookieless server-hash step reads $raw_user_agent and $host straight off event.properties and drops the event with a cookieless_missing_user_agent/cookieless_missing_host ingestion warning if either is absent. createCommonProperties rebuilt an allowlisted properties object that dropped both, so every event was silently discarded at ingestion. Forward them through; never add $ip, which PostHog's capture service fills in server-side from the connection. --- frontend/lib/posthog-privacy.ts | 23 +++++++++++- frontend/test/posthog-source.test.mjs | 19 ++++++++++ frontend/test/posthog.test.ts | 52 +++++++++++++++++++++++++++ 3 files changed, 93 insertions(+), 1 deletion(-) diff --git a/frontend/lib/posthog-privacy.ts b/frontend/lib/posthog-privacy.ts index 1254d76..4809f28 100644 --- a/frontend/lib/posthog-privacy.ts +++ b/frontend/lib/posthog-privacy.ts @@ -70,11 +70,30 @@ function getRawPath(properties: Record): unknown { function createCommonProperties(properties: Record) { const token = properties.token; + // PostHog's cookieless server-hash ingestion step computes the anonymous + // distinct id from day + team + $ip + $host + $raw_user_agent. It reads + // $raw_user_agent/$host straight off event.properties (not headers) and + // silently drops the event with a cookieless_missing_user_agent / + // cookieless_missing_host ingestion warning if either is absent + // (PostHog/posthog nodejs/src/ingestion/common/cookieless/cookieless-manager.ts, + // getProperties()/doBatchInner()). posthog-js attaches both to every + // envelope by default (PostHog/posthog-js + // packages/browser-common/src/utils/event-utils.ts, getEventProperties()), + // so they must survive the allowlist rebuild below. $ip is deliberately + // NOT forwarded here: posthog-js never sends it, and PostHog's capture + // service fills it in from the request's own connection IP when absent — a + // client-supplied $ip would only be able to make that worse, never better. + const rawUserAgent = properties.$raw_user_agent; + const host = properties.$host; if ( typeof token !== "string" || !PROJECT_TOKEN_PATTERN.test(token) || properties.$cookieless_mode !== true || - properties.$process_person_profile !== false + properties.$process_person_profile !== false || + typeof rawUserAgent !== "string" || + rawUserAgent === "" || + typeof host !== "string" || + host === "" ) { return null; } @@ -88,6 +107,8 @@ function createCommonProperties(properties: Record) { site: "codeswhat", surface: "marketing", path, + $raw_user_agent: rawUserAgent, + $host: host, }; if (properties.distinct_id === "$posthog_cookieless") { common.distinct_id = "$posthog_cookieless"; diff --git a/frontend/test/posthog-source.test.mjs b/frontend/test/posthog-source.test.mjs index b742812..7660607 100644 --- a/frontend/test/posthog-source.test.mjs +++ b/frontend/test/posthog-source.test.mjs @@ -62,3 +62,22 @@ test("public documentation does not advertise the retired provider badge", async const retiredBadgeText = ["Go", "Report", "Card"].join("\\s+"); assert.doesNotMatch(roadmap, new RegExp(retiredBadgeText, "i")); }); + +test("the cookieless envelope keeps the fields PostHog's server hash requires", async () => { + const privacy = await read("lib/posthog-privacy.ts"); + + // PostHog's cookieless server-hash ingestion step reads $raw_user_agent and + // $host straight off event.properties and drops the event — with a + // cookieless_missing_user_agent / cookieless_missing_host ingestion warning + // and zero rows ingested — if either is absent (PostHog/posthog + // nodejs/src/ingestion/common/cookieless/cookieless-manager.ts, + // getProperties()/doBatchInner()). posthog-js attaches both by default; + // createCommonProperties must allowlist them through, not silently strip + // them. Regression guard: if these keys ever disappear from the allowlist + // (or the comment explaining why they're there), every cookieless event on + // codeswhat.com drops with no PostHog-side error beyond the ingestion + // warning. + assert.match(privacy, /\$raw_user_agent/u); + assert.match(privacy, /\$host/u); + assert.match(privacy, /cookieless_missing_user_agent|cookieless server-hash/u); +}); diff --git a/frontend/test/posthog.test.ts b/frontend/test/posthog.test.ts index e894f1f..c2c064d 100644 --- a/frontend/test/posthog.test.ts +++ b/frontend/test/posthog.test.ts @@ -17,6 +17,19 @@ const { PostHog } = require("../node_modules/posthog-js/lib/src/posthog-core.js" }; }; +// posthog-js attaches these to every envelope by default (PostHog/posthog-js +// packages/browser-common/src/utils/event-utils.ts, getEventProperties()). +// sanitizeEvent must forward them: PostHog's cookieless server-hash +// ingestion step reads them straight off event.properties and drops the +// event with a cookieless_missing_user_agent / cookieless_missing_host +// ingestion warning if either is absent (PostHog/posthog +// nodejs/src/ingestion/common/cookieless/cookieless-manager.ts, +// getProperties() + doBatchInner()). +const COOKIELESS_HASH_PROPERTIES = { + $raw_user_agent: "Mozilla/5.0 (Test Runner)", + $host: "codeswhat.com", +}; + test("route sanitization only returns the finite public route manifest", () => { assert.deepEqual(ALLOWED_ROUTES, ["/"]); assert.equal(sanitizeRoute("/?utm_source=secret#private"), "/"); @@ -45,6 +58,7 @@ test("pageview events keep only the sanitized pathname", () => { distinct_id: "$posthog_cookieless", $cookieless_mode: true, $process_person_profile: false, + ...COOKIELESS_HASH_PROPERTIES, }, }), { @@ -59,6 +73,7 @@ test("pageview events keep only the sanitized pathname", () => { surface: "marketing", path: "/", $current_url: "https://codeswhat.com/", + ...COOKIELESS_HASH_PROPERTIES, }, }, ); @@ -76,6 +91,7 @@ test("CTA events are limited to the initial GitHub placements", () => { path: "/", cta_id: "github_org", placement: "hero", + ...COOKIELESS_HASH_PROPERTIES, }, }), { @@ -90,6 +106,7 @@ test("CTA events are limited to the initial GitHub placements", () => { path: "/", cta_id: "github_org", placement: "hero", + ...COOKIELESS_HASH_PROPERTIES, }, }, ); @@ -116,6 +133,7 @@ test("web vitals events keep only metric data", () => { $web_vitals_LCP_event: { attribution: "private" }, rating: "good", $current_url: "https://codeswhat.com/?private=1", + ...COOKIELESS_HASH_PROPERTIES, }, }), { @@ -129,6 +147,7 @@ test("web vitals events keep only metric data", () => { surface: "marketing", path: "/", $web_vitals_LCP_value: 123.4, + ...COOKIELESS_HASH_PROPERTIES, }, }, ); @@ -166,6 +185,7 @@ test("the pinned PostHog before_send pipeline keeps the required cookieless enve path: "/?secret=1#fragment", $set: { email: "private@example.com" }, $set_once: { referrer: "private" }, + ...COOKIELESS_HASH_PROPERTIES, }, $set: { email: "private@example.com" }, $set_once: { referrer: "private" }, @@ -188,6 +208,38 @@ test("the pinned PostHog before_send pipeline keeps the required cookieless enve surface: "marketing", path: "/", $current_url: "https://codeswhat.com/", + ...COOKIELESS_HASH_PROPERTIES, }, }); }); + +test("sanitizeEvent requires and forwards the cookieless server-hash fields", () => { + const validProperties = { + token: "phc_public-token_123", + distinct_id: "$posthog_cookieless", + $cookieless_mode: true, + $process_person_profile: false, + path: "/", + ...COOKIELESS_HASH_PROPERTIES, + }; + + const result = sanitizeEvent({ event: "$pageview", 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); + assert.equal(result.properties.$ip, undefined); + + // Regression guard: if sanitizeEvent ever goes back to rebuilding + // properties from an allowlist that forgets these two keys, cookieless + // ingestion drops every event again with zero warning-free indication + // beyond cookieless_missing_user_agent / cookieless_missing_host. + for (const missingKey of Object.keys(COOKIELESS_HASH_PROPERTIES)) { + const withoutField = { ...validProperties }; + delete withoutField[missingKey as keyof typeof withoutField]; + assert.equal( + sanitizeEvent({ event: "$pageview", properties: withoutField }), + null, + `sanitizeEvent must drop events missing ${missingKey}`, + ); + } +});