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