From 8d2bf0649dd31fc5754d65f031b226ba584a8830 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 16 May 2026 10:32:55 -0700 Subject: [PATCH 1/7] docs(gtm): spec for analytics-foundation-1e (qualified lead + drift guard) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes analytics-foundation. Two deliverables carved out of Spec 1D: - marketing:lead_qualified server-side, fired from /api/leads when the enterprise gate passes (non-personal email domain + non-empty company). Personal-email blocklist lives in @ngaf/telemetry/shared. - Code → taxonomy drift guard (mirror of the existing insights guard). Regex scanner over apps/ + libs/ asserts every fired event name is documented in docs/gtm/taxonomy.md. Co-Authored-By: Claude Opus 4.7 --- ...e-qualified-lead-and-drift-guard-design.md | 290 ++++++++++++++++++ 1 file changed, 290 insertions(+) create mode 100644 docs/superpowers/specs/gtm/2026-05-16-analytics-foundation-1e-qualified-lead-and-drift-guard-design.md diff --git a/docs/superpowers/specs/gtm/2026-05-16-analytics-foundation-1e-qualified-lead-and-drift-guard-design.md b/docs/superpowers/specs/gtm/2026-05-16-analytics-foundation-1e-qualified-lead-and-drift-guard-design.md new file mode 100644 index 000000000..72cca9f2d --- /dev/null +++ b/docs/superpowers/specs/gtm/2026-05-16-analytics-foundation-1e-qualified-lead-and-drift-guard-design.md @@ -0,0 +1,290 @@ +--- +workstream: analytics-foundation-1e-qualified-lead-and-drift-guard +status: approved +owner: brian +phase: 0 +spec: docs/superpowers/specs/gtm/2026-05-16-analytics-foundation-1e-qualified-lead-and-drift-guard-design.md +plan: docs/superpowers/plans/gtm/2026-05-16-analytics-foundation-1e-qualified-lead-and-drift-guard.md +parent: docs/superpowers/specs/gtm/2026-05-13-gtm-meta-design.md +--- + +# Analytics Foundation 1E — Qualified-Lead + Drift Guard (Design) + +> Spec 1E closes analytics-foundation. Two deliverables that were carved out of Spec 1D: the server-side `marketing:lead_qualified` event with a qualification gate, and a CI guard that fails when code fires a PostHog event whose name isn't documented in `docs/gtm/taxonomy.md`. + +## 1. Goal + +1. **Fire `marketing:lead_qualified` server-side** when a lead-form submission passes the enterprise qualification gate (non-personal email domain + non-empty company). The `gtm.md` north-star metric for the enterprise track depends on this event. +2. **Add a code → taxonomy drift guard** — a CI test that scans `apps/` + `libs/` for event-name literals fired via `posthog.capture(...)`, `track(...)`, `captureServerEvent({ event: '...' })`, or `analyticsEvents.`, and asserts every name is documented in `docs/gtm/taxonomy.md`. Mirrors the existing insights → taxonomy guard at `tools/posthog/taxonomy.spec.ts`. + +## 2. Context + +- Parent: `docs/superpowers/specs/gtm/2026-05-13-gtm-meta-design.md`. Spec 1D was scoped down during brainstorming to deliver only the proxies and the `properties.ts` consolidation. The two items here (qualified lead + audit/drift guard) were deliberately deferred to 1E to keep 1D shippable. +- Qualified-lead criteria are defined in `gtm.md` §4: *"Fired on `lead_form_success`; criteria are non-personal `email_domain`, non-empty `company`, and `track=enterprise`."* The existing `LeadForm` lives on enterprise-track pages (pricing/contact), so `track=enterprise` is implicit from form location and stamped as a property rather than collected as a UI field. +- The existing `apps/website/src/app/api/leads/route.ts` already calls `captureLeadConversion` after every successful lead submission, which fires `marketing:lead_form_success`. The new call site for `captureLeadQualified` is one line below it. +- The existing drift guard (`tools/posthog/taxonomy.spec.ts`) only catches the case where a dashboard insight references an event missing from the taxonomy. It does NOT catch the opposite direction (code fires an event not in taxonomy). The Spec 1C rename of `cockpit:recipe_start` → `cockpit:recipe_opened` was only caught because a dashboard insight still referenced the old name. Pure code drift would slip through. +- The `@ngaf/telemetry/shared` subpath gained the analytics helpers in Spec 1D. The personal-email blocklist is a natural addition to that surface. + +## 3. Scope + +**In scope:** + +- **`libs/telemetry/src/shared/personal-email-domains.ts`** — `PERSONAL_EMAIL_DOMAINS: ReadonlySet` (lowercase) plus `isPersonalEmailDomain(domain: string | null | undefined): boolean`. Domains: `gmail.com`, `yahoo.com`, `hotmail.com`, `outlook.com`, `live.com`, `icloud.com`, `me.com`, `mac.com`, `proton.me`, `protonmail.com`, `aol.com`, `gmx.com`, `mail.com`, `yandex.com`, `fastmail.com`, `msn.com`, `qq.com`, `163.com`, `126.com`. Predicate is case-insensitive. +- Spec + test for the predicate. +- Export from `@ngaf/telemetry/shared` (extend `libs/telemetry/src/shared/public-api.ts`). +- **`apps/website/src/lib/analytics/events.ts`** — append `marketingLeadQualified: 'marketing:lead_qualified'` to the `analyticsEvents` const. +- **`apps/website/src/lib/analytics/server.ts`** — new exported `captureLeadQualified({ email, company, sourcePage })` function. Returns early when `getEmailDomain(email)` is null, when `isPersonalEmailDomain(domain)` is true, or when `toSafeAnalyticsString(company, 200)` is undefined. Otherwise fires `marketing:lead_qualified` via `captureServerEvent` with properties `{ email_domain, company, source_page, track: 'enterprise' }`. Uses `getHashedEmailDistinctId(email)` for the distinct id (matches `captureLeadConversion`'s pattern). +- **`apps/website/src/app/api/leads/route.ts`** — one new `await captureLeadQualified({...})` call immediately after the existing `captureLeadConversion` call. Same input fields. +- **`apps/website/src/lib/analytics/server.spec.ts`** (new or extended) — unit tests for `captureLeadQualified` with `captureServerEvent` mocked: personal-domain email → no fire; missing company → no fire; both gates pass → exactly one fire with the right event name + properties. +- **`tools/posthog/code-taxonomy.spec.ts`** (new) — drift scanner. Walks the scan roots enumerated in §5.5. Uses regexes (not AST) to extract event-name literals. Resolves `analyticsEvents.` references back to their literal via the events.ts map. Asserts every discovered name appears in `docs/gtm/taxonomy.md`'s namespaced-event regex. Test target stays `posthog-tools:test`. +- `tools/posthog/code-taxonomy.spec.ts` adheres to the same scan-and-assert shape as the existing `tools/posthog/taxonomy.spec.ts`. Both tests run in the same `posthog-tools:test` invocation. + +**Out of scope:** + +- Adding a `track` field to the `LeadForm` UI. The form's location (pricing/contact pages) implies enterprise. If a developer-track lead form ships later, the qualifier branches need a `track` parameter — call that out as a known follow-up. +- Notifications beyond the PostHog event. Resend + Loops already fire on every lead submission; no `[QUALIFIED]` tag, no Slack webhook in this scope. +- Disposable-email-domain detection (e.g. `disposable-email-domains` npm package) or Clearbit-style enrichment. +- Reverse-direction drift guard (taxonomy → code) — orphaned taxonomy entries are tolerated for staging. +- AST-based scanning. Regex is sufficient to catch >95% of real drift; if false negatives ever bite, we revisit. +- PostHog dashboard tiles / insights for qualified-lead conversion rate. The event needs to fire and accumulate data before a tile is useful; tile work belongs to a later spec. +- Per-event property schema validation. The taxonomy documents what properties an event can carry; this spec doesn't enforce that. + +**Success criteria:** + +- A submitted `LeadForm` with `email=jane@acme.com, company=Acme` produces both `marketing:lead_form_success` and `marketing:lead_qualified` in PostHog. +- The same form with `email=jane@gmail.com, company=Acme` produces only `marketing:lead_form_success` (no qualified). +- The same form with `email=jane@acme.com` and empty company produces only `marketing:lead_form_success`. +- Running `nx run posthog-tools:test` covers both the insights→taxonomy guard (existing) and the new code→taxonomy guard. +- Renaming any event name in code without updating taxonomy.md fails the new guard with a clear diff message. + +## 4. Architecture + +``` +POST /api/leads {name, email, company, message} + │ + ├─ NDJSON write + Resend email + Loops + audience (existing, unchanged) + │ + ├─ await captureLeadConversion({ email, company, sourcePage }) (existing) + │ └─▶ marketing:lead_form_success + │ + └─ await captureLeadQualified({ email, company, sourcePage }) (NEW) + │ + ├─ getEmailDomain(email) → null ─▶ return + ├─ isPersonalEmailDomain(domain) === true ─▶ return + ├─ toSafeAnalyticsString(company, 200) === undefined ─▶ return + │ + └─▶ marketing:lead_qualified + properties: { email_domain, company, source_page, track: 'enterprise' } + distinctId: getHashedEmailDistinctId(email) + +CI guard: + nx run posthog-tools:test + ├─ taxonomy.spec.ts (existing — insights → taxonomy) + └─ code-taxonomy.spec.ts (NEW — code → taxonomy) + ├─ scan apps/website/src + ├─ scan apps/cockpit/src + instrumentation files + ├─ scan libs/ + ├─ resolve analyticsEvents. via events.ts + └─ assert no name missing from taxonomy.md +``` + +## 5. Components + +### 5.1 `libs/telemetry/src/shared/personal-email-domains.ts` (new) + +```typescript +// SPDX-License-Identifier: MIT +export const PERSONAL_EMAIL_DOMAINS: ReadonlySet = new Set([ + 'gmail.com', + 'yahoo.com', + 'hotmail.com', + 'outlook.com', + 'live.com', + 'icloud.com', + 'me.com', + 'mac.com', + 'proton.me', + 'protonmail.com', + 'aol.com', + 'gmx.com', + 'mail.com', + 'yandex.com', + 'fastmail.com', + 'msn.com', + 'qq.com', + '163.com', + '126.com', +]); + +export function isPersonalEmailDomain(domain: string | null | undefined): boolean { + if (!domain) return false; + return PERSONAL_EMAIL_DOMAINS.has(domain.toLowerCase()); +} +``` + +Exported from `libs/telemetry/src/shared/public-api.ts` so `@ngaf/telemetry/shared` exposes both. + +### 5.2 `apps/website/src/lib/analytics/events.ts` (modified) + +```typescript +export const analyticsEvents = { + // ...existing entries... + marketingLeadQualified: 'marketing:lead_qualified', +} as const; +``` + +### 5.3 `apps/website/src/lib/analytics/server.ts` (modified) + +Add an import: + +```typescript +import { isPersonalEmailDomain } from '@ngaf/telemetry/shared'; +``` + +Add a new exported function below `captureLeadConversion`: + +```typescript +export async function captureLeadQualified({ + email, + company, + sourcePage, +}: { + email: string; + company?: string; + sourcePage?: string; +}) { + const domain = getEmailDomain(email); + if (!domain || isPersonalEmailDomain(domain)) return; + + const safeCompany = toSafeAnalyticsString(company, 200); + if (!safeCompany) return; + + const distinctId = getHashedEmailDistinctId(email); + if (!distinctId) return; + + await captureServerEvent({ + distinctId, + event: analyticsEvents.marketingLeadQualified, + properties: { + email_domain: domain, + company: safeCompany, + source_page: sourcePage, + track: 'enterprise', + }, + }); +} +``` + +### 5.4 `apps/website/src/app/api/leads/route.ts` (modified) + +One-line addition immediately after the existing `captureLeadConversion` call: + +```typescript +await captureLeadConversion({ email, company, sourcePage }); +await captureLeadQualified({ email, company, sourcePage }); // NEW +``` + +Update the import at the top: + +```typescript +import { captureLeadConversion, captureLeadQualified } from '../../../lib/analytics/server'; +``` + +### 5.5 `tools/posthog/code-taxonomy.spec.ts` (new) + +The scanner reads files, extracts event-name literals via four regex patterns, and resolves the `analyticsEvents.` references through the events.ts map. + +Patterns: +- `posthog.capture\(\s*['"]([^'"]+)['"]` — direct calls +- `\btrack\(\s*['"]([^'"]+)['"]` — wrapper calls +- `captureServerEvent\(\s*\{\s*[^}]*event:\s*['"]([^'"]+)['"]` — server-side +- `analyticsEvents\.([a-zA-Z]+)` — symbolic refs, resolved via the events map + +The map is loaded by parsing `apps/website/src/lib/analytics/events.ts` (regex-extract the `analyticsEvents = {...}` literal — simpler than running TS). + +Scan roots: +- `apps/website/src` (recursive) +- `apps/website/instrumentation-client.ts` +- `apps/cockpit/src` (recursive) +- `apps/cockpit/instrumentation-client.ts` +- `libs/cockpit-telemetry/src` +- `libs/telemetry/src` (for `@ngaf/telemetry/browser` and node service captures) + +Excludes: +- `*.spec.ts`, `*.spec.tsx` — tests reference event names freely as fixtures +- `.next/`, `dist/`, `node_modules/` + +The test asserts the difference set is empty with a clear message: `Events fired in code but missing from docs/gtm/taxonomy.md:\n`. + +## 6. Data flow + +For a qualified lead: + +1. User submits `LeadForm` with `email=jane@acme.com, company=Acme, message=…`. +2. `POST /api/leads` runs through the existing pipeline (NDJSON, Resend, Loops). +3. `captureLeadConversion` fires `marketing:lead_form_success` with `distinct_id: email_sha256:`. +4. `captureLeadQualified` runs: + - `getEmailDomain('jane@acme.com')` → `'acme.com'`. + - `isPersonalEmailDomain('acme.com')` → `false`. + - `toSafeAnalyticsString('Acme', 200)` → `'Acme'`. + - `getHashedEmailDistinctId('jane@acme.com')` → `email_sha256:`. + - Fires `marketing:lead_qualified` with the four properties. +5. PostHog Live Events shows both events for the same `distinct_id`. The taxonomy is happy. + +For a personal-email lead: + +1. User submits with `email=jane@gmail.com, company=Acme`. +2. Lead form pipeline runs identically up to step 3. +3. `captureLeadQualified` runs: + - `getEmailDomain` → `'gmail.com'`. + - `isPersonalEmailDomain('gmail.com')` → `true`. Return. +4. Only `marketing:lead_form_success` fires. + +For the drift guard: + +1. Developer adds `posthog.capture('marketing:newest_event', {...})` in `apps/website/src/components/foo.tsx` without updating taxonomy.md. +2. `nx run posthog-tools:test` runs. +3. `code-taxonomy.spec.ts` scans the file, finds `marketing:newest_event` not in taxonomy.md, fails the test with: `Events fired in code but missing from docs/gtm/taxonomy.md:\nmarketing:newest_event`. +4. Developer adds the row to taxonomy.md; test passes. + +## 7. Error handling + +- `captureLeadQualified` swallows all errors from `captureServerEvent` (which already does its own try/catch on `posthog-node`). No exceptions bubble out of the route handler. +- Empty / malformed input is handled by the early returns. The function is a no-op when called with bad data. +- The drift scanner reads file contents synchronously via `node:fs/promises.readFile`. If a scan root doesn't exist (e.g. file removed), the test skips it gracefully and only fails when an undocumented event is fired. + +## 8. Testing strategy + +- **Personal-email predicate:** unit tests cover blocklist hits (lowercase + uppercase), explicit non-personal domain, empty / null / undefined inputs. 5–6 assertions. +- **`captureLeadQualified`:** vitest with `captureServerEvent` mocked via `vi.fn()`. Cases: + 1. Personal domain → no call to `captureServerEvent`. + 2. Missing company → no call. + 3. Empty company string → no call. + 4. Both gates pass → exactly one call with the expected event name + properties (`track: 'enterprise'`). + 5. Distinct id pattern matches `email_sha256:<64 hex chars>`. +- **Drift scanner:** smoke test that the test runs cleanly against the current repo (i.e. there's no pre-existing drift after Spec 1D). Plus a synthetic-input table test where the scanner is called against a small in-memory file map — assert it correctly extracts and reports undocumented names. + +## 9. Risks + +- **Regex-based scanner has false negatives.** Event names built from template literals (e.g. `` posthog.capture(`marketing:${kind}_click`) ``) won't be caught. Such patterns are an anti-pattern per taxonomy.md ("Static event names. Vary via properties, not event names.") — flag them in code review. +- **Personal-email blocklist will need maintenance.** New free-mail providers emerge occasionally; each addition is a one-line PR. +- **Implicit `track: 'enterprise'`** — if a future developer-track lead form ships, the qualifier needs a `track` parameter. Documented as a follow-up. +- **The drift guard adds friction to renames.** Any event name change now requires updating taxonomy.md in the same PR. That's the point of the guard, but worth flagging for contributors. + +## 10. Phases + +1. **Phase 0 — Personal-email blocklist.** Create `personal-email-domains.{ts,spec.ts}`, export from `@ngaf/telemetry/shared`. (~3 commits.) +2. **Phase 1 — `captureLeadQualified` + wiring.** Add to `events.ts`, implement in `server.ts`, wire from `/api/leads/route.ts`, unit tests. (~3 commits.) +3. **Phase 2 — Drift guard.** New `tools/posthog/code-taxonomy.spec.ts` + tests. (~2 commits.) +4. **Phase 3 — Verification.** Run the full affected test suite and confirm no drift hits land. (No commit.) + +## 11. Deliverables + +- ☐ `libs/telemetry/src/shared/personal-email-domains.ts` + spec +- ☐ `libs/telemetry/src/shared/public-api.ts` exports the new function + constant +- ☐ `apps/website/src/lib/analytics/events.ts` gains `marketingLeadQualified` +- ☐ `apps/website/src/lib/analytics/server.ts` gains `captureLeadQualified` +- ☐ `apps/website/src/app/api/leads/route.ts` calls `captureLeadQualified` +- ☐ `apps/website/src/lib/analytics/server.spec.ts` (new or extended) covers the qualifier matrix +- ☐ `tools/posthog/code-taxonomy.spec.ts` + passes against current repo +- ☐ `nx run-many -t test -p telemetry,website,posthog-tools` green From b3cbeba30e74a3c7fcb0b4163a71a91f361b5ed2 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 16 May 2026 10:37:40 -0700 Subject: [PATCH 2/7] docs(gtm): implementation plan for analytics-foundation-1e (qualified lead + drift guard) Co-Authored-By: Claude Opus 4.7 --- ...ation-1e-qualified-lead-and-drift-guard.md | 738 ++++++++++++++++++ 1 file changed, 738 insertions(+) create mode 100644 docs/superpowers/plans/gtm/2026-05-16-analytics-foundation-1e-qualified-lead-and-drift-guard.md diff --git a/docs/superpowers/plans/gtm/2026-05-16-analytics-foundation-1e-qualified-lead-and-drift-guard.md b/docs/superpowers/plans/gtm/2026-05-16-analytics-foundation-1e-qualified-lead-and-drift-guard.md new file mode 100644 index 000000000..2ff6f6cf4 --- /dev/null +++ b/docs/superpowers/plans/gtm/2026-05-16-analytics-foundation-1e-qualified-lead-and-drift-guard.md @@ -0,0 +1,738 @@ +# Analytics Foundation 1E — Qualified-Lead + Drift Guard 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:** Fire `marketing:lead_qualified` server-side when a lead submission passes the enterprise gate, and add a CI test that fails when code fires an event name not documented in `docs/gtm/taxonomy.md`. + +**Architecture:** A `PERSONAL_EMAIL_DOMAINS` set and `isPersonalEmailDomain` predicate live in `@ngaf/telemetry/shared`. A `captureLeadQualified` server-side helper in `apps/website/src/lib/analytics/server.ts` reuses the existing posthog-node plumbing and gates on (non-personal domain) + (non-empty company). One new line in `/api/leads/route.ts` calls it after `captureLeadConversion`. The drift guard is a `node:test` script at `tools/posthog/code-taxonomy.spec.ts` that mirrors the existing insights guard's shape — regex over `apps/` + `libs/`, asserts no undocumented event names. + +**Tech Stack:** TypeScript; node:test + node:assert/strict (matches existing `tools/posthog/taxonomy.spec.ts`); Vitest for unit tests in `libs/telemetry` and `apps/website`; `posthog-node` (existing). + +--- + +## Context for the implementer + +- **Spec:** `docs/superpowers/specs/gtm/2026-05-16-analytics-foundation-1e-qualified-lead-and-drift-guard-design.md` — read §§3–6 before starting. +- **`posthog-tools:test` uses node:test, NOT vitest.** The existing `tools/posthog/taxonomy.spec.ts` uses `node:test` and `node:assert/strict`. The new `code-taxonomy.spec.ts` must follow that pattern — the executor is `npx tsx --test tools/posthog/*.spec.ts`. +- **Unit tests inside libs/website use Vitest** (per `apps/website/vite.config.mts` and `libs/telemetry/vite.config.mts`). The captureLeadQualified test goes here. +- **`@ngaf/telemetry/shared`** is the published subpath (Spec 1D). The new `isPersonalEmailDomain` exports from there. +- **`server.ts` already imports from `@ngaf/telemetry/shared`** (post-Spec 1D refactor). Adding the new import is a one-line append. +- **Commit format:** conventional commits, one task = one commit. +- **TDD discipline:** every code-change task writes test → run-see-fail → implement → run-see-pass → commit. +- **Worktree:** plan executes on branch `gtm-spec-1e-qualified-lead-and-drift-guard` (already created from `origin/main` at `465f6d16`). + +## File structure (locked) + +``` +NEW +├── libs/telemetry/src/shared/personal-email-domains.ts # Phase 0 +├── libs/telemetry/src/shared/personal-email-domains.spec.ts # Phase 0 +├── apps/website/src/lib/analytics/server.spec.ts # Phase 1 +├── tools/posthog/code-taxonomy.spec.ts # Phase 2 + +MODIFIED +├── libs/telemetry/src/shared/public-api.ts # Phase 0 — export new symbols +├── apps/website/src/lib/analytics/events.ts # Phase 1 — add marketingLeadQualified +├── apps/website/src/lib/analytics/server.ts # Phase 1 — add captureLeadQualified +├── apps/website/src/app/api/leads/route.ts # Phase 1 — call captureLeadQualified +``` + +No deletions. + +--- + +## Phase 0 — Personal-email blocklist + +### Task 0.1: Create `personal-email-domains.ts` + spec via TDD + +**Files:** +- Create: `libs/telemetry/src/shared/personal-email-domains.ts` +- Create: `libs/telemetry/src/shared/personal-email-domains.spec.ts` + +- [ ] **Step 1: Write the failing test** + +Create `libs/telemetry/src/shared/personal-email-domains.spec.ts`: + +```typescript +// SPDX-License-Identifier: MIT +import { describe, expect, it } from 'vitest'; +import { PERSONAL_EMAIL_DOMAINS, isPersonalEmailDomain } from './personal-email-domains'; + +describe('personal email domains', () => { + it('exposes a non-empty set of well-known free-mail domains', () => { + expect(PERSONAL_EMAIL_DOMAINS.size).toBeGreaterThan(10); + expect(PERSONAL_EMAIL_DOMAINS.has('gmail.com')).toBe(true); + expect(PERSONAL_EMAIL_DOMAINS.has('proton.me')).toBe(true); + }); + + it('returns true for blocklisted domains (case-insensitive)', () => { + expect(isPersonalEmailDomain('gmail.com')).toBe(true); + expect(isPersonalEmailDomain('GMAIL.COM')).toBe(true); + expect(isPersonalEmailDomain('Hotmail.Com')).toBe(true); + expect(isPersonalEmailDomain('proton.me')).toBe(true); + expect(isPersonalEmailDomain('163.com')).toBe(true); + }); + + it('returns false for unknown domains and falsy inputs', () => { + expect(isPersonalEmailDomain('acme.com')).toBe(false); + expect(isPersonalEmailDomain('cacheplane.ai')).toBe(false); + expect(isPersonalEmailDomain('')).toBe(false); + expect(isPersonalEmailDomain(null)).toBe(false); + expect(isPersonalEmailDomain(undefined)).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run, see fail** + +```bash +npx nx run telemetry:test -- --testPathPattern=personal-email-domains.spec +``` + +Expected: fails with `Cannot find module './personal-email-domains'`. + +- [ ] **Step 3: Implement** + +Create `libs/telemetry/src/shared/personal-email-domains.ts`: + +```typescript +// SPDX-License-Identifier: MIT +export const PERSONAL_EMAIL_DOMAINS: ReadonlySet = new Set([ + 'gmail.com', + 'yahoo.com', + 'hotmail.com', + 'outlook.com', + 'live.com', + 'icloud.com', + 'me.com', + 'mac.com', + 'proton.me', + 'protonmail.com', + 'aol.com', + 'gmx.com', + 'mail.com', + 'yandex.com', + 'fastmail.com', + 'msn.com', + 'qq.com', + '163.com', + '126.com', +]); + +export function isPersonalEmailDomain(domain: string | null | undefined): boolean { + if (!domain) return false; + return PERSONAL_EMAIL_DOMAINS.has(domain.toLowerCase()); +} +``` + +- [ ] **Step 4: Run, see pass** + +```bash +npx nx run telemetry:test -- --testPathPattern=personal-email-domains.spec +``` + +Expected: 3 tests passing. + +- [ ] **Step 5: Commit** + +```bash +git add libs/telemetry/src/shared/personal-email-domains.ts libs/telemetry/src/shared/personal-email-domains.spec.ts +git commit -m "$(cat <<'EOF' +feat(telemetry): add PERSONAL_EMAIL_DOMAINS + isPersonalEmailDomain + +19 free-mail domains in a ReadonlySet plus a case-insensitive predicate. +Used by the website's lead-qualification gate to filter out personal +email submissions before firing marketing:lead_qualified. + +Co-Authored-By: Claude Opus 4.7 +EOF +)" +``` + +--- + +### Task 0.2: Export from `@ngaf/telemetry/shared` + +**Files:** +- Modify: `libs/telemetry/src/shared/public-api.ts` + +- [ ] **Step 1: Append exports** + +Open `libs/telemetry/src/shared/public-api.ts` and append: + +```typescript +export { PERSONAL_EMAIL_DOMAINS, isPersonalEmailDomain } from './personal-email-domains'; +``` + +The full file after edit: + +```typescript +// SPDX-License-Identifier: MIT +export type { NgafEvent, NgafNodeEvent, NgafBrowserEvent } from './events'; +export { + getEmailDomain, + getSourcePage, + normalizePostHogHost, + toSafeAnalyticsString, +} from './properties'; +export { PERSONAL_EMAIL_DOMAINS, isPersonalEmailDomain } from './personal-email-domains'; +``` + +- [ ] **Step 2: Build telemetry to confirm the export surface** + +```bash +npx nx run telemetry:build +``` + +Expected: build succeeds; the `@ngaf/telemetry/shared` subpath now resolves `isPersonalEmailDomain`. + +- [ ] **Step 3: Commit** + +```bash +git add libs/telemetry/src/shared/public-api.ts +git commit -m "$(cat <<'EOF' +feat(telemetry): export isPersonalEmailDomain from @ngaf/telemetry/shared + +Both the website (lead-qualification gate, this PR) and any future +consumer can import the blocklist + predicate from the published lib. + +Co-Authored-By: Claude Opus 4.7 +EOF +)" +``` + +--- + +## Phase 1 — `captureLeadQualified` + wiring + +### Task 1.1: Add `marketingLeadQualified` to events.ts + +**Files:** +- Modify: `apps/website/src/lib/analytics/events.ts` + +- [ ] **Step 1: Append the entry** + +Open `apps/website/src/lib/analytics/events.ts`. Locate the `analyticsEvents` object literal. Add `marketingLeadQualified` immediately after `marketingLeadFormFail`: + +```typescript +export const analyticsEvents = { + // ...existing entries unchanged... + marketingLeadFormSubmit: 'marketing:lead_form_submit', + marketingLeadFormSuccess: 'marketing:lead_form_success', + marketingLeadFormFail: 'marketing:lead_form_fail', + marketingLeadQualified: 'marketing:lead_qualified', // NEW + marketingNewsletterSignupSubmit: 'marketing:newsletter_signup_submit', + // ...rest unchanged... +} as const; +``` + +- [ ] **Step 2: Run website tests + build** + +```bash +npx nx run website:build +``` + +Expected: green. (No tests yet — the constant is consumed in Task 1.3.) + +- [ ] **Step 3: Commit** + +```bash +git add apps/website/src/lib/analytics/events.ts +git commit -m "$(cat <<'EOF' +feat(website): add marketingLeadQualified to analyticsEvents map + +Symbolic ref for the new server-side event. Wiring lands in Tasks 1.2 +and 1.3. + +Co-Authored-By: Claude Opus 4.7 +EOF +)" +``` + +--- + +### Task 1.2: TDD: tests for `captureLeadQualified` + +**Files:** +- Create: `apps/website/src/lib/analytics/server.spec.ts` + +- [ ] **Step 1: Write the failing test** + +Create `apps/website/src/lib/analytics/server.spec.ts`: + +```typescript +// SPDX-License-Identifier: MIT +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +const captureMock = vi.hoisted(() => vi.fn()); +vi.mock('posthog-node', () => ({ + PostHog: vi.fn(() => ({ + capture: captureMock, + shutdown: vi.fn().mockResolvedValue(undefined), + })), +})); + +beforeEach(() => { + captureMock.mockClear(); + process.env.NEXT_PUBLIC_POSTHOG_TOKEN = 'phc_test'; +}); + +describe('captureLeadQualified', () => { + it('fires marketing:lead_qualified when domain is non-personal and company is non-empty', async () => { + const { captureLeadQualified } = await import('./server'); + await captureLeadQualified({ + email: 'jane@acme.com', + company: 'Acme', + sourcePage: '/pricing', + }); + expect(captureMock).toHaveBeenCalledTimes(1); + const call = captureMock.mock.calls[0][0]; + expect(call.event).toBe('marketing:lead_qualified'); + expect(call.properties).toMatchObject({ + email_domain: 'acme.com', + company: 'Acme', + source_page: '/pricing', + track: 'enterprise', + }); + expect(call.distinctId).toMatch(/^email_sha256:[a-f0-9]{64}$/); + }); + + it('skips when the email domain is personal', async () => { + const { captureLeadQualified } = await import('./server'); + await captureLeadQualified({ + email: 'jane@gmail.com', + company: 'Acme', + sourcePage: '/pricing', + }); + expect(captureMock).not.toHaveBeenCalled(); + }); + + it('skips when company is missing', async () => { + const { captureLeadQualified } = await import('./server'); + await captureLeadQualified({ + email: 'jane@acme.com', + sourcePage: '/pricing', + }); + expect(captureMock).not.toHaveBeenCalled(); + }); + + it('skips when company is blank string', async () => { + const { captureLeadQualified } = await import('./server'); + await captureLeadQualified({ + email: 'jane@acme.com', + company: ' ', + sourcePage: '/pricing', + }); + expect(captureMock).not.toHaveBeenCalled(); + }); + + it('skips when email is malformed', async () => { + const { captureLeadQualified } = await import('./server'); + await captureLeadQualified({ + email: 'not-an-email', + company: 'Acme', + }); + expect(captureMock).not.toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 2: Run, see fail** + +```bash +cd apps/website && npx vitest run src/lib/analytics/server.spec.ts +``` + +Expected: fails with `captureLeadQualified is not exported` (or similar). + +- [ ] **Step 3: Verify the mock setup works for a known-existing function** + +(Sanity check — skip if comfortable.) Add a temporary test for `captureLeadConversion` to confirm the posthog-node mock is wired correctly: + +```typescript +it('sanity: captureLeadConversion fires marketing:lead_form_success', async () => { + const { captureLeadConversion } = await import('./server'); + await captureLeadConversion({ email: 'jane@acme.com', company: 'Acme' }); + expect(captureMock).toHaveBeenCalled(); +}); +``` + +Run; expected pass. Then DELETE this sanity test before committing. + +- [ ] **Step 4: No commit yet** (the test fails because `captureLeadQualified` doesn't exist — Task 1.3 implements it). Move on. + +--- + +### Task 1.3: Implement `captureLeadQualified` + wire from route + +**Files:** +- Modify: `apps/website/src/lib/analytics/server.ts` +- Modify: `apps/website/src/app/api/leads/route.ts` + +- [ ] **Step 1: Update the import in server.ts** + +Open `apps/website/src/lib/analytics/server.ts`. Find the existing import: + +```typescript +import { getEmailDomain, normalizePostHogHost, toSafeAnalyticsString } from '@ngaf/telemetry/shared'; +``` + +Replace with: + +```typescript +import { getEmailDomain, isPersonalEmailDomain, normalizePostHogHost, toSafeAnalyticsString } from '@ngaf/telemetry/shared'; +``` + +- [ ] **Step 2: Append `captureLeadQualified` to server.ts** + +Add a new exported function below `captureLeadConversion` (and above `captureWhitepaperConversion`): + +```typescript +export async function captureLeadQualified({ + email, + company, + sourcePage, +}: { + email: string; + company?: string; + sourcePage?: string; +}) { + const domain = getEmailDomain(email); + if (!domain || isPersonalEmailDomain(domain)) return; + + const safeCompany = toSafeAnalyticsString(company, 200); + if (!safeCompany) return; + + const distinctId = getHashedEmailDistinctId(email); + if (!distinctId) return; + + await captureServerEvent({ + distinctId, + event: analyticsEvents.marketingLeadQualified, + properties: { + email_domain: domain, + company: safeCompany, + source_page: sourcePage, + track: 'enterprise', + }, + }); +} +``` + +- [ ] **Step 3: Run the spec from Task 1.2 — see pass** + +```bash +cd apps/website && npx vitest run src/lib/analytics/server.spec.ts +``` + +Expected: 5 tests passing. + +- [ ] **Step 4: Wire from `/api/leads/route.ts`** + +Open `apps/website/src/app/api/leads/route.ts`. Find the import: + +```typescript +import { captureLeadConversion } from '../../../lib/analytics/server'; +``` + +Change to: + +```typescript +import { captureLeadConversion, captureLeadQualified } from '../../../lib/analytics/server'; +``` + +Find the existing `captureLeadConversion` call near the bottom of the `POST` handler: + +```typescript +await captureLeadConversion({ email, company, sourcePage }); +``` + +Append a new line immediately after: + +```typescript +await captureLeadConversion({ email, company, sourcePage }); +await captureLeadQualified({ email, company, sourcePage }); +``` + +- [ ] **Step 5: Run website tests + build** + +```bash +npx nx run website:build +cd apps/website && npx vitest run +``` + +Expected: green. + +- [ ] **Step 6: Commit** + +```bash +git add apps/website/src/lib/analytics/server.ts apps/website/src/lib/analytics/server.spec.ts apps/website/src/app/api/leads/route.ts +git commit -m "$(cat <<'EOF' +feat(website): fire marketing:lead_qualified server-side on enterprise leads + +captureLeadQualified gates on getEmailDomain(email) being present, +isPersonalEmailDomain(domain) being false, and toSafeAnalyticsString +(company, 200) being non-empty. When all three pass, fires the event +with properties { email_domain, company, source_page, track: 'enterprise' }. + +Wired from /api/leads/route.ts immediately after captureLeadConversion. +Five unit tests cover the gate matrix. + +Co-Authored-By: Claude Opus 4.7 +EOF +)" +``` + +--- + +## Phase 2 — Drift guard (code → taxonomy) + +### Task 2.1: Create `tools/posthog/code-taxonomy.spec.ts` + +**Files:** +- Create: `tools/posthog/code-taxonomy.spec.ts` + +- [ ] **Step 1: Write the scanner** + +The file is a `node:test` script — same shape as the existing `tools/posthog/taxonomy.spec.ts`. It scans the workspace, extracts event-name literals via four regex patterns, resolves `analyticsEvents.` references, and asserts every name is present in `docs/gtm/taxonomy.md`. + +Create `tools/posthog/code-taxonomy.spec.ts`: + +```typescript +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readdir, readFile, stat } from 'node:fs/promises'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = join(HERE, '..', '..'); +const TAXONOMY_PATH = join(REPO_ROOT, 'docs', 'gtm', 'taxonomy.md'); + +const SCAN_ROOTS = [ + 'apps/website/src', + 'apps/website/instrumentation-client.ts', + 'apps/cockpit/src', + 'apps/cockpit/instrumentation-client.ts', + 'libs/cockpit-telemetry/src', + 'libs/telemetry/src', +]; + +const EVENT_NAME_RE = /^(?:\$pageview|(?:marketing|cockpit|ngaf|docs):[a-z_]+)$/; + +const CAPTURE_PATTERNS: RegExp[] = [ + /posthog\.capture\(\s*['"]([^'"]+)['"]/g, + /\btrack\(\s*['"]([^'"]+)['"]/g, + /captureServerEvent\(\s*\{\s*[^}]*?event:\s*['"]([^'"]+)['"]/gs, +]; + +const ANALYTICS_EVENTS_REF_RE = /analyticsEvents\.([a-zA-Z]+)/g; + +async function walk(path: string, files: string[]): Promise { + let info; + try { + info = await stat(path); + } catch { + return; // missing root — skip + } + if (info.isFile()) { + files.push(path); + return; + } + if (!info.isDirectory()) return; + const entries = await readdir(path, { withFileTypes: true }); + for (const e of entries) { + if (e.name === 'node_modules' || e.name === '.next' || e.name === 'dist') continue; + const full = join(path, e.name); + if (e.isDirectory()) { + await walk(full, files); + } else if ( + (e.name.endsWith('.ts') || e.name.endsWith('.tsx')) && + !e.name.endsWith('.spec.ts') && + !e.name.endsWith('.spec.tsx') + ) { + files.push(full); + } + } +} + +async function loadAnalyticsEventsMap(): Promise> { + const map = new Map(); + const path = join(REPO_ROOT, 'apps', 'website', 'src', 'lib', 'analytics', 'events.ts'); + let body: string; + try { + body = await readFile(path, 'utf8'); + } catch { + return map; + } + // Match "keyName: 'event:name'," inside the analyticsEvents = { ... } block. + const entryRe = /(\w+):\s*['"]([^'"]+)['"]/g; + for (const m of body.matchAll(entryRe)) { + map.set(m[1], m[2]); + } + return map; +} + +test('every event fired in code appears in docs/gtm/taxonomy.md', async () => { + // 1. Collect all candidate source files. + const files: string[] = []; + for (const root of SCAN_ROOTS) { + await walk(join(REPO_ROOT, root), files); + } + + // 2. Load the analyticsEvents map so we can resolve symbolic refs. + const aliasMap = await loadAnalyticsEventsMap(); + + // 3. Scan every file for event name literals + analyticsEvents references. + const referenced = new Set(); + for (const file of files) { + const body = await readFile(file, 'utf8'); + + for (const pattern of CAPTURE_PATTERNS) { + pattern.lastIndex = 0; + for (const m of body.matchAll(pattern)) { + const candidate = m[1]; + if (EVENT_NAME_RE.test(candidate)) referenced.add(candidate); + } + } + + ANALYTICS_EVENTS_REF_RE.lastIndex = 0; + for (const m of body.matchAll(ANALYTICS_EVENTS_REF_RE)) { + const key = m[1]; + const resolved = aliasMap.get(key); + if (resolved && EVENT_NAME_RE.test(resolved)) referenced.add(resolved); + } + } + + // 4. Load taxonomy documented events. + const taxonomy = await readFile(TAXONOMY_PATH, 'utf8'); + const documented = new Set(); + for (const m of taxonomy.matchAll(/`(\$pageview|(?:marketing|cockpit|ngaf|docs):[a-z_]+)`/g)) { + documented.add(m[1]); + } + + // 5. Difference. + const undocumented = [...referenced].filter((e) => !documented.has(e)).sort(); + assert.deepEqual( + undocumented, + [], + `Events fired in code but missing from docs/gtm/taxonomy.md:\n${undocumented.join('\n')}\n\n` + + `Add a row to taxonomy.md (Marketing / Cockpit / ngaf / Docs section as appropriate) ` + + `so the dashboards-as-code guard knows the event is intentional.`, + ); +}); +``` + +- [ ] **Step 2: Run the existing posthog-tools tests + the new one** + +```bash +npx nx run posthog-tools:test +``` + +Expected: both `taxonomy.spec.ts` and `code-taxonomy.spec.ts` pass against the current repo (Spec 1D landed clean; no expected drift). + +- [ ] **Step 3: Sanity — confirm the guard actually fails on a synthetic drift** + +This is a one-shot verification (no commit). Temporarily add a fake call somewhere outside the spec scan exclusions to confirm the test fails: + +```bash +# Add temporary line — DO NOT commit +echo "track('marketing:NEVER_EXISTS', {});" >> apps/website/src/lib/analytics/client.ts +npx nx run posthog-tools:test +``` + +Expected: test fails with `Events fired in code but missing from docs/gtm/taxonomy.md:\nmarketing:NEVER_EXISTS`. + +Revert: + +```bash +git checkout apps/website/src/lib/analytics/client.ts +npx nx run posthog-tools:test +``` + +Expected: green again. + +- [ ] **Step 4: Commit** + +```bash +git add tools/posthog/code-taxonomy.spec.ts +git commit -m "$(cat <<'EOF' +test(posthog): drift guard for code → taxonomy + +New node:test script that scans apps/ + libs/ for event-name literals +fired via posthog.capture(...), track(...), captureServerEvent({event}), +or analyticsEvents.. Asserts every name is documented in +docs/gtm/taxonomy.md. Mirror of the existing insights → taxonomy guard. + +Catches the kind of drift that slipped through during Spec 1C +(cockpit:recipe_start → recipe_opened rename). + +Co-Authored-By: Claude Opus 4.7 +EOF +)" +``` + +--- + +## Phase 3 — Verification + +### Task 3.1: Full test sweep across affected projects (no commit) + +- [ ] **Step 1: Run tests** + +```bash +npx nx run-many -t test -p telemetry,website,posthog-tools +``` + +Expected: all three projects green. + +- [ ] **Step 2: Run builds** + +```bash +npx nx run-many -t build -p telemetry,website +``` + +Expected: green. + +- [ ] **Step 3: Confirm no taxonomy drift** + +```bash +npx nx run posthog-tools:test 2>&1 | grep -E "passed|failed" +``` + +Expected: both `taxonomy.spec.ts` and `code-taxonomy.spec.ts` pass. + +- [ ] **Step 4: Done** + +If all three checks pass, Spec 1E is implementation-complete. Proceed to PR. + +--- + +## Self-Review + +**1. Spec coverage:** + +| Spec deliverable | Task | +|---|---| +| `libs/telemetry/src/shared/personal-email-domains.ts` + spec | 0.1 | +| `libs/telemetry/src/shared/public-api.ts` export | 0.2 | +| `apps/website/src/lib/analytics/events.ts` marketingLeadQualified | 1.1 | +| `apps/website/src/lib/analytics/server.ts` captureLeadQualified | 1.3 | +| `apps/website/src/app/api/leads/route.ts` calls captureLeadQualified | 1.3 | +| Unit tests for captureLeadQualified | 1.2 | +| `tools/posthog/code-taxonomy.spec.ts` | 2.1 | +| Drift guard passes against current repo | 2.1 (Step 2) | +| All affected tests green | 3.1 | + +All deliverables covered. + +**2. Placeholder scan:** No "TBD", no "implement later", no "similar to Task N" without showing the code. Every step has the actual code or command. ✓ + +**3. Type consistency:** + +- `isPersonalEmailDomain(domain: string | null | undefined): boolean` — same signature in Task 0.1 (implementation) and Task 1.3 (consumer). ✓ +- `PERSONAL_EMAIL_DOMAINS: ReadonlySet` — defined in 0.1, exported in 0.2, never re-typed in consumers. ✓ +- `captureLeadQualified({ email, company?, sourcePage? })` — same parameter shape in Task 1.2 test mocks, Task 1.3 implementation, and Task 1.3 route call. ✓ +- `analyticsEvents.marketingLeadQualified` resolves to `'marketing:lead_qualified'` — added in 1.1, consumed in 1.3, scanned in 2.1. ✓ +- The drift guard's `aliasMap` regex matches the `analyticsEvents` literal shape used in `apps/website/src/lib/analytics/events.ts`. ✓ +- `EVENT_NAME_RE` and the taxonomy regex use the same allowed prefixes (`marketing`, `cockpit`, `ngaf`, `docs`) — match `$pageview` literal. ✓ From 68f26e8c45252835860923caf8bc053471e1086e Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 16 May 2026 11:11:37 -0700 Subject: [PATCH 3/7] feat(telemetry): add PERSONAL_EMAIL_DOMAINS + isPersonalEmailDomain 19 free-mail domains in a ReadonlySet plus a case-insensitive predicate. Used by the website's lead-qualification gate to filter out personal email submissions before firing marketing:lead_qualified. Co-Authored-By: Claude Opus 4.7 --- .../src/shared/personal-email-domains.spec.ts | 27 +++++++++++++++++++ .../src/shared/personal-email-domains.ts | 27 +++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 libs/telemetry/src/shared/personal-email-domains.spec.ts create mode 100644 libs/telemetry/src/shared/personal-email-domains.ts diff --git a/libs/telemetry/src/shared/personal-email-domains.spec.ts b/libs/telemetry/src/shared/personal-email-domains.spec.ts new file mode 100644 index 000000000..f0df4d878 --- /dev/null +++ b/libs/telemetry/src/shared/personal-email-domains.spec.ts @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT +import { describe, expect, it } from 'vitest'; +import { PERSONAL_EMAIL_DOMAINS, isPersonalEmailDomain } from './personal-email-domains'; + +describe('personal email domains', () => { + it('exposes a non-empty set of well-known free-mail domains', () => { + expect(PERSONAL_EMAIL_DOMAINS.size).toBeGreaterThan(10); + expect(PERSONAL_EMAIL_DOMAINS.has('gmail.com')).toBe(true); + expect(PERSONAL_EMAIL_DOMAINS.has('proton.me')).toBe(true); + }); + + it('returns true for blocklisted domains (case-insensitive)', () => { + expect(isPersonalEmailDomain('gmail.com')).toBe(true); + expect(isPersonalEmailDomain('GMAIL.COM')).toBe(true); + expect(isPersonalEmailDomain('Hotmail.Com')).toBe(true); + expect(isPersonalEmailDomain('proton.me')).toBe(true); + expect(isPersonalEmailDomain('163.com')).toBe(true); + }); + + it('returns false for unknown domains and falsy inputs', () => { + expect(isPersonalEmailDomain('acme.com')).toBe(false); + expect(isPersonalEmailDomain('cacheplane.ai')).toBe(false); + expect(isPersonalEmailDomain('')).toBe(false); + expect(isPersonalEmailDomain(null)).toBe(false); + expect(isPersonalEmailDomain(undefined)).toBe(false); + }); +}); diff --git a/libs/telemetry/src/shared/personal-email-domains.ts b/libs/telemetry/src/shared/personal-email-domains.ts new file mode 100644 index 000000000..d4a861137 --- /dev/null +++ b/libs/telemetry/src/shared/personal-email-domains.ts @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT +export const PERSONAL_EMAIL_DOMAINS: ReadonlySet = new Set([ + 'gmail.com', + 'yahoo.com', + 'hotmail.com', + 'outlook.com', + 'live.com', + 'icloud.com', + 'me.com', + 'mac.com', + 'proton.me', + 'protonmail.com', + 'aol.com', + 'gmx.com', + 'mail.com', + 'yandex.com', + 'fastmail.com', + 'msn.com', + 'qq.com', + '163.com', + '126.com', +]); + +export function isPersonalEmailDomain(domain: string | null | undefined): boolean { + if (!domain) return false; + return PERSONAL_EMAIL_DOMAINS.has(domain.toLowerCase()); +} From bf7d12ae729a0774f83be34e3985327235b151a5 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 16 May 2026 11:13:51 -0700 Subject: [PATCH 4/7] feat(telemetry): export isPersonalEmailDomain from @ngaf/telemetry/shared Both the website (lead-qualification gate, this PR) and any future consumer can import the blocklist + predicate from the published lib. Co-Authored-By: Claude Opus 4.7 --- libs/telemetry/src/shared/public-api.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/libs/telemetry/src/shared/public-api.ts b/libs/telemetry/src/shared/public-api.ts index b22146d0d..1d39dd693 100644 --- a/libs/telemetry/src/shared/public-api.ts +++ b/libs/telemetry/src/shared/public-api.ts @@ -6,3 +6,4 @@ export { normalizePostHogHost, toSafeAnalyticsString, } from './properties'; +export { PERSONAL_EMAIL_DOMAINS, isPersonalEmailDomain } from './personal-email-domains'; From 1e965bec84128a67da1b4f2989c513b131985a53 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 16 May 2026 11:20:16 -0700 Subject: [PATCH 5/7] feat(website): add marketingLeadQualified to analyticsEvents map Symbolic ref for the new server-side event. Wiring lands in Tasks 1.2 and 1.3. Co-Authored-By: Claude Opus 4.7 --- apps/website/src/lib/analytics/events.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/website/src/lib/analytics/events.ts b/apps/website/src/lib/analytics/events.ts index 62820156c..186958691 100644 --- a/apps/website/src/lib/analytics/events.ts +++ b/apps/website/src/lib/analytics/events.ts @@ -8,6 +8,7 @@ export const analyticsEvents = { marketingLeadFormSubmit: 'marketing:lead_form_submit', marketingLeadFormSuccess: 'marketing:lead_form_success', marketingLeadFormFail: 'marketing:lead_form_fail', + marketingLeadQualified: 'marketing:lead_qualified', marketingNewsletterSignupSubmit: 'marketing:newsletter_signup_submit', marketingNewsletterSignupSuccess: 'marketing:newsletter_signup_success', marketingNewsletterSignupFail: 'marketing:newsletter_signup_fail', From 5fa312eb45f2000114f46e4a3e4d476355efa1fc Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 16 May 2026 12:01:28 -0700 Subject: [PATCH 6/7] feat(website): fire marketing:lead_qualified server-side on enterprise leads captureLeadQualified gates on getEmailDomain(email) being present, isPersonalEmailDomain(domain) being false, and toSafeAnalyticsString(company, 200) being non-empty. When all three pass, fires marketing:lead_qualified with properties { email_domain, company, source_page, track: 'enterprise' }. Wired from /api/leads/route.ts immediately after captureLeadConversion. Five unit tests cover the gate matrix. Co-Authored-By: Claude Opus 4.7 --- apps/website/src/app/api/leads/route.ts | 3 +- apps/website/src/lib/analytics/server.spec.ts | 76 +++++++++++++++++++ apps/website/src/lib/analytics/server.ts | 32 +++++++- 3 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 apps/website/src/lib/analytics/server.spec.ts diff --git a/apps/website/src/app/api/leads/route.ts b/apps/website/src/app/api/leads/route.ts index 128e7478e..b2cab92de 100644 --- a/apps/website/src/app/api/leads/route.ts +++ b/apps/website/src/app/api/leads/route.ts @@ -4,7 +4,7 @@ import path from 'path'; import { sendEmail, FROM, NOTIFY_TO, addToAudience } from '../../../../lib/resend'; import { loopsUpsertContact, loopsSendEvent } from '../../../../lib/loops'; import { leadNotificationHtml } from '../../../../emails/lead-notification'; -import { captureLeadConversion } from '../../../lib/analytics/server'; +import { captureLeadConversion, captureLeadQualified } from '../../../lib/analytics/server'; import { getSourcePage } from '@ngaf/telemetry/shared'; const LEADS_FILE = path.join(process.cwd(), 'data', 'leads.ndjson'); @@ -61,6 +61,7 @@ export async function POST(req: NextRequest) { } await captureLeadConversion({ email, company, sourcePage }); + await captureLeadQualified({ email, company, sourcePage }); return NextResponse.json({ ok: true }); } diff --git a/apps/website/src/lib/analytics/server.spec.ts b/apps/website/src/lib/analytics/server.spec.ts new file mode 100644 index 000000000..33da78d6b --- /dev/null +++ b/apps/website/src/lib/analytics/server.spec.ts @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: MIT +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +const captureMock = vi.hoisted(() => vi.fn()); +vi.mock('posthog-node', () => ({ + PostHog: vi.fn(function () { + return { + capture: captureMock, + shutdown: vi.fn().mockResolvedValue(undefined), + }; + }), +})); + +beforeEach(() => { + captureMock.mockClear(); + process.env.NEXT_PUBLIC_POSTHOG_TOKEN = 'phc_test'; +}); + +describe('captureLeadQualified', () => { + it('fires marketing:lead_qualified when domain is non-personal and company is non-empty', async () => { + const { captureLeadQualified } = await import('./server'); + await captureLeadQualified({ + email: 'jane@acme.com', + company: 'Acme', + sourcePage: '/pricing', + }); + expect(captureMock).toHaveBeenCalledTimes(1); + const call = captureMock.mock.calls[0][0]; + expect(call.event).toBe('marketing:lead_qualified'); + expect(call.properties).toMatchObject({ + email_domain: 'acme.com', + company: 'Acme', + source_page: '/pricing', + track: 'enterprise', + }); + expect(call.distinctId).toMatch(/^email_sha256:[a-f0-9]{64}$/); + }); + + it('skips when the email domain is personal', async () => { + const { captureLeadQualified } = await import('./server'); + await captureLeadQualified({ + email: 'jane@gmail.com', + company: 'Acme', + sourcePage: '/pricing', + }); + expect(captureMock).not.toHaveBeenCalled(); + }); + + it('skips when company is missing', async () => { + const { captureLeadQualified } = await import('./server'); + await captureLeadQualified({ + email: 'jane@acme.com', + sourcePage: '/pricing', + }); + expect(captureMock).not.toHaveBeenCalled(); + }); + + it('skips when company is blank string', async () => { + const { captureLeadQualified } = await import('./server'); + await captureLeadQualified({ + email: 'jane@acme.com', + company: ' ', + sourcePage: '/pricing', + }); + expect(captureMock).not.toHaveBeenCalled(); + }); + + it('skips when email is malformed', async () => { + const { captureLeadQualified } = await import('./server'); + await captureLeadQualified({ + email: 'not-an-email', + company: 'Acme', + }); + expect(captureMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/website/src/lib/analytics/server.ts b/apps/website/src/lib/analytics/server.ts index b78538fe5..0f13c1539 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 '@ngaf/telemetry/shared'; +import { getEmailDomain, isPersonalEmailDomain, normalizePostHogHost, toSafeAnalyticsString } from '@ngaf/telemetry/shared'; function getServerPostHogClient(): PostHog | null { const token = toSafeAnalyticsString(process.env.NEXT_PUBLIC_POSTHOG_TOKEN, 500); @@ -73,6 +73,36 @@ export async function captureLeadConversion({ }); } +export async function captureLeadQualified({ + email, + company, + sourcePage, +}: { + email: string; + company?: string; + sourcePage?: string; +}) { + const domain = getEmailDomain(email); + if (!domain || isPersonalEmailDomain(domain)) return; + + const safeCompany = toSafeAnalyticsString(company, 200); + if (!safeCompany) return; + + const distinctId = getHashedEmailDistinctId(email); + if (!distinctId) return; + + await captureServerEvent({ + distinctId, + event: analyticsEvents.marketingLeadQualified, + properties: { + email_domain: domain, + company: safeCompany, + source_page: sourcePage, + track: 'enterprise', + }, + }); +} + export async function captureWhitepaperConversion({ email, paper, From 6875769dfac7dd54754e42a79fc4f921a2c20b78 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 16 May 2026 12:12:43 -0700 Subject: [PATCH 7/7] =?UTF-8?q?test(posthog):=20drift=20guard=20for=20code?= =?UTF-8?q?=20=E2=86=92=20taxonomy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New node:test script that scans apps/ + libs/ for event-name literals fired via posthog.capture(...), track(...), captureServerEvent({event}), or analyticsEvents.. Asserts every name is documented in docs/gtm/taxonomy.md. Mirror of the existing insights → taxonomy guard. Catches the kind of drift that slipped through during Spec 1C (cockpit:recipe_start → recipe_opened rename). Co-Authored-By: Claude Opus 4.7 --- tools/posthog/code-taxonomy.spec.ts | 116 ++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 tools/posthog/code-taxonomy.spec.ts diff --git a/tools/posthog/code-taxonomy.spec.ts b/tools/posthog/code-taxonomy.spec.ts new file mode 100644 index 000000000..4bd208f86 --- /dev/null +++ b/tools/posthog/code-taxonomy.spec.ts @@ -0,0 +1,116 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readdir, readFile, stat } from 'node:fs/promises'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = join(HERE, '..', '..'); +const TAXONOMY_PATH = join(REPO_ROOT, 'docs', 'gtm', 'taxonomy.md'); + +const SCAN_ROOTS = [ + 'apps/website/src', + 'apps/website/instrumentation-client.ts', + 'apps/cockpit/src', + 'apps/cockpit/instrumentation-client.ts', + 'libs/cockpit-telemetry/src', + 'libs/telemetry/src', +]; + +const EVENT_NAME_RE = /^(?:\$pageview|(?:marketing|cockpit|ngaf|docs):[a-z_]+)$/; + +const CAPTURE_PATTERNS: RegExp[] = [ + /posthog\.capture\(\s*['"]([^'"]+)['"]/g, + /\btrack\(\s*['"]([^'"]+)['"]/g, + /captureServerEvent\(\s*\{\s*[^}]*?event:\s*['"]([^'"]+)['"]/gs, +]; + +const ANALYTICS_EVENTS_REF_RE = /analyticsEvents\.([a-zA-Z]+)/g; + +async function walk(path: string, files: string[]): Promise { + let info; + try { + info = await stat(path); + } catch { + return; + } + if (info.isFile()) { + files.push(path); + return; + } + if (!info.isDirectory()) return; + const entries = await readdir(path, { withFileTypes: true }); + for (const e of entries) { + if (e.name === 'node_modules' || e.name === '.next' || e.name === 'dist') continue; + const full = join(path, e.name); + if (e.isDirectory()) { + await walk(full, files); + } else if ( + (e.name.endsWith('.ts') || e.name.endsWith('.tsx')) && + !e.name.endsWith('.spec.ts') && + !e.name.endsWith('.spec.tsx') + ) { + files.push(full); + } + } +} + +async function loadAnalyticsEventsMap(): Promise> { + const map = new Map(); + const path = join(REPO_ROOT, 'apps', 'website', 'src', 'lib', 'analytics', 'events.ts'); + let body: string; + try { + body = await readFile(path, 'utf8'); + } catch { + return map; + } + const entryRe = /(\w+):\s*['"]([^'"]+)['"]/g; + for (const m of body.matchAll(entryRe)) { + map.set(m[1], m[2]); + } + return map; +} + +test('every event fired in code appears in docs/gtm/taxonomy.md', async () => { + const files: string[] = []; + for (const root of SCAN_ROOTS) { + await walk(join(REPO_ROOT, root), files); + } + + const aliasMap = await loadAnalyticsEventsMap(); + + const referenced = new Set(); + for (const file of files) { + const body = await readFile(file, 'utf8'); + + for (const pattern of CAPTURE_PATTERNS) { + pattern.lastIndex = 0; + for (const m of body.matchAll(pattern)) { + const candidate = m[1]; + if (EVENT_NAME_RE.test(candidate)) referenced.add(candidate); + } + } + + ANALYTICS_EVENTS_REF_RE.lastIndex = 0; + for (const m of body.matchAll(ANALYTICS_EVENTS_REF_RE)) { + const key = m[1]; + const resolved = aliasMap.get(key); + if (resolved && EVENT_NAME_RE.test(resolved)) referenced.add(resolved); + } + } + + const taxonomy = await readFile(TAXONOMY_PATH, 'utf8'); + const documented = new Set(); + for (const m of taxonomy.matchAll(/`(\$pageview|(?:marketing|cockpit|ngaf|docs):[a-z_]+)`/g)) { + documented.add(m[1]); + } + + const undocumented = [...referenced].filter((e) => !documented.has(e)).sort(); + assert.deepEqual( + undocumented, + [], + `Events fired in code but missing from docs/gtm/taxonomy.md:\n${undocumented.join('\n')}\n\n` + + `Add a row to taxonomy.md (Marketing / Cockpit / ngaf / Docs section as appropriate) ` + + `so the dashboards-as-code guard knows the event is intentional.`, + ); +});