From 71ac4e6a1b57c92fd846af7e88bf8d2834533418 Mon Sep 17 00:00:00 2001 From: scttbnsn <80784472+scttbnsn@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:43:17 -0400 Subject: [PATCH 1/3] fix(analytics): set $pathname on web vitals from the sanitized path --- frontend/lib/posthog-privacy.ts | 8 +++++++- frontend/test/posthog.test.ts | 24 ++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/frontend/lib/posthog-privacy.ts b/frontend/lib/posthog-privacy.ts index c2f8040..e8e9490 100644 --- a/frontend/lib/posthog-privacy.ts +++ b/frontend/lib/posthog-privacy.ts @@ -187,7 +187,13 @@ export function sanitizeEvent(input: unknown): SanitizedEvent | null { } } return Object.keys(vitalProperties).length > 0 - ? createSanitizedEvent(eventInput, event, { ...common, ...vitalProperties }) + ? createSanitizedEvent(eventInput, event, { + ...common, + // Same already-sanitized path the pageview branch uses, so Web + // analytics reports that key off $pathname resolve vitals too. + $pathname: common.path, + ...vitalProperties, + }) : null; } diff --git a/frontend/test/posthog.test.ts b/frontend/test/posthog.test.ts index 4c4d7a8..fb7b2b7 100644 --- a/frontend/test/posthog.test.ts +++ b/frontend/test/posthog.test.ts @@ -289,6 +289,7 @@ test("web vitals events keep only metric data", () => { site: "codeswhat", surface: "marketing", path: "/", + $pathname: "/", $web_vitals_LCP_value: 123.4, ...COOKIELESS_HASH_PROPERTIES, }, @@ -387,3 +388,26 @@ test("sanitizeEvent requires and forwards the cookieless server-hash fields", () ); } }); + +test("web vitals carry a $pathname taken from the sanitized path", () => { + const base = { + token: "phc_public-token_123", + $cookieless_mode: true, + $process_person_profile: false, + ...COOKIELESS_HASH_PROPERTIES, + }; + const other = sanitizeEvent({ + event: "$web_vitals", + properties: { ...base, path: "/private/page?secret=1", $web_vitals_LCP_value: 120 }, + }); + assert.ok(other); + assert.equal(other.properties.path, "/_other"); + assert.equal(other.properties.$pathname, "/_other"); + + const home = sanitizeEvent({ + event: "$web_vitals", + properties: { ...base, path: "/", $pathname: "/ignored", $web_vitals_CLS_value: 0.1 }, + }); + assert.ok(home); + assert.equal(home.properties.$pathname, "/"); +}); From 8dd4281d5871413bc1e6a5014264f7657e3f95d6 Mon Sep 17 00:00:00 2001 From: scttbnsn <80784472+scttbnsn@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:43:44 -0400 Subject: [PATCH 2/3] feat(analytics): add project CTA, newsletter and client error events --- frontend/components/EmailSignupForm.tsx | 17 +- frontend/components/site/TrackedCtaLink.tsx | 13 +- .../components/site/sections/Projects.tsx | 7 +- frontend/instrumentation-client.ts | 27 ++- frontend/lib/posthog-privacy.ts | 74 ++++++- frontend/test/posthog-source.test.mjs | 31 +++ frontend/test/posthog.test.ts | 181 +++++++++++++++++- 7 files changed, 338 insertions(+), 12 deletions(-) diff --git a/frontend/components/EmailSignupForm.tsx b/frontend/components/EmailSignupForm.tsx index 19a7896..172d781 100644 --- a/frontend/components/EmailSignupForm.tsx +++ b/frontend/components/EmailSignupForm.tsx @@ -1,10 +1,19 @@ "use client"; import { Bell, Loader2 } from "lucide-react"; +import posthog from "posthog-js"; import { useState } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; +// Maps the /api/subscribe status to a fixed error_class. Never the email or the +// response text; the sanitizer drops anything outside this list. +function classifyNewsletterError(status: number) { + if (status === 429) return "rate_limited"; + if (status === 400) return "invalid_email"; + return "unavailable"; +} + export function EmailSignupForm() { const [email, setEmail] = useState(""); const [honeypot, setHoneypot] = useState(""); // Bot trap @@ -52,9 +61,10 @@ export function EmailSignupForm() { body: JSON.stringify({ email: trimmedEmail }), }); - const data = await response.json(); + const data = await response.json().catch(() => ({})); if (response.ok) { + posthog.capture("newsletter submitted", { result: "success" }); setStatus("success"); toast.success("What's next? Check your inbox to find out 👀", { icon: "🎯", @@ -67,6 +77,10 @@ export function EmailSignupForm() { setStatus("idle"); }, 10000); } else { + posthog.capture("newsletter submitted", { + result: "error", + error_class: classifyNewsletterError(response.status), + }); setStatus("error"); toast.error(data.error || "❌ Something went wrong", { duration: 5000, @@ -78,6 +92,7 @@ export function EmailSignupForm() { }, 5000); } } catch { + posthog.capture("newsletter submitted", { result: "error", error_class: "network" }); setStatus("error"); toast.error("🌐 Network error. Please check your connection and try again.", { duration: 5000, diff --git a/frontend/components/site/TrackedCtaLink.tsx b/frontend/components/site/TrackedCtaLink.tsx index d4ae1cd..e34c915 100644 --- a/frontend/components/site/TrackedCtaLink.tsx +++ b/frontend/components/site/TrackedCtaLink.tsx @@ -4,17 +4,24 @@ import posthog from "posthog-js"; import type { AnchorHTMLAttributes } from "react"; type TrackedCtaLinkProps = AnchorHTMLAttributes & { - placement: "header" | "hero" | "footer"; + placement: "header" | "hero" | "footer" | "projects"; + /** Must be in ALLOWED_CTA_IDS (lib/posthog-privacy.ts) or the sanitizer drops the event. */ + ctaId?: string; }; -export function TrackedCtaLink({ placement, onClick, ...props }: TrackedCtaLinkProps) { +export function TrackedCtaLink({ + placement, + ctaId = "github_org", + onClick, + ...props +}: TrackedCtaLinkProps) { return ( // biome-ignore lint/a11y/noStaticElementInteractions: this link records an allowlisted outbound CTA. { - posthog.capture("cta activated", { cta_id: "github_org", placement }); + posthog.capture("cta activated", { cta_id: ctaId, placement }); onClick?.(event); }} /> diff --git a/frontend/components/site/sections/Projects.tsx b/frontend/components/site/sections/Projects.tsx index 6df0de0..2acfcc2 100644 --- a/frontend/components/site/sections/Projects.tsx +++ b/frontend/components/site/sections/Projects.tsx @@ -1,4 +1,5 @@ import { ArrowUpRight } from "lucide-react"; +import { TrackedCtaLink } from "@/components/site/TrackedCtaLink"; import { PROJECTS } from "@/lib/site-config"; /** @@ -26,7 +27,9 @@ export function Projects() { diff --git a/frontend/instrumentation-client.ts b/frontend/instrumentation-client.ts index 6a8604f..d260604 100644 --- a/frontend/instrumentation-client.ts +++ b/frontend/instrumentation-client.ts @@ -1,5 +1,10 @@ import posthog from "posthog-js"; -import { getPostHogConfig, sanitizeEvent, sanitizeRoute } from "./lib/posthog-privacy"; +import { + classifyClientErrorName, + getPostHogConfig, + sanitizeEvent, + sanitizeRoute, +} from "./lib/posthog-privacy"; const posthogConfig = getPostHogConfig({ NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN: process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN, @@ -11,6 +16,21 @@ function capturePageview(url: string) { posthog.capture("$pageview", { path: sanitizeRoute(url) }); } +const MAX_CLIENT_ERRORS_PER_PAGE_LOAD = 5; +let clientErrorCount = 0; + +// Only a bounded kind, an allowlisted constructor name and the sanitized route +// leave the page. No message, stack, filename or URL is ever read. +function captureClientError(errorKind: "error" | "unhandled_rejection", cause: unknown) { + if (clientErrorCount >= MAX_CLIENT_ERRORS_PER_PAGE_LOAD) return; + clientErrorCount += 1; + posthog.capture("client error", { + error_kind: errorKind, + error_name: classifyClientErrorName(cause), + path: sanitizeRoute(window.location.pathname), + }); +} + if (typeof window !== "undefined" && posthogConfig) { posthog.init(posthogConfig.token, { api_host: posthogConfig.apiHost, @@ -56,6 +76,11 @@ if (typeof window !== "undefined" && posthogConfig) { before_send: (event) => sanitizeEvent(event) as typeof event | null, }); + window.addEventListener("error", (event) => captureClientError("error", event.error)); + window.addEventListener("unhandledrejection", (event) => + captureClientError("unhandled_rejection", event.reason), + ); + capturePageview(window.location.pathname); } diff --git a/frontend/lib/posthog-privacy.ts b/frontend/lib/posthog-privacy.ts index e8e9490..9b3d120 100644 --- a/frontend/lib/posthog-privacy.ts +++ b/frontend/lib/posthog-privacy.ts @@ -2,13 +2,38 @@ export const POSTHOG_API_HOST = "https://e.codeswhat.com"; export const POSTHOG_UI_HOST = "https://us.posthog.com"; export const PRODUCTION_ORIGIN = "https://codeswhat.com"; export const ALLOWED_ROUTES = ["/"] as const; -export const ALLOWED_CTA_IDS = ["github_org"] as const; +export const ALLOWED_CTA_IDS = [ + "github_org", + "project_sockguard", + "project_drydock", + "project_portwing", + "project_portkey-admin-mcp", +] as const; +export const NEWSLETTER_ERROR_CLASSES = [ + "invalid_email", + "rate_limited", + "unavailable", + "network", +] as const; +export const CLIENT_ERROR_KINDS = ["error", "unhandled_rejection"] as const; +export const CLIENT_ERROR_NAMES = [ + "TypeError", + "ReferenceError", + "SyntaxError", + "RangeError", + "ChunkLoadError", + "Error", + "other", +] as const; const OTHER_PATH = "/_other"; const PROJECT_TOKEN_PATTERN = /^phc_[A-Za-z0-9_-]+$/u; const allowedRoutes = new Set(ALLOWED_ROUTES); const allowedCtaIds = new Set(ALLOWED_CTA_IDS); -const allowedPlacements = new Set(["header", "hero", "footer"]); +const projectCtaPrefix = "project_"; +const allowedNewsletterErrorClasses = new Set(NEWSLETTER_ERROR_CLASSES); +const allowedClientErrorKinds = new Set(CLIENT_ERROR_KINDS); +const allowedClientErrorNames = new Set(CLIENT_ERROR_NAMES); const allowedVitalKeys = new Set([ "$web_vitals_CLS_value", "$web_vitals_FCP_value", @@ -51,7 +76,13 @@ type EventInput = { }; type SanitizedEvent = { - event: "$pageview" | "$pageleave" | "cta activated" | "$web_vitals"; + event: + | "$pageview" + | "$pageleave" + | "cta activated" + | "$web_vitals" + | "newsletter submitted" + | "client error"; properties: Record; timestamp?: Date; uuid?: string; @@ -81,6 +112,18 @@ function sanitizeReferringDomain(value: unknown): string | undefined { return value.toLowerCase(); } +export function classifyClientErrorName(value: unknown): string { + const name = value instanceof Error ? value.name : undefined; + return typeof name === "string" && allowedClientErrorNames.has(name) ? name : "other"; +} + +function isAllowedCtaPlacement(ctaId: string, placement: string): boolean { + if (ctaId === "github_org") { + return placement === "header" || placement === "hero" || placement === "footer"; + } + return ctaId.startsWith(projectCtaPrefix) && placement === "projects"; +} + function createCommonProperties(properties: Record) { const token = properties.token; // PostHog's cookieless server-hash ingestion step computes the anonymous @@ -173,7 +216,7 @@ export function sanitizeEvent(input: unknown): SanitizedEvent | null { if (event === "cta activated") { const ctaId = typeof values.cta_id === "string" ? values.cta_id : ""; const placement = typeof values.placement === "string" ? values.placement : ""; - return allowedCtaIds.has(ctaId) && allowedPlacements.has(placement) + return allowedCtaIds.has(ctaId) && isAllowedCtaPlacement(ctaId, placement) ? createSanitizedEvent(eventInput, event, { ...common, cta_id: ctaId, placement }) : null; } @@ -197,5 +240,28 @@ export function sanitizeEvent(input: unknown): SanitizedEvent | null { : null; } + if (event === "newsletter submitted") { + const result = values.result; + if (result === "success") { + return createSanitizedEvent(eventInput, event, { ...common, result }); + } + const errorClass = typeof values.error_class === "string" ? values.error_class : ""; + return result === "error" && allowedNewsletterErrorClasses.has(errorClass) + ? createSanitizedEvent(eventInput, event, { ...common, result, error_class: errorClass }) + : null; + } + + if (event === "client error") { + const errorKind = typeof values.error_kind === "string" ? values.error_kind : ""; + const errorName = typeof values.error_name === "string" ? values.error_name : ""; + return allowedClientErrorKinds.has(errorKind) && allowedClientErrorNames.has(errorName) + ? createSanitizedEvent(eventInput, event, { + ...common, + error_kind: errorKind, + error_name: errorName, + }) + : null; + } + return null; } diff --git a/frontend/test/posthog-source.test.mjs b/frontend/test/posthog-source.test.mjs index 3d9a8f7..d1346b9 100644 --- a/frontend/test/posthog-source.test.mjs +++ b/frontend/test/posthog-source.test.mjs @@ -35,6 +35,7 @@ test("privacy posture disables persistence, recording, autocapture, and automati "advanced_disable_flags: true", "disable_persistence: true", "save_referrer: true", + "capture_exceptions: false", "capture_performance:", ]) { assert.match(instrumentation, new RegExp(option.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&"))); @@ -82,3 +83,33 @@ test("the cookieless envelope keeps the fields PostHog's server hash requires", assert.match(privacy, /\$host/u); assert.match(privacy, /cookieless_missing_user_agent|cookieless server-hash/u); }); + +test("every project card has an allowlisted project_ CTA id", async () => { + const config = await read("lib/site-config.ts"); + const privacy = await read("lib/posthog-privacy.ts"); + const projects = await read("components/site/sections/Projects.tsx"); + const slugs = [...config.matchAll(/^\s+slug: "([^"]+)",$/gmu)].map((match) => match[1]); + assert.ok(slugs.length > 0); + for (const slug of slugs) { + assert.ok(privacy.includes(`"project_${slug}"`), `project_${slug} is not in ALLOWED_CTA_IDS`); + } + assert.match(projects, /]*ctaId=\{`project_\$\{project\.slug\}`\}/u); + assert.match(projects, /placement="projects"/u); +}); + +test("client error reporting sends no message, stack, filename or URL", async () => { + const instrumentation = await read("instrumentation-client.ts"); + assert.match(instrumentation, /addEventListener\("error"/u); + assert.match(instrumentation, /addEventListener\("unhandledrejection"/u); + const call = instrumentation.match(/posthog\.capture\("client error", \{[^}]*\}\)/u)?.[0] ?? ""; + assert.match(call, /error_kind/u); + assert.match(call, /error_name/u); + assert.doesNotMatch(call, /message|stack|filename|href|url/iu); +}); + +test("newsletter submissions report a fixed result and error class only", async () => { + const form = await read("components/EmailSignupForm.tsx"); + const calls = [...form.matchAll(/posthog\.capture\("newsletter submitted", \{[^}]*\}\)/gu)]; + assert.equal(calls.length, 3); + for (const [call] of calls) assert.doesNotMatch(call, /email|message|data\./iu); +}); diff --git a/frontend/test/posthog.test.ts b/frontend/test/posthog.test.ts index fb7b2b7..08a9f68 100644 --- a/frontend/test/posthog.test.ts +++ b/frontend/test/posthog.test.ts @@ -52,6 +52,7 @@ import { test } from "node:test"; import { ALLOWED_CTA_IDS, ALLOWED_ROUTES, + classifyClientErrorName, getPostHogConfig, sanitizeEvent, sanitizeRoute, @@ -223,7 +224,13 @@ test("$pathname never diverges from the allowlisted path", () => { }); test("CTA events are limited to the initial GitHub placements", () => { - assert.deepEqual(ALLOWED_CTA_IDS, ["github_org"]); + assert.deepEqual(ALLOWED_CTA_IDS, [ + "github_org", + "project_sockguard", + "project_drydock", + "project_portwing", + "project_portkey-admin-mcp", + ]); assert.deepEqual( sanitizeEvent({ event: "cta activated", @@ -389,6 +396,14 @@ test("sanitizeEvent requires and forwards the cookieless server-hash fields", () } }); +const ENVELOPE = { + token: "phc_public-token_123", + $cookieless_mode: true, + $process_person_profile: false, + path: "/", + ...COOKIELESS_HASH_PROPERTIES, +}; + test("web vitals carry a $pathname taken from the sanitized path", () => { const base = { token: "phc_public-token_123", @@ -411,3 +426,167 @@ test("web vitals carry a $pathname taken from the sanitized path", () => { assert.ok(home); assert.equal(home.properties.$pathname, "/"); }); + +test("a CTA event shaped like the browser sends it (no path property) survives the pinned pipeline", () => { + // posthog-js never sets `path` on a cta capture; the route has to come from + // $pathname / $current_url, which carry the real query string. + const client = new PostHog(); + client.config.before_send = sanitizeEvent; + for (const [ctaId, placement] of [ + ["github_org", "header"], + ["project_drydock", "projects"], + ]) { + const result = client._runBeforeSend({ + event: "cta activated", + properties: { + token: "phc_public-token_123", + $cookieless_mode: true, + $process_person_profile: false, + $current_url: "https://codeswhat.com/?utm_source=github#projects", + $pathname: "/", + cta_id: ctaId, + placement, + ...COOKIELESS_HASH_PROPERTIES, + }, + }) as { event: string; properties: Record } | null; + assert.ok(result, `${ctaId} was dropped`); + assert.equal(result.event, "cta activated"); + assert.equal(result.properties.cta_id, ctaId); + assert.equal(result.properties.placement, placement); + assert.equal(result.properties.path, "/"); + } +}); + +test("project CTAs need a project_ id with the projects placement", () => { + const accepted = ALLOWED_CTA_IDS.filter((id) => id.startsWith("project_")); + assert.equal(accepted.length, 4); + for (const ctaId of accepted) { + assert.ok( + sanitizeEvent({ + event: "cta activated", + properties: { ...ENVELOPE, cta_id: ctaId, placement: "projects" }, + }), + ); + for (const placement of ["header", "hero", "footer", "elsewhere", ""]) { + assert.equal( + sanitizeEvent({ + event: "cta activated", + properties: { ...ENVELOPE, cta_id: ctaId, placement }, + }), + null, + ); + } + } + assert.equal( + sanitizeEvent({ + event: "cta activated", + properties: { ...ENVELOPE, cta_id: "github_org", placement: "projects" }, + }), + null, + ); + for (const ctaId of ["project_unknown", "project_", "project_drydock?x=1", 7, undefined]) { + assert.equal( + sanitizeEvent({ + event: "cta activated", + properties: { ...ENVELOPE, cta_id: ctaId, placement: "projects" }, + }), + null, + ); + } +}); + +test("newsletter submitted keeps only a result and a fixed error class", () => { + const success = sanitizeEvent({ + event: "newsletter submitted", + properties: { ...ENVELOPE, result: "success", email: "me@example.com", error_class: "network" }, + }); + assert.ok(success); + assert.equal(success.properties.result, "success"); + assert.equal("error_class" in success.properties, false); + assert.equal("email" in success.properties, false); + + for (const errorClass of ["invalid_email", "rate_limited", "unavailable", "network"]) { + const result = sanitizeEvent({ + event: "newsletter submitted", + properties: { + ...ENVELOPE, + result: "error", + error_class: errorClass, + email: "me@example.com", + }, + }); + assert.ok(result, errorClass); + assert.equal(result.properties.error_class, errorClass); + assert.equal("email" in result.properties, false); + } + + for (const bad of [ + { result: "error" }, + { result: "error", error_class: "me@example.com" }, + { result: "error", error_class: "Failed to subscribe" }, + { result: "pending" }, + { result: "SUCCESS" }, + {}, + ]) { + assert.equal( + sanitizeEvent({ event: "newsletter submitted", properties: { ...ENVELOPE, ...bad } }), + null, + JSON.stringify(bad), + ); + } +}); + +test("client error keeps only a bounded kind, an allowlisted name, and the route", () => { + for (const kind of ["error", "unhandled_rejection"]) { + const result = sanitizeEvent({ + event: "client error", + properties: { + ...ENVELOPE, + error_kind: kind, + error_name: "TypeError", + message: "Cannot read properties of undefined", + stack: "at https://codeswhat.com/_next/x.js:1:1", + filename: "https://codeswhat.com/_next/x.js", + }, + }); + assert.ok(result, kind); + assert.equal(result.properties.error_kind, kind); + assert.equal(result.properties.error_name, "TypeError"); + assert.equal(result.properties.path, "/"); + for (const leaked of ["message", "stack", "filename"]) { + assert.equal(leaked in result.properties, false, leaked); + } + } + for (const bad of [ + { error_kind: "exception", error_name: "TypeError" }, + { error_kind: "error", error_name: "CustomSecretError" }, + { error_kind: "error", error_name: "Cannot read properties of undefined" }, + { error_kind: "error" }, + { error_name: "Error" }, + ]) { + assert.equal( + sanitizeEvent({ event: "client error", properties: { ...ENVELOPE, ...bad } }), + null, + JSON.stringify(bad), + ); + } +}); + +test("classifyClientErrorName only returns allowlisted constructor names", () => { + class ChunkLoadError extends Error { + name = "ChunkLoadError"; + } + assert.equal(classifyClientErrorName(new TypeError("x")), "TypeError"); + assert.equal(classifyClientErrorName(new ReferenceError("x")), "ReferenceError"); + assert.equal(classifyClientErrorName(new SyntaxError("x")), "SyntaxError"); + assert.equal(classifyClientErrorName(new RangeError("x")), "RangeError"); + assert.equal(classifyClientErrorName(new ChunkLoadError("x")), "ChunkLoadError"); + assert.equal(classifyClientErrorName(new Error("x")), "Error"); + assert.equal(classifyClientErrorName(new URIError("x")), "other"); + const custom = new Error("x"); + custom.name = "me@example.com"; + assert.equal(classifyClientErrorName(custom), "other"); + for (const value of [null, undefined, "boom", 42, { name: "TypeError" }]) { + assert.equal(classifyClientErrorName(value), "other"); + } +}); From 98e10329f6e2b1635ef1fb0600b7d80cf3654161 Mon Sep 17 00:00:00 2001 From: scttbnsn <80784472+scttbnsn@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:43:54 -0400 Subject: [PATCH 3/3] feat(analytics): record campaign labels through a bounded UTM vocabulary --- ROADMAP.md | 29 +++++++--- frontend/instrumentation-client.ts | 6 +- frontend/lib/posthog-privacy.ts | 34 +++++++++++ frontend/test/posthog-source.test.mjs | 2 + frontend/test/posthog.test.ts | 83 +++++++++++++++++++++++++++ 5 files changed, 145 insertions(+), 9 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a315e29..5f36304 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -60,15 +60,28 @@ ships with the next promotion — the items below are what's left after that. (portwing's landed), so a Playwright canary's silence proves nothing either way. Verify with a real browser or by reading the deployed chunk for the property names. -- [ ] Acquisition data: decided org-wide in `CodesWhat/ops` +- [x] Acquisition data: decided org-wide in `CodesWhat/ops` `standards/analytics.md` ("Acquisition data and consent", as of - `c161ebc`) — that file is the authority. Shape: `save_campaign_params: - true` with `gclid`/`fbclid`/`msclkid` excluded; `save_referrer: true` - with the sanitizer forwarding `$referring_domain` only (drop it unless - it's a bare hostname, never copy `$referrer`); geo stays off (cookieless - strips the IP upstream, PostHog #48660). No banner needed. Implement - AFTER the `$pathname`/`$pageleave` promotion is verified in production, - so the two changes are separately attributable. + `c161ebc`) — that file is the authority. Landed: `save_campaign_params: + true` with `mask_personal_data_properties: true` (masks + `gclid`/`fbclid`/`msclkid` and the rest of the SDK's per-click ids); + `save_referrer: true` with the sanitizer forwarding `$referring_domain` + only (bare hostname, never `$referrer`). The sanitizer forwards only + `utm_source` (github, x, bluesky, linkedin, reddit, hackernews, + newsletter, producthunt), `utm_medium` (social, email, referral, + community) and `utm_campaign` (`/^[a-z0-9-]{1,40}$/`); everything else is + dropped. The vocabulary is pending Scott's approval. Geo stays off + (cookieless strips the IP upstream, PostHog #48660). No banner needed. +- [x] `$web_vitals` carries `$pathname` from the sanitized path +- [x] `cta activated` for the project cards: `project_` ids, placement + `projects`. `cta activated` had zero rows in 120 days on codeswhat.com; + reproduced in a browser and the capture, sanitizer and send path all work, + so it was low traffic (76 pageviews) rather than a bug. Check it fires after + deploy. +- [x] `newsletter submitted` (`result`, plus `error_class` from + invalid_email / rate_limited / unavailable / network on error) +- [x] `client error` (`error_kind`, allowlisted `error_name`, sanitized path; + max 5 per page load; `capture_exceptions` stays off) ## Telemetry and badge audit (2026-08-14) diff --git a/frontend/instrumentation-client.ts b/frontend/instrumentation-client.ts index d260604..e28cd7b 100644 --- a/frontend/instrumentation-client.ts +++ b/frontend/instrumentation-client.ts @@ -62,7 +62,11 @@ if (typeof window !== "undefined" && posthogConfig) { disable_persistence: true, respect_dnt: true, save_referrer: true, - save_campaign_params: false, + // Campaign labels only: the sanitizer forwards utm_source/medium/campaign + // through a bounded vocabulary, and masking drops the per-click ids + // (gclid, fbclid, msclkid and the rest of PERSONAL_DATA_CAMPAIGN_PARAMS). + save_campaign_params: true, + mask_personal_data_properties: true, disable_capture_url_hashes: true, disable_scroll_properties: true, mask_all_element_attributes: true, diff --git a/frontend/lib/posthog-privacy.ts b/frontend/lib/posthog-privacy.ts index 9b3d120..3949f39 100644 --- a/frontend/lib/posthog-privacy.ts +++ b/frontend/lib/posthog-privacy.ts @@ -25,6 +25,19 @@ export const CLIENT_ERROR_NAMES = [ "Error", "other", ] as const; +// Incoming UTM values are untrusted, so only this bounded vocabulary (plus a +// strict slug shape for utm_campaign) is forwarded. Everything else is dropped. +export const UTM_SOURCES = [ + "github", + "x", + "bluesky", + "linkedin", + "reddit", + "hackernews", + "newsletter", + "producthunt", +] as const; +export const UTM_MEDIUMS = ["social", "email", "referral", "community"] as const; const OTHER_PATH = "/_other"; const PROJECT_TOKEN_PATTERN = /^phc_[A-Za-z0-9_-]+$/u; @@ -34,6 +47,9 @@ const projectCtaPrefix = "project_"; const allowedNewsletterErrorClasses = new Set(NEWSLETTER_ERROR_CLASSES); const allowedClientErrorKinds = new Set(CLIENT_ERROR_KINDS); const allowedClientErrorNames = new Set(CLIENT_ERROR_NAMES); +const allowedUtmSources = new Set(UTM_SOURCES); +const allowedUtmMediums = new Set(UTM_MEDIUMS); +const UTM_CAMPAIGN_PATTERN = /^[a-z0-9-]{1,40}$/u; const allowedVitalKeys = new Set([ "$web_vitals_CLS_value", "$web_vitals_FCP_value", @@ -112,6 +128,18 @@ function sanitizeReferringDomain(value: unknown): string | undefined { return value.toLowerCase(); } +function sanitizeUtmValue(value: unknown, allowed: Set): string | undefined { + if (typeof value !== "string") return undefined; + const normalized = value.toLowerCase(); + return allowed.has(normalized) ? normalized : undefined; +} + +function sanitizeUtmCampaign(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const normalized = value.toLowerCase(); + return UTM_CAMPAIGN_PATTERN.test(normalized) ? normalized : undefined; +} + export function classifyClientErrorName(value: unknown): string { const name = value instanceof Error ? value.name : undefined; return typeof name === "string" && allowedClientErrorNames.has(name) ? name : "other"; @@ -171,6 +199,12 @@ function createCommonProperties(properties: Record) { } const referringDomain = sanitizeReferringDomain(properties.$referring_domain); if (referringDomain !== undefined) common.$referring_domain = referringDomain; + const utmSource = sanitizeUtmValue(properties.utm_source, allowedUtmSources); + if (utmSource !== undefined) common.utm_source = utmSource; + const utmMedium = sanitizeUtmValue(properties.utm_medium, allowedUtmMediums); + if (utmMedium !== undefined) common.utm_medium = utmMedium; + const utmCampaign = sanitizeUtmCampaign(properties.utm_campaign); + if (utmCampaign !== undefined) common.utm_campaign = utmCampaign; return common; } diff --git a/frontend/test/posthog-source.test.mjs b/frontend/test/posthog-source.test.mjs index d1346b9..97b1eb4 100644 --- a/frontend/test/posthog-source.test.mjs +++ b/frontend/test/posthog-source.test.mjs @@ -35,6 +35,8 @@ test("privacy posture disables persistence, recording, autocapture, and automati "advanced_disable_flags: true", "disable_persistence: true", "save_referrer: true", + "save_campaign_params: true", + "mask_personal_data_properties: true", "capture_exceptions: false", "capture_performance:", ]) { diff --git a/frontend/test/posthog.test.ts b/frontend/test/posthog.test.ts index 08a9f68..896e0df 100644 --- a/frontend/test/posthog.test.ts +++ b/frontend/test/posthog.test.ts @@ -590,3 +590,86 @@ test("classifyClientErrorName only returns allowlisted constructor names", () => assert.equal(classifyClientErrorName(value), "other"); } }); + +test("UTM labels pass only through the bounded vocabulary", () => { + const forward = (utm: Record, event = "$pageview") => { + const result = sanitizeEvent({ + event, + properties: { ...ENVELOPE, $web_vitals_LCP_value: 1, ...utm }, + }); + assert.ok(result); + return result.properties; + }; + + for (const event of ["$pageview", "$pageleave", "$web_vitals"]) { + const props = forward( + { utm_source: "GitHub", utm_medium: "social", utm_campaign: "launch-2026" }, + event, + ); + assert.equal(props.utm_source, "github"); + assert.equal(props.utm_medium, "social"); + assert.equal(props.utm_campaign, "launch-2026"); + } + for (const source of [ + "github", + "x", + "bluesky", + "linkedin", + "reddit", + "hackernews", + "newsletter", + "producthunt", + ]) { + assert.equal(forward({ utm_source: source }).utm_source, source); + } + for (const medium of ["social", "email", "referral", "community"]) { + assert.equal(forward({ utm_medium: medium }).utm_medium, medium); + } + + for (const bad of [ + "me@example.com", + "user-12345", + "github.com", + "twitter", + "", + "", + 42, + null, + ]) { + assert.equal("utm_source" in forward({ utm_source: bad }), false, String(bad)); + assert.equal("utm_medium" in forward({ utm_medium: bad }), false, String(bad)); + } + for (const bad of [ + "me@example.com", + "Has Space", + "a".repeat(41), + "under_score", + "", + 7, + "", + ]) { + assert.equal("utm_campaign" in forward({ utm_campaign: bad }), false, String(bad)); + } + assert.equal(forward({ utm_campaign: "a".repeat(40) }).utm_campaign, "a".repeat(40)); + + const props = forward({ + utm_content: "x", + utm_term: "y", + gclid: "abc", + fbclid: "abc", + msclkid: "abc", + gad_source: "1", + mc_cid: "abc", + }); + for (const dropped of [ + "utm_content", + "utm_term", + "gclid", + "fbclid", + "msclkid", + "gad_source", + "mc_cid", + ]) { + assert.equal(dropped in props, false, dropped); + } +});