Skip to content

Commit d20eb8f

Browse files
authored
feat(website): add PostHog GTM analytics (#181)
1 parent 37a6158 commit d20eb8f

25 files changed

Lines changed: 1461 additions & 23 deletions

apps/website/.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
# LangGraph Platform URL for the Angular Elements live demo
22
NEXT_PUBLIC_LANGGRAPH_URL=http://localhost:2024
33

4+
# PostHog analytics (https://posthog.com)
5+
NEXT_PUBLIC_POSTHOG_TOKEN=
6+
NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
7+
NEXT_PUBLIC_POSTHOG_CAPTURE_LOCAL=false
8+
49
# Anthropic API key for docs generation scripts
510
ANTHROPIC_API_KEY=your-anthropic-api-key-here
611

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import posthog from 'posthog-js';
2+
import {
3+
normalizePostHogHost,
4+
shouldCaptureAnalytics,
5+
} from './src/lib/analytics/properties';
6+
7+
const token = process.env.NEXT_PUBLIC_POSTHOG_TOKEN;
8+
const captureLocal = process.env.NEXT_PUBLIC_POSTHOG_CAPTURE_LOCAL === 'true';
9+
const browserHost = typeof window === 'undefined' ? undefined : window.location.host;
10+
11+
if (shouldCaptureAnalytics({ token, captureLocal, host: browserHost })) {
12+
posthog.init(token!, {
13+
api_host: normalizePostHogHost(process.env.NEXT_PUBLIC_POSTHOG_HOST),
14+
defaults: '2026-01-30',
15+
});
16+
}

apps/website/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
"class-variance-authority": "^0.7.0",
88
"clsx": "^2.1.1",
99
"next": "~16.1.6",
10+
"posthog-js": "^1.372.6",
11+
"posthog-node": "^5.20.0",
1012
"react": "^19.0.0",
1113
"react-dom": "^19.0.0",
1214
"remark-gfm": "^4.0.1",

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ 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';
8+
import { getSourcePage } from '../../../lib/analytics/properties';
79

810
const LEADS_FILE = path.join(process.cwd(), 'data', 'leads.ndjson');
911

@@ -22,6 +24,7 @@ export async function POST(req: NextRequest) {
2224
}
2325

2426
const ts = new Date().toISOString();
27+
const sourcePage = getSourcePage(req.headers.get('referer'));
2528

2629
// NDJSON backup (always writes, even if Resend fails)
2730
try {
@@ -57,5 +60,7 @@ export async function POST(req: NextRequest) {
5760
console.error('[resend] lead notification failed:', err);
5861
}
5962

63+
await captureLeadConversion({ email, company, sourcePage });
64+
6065
return NextResponse.json({ ok: true });
6166
}

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { NextRequest, NextResponse } from 'next/server';
22
import { sendEmail, FROM, addToAudience } from '../../../../lib/resend';
33
import { loopsUpsertContact, loopsSendEvent } from '../../../../lib/loops';
44
import { newsletterWelcomeHtml } from '../../../../emails/newsletter-welcome';
5+
import { captureNewsletterConversion } from '../../../lib/analytics/server';
6+
import { getSourcePage } from '../../../lib/analytics/properties';
57

68
export async function POST(req: NextRequest) {
79
let body: { email?: string };
@@ -12,6 +14,7 @@ export async function POST(req: NextRequest) {
1214
}
1315

1416
const email = (body.email || '').trim().slice(0, 320);
17+
const sourcePage = getSourcePage(req.headers.get('referer'));
1518

1619
if (!email || !email.includes('@')) {
1720
return NextResponse.json({ error: 'Valid email required' }, { status: 400 });
@@ -40,5 +43,7 @@ export async function POST(req: NextRequest) {
4043
console.error('[resend] newsletter signup failed:', err);
4144
}
4245

46+
await captureNewsletterConversion({ email, sourcePage });
47+
4348
return NextResponse.json({ ok: true });
4449
}

apps/website/src/app/api/whitepaper-signup/route.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import { whitepaperDownloadHtml } from '../../../../emails/whitepaper-download';
88
import { angularDownloadHtml } from '../../../../emails/angular-download';
99
import { renderDownloadHtml } from '../../../../emails/render-download';
1010
import { chatDownloadHtml } from '../../../../emails/chat-download';
11+
import { captureWhitepaperConversion } from '../../../lib/analytics/server';
12+
import { getSourcePage } from '../../../lib/analytics/properties';
1113

1214
const SIGNUPS_FILE = path.join(process.cwd(), 'data', 'whitepaper-signups.ndjson');
1315

@@ -38,6 +40,7 @@ export async function POST(req: NextRequest) {
3840
const name = (body.name || '').trim().slice(0, 200);
3941
const email = (body.email || '').trim().slice(0, 320);
4042
const paper = (VALID_PAPERS.includes(body.paper as PaperId) ? body.paper : 'overview') as PaperId;
43+
const sourcePage = getSourcePage(req.headers.get('referer'));
4144

4245
if (!email || !email.includes('@')) {
4346
return NextResponse.json({ error: 'Valid email required' }, { status: 400 });
@@ -71,5 +74,7 @@ export async function POST(req: NextRequest) {
7174
console.error('[whitepaper-signup] email pipeline failed:', err);
7275
}
7376

77+
await captureWhitepaperConversion({ email, paper, sourcePage });
78+
7479
return NextResponse.json({ ok: true });
7580
}

apps/website/src/components/docs/CopyPromptButton.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
'use client';
22
import { useState } from 'react';
33
import { tokens } from '@ngaf/design-tokens';
4+
import { analyticsEvents } from '../../lib/analytics/events';
5+
import { track } from '../../lib/analytics/client';
46

57
interface Props {
68
prompt: string;
@@ -14,6 +16,10 @@ export function CopyPromptButton({ prompt, variant = 'docs', label }: Props) {
1416
const handleClick = async () => {
1517
try {
1618
await navigator.clipboard.writeText(prompt);
19+
track(analyticsEvents.docsCopyPromptClick, {
20+
surface: variant === 'hero' ? 'home' : 'docs',
21+
cta_id: label ?? 'copy_prompt',
22+
});
1723
setCopied(true);
1824
setTimeout(() => setCopied(false), 2000);
1925
} catch {

apps/website/src/components/docs/DocsSearch.tsx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { useState, useEffect, useRef, useCallback } from 'react';
33
import { useRouter } from 'next/navigation';
44
import { docsConfig, type LibraryId } from '../../lib/docs-config';
55
import { tokens } from '@ngaf/design-tokens';
6+
import { analyticsEvents } from '../../lib/analytics/events';
7+
import { track } from '../../lib/analytics/client';
68

79
interface SearchablePage {
810
title: string;
@@ -60,6 +62,13 @@ export function DocsSearch({ library }: { library?: LibraryId }) {
6062
}, [open]);
6163

6264
const navigate = (page: SearchablePage) => {
65+
track(analyticsEvents.docsSearchResultClick, {
66+
surface: 'docs',
67+
destination_url: `/docs/${page.library}/${page.section}/${page.slug}`,
68+
library: page.library === 'agent' || page.library === 'render' || page.library === 'chat' ? page.library : 'unknown',
69+
query_length: query.length,
70+
result_count: results.length,
71+
});
6372
router.push(`/docs/${page.library}/${page.section}/${page.slug}`);
6473
setOpen(false);
6574
};
@@ -95,7 +104,17 @@ export function DocsSearch({ library }: { library?: LibraryId }) {
95104
<input
96105
ref={inputRef}
97106
value={query}
98-
onChange={(e) => { setQuery(e.target.value); setSelected(0); }}
107+
onChange={(e) => {
108+
const nextQuery = e.target.value;
109+
setQuery(nextQuery);
110+
setSelected(0);
111+
if (nextQuery.length === 1) {
112+
track(analyticsEvents.docsSearchSubmit, {
113+
surface: 'docs',
114+
library: library === 'agent' || library === 'render' || library === 'chat' ? library : 'unknown',
115+
});
116+
}
117+
}}
99118
onKeyDown={handleInputKeyDown}
100119
placeholder="Search documentation..."
101120
style={{

apps/website/src/components/docs/mdx/CodeBlock.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
'use client';
22
import { useRef, useState } from 'react';
3+
import { analyticsEvents } from '../../../lib/analytics/events';
4+
import { track } from '../../../lib/analytics/client';
35

46
function CopyIcon() {
57
return (
@@ -25,6 +27,10 @@ export function Pre({ children, ...props }: React.HTMLAttributes<HTMLPreElement>
2527
const copy = async () => {
2628
const text = ref.current?.textContent ?? '';
2729
await navigator.clipboard.writeText(text);
30+
track(analyticsEvents.docsCopyCodeClick, {
31+
surface: 'docs',
32+
cta_id: 'copy_code',
33+
});
2834
setCopied(true);
2935
setTimeout(() => setCopied(false), 2000);
3036
};

apps/website/src/components/landing/WhitePaperGate.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
import { useState } from 'react';
44
import { motion } from 'framer-motion';
55
import { tokens } from '../../../lib/design-tokens';
6+
import { analyticsEvents } from '../../lib/analytics/events';
7+
import { track } from '../../lib/analytics/client';
68

79
type FormState = 'idle' | 'submitting' | 'done' | 'error';
810

@@ -71,6 +73,11 @@ export function WhitePaperGate() {
7173
? `Role: ${role}${message ? '\n\n' + message : ''}`
7274
: message;
7375

76+
track(analyticsEvents.marketingLeadFormSubmit, {
77+
surface: 'home',
78+
source_section: 'whitepaper-gate',
79+
});
80+
7481
try {
7582
const res = await fetch('/api/leads', {
7683
method: 'POST',
@@ -89,8 +96,17 @@ export function WhitePaperGate() {
8996
throw new Error(data.error ?? 'Server error');
9097
}
9198

99+
track(analyticsEvents.marketingLeadFormSuccess, {
100+
surface: 'home',
101+
source_section: 'whitepaper-gate',
102+
});
92103
setFormState('done');
93104
} catch {
105+
track(analyticsEvents.marketingLeadFormFail, {
106+
surface: 'home',
107+
source_section: 'whitepaper-gate',
108+
error_reason: 'api_error',
109+
});
94110
setFormState('error');
95111
}
96112
};

0 commit comments

Comments
 (0)