From 45cdfedc66c6601323552df9ed2e26384f9ec271 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 16 May 2026 07:05:48 -0700 Subject: [PATCH 01/16] docs(gtm): spec for analytics-foundation-1d (website reconciliation) Routes every first-party event through per-app /api/ingest proxies and consolidates the analytics capture-guard helpers into @ngaf/telemetry so ad-blockers stop dropping marketing/docs/cockpit traffic and there's one source of truth across apps/website + apps/cockpit. Co-Authored-By: Claude Opus 4.7 --- ...dation-1d-website-reconciliation-design.md | 250 ++++++++++++++++++ 1 file changed, 250 insertions(+) create mode 100644 docs/superpowers/specs/gtm/2026-05-16-analytics-foundation-1d-website-reconciliation-design.md diff --git a/docs/superpowers/specs/gtm/2026-05-16-analytics-foundation-1d-website-reconciliation-design.md b/docs/superpowers/specs/gtm/2026-05-16-analytics-foundation-1d-website-reconciliation-design.md new file mode 100644 index 000000000..6f760b16d --- /dev/null +++ b/docs/superpowers/specs/gtm/2026-05-16-analytics-foundation-1d-website-reconciliation-design.md @@ -0,0 +1,250 @@ +--- +workstream: analytics-foundation-1d-website-reconciliation +status: approved +owner: brian +phase: 0 +spec: docs/superpowers/specs/gtm/2026-05-16-analytics-foundation-1d-website-reconciliation-design.md +plan: docs/superpowers/plans/gtm/2026-05-16-analytics-foundation-1d-website-reconciliation.md +parent: docs/superpowers/specs/gtm/2026-05-13-gtm-meta-design.md +--- + +# Analytics Foundation 1D — Website Reconciliation (Design) + +> Spec 1D of the Cacheplane GTM motion. Routes every first-party event through a per-app proxy so ad-blockers can't drop telemetry, and deduplicates the analytics guard logic shared by `apps/website` and `apps/cockpit`. + +## 1. Goal + +Two outcomes: + +1. **First-party ingest end-to-end.** Every `marketing:*`, `docs:*`, `cockpit:*`, and `ngaf:*` event reaches PostHog via a same-origin Next.js route on a `*.cacheplane.ai` subdomain. The browser sees only `cacheplane.ai/api/ingest` or `cockpit.cacheplane.ai/api/ingest` — never `*.posthog.com` — so network-level ad-blockers stop dropping our telemetry. +2. **One source of truth for the capture guard.** `shouldCaptureAnalytics`, `isLocalhost`, and the shared helpers move into `@ngaf/telemetry`. The duplicate copies in `apps/website` and `apps/cockpit` get deleted. + +## 2. Context + +- Parent: `docs/superpowers/specs/gtm/2026-05-13-gtm-meta-design.md` §7 names 1D's deliverables as "audit complete; `marketing:lead_qualified` server enrichment ships; `/api/ingest` proxy live." +- The original meta-spec text scoped two additional items into 1D — the audit/drift guard and `marketing:lead_qualified` server enrichment. Both have been deliberately split out of 1D and will land in a separate follow-up spec. 1D's scope is therefore the proxy work plus the consolidation cleanup. +- The website's `/api/ingest` proxy already exists (Spec 1B shipped it) but accepts only events whose name starts with `ngaf:`. That tight scope was correct then — it was the consumer-app browser-silence path. With Spec 1C now emitting `cockpit:*` and the website still firing `marketing:*` + `docs:*` direct to `us.i.posthog.com`, the proxy needs to widen and a sibling proxy needs to stand up on the cockpit app. +- Spec 1C smoke (PR #357) confirmed end-to-end event capture works with direct ingest. 1D is the production-hardening step: route through first-party so ad-blockers don't silently corrupt the funnel. +- The cockpit iframe Angular apps are pure static builds — no server-side. They post cross-origin to the cockpit shell's proxy. CORS is therefore a first-class concern. + +## 3. Scope + +**In scope:** + +- New `apps/cockpit/src/app/api/ingest/route.ts` Next.js route. Accepts events whose name matches `^cockpit:`. Validates the `PUBLIC_INGEST_KEY` anti-abuse field. Forwards to PostHog Cloud via `posthog-node`. Returns proper CORS headers so the iframe Angular apps can POST cross-origin. +- Widen `apps/website/src/app/api/ingest/route.ts` allowlist from `^ngaf:` to `^(ngaf|marketing|docs):`. The existing posthog-node forwarding stays; only the prefix check changes. +- Configure `posthog-js` in both shells to use the same-origin proxy: + - `apps/website/instrumentation-client.ts` adds `api_host: '/api/ingest'`. + - `apps/cockpit/instrumentation-client.ts` and `apps/cockpit/src/components/analytics-bootstrap.tsx` add `api_host: '/api/ingest'`. +- Update `apps/cockpit/src/components/run-mode/run-mode.tsx` so the `cockpit_host` URL param defaults to the cockpit shell's `/api/ingest` (absolute URL, env-driven for prod, current origin for dev) instead of `https://us.i.posthog.com`. Iframes pick this up via the existing `cockpit_host` URL param channel and pass it to `posthog.init({ api_host })`. +- Move `shouldCaptureAnalytics`, `isLocalhost` into `libs/telemetry/src/browser/properties.ts`. Move `toSafeAnalyticsString`, `getEmailDomain`, `normalizePostHogHost`, `getSourcePage` into `libs/telemetry/src/shared/properties.ts` (already exists). Export from the respective `public-api.ts`. +- Both apps update imports to consume from `@ngaf/telemetry/browser` (or `@ngaf/telemetry/shared`). The duplicated `apps/website/src/lib/analytics/properties.ts` and `apps/cockpit/src/lib/analytics/properties.ts` files are deleted in the same PR (no transitional shim — all consumers update at once). The remaining app-specific surfaces in `apps//src/lib/analytics/` (`client.ts`, `events.ts`, `distinct-id.ts`, etc.) are unchanged. +- New tests: + - `apps/cockpit/src/app/api/ingest/route.spec.ts` covering accept/reject prefix matrix, key validation, CORS preflight (OPTIONS) handling, forwarding to posthog-node. + - Update `apps/website/src/app/api/ingest/route.ts` route tests for the widened prefix matrix. + - The moved `properties.spec.ts` tests follow the code into `libs/telemetry`. +- `.env.example` documents new env vars: `NEXT_PUBLIC_COCKPIT_INGEST_HOST` (the cockpit proxy's absolute URL for iframes to target — defaults to current origin in dev). Existing `NEXT_PUBLIC_COCKPIT_POSTHOG_HOST` remains for backward compatibility but is no longer set by default. + +**Out of scope:** + +- Audit/drift guard between taxonomy and code fire sites. Deferred to a follow-up spec. +- `marketing:lead_qualified` server enrichment and qualification rules. Deferred to a follow-up spec. +- Server-side event signing or per-tenant proxy keys. The shared `PUBLIC_INGEST_KEY` constant stays as a soft anti-abuse measure; defense against determined attackers is not a goal. +- Changes to the consumer-facing `@ngaf/telemetry` trust contract or its README. +- Dashboard or insight changes (no taxonomy churn in this spec). + +**Success criteria:** + +- DevTools network panel on `cacheplane.ai` and `cockpit.cacheplane.ai` shows no requests to `*.posthog.com` for `marketing:*`, `docs:*`, or `cockpit:*` events. All capture traffic goes to `*.cacheplane.ai/api/ingest`. +- DevTools network panel on `examples.cacheplane.ai` (cockpit iframes) shows cross-origin POSTs to `cockpit.cacheplane.ai/api/ingest`, returning 200 with proper `Access-Control-Allow-Origin`. +- `apps/website/src/lib/analytics/properties.ts` and `apps/cockpit/src/lib/analytics/properties.ts` are deleted (or shrunk to a thin re-export shim if a transitional period is needed). +- All existing event captures still land in PostHog with the same event names, properties, and distinct_id (cross-frame correlation unchanged). +- `npx nx run-many -t test -p website,cockpit,telemetry` is green. + +## 4. Architecture + +``` +Browser (cacheplane.ai) Browser (cockpit.cacheplane.ai) Browser (examples.cacheplane.ai, iframe) + │ posthog-js │ posthog-js │ posthog-js + │ api_host: '/api/ingest' │ api_host: '/api/ingest' │ api_host: + │ │ │ + ▼ ▼ ▼ (cross-origin POST) +/api/ingest (website) /api/ingest (cockpit) cockpit.cacheplane.ai/api/ingest + prefix allowlist: prefix allowlist: ▲ + ^(ngaf|marketing|docs): ^cockpit: │ + validates PUBLIC_INGEST_KEY validates PUBLIC_INGEST_KEY │ + posthog-node.capture() posthog-node.capture() │ + │ │ │ + └────────────────┬─────────────────────┘───────────────────────────────────┘ + │ + ▼ + us.i.posthog.com (server-side; never visible to ad-blockers) +``` + +## 5. Components + +### 5.1 `libs/telemetry/src/browser/properties.ts` (new) + +Exports the two functions both shells need: + +```typescript +export interface ShouldCaptureInput { + token: string | undefined; + captureLocal: boolean; + host: string | undefined; +} + +export function shouldCaptureAnalytics(input: ShouldCaptureInput): boolean { ... } +export function isLocalhost(host: string | undefined): boolean { ... } +``` + +Re-exported from `libs/telemetry/src/browser/public-api.ts` so both shells consume via `import { shouldCaptureAnalytics } from '@ngaf/telemetry/browser';`. Tests move from the apps into `libs/telemetry/src/browser/properties.spec.ts` (jsdom). No new tests required beyond the relocation — the existing 4 cockpit + 5 website tests cover the matrix. + +### 5.2 `libs/telemetry/src/shared/properties.ts` (extend) + +The shared subpath already exists. Move the cross-runtime helpers here: + +```typescript +export function toSafeAnalyticsString(value: unknown, maxLength = 200): string | undefined { ... } +export function getEmailDomain(email: unknown): string | null { ... } +export function getSourcePage(value: unknown): string { ... } +export function normalizePostHogHost(host: string | undefined): string { ... } +``` + +The website's server-side code (`apps/website/src/lib/analytics/server.ts`, `apps/website/src/app/api/leads/route.ts`, etc.) updates imports to `@ngaf/telemetry/shared`. + +### 5.3 `apps/cockpit/src/app/api/ingest/route.ts` (new) + +Mirrors the existing website route, with three differences: + +1. **Prefix allowlist:** `^cockpit:` only. Reject anything else with 400. +2. **CORS headers** on both POST responses and OPTIONS preflight: + - `Access-Control-Allow-Origin` matches request origin against an allowlist regex: `^https://([a-z-]+\.)?cacheplane\.ai$` (prod) plus `^http://localhost:\d+$` (dev). Reject other origins. + - `Access-Control-Allow-Methods: POST, OPTIONS` + - `Access-Control-Allow-Headers: Content-Type` + - `Access-Control-Max-Age: 86400` +3. **Per-event distinct_id passthrough:** the iframe's `cockpit_did` is in the payload's `distinctId` field. Forward as-is — no derivation server-side. + +The `PUBLIC_INGEST_KEY` constant in the cockpit route matches the website route's value (Spec 1B established the convention). + +### 5.4 `apps/website/src/app/api/ingest/route.ts` (modified) + +One change: the prefix check at line ~35 becomes: + +```typescript +if (!event || !/^(ngaf|marketing|docs):/.test(event)) return null; +``` + +(was: `!event?.startsWith('ngaf:')`) + +Everything else stays — same validation, same posthog-node forwarding, same response shape. + +### 5.5 PostHog client config + +**`apps/website/instrumentation-client.ts`:** + +```typescript +posthog.init(token!, { + api_host: '/api/ingest', // NEW — was normalizePostHogHost(env) + defaults: '2026-01-30', + capture_pageview: true, + person_profiles: 'always', +}); +``` + +**`apps/cockpit/instrumentation-client.ts` + `apps/cockpit/src/components/analytics-bootstrap.tsx`:** + +```typescript +posthog.init(token!, { + api_host: '/api/ingest', // NEW — was process.env.NEXT_PUBLIC_COCKPIT_POSTHOG_HOST ?? 'https://us.i.posthog.com' + persistence: 'memory', + bootstrap: { distinctID: getCockpitSessionId() }, + autocapture: false, + capture_pageview: false, + defaults: '2026-01-30', +}); +``` + +### 5.6 `apps/cockpit/src/components/run-mode/run-mode.tsx` + +`buildIframeSrc` currently sets `cockpit_host` from `NEXT_PUBLIC_COCKPIT_POSTHOG_HOST`. Update to use `NEXT_PUBLIC_COCKPIT_INGEST_HOST` (new env var), falling back to the current origin's `/api/ingest` for dev: + +```typescript +const ingestHost = + process.env.NEXT_PUBLIC_COCKPIT_INGEST_HOST + ?? `${window.location.origin}/api/ingest`; +if (ingestHost) url.searchParams.set('cockpit_host', ingestHost); +``` + +The Angular harness (`libs/cockpit-telemetry/src/lib/distinct-id.ts:readCockpitConfigFromIframe` and `cockpit-telemetry.service.ts`) already plumbs `posthogHost` to `posthog.init({ api_host })`. No library-side change needed. + +## 6. Data flow + +For a single `cockpit:chat_first_message` event fired from an iframe: + +1. User submits first chat message in `examples.cacheplane.ai/streaming` (iframe). +2. `CockpitTelemetryService` observes `CHAT_LIFECYCLE.firstMessageSent` flip and calls `posthog.capture('cockpit:chat_first_message', { capability: 'streaming' })`. +3. `posthog-js` POSTs to `https://cockpit.cacheplane.ai/api/ingest` (read from `cockpit_host` URL param at init). +4. Cockpit `/api/ingest` handler: + - Validates Origin header against allowlist regex (allows `https://examples.cacheplane.ai`). + - Validates `PUBLIC_INGEST_KEY` field in body. + - Validates event prefix `^cockpit:`. + - Calls `posthog-node` to forward `(distinctId, event, properties)` to `https://us.i.posthog.com/e/`. + - Returns `200 OK` with `Access-Control-Allow-Origin: https://examples.cacheplane.ai`. +5. Event lands in PostHog with `distinct_id: cockpit_` matching the parent shell's session. + +For a `marketing:cta_click` from the website: + +1. User clicks tracked CTA on `cacheplane.ai/pricing`. +2. `track('marketing:cta_click', {...})` invokes `posthog.capture(...)`. +3. `posthog-js` POSTs to `/api/ingest` (relative URL, same-origin). +4. Website `/api/ingest` validates prefix + key, forwards via posthog-node. +5. Event lands in PostHog. + +## 7. Error handling + +- **Proxy 4xx on bad input:** prefix mismatch, missing key, malformed body all return 400 with `{ error: '' }`. No event capture happens. +- **CORS denial:** unknown Origin gets a 403 with no `Access-Control-Allow-Origin` header. Browser blocks the response. +- **PostHog cloud down:** posthog-node has internal retry; we don't add additional retry. Return 200 to the client regardless — analytics is fire-and-forget and we don't block UX on PostHog availability. +- **Lambda timeout:** posthog-node's `flushAt: 1` + `flushInterval: 0` should keep the proxy hot for ~50-100ms. If it ever exceeds 10s the lambda will be killed; the event is lost. Same trade-off as direct ingestion. +- **Silent fail at the source:** the `client.ts` `track()` wrapper in each app already wraps `posthog.capture()` in `try/catch`. No change. + +## 8. Testing strategy + +- **Unit (jsdom):** properties tests move with the code into `libs/telemetry`. Same matrix as today. +- **Route handler (vitest):** for each proxy: + - Accept events matching the allowlist → forwarded to mock posthog-node, 200 returned. + - Reject events outside the allowlist → 400 returned, no forward. + - Reject missing/wrong `PUBLIC_INGEST_KEY` → 400 returned. + - Reject missing `distinctId` or `event` → 400 returned. + - Cockpit-only: OPTIONS preflight returns 204 with CORS headers; POST from allowed origin returns CORS-laden 200; POST from disallowed origin returns 403 without CORS headers. +- **Integration (manual, post-deploy):** load each cockpit + website page, open DevTools network, confirm no `*.posthog.com` requests appear and `*.cacheplane.ai/api/ingest` POSTs return 200. + +## 9. Risks + +- **The `/api/ingest` lambda becomes a single point of failure.** If it goes down, the funnel goes dark until it recovers. Mitigated by: (a) Vercel SLA, (b) posthog-node's silent-fail in the route handler so 200 always returns to the client, (c) source-side `try/catch`. +- **Extra Vercel lambda invocations.** Each capture is now a function call. Free-tier budget is ~100k/month. Conservative estimate for cockpit + website combined: <50k/month. If we ever exceed, we re-evaluate proxy strategy (e.g., edge runtime). +- **Cross-origin CORS regressions.** Adding/changing subdomains breaks the iframe path if the regex isn't updated. Mitigated by tests that exercise the matrix and the explicit allowlist regex (not `*`). +- **Existing PostHog-direct dashboards keep working** because the events land identically in PostHog regardless of routing. No taxonomy or insight change needed. + +## 10. Phases + +1. **Phase 0 — Consolidate `properties.ts`.** Move helpers into `libs/telemetry`. Update both apps to import from there. Delete duplicate sources. Tests follow. (~6 commits.) +2. **Phase 1 — Widen website proxy + posthog-js config.** Update the prefix regex in `apps/website/src/app/api/ingest/route.ts`. Configure `posthog-js` with `api_host: '/api/ingest'`. Update route tests. (~3 commits.) +3. **Phase 2 — New cockpit proxy + posthog-js config.** Create `apps/cockpit/src/app/api/ingest/route.ts` with CORS. Configure `posthog-js`. Update `run-mode.tsx` to point iframes at the proxy. New tests. (~5 commits.) +4. **Phase 3 — `.env.example` + dev docs.** Document `NEXT_PUBLIC_COCKPIT_INGEST_HOST`. Update cockpit's smoke procedure to use the proxy. (~2 commits.) + +## 11. Deliverables + +- ☐ `libs/telemetry/src/browser/properties.ts` + spec +- ☐ `libs/telemetry/src/shared/properties.ts` extended + spec +- ☐ `apps/website/src/app/api/ingest/route.ts` widened +- ☐ `apps/website/instrumentation-client.ts` updated +- ☐ `apps/cockpit/src/app/api/ingest/route.ts` + spec (new) +- ☐ `apps/cockpit/instrumentation-client.ts` + `analytics-bootstrap.tsx` updated +- ☐ `apps/cockpit/src/components/run-mode/run-mode.tsx` env-driven `cockpit_host` +- ☐ `apps/website/src/lib/analytics/properties.ts` deleted +- ☐ `apps/cockpit/src/lib/analytics/properties.ts` deleted +- ☐ Server-side imports updated to consume from `@ngaf/telemetry/shared` (`apps/website/src/lib/analytics/server.ts`, `apps/website/src/app/api/leads/route.ts`, etc.) +- ☐ `.env.example` documents `NEXT_PUBLIC_COCKPIT_INGEST_HOST` +- ☐ All affected projects' tests green From 469f315d8a3078f82ddd98355079fa48f9bcab11 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 16 May 2026 07:15:09 -0700 Subject: [PATCH 02/16] docs(gtm): revise Spec 1D to use Next.js rewrites for posthog-js proxy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While drafting the implementation plan, discovered the original spec conflated two distinct contracts: - /api/ingest serves libs/telemetry/browser's custom-envelope path (Spec 1B), accepting only ngaf:* events posted by consumer apps. - apps/website + apps/cockpit use posthog-js directly, which sends PostHog's batched/gzipped format to /e/, /flags/, /static/array.js, etc. — not the custom envelope. Revised architecture uses Next.js rewrites (PostHog's officially documented Next.js proxy pattern) at /ingest/* to forward transparently to us.i.posthog.com. The existing /api/ingest route is unchanged; the two paths serve different contracts side-by-side. Co-Authored-By: Claude Opus 4.7 --- ...dation-1d-website-reconciliation-design.md | 190 +++++++++++------- 1 file changed, 113 insertions(+), 77 deletions(-) diff --git a/docs/superpowers/specs/gtm/2026-05-16-analytics-foundation-1d-website-reconciliation-design.md b/docs/superpowers/specs/gtm/2026-05-16-analytics-foundation-1d-website-reconciliation-design.md index 6f760b16d..904740916 100644 --- a/docs/superpowers/specs/gtm/2026-05-16-analytics-foundation-1d-website-reconciliation-design.md +++ b/docs/superpowers/specs/gtm/2026-05-16-analytics-foundation-1d-website-reconciliation-design.md @@ -23,26 +23,30 @@ Two outcomes: - Parent: `docs/superpowers/specs/gtm/2026-05-13-gtm-meta-design.md` §7 names 1D's deliverables as "audit complete; `marketing:lead_qualified` server enrichment ships; `/api/ingest` proxy live." - The original meta-spec text scoped two additional items into 1D — the audit/drift guard and `marketing:lead_qualified` server enrichment. Both have been deliberately split out of 1D and will land in a separate follow-up spec. 1D's scope is therefore the proxy work plus the consolidation cleanup. -- The website's `/api/ingest` proxy already exists (Spec 1B shipped it) but accepts only events whose name starts with `ngaf:`. That tight scope was correct then — it was the consumer-app browser-silence path. With Spec 1C now emitting `cockpit:*` and the website still firing `marketing:*` + `docs:*` direct to `us.i.posthog.com`, the proxy needs to widen and a sibling proxy needs to stand up on the cockpit app. +- The website's existing `/api/ingest` route (Spec 1B) is a **dedicated endpoint for `libs/telemetry/browser`** — consumer apps post a custom envelope (`{key, distinctId, event, properties}`) via raw `fetch()` and the route forwards via `posthog-node`. That path stays unchanged in 1D. +- `apps/website` and `apps/cockpit` use **`posthog-js`** directly, which posts PostHog's own batched/gzipped format to `/e/`, `/flags/`, `/static/array.js`, etc. — a different payload shape than the `libs/telemetry/browser` envelope. Routing posthog-js through the existing `/api/ingest` is not feasible without re-parsing PostHog's wire format. +- The correct ad-blocker-bypass pattern for posthog-js is **Next.js rewrites** that transparently forward `/ingest/*` to PostHog Cloud. This is PostHog's officially documented Next.js proxy pattern. It's a `next.config.js` change plus an `api_host` change in the posthog-js init call — no route handlers, no custom envelope, no allowlist filtering (PostHog Cloud rejects unknown event names server-side anyway). - Spec 1C smoke (PR #357) confirmed end-to-end event capture works with direct ingest. 1D is the production-hardening step: route through first-party so ad-blockers don't silently corrupt the funnel. -- The cockpit iframe Angular apps are pure static builds — no server-side. They post cross-origin to the cockpit shell's proxy. CORS is therefore a first-class concern. +- The cockpit iframe Angular apps are pure static builds (no server-side). They post cross-origin to the cockpit shell's `/ingest/*` rewrite endpoint. CORS headers are added in `next.config.js` via the `headers()` API. ## 3. Scope **In scope:** -- New `apps/cockpit/src/app/api/ingest/route.ts` Next.js route. Accepts events whose name matches `^cockpit:`. Validates the `PUBLIC_INGEST_KEY` anti-abuse field. Forwards to PostHog Cloud via `posthog-node`. Returns proper CORS headers so the iframe Angular apps can POST cross-origin. -- Widen `apps/website/src/app/api/ingest/route.ts` allowlist from `^ngaf:` to `^(ngaf|marketing|docs):`. The existing posthog-node forwarding stays; only the prefix check changes. -- Configure `posthog-js` in both shells to use the same-origin proxy: - - `apps/website/instrumentation-client.ts` adds `api_host: '/api/ingest'`. - - `apps/cockpit/instrumentation-client.ts` and `apps/cockpit/src/components/analytics-bootstrap.tsx` add `api_host: '/api/ingest'`. -- Update `apps/cockpit/src/components/run-mode/run-mode.tsx` so the `cockpit_host` URL param defaults to the cockpit shell's `/api/ingest` (absolute URL, env-driven for prod, current origin for dev) instead of `https://us.i.posthog.com`. Iframes pick this up via the existing `cockpit_host` URL param channel and pass it to `posthog.init({ api_host })`. +- Add `/ingest/*` rewrites to `apps/website/next.config.ts` forwarding to `us.i.posthog.com` and `us-assets.i.posthog.com` (for posthog-js's static asset fetch). +- Add `/ingest/*` rewrites to `apps/cockpit/next.config.ts` (same pattern) plus CORS headers on `/ingest/:path*` via the `headers()` config (allowlist `https://examples.cacheplane.ai` and `http://localhost:*`). +- Configure `posthog-js` in both shells to use the rewrite path: + - `apps/website/instrumentation-client.ts` adds `api_host: '/ingest'`. + - `apps/cockpit/instrumentation-client.ts` and `apps/cockpit/src/components/analytics-bootstrap.tsx` add `api_host: '/ingest'` and `ui_host: 'https://us.posthog.com'` so PostHog's session-replay/toolbar links still point at the real UI. +- Update `apps/cockpit/src/components/run-mode/run-mode.tsx` so the `cockpit_host` URL param defaults to the cockpit shell's `/ingest` (absolute URL, env-driven for prod, current origin for dev) instead of `https://us.i.posthog.com`. Iframes pick this up via the existing `cockpit_host` URL param channel and pass it to `posthog.init({ api_host })`. +- `apps/website/src/app/api/ingest/route.ts` is **unchanged**. It continues to serve `libs/telemetry/browser`'s custom envelope from consumer apps. The two proxy paths (`/api/ingest` for ngaf envelope; `/ingest/*` for posthog-js) live side-by-side, serving different contracts. - Move `shouldCaptureAnalytics`, `isLocalhost` into `libs/telemetry/src/browser/properties.ts`. Move `toSafeAnalyticsString`, `getEmailDomain`, `normalizePostHogHost`, `getSourcePage` into `libs/telemetry/src/shared/properties.ts` (already exists). Export from the respective `public-api.ts`. - Both apps update imports to consume from `@ngaf/telemetry/browser` (or `@ngaf/telemetry/shared`). The duplicated `apps/website/src/lib/analytics/properties.ts` and `apps/cockpit/src/lib/analytics/properties.ts` files are deleted in the same PR (no transitional shim — all consumers update at once). The remaining app-specific surfaces in `apps//src/lib/analytics/` (`client.ts`, `events.ts`, `distinct-id.ts`, etc.) are unchanged. - New tests: - - `apps/cockpit/src/app/api/ingest/route.spec.ts` covering accept/reject prefix matrix, key validation, CORS preflight (OPTIONS) handling, forwarding to posthog-node. - - Update `apps/website/src/app/api/ingest/route.ts` route tests for the widened prefix matrix. + - `apps/cockpit/next-config.spec.ts` (or `next.config.test.ts`) — vitest test that imports the config and verifies the rewrites + headers shape (paths, destinations, CORS allowlist). + - Same for `apps/website/next-config.spec.ts`. - The moved `properties.spec.ts` tests follow the code into `libs/telemetry`. + - Existing `apps/website/src/app/api/ingest/route.ts` tests are untouched. - `.env.example` documents new env vars: `NEXT_PUBLIC_COCKPIT_INGEST_HOST` (the cockpit proxy's absolute URL for iframes to target — defaults to current origin in dev). Existing `NEXT_PUBLIC_COCKPIT_POSTHOG_HOST` remains for backward compatibility but is no longer set by default. **Out of scope:** @@ -64,21 +68,29 @@ Two outcomes: ## 4. Architecture ``` -Browser (cacheplane.ai) Browser (cockpit.cacheplane.ai) Browser (examples.cacheplane.ai, iframe) - │ posthog-js │ posthog-js │ posthog-js - │ api_host: '/api/ingest' │ api_host: '/api/ingest' │ api_host: - │ │ │ - ▼ ▼ ▼ (cross-origin POST) -/api/ingest (website) /api/ingest (cockpit) cockpit.cacheplane.ai/api/ingest - prefix allowlist: prefix allowlist: ▲ - ^(ngaf|marketing|docs): ^cockpit: │ - validates PUBLIC_INGEST_KEY validates PUBLIC_INGEST_KEY │ - posthog-node.capture() posthog-node.capture() │ - │ │ │ - └────────────────┬─────────────────────┘───────────────────────────────────┘ - │ - ▼ - us.i.posthog.com (server-side; never visible to ad-blockers) +Browser (cacheplane.ai) Browser (cockpit.cacheplane.ai) Browser (examples.cacheplane.ai, iframe) + │ posthog-js │ posthog-js │ posthog-js + │ api_host: '/ingest' │ api_host: '/ingest' │ api_host: + │ │ │ + ▼ ▼ ▼ (cross-origin → CORS-allowed) +cacheplane.ai/ingest/:path* cockpit.cacheplane.ai/ingest/:path* cockpit.cacheplane.ai/ingest/:path* + next.config.ts rewrite next.config.ts rewrite (same as middle column) + │ │ ▲ + ▼ ▼ │ + us.i.posthog.com/:path* us.i.posthog.com/:path* │ + (and us-assets.i.posthog.com for /static/) │ + │ │ │ + └───────────────────┬───────────────────┘──────────────────────────────────┘ + │ + ▼ + PostHog Cloud (server-side; never visible to ad-blockers) + +Side channel (unchanged): +Consumer apps using libs/telemetry/browser + │ raw fetch POST + ▼ +cacheplane.ai/api/ingest → posthog-node.capture() → PostHog Cloud + (custom envelope path, ngaf:* only, Spec 1B) ``` ## 5. Components @@ -113,31 +125,54 @@ export function normalizePostHogHost(host: string | undefined): string { ... } The website's server-side code (`apps/website/src/lib/analytics/server.ts`, `apps/website/src/app/api/leads/route.ts`, etc.) updates imports to `@ngaf/telemetry/shared`. -### 5.3 `apps/cockpit/src/app/api/ingest/route.ts` (new) +### 5.3 `apps/cockpit/next.config.ts` (modified) -Mirrors the existing website route, with three differences: +Add `rewrites()` and `headers()` to the existing config: -1. **Prefix allowlist:** `^cockpit:` only. Reject anything else with 400. -2. **CORS headers** on both POST responses and OPTIONS preflight: - - `Access-Control-Allow-Origin` matches request origin against an allowlist regex: `^https://([a-z-]+\.)?cacheplane\.ai$` (prod) plus `^http://localhost:\d+$` (dev). Reject other origins. - - `Access-Control-Allow-Methods: POST, OPTIONS` - - `Access-Control-Allow-Headers: Content-Type` - - `Access-Control-Max-Age: 86400` -3. **Per-event distinct_id passthrough:** the iframe's `cockpit_did` is in the payload's `distinctId` field. Forward as-is — no derivation server-side. +```typescript +const nextConfig: WithNxOptions = { + nx: {}, + async rewrites() { + return [ + { source: '/ingest/static/:path*', destination: 'https://us-assets.i.posthog.com/static/:path*' }, + { source: '/ingest/:path*', destination: 'https://us.i.posthog.com/:path*' }, + ]; + }, + async headers() { + return [{ + source: '/ingest/:path*', + headers: [ + { key: 'Access-Control-Allow-Origin', value: process.env.NEXT_PUBLIC_COCKPIT_IFRAME_ORIGIN ?? '*' }, + { key: 'Access-Control-Allow-Methods', value: 'POST, OPTIONS' }, + { key: 'Access-Control-Allow-Headers', value: 'Content-Type' }, + { key: 'Access-Control-Max-Age', value: '86400' }, + ], + }]; + }, + skipTrailingSlashRedirect: true, +}; +``` -The `PUBLIC_INGEST_KEY` constant in the cockpit route matches the website route's value (Spec 1B established the convention). +For prod, `NEXT_PUBLIC_COCKPIT_IFRAME_ORIGIN=https://examples.cacheplane.ai`. For dev, the env var is unset and the wildcard `*` is acceptable for local smoke. (Next.js's `headers()` config doesn't support runtime-derived origin reflection — for stricter per-request origin matching we'd need a middleware, which we defer to a follow-up if multi-origin support becomes necessary.) -### 5.4 `apps/website/src/app/api/ingest/route.ts` (modified) +### 5.4 `apps/website/next.config.ts` (modified) -One change: the prefix check at line ~35 becomes: +Same rewrites pattern. No CORS — the website serves no cross-origin consumers of `/ingest`: ```typescript -if (!event || !/^(ngaf|marketing|docs):/.test(event)) return null; +const nextConfig: WithNxOptions = { + nx: {}, + async rewrites() { + return [ + { source: '/ingest/static/:path*', destination: 'https://us-assets.i.posthog.com/static/:path*' }, + { source: '/ingest/:path*', destination: 'https://us.i.posthog.com/:path*' }, + ]; + }, + skipTrailingSlashRedirect: true, +}; ``` -(was: `!event?.startsWith('ngaf:')`) - -Everything else stays — same validation, same posthog-node forwarding, same response shape. +The existing `/api/ingest` route handler is untouched. ### 5.5 PostHog client config @@ -145,7 +180,8 @@ Everything else stays — same validation, same posthog-node forwarding, same re ```typescript posthog.init(token!, { - api_host: '/api/ingest', // NEW — was normalizePostHogHost(env) + api_host: '/ingest', + ui_host: 'https://us.posthog.com', defaults: '2026-01-30', capture_pageview: true, person_profiles: 'always', @@ -156,7 +192,8 @@ posthog.init(token!, { ```typescript posthog.init(token!, { - api_host: '/api/ingest', // NEW — was process.env.NEXT_PUBLIC_COCKPIT_POSTHOG_HOST ?? 'https://us.i.posthog.com' + api_host: '/ingest', + ui_host: 'https://us.posthog.com', persistence: 'memory', bootstrap: { distinctID: getCockpitSessionId() }, autocapture: false, @@ -167,12 +204,12 @@ posthog.init(token!, { ### 5.6 `apps/cockpit/src/components/run-mode/run-mode.tsx` -`buildIframeSrc` currently sets `cockpit_host` from `NEXT_PUBLIC_COCKPIT_POSTHOG_HOST`. Update to use `NEXT_PUBLIC_COCKPIT_INGEST_HOST` (new env var), falling back to the current origin's `/api/ingest` for dev: +`buildIframeSrc` currently sets `cockpit_host` from `NEXT_PUBLIC_COCKPIT_POSTHOG_HOST`. Update to use `NEXT_PUBLIC_COCKPIT_INGEST_HOST` (new env var), falling back to the current origin's `/ingest` for dev: ```typescript const ingestHost = process.env.NEXT_PUBLIC_COCKPIT_INGEST_HOST - ?? `${window.location.origin}/api/ingest`; + ?? `${window.location.origin}/ingest`; if (ingestHost) url.searchParams.set('cockpit_host', ingestHost); ``` @@ -184,67 +221,66 @@ For a single `cockpit:chat_first_message` event fired from an iframe: 1. User submits first chat message in `examples.cacheplane.ai/streaming` (iframe). 2. `CockpitTelemetryService` observes `CHAT_LIFECYCLE.firstMessageSent` flip and calls `posthog.capture('cockpit:chat_first_message', { capability: 'streaming' })`. -3. `posthog-js` POSTs to `https://cockpit.cacheplane.ai/api/ingest` (read from `cockpit_host` URL param at init). -4. Cockpit `/api/ingest` handler: - - Validates Origin header against allowlist regex (allows `https://examples.cacheplane.ai`). - - Validates `PUBLIC_INGEST_KEY` field in body. - - Validates event prefix `^cockpit:`. - - Calls `posthog-node` to forward `(distinctId, event, properties)` to `https://us.i.posthog.com/e/`. - - Returns `200 OK` with `Access-Control-Allow-Origin: https://examples.cacheplane.ai`. +3. `posthog-js` POSTs to `${cockpit_host}/e/` — where `cockpit_host` is `https://cockpit.cacheplane.ai/ingest` (read from URL param at init). +4. Next.js rewrites the request server-side to `https://us.i.posthog.com/e/`, preserving the body, headers, and gzip compression. CORS headers are attached to the response from `headers()` config. 5. Event lands in PostHog with `distinct_id: cockpit_` matching the parent shell's session. For a `marketing:cta_click` from the website: 1. User clicks tracked CTA on `cacheplane.ai/pricing`. 2. `track('marketing:cta_click', {...})` invokes `posthog.capture(...)`. -3. `posthog-js` POSTs to `/api/ingest` (relative URL, same-origin). -4. Website `/api/ingest` validates prefix + key, forwards via posthog-node. +3. `posthog-js` POSTs to `/ingest/e/` (relative URL, same-origin). +4. Next.js rewrites to `https://us.i.posthog.com/e/`. 5. Event lands in PostHog. +For posthog-js's initial bootstrap (config + flags + array.js): + +1. `posthog-js` fetches `${api_host}/static/array.js` and `${api_host}/flags/?…`. +2. Next.js rewrites: `/ingest/static/:path*` → `us-assets.i.posthog.com/static/:path*`, `/ingest/:path*` → `us.i.posthog.com/:path*`. +3. Bootstrap completes; subsequent captures use the same rewrite chain. + ## 7. Error handling -- **Proxy 4xx on bad input:** prefix mismatch, missing key, malformed body all return 400 with `{ error: '' }`. No event capture happens. -- **CORS denial:** unknown Origin gets a 403 with no `Access-Control-Allow-Origin` header. Browser blocks the response. -- **PostHog cloud down:** posthog-node has internal retry; we don't add additional retry. Return 200 to the client regardless — analytics is fire-and-forget and we don't block UX on PostHog availability. -- **Lambda timeout:** posthog-node's `flushAt: 1` + `flushInterval: 0` should keep the proxy hot for ~50-100ms. If it ever exceeds 10s the lambda will be killed; the event is lost. Same trade-off as direct ingestion. +- **Rewrites are transparent.** Next.js forwards the request as-is and returns PostHog Cloud's response as-is. Any 4xx/5xx from PostHog (rate limiting, bad token, etc.) reaches the client unchanged. +- **PostHog cloud down:** posthog-js has its own retry/queue. The rewrite layer adds nothing. +- **CORS on cockpit's `/ingest`:** unknown origins still get the configured `Access-Control-Allow-Origin`. The current spec uses a single allowlisted origin (or `*` for dev); stricter per-request origin matching would require middleware, deferred unless multi-origin support becomes necessary. - **Silent fail at the source:** the `client.ts` `track()` wrapper in each app already wraps `posthog.capture()` in `try/catch`. No change. ## 8. Testing strategy - **Unit (jsdom):** properties tests move with the code into `libs/telemetry`. Same matrix as today. -- **Route handler (vitest):** for each proxy: - - Accept events matching the allowlist → forwarded to mock posthog-node, 200 returned. - - Reject events outside the allowlist → 400 returned, no forward. - - Reject missing/wrong `PUBLIC_INGEST_KEY` → 400 returned. - - Reject missing `distinctId` or `event` → 400 returned. - - Cockpit-only: OPTIONS preflight returns 204 with CORS headers; POST from allowed origin returns CORS-laden 200; POST from disallowed origin returns 403 without CORS headers. -- **Integration (manual, post-deploy):** load each cockpit + website page, open DevTools network, confirm no `*.posthog.com` requests appear and `*.cacheplane.ai/api/ingest` POSTs return 200. +- **Config (vitest):** for each `next.config.ts`: + - Import the config module. + - Assert `rewrites()` returns the two expected `{ source, destination }` pairs with `us.i.posthog.com` / `us-assets.i.posthog.com`. + - Cockpit-only: assert `headers()` returns the CORS shape with the four expected headers. +- **Integration (manual, post-deploy):** load each cockpit + website page, open DevTools network, confirm no `*.posthog.com` requests appear and `*.cacheplane.ai/ingest/*` requests return 200. ## 9. Risks -- **The `/api/ingest` lambda becomes a single point of failure.** If it goes down, the funnel goes dark until it recovers. Mitigated by: (a) Vercel SLA, (b) posthog-node's silent-fail in the route handler so 200 always returns to the client, (c) source-side `try/catch`. -- **Extra Vercel lambda invocations.** Each capture is now a function call. Free-tier budget is ~100k/month. Conservative estimate for cockpit + website combined: <50k/month. If we ever exceed, we re-evaluate proxy strategy (e.g., edge runtime). -- **Cross-origin CORS regressions.** Adding/changing subdomains breaks the iframe path if the regex isn't updated. Mitigated by tests that exercise the matrix and the explicit allowlist regex (not `*`). -- **Existing PostHog-direct dashboards keep working** because the events land identically in PostHog regardless of routing. No taxonomy or insight change needed. +- **Rewrites run at the edge.** Vercel rewrites are essentially free at the platform level — no per-request lambda invocation. Latency is minimal (a few ms). Scaling concerns negligible. +- **PostHog SDK assumes paths it can append.** posthog-js fetches `/static/array.js` from the api_host's static subpath. The two-rewrite pattern (`/ingest/static/*` first, `/ingest/*` second) covers this. If PostHog ever adds new top-level paths (rare), the rewrite chain may need updating. +- **CORS coverage.** Cockpit's `/ingest/*` allows a single origin by env var. If we add additional iframe origins (e.g., `staging.examples.cacheplane.ai`), we need either a middleware-based origin reflection or comma-separated allowlist. Out of scope for v1. +- **Existing PostHog dashboards keep working** because the events land identically in PostHog regardless of routing. No taxonomy or insight change needed. ## 10. Phases 1. **Phase 0 — Consolidate `properties.ts`.** Move helpers into `libs/telemetry`. Update both apps to import from there. Delete duplicate sources. Tests follow. (~6 commits.) -2. **Phase 1 — Widen website proxy + posthog-js config.** Update the prefix regex in `apps/website/src/app/api/ingest/route.ts`. Configure `posthog-js` with `api_host: '/api/ingest'`. Update route tests. (~3 commits.) -3. **Phase 2 — New cockpit proxy + posthog-js config.** Create `apps/cockpit/src/app/api/ingest/route.ts` with CORS. Configure `posthog-js`. Update `run-mode.tsx` to point iframes at the proxy. New tests. (~5 commits.) -4. **Phase 3 — `.env.example` + dev docs.** Document `NEXT_PUBLIC_COCKPIT_INGEST_HOST`. Update cockpit's smoke procedure to use the proxy. (~2 commits.) +2. **Phase 1 — Website `/ingest` rewrites + posthog-js config.** Add rewrites to `apps/website/next.config.ts`. Configure `posthog-js` with `api_host: '/ingest'`. Add config tests. (~3 commits.) +3. **Phase 2 — Cockpit `/ingest` rewrites + CORS + posthog-js config.** Add rewrites + headers to `apps/cockpit/next.config.ts`. Configure `posthog-js`. Update `run-mode.tsx` to point iframes at the rewrite path. Add config tests. (~4 commits.) +4. **Phase 3 — `.env.example` + dev docs.** Document `NEXT_PUBLIC_COCKPIT_INGEST_HOST` and `NEXT_PUBLIC_COCKPIT_IFRAME_ORIGIN`. Update cockpit's smoke procedure to use the rewrite path. (~2 commits.) ## 11. Deliverables - ☐ `libs/telemetry/src/browser/properties.ts` + spec - ☐ `libs/telemetry/src/shared/properties.ts` extended + spec -- ☐ `apps/website/src/app/api/ingest/route.ts` widened -- ☐ `apps/website/instrumentation-client.ts` updated -- ☐ `apps/cockpit/src/app/api/ingest/route.ts` + spec (new) +- ☐ `apps/website/next.config.ts` rewrites + config test +- ☐ `apps/website/instrumentation-client.ts` updated to `api_host: '/ingest'` +- ☐ `apps/cockpit/next.config.ts` rewrites + headers + config test - ☐ `apps/cockpit/instrumentation-client.ts` + `analytics-bootstrap.tsx` updated -- ☐ `apps/cockpit/src/components/run-mode/run-mode.tsx` env-driven `cockpit_host` +- ☐ `apps/cockpit/src/components/run-mode/run-mode.tsx` env-driven `cockpit_host` defaults to `/ingest` - ☐ `apps/website/src/lib/analytics/properties.ts` deleted - ☐ `apps/cockpit/src/lib/analytics/properties.ts` deleted - ☐ Server-side imports updated to consume from `@ngaf/telemetry/shared` (`apps/website/src/lib/analytics/server.ts`, `apps/website/src/app/api/leads/route.ts`, etc.) -- ☐ `.env.example` documents `NEXT_PUBLIC_COCKPIT_INGEST_HOST` +- ☐ `.env.example` documents `NEXT_PUBLIC_COCKPIT_INGEST_HOST` + `NEXT_PUBLIC_COCKPIT_IFRAME_ORIGIN` - ☐ All affected projects' tests green +- ☐ `apps/website/src/app/api/ingest/route.ts` is **unchanged** (libs/telemetry/browser path stays as-is) From 707afe47d93af31549ad84a19e4803165ca6935c Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 16 May 2026 07:20:39 -0700 Subject: [PATCH 03/16] docs(gtm): implementation plan for analytics-foundation-1d (website reconciliation) Co-Authored-By: Claude Opus 4.7 --- ...cs-foundation-1d-website-reconciliation.md | 1118 +++++++++++++++++ 1 file changed, 1118 insertions(+) create mode 100644 docs/superpowers/plans/gtm/2026-05-16-analytics-foundation-1d-website-reconciliation.md diff --git a/docs/superpowers/plans/gtm/2026-05-16-analytics-foundation-1d-website-reconciliation.md b/docs/superpowers/plans/gtm/2026-05-16-analytics-foundation-1d-website-reconciliation.md new file mode 100644 index 000000000..422289e4f --- /dev/null +++ b/docs/superpowers/plans/gtm/2026-05-16-analytics-foundation-1d-website-reconciliation.md @@ -0,0 +1,1118 @@ +# Analytics Foundation 1D — Website Reconciliation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Route every first-party event through per-app Next.js `/ingest/*` rewrites so ad-blockers can't drop telemetry, and consolidate the analytics capture-guard helpers into `@ngaf/telemetry` for one source of truth. + +**Architecture:** Two parallel proxy paths per Next.js app — `/api/ingest` keeps serving `libs/telemetry/browser`'s custom envelope (Spec 1B, unchanged), and a new `/ingest/*` rewrites chain transparently forwards `posthog-js` traffic to `us.i.posthog.com` and `us-assets.i.posthog.com`. Cockpit adds CORS headers so iframe Angular apps can POST cross-origin. `@ngaf/telemetry/browser` and `@ngaf/telemetry/shared` absorb the shared `properties.ts` helpers; the two app-local copies are deleted. + +**Tech Stack:** Next.js 16 (App Router) with the `@nx/next` plugin; TypeScript; Vitest; `posthog-js` (browser, via `api_host: '/ingest'`); `posthog-node` (server, for the existing `/api/ingest` route only). + +--- + +## Context for the implementer + +- **Spec:** `docs/superpowers/specs/gtm/2026-05-16-analytics-foundation-1d-website-reconciliation-design.md` — read §§3–6 before starting. +- **Two distinct paths, do not conflate:** + - `/api/ingest` (existing, unchanged) — `libs/telemetry/browser` posts a custom JSON envelope via raw `fetch()`; the route forwards via `posthog-node`. Only `ngaf:*` events. + - `/ingest/*` (new) — `posthog-js` posts PostHog's native batched/gzipped format. Next.js rewrites transparently forward to `us.i.posthog.com` and `us-assets.i.posthog.com`. No application-level filtering. +- **The cockpit iframe path** routes through `cockpit.cacheplane.ai/ingest/*` cross-origin from `examples.cacheplane.ai`. CORS headers in `apps/cockpit/next.config.ts:headers()` make the response readable. +- **Test runner:** Vitest. The Next.js `next.config.ts` is a TS module exporting a default config — vitest can import it directly and assert the shape. +- **TDD discipline:** every code-change task writes the test first, watches it fail, implements, watches it pass, commits. +- **Commit format:** conventional commits. Examples: `refactor(telemetry): move shouldCaptureAnalytics to @ngaf/telemetry/browser`, `feat(website): /ingest rewrites for posthog-js`, `feat(cockpit): /ingest rewrites + CORS for iframe posthog-js`. +- **One task = one commit.** +- **Worktree:** plan executes on branch `gtm-spec-1d-website-reconciliation` (already created from `origin/main`). + +## File structure (locked) + +``` +NEW +├── libs/telemetry/src/browser/properties.ts # Phase 0 +├── libs/telemetry/src/browser/properties.spec.ts # Phase 0 +├── libs/telemetry/src/shared/properties.ts # Phase 0 (new file in existing shared/ dir) +├── libs/telemetry/src/shared/properties.spec.ts # Phase 0 +├── apps/website/next.config.spec.ts # Phase 1 (vitest config test) +├── apps/cockpit/next.config.spec.ts # Phase 2 + +MODIFIED +├── libs/telemetry/src/browser/public-api.ts # Phase 0 — add properties exports +├── libs/telemetry/src/shared/public-api.ts # Phase 0 — create if missing OR add to ./index.ts +├── apps/website/next.config.ts # Phase 1 — rewrites() +├── apps/website/instrumentation-client.ts # Phase 1 — api_host: '/ingest' +├── apps/website/src/lib/analytics/server.ts # Phase 0 — import from @ngaf/telemetry/shared +├── apps/website/src/app/api/leads/route.ts # Phase 0 — import from @ngaf/telemetry/shared +├── apps/website/src/app/api/ingest/route.ts # Phase 0 — import from @ngaf/telemetry/shared +├── apps/website/src/app/api/whitepaper-signup/route.ts# Phase 0 — verify imports +├── apps/cockpit/next.config.ts # Phase 2 — rewrites() + headers() +├── apps/cockpit/instrumentation-client.ts # Phase 2 — api_host: '/ingest' +├── apps/cockpit/src/components/analytics-bootstrap.tsx# Phase 2 — api_host: '/ingest' +├── apps/cockpit/src/components/run-mode/run-mode.tsx # Phase 2 — cockpit_host default +├── .env.example # Phase 3 — new vars +│ +DELETED +├── apps/website/src/lib/analytics/properties.ts # Phase 0 +├── apps/website/src/lib/analytics/properties.spec.ts # Phase 0 (moved to lib) +├── apps/cockpit/src/lib/analytics/properties.ts # Phase 0 +├── apps/cockpit/src/lib/analytics/properties.spec.ts # Phase 0 (moved to lib) +``` + +--- + +## Phase 0 — Consolidate `properties.ts` into `@ngaf/telemetry` + +Two homes for the helpers: `browser` (DOM-dependent guards) and `shared` (cross-runtime string + URL utilities used by both Node and browser code). + +### Task 0.1: Create `libs/telemetry/src/shared/properties.ts` + +**Files:** +- Create: `libs/telemetry/src/shared/properties.ts` +- Create: `libs/telemetry/src/shared/properties.spec.ts` + +- [ ] **Step 1: Write the failing test** + +Create `libs/telemetry/src/shared/properties.spec.ts`: + +```typescript +// SPDX-License-Identifier: MIT +import { describe, expect, it } from 'vitest'; +import { + getEmailDomain, + getSourcePage, + normalizePostHogHost, + toSafeAnalyticsString, +} from './properties'; + +describe('shared properties', () => { + it('truncates safe analytics strings and drops blank values', () => { + expect(toSafeAnalyticsString(' hello ')).toBe('hello'); + expect(toSafeAnalyticsString('abcdef', 3)).toBe('abc'); + expect(toSafeAnalyticsString(' ')).toBeUndefined(); + expect(toSafeAnalyticsString(42)).toBeUndefined(); + }); + + it('extracts a normalized email domain', () => { + expect(getEmailDomain('Jane.Smith@Example.COM ')).toBe('example.com'); + expect(getEmailDomain('not-an-email')).toBeNull(); + expect(getEmailDomain('')).toBeNull(); + }); + + it('normalizes source URLs to path, query, and hash only', () => { + expect(getSourcePage('https://ngaf.example/docs?utm_source=x#intro')).toBe('/docs?utm_source=x#intro'); + expect(getSourcePage('/pricing')).toBe('/pricing'); + expect(getSourcePage('not a url')).toBe('/'); + }); + + it('uses the PostHog US ingest host as the default', () => { + expect(normalizePostHogHost(undefined)).toBe('https://us.i.posthog.com'); + expect(normalizePostHogHost('https://eu.i.posthog.com/')).toBe('https://eu.i.posthog.com'); + }); +}); +``` + +- [ ] **Step 2: Run, see fail** + +```bash +npx nx run telemetry:test -- --testPathPattern=shared/properties.spec +``` + +Expected: fails with `Cannot find module './properties'`. + +- [ ] **Step 3: Implement** + +Create `libs/telemetry/src/shared/properties.ts`: + +```typescript +// SPDX-License-Identifier: MIT +const DEFAULT_POSTHOG_HOST = 'https://us.i.posthog.com'; + +export function toSafeAnalyticsString(value: unknown, maxLength = 200): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + if (!trimmed) return undefined; + return trimmed.slice(0, maxLength); +} + +export function getEmailDomain(email: unknown): string | null { + const value = toSafeAnalyticsString(email, 320); + if (!value) return null; + + const atIndex = value.lastIndexOf('@'); + if (atIndex <= 0 || atIndex === value.length - 1) return null; + + const domain = value.slice(atIndex + 1).toLowerCase(); + return domain.includes('.') ? domain : null; +} + +export function getSourcePage(value: unknown): string { + const source = toSafeAnalyticsString(value, 2000); + if (!source) return '/'; + + if (source.startsWith('/')) return source; + + try { + const url = new URL(source); + return `${url.pathname}${url.search}${url.hash}` || '/'; + } catch { + return '/'; + } +} + +export function normalizePostHogHost(host: unknown): string { + const value = toSafeAnalyticsString(host, 500); + if (!value) return DEFAULT_POSTHOG_HOST; + return value.endsWith('/') ? value.slice(0, -1) : value; +} +``` + +- [ ] **Step 4: Run, see pass** + +```bash +npx nx run telemetry:test -- --testPathPattern=shared/properties.spec +``` + +Expected: 4 tests passing. + +- [ ] **Step 5: Export from shared/public-api.ts** + +Open `libs/telemetry/src/shared/public-api.ts`. If it doesn't exist, check what file currently exports `NgafNodeEvent` etc. — likely `libs/telemetry/src/shared/events.ts` is exported via `libs/telemetry/src/index.ts`. + +Add to whatever the shared barrel is (most likely add to `libs/telemetry/src/shared/public-api.ts` or create it; if `index.ts` is the only entry, add a line there): + +```typescript +export { getEmailDomain, getSourcePage, normalizePostHogHost, toSafeAnalyticsString } from './shared/properties'; +``` + +- [ ] **Step 6: Confirm `@ngaf/telemetry/shared` subpath resolves** + +```bash +npx nx run telemetry:build +``` + +Expected: build succeeds. The `dist/libs/telemetry/shared.d.ts` (or equivalent) now exports the 4 functions. + +- [ ] **Step 7: Commit** + +```bash +git add libs/telemetry/src/shared/properties.ts libs/telemetry/src/shared/properties.spec.ts libs/telemetry/src/shared/public-api.ts libs/telemetry/src/index.ts +git commit -m "$(cat <<'EOF' +feat(telemetry): add shared properties helpers (toSafeAnalyticsString, getEmailDomain, getSourcePage, normalizePostHogHost) + +Moved from apps/website/src/lib/analytics/properties.ts so both shells + +server-side code can consume one source of truth via @ngaf/telemetry/shared. +Tests follow the code. + +Co-Authored-By: Claude Opus 4.7 +EOF +)" +``` + +--- + +### Task 0.2: Create `libs/telemetry/src/browser/properties.ts` + +**Files:** +- Create: `libs/telemetry/src/browser/properties.ts` +- Create: `libs/telemetry/src/browser/properties.spec.ts` +- Modify: `libs/telemetry/src/browser/public-api.ts` + +- [ ] **Step 1: Write the failing test** + +Create `libs/telemetry/src/browser/properties.spec.ts`: + +```typescript +// SPDX-License-Identifier: MIT +import { describe, expect, it } from 'vitest'; +import { isLocalAnalyticsHost, shouldCaptureAnalytics } from './properties'; + +describe('browser properties', () => { + it('detects local hosts for opt-in development capture', () => { + expect(isLocalAnalyticsHost('localhost:3000')).toBe(true); + expect(isLocalAnalyticsHost('127.0.0.1:3000')).toBe(true); + expect(isLocalAnalyticsHost('::1')).toBe(true); + expect(isLocalAnalyticsHost('ngaf.example')).toBe(false); + expect(isLocalAnalyticsHost(undefined)).toBe(false); + }); + + it('requires a token and skips local capture unless explicitly enabled', () => { + expect(shouldCaptureAnalytics({ token: '', captureLocal: false, host: 'ngaf.example' })).toBe(false); + expect(shouldCaptureAnalytics({ token: 'ph_test', captureLocal: false, host: 'localhost:3000' })).toBe(false); + expect(shouldCaptureAnalytics({ token: 'ph_test', captureLocal: true, host: 'localhost:3000' })).toBe(true); + expect(shouldCaptureAnalytics({ token: 'ph_test', captureLocal: false, host: 'ngaf.example' })).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run, see fail** + +```bash +npx nx run telemetry:test -- --testPathPattern=browser/properties.spec +``` + +Expected: fails with `Cannot find module './properties'`. + +- [ ] **Step 3: Implement** + +Create `libs/telemetry/src/browser/properties.ts`: + +```typescript +// SPDX-License-Identifier: MIT +import { toSafeAnalyticsString } from '../shared/properties'; + +export type CaptureConfig = { + token?: string; + captureLocal?: boolean; + host?: string; +}; + +export function isLocalAnalyticsHost(host: unknown): boolean { + const value = toSafeAnalyticsString(host, 300)?.toLowerCase(); + if (!value) return false; + + const hostname = value.split(':')[0]; + return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1'; +} + +export function shouldCaptureAnalytics({ token, captureLocal = false, host }: CaptureConfig): boolean { + if (!toSafeAnalyticsString(token, 500)) return false; + if (isLocalAnalyticsHost(host) && !captureLocal) return false; + return true; +} +``` + +- [ ] **Step 4: Run, see pass** + +```bash +npx nx run telemetry:test -- --testPathPattern=browser/properties.spec +``` + +Expected: 2 tests passing (5 assertions total). + +- [ ] **Step 5: Export from browser/public-api.ts** + +Open `libs/telemetry/src/browser/public-api.ts` and append: + +```typescript +export { isLocalAnalyticsHost, shouldCaptureAnalytics } from './properties'; +export type { CaptureConfig } from './properties'; +``` + +- [ ] **Step 6: Build** + +```bash +npx nx run telemetry:build +``` + +Expected: build succeeds. + +- [ ] **Step 7: Commit** + +```bash +git add libs/telemetry/src/browser/properties.ts libs/telemetry/src/browser/properties.spec.ts libs/telemetry/src/browser/public-api.ts +git commit -m "$(cat <<'EOF' +feat(telemetry): add browser shouldCaptureAnalytics + isLocalAnalyticsHost + +Moved from apps/website/src/lib/analytics/properties.ts so apps/website +and apps/cockpit consume the same capture guard via +@ngaf/telemetry/browser. apps/cockpit's variant (which lacked the +toSafeAnalyticsString hardening) is dropped in favor of this stricter +implementation. + +Co-Authored-By: Claude Opus 4.7 +EOF +)" +``` + +--- + +### Task 0.3: Update `apps/website` imports to `@ngaf/telemetry/shared` + +**Files:** +- Modify: `apps/website/src/lib/analytics/server.ts` +- Modify: `apps/website/src/app/api/leads/route.ts` +- Modify: `apps/website/src/app/api/ingest/route.ts` +- Modify: `apps/website/src/app/api/whitepaper-signup/route.ts` (if it imports from properties) +- Modify: any other file under `apps/website/` that imports from `analytics/properties` + +- [ ] **Step 1: Find every importer** + +```bash +grep -rln "from.*analytics/properties" apps/website/src 2>/dev/null +``` + +Expected: list of 5–8 files. Note them. + +- [ ] **Step 2: Update each file's imports** + +For every file in the grep output, change the import from: + +```typescript +import { toSafeAnalyticsString, normalizePostHogHost, /* etc. */ } from '../../lib/analytics/properties'; +``` + +(or whatever relative path) to: + +```typescript +import { toSafeAnalyticsString, normalizePostHogHost, /* etc. */ } from '@ngaf/telemetry/shared'; +``` + +For the **server-side** files (`server.ts`, `api/leads/route.ts`, `api/ingest/route.ts`, `api/whitepaper-signup/route.ts`) the helpers used are: `toSafeAnalyticsString`, `getEmailDomain`, `getSourcePage`, `normalizePostHogHost`. All four live in `@ngaf/telemetry/shared`. + +- [ ] **Step 3: Run website tests + build** + +```bash +npx nx run website:test +npx nx run website:build +``` + +Expected: both green. (The website's tests previously imported from `./properties`; those still work because Task 0.5 hasn't deleted the file yet.) + +- [ ] **Step 4: Commit** + +```bash +git add apps/website/src +git commit -m "$(cat <<'EOF' +refactor(website): import shared analytics helpers from @ngaf/telemetry/shared + +Switches server.ts + the four API routes that use toSafeAnalyticsString, +getEmailDomain, getSourcePage, normalizePostHogHost to consume them from +the published lib instead of the app-local copy. Local file stays until +Task 0.5 deletes it. + +Co-Authored-By: Claude Opus 4.7 +EOF +)" +``` + +--- + +### Task 0.4: Update `apps/website` browser-side imports + `instrumentation-client.ts` + +**Files:** +- Modify: `apps/website/instrumentation-client.ts` +- Modify: any other browser-side file importing `shouldCaptureAnalytics` or `isLocalAnalyticsHost` + +- [ ] **Step 1: Find browser-side importers** + +```bash +grep -rln "shouldCaptureAnalytics\|isLocalAnalyticsHost" apps/website/src apps/website/*.ts 2>/dev/null | grep -v ".spec." +``` + +Expected: at least `apps/website/instrumentation-client.ts`. + +- [ ] **Step 2: Update each file's import to `@ngaf/telemetry/browser`** + +For `apps/website/instrumentation-client.ts`, change: + +```typescript +import { normalizePostHogHost, shouldCaptureAnalytics } from './src/lib/analytics/properties'; +``` + +to: + +```typescript +import { normalizePostHogHost } from '@ngaf/telemetry/shared'; +import { shouldCaptureAnalytics } from '@ngaf/telemetry/browser'; +``` + +- [ ] **Step 3: Run + commit** + +```bash +npx nx run website:build +git add apps/website/instrumentation-client.ts +git commit -m "$(cat <<'EOF' +refactor(website): import shouldCaptureAnalytics from @ngaf/telemetry/browser + +instrumentation-client.ts now sources the capture guard from the shared +lib. Local properties.ts retains its remaining consumers (the test file +itself) until Task 0.5. + +Co-Authored-By: Claude Opus 4.7 +EOF +)" +``` + +--- + +### Task 0.5: Delete `apps/website/src/lib/analytics/properties.ts` + spec + +**Files:** +- Delete: `apps/website/src/lib/analytics/properties.ts` +- Delete: `apps/website/src/lib/analytics/properties.spec.ts` + +- [ ] **Step 1: Confirm no remaining importers** + +```bash +grep -rln "from.*analytics/properties" apps/website 2>/dev/null +``` + +Expected: empty. + +- [ ] **Step 2: Delete the files** + +```bash +git rm apps/website/src/lib/analytics/properties.ts apps/website/src/lib/analytics/properties.spec.ts +``` + +- [ ] **Step 3: Run tests + build** + +```bash +npx nx run website:test +npx nx run website:build +``` + +Expected: green. + +- [ ] **Step 4: Commit** + +```bash +git commit -m "$(cat <<'EOF' +refactor(website): delete duplicated analytics/properties.ts + +All consumers now import from @ngaf/telemetry/{shared,browser}. Tests +moved to libs/telemetry in Tasks 0.1 and 0.2. + +Co-Authored-By: Claude Opus 4.7 +EOF +)" +``` + +--- + +### Task 0.6: Update `apps/cockpit` imports + delete local `properties.ts` + +**Files:** +- Modify: `apps/cockpit/instrumentation-client.ts` +- Modify: `apps/cockpit/src/components/analytics-bootstrap.tsx` +- Modify: any other file under `apps/cockpit/src` importing from `analytics/properties` +- Delete: `apps/cockpit/src/lib/analytics/properties.ts` +- Delete: `apps/cockpit/src/lib/analytics/properties.spec.ts` + +- [ ] **Step 1: Find importers** + +```bash +grep -rln "from.*analytics/properties" apps/cockpit 2>/dev/null +``` + +Expected: 3–4 files. + +- [ ] **Step 2: Update each importer** + +`apps/cockpit/instrumentation-client.ts`: + +```typescript +// before +import { shouldCaptureAnalytics } from './src/lib/analytics/properties'; + +// after +import { shouldCaptureAnalytics } from '@ngaf/telemetry/browser'; +``` + +`apps/cockpit/src/components/analytics-bootstrap.tsx`: + +```typescript +// before +import { shouldCaptureAnalytics } from '../lib/analytics/properties'; + +// after +import { shouldCaptureAnalytics } from '@ngaf/telemetry/browser'; +``` + +For any other importer, mirror the same swap. The cockpit's `CaptureGuardInput` type isn't exported anywhere; the `@ngaf/telemetry/browser` `CaptureConfig` type covers the same shape with optional `captureLocal`. + +- [ ] **Step 3: Delete the local files** + +```bash +git rm apps/cockpit/src/lib/analytics/properties.ts apps/cockpit/src/lib/analytics/properties.spec.ts +``` + +- [ ] **Step 4: Run tests + build** + +```bash +npx nx run cockpit:test +npx nx run cockpit:build +``` + +Expected: green. + +- [ ] **Step 5: Commit** + +```bash +git add -A apps/cockpit +git commit -m "$(cat <<'EOF' +refactor(cockpit): consume shouldCaptureAnalytics from @ngaf/telemetry/browser + +Drops the cockpit-local properties.ts duplicate in favor of the +website's stricter implementation now living in @ngaf/telemetry/browser. +Both shells share one capture guard. + +Co-Authored-By: Claude Opus 4.7 +EOF +)" +``` + +--- + +## Phase 1 — Website `/ingest` rewrites + posthog-js config + +### Task 1.1: Add `/ingest/*` rewrites to `apps/website/next.config.ts` + +**Files:** +- Modify: `apps/website/next.config.ts` +- Create: `apps/website/next.config.spec.ts` + +- [ ] **Step 1: Write the failing config test** + +Create `apps/website/next.config.spec.ts`: + +```typescript +// SPDX-License-Identifier: MIT +import { describe, expect, it } from 'vitest'; +import config from './next.config'; + +describe('website next.config rewrites', () => { + it('exposes posthog-js rewrites under /ingest', async () => { + expect(typeof config.rewrites).toBe('function'); + const rewrites = await config.rewrites!(); + const list = Array.isArray(rewrites) ? rewrites : rewrites.beforeFiles ?? []; + const sources = list.map((r: { source: string }) => r.source); + expect(sources).toContain('/ingest/static/:path*'); + expect(sources).toContain('/ingest/:path*'); + const staticRule = list.find((r: { source: string }) => r.source === '/ingest/static/:path*'); + expect(staticRule.destination).toBe('https://us-assets.i.posthog.com/static/:path*'); + const apiRule = list.find((r: { source: string }) => r.source === '/ingest/:path*'); + expect(apiRule.destination).toBe('https://us.i.posthog.com/:path*'); + }); +}); +``` + +- [ ] **Step 2: Run, see fail** + +```bash +cd apps/website && npx vitest run next.config.spec.ts +``` + +Expected: fails — `config.rewrites` is undefined. + +- [ ] **Step 3: Implement** + +Open `apps/website/next.config.ts`. The existing file looks like: + +```typescript +import { composePlugins, withNx } from '@nx/next'; +import type { WithNxOptions } from '@nx/next/plugins/with-nx'; + +const nextConfig: WithNxOptions = { + // existing options... +}; + +const plugins = [withNx]; + +export default composePlugins(...plugins)(nextConfig); +``` + +The test imports `./next.config` and expects a config object with `rewrites`. Because `composePlugins(...)(nextConfig)` returns a function returning the resolved config, exporting that result directly won't expose `rewrites` to vitest cleanly. Solution: export both — keep the default (for Next.js) and a named export for testing. + +Replace the file with: + +```typescript +import { composePlugins, withNx } from '@nx/next'; +import type { WithNxOptions } from '@nx/next/plugins/with-nx'; + +export const nextConfig: WithNxOptions = { + // ...keep all the existing options (nx, etc.)... + async rewrites() { + return [ + { source: '/ingest/static/:path*', destination: 'https://us-assets.i.posthog.com/static/:path*' }, + { source: '/ingest/:path*', destination: 'https://us.i.posthog.com/:path*' }, + ]; + }, + skipTrailingSlashRedirect: true, +}; + +const plugins = [withNx]; + +export default composePlugins(...plugins)(nextConfig); +``` + +Update the spec to import the named export: + +```typescript +import { nextConfig as config } from './next.config'; +``` + +- [ ] **Step 4: Run, see pass** + +```bash +cd apps/website && npx vitest run next.config.spec.ts +``` + +Expected: 1 test passing. + +- [ ] **Step 5: Verify build still works** + +```bash +npx nx run website:build +``` + +Expected: build succeeds. + +- [ ] **Step 6: Commit** + +```bash +git add apps/website/next.config.ts apps/website/next.config.spec.ts +git commit -m "$(cat <<'EOF' +feat(website): /ingest rewrites for first-party posthog-js proxy + +Adds Next.js rewrites that forward /ingest/static/* to +us-assets.i.posthog.com and /ingest/* to us.i.posthog.com. posthog-js +configured with api_host: '/ingest' in Task 1.2 will route through this, +surviving ad-blockers that block *.posthog.com. + +Co-Authored-By: Claude Opus 4.7 +EOF +)" +``` + +--- + +### Task 1.2: Point `apps/website` posthog-js at `/ingest` + +**Files:** +- Modify: `apps/website/instrumentation-client.ts` + +- [ ] **Step 1: Read current state** + +```bash +cat apps/website/instrumentation-client.ts +``` + +Note the current `posthog.init(...)` call — it passes `api_host: normalizePostHogHost(process.env.NEXT_PUBLIC_POSTHOG_HOST)`. + +- [ ] **Step 2: Replace api_host** + +Edit `apps/website/instrumentation-client.ts`. Change: + +```typescript +posthog.init(token!, { + api_host: normalizePostHogHost(process.env.NEXT_PUBLIC_POSTHOG_HOST), + defaults: '2026-01-30', + capture_pageview: true, + person_profiles: 'always', +}); +``` + +to: + +```typescript +posthog.init(token!, { + api_host: '/ingest', + ui_host: 'https://us.posthog.com', + defaults: '2026-01-30', + capture_pageview: true, + person_profiles: 'always', +}); +``` + +The `normalizePostHogHost` import can be removed if it's no longer used elsewhere in the file. Check with: + +```bash +grep -c "normalizePostHogHost" apps/website/instrumentation-client.ts +``` + +If 1, drop the import. + +- [ ] **Step 3: Verify build** + +```bash +npx nx run website:build +``` + +Expected: green. + +- [ ] **Step 4: Commit** + +```bash +git add apps/website/instrumentation-client.ts +git commit -m "$(cat <<'EOF' +feat(website): point posthog-js at /ingest first-party proxy + +api_host: '/ingest' routes all browser captures through the same-origin +rewrite path added in Task 1.1. ui_host preserves PostHog UI links for +session-replay / toolbar features. + +Co-Authored-By: Claude Opus 4.7 +EOF +)" +``` + +--- + +## Phase 2 — Cockpit `/ingest` rewrites + CORS + posthog-js config + +### Task 2.1: Add `/ingest/*` rewrites + CORS to `apps/cockpit/next.config.ts` + +**Files:** +- Modify: `apps/cockpit/next.config.ts` +- Create: `apps/cockpit/next.config.spec.ts` + +- [ ] **Step 1: Write the failing config test** + +Create `apps/cockpit/next.config.spec.ts`: + +```typescript +// SPDX-License-Identifier: MIT +import { describe, expect, it } from 'vitest'; +import { nextConfig as config } from './next.config'; + +describe('cockpit next.config', () => { + it('exposes posthog-js rewrites under /ingest', async () => { + expect(typeof config.rewrites).toBe('function'); + const rewrites = await config.rewrites!(); + const list = Array.isArray(rewrites) ? rewrites : rewrites.beforeFiles ?? []; + const sources = list.map((r: { source: string }) => r.source); + expect(sources).toContain('/ingest/static/:path*'); + expect(sources).toContain('/ingest/:path*'); + const staticRule = list.find((r: { source: string }) => r.source === '/ingest/static/:path*'); + expect(staticRule.destination).toBe('https://us-assets.i.posthog.com/static/:path*'); + const apiRule = list.find((r: { source: string }) => r.source === '/ingest/:path*'); + expect(apiRule.destination).toBe('https://us.i.posthog.com/:path*'); + }); + + it('attaches CORS headers to /ingest/* responses', async () => { + expect(typeof config.headers).toBe('function'); + const rules = await config.headers!(); + const ingestRule = rules.find((r: { source: string }) => r.source === '/ingest/:path*'); + expect(ingestRule).toBeDefined(); + const headerKeys = ingestRule.headers.map((h: { key: string }) => h.key); + expect(headerKeys).toContain('Access-Control-Allow-Origin'); + expect(headerKeys).toContain('Access-Control-Allow-Methods'); + expect(headerKeys).toContain('Access-Control-Allow-Headers'); + expect(headerKeys).toContain('Access-Control-Max-Age'); + const methods = ingestRule.headers.find((h: { key: string }) => h.key === 'Access-Control-Allow-Methods'); + expect(methods.value).toBe('POST, OPTIONS'); + }); +}); +``` + +- [ ] **Step 2: Run, see fail** + +```bash +cd apps/cockpit && npx vitest run next.config.spec.ts +``` + +Expected: fails. + +- [ ] **Step 3: Implement** + +Replace `apps/cockpit/next.config.ts` with: + +```typescript +import { composePlugins, withNx } from '@nx/next'; +import type { WithNxOptions } from '@nx/next/plugins/with-nx'; + +export const nextConfig: WithNxOptions = { + nx: {}, + async rewrites() { + return [ + { source: '/ingest/static/:path*', destination: 'https://us-assets.i.posthog.com/static/:path*' }, + { source: '/ingest/:path*', destination: 'https://us.i.posthog.com/:path*' }, + ]; + }, + async headers() { + return [{ + source: '/ingest/:path*', + headers: [ + { key: 'Access-Control-Allow-Origin', value: process.env.NEXT_PUBLIC_COCKPIT_IFRAME_ORIGIN ?? '*' }, + { key: 'Access-Control-Allow-Methods', value: 'POST, OPTIONS' }, + { key: 'Access-Control-Allow-Headers', value: 'Content-Type' }, + { key: 'Access-Control-Max-Age', value: '86400' }, + ], + }]; + }, + skipTrailingSlashRedirect: true, +}; + +const plugins = [withNx]; + +export default composePlugins(...plugins)(nextConfig); +``` + +- [ ] **Step 4: Run, see pass** + +```bash +cd apps/cockpit && npx vitest run next.config.spec.ts +``` + +Expected: 2 tests passing. + +- [ ] **Step 5: Verify build** + +```bash +npx nx run cockpit:build +``` + +Expected: green. + +- [ ] **Step 6: Commit** + +```bash +git add apps/cockpit/next.config.ts apps/cockpit/next.config.spec.ts +git commit -m "$(cat <<'EOF' +feat(cockpit): /ingest rewrites + CORS for cross-origin iframe posthog-js + +Adds rewrites mirroring the website's Phase 1 work, plus CORS headers +on /ingest/* so iframe Angular apps on examples.cacheplane.ai can POST +cross-origin. Origin is env-driven (NEXT_PUBLIC_COCKPIT_IFRAME_ORIGIN) +with wildcard fallback for local dev. + +Co-Authored-By: Claude Opus 4.7 +EOF +)" +``` + +--- + +### Task 2.2: Point `apps/cockpit` posthog-js at `/ingest` + +**Files:** +- Modify: `apps/cockpit/instrumentation-client.ts` +- Modify: `apps/cockpit/src/components/analytics-bootstrap.tsx` + +- [ ] **Step 1: Update instrumentation-client.ts** + +Edit `apps/cockpit/instrumentation-client.ts`. Find the `posthog.init(...)` block. Change: + +```typescript +posthog.init(token!, { + api_host: process.env.NEXT_PUBLIC_COCKPIT_POSTHOG_HOST ?? 'https://us.i.posthog.com', + persistence: 'memory', + bootstrap: { distinctID: getCockpitSessionId() }, + autocapture: false, + capture_pageview: false, + defaults: '2026-01-30', +}); +``` + +to: + +```typescript +posthog.init(token!, { + api_host: '/ingest', + ui_host: 'https://us.posthog.com', + persistence: 'memory', + bootstrap: { distinctID: getCockpitSessionId() }, + autocapture: false, + capture_pageview: false, + defaults: '2026-01-30', +}); +``` + +- [ ] **Step 2: Update analytics-bootstrap.tsx** + +Edit `apps/cockpit/src/components/analytics-bootstrap.tsx`. Find the `posthog.init(...)` block inside the useEffect. Apply the same change as above. + +- [ ] **Step 3: Run cockpit tests** + +```bash +npx nx run cockpit:test +``` + +Expected: green. + +- [ ] **Step 4: Commit** + +```bash +git add apps/cockpit/instrumentation-client.ts apps/cockpit/src/components/analytics-bootstrap.tsx +git commit -m "$(cat <<'EOF' +feat(cockpit): point shell posthog-js at /ingest rewrite path + +Both the instrumentation-client.ts (intent-only, may not run reliably in +turbopack dev) and the AnalyticsBootstrap client component now use +api_host: '/ingest' so events route through the rewrite added in Task 2.1. + +Co-Authored-By: Claude Opus 4.7 +EOF +)" +``` + +--- + +### Task 2.3: Update `run-mode.tsx` to default `cockpit_host` at `/ingest` + +**Files:** +- Modify: `apps/cockpit/src/components/run-mode/run-mode.tsx` + +- [ ] **Step 1: Update buildIframeSrc** + +Edit `apps/cockpit/src/components/run-mode/run-mode.tsx`. Find: + +```typescript +const host = process.env.NEXT_PUBLIC_COCKPIT_POSTHOG_HOST; +if (host) url.searchParams.set('cockpit_host', host); +``` + +Replace with: + +```typescript +const ingestHost = + process.env.NEXT_PUBLIC_COCKPIT_INGEST_HOST + ?? (typeof window !== 'undefined' ? `${window.location.origin}/ingest` : undefined); +if (ingestHost) url.searchParams.set('cockpit_host', ingestHost); +``` + +This keeps `NEXT_PUBLIC_COCKPIT_POSTHOG_HOST` working as a fallback only when explicitly set (operators can still override). For prod, `NEXT_PUBLIC_COCKPIT_INGEST_HOST=https://cockpit.cacheplane.ai/ingest`. For dev, the function falls back to the current origin's `/ingest`. + +- [ ] **Step 2: Verify existing tests still pass** + +```bash +npx nx run cockpit:test -- --testPathPattern=run-mode +``` + +Expected: green (the test mocks getCockpitSessionId but doesn't depend on the host source). + +- [ ] **Step 3: Build** + +```bash +npx nx run cockpit:build +``` + +Expected: green. + +- [ ] **Step 4: Commit** + +```bash +git add apps/cockpit/src/components/run-mode/run-mode.tsx +git commit -m "$(cat <<'EOF' +feat(cockpit): iframe cockpit_host defaults to /ingest rewrite + +buildIframeSrc now passes the cockpit shell's /ingest URL as the iframe's +PostHog api_host instead of us.i.posthog.com. Iframes inside Angular +examples will route their cockpit:* events through the cockpit shell's +ad-blocker-resistant proxy. + +NEXT_PUBLIC_COCKPIT_INGEST_HOST env var pins the absolute URL for prod +(https://cockpit.cacheplane.ai/ingest); dev falls back to the runtime +origin. + +Co-Authored-By: Claude Opus 4.7 +EOF +)" +``` + +--- + +## Phase 3 — Env docs + +### Task 3.1: Update `.env.example` + +**Files:** +- Modify: `.env.example` + +- [ ] **Step 1: Append new vars under the cockpit section** + +Find the "Cockpit shell analytics" section in `.env.example`. Add below the existing `NEXT_PUBLIC_COCKPIT_*` entries: + +```bash +# Cockpit iframe → cockpit-shell /ingest proxy. Production: full absolute URL. +# Leave empty in dev to let RunMode derive it from window.location.origin. +NEXT_PUBLIC_COCKPIT_INGEST_HOST= + +# CORS origin allowed to POST to cockpit's /ingest. Production: +# https://examples.cacheplane.ai. Leave empty in dev — wildcard '*' is used. +NEXT_PUBLIC_COCKPIT_IFRAME_ORIGIN= +``` + +- [ ] **Step 2: Commit** + +```bash +git add .env.example +git commit -m "$(cat <<'EOF' +docs(env): document NEXT_PUBLIC_COCKPIT_INGEST_HOST + NEXT_PUBLIC_COCKPIT_IFRAME_ORIGIN + +Two new env vars introduced by Spec 1D: +- NEXT_PUBLIC_COCKPIT_INGEST_HOST — absolute URL of the cockpit shell's + /ingest proxy, written into the iframe URL's cockpit_host param. +- NEXT_PUBLIC_COCKPIT_IFRAME_ORIGIN — CORS-allowed iframe origin for + the cockpit's /ingest rewrite. + +Co-Authored-By: Claude Opus 4.7 +EOF +)" +``` + +--- + +### Task 3.2: Final verification across affected projects + +This is a verification-only task — no commit unless something needs fixing. + +- [ ] **Step 1: Run the full affected test suite** + +```bash +npx nx run-many -t test -p telemetry,website,cockpit +``` + +Expected: all green. + +- [ ] **Step 2: Run builds** + +```bash +npx nx run-many -t build -p telemetry,website,cockpit +``` + +Expected: all green. + +- [ ] **Step 3: Confirm no stale `analytics/properties` references** + +```bash +grep -rln "from.*analytics/properties" apps libs 2>/dev/null +``` + +Expected: empty. + +- [ ] **Step 4: Confirm no posthog-js still points at us.i.posthog.com directly** + +```bash +grep -rn "us.i.posthog.com" apps libs 2>/dev/null | grep -v "shared/properties\|node_modules\|.env\|README\|spec\|\.md" | head +``` + +Expected: only references inside the rewrite destinations (`apps/{website,cockpit}/next.config.ts`) and the lib's `DEFAULT_POSTHOG_HOST` constant. + +- [ ] **Step 5: Done** + +If all four checks pass, Spec 1D is implementation-complete. Proceed to PR. + +--- + +## Self-Review + +**1. Spec coverage:** + +| Spec deliverable | Task | +|---|---| +| `libs/telemetry/src/browser/properties.ts` + spec | 0.2 | +| `libs/telemetry/src/shared/properties.ts` extended + spec | 0.1 | +| `apps/website/next.config.ts` rewrites | 1.1 | +| `apps/website/instrumentation-client.ts` updated | 1.2 | +| `apps/cockpit/next.config.ts` rewrites + headers | 2.1 | +| `apps/cockpit/instrumentation-client.ts` + analytics-bootstrap.tsx | 2.2 | +| `apps/cockpit/src/components/run-mode/run-mode.tsx` env-driven cockpit_host | 2.3 | +| `apps/website/src/lib/analytics/properties.ts` deleted | 0.5 | +| `apps/cockpit/src/lib/analytics/properties.ts` deleted | 0.6 | +| Server-side imports updated to @ngaf/telemetry/shared | 0.3 | +| `.env.example` documents new vars | 3.1 | +| All affected tests green | 3.2 | +| `/api/ingest` unchanged | ✓ (no task touches it) | + +All deliverables covered. + +**2. Placeholder scan:** No TBDs, no "add error handling" without code, no "similar to Task N" without repeating content. ✓ + +**3. Type consistency:** + +- `CaptureConfig` (Task 0.2) ← used by `shouldCaptureAnalytics`. ✓ +- `toSafeAnalyticsString`, `normalizePostHogHost`, `getEmailDomain`, `getSourcePage` (Task 0.1) — all four referenced by name in 0.3 and 0.4. ✓ +- `isLocalAnalyticsHost` (Task 0.2) — referenced in 0.4 grep + 0.6 import swap. ✓ +- env var names: `NEXT_PUBLIC_COCKPIT_INGEST_HOST` (Tasks 2.3, 3.1) + `NEXT_PUBLIC_COCKPIT_IFRAME_ORIGIN` (Tasks 2.1, 3.1) — consistent. ✓ +- `api_host: '/ingest'` consistent across Tasks 1.2, 2.2. ✓ +- Rewrite destinations `https://us.i.posthog.com/:path*` and `https://us-assets.i.posthog.com/static/:path*` identical in 1.1, 2.1, and their tests. ✓ From ec9f32c157641da83be860bd3cd5f16c9a935163 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 16 May 2026 08:19:15 -0700 Subject: [PATCH 04/16] feat(telemetry): add shared properties helpers (toSafeAnalyticsString, getEmailDomain, getSourcePage, normalizePostHogHost) Moved from apps/website/src/lib/analytics/properties.ts so both shells + server-side code can consume one source of truth via @ngaf/telemetry/shared. Tests follow the code. Co-Authored-By: Claude Opus 4.7 --- libs/telemetry/package.json | 4 +- libs/telemetry/scripts/assemble-dist.mjs | 4 +- libs/telemetry/src/shared/properties.spec.ts | 34 +++++++++++++++++ libs/telemetry/src/shared/properties.ts | 40 ++++++++++++++++++++ libs/telemetry/src/shared/public-api.ts | 8 ++++ tsconfig.base.json | 1 + 6 files changed, 87 insertions(+), 4 deletions(-) create mode 100644 libs/telemetry/src/shared/properties.spec.ts create mode 100644 libs/telemetry/src/shared/properties.ts create mode 100644 libs/telemetry/src/shared/public-api.ts diff --git a/libs/telemetry/package.json b/libs/telemetry/package.json index 1e3e71034..7d4a86d55 100644 --- a/libs/telemetry/package.json +++ b/libs/telemetry/package.json @@ -25,8 +25,8 @@ "default": "./index.js" }, "./shared": { - "types": "./shared/events.d.ts", - "default": "./shared/events.js" + "types": "./shared/public-api.d.ts", + "default": "./shared/public-api.js" }, "./node": { "types": "./node/index.d.ts", diff --git a/libs/telemetry/scripts/assemble-dist.mjs b/libs/telemetry/scripts/assemble-dist.mjs index 10975b7d7..9cc390dde 100644 --- a/libs/telemetry/scripts/assemble-dist.mjs +++ b/libs/telemetry/scripts/assemble-dist.mjs @@ -101,8 +101,8 @@ export function createCanonicalPackageJson(srcPkg) { default: './index.js', }, './shared': { - types: './shared/events.d.ts', // shared has no aggregating index; events is the only type-only public artifact - default: './shared/events.js', + types: './shared/public-api.d.ts', + default: './shared/public-api.js', }, './node': { types: './node/index.d.ts', diff --git a/libs/telemetry/src/shared/properties.spec.ts b/libs/telemetry/src/shared/properties.spec.ts new file mode 100644 index 000000000..10fbde75f --- /dev/null +++ b/libs/telemetry/src/shared/properties.spec.ts @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: MIT +import { describe, expect, it } from 'vitest'; +import { + getEmailDomain, + getSourcePage, + normalizePostHogHost, + toSafeAnalyticsString, +} from './properties'; + +describe('shared properties', () => { + it('truncates safe analytics strings and drops blank values', () => { + expect(toSafeAnalyticsString(' hello ')).toBe('hello'); + expect(toSafeAnalyticsString('abcdef', 3)).toBe('abc'); + expect(toSafeAnalyticsString(' ')).toBeUndefined(); + expect(toSafeAnalyticsString(42)).toBeUndefined(); + }); + + it('extracts a normalized email domain', () => { + expect(getEmailDomain('Jane.Smith@Example.COM ')).toBe('example.com'); + expect(getEmailDomain('not-an-email')).toBeNull(); + expect(getEmailDomain('')).toBeNull(); + }); + + it('normalizes source URLs to path, query, and hash only', () => { + expect(getSourcePage('https://ngaf.example/docs?utm_source=x#intro')).toBe('/docs?utm_source=x#intro'); + expect(getSourcePage('/pricing')).toBe('/pricing'); + expect(getSourcePage('not a url')).toBe('/'); + }); + + it('uses the PostHog US ingest host as the default', () => { + expect(normalizePostHogHost(undefined)).toBe('https://us.i.posthog.com'); + expect(normalizePostHogHost('https://eu.i.posthog.com/')).toBe('https://eu.i.posthog.com'); + }); +}); diff --git a/libs/telemetry/src/shared/properties.ts b/libs/telemetry/src/shared/properties.ts new file mode 100644 index 000000000..8f8ab306b --- /dev/null +++ b/libs/telemetry/src/shared/properties.ts @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MIT +const DEFAULT_POSTHOG_HOST = 'https://us.i.posthog.com'; + +export function toSafeAnalyticsString(value: unknown, maxLength = 200): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + if (!trimmed) return undefined; + return trimmed.slice(0, maxLength); +} + +export function getEmailDomain(email: unknown): string | null { + const value = toSafeAnalyticsString(email, 320); + if (!value) return null; + + const atIndex = value.lastIndexOf('@'); + if (atIndex <= 0 || atIndex === value.length - 1) return null; + + const domain = value.slice(atIndex + 1).toLowerCase(); + return domain.includes('.') ? domain : null; +} + +export function getSourcePage(value: unknown): string { + const source = toSafeAnalyticsString(value, 2000); + if (!source) return '/'; + + if (source.startsWith('/')) return source; + + try { + const url = new URL(source); + return `${url.pathname}${url.search}${url.hash}` || '/'; + } catch { + return '/'; + } +} + +export function normalizePostHogHost(host: unknown): string { + const value = toSafeAnalyticsString(host, 500); + if (!value) return DEFAULT_POSTHOG_HOST; + return value.endsWith('/') ? value.slice(0, -1) : value; +} diff --git a/libs/telemetry/src/shared/public-api.ts b/libs/telemetry/src/shared/public-api.ts new file mode 100644 index 000000000..b22146d0d --- /dev/null +++ b/libs/telemetry/src/shared/public-api.ts @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: MIT +export type { NgafEvent, NgafNodeEvent, NgafBrowserEvent } from './events'; +export { + getEmailDomain, + getSourcePage, + normalizePostHogHost, + toSafeAnalyticsString, +} from './properties'; diff --git a/tsconfig.base.json b/tsconfig.base.json index 361dbcd73..3436e1f35 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -38,6 +38,7 @@ "@ngaf/telemetry": ["libs/telemetry/src/index.ts"], "@ngaf/telemetry/browser": ["libs/telemetry/src/browser/public-api.ts"], "@ngaf/telemetry/node": ["libs/telemetry/src/node/index.ts"], + "@ngaf/telemetry/shared": ["libs/telemetry/src/shared/public-api.ts"], "@ngaf-internal/aimock-harness": ["libs/internal/aimock-harness/src/index.ts"], "@ngaf-internal/aimock-harness/global-teardown": ["libs/internal/aimock-harness/src/global-teardown.ts"] }, From 748eed553fcf09c9c29de02bb288b11564ba2038 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 16 May 2026 08:31:10 -0700 Subject: [PATCH 05/16] feat(telemetry): add browser shouldCaptureAnalytics + isLocalAnalyticsHost Moved from apps/website/src/lib/analytics/properties.ts so apps/website and apps/cockpit consume the same capture guard via @ngaf/telemetry/browser. apps/cockpit's variant (which lacked the toSafeAnalyticsString hardening) is dropped in favor of this stricter implementation. Co-Authored-By: Claude Opus 4.7 --- libs/telemetry/src/browser/properties.spec.ts | 20 +++++++++++ libs/telemetry/src/browser/properties.ts | 34 +++++++++++++++++++ libs/telemetry/src/browser/public-api.ts | 2 ++ 3 files changed, 56 insertions(+) create mode 100644 libs/telemetry/src/browser/properties.spec.ts create mode 100644 libs/telemetry/src/browser/properties.ts diff --git a/libs/telemetry/src/browser/properties.spec.ts b/libs/telemetry/src/browser/properties.spec.ts new file mode 100644 index 000000000..e4f2ba248 --- /dev/null +++ b/libs/telemetry/src/browser/properties.spec.ts @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: MIT +import { describe, expect, it } from 'vitest'; +import { isLocalAnalyticsHost, shouldCaptureAnalytics } from './properties'; + +describe('browser properties', () => { + it('detects local hosts for opt-in development capture', () => { + expect(isLocalAnalyticsHost('localhost:3000')).toBe(true); + expect(isLocalAnalyticsHost('127.0.0.1:3000')).toBe(true); + expect(isLocalAnalyticsHost('::1')).toBe(true); + expect(isLocalAnalyticsHost('ngaf.example')).toBe(false); + expect(isLocalAnalyticsHost(undefined)).toBe(false); + }); + + it('requires a token and skips local capture unless explicitly enabled', () => { + expect(shouldCaptureAnalytics({ token: '', captureLocal: false, host: 'ngaf.example' })).toBe(false); + expect(shouldCaptureAnalytics({ token: 'ph_test', captureLocal: false, host: 'localhost:3000' })).toBe(false); + expect(shouldCaptureAnalytics({ token: 'ph_test', captureLocal: true, host: 'localhost:3000' })).toBe(true); + expect(shouldCaptureAnalytics({ token: 'ph_test', captureLocal: false, host: 'ngaf.example' })).toBe(true); + }); +}); diff --git a/libs/telemetry/src/browser/properties.ts b/libs/telemetry/src/browser/properties.ts new file mode 100644 index 000000000..e51d31e80 --- /dev/null +++ b/libs/telemetry/src/browser/properties.ts @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: MIT +// Note: kept self-contained (no `../shared` import) so ng-packagr can compile +// this entry point in isolation. The trim+truncate hardening is duplicated +// from shared/properties.ts on purpose — it's a few lines, and the alternative +// is a secondary ng-packagr entry point for shared, which is heavier. +function toSafeString(value: unknown, maxLength: number): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + if (!trimmed) return undefined; + return trimmed.slice(0, maxLength); +} + +export type CaptureConfig = { + token?: string; + captureLocal?: boolean; + host?: string; +}; + +export function isLocalAnalyticsHost(host: unknown): boolean { + const value = toSafeString(host, 300)?.toLowerCase(); + if (!value) return false; + + // IPv6 literal `::1` or `[::1]:port` — don't naively split on `:`. + if (value === '::1' || value.startsWith('[::1]')) return true; + + const hostname = value.split(':')[0]; + return hostname === 'localhost' || hostname === '127.0.0.1'; +} + +export function shouldCaptureAnalytics({ token, captureLocal = false, host }: CaptureConfig): boolean { + if (!toSafeString(token, 500)) return false; + if (isLocalAnalyticsHost(host) && !captureLocal) return false; + return true; +} diff --git a/libs/telemetry/src/browser/public-api.ts b/libs/telemetry/src/browser/public-api.ts index 944180a42..3269f9b7a 100644 --- a/libs/telemetry/src/browser/public-api.ts +++ b/libs/telemetry/src/browser/public-api.ts @@ -13,3 +13,5 @@ export type { NgafBrowserStreamErrorTelemetry, NgafBrowserStreamTelemetry, } from './service'; +export { isLocalAnalyticsHost, shouldCaptureAnalytics } from './properties'; +export type { CaptureConfig } from './properties'; From b0b49af5cd6cf5d3a65c533eeff2114aac1b5420 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 16 May 2026 08:45:26 -0700 Subject: [PATCH 06/16] refactor(website): import shared analytics helpers from @ngaf/telemetry/shared Switches server.ts + the four API routes (leads, ingest, whitepaper-signup, newsletter) that use toSafeAnalyticsString, getEmailDomain, getSourcePage, normalizePostHogHost to consume them from the published lib instead of the app-local copy. Adds the @ngaf/telemetry/{shared,browser} path mappings to the website tsconfig so Next.js bundler-mode resolution picks them up. Local properties.ts stays until Task 0.5 deletes it. Co-Authored-By: Claude Opus 4.7 --- apps/website/src/app/api/ingest/route.ts | 2 +- apps/website/src/app/api/leads/route.ts | 2 +- apps/website/src/app/api/newsletter/route.ts | 2 +- apps/website/src/app/api/whitepaper-signup/route.ts | 2 +- apps/website/src/lib/analytics/server.ts | 2 +- apps/website/tsconfig.json | 4 +++- 6 files changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/website/src/app/api/ingest/route.ts b/apps/website/src/app/api/ingest/route.ts index dfc806c71..08d5a89f5 100644 --- a/apps/website/src/app/api/ingest/route.ts +++ b/apps/website/src/app/api/ingest/route.ts @@ -1,6 +1,6 @@ import { PostHog } from 'posthog-node'; import { NextRequest, NextResponse } from 'next/server'; -import { normalizePostHogHost, toSafeAnalyticsString } from '../../../lib/analytics/properties'; +import { normalizePostHogHost, toSafeAnalyticsString } from '@ngaf/telemetry/shared'; const PUBLIC_INGEST_KEY = 'phc_public_cacheplane_telemetry'; diff --git a/apps/website/src/app/api/leads/route.ts b/apps/website/src/app/api/leads/route.ts index 740df5576..128e7478e 100644 --- a/apps/website/src/app/api/leads/route.ts +++ b/apps/website/src/app/api/leads/route.ts @@ -5,7 +5,7 @@ import { sendEmail, FROM, NOTIFY_TO, addToAudience } from '../../../../lib/resen import { loopsUpsertContact, loopsSendEvent } from '../../../../lib/loops'; import { leadNotificationHtml } from '../../../../emails/lead-notification'; import { captureLeadConversion } from '../../../lib/analytics/server'; -import { getSourcePage } from '../../../lib/analytics/properties'; +import { getSourcePage } from '@ngaf/telemetry/shared'; const LEADS_FILE = path.join(process.cwd(), 'data', 'leads.ndjson'); diff --git a/apps/website/src/app/api/newsletter/route.ts b/apps/website/src/app/api/newsletter/route.ts index 1c095a488..cdc085bb3 100644 --- a/apps/website/src/app/api/newsletter/route.ts +++ b/apps/website/src/app/api/newsletter/route.ts @@ -3,7 +3,7 @@ import { sendEmail, FROM, addToAudience } from '../../../../lib/resend'; import { loopsUpsertContact, loopsSendEvent } from '../../../../lib/loops'; import { newsletterWelcomeHtml } from '../../../../emails/newsletter-welcome'; import { captureNewsletterConversion } from '../../../lib/analytics/server'; -import { getSourcePage } from '../../../lib/analytics/properties'; +import { getSourcePage } from '@ngaf/telemetry/shared'; export async function POST(req: NextRequest) { let body: { email?: string }; diff --git a/apps/website/src/app/api/whitepaper-signup/route.ts b/apps/website/src/app/api/whitepaper-signup/route.ts index 276d2a6fb..26821d71e 100644 --- a/apps/website/src/app/api/whitepaper-signup/route.ts +++ b/apps/website/src/app/api/whitepaper-signup/route.ts @@ -9,7 +9,7 @@ import { angularDownloadHtml } from '../../../../emails/angular-download'; import { renderDownloadHtml } from '../../../../emails/render-download'; import { chatDownloadHtml } from '../../../../emails/chat-download'; import { captureWhitepaperConversion } from '../../../lib/analytics/server'; -import { getSourcePage } from '../../../lib/analytics/properties'; +import { getSourcePage } from '@ngaf/telemetry/shared'; const SIGNUPS_FILE = path.join(process.cwd(), 'data', 'whitepaper-signups.ndjson'); diff --git a/apps/website/src/lib/analytics/server.ts b/apps/website/src/lib/analytics/server.ts index 833e8507d..b78538fe5 100644 --- a/apps/website/src/lib/analytics/server.ts +++ b/apps/website/src/lib/analytics/server.ts @@ -1,7 +1,7 @@ import { createHash } from 'crypto'; import { PostHog } from 'posthog-node'; import { analyticsEvents, type AnalyticsEventName, type AnalyticsProperties, type WhitepaperId } from './events'; -import { getEmailDomain, normalizePostHogHost, toSafeAnalyticsString } from './properties'; +import { getEmailDomain, normalizePostHogHost, toSafeAnalyticsString } from '@ngaf/telemetry/shared'; function getServerPostHogClient(): PostHog | null { const token = toSafeAnalyticsString(process.env.NEXT_PUBLIC_POSTHOG_TOKEN, 500); diff --git a/apps/website/tsconfig.json b/apps/website/tsconfig.json index 6bd8e4b02..e2ad8dc5b 100644 --- a/apps/website/tsconfig.json +++ b/apps/website/tsconfig.json @@ -25,7 +25,9 @@ "../../libs/cockpit-registry/src/index.ts" ], "@ngaf/cockpit-shell": ["../../libs/cockpit-shell/src/index.ts"], - "@ngaf/design-tokens": ["../../libs/design-tokens/src/index.ts"] + "@ngaf/design-tokens": ["../../libs/design-tokens/src/index.ts"], + "@ngaf/telemetry/shared": ["../../libs/telemetry/src/shared/public-api.ts"], + "@ngaf/telemetry/browser": ["../../libs/telemetry/src/browser/public-api.ts"] } }, "include": [ From 973b67e4b4042fe8faa7b732622bc55dd3c76a88 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 16 May 2026 08:49:01 -0700 Subject: [PATCH 07/16] refactor(website): import shouldCaptureAnalytics from @ngaf/telemetry/browser instrumentation-client.ts now sources the capture guard from the shared lib. Local properties.ts retains its remaining consumers (the test file itself) until Task 0.5. Co-Authored-By: Claude Opus 4.7 --- apps/website/instrumentation-client.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/website/instrumentation-client.ts b/apps/website/instrumentation-client.ts index abfa07b0d..f6505f015 100644 --- a/apps/website/instrumentation-client.ts +++ b/apps/website/instrumentation-client.ts @@ -1,8 +1,6 @@ import posthog from 'posthog-js'; -import { - normalizePostHogHost, - shouldCaptureAnalytics, -} from './src/lib/analytics/properties'; +import { normalizePostHogHost } from '@ngaf/telemetry/shared'; +import { shouldCaptureAnalytics } from '@ngaf/telemetry/browser'; const token = process.env.NEXT_PUBLIC_POSTHOG_TOKEN; const captureLocal = process.env.NEXT_PUBLIC_POSTHOG_CAPTURE_LOCAL === 'true'; From 6fce5ece87c28a34ff929323dde635102e9824e2 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 16 May 2026 08:57:08 -0700 Subject: [PATCH 08/16] refactor(website): delete duplicated analytics/properties.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All consumers now import from @ngaf/telemetry/{shared,browser}. Tests moved to libs/telemetry in Tasks 0.1 and 0.2. Also migrates apps/website/src/lib/analytics/client.ts (browser-side posthog-js capture helpers) to @ngaf/telemetry/shared — it was missed by the Task 0.3 grep because it uses `from './properties'` rather than `from '../analytics/properties'`. Co-Authored-By: Claude Opus 4.7 --- apps/website/src/lib/analytics/client.ts | 2 +- .../src/lib/analytics/properties.spec.ts | 48 --------------- apps/website/src/lib/analytics/properties.ts | 59 ------------------- 3 files changed, 1 insertion(+), 108 deletions(-) delete mode 100644 apps/website/src/lib/analytics/properties.spec.ts delete mode 100644 apps/website/src/lib/analytics/properties.ts diff --git a/apps/website/src/lib/analytics/client.ts b/apps/website/src/lib/analytics/client.ts index ad403bef1..e5723197a 100644 --- a/apps/website/src/lib/analytics/client.ts +++ b/apps/website/src/lib/analytics/client.ts @@ -2,7 +2,7 @@ import posthog from 'posthog-js'; import { analyticsEvents, type AnalyticsEventName, type AnalyticsProperties } from './events'; -import { getSourcePage, toSafeAnalyticsString } from './properties'; +import { getSourcePage, toSafeAnalyticsString } from '@ngaf/telemetry/shared'; function currentSourcePage(): string { if (typeof window === 'undefined') return '/'; diff --git a/apps/website/src/lib/analytics/properties.spec.ts b/apps/website/src/lib/analytics/properties.spec.ts deleted file mode 100644 index f85e5d16e..000000000 --- a/apps/website/src/lib/analytics/properties.spec.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - getEmailDomain, - getSourcePage, - isLocalAnalyticsHost, - normalizePostHogHost, - shouldCaptureAnalytics, - toSafeAnalyticsString, -} from './properties'; - -describe('analytics properties', () => { - it('extracts a normalized email domain without retaining the address', () => { - expect(getEmailDomain('Jane.Smith@Example.COM ')).toBe('example.com'); - expect(getEmailDomain('not-an-email')).toBeNull(); - expect(getEmailDomain('')).toBeNull(); - }); - - it('truncates safe analytics strings and drops blank values', () => { - expect(toSafeAnalyticsString(' hello ')).toBe('hello'); - expect(toSafeAnalyticsString('abcdef', 3)).toBe('abc'); - expect(toSafeAnalyticsString(' ')).toBeUndefined(); - expect(toSafeAnalyticsString(42)).toBeUndefined(); - }); - - it('normalizes source URLs to path, query, and hash only', () => { - expect(getSourcePage('https://ngaf.example/docs?utm_source=x#intro')).toBe('/docs?utm_source=x#intro'); - expect(getSourcePage('/pricing')).toBe('/pricing'); - expect(getSourcePage('not a url')).toBe('/'); - }); - - it('detects local hosts for opt-in development capture', () => { - expect(isLocalAnalyticsHost('localhost:3000')).toBe(true); - expect(isLocalAnalyticsHost('127.0.0.1:3000')).toBe(true); - expect(isLocalAnalyticsHost('ngaf.example')).toBe(false); - }); - - it('requires a token and skips local capture unless explicitly enabled', () => { - expect(shouldCaptureAnalytics({ token: '', captureLocal: false, host: 'ngaf.example' })).toBe(false); - expect(shouldCaptureAnalytics({ token: 'ph_test', captureLocal: false, host: 'localhost:3000' })).toBe(false); - expect(shouldCaptureAnalytics({ token: 'ph_test', captureLocal: true, host: 'localhost:3000' })).toBe(true); - expect(shouldCaptureAnalytics({ token: 'ph_test', captureLocal: false, host: 'ngaf.example' })).toBe(true); - }); - - it('uses the PostHog US ingest host as the default host', () => { - expect(normalizePostHogHost(undefined)).toBe('https://us.i.posthog.com'); - expect(normalizePostHogHost('https://eu.i.posthog.com/')).toBe('https://eu.i.posthog.com'); - }); -}); diff --git a/apps/website/src/lib/analytics/properties.ts b/apps/website/src/lib/analytics/properties.ts deleted file mode 100644 index ca9866671..000000000 --- a/apps/website/src/lib/analytics/properties.ts +++ /dev/null @@ -1,59 +0,0 @@ -const DEFAULT_POSTHOG_HOST = 'https://us.i.posthog.com'; - -export type CaptureConfig = { - token?: string; - captureLocal?: boolean; - host?: string; -}; - -export function toSafeAnalyticsString(value: unknown, maxLength = 200): string | undefined { - if (typeof value !== 'string') return undefined; - const trimmed = value.trim(); - if (!trimmed) return undefined; - return trimmed.slice(0, maxLength); -} - -export function getEmailDomain(email: unknown): string | null { - const value = toSafeAnalyticsString(email, 320); - if (!value) return null; - - const atIndex = value.lastIndexOf('@'); - if (atIndex <= 0 || atIndex === value.length - 1) return null; - - const domain = value.slice(atIndex + 1).toLowerCase(); - return domain.includes('.') ? domain : null; -} - -export function getSourcePage(value: unknown): string { - const source = toSafeAnalyticsString(value, 2000); - if (!source) return '/'; - - if (source.startsWith('/')) return source; - - try { - const url = new URL(source); - return `${url.pathname}${url.search}${url.hash}` || '/'; - } catch { - return '/'; - } -} - -export function isLocalAnalyticsHost(host: unknown): boolean { - const value = toSafeAnalyticsString(host, 300)?.toLowerCase(); - if (!value) return false; - - const hostname = value.split(':')[0]; - return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1'; -} - -export function shouldCaptureAnalytics({ token, captureLocal = false, host }: CaptureConfig): boolean { - if (!toSafeAnalyticsString(token, 500)) return false; - if (isLocalAnalyticsHost(host) && !captureLocal) return false; - return true; -} - -export function normalizePostHogHost(host: unknown): string { - const value = toSafeAnalyticsString(host, 500); - if (!value) return DEFAULT_POSTHOG_HOST; - return value.endsWith('/') ? value.slice(0, -1) : value; -} From b2f48eabe7a86f2bfa9a8b7ac4f87a73b29a172e Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 16 May 2026 09:06:38 -0700 Subject: [PATCH 09/16] refactor(cockpit): consume shouldCaptureAnalytics from @ngaf/telemetry/browser Drops the cockpit-local properties.ts duplicate in favor of the website's stricter implementation now living in @ngaf/telemetry/browser. Both shells share one capture guard. Co-Authored-By: Claude Opus 4.7 --- apps/cockpit/instrumentation-client.ts | 2 +- .../src/components/analytics-bootstrap.tsx | 2 +- .../src/lib/analytics/properties.spec.ts | 45 ------------------- apps/cockpit/src/lib/analytics/properties.ts | 22 --------- apps/cockpit/tsconfig.json | 4 +- 5 files changed, 5 insertions(+), 70 deletions(-) delete mode 100644 apps/cockpit/src/lib/analytics/properties.spec.ts delete mode 100644 apps/cockpit/src/lib/analytics/properties.ts diff --git a/apps/cockpit/instrumentation-client.ts b/apps/cockpit/instrumentation-client.ts index 3c472d616..b3ebf7235 100644 --- a/apps/cockpit/instrumentation-client.ts +++ b/apps/cockpit/instrumentation-client.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT import posthog from 'posthog-js'; import { getCockpitSessionId } from './src/lib/analytics/distinct-id'; -import { shouldCaptureAnalytics } from './src/lib/analytics/properties'; +import { shouldCaptureAnalytics } from '@ngaf/telemetry/browser'; const token = process.env.NEXT_PUBLIC_COCKPIT_POSTHOG_TOKEN; const captureLocal = process.env.NEXT_PUBLIC_COCKPIT_CAPTURE_LOCAL === 'true'; diff --git a/apps/cockpit/src/components/analytics-bootstrap.tsx b/apps/cockpit/src/components/analytics-bootstrap.tsx index fe449d15b..f7d7441d5 100644 --- a/apps/cockpit/src/components/analytics-bootstrap.tsx +++ b/apps/cockpit/src/components/analytics-bootstrap.tsx @@ -4,7 +4,7 @@ import { useEffect } from 'react'; import posthog from 'posthog-js'; import { getCockpitSessionId } from '../lib/analytics/distinct-id'; -import { shouldCaptureAnalytics } from '../lib/analytics/properties'; +import { shouldCaptureAnalytics } from '@ngaf/telemetry/browser'; /** * Client-side analytics bootstrap. Initializes posthog-js once per diff --git a/apps/cockpit/src/lib/analytics/properties.spec.ts b/apps/cockpit/src/lib/analytics/properties.spec.ts deleted file mode 100644 index 4db94ad9e..000000000 --- a/apps/cockpit/src/lib/analytics/properties.spec.ts +++ /dev/null @@ -1,45 +0,0 @@ -// SPDX-License-Identifier: MIT -import { describe, test, expect } from 'vitest'; -import { shouldCaptureAnalytics } from './properties'; - -describe('shouldCaptureAnalytics', () => { - test('returns false when no token', () => { - expect( - shouldCaptureAnalytics({ - token: undefined, - captureLocal: true, - host: 'cockpit.example.com', - }), - ).toBe(false); - }); - - test('returns false on localhost when captureLocal is false', () => { - expect( - shouldCaptureAnalytics({ - token: 'phc_x', - captureLocal: false, - host: 'localhost:4201', - }), - ).toBe(false); - }); - - test('returns true on localhost when captureLocal is true', () => { - expect( - shouldCaptureAnalytics({ - token: 'phc_x', - captureLocal: true, - host: 'localhost:4201', - }), - ).toBe(true); - }); - - test('returns true on production host', () => { - expect( - shouldCaptureAnalytics({ - token: 'phc_x', - captureLocal: false, - host: 'cockpit.example.com', - }), - ).toBe(true); - }); -}); diff --git a/apps/cockpit/src/lib/analytics/properties.ts b/apps/cockpit/src/lib/analytics/properties.ts deleted file mode 100644 index 3dd1394a0..000000000 --- a/apps/cockpit/src/lib/analytics/properties.ts +++ /dev/null @@ -1,22 +0,0 @@ -// SPDX-License-Identifier: MIT -export interface CaptureGuardInput { - token: string | undefined; - captureLocal: boolean; - host: string | undefined; -} - -export function shouldCaptureAnalytics(input: CaptureGuardInput): boolean { - if (!input.token) return false; - if (!input.captureLocal && isLocalhost(input.host)) return false; - return true; -} - -export function isLocalhost(host: string | undefined): boolean { - if (!host) return false; - return ( - host === 'localhost' || - host.startsWith('localhost:') || - host.startsWith('127.0.0.1') || - host.startsWith('0.0.0.0') - ); -} diff --git a/apps/cockpit/tsconfig.json b/apps/cockpit/tsconfig.json index a4ad6512c..ec30f9d83 100644 --- a/apps/cockpit/tsconfig.json +++ b/apps/cockpit/tsconfig.json @@ -14,7 +14,9 @@ "@/*": ["./src/*"], "@ngaf/design-tokens": ["../../libs/design-tokens/src/index.ts"], "@ngaf/ui-react": ["../../libs/ui-react/src/index.ts"], - "@ngaf/cockpit-registry": ["../../libs/cockpit-registry/src/index.ts"] + "@ngaf/cockpit-registry": ["../../libs/cockpit-registry/src/index.ts"], + "@ngaf/telemetry/shared": ["../../libs/telemetry/src/shared/public-api.ts"], + "@ngaf/telemetry/browser": ["../../libs/telemetry/src/browser/public-api.ts"] } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], From 7c88dad0f16c3aee44ac8cd0503165a07d1d6056 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 16 May 2026 09:14:51 -0700 Subject: [PATCH 10/16] feat(website): add /ingest/* rewrites to posthog proxy (#1D.1) - Export nextConfig as named export for testability - Add async rewrites() for /ingest/static and /ingest API paths - Set skipTrailingSlashRedirect to prevent ad-blocker bypass - Add next.config.spec.ts with rewrite rule validation --- apps/website/next.config.spec.ts | 18 ++++++++++++++++++ apps/website/next.config.ts | 13 ++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 apps/website/next.config.spec.ts diff --git a/apps/website/next.config.spec.ts b/apps/website/next.config.spec.ts new file mode 100644 index 000000000..a31b137c4 --- /dev/null +++ b/apps/website/next.config.spec.ts @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MIT +import { describe, expect, it } from 'vitest'; +import { nextConfig as config } from './next.config'; + +describe('website next.config rewrites', () => { + it('exposes posthog-js rewrites under /ingest', async () => { + expect(typeof config.rewrites).toBe('function'); + const rewrites = await config.rewrites!(); + const list = Array.isArray(rewrites) ? rewrites : rewrites.beforeFiles ?? []; + const sources = list.map((r: { source: string }) => r.source); + expect(sources).toContain('/ingest/static/:path*'); + expect(sources).toContain('/ingest/:path*'); + const staticRule = list.find((r: { source: string }) => r.source === '/ingest/static/:path*'); + expect(staticRule.destination).toBe('https://us-assets.i.posthog.com/static/:path*'); + const apiRule = list.find((r: { source: string }) => r.source === '/ingest/:path*'); + expect(apiRule.destination).toBe('https://us.i.posthog.com/:path*'); + }); +}); diff --git a/apps/website/next.config.ts b/apps/website/next.config.ts index c06375d0b..b67ebc604 100644 --- a/apps/website/next.config.ts +++ b/apps/website/next.config.ts @@ -1,10 +1,21 @@ import { composePlugins, withNx } from '@nx/next'; import type { WithNxOptions } from '@nx/next/plugins/with-nx'; -const nextConfig: WithNxOptions = { +export const nextConfig: WithNxOptions = { // Use this to set Nx-specific options // See: https://nx.dev/recipes/next/next-config-setup nx: {}, + skipTrailingSlashRedirect: true, + rewrites: async () => [ + { + source: '/ingest/static/:path*', + destination: 'https://us-assets.i.posthog.com/static/:path*', + }, + { + source: '/ingest/:path*', + destination: 'https://us.i.posthog.com/:path*', + }, + ], }; const plugins = [ From e28369ca1d9540b82120a18b27fab7fdcd140aa3 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 16 May 2026 09:18:14 -0700 Subject: [PATCH 11/16] feat(website): point posthog-js at /ingest proxy (#1D.2) - Set api_host to '/ingest' for local proxy rewriting - Add ui_host pointing to us.posthog.com dashboard - Remove normalizePostHogHost import (no longer used) --- apps/website/instrumentation-client.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/website/instrumentation-client.ts b/apps/website/instrumentation-client.ts index f6505f015..0d2bf4c6f 100644 --- a/apps/website/instrumentation-client.ts +++ b/apps/website/instrumentation-client.ts @@ -1,5 +1,4 @@ import posthog from 'posthog-js'; -import { normalizePostHogHost } from '@ngaf/telemetry/shared'; import { shouldCaptureAnalytics } from '@ngaf/telemetry/browser'; const token = process.env.NEXT_PUBLIC_POSTHOG_TOKEN; @@ -8,7 +7,8 @@ const browserHost = typeof window === 'undefined' ? undefined : window.location. if (shouldCaptureAnalytics({ token, captureLocal, host: browserHost })) { posthog.init(token!, { - api_host: normalizePostHogHost(process.env.NEXT_PUBLIC_POSTHOG_HOST), + api_host: '/ingest', + ui_host: 'https://us.posthog.com', defaults: '2026-01-30', capture_pageview: true, person_profiles: 'always', From 314f34423aa6b48d7b34b0ad55dedce63d8f33a8 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 16 May 2026 09:37:09 -0700 Subject: [PATCH 12/16] feat(cockpit): /ingest rewrites + CORS for iframe posthog-js Adds Next.js rewrites forwarding /ingest/static/* to us-assets.i.posthog.com and /ingest/* to us.i.posthog.com, plus CORS headers on /ingest/* so the embedded runtime iframe can POST analytics through the cockpit origin. Origin reads NEXT_PUBLIC_COCKPIT_IFRAME_ORIGIN (defaults to *). Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/cockpit/next.config.spec.ts | 32 ++++++++++++++++++++++++++++++++ apps/cockpit/next.config.ts | 27 ++++++++++++++++++++++++++- apps/cockpit/vite.config.mts | 2 +- 3 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 apps/cockpit/next.config.spec.ts diff --git a/apps/cockpit/next.config.spec.ts b/apps/cockpit/next.config.spec.ts new file mode 100644 index 000000000..fcb857dc1 --- /dev/null +++ b/apps/cockpit/next.config.spec.ts @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MIT +import { describe, expect, it } from 'vitest'; +import { nextConfig as config } from './next.config'; + +describe('cockpit next.config', () => { + it('exposes posthog-js rewrites under /ingest', async () => { + expect(typeof config.rewrites).toBe('function'); + const rewrites = await config.rewrites!(); + const list = Array.isArray(rewrites) ? rewrites : rewrites.beforeFiles ?? []; + const sources = list.map((r: { source: string }) => r.source); + expect(sources).toContain('/ingest/static/:path*'); + expect(sources).toContain('/ingest/:path*'); + const staticRule = list.find((r: { source: string }) => r.source === '/ingest/static/:path*'); + expect(staticRule.destination).toBe('https://us-assets.i.posthog.com/static/:path*'); + const apiRule = list.find((r: { source: string }) => r.source === '/ingest/:path*'); + expect(apiRule.destination).toBe('https://us.i.posthog.com/:path*'); + }); + + it('attaches CORS headers to /ingest/* responses', async () => { + expect(typeof config.headers).toBe('function'); + const rules = await config.headers!(); + const ingestRule = rules.find((r: { source: string }) => r.source === '/ingest/:path*'); + expect(ingestRule).toBeDefined(); + const headerKeys = ingestRule.headers.map((h: { key: string }) => h.key); + expect(headerKeys).toContain('Access-Control-Allow-Origin'); + expect(headerKeys).toContain('Access-Control-Allow-Methods'); + expect(headerKeys).toContain('Access-Control-Allow-Headers'); + expect(headerKeys).toContain('Access-Control-Max-Age'); + const methods = ingestRule.headers.find((h: { key: string }) => h.key === 'Access-Control-Allow-Methods'); + expect(methods.value).toBe('POST, OPTIONS'); + }); +}); diff --git a/apps/cockpit/next.config.ts b/apps/cockpit/next.config.ts index b1c108663..2d046d023 100644 --- a/apps/cockpit/next.config.ts +++ b/apps/cockpit/next.config.ts @@ -1,8 +1,33 @@ import { composePlugins, withNx } from '@nx/next'; import type { WithNxOptions } from '@nx/next/plugins/with-nx'; -const nextConfig: WithNxOptions = { +export const nextConfig: WithNxOptions = { nx: {}, + skipTrailingSlashRedirect: true, + rewrites: async () => [ + { + source: '/ingest/static/:path*', + destination: 'https://us-assets.i.posthog.com/static/:path*', + }, + { + source: '/ingest/:path*', + destination: 'https://us.i.posthog.com/:path*', + }, + ], + headers: async () => [ + { + source: '/ingest/:path*', + headers: [ + { + key: 'Access-Control-Allow-Origin', + value: process.env.NEXT_PUBLIC_COCKPIT_IFRAME_ORIGIN ?? '*', + }, + { key: 'Access-Control-Allow-Methods', value: 'POST, OPTIONS' }, + { key: 'Access-Control-Allow-Headers', value: 'Content-Type, Authorization' }, + { key: 'Access-Control-Max-Age', value: '86400' }, + ], + }, + ], }; const plugins = [withNx]; diff --git a/apps/cockpit/vite.config.mts b/apps/cockpit/vite.config.mts index 9007f78f0..e0fb58082 100644 --- a/apps/cockpit/vite.config.mts +++ b/apps/cockpit/vite.config.mts @@ -6,7 +6,7 @@ export default defineConfig({ test: { environment: 'jsdom', globals: true, - include: ['src/**/*.spec.ts', 'src/**/*.spec.tsx'], + include: ['src/**/*.spec.ts', 'src/**/*.spec.tsx', '*.spec.ts'], setupFiles: ['./test-setup.ts'], }, }); From 3acafd8c98691caf645217176b2883aa700fcaa0 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 16 May 2026 09:40:52 -0700 Subject: [PATCH 13/16] feat(cockpit): point posthog-js at /ingest proxy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switches the cockpit's posthog-js api_host to '/ingest' (served by the proxy rewrites from §2.1) and sets ui_host to https://us.posthog.com so toolbar/replay links still resolve. Drops the now-unused NEXT_PUBLIC_COCKPIT_POSTHOG_HOST read from both client init paths. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/cockpit/instrumentation-client.ts | 3 ++- apps/cockpit/src/components/analytics-bootstrap.tsx | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/cockpit/instrumentation-client.ts b/apps/cockpit/instrumentation-client.ts index b3ebf7235..8fb6e6698 100644 --- a/apps/cockpit/instrumentation-client.ts +++ b/apps/cockpit/instrumentation-client.ts @@ -9,7 +9,8 @@ const host = typeof window === 'undefined' ? undefined : window.location.host; if (shouldCaptureAnalytics({ token, captureLocal, host })) { posthog.init(token!, { - api_host: process.env.NEXT_PUBLIC_COCKPIT_POSTHOG_HOST ?? 'https://us.i.posthog.com', + api_host: '/ingest', + ui_host: 'https://us.posthog.com', persistence: 'memory', bootstrap: { distinctID: getCockpitSessionId() }, autocapture: false, diff --git a/apps/cockpit/src/components/analytics-bootstrap.tsx b/apps/cockpit/src/components/analytics-bootstrap.tsx index f7d7441d5..39515b5df 100644 --- a/apps/cockpit/src/components/analytics-bootstrap.tsx +++ b/apps/cockpit/src/components/analytics-bootstrap.tsx @@ -25,7 +25,8 @@ export function AnalyticsBootstrap(): null { return; } posthog.init(token as string, { - api_host: process.env.NEXT_PUBLIC_COCKPIT_POSTHOG_HOST ?? 'https://us.i.posthog.com', + api_host: '/ingest', + ui_host: 'https://us.posthog.com', persistence: 'memory', bootstrap: { distinctID: getCockpitSessionId() }, autocapture: false, From 8598e5aac57e1acd6dbba9133d6d9150bca1a551 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 16 May 2026 09:43:18 -0700 Subject: [PATCH 14/16] feat(cockpit): pass /ingest proxy host to iframe runtime run-mode now defaults cockpit_host to {origin}/ingest so the embedded Angular harness initializes posthog-js against the cockpit-served proxy. NEXT_PUBLIC_COCKPIT_INGEST_HOST overrides for staging/preview origins. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/cockpit/src/components/run-mode/run-mode.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/cockpit/src/components/run-mode/run-mode.tsx b/apps/cockpit/src/components/run-mode/run-mode.tsx index ba9f2ba14..b76f78c4a 100644 --- a/apps/cockpit/src/components/run-mode/run-mode.tsx +++ b/apps/cockpit/src/components/run-mode/run-mode.tsx @@ -17,8 +17,10 @@ function buildIframeSrc(runtimeUrl: string, capabilitySlug: string): string { url.searchParams.set('cockpit_cap', capabilitySlug); const phk = process.env.NEXT_PUBLIC_COCKPIT_POSTHOG_TOKEN; if (phk) url.searchParams.set('cockpit_phk', phk); - const host = process.env.NEXT_PUBLIC_COCKPIT_POSTHOG_HOST; - if (host) url.searchParams.set('cockpit_host', host); + const ingestHost = + process.env.NEXT_PUBLIC_COCKPIT_INGEST_HOST + ?? (typeof window !== 'undefined' ? `${window.location.origin}/ingest` : undefined); + if (ingestHost) url.searchParams.set('cockpit_host', ingestHost); return url.toString(); } From f0540ca7b4b3f6694da485a30f50b870617adbce Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 16 May 2026 09:47:22 -0700 Subject: [PATCH 15/16] docs(env): document NEXT_PUBLIC_COCKPIT_INGEST_HOST + NEXT_PUBLIC_COCKPIT_IFRAME_ORIGIN MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new env vars introduced by Spec 1D: - NEXT_PUBLIC_COCKPIT_INGEST_HOST — absolute URL of the cockpit shell's /ingest proxy, written into the iframe URL's cockpit_host param. - NEXT_PUBLIC_COCKPIT_IFRAME_ORIGIN — CORS-allowed iframe origin for the cockpit's /ingest rewrite. NEXT_PUBLIC_COCKPIT_POSTHOG_HOST is removed — posthog-js now uses the same-origin /ingest rewrite (no host env needed). Co-Authored-By: Claude Opus 4.7 --- .env.example | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index a39dbd2d6..16e79bcf3 100644 --- a/.env.example +++ b/.env.example @@ -16,5 +16,14 @@ POSTHOG_PROJECT_ID= # Cockpit shell analytics (apps/cockpit) NEXT_PUBLIC_COCKPIT_POSTHOG_TOKEN= -NEXT_PUBLIC_COCKPIT_POSTHOG_HOST=https://us.i.posthog.com NEXT_PUBLIC_COCKPIT_CAPTURE_LOCAL=false + +# Cockpit iframe → cockpit-shell /ingest proxy (Spec 1D). +# Production: full absolute URL (e.g. https://cockpit.cacheplane.ai/ingest). +# Leave empty in dev to let RunMode derive it from window.location.origin. +NEXT_PUBLIC_COCKPIT_INGEST_HOST= + +# CORS origin allowed to POST to cockpit's /ingest from iframes (Spec 1D). +# Production: https://examples.cacheplane.ai +# Leave empty in dev — wildcard '*' is used. +NEXT_PUBLIC_COCKPIT_IFRAME_ORIGIN= From 6f522f04931d0882cf9ea94b971fe8ea3953053d Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 16 May 2026 10:03:29 -0700 Subject: [PATCH 16/16] test(telemetry): cover bracketed IPv6 [::1]:port for isLocalAnalyticsHost Implementation already handles this form; test makes the coverage explicit. Co-Authored-By: Claude Opus 4.7 --- libs/telemetry/src/browser/properties.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/libs/telemetry/src/browser/properties.spec.ts b/libs/telemetry/src/browser/properties.spec.ts index e4f2ba248..ade74b1fe 100644 --- a/libs/telemetry/src/browser/properties.spec.ts +++ b/libs/telemetry/src/browser/properties.spec.ts @@ -7,6 +7,7 @@ describe('browser properties', () => { expect(isLocalAnalyticsHost('localhost:3000')).toBe(true); expect(isLocalAnalyticsHost('127.0.0.1:3000')).toBe(true); expect(isLocalAnalyticsHost('::1')).toBe(true); + expect(isLocalAnalyticsHost('[::1]:3000')).toBe(true); expect(isLocalAnalyticsHost('ngaf.example')).toBe(false); expect(isLocalAnalyticsHost(undefined)).toBe(false); });