From da25024720cda8514d7958e1f2bd4ab4040d36ce Mon Sep 17 00:00:00 2001 From: Scott Benson <80784472+scttbnsn@users.noreply.github.com> Date: Sat, 15 Aug 2026 18:46:40 -0400 Subject: [PATCH 1/3] fix(analytics): promote cookieless ingestion fix to production (#53) 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. Co-authored-by: biggest-littlest --- 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}`, + ); + } +}); From 70a29da509c26431e8b998046950308eb00d14ba Mon Sep 17 00:00:00 2001 From: scttbnsn <80784472+scttbnsn@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:19:35 -0400 Subject: [PATCH 2/3] chore(config): drop the stale Cursor rules folder --- .cursor/rules/codeswhat-project.mdc | 71 ----------------------------- 1 file changed, 71 deletions(-) delete mode 100644 .cursor/rules/codeswhat-project.mdc diff --git a/.cursor/rules/codeswhat-project.mdc b/.cursor/rules/codeswhat-project.mdc deleted file mode 100644 index 5f069e2..0000000 --- a/.cursor/rules/codeswhat-project.mdc +++ /dev/null @@ -1,71 +0,0 @@ ---- -description: CodesWhat - Software consultancy website development rules -alwaysApply: true ---- - -# CodesWhat Project Rules - -## Project Context -Building CodesWhat (codeswhat-website) - a modern software consultancy landing page. -- Next.js 15.4.0 App Router, TypeScript strict, Tailwind CSS v4 -- Vercel deployment with `frontend/` as root directory -- EmailOctopus for subscriptions, Sonner for toasts - -## Development Workflow -- Use `./start.sh` for dev server (auto-restarts, port management) -- Run `npm run check:all` before committing -- Frontend directory is the Vercel root - -## Git Commit Standards -**MANDATORY**: All commits must include emojis. - -Format: ` : ` - -Common types: -- ๐Ÿ› fix - ๐ŸŽจ style - โœจ feat - ๐Ÿ”ง config - ๐Ÿ“ docs -- ๐Ÿ”„ refactor - ๐Ÿ“ฆ deps - ๐Ÿงช test - ๐Ÿš€ deploy - ๐Ÿ—‘๏ธ remove - -Multi-change commits use emoji bullets: -``` -๐ŸŽจ style: Improve dark mode visibility - -- ๐Ÿ› fix: Toast notification positioning -- ๐ŸŽจ style: Updated color contrast -- ๐Ÿงน cleanup: Removed unused styles -``` - -## Component Patterns -- Server Components by default -- Client Components only for interactivity -- Use shadcn/ui components: `npx shadcn@canary add [component]` - -## Styling Rules -- Tailwind classes only, no inline styles -- Dark mode: use `dark:` prefix -- Toast backgrounds must be solid (no transparency) -- Background pattern uses `fixed` positioning - -## API Implementation -```typescript -// Rate limiting example -const limiter = rateLimit({ max: 5, windowMs: 60000 }) - -// Always validate and sanitize -const email = formData.get('email')?.toString().toLowerCase().trim() -``` - -## Environment Variables -Public: `NEXT_PUBLIC_SITE_URL`, `NEXT_PUBLIC_SITE_NAME` -Private: `EMAILOCTOPUS_API_KEY`, `EMAILOCTOPUS_LIST_ID` - -## Key Features -- Email subscription: 5 req/min rate limit -- Toast notifications: bottom-right, 5s duration, hover to pause -- Dark mode: logo inverts colors -- SEO: robots.ts, sitemap.ts, JSON-LD on homepage - -## Pre-deployment -1. `npm run check:all` -2. Test email subscription -3. Verify dark mode -4. Check mobile responsive From e2f2576bdf47729a55e08eb6f41df6f11f7027c2 Mon Sep 17 00:00:00 2001 From: scttbnsn <80784472+scttbnsn@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:20:03 -0400 Subject: [PATCH 3/3] docs(config): drop dangling .cursorrules references --- .coderabbit.yaml | 1 - docs/README.md | 1 - 2 files changed, 2 deletions(-) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 126537c..7f7397f 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -67,7 +67,6 @@ knowledge_base: enabled: true file_patterns: - "**/CLAUDE.md" - - "**/.cursorrules" # Web search for additional context (library docs, etc.) web_search: diff --git a/docs/README.md b/docs/README.md index 316fa13..f32f375 100644 --- a/docs/README.md +++ b/docs/README.md @@ -16,5 +16,4 @@ npm run dev # Start development server ``` ### Other Documentation -- **Cursor Rules**: See `.cursorrules` in the project root - **Future Plans**: See `FUTURE_SECTIONS.md` in the project root \ No newline at end of file