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
23 changes: 22 additions & 1 deletion frontend/lib/posthog-privacy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,30 @@ function getRawPath(properties: Record<string, unknown>): unknown {

function createCommonProperties(properties: Record<string, unknown>) {
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;
}
Expand All @@ -88,6 +107,8 @@ function createCommonProperties(properties: Record<string, unknown>) {
site: "codeswhat",
surface: "marketing",
path,
$raw_user_agent: rawUserAgent,
$host: host,
};
if (properties.distinct_id === "$posthog_cookieless") {
common.distinct_id = "$posthog_cookieless";
Expand Down
19 changes: 19 additions & 0 deletions frontend/test/posthog-source.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
52 changes: 52 additions & 0 deletions frontend/test/posthog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"), "/");
Expand Down Expand Up @@ -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,
},
}),
{
Expand All @@ -59,6 +73,7 @@ test("pageview events keep only the sanitized pathname", () => {
surface: "marketing",
path: "/",
$current_url: "https://codeswhat.com/",
...COOKIELESS_HASH_PROPERTIES,
},
},
);
Expand All @@ -76,6 +91,7 @@ test("CTA events are limited to the initial GitHub placements", () => {
path: "/",
cta_id: "github_org",
placement: "hero",
...COOKIELESS_HASH_PROPERTIES,
},
}),
{
Expand All @@ -90,6 +106,7 @@ test("CTA events are limited to the initial GitHub placements", () => {
path: "/",
cta_id: "github_org",
placement: "hero",
...COOKIELESS_HASH_PROPERTIES,
},
},
);
Expand All @@ -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,
},
}),
{
Expand All @@ -129,6 +147,7 @@ test("web vitals events keep only metric data", () => {
surface: "marketing",
path: "/",
$web_vitals_LCP_value: 123.4,
...COOKIELESS_HASH_PROPERTIES,
},
},
);
Expand Down Expand Up @@ -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" },
Expand All @@ -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}`,
);
}
});
Loading