Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions apps/website/instrumentation-client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions apps/website/instrumentation-client.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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();
7 changes: 7 additions & 0 deletions apps/website/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -96,6 +97,12 @@ export default function RootLayout({
className={`${display.variable} ${sans.variable} ${diagram.variable} ${mono.variable}`}
>
<body>
{/*
First in <body> 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.
*/}
<PreAnalyticsBeacon />
{/*
Site-wide structured data, mounted once here so it is present on every
route. Per-route nodes (BlogPosting, TechArticle) reference the
Expand Down
23 changes: 23 additions & 0 deletions apps/website/src/components/shared/PreAnalyticsBeacon.spec.tsx
Original file line number Diff line number Diff line change
@@ -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(<PreAnalyticsBeacon />)).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(<PreAnalyticsBeacon />);
expect(html).toMatch(/^<script data-pre-analytics-beacon="">/);
expect(html).toContain('"token":"phc_test"');
expect(html).toContain('"captureLocal":true');
// An inline script, not a request: it must not compete for bandwidth.
expect(html).not.toContain('src=');
});
});
21 changes: 21 additions & 0 deletions apps/website/src/components/shared/PreAnalyticsBeacon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { preAnalyticsBeaconScript } from '../../lib/analytics/pre-analytics-beacon';

/**
* Inlines the pre-analytics exit beacon (lib/analytics/pre-analytics-beacon.ts)
* so it is armed while the HTML is still parsing — before any bundle, and
* without a request of its own to compete with the hero poster.
*
* Renders nothing without a PostHog token, matching instrumentation-client.ts,
* which never initializes PostHog without one either.
*/
export function PreAnalyticsBeacon() {
const token = process.env.NEXT_PUBLIC_POSTHOG_TOKEN;
if (!token) return null;
const captureLocal = process.env.NEXT_PUBLIC_POSTHOG_CAPTURE_LOCAL === 'true';
return (
<script
data-pre-analytics-beacon=""
dangerouslySetInnerHTML={{ __html: preAnalyticsBeaconScript({ token, captureLocal }) }}
/>
);
}
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 @@ -29,6 +29,7 @@ export const analyticsEvents = {
marketingAiCrawlerVisit: 'marketing:ai_crawler_visit',
marketingAiReferralVisit: 'marketing:ai_referral_visit',
marketingStageProgress: 'marketing:stage_progress',
marketingPreAnalyticsExit: 'marketing:pre_analytics_exit',
marketingEngagedTime: 'marketing:engaged_time',
} as const;

Expand Down
184 changes: 184 additions & 0 deletions apps/website/src/lib/analytics/pre-analytics-beacon.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { shouldCaptureAnalytics } from '@threadplane/telemetry/browser';
import {
PRE_ANALYTICS_EVENT,
PRE_ANALYTICS_GLOBAL,
installPreAnalyticsBeacon,
preAnalyticsBeaconScript,
type PreAnalyticsBeaconConfig,
} from './pre-analytics-beacon';

const CONFIG: PreAnalyticsBeaconConfig = { token: 'phc_test', captureLocal: false };

const originalLocation = window.location;
let sendBeacon: ReturnType<typeof vi.fn>;

function setLocation(host: string, pathname = '/', search = '') {
const hostname = host.startsWith('[') ? host.slice(0, host.indexOf(']') + 1) : host.split(':')[0];
Object.defineProperty(window, 'location', {
value: { ...originalLocation, host, hostname, pathname, search },
writable: true,
configurable: true,
});
}

function setVisibility(state: 'visible' | 'hidden') {
Object.defineProperty(document, 'visibilityState', { value: state, configurable: true });
document.dispatchEvent(new Event('visibilitychange'));
}

function handle() {
return (window as unknown as Record<string, { disarm(): void } | undefined>)[PRE_ANALYTICS_GLOBAL];
}

async function sentPayload(call = 0) {
const [url, blob] = sendBeacon.mock.calls[call] as [string, Blob];
return { url, type: blob.type, body: JSON.parse(await blob.text()) };
}

beforeEach(() => {
sendBeacon = vi.fn(() => true);
Object.defineProperty(navigator, 'sendBeacon', { value: sendBeacon, configurable: true });
Object.defineProperty(document, 'visibilityState', { value: 'visible', configurable: true });
setLocation('threadplane.ai', '/', '?utm_source=x');
});

afterEach(() => {
handle()?.disarm();
delete (window as unknown as Record<string, unknown>)[PRE_ANALYTICS_GLOBAL];
Object.defineProperty(window, 'location', { value: originalLocation, writable: true, configurable: true });
});

describe('installPreAnalyticsBeacon', () => {
it('sends one event, in the shape posthog-js itself sends, when the page is left', async () => {
installPreAnalyticsBeacon(CONFIG);
window.dispatchEvent(new Event('pagehide'));

expect(sendBeacon).toHaveBeenCalledTimes(1);
const { url, type, body } = await sentPayload();
// Same proxy, same IP-less capture and beacon marker posthog-js uses.
expect(url).toBe('/ingest/i/v0/e/?ip=0&beacon=1');
expect(type).toBe('application/json');
expect(body).toMatchObject({
api_key: 'phc_test',
event: PRE_ANALYTICS_EVENT,
properties: {
$process_person_profile: false,
$lib: 'tplane-pre-analytics-beacon',
trigger: 'pagehide',
viewport_width: window.innerWidth,
},
});
expect(typeof body.distinct_id).toBe('string');
expect(body.distinct_id.length).toBeGreaterThan(8);
expect(typeof body.properties.elapsed_ms).toBe('number');
expect(typeof body.timestamp).toBe('string');
});

it('records the pathname only — never the query string', async () => {
setLocation('threadplane.ai', '/pricing', '?email=someone%40example.com');
installPreAnalyticsBeacon(CONFIG);
window.dispatchEvent(new Event('pagehide'));
const { body } = await sentPayload();
expect(body.properties.source_page).toBe('/pricing');
expect(JSON.stringify(body)).not.toContain('example.com');
});

it('treats a page going hidden as a leave — mobile browsers often never fire pagehide', async () => {
installPreAnalyticsBeacon(CONFIG);
setVisibility('visible');
expect(sendBeacon).not.toHaveBeenCalled();
setVisibility('hidden');
expect(sendBeacon).toHaveBeenCalledTimes(1);
expect((await sentPayload()).body.properties.trigger).toBe('hidden');
});

it('sends at most once per document', () => {
installPreAnalyticsBeacon(CONFIG);
setVisibility('hidden');
window.dispatchEvent(new Event('pagehide'));
setVisibility('hidden');
expect(sendBeacon).toHaveBeenCalledTimes(1);
});

it('never sends once PostHog has taken over and disarmed it', () => {
installPreAnalyticsBeacon(CONFIG);
const installed = handle();
expect(installed).toBeDefined();
installed?.disarm();
window.dispatchEvent(new Event('pagehide'));
setVisibility('hidden');
expect(sendBeacon).not.toHaveBeenCalled();
});

it('is idempotent: a second install does not add a second sender', () => {
installPreAnalyticsBeacon(CONFIG);
installPreAnalyticsBeacon(CONFIG);
window.dispatchEvent(new Event('pagehide'));
expect(sendBeacon).toHaveBeenCalledTimes(1);
});

it('does nothing, and throws nothing, in a browser without sendBeacon', () => {
Object.defineProperty(navigator, 'sendBeacon', { value: undefined, configurable: true });
installPreAnalyticsBeacon(CONFIG);
expect(() => window.dispatchEvent(new Event('pagehide'))).not.toThrow();
});

it('installs nothing without a token', () => {
installPreAnalyticsBeacon({ ...CONFIG, token: '' });
expect(handle()).toBeUndefined();
window.dispatchEvent(new Event('pagehide'));
expect(sendBeacon).not.toHaveBeenCalled();
});
});

describe('the gate matches shouldCaptureAnalytics exactly', () => {
/**
* The beacon must fire exactly where PostHog would capture and nowhere else,
* but it runs before any bundle loads, so it cannot import the real gate. It
* carries its own copy. This table is what keeps the copy honest: if either
* side changes, a row disagrees.
*/
const hosts = [
'threadplane.ai',
'threadplane-git-branch-cacheplane.vercel.app',
'localhost',
'localhost:3000',
'127.0.0.1',
'127.0.0.1:4308',
'[::1]:3000',
'LOCALHOST:3000',
'localhost.example.com',
];
for (const host of hosts) {
for (const captureLocal of [false, true]) {
it(`${host}, captureLocal=${captureLocal}`, () => {
setLocation(host);
installPreAnalyticsBeacon({ token: 'phc_test', captureLocal });
const expected = shouldCaptureAnalytics({ token: 'phc_test', captureLocal, host });
expect(handle() !== undefined).toBe(expected);
});
}
}
});

describe('preAnalyticsBeaconScript', () => {
/**
* The function above is inlined into the HTML through its own source text,
* so it must not reference anything outside its body. Evaluating the exact
* string that ships — rather than calling the imported function — is what
* proves that. A helper or module constant used inside the function would
* pass every test above and throw here.
*/
it('is self-contained: the shipped string works on its own', async () => {
new Function(preAnalyticsBeaconScript(CONFIG))();
window.dispatchEvent(new Event('pagehide'));
expect(sendBeacon).toHaveBeenCalledTimes(1);
expect((await sentPayload()).body.event).toBe(PRE_ANALYTICS_EVENT);
});

it('cannot be broken out of by the config it embeds', () => {
const script = preAnalyticsBeaconScript({ token: '</script><script>alert(1)</script>', captureLocal: false });
expect(script).not.toContain('</script>');
});
});
Loading
Loading