Skip to content
3 changes: 2 additions & 1 deletion apps/website/src/app/api/leads/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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 });
}
1 change: 1 addition & 0 deletions apps/website/src/lib/analytics/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
76 changes: 76 additions & 0 deletions apps/website/src/lib/analytics/server.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
32 changes: 31 additions & 1 deletion apps/website/src/lib/analytics/server.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading