Skip to content

Commit 8e4d5fe

Browse files
bloveclaude
andauthored
feat(gtm): Spec 1E — qualified lead + drift guard (analytics-foundation 1e) (#376)
* docs(gtm): spec for analytics-foundation-1e (qualified lead + drift guard) 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 <[email protected]> * docs(gtm): implementation plan for analytics-foundation-1e (qualified lead + drift guard) Co-Authored-By: Claude Opus 4.7 <[email protected]> * 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 <[email protected]> * 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 <[email protected]> * 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 <[email protected]> * 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 <[email protected]> * 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.<key>. 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 <[email protected]> --------- Co-authored-by: Claude Opus 4.7 <[email protected]>
1 parent d0ecc51 commit 8e4d5fe

10 files changed

Lines changed: 1309 additions & 2 deletions

File tree

apps/website/src/app/api/leads/route.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import path from 'path';
44
import { sendEmail, FROM, NOTIFY_TO, addToAudience } from '../../../../lib/resend';
55
import { loopsUpsertContact, loopsSendEvent } from '../../../../lib/loops';
66
import { leadNotificationHtml } from '../../../../emails/lead-notification';
7-
import { captureLeadConversion } from '../../../lib/analytics/server';
7+
import { captureLeadConversion, captureLeadQualified } from '../../../lib/analytics/server';
88
import { getSourcePage } from '@ngaf/telemetry/shared';
99

1010
const LEADS_FILE = path.join(process.cwd(), 'data', 'leads.ndjson');
@@ -61,6 +61,7 @@ export async function POST(req: NextRequest) {
6161
}
6262

6363
await captureLeadConversion({ email, company, sourcePage });
64+
await captureLeadQualified({ email, company, sourcePage });
6465

6566
return NextResponse.json({ ok: true });
6667
}

apps/website/src/lib/analytics/events.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export const analyticsEvents = {
88
marketingLeadFormSubmit: 'marketing:lead_form_submit',
99
marketingLeadFormSuccess: 'marketing:lead_form_success',
1010
marketingLeadFormFail: 'marketing:lead_form_fail',
11+
marketingLeadQualified: 'marketing:lead_qualified',
1112
marketingNewsletterSignupSubmit: 'marketing:newsletter_signup_submit',
1213
marketingNewsletterSignupSuccess: 'marketing:newsletter_signup_success',
1314
marketingNewsletterSignupFail: 'marketing:newsletter_signup_fail',
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
// SPDX-License-Identifier: MIT
2+
import { describe, expect, it, vi, beforeEach } from 'vitest';
3+
4+
const captureMock = vi.hoisted(() => vi.fn());
5+
vi.mock('posthog-node', () => ({
6+
PostHog: vi.fn(function () {
7+
return {
8+
capture: captureMock,
9+
shutdown: vi.fn().mockResolvedValue(undefined),
10+
};
11+
}),
12+
}));
13+
14+
beforeEach(() => {
15+
captureMock.mockClear();
16+
process.env.NEXT_PUBLIC_POSTHOG_TOKEN = 'phc_test';
17+
});
18+
19+
describe('captureLeadQualified', () => {
20+
it('fires marketing:lead_qualified when domain is non-personal and company is non-empty', async () => {
21+
const { captureLeadQualified } = await import('./server');
22+
await captureLeadQualified({
23+
24+
company: 'Acme',
25+
sourcePage: '/pricing',
26+
});
27+
expect(captureMock).toHaveBeenCalledTimes(1);
28+
const call = captureMock.mock.calls[0][0];
29+
expect(call.event).toBe('marketing:lead_qualified');
30+
expect(call.properties).toMatchObject({
31+
email_domain: 'acme.com',
32+
company: 'Acme',
33+
source_page: '/pricing',
34+
track: 'enterprise',
35+
});
36+
expect(call.distinctId).toMatch(/^email_sha256:[a-f0-9]{64}$/);
37+
});
38+
39+
it('skips when the email domain is personal', async () => {
40+
const { captureLeadQualified } = await import('./server');
41+
await captureLeadQualified({
42+
43+
company: 'Acme',
44+
sourcePage: '/pricing',
45+
});
46+
expect(captureMock).not.toHaveBeenCalled();
47+
});
48+
49+
it('skips when company is missing', async () => {
50+
const { captureLeadQualified } = await import('./server');
51+
await captureLeadQualified({
52+
53+
sourcePage: '/pricing',
54+
});
55+
expect(captureMock).not.toHaveBeenCalled();
56+
});
57+
58+
it('skips when company is blank string', async () => {
59+
const { captureLeadQualified } = await import('./server');
60+
await captureLeadQualified({
61+
62+
company: ' ',
63+
sourcePage: '/pricing',
64+
});
65+
expect(captureMock).not.toHaveBeenCalled();
66+
});
67+
68+
it('skips when email is malformed', async () => {
69+
const { captureLeadQualified } = await import('./server');
70+
await captureLeadQualified({
71+
email: 'not-an-email',
72+
company: 'Acme',
73+
});
74+
expect(captureMock).not.toHaveBeenCalled();
75+
});
76+
});

apps/website/src/lib/analytics/server.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { createHash } from 'crypto';
22
import { PostHog } from 'posthog-node';
33
import { analyticsEvents, type AnalyticsEventName, type AnalyticsProperties, type WhitepaperId } from './events';
4-
import { getEmailDomain, normalizePostHogHost, toSafeAnalyticsString } from '@ngaf/telemetry/shared';
4+
import { getEmailDomain, isPersonalEmailDomain, normalizePostHogHost, toSafeAnalyticsString } from '@ngaf/telemetry/shared';
55

66
function getServerPostHogClient(): PostHog | null {
77
const token = toSafeAnalyticsString(process.env.NEXT_PUBLIC_POSTHOG_TOKEN, 500);
@@ -73,6 +73,36 @@ export async function captureLeadConversion({
7373
});
7474
}
7575

76+
export async function captureLeadQualified({
77+
email,
78+
company,
79+
sourcePage,
80+
}: {
81+
email: string;
82+
company?: string;
83+
sourcePage?: string;
84+
}) {
85+
const domain = getEmailDomain(email);
86+
if (!domain || isPersonalEmailDomain(domain)) return;
87+
88+
const safeCompany = toSafeAnalyticsString(company, 200);
89+
if (!safeCompany) return;
90+
91+
const distinctId = getHashedEmailDistinctId(email);
92+
if (!distinctId) return;
93+
94+
await captureServerEvent({
95+
distinctId,
96+
event: analyticsEvents.marketingLeadQualified,
97+
properties: {
98+
email_domain: domain,
99+
company: safeCompany,
100+
source_page: sourcePage,
101+
track: 'enterprise',
102+
},
103+
});
104+
}
105+
76106
export async function captureWhitepaperConversion({
77107
email,
78108
paper,

0 commit comments

Comments
 (0)