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
53 changes: 52 additions & 1 deletion src/analytics/posthog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,17 +24,29 @@ export {
DENIED_EVENT_PROPERTIES,
EVENT_PROPERTIES,
FEEDBACK_EVENT_TYPES,
PAGEVIEW_EVENT,
PAGEVIEW_PROPERTIES,
POSTHOG_INIT_OPTIONS,
PROXY_PATH,
SYNC_FAIL_REASONS,
SYNC_TRIGGERS,
isPublicAnalyticsHost,
mapSyncFailReason,
resolveAnalyticsHost,
sanitizePathname,
sanitizeReferringDomain,
sanitizedPageviewProps,
scrubEventProperties,
syncFailReasonFromHttp,
validateAnalyticsEvent,
} from './posthogModel.mjs';

import {
isPublicAnalyticsHost,
sanitizePathname,
sanitizedPageviewProps,
} from './posthogModel.mjs';

export type SyncTrigger = 'manual' | 'automatic';
export type SyncFailReason = 'network' | 'rate_limited' | 'revoked' | 'server' | 'unknown';
export type AnalyticsRange = '7D' | '30D' | '90D' | 'YTD' | '1Y' | 'ALL' | 'CUSTOM';
Expand Down Expand Up @@ -102,7 +114,19 @@ export async function initAnalytics(): Promise<void> {
property_denylist: [...DENIED_EVENT_PROPERTIES],
before_send: (event: CaptureResult | null) => {
if (!event || !event.properties || typeof event.event !== 'string') return event;
event.properties = scrubEventProperties(event.event, event.properties);
// For $pageview, sanitized standard properties are recomputed from
// the live location — denylist already removed the SDK's raw URL
// fields, so nothing caller-supplied can carry query/hash/referrer
// data into the event.
const pageviewProps =
event.event === '$pageview'
? sanitizedPageviewProps(window.location, document.referrer)
: undefined;
event.properties = scrubEventProperties(
event.event,
event.properties,
pageviewProps,
);
return event;
},
});
Expand All @@ -129,3 +153,30 @@ export function captureEvent<E extends AnalyticsEventName>(
/* analytics is best-effort — never surface to the app */
}
}

// ── Sanitized manual $pageview ─────────────────────────────────────────────
//
// Automatic pageview capture stays OFF (capture_pageview: false) — the SDK
// would stamp raw URL/referrer fields. This is the ONLY way a $pageview is
// emitted: no caller properties, no raw location data. The sanitized
// $current_url/$pathname/$host/$referring_domain are recomputed inside
// before_send from the live location, so this function cannot be used to
// smuggle arbitrary values.

// Dedupe consecutive views of the same sanitized pathname — protects the
// initial emit against React StrictMode remounts and keeps hash/query-only
// pushState entries (?range=, section anchors) from counting as pageviews.
let lastPageviewPath: string | null = null;

export function capturePageview(): void {
if (!client || typeof window === 'undefined') return;
if (!isPublicAnalyticsHost(window.location.host)) return;
const pathname = sanitizePathname(window.location.pathname);
if (pathname === lastPageviewPath) return;
lastPageviewPath = pathname;
try {
client.capture('$pageview');
} catch {
/* analytics is best-effort — never surface to the app */
}
}
87 changes: 86 additions & 1 deletion src/analytics/posthogModel.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ export const REQUIRED_SDK_EVENT_PROPERTIES = ['token', 'distinct_id'];
// non-$ key outside the emitting event's own schema, while preserving the
// transport-critical SDK fields above. This keeps the privacy allowlist
// narrow without corrupting the event envelope that PostHog must ingest.
export function scrubEventProperties(eventName, properties) {
export function scrubEventProperties(eventName, properties, pageviewProps) {
const schema = EVENT_PROPERTIES[eventName];
const allowedCustom = schema ? new Set(Object.keys(schema)) : new Set();
const requiredSdk = new Set(REQUIRED_SDK_EVENT_PROPERTIES);
Expand All @@ -188,8 +188,93 @@ export function scrubEventProperties(eventName, properties) {
if (!key.startsWith('$') && !allowedCustom.has(key) && !requiredSdk.has(key)) continue;
clean[key] = value;
}
// $pageview re-attaches ONLY the sanitized property set, recomputed from
// the live location by the facade — property_denylist has already removed
// the SDK's raw URL/referrer fields, and caller-supplied values can never
// reach this point.
if (eventName === PAGEVIEW_EVENT && pageviewProps) {
Object.assign(clean, pageviewProps);
}
return clean;
}

// ── Sanitized page identity (manual $pageview) ─────────────────────────────
//
// Web Analytics needs $current_url/$pathname on $pageview events. Automatic
// capture stays OFF — the SDK would stamp the raw location (query, hash,
// referrer) before our scrubber could clean it. Instead the facade emits
// $pageview with NO caller properties; property_denylist strips every
// URL-derived field the SDK adds, and before_send re-attaches ONLY the
// sanitized set below, computed from window.location at send time.

export const PAGEVIEW_EVENT = '$pageview';

// The only standard properties a $pageview may carry — all sanitized here.
export const PAGEVIEW_PROPERTIES = [
'$current_url',
'$pathname',
'$host',
'$referring_domain',
];

// Canonical analytics pathname: strips query/hash implicitly (caller passes
// location.pathname), collapses trailing slashes to the canonical non-slash
// form used by canonicals/sitemap, and refuses anything that is not a plain
// path — a hostile or malformed value degrades to '/'.
export function sanitizePathname(pathname) {
if (typeof pathname !== 'string' || !pathname.startsWith('/')) return '/';
if (/[?#]/.test(pathname)) return '/';
const clean = pathname.replace(/\/+$/, '') || '/';
// Only allow boring URL-path characters — percent-encoding, whitespace or
// anything exotic collapses to the root page.
if (!/^\/[A-Za-z0-9\-._~/]*$/.test(clean)) return '/';
return clean;
}

// Web Analytics pageviews are only meaningful for the public production
// surface. Internal/preview origins (e.g. *.vercel.app deployment URLs)
// must not create traffic records — only the canonical domain and local
// dev hosts emit.
export function isPublicAnalyticsHost(host) {
return (
host === 'devledger.site' ||
host === 'localhost' ||
host === '127.0.0.1' ||
host === '::1' ||
host.startsWith('localhost:') ||
host.startsWith('127.0.0.1:')
);
}

// Referrer reduced to its registrable hostname only — never a URL, path or
// query. Self-referrals and anything unparseable become ''.
export function sanitizeReferringDomain(referrer, ownHost) {
if (typeof referrer !== 'string' || !referrer) return '';
try {
const host = new URL(referrer).host;
if (!host || host === ownHost) return '';
if (!/^[A-Za-z0-9.-]+$/.test(host)) return '';
return host;
} catch {
return '';
}
}

// The complete sanitized $pageview property set, derived from a
// location-like {origin, pathname, host} and a referrer string. This is the
// ONLY place those values are built — tests pin the exact shape.
export function sanitizedPageviewProps(locationLike, referrer) {
const pathname = sanitizePathname(locationLike?.pathname);
const host = typeof locationLike?.host === 'string' ? locationLike.host : '';
const origin = typeof locationLike?.origin === 'string' ? locationLike.origin : '';
return {
$current_url: origin + pathname,
$pathname: pathname,
$host: host,
$referring_domain: sanitizeReferringDomain(referrer, host),
};
}

// ── SDK initialization options ─────────────────────────────────────────────

// Every automatic collector explicitly OFF — custom allowlisted events only.
Expand Down
8 changes: 5 additions & 3 deletions src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import "@fontsource-variable/newsreader/opsz.css";
import "@fontsource-variable/newsreader/opsz-italic.css";
import "./index.css";
import App from "./App";
import { initAnalytics, captureEvent } from "./analytics/posthog";
import { initAnalytics, captureEvent, capturePageview } from "./analytics/posthog";
import { consumeLoginPending } from "./analytics/loginFunnel";
import { SecurityPrivacyPage } from "./security/SecurityPrivacyPage";
import { PrivacyPolicyPage } from "./legal/PrivacyPolicyPage";
Expand Down Expand Up @@ -170,8 +170,10 @@ function Root() {

// PostHog product analytics — initialized once at boot. Lazy-loaded and
// no-ops without a configured token; sends only allowlisted custom events
// through the same-origin /rly proxy.
initAnalytics();
// through the same-origin /rly proxy. The initial sanitized $pageview
// emits once the SDK is ready; client-side route changes emit from
// useRoute, deduped on pathname.
void initAnalytics().then(() => capturePageview()).catch(() => {});

createRoot(document.getElementById("root")!).render(
<StrictMode>
Expand Down
3 changes: 3 additions & 0 deletions src/pages.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from 'react';
import { capturePageview } from './analytics/posthog.ts';

/* Canonical three-page registry — the entire authenticated app is exactly
OVERVIEW / ACTIVITY / CODE. Header nav, routing and the right rail all
Expand Down Expand Up @@ -35,6 +36,7 @@ export function useRoute(): { page: PageId; navigate: (page: PageId) => void } {
useEffect(() => {
const onPop = () => {
const next = pageFromPath(window.location.pathname);
capturePageview();
setPage(next);
// Section-rail hash jumps land on their anchor; everything else tops.
const el = window.location.hash
Expand All @@ -57,6 +59,7 @@ export function useRoute(): { page: PageId; navigate: (page: PageId) => void } {
history.pushState({ page: next }, '', pathForPage(next) + window.location.search);
window.scrollTo(0, 0);
setPage(next);
capturePageview();
},
[page],
);
Expand Down
115 changes: 114 additions & 1 deletion test/posthog-analytics.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ import { fileURLToPath } from 'node:url';
import {
ANALYTICS_RANGES,
DENIED_EVENT_PROPERTIES,
PAGEVIEW_PROPERTIES,
isPublicAnalyticsHost,
sanitizePathname,
sanitizeReferringDomain,
sanitizedPageviewProps,
EVENT_PROPERTIES,
FEEDBACK_EVENT_TYPES,
POSTHOG_INIT_OPTIONS,
Expand Down Expand Up @@ -272,7 +277,7 @@ test('init wires api_host, property_denylist and before_send from the model', ()
assert.match(facade, /api_host: apiHost/);
assert.match(facade, /property_denylist: \[\.\.\.DENIED_EVENT_PROPERTIES\]/);
assert.match(facade, /before_send:/);
assert.match(facade, /scrubEventProperties\(event\.event, event\.properties\)/);
assert.match(facade, /scrubEventProperties\([\s\S]*?event\.event[\s\S]*?event\.properties/);
assert.match(facade, /\.\.\.\s*\(?\s*POSTHOG_INIT_OPTIONS/);
assert.match(facade, /import\.meta\.env\.VITE_POSTHOG_PROJECT_TOKEN/);
});
Expand Down Expand Up @@ -390,6 +395,114 @@ test('captureEvent validates before sending and never throws without a client',
assert.doesNotMatch(facade, /sessionRecording|startSessionRecording/i);
});

/* ── Sanitized manual $pageview ── */

test('$pageview is not a caller-emittable custom event', () => {
assert.ok(!('$pageview' in EVENT_PROPERTIES), '$pageview must not join the custom allowlist');
assert.equal(validateAnalyticsEvent('$pageview', {}), null);
assert.equal(validateAnalyticsEvent('$pageview', { $current_url: 'x' }), null);
const facade = src('src/analytics/posthog.ts');
assert.match(facade, /client\.capture\('\$pageview'\)/, 'pageview emits with zero caller properties');
assert.match(facade, /export function capturePageview\(\)/, 'dedicated pageview entry point');
});

test('sanitizePathname strips query, fragment and trailing slashes', () => {
assert.equal(sanitizePathname('/blog/foo'), '/blog/foo');
assert.equal(sanitizePathname('/blog/foo/'), '/blog/foo');
assert.equal(sanitizePathname('/blog/'), '/blog');
assert.equal(sanitizePathname('/'), '/');
assert.equal(sanitizePathname('/overview'), '/overview');
// pathname input never carries ?/# — but hostile input is refused anyway
assert.equal(sanitizePathname('/blog/foo?token=SECRET'), '/');
assert.equal(sanitizePathname('/x#frag'), '/');
assert.equal(sanitizePathname('/bad path'), '/');
assert.equal(sanitizePathname('/%2e%2e/'), '/');
assert.equal(sanitizePathname('https://evil.example/x'), '/');
assert.equal(sanitizePathname(''), '/');
assert.equal(sanitizePathname(undefined), '/');
});

test('sanitizedPageviewProps produces the exact allowed shape', () => {
const loc = {
origin: 'https://devledger.site',
host: 'devledger.site',
pathname: '/blog/what-is-code-churn',
};
assert.deepEqual(sanitizedPageviewProps(loc, ''), {
$current_url: 'https://devledger.site/blog/what-is-code-churn',
$pathname: '/blog/what-is-code-churn',
$host: 'devledger.site',
$referring_domain: '',
});
// A hostile referrer reduces to a bare domain, never a URL.
const withRef = sanitizedPageviewProps(loc, 'https://google.com/a/path?user=x&campaign=y');
assert.equal(withRef.$referring_domain, 'google.com');
const keys = Object.keys(withRef).sort();
assert.deepEqual(keys, [...PAGEVIEW_PROPERTIES].sort());
});

test('referrer never leaks path/query — self-referral yields empty domain', () => {
assert.equal(sanitizeReferringDomain('https://github.com/Aliferous3/dev-ledger', 'devledger.site'), 'github.com');
assert.equal(sanitizeReferringDomain('https://devledger.site/blog', 'devledger.site'), '');
assert.equal(sanitizeReferringDomain('not a url', 'devledger.site'), '');
assert.equal(sanitizeReferringDomain('', 'devledger.site'), '');
});

test('internal origins never emit pageviews', () => {
assert.equal(isPublicAnalyticsHost('devledger.site'), true);
assert.equal(isPublicAnalyticsHost('dev-ledger-site.vercel.app'), false);
assert.equal(isPublicAnalyticsHost('dev-ledger-abc123.vercel.app'), false);
assert.equal(isPublicAnalyticsHost('localhost:4173'), true);
assert.equal(isPublicAnalyticsHost('localhost'), true);
assert.equal(isPublicAnalyticsHost('127.0.0.1:5173'), true);
});

test('before_send $pageview path re-attaches only sanitized props after denylist', () => {
// Simulates the pipeline: denylist already removed the SDK's raw fields;
// before_send merges caller leftovers through the scrubber and attaches
// the recomputed sanitized set.
const dirty = {
token: 'phc_x',
distinct_id: 'anon',
$session_id: 's',
stray: 'dropped',
};
const clean = scrubEventProperties('$pageview', dirty, {
$current_url: 'https://devledger.site/security',
$pathname: '/security',
$host: 'devledger.site',
$referring_domain: 'google.com',
});
assert.deepEqual(clean, {
token: 'phc_x',
distinct_id: 'anon',
$session_id: 's',
$current_url: 'https://devledger.site/security',
$pathname: '/security',
$host: 'devledger.site',
$referring_domain: 'google.com',
});
});

test('facade dedupes pageviews and gates on the public host', () => {
const facade = src('src/analytics/posthog.ts');
assert.match(facade, /lastPageviewPath/);
assert.match(facade, /pathname === lastPageviewPath/);
assert.match(facade, /isPublicAnalyticsHost\(window\.location\.host\)/);
assert.match(facade, /sanitizedPageviewProps\(window\.location, document\.referrer\)/);
assert.doesNotMatch(facade, /location\.href/, 'never reads location.href');
});

test('pageview wiring: boot emit once, route changes and popstate covered', () => {
const main = src('src/main.tsx');
assert.match(main, /initAnalytics\(\)\.then\(\(\) => capturePageview\(\)\)/);
const pages = src('src/pages.ts');
// navigate + popstate each emit — dedup in capturePageview collapses
// same-path repeats (StrictMode, hash/range-only history entries).
assert.match(pages, /capturePageview\(\)/);
assert.ok((pages.match(/capturePageview\(\)/g) || []).length >= 2);
});

test('the built artifact would not contact posthog hosts directly', () => {
// No posthog host literal anywhere in src/ — the only allowed reference
// is the proxy destination in vercel.json.
Expand Down
Loading