diff --git a/apps/website/instrumentation-client.spec.ts b/apps/website/instrumentation-client.spec.ts index 58c2b6ef1..ea7e9d40a 100644 --- a/apps/website/instrumentation-client.spec.ts +++ b/apps/website/instrumentation-client.spec.ts @@ -66,6 +66,43 @@ describe('website posthog init call', () => { expect(initMock).toHaveBeenCalledWith('dummy-token', reloadedOptions); }); + it('disarms the pre-analytics beacon once PostHog has initialized', async () => { + vi.stubEnv('NEXT_PUBLIC_POSTHOG_TOKEN', 'dummy-token'); + vi.stubEnv('NEXT_PUBLIC_POSTHOG_CAPTURE_LOCAL', 'true'); + vi.resetModules(); + const disarm = vi.fn(); + window.__tplanePreAnalytics = { disarm }; + const posthogModule = await import('posthog-js'); + const initMock = vi.mocked(posthogModule.default.init); + initMock.mockClear(); + + await import('./instrumentation-client'); + + expect(initMock).toHaveBeenCalled(); + expect(disarm).toHaveBeenCalledTimes(1); + // After init, not before: until then only the beacon can see a leave. + expect(disarm.mock.invocationCallOrder[0]).toBeGreaterThan(initMock.mock.invocationCallOrder[0]); + delete window.__tplanePreAnalytics; + }); + + it('disarms the pre-analytics beacon when PostHog is not going to run at all', async () => { + // No token: the gate declines, PostHog never initializes here, so the + // beacon must not report for it either. + vi.stubEnv('NEXT_PUBLIC_POSTHOG_TOKEN', ''); + vi.resetModules(); + const disarm = vi.fn(); + window.__tplanePreAnalytics = { disarm }; + const posthogModule = await import('posthog-js'); + const initMock = vi.mocked(posthogModule.default.init); + initMock.mockClear(); + + await import('./instrumentation-client'); + + expect(initMock).not.toHaveBeenCalled(); + expect(disarm).toHaveBeenCalledTimes(1); + delete window.__tplanePreAnalytics; + }); + it('calls posthog.init on a production host, with no capture-local opt-in', async () => { // This exercises the OTHER branch of shouldCaptureAnalytics: a real // deployed host, no NEXT_PUBLIC_POSTHOG_CAPTURE_LOCAL at all. A guard diff --git a/apps/website/instrumentation-client.ts b/apps/website/instrumentation-client.ts index f5ed730b5..6cd82988d 100644 --- a/apps/website/instrumentation-client.ts +++ b/apps/website/instrumentation-client.ts @@ -1,5 +1,8 @@ import posthog, { type PostHogConfig } from 'posthog-js'; import { shouldCaptureAnalytics } from '@threadplane/telemetry/browser'; +// Type-only: brings in the `window.__tplanePreAnalytics` declaration without +// bundling the beacon, which app/layout.tsx already inlines into the HTML. +import type {} from './src/lib/analytics/pre-analytics-beacon'; const token = process.env.NEXT_PUBLIC_POSTHOG_TOKEN; const captureLocal = process.env.NEXT_PUBLIC_POSTHOG_CAPTURE_LOCAL === 'true'; @@ -39,3 +42,10 @@ export const POSTHOG_INIT_OPTIONS = { if (shouldCaptureAnalytics({ token, captureLocal, host: browserHost })) { posthog.init(token!, POSTHOG_INIT_OPTIONS); } + +// Stand down the pre-analytics exit beacon inlined by app/layout.tsx. After +// init, posthog-js owns the session and flushes its own events on unload; when +// the gate above declines, PostHog never runs here and neither should the +// beacon. Either way the two must never both report one visit. See +// src/lib/analytics/pre-analytics-beacon.ts. +if (typeof window !== 'undefined') window.__tplanePreAnalytics?.disarm(); diff --git a/apps/website/src/app/layout.tsx b/apps/website/src/app/layout.tsx index 02263d230..431371c4e 100644 --- a/apps/website/src/app/layout.tsx +++ b/apps/website/src/app/layout.tsx @@ -20,6 +20,7 @@ import { } from '../lib/site-metadata'; import { getFormPolicy } from '../lib/growth/form-policy'; import { WebsiteSignals } from '../components/shared/WebsiteSignals'; +import { PreAnalyticsBeacon } from '../components/shared/PreAnalyticsBeacon'; import { EngagedTimeSignal } from '../components/shared/EngagedTimeSignal'; import { websiteContentCatalog } from '../lib/growth/website-content'; @@ -96,6 +97,12 @@ export default function RootLayout({ className={`${display.variable} ${sans.variable} ${diagram.variable} ${mono.variable}`} > + {/* + First in so it is armed while the HTML is still parsing, before + any bundle loads. It records visitors who leave before PostHog has + initialized; instrumentation-client.ts disarms it once PostHog runs. + */} + {/* Site-wide structured data, mounted once here so it is present on every route. Per-route nodes (BlogPosting, TechArticle) reference the diff --git a/apps/website/src/components/shared/PreAnalyticsBeacon.spec.tsx b/apps/website/src/components/shared/PreAnalyticsBeacon.spec.tsx new file mode 100644 index 000000000..6c7d64f74 --- /dev/null +++ b/apps/website/src/components/shared/PreAnalyticsBeacon.spec.tsx @@ -0,0 +1,23 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { PreAnalyticsBeacon } from './PreAnalyticsBeacon'; + +afterEach(() => vi.unstubAllEnvs()); + +describe('PreAnalyticsBeacon', () => { + it('renders nothing without a PostHog token', () => { + vi.stubEnv('NEXT_PUBLIC_POSTHOG_TOKEN', ''); + expect(renderToStaticMarkup()).toBe(''); + }); + + it('inlines the beacon with the token and the capture-local setting', () => { + vi.stubEnv('NEXT_PUBLIC_POSTHOG_TOKEN', 'phc_test'); + vi.stubEnv('NEXT_PUBLIC_POSTHOG_CAPTURE_LOCAL', 'true'); + const html = renderToStaticMarkup(); + expect(html).toMatch(/^', captureLocal: false }); + expect(script).not.toContain(''); + }); +}); diff --git a/apps/website/src/lib/analytics/pre-analytics-beacon.ts b/apps/website/src/lib/analytics/pre-analytics-beacon.ts new file mode 100644 index 000000000..7a9135dd6 --- /dev/null +++ b/apps/website/src/lib/analytics/pre-analytics-beacon.ts @@ -0,0 +1,155 @@ +/** + * The pre-analytics exit beacon: records visitors who leave before PostHog has + * initialized. + * + * posthog-js arrives in a ~60KB chunk that shares the connection with + * everything else on first load. Measured on production with Lighthouse mobile + * and devtools throttling, it finished downloading at ~4.2s and sent its first + * event — the `$pageview` — at ~4.5s. A visitor on a slow connection who + * leaves before that is never recorded at all, so every PostHog figure for + * short mobile visits (bounce rate, session length) is computed only from + * visitors whose connections were fast enough. This measures that missing + * cohort. + * + * It sends a custom event, deliberately not a `$pageview`: it does not pretend + * to be posthog-js, so it cannot corrupt web-analytics sessions or bounce rate. + * No person profile is created and no IP is kept. + * + * Wiring: app/layout.tsx inlines {@link preAnalyticsBeaconScript} as the first + * thing in , so it is armed before any bundle loads and costs no request. + * instrumentation-client.ts disarms it the moment posthog.init() runs — from + * then on posthog-js owns the session and flushes its own events on unload — + * and also when its own gate declines, so the two can never both report. + */ + +import { analyticsEvents } from './events'; + +export interface PreAnalyticsBeaconConfig { + /** NEXT_PUBLIC_POSTHOG_TOKEN. Public by design; it is in every page's JS. */ + readonly token: string; + /** NEXT_PUBLIC_POSTHOG_CAPTURE_LOCAL === 'true'. */ + readonly captureLocal: boolean; +} + +/** + * Resolved through analyticsEvents so tools/posthog/code-taxonomy.spec.ts sees + * it: that gate finds events by `track(...)` and `analyticsEvents.X`, and would + * otherwise miss a name that only appears inside a JSON body. The literal in + * installPreAnalyticsBeacon below is tested equal to this. + */ +export const PRE_ANALYTICS_EVENT = analyticsEvents.marketingPreAnalyticsExit; + +/** The window property holding the beacon's `disarm()` handle. */ +export const PRE_ANALYTICS_GLOBAL = '__tplanePreAnalytics'; + +export interface PreAnalyticsBeaconHandle { + disarm(): void; +} + +declare global { + interface Window { + __tplanePreAnalytics?: PreAnalyticsBeaconHandle; + } +} + +/** + * Arms the beacon. + * + * MUST be self-contained. It is serialized with Function.prototype.toString + * and inlined into the HTML, so it may reference nothing outside its own body: + * no imports, no module constants, no helpers. That is why the event name, the + * global's name and the local-host rule are written out literally here. The + * spec evaluates the serialized string on its own to enforce this, and a gate + * parity table keeps the local-host rule identical to shouldCaptureAnalytics. + */ +export function installPreAnalyticsBeacon(config: PreAnalyticsBeaconConfig): void { + const w = window as unknown as Record; + if (w.__tplanePreAnalytics) return; + + // Mirror of shouldCaptureAnalytics / isLocalAnalyticsHost in + // @threadplane/telemetry/browser. Keep them identical. + const host = String(location.host || '').toLowerCase(); + const isLocal = + host === '::1' || + host.indexOf('[::1]') === 0 || + host.split(':')[0] === 'localhost' || + host.split(':')[0] === '127.0.0.1'; + if (!config.token || (isLocal && !config.captureLocal)) return; + + let armed = true; + + function send(trigger: 'pagehide' | 'hidden') { + if (!armed) return; + armed = false; + removeListeners(); + const nav = navigator as Navigator & { connection?: { effectiveType?: string } }; + if (typeof nav.sendBeacon !== 'function') return; + let referrerHost: string | null = null; + try { + referrerHost = document.referrer ? new URL(document.referrer).host : null; + } catch { + referrerHost = null; + } + const distinctId = + typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' + ? crypto.randomUUID() + : 'pre-' + Date.now().toString(36) + Math.random().toString(36).slice(2); + const body = JSON.stringify({ + api_key: config.token, + event: 'marketing:pre_analytics_exit', + distinct_id: distinctId, + timestamp: new Date().toISOString(), + properties: { + $process_person_profile: false, + $lib: 'tplane-pre-analytics-beacon', + // Milliseconds since navigation start: how long the visitor stayed. + elapsed_ms: Math.round(performance.now()), + // 'pagehide' is a definite leave. 'hidden' is how a phone usually + // leaves (app switch, tab switch), but the visitor may come back. + trigger: trigger, + // Pathname only: the query string can carry anything a link put there. + source_page: location.pathname, + referrer_host: referrerHost, + viewport_width: window.innerWidth, + // Chromium only; null on Safari and Firefox. + effective_type: (nav.connection && nav.connection.effectiveType) || null, + }, + }); + try { + // Same proxy, content type and IP-less capture posthog-js uses for its + // own unload beacons. + nav.sendBeacon('/ingest/i/v0/e/?ip=0&beacon=1', new Blob([body], { type: 'application/json' })); + } catch { + // Leaving the page must never throw. + } + } + + function onPageHide() { + send('pagehide'); + } + function onVisibilityChange() { + if (document.visibilityState === 'hidden') send('hidden'); + } + function removeListeners() { + window.removeEventListener('pagehide', onPageHide); + document.removeEventListener('visibilitychange', onVisibilityChange); + } + + window.addEventListener('pagehide', onPageHide); + document.addEventListener('visibilitychange', onVisibilityChange); + w.__tplanePreAnalytics = { + disarm: function () { + armed = false; + removeListeners(); + }, + }; +} + +/** + * The inline