diff --git a/docs/package.json b/docs/package.json
index 34c4c2ba..422b3160 100644
--- a/docs/package.json
+++ b/docs/package.json
@@ -18,7 +18,10 @@
"@human-kit/ui": "workspace:*",
"@lucide/svelte": "^0.574.0",
"@sveltejs/adapter-vercel": "^6.3.1",
+ "@vercel/analytics": "^2.0.1",
+ "@vercel/speed-insights": "^2.0.0",
"minisearch": "^7.2.0",
+ "posthog-js": "^1.434.12",
"svelte-render-scan": "^1.1.0",
"tailwind-variants": "^3.2.2",
"vite-plugin-devtools-json": "^1.0.0"
diff --git a/docs/src/hooks.server.ts b/docs/src/hooks.server.ts
new file mode 100644
index 00000000..ad0c5e0b
--- /dev/null
+++ b/docs/src/hooks.server.ts
@@ -0,0 +1,25 @@
+import type { Handle } from '@sveltejs/kit';
+import { isTelemetryProxyPath, proxyTelemetry } from '$lib/docs/telemetry/proxy';
+
+/**
+ * Answers the telemetry path before the router sees it.
+ *
+ * The path is not a route of this site: it is the front of a proxy, and
+ * `proxy.ts` says why the events do not go straight to the vendor. A request
+ * that is not for the proxy goes on to the router, untouched.
+ */
+export const handle: Handle = async ({ event, resolve }) => {
+ if (isTelemetryProxyPath(event.url.pathname)) {
+ const method = event.request.method;
+ const hasBody = method !== 'GET' && method !== 'HEAD';
+ return proxyTelemetry({
+ url: event.url,
+ method,
+ headers: event.request.headers,
+ body: hasBody ? await event.request.arrayBuffer() : null,
+ clientAddress: event.getClientAddress()
+ });
+ }
+
+ return resolve(event);
+};
diff --git a/docs/src/lib/docs/components/code-copy/code-copy.svelte b/docs/src/lib/docs/components/code-copy/code-copy.svelte
index 2cff798a..0339affd 100644
--- a/docs/src/lib/docs/components/code-copy/code-copy.svelte
+++ b/docs/src/lib/docs/components/code-copy/code-copy.svelte
@@ -36,6 +36,7 @@
props: {
text,
label: 'Copy code',
+ source: 'code-block',
class: 'absolute top-1.5 right-1.5'
}
});
diff --git a/docs/src/lib/docs/components/copy-button/copy-button.svelte b/docs/src/lib/docs/components/copy-button/copy-button.svelte
index fc8caef8..33d6723c 100644
--- a/docs/src/lib/docs/components/copy-button/copy-button.svelte
+++ b/docs/src/lib/docs/components/copy-button/copy-button.svelte
@@ -2,16 +2,28 @@
import Button from '../button/button.svelte';
import Check from '../icons/check.svelte';
import Copy from '../icons/copy.svelte';
+ import { track } from '$lib/docs/telemetry';
import type { ButtonSize, ButtonVariant } from '../button/recipe';
// Reusable copy-to-clipboard button built on the docs Button component. Shows a
// check for a moment after copying (and disables itself so it can't re-fire).
+ //
+ // Every copy on the site goes through this button, so the `copy` event is sent
+ // from here: one place, and no call site can forget it.
interface Props {
text: string;
label?: string;
variant?: ButtonVariant;
size?: ButtonSize;
class?: string;
+ /**
+ * What the reader copied, for the telemetry: a code block of a guide, the
+ * source of a demo, an install command. A button with no source sends no
+ * event.
+ */
+ source?: 'code-block' | 'demo-source' | 'install-command';
+ /** More about the copy, e.g. which package manager the command was for. */
+ detail?: string;
}
let {
@@ -19,7 +31,9 @@
label = 'Copy',
variant = 'ghost',
size = 'icon-sm',
- class: className = ''
+ class: className = '',
+ source,
+ detail
}: Props = $props();
let copied = $state(false);
@@ -30,6 +44,8 @@
copied = true;
clearTimeout(timeout);
timeout = setTimeout(() => (copied = false), 1500);
+ // After the write: a copy that the browser refused is not a copy.
+ if (source) track('copy', { source, detail: detail ?? null });
}
diff --git a/docs/src/lib/docs/components/demo/demo.svelte b/docs/src/lib/docs/components/demo/demo.svelte
index 8a47686a..5c4c161b 100644
--- a/docs/src/lib/docs/components/demo/demo.svelte
+++ b/docs/src/lib/docs/components/demo/demo.svelte
@@ -5,6 +5,7 @@
import { buttonVariants } from '../button/recipe';
import Surface from '../surface/surface.svelte';
import Code from '../icons/code.svelte';
+ import { track } from '$lib/docs/telemetry';
interface Props {
source: { code: string; html: string };
@@ -14,18 +15,35 @@
let { source, children }: Props = $props();
let expanded = $state(false);
+
+ // One event for each demo, not one for each click: the question is whether the
+ // reader tried the component, and the answer does not become more true with
+ // the second press. A plain variable, because no markup reads it.
+ let interacted = false;
+
+ // In the capture phase: a primitive inside the demo can stop a press from
+ // going up, and the read of this signal must not depend on which one does.
+ function onPreviewInteraction() {
+ if (interacted) return;
+ interacted = true;
+ track('demo_interact');
+ }
-
+
{@render children()}
(expanded = next)}>
-
+
{expanded ? 'Hide code' : 'Show code'}
diff --git a/docs/src/lib/docs/components/header/header.svelte b/docs/src/lib/docs/components/header/header.svelte
index e80ce85e..695ddddb 100644
--- a/docs/src/lib/docs/components/header/header.svelte
+++ b/docs/src/lib/docs/components/header/header.svelte
@@ -5,6 +5,7 @@
import { Frame } from '../frame/index.js';
import { buttonVariants } from '../button/recipe';
import Github from '../icons/github.svelte';
+ import { track } from '$lib/docs/telemetry';
interface Props {
title?: string;
@@ -109,6 +110,7 @@
rel="noreferrer"
aria-label="GitHub repository"
class={buttonVariants({ variant: 'ghost', size: 'icon' })}
+ onclick={() => track('github_click')}
>
diff --git a/docs/src/lib/docs/components/install-command/install-command.svelte b/docs/src/lib/docs/components/install-command/install-command.svelte
index a74ac7e4..ef7b7c3e 100644
--- a/docs/src/lib/docs/components/install-command/install-command.svelte
+++ b/docs/src/lib/docs/components/install-command/install-command.svelte
@@ -75,7 +75,13 @@
{/each}
-
+
diff --git a/docs/src/lib/docs/telemetry/README.md b/docs/src/lib/docs/telemetry/README.md
new file mode 100644
index 00000000..894b3fc3
--- /dev/null
+++ b/docs/src/lib/docs/telemetry/README.md
@@ -0,0 +1,67 @@
+# Telemetry
+
+Two layers, because one script cannot answer both questions.
+
+## Layer 1 — the counts
+
+`src/routes/+layout.ts` starts the analytics and the speed report of the host.
+They give the views of a page, where a reader came from, the country, the device,
+and the speed of a page for real readers.
+
+There is nothing to configure. The host turns the feature on for the project, and
+the scripts come from this domain. A content blocker matches the host of a
+request, so a script from this domain is not stopped, and the counts stay
+correct. Neither script writes to the device of the reader.
+
+## Layer 2 — the clicks
+
+`telemetry.ts` loads the client of the analytics vendor after the page hydrates.
+It records each click with the element behind it, and it gives the heat maps.
+`proxy.ts` and `src/hooks.server.ts` put the requests on a path of this domain,
+for the same reason as above.
+
+The client writes nothing to the device (`persistence: 'memory'`), so the site
+needs no consent dialog. The cost is that a reader who comes back tomorrow is a
+new reader. Layer 1 carries the counts that need an identity.
+
+### To turn it on
+
+Give the build two variables. Both are public, and both are read at build time.
+
+| Variable | Value |
+| --------------------- | ------------------------------------------ |
+| `VITE_POSTHOG_KEY` | The project key. It can only write events. |
+| `VITE_POSTHOG_REGION` | `eu` or `us`. The default is `eu`. |
+
+Set them in the project of the host, for Production and for Preview. To try the
+site on a machine, put them in `docs/.env.local`.
+
+**The region must be the region of the project.** A key of one region is unknown
+in the other, and the vendor answers with an error that the browser does not
+show. Telemetry then looks installed and collects nothing.
+
+Without `VITE_POSTHOG_KEY` each function does nothing. Therefore a build with no
+key, and each test, send no events. Development is also off: a local page must
+not add noise to the numbers of the site.
+
+## The events
+
+| Event | Properties | The question it answers |
+| --------------- | ------------------ | ---------------------------------------------------- |
+| `$pageview` | `route` | Which page a reader opens. |
+| `search` | `query`, `results` | What a reader looks for. `results: 0` is a gap. |
+| `copy` | `source`, `detail` | Which snippet a reader takes away. |
+| `demo_interact` | — | The reader tried the demo, and did not only read it. |
+| `github_click` | — | How many readers go to the repository. |
+| `theme_change` | `to` | How many readers use the dark theme. |
+
+Each click also arrives by itself, with the element behind it, because the client
+records them without instructions. The six events above are the ones that are
+named by hand, because a name makes a chart possible.
+
+`search` with `results: 0` is the most valuable of them: the query is the name of
+a component that is missing, or of a page that does not use the word the reader
+knows.
+
+To add an event, put its name in `TelemetryEvent` and call `track`. A name that
+is not in that type is a build error.
diff --git a/docs/src/lib/docs/telemetry/index.ts b/docs/src/lib/docs/telemetry/index.ts
new file mode 100644
index 00000000..5e54fd2f
--- /dev/null
+++ b/docs/src/lib/docs/telemetry/index.ts
@@ -0,0 +1,17 @@
+export {
+ startTelemetry,
+ telemetryIsOn,
+ track,
+ trackPageView,
+ type TelemetryEvent,
+ type TelemetryProperties
+} from './telemetry';
+export {
+ TELEMETRY_PROXY_PREFIX,
+ isTelemetryProxyPath,
+ proxyTelemetry,
+ telemetryTarget,
+ telemetryRequestHeaders,
+ telemetryResponseHeaders,
+ type TelemetryProxyRequest
+} from './proxy';
diff --git a/docs/src/lib/docs/telemetry/proxy.test.ts b/docs/src/lib/docs/telemetry/proxy.test.ts
new file mode 100644
index 00000000..ec548ee9
--- /dev/null
+++ b/docs/src/lib/docs/telemetry/proxy.test.ts
@@ -0,0 +1,113 @@
+import { describe, expect, it } from 'vitest';
+import {
+ TELEMETRY_ASSET_HOST,
+ TELEMETRY_EVENT_HOST,
+ isTelemetryProxyPath,
+ proxyTelemetry,
+ telemetryRequestHeaders,
+ telemetryResponseHeaders,
+ telemetryTarget
+} from './proxy';
+
+/** Records the request the proxy made, and answers it. */
+function spyFetch(answer?: Response) {
+ const calls: Array<{ url: URL; init: RequestInit }> = [];
+ const fetchImplementation = (async (url: URL, init: RequestInit) => {
+ calls.push({ url, init });
+ return answer ?? new Response('ok', { status: 200 });
+ }) as unknown as typeof fetch;
+ return { calls, fetchImplementation };
+}
+
+describe('the telemetry proxy', () => {
+ it('answers its own path, and nothing else', () => {
+ expect(isTelemetryProxyPath('/ph')).toBe(true);
+ expect(isTelemetryProxyPath('/ph/e/')).toBe(true);
+ expect(isTelemetryProxyPath('/docs/slider')).toBe(false);
+ // A route that only starts with the same letters is not the proxy.
+ expect(isTelemetryProxyPath('/photos')).toBe(false);
+ });
+
+ it('sends the events to the event host, and keeps the query', () => {
+ const target = telemetryTarget(new URL('https://human-kit.dev/ph/e/?ip=0&v=2'));
+
+ expect(target.host).toBe(TELEMETRY_EVENT_HOST);
+ expect(target.pathname).toBe('/e/');
+ expect(target.search).toBe('?ip=0&v=2');
+ });
+
+ it('sends the script to the asset host', () => {
+ const script = telemetryTarget(new URL('https://human-kit.dev/ph/static/array.js'));
+ const array = telemetryTarget(new URL('https://human-kit.dev/ph/array/key/config.js'));
+
+ expect(script.host).toBe(TELEMETRY_ASSET_HOST);
+ expect(array.host).toBe(TELEMETRY_ASSET_HOST);
+ });
+
+ it('drops the headers of one hop, which a copied request must not carry', () => {
+ const headers = telemetryRequestHeaders(
+ new Headers({
+ connection: 'keep-alive',
+ 'transfer-encoding': 'chunked',
+ 'content-length': '12',
+ cookie: 'theme=dark',
+ 'content-type': 'application/json'
+ }),
+ '203.0.113.7'
+ );
+
+ expect(headers.get('connection')).toBeNull();
+ expect(headers.get('transfer-encoding')).toBeNull();
+ expect(headers.get('content-length')).toBeNull();
+ expect(headers.get('cookie')).toBeNull();
+ // The message itself survives.
+ expect(headers.get('content-type')).toBe('application/json');
+ });
+
+ it('names the reader as the sender, so the country is the country of the reader', () => {
+ const headers = telemetryRequestHeaders(new Headers(), '203.0.113.7');
+
+ expect(headers.get('x-forwarded-for')).toBe('203.0.113.7');
+ // The answer is read and written again, so it must not arrive compressed.
+ expect(headers.get('accept-encoding')).toBe('identity');
+ });
+
+ it('gives back no cookie of this domain, and no length it did not measure', () => {
+ const headers = telemetryResponseHeaders(
+ new Headers({
+ 'set-cookie': 'ph_id=1',
+ 'content-encoding': 'gzip',
+ 'content-length': '4',
+ 'content-type': 'application/json'
+ })
+ );
+
+ expect(headers.get('set-cookie')).toBeNull();
+ expect(headers.get('content-encoding')).toBeNull();
+ expect(headers.get('content-length')).toBeNull();
+ expect(headers.get('content-type')).toBe('application/json');
+ });
+
+ it('passes a post with its body, and answers with the status of the vendor', async () => {
+ const body = new TextEncoder().encode('{"event":"copy"}').buffer as ArrayBuffer;
+ const { calls, fetchImplementation } = spyFetch(new Response('1', { status: 202 }));
+
+ const response = await proxyTelemetry(
+ {
+ url: new URL('https://human-kit.dev/ph/e/'),
+ method: 'POST',
+ headers: new Headers({ 'content-type': 'application/json' }),
+ body,
+ clientAddress: '198.51.100.4'
+ },
+ fetchImplementation
+ );
+
+ expect(calls).toHaveLength(1);
+ expect(calls[0].url.href).toBe(`https://${TELEMETRY_EVENT_HOST}/e/`);
+ expect(calls[0].init.method).toBe('POST');
+ expect(calls[0].init.body).toBe(body);
+ expect(response.status).toBe(202);
+ expect(await response.text()).toBe('1');
+ });
+});
diff --git a/docs/src/lib/docs/telemetry/proxy.ts b/docs/src/lib/docs/telemetry/proxy.ts
new file mode 100644
index 00000000..87a60c41
--- /dev/null
+++ b/docs/src/lib/docs/telemetry/proxy.ts
@@ -0,0 +1,160 @@
+/**
+ * The reverse proxy behind the telemetry endpoint.
+ *
+ * WHY THE EVENTS DO NOT GO STRAIGHT TO THE VENDOR
+ * The readers of these docs are developers, and many of them run a content
+ * blocker. A blocker matches the host of a request, so a script and an event
+ * that go to an analytics vendor are dropped before they leave the browser. The
+ * loss is silent and it is not measurable, because the requests that a blocker
+ * stops never arrive anywhere. The client in `telemetry.ts` therefore sends
+ * everything to a path on this site, and `hooks.server.ts` gives the request to
+ * the vendor from the server, where no blocker sees the host.
+ *
+ * This file holds the parts that have no server in them, so a test can drive
+ * them with a fetch of its own.
+ */
+
+/** The path the client sends its events to. It must stay same-origin. */
+export const TELEMETRY_PROXY_PREFIX = '/ph';
+
+/**
+ * The region of the analytics project, from `VITE_POSTHOG_REGION`.
+ *
+ * It MUST be the region the project was created in. A key from one region is
+ * unknown in the other, and the vendor answers the events with a 401 that the
+ * browser never shows: telemetry then looks installed and collects nothing.
+ * Both the client and this proxy read this one constant, so the two cannot
+ * disagree.
+ */
+const REGION = (import.meta.env.VITE_POSTHOG_REGION as string | undefined) === 'us' ? 'us' : 'eu';
+
+/** Where the events go. */
+export const TELEMETRY_EVENT_HOST = `${REGION}.i.posthog.com`;
+
+/** Where the client script and the toolbar assets come from. */
+export const TELEMETRY_ASSET_HOST = `${REGION}-assets.i.posthog.com`;
+
+/**
+ * The address of the dashboard. The client needs it to build the links of the
+ * toolbar, which must point at the vendor and not at this proxy.
+ */
+export const TELEMETRY_UI_HOST = `https://${REGION}.posthog.com`;
+
+/**
+ * Headers that describe one hop of a connection, not the message.
+ *
+ * A proxy that copies these into its own request breaks it: `fetch` refuses a
+ * `connection` or a `transfer-encoding` that it did not write itself, and the
+ * failure reads as a fault of the destination. `host` and `content-length` are
+ * here for the same reason — the new request has a new host and a new length.
+ */
+const REQUEST_HEADERS_TO_DROP = new Set([
+ 'connection',
+ 'keep-alive',
+ 'proxy-authenticate',
+ 'proxy-authorization',
+ 'te',
+ 'trailer',
+ 'transfer-encoding',
+ 'upgrade',
+ 'host',
+ 'content-length',
+ // The answer is read and written again below, so it must not arrive
+ // compressed with an encoding that the copied headers would then describe
+ // wrongly.
+ 'accept-encoding',
+ // Nothing on this site needs a session, and a cookie of this domain says
+ // nothing to the vendor.
+ 'cookie'
+]);
+
+/** Answer headers that describe a body this proxy does not pass on unchanged. */
+const RESPONSE_HEADERS_TO_DROP = new Set([
+ 'connection',
+ 'keep-alive',
+ 'transfer-encoding',
+ 'content-encoding',
+ 'content-length',
+ // The vendor must not write a cookie of this domain.
+ 'set-cookie'
+]);
+
+/** True for a request this proxy answers instead of the router. */
+export function isTelemetryProxyPath(pathname: string): boolean {
+ return pathname === TELEMETRY_PROXY_PREFIX || pathname.startsWith(`${TELEMETRY_PROXY_PREFIX}/`);
+}
+
+/**
+ * The address at the vendor for one request to the proxy.
+ *
+ * The assets are on a different host from the events, and the client asks for
+ * both through this one path.
+ */
+export function telemetryTarget(url: URL): URL {
+ const path = url.pathname.slice(TELEMETRY_PROXY_PREFIX.length) || '/';
+ const isAsset = path.startsWith('/static/') || path.startsWith('/array/');
+ const host = isAsset ? TELEMETRY_ASSET_HOST : TELEMETRY_EVENT_HOST;
+ return new URL(`https://${host}${path}${url.search}`);
+}
+
+/**
+ * The headers of the request to the vendor.
+ *
+ * `clientAddress` becomes `x-forwarded-for`: the vendor reads the country of a
+ * reader from that header, and without it every event comes from the data
+ * centre of this site.
+ */
+export function telemetryRequestHeaders(source: Headers, clientAddress: string): Headers {
+ const headers = new Headers();
+ for (const [name, value] of source) {
+ if (REQUEST_HEADERS_TO_DROP.has(name.toLowerCase())) continue;
+ headers.set(name, value);
+ }
+ headers.set('accept-encoding', 'identity');
+ headers.set('x-forwarded-for', clientAddress);
+ return headers;
+}
+
+/** The headers this proxy gives back to the browser. */
+export function telemetryResponseHeaders(source: Headers): Headers {
+ const headers = new Headers();
+ for (const [name, value] of source) {
+ if (RESPONSE_HEADERS_TO_DROP.has(name.toLowerCase())) continue;
+ headers.set(name, value);
+ }
+ return headers;
+}
+
+/** Everything a request needs to reach the vendor, without a server object. */
+export interface TelemetryProxyRequest {
+ url: URL;
+ method: string;
+ headers: Headers;
+ body: ArrayBuffer | null;
+ clientAddress: string;
+}
+
+/**
+ * Gives one request to the vendor and returns its answer.
+ *
+ * The body is read to the end first. A stream would be less work, but it needs
+ * a `duplex` option that not each runtime has, and an event of this size is a
+ * few hundred bytes.
+ */
+export async function proxyTelemetry(
+ request: TelemetryProxyRequest,
+ fetchImplementation: typeof fetch = fetch
+): Promise
{
+ const response = await fetchImplementation(telemetryTarget(request.url), {
+ method: request.method,
+ headers: telemetryRequestHeaders(request.headers, request.clientAddress),
+ body: request.body,
+ redirect: 'follow'
+ });
+
+ return new Response(response.body, {
+ status: response.status,
+ statusText: response.statusText,
+ headers: telemetryResponseHeaders(response.headers)
+ });
+}
diff --git a/docs/src/lib/docs/telemetry/telemetry.ts b/docs/src/lib/docs/telemetry/telemetry.ts
new file mode 100644
index 00000000..c30db105
--- /dev/null
+++ b/docs/src/lib/docs/telemetry/telemetry.ts
@@ -0,0 +1,143 @@
+/**
+ * The telemetry of the docs site: what a reader opens, and what a reader does
+ * with it.
+ *
+ * TWO LAYERS, AND WHY
+ * The counts of the readers and of where they came from are collected by the
+ * host of this site (see `src/routes/+layout.ts`). That script comes from this
+ * domain, so a content blocker does not stop it, and it writes nothing to the
+ * device of the reader.
+ *
+ * This file is the second layer, and it answers a different question: what a
+ * reader touches on the page. It loads the client of an analytics vendor, which
+ * records each click with the element behind it, and it gives the heat maps. It
+ * goes through the proxy in `proxy.ts` for the same reason: a blocker matches
+ * the host.
+ *
+ * NO COOKIE, AND NO CONSENT DIALOG
+ * `persistence: 'memory'` keeps the identity of a reader in the tab, and writes
+ * nothing to the device. Therefore the site needs no consent dialog, and a
+ * reader who comes back tomorrow is a new reader. That is the cost, and the
+ * first layer carries the counts that need an identity, so the cost is small.
+ * A change of this line is a change of the privacy statement of the site.
+ *
+ * HOW TO TURN IT ON
+ * See `README.md` in this directory. Without `VITE_POSTHOG_KEY` each function
+ * here does nothing, thus a build with no key, and each test, send no events.
+ */
+import type { PostHog } from 'posthog-js';
+import { browser, dev } from '$app/environment';
+import { TELEMETRY_PROXY_PREFIX, TELEMETRY_UI_HOST } from './proxy';
+
+/**
+ * The events this site sends.
+ *
+ * Each one is here because it answers a question that decides work:
+ * - `search`: what a reader looks for, and what a reader does not find. A
+ * search with 0 results is the name of the component that is missing.
+ * - `copy`: which snippet a reader takes away. A page that is read and never
+ * copied is a page that did not answer.
+ * - `demo_interact`: the reader touched the demo, and did not only read it.
+ * - `github_click` and `theme_change`: the two other controls of the chrome.
+ */
+export type TelemetryEvent = 'search' | 'copy' | 'demo_interact' | 'github_click' | 'theme_change';
+
+export type TelemetryProperties = Record;
+
+/**
+ * The key of the analytics project, given to the build as
+ * `VITE_POSTHOG_KEY`. It is a public key: it can only write events.
+ */
+const KEY = import.meta.env.VITE_POSTHOG_KEY as string | undefined;
+
+/** How many events wait while the client downloads. */
+const MAX_PENDING = 20;
+
+/** How long to wait for an idle moment before the client downloads. */
+const IDLE_TIMEOUT_MS = 4000;
+
+let client: PostHog | null = null;
+let startRequested = false;
+const pending: Array<{ event: string; properties?: TelemetryProperties }> = [];
+
+/**
+ * True when the events leave the browser.
+ *
+ * Development is off on purpose: a local page must not add noise to the numbers
+ * of the site, and the tests run with `dev` true.
+ */
+export function telemetryIsOn(): boolean {
+ return browser && !dev && typeof KEY === 'string' && KEY !== '';
+}
+
+/**
+ * Downloads the client and starts it. Safe to call more than one time.
+ *
+ * The download waits for an idle moment. The demos on a component page hydrate
+ * first, and telemetry must not take the main thread from them.
+ */
+export function startTelemetry(): void {
+ if (!telemetryIsOn() || startRequested) return;
+ startRequested = true;
+
+ const start = () => {
+ void import('posthog-js').then(({ default: posthog }) => {
+ posthog.init(KEY as string, {
+ // The proxy on this domain, not the vendor. See proxy.ts.
+ api_host: TELEMETRY_PROXY_PREFIX,
+ // The dashboard, for the links of the toolbar.
+ ui_host: TELEMETRY_UI_HOST,
+ defaults: '2025-05-24',
+ // See "NO COOKIE" at the top of this file.
+ persistence: 'memory',
+ person_profiles: 'identified_only',
+ disable_session_recording: true,
+ // A page of this site is prerendered and the router swaps it without a
+ // load, so the layout sends each view by hand.
+ capture_pageview: false,
+ capture_pageleave: true,
+ autocapture: true
+ });
+ client = posthog;
+ for (const item of pending) posthog.capture(item.event, item.properties);
+ pending.length = 0;
+ });
+ };
+
+ if ('requestIdleCallback' in window) {
+ window.requestIdleCallback(start, { timeout: IDLE_TIMEOUT_MS });
+ } else {
+ setTimeout(start, 2000);
+ }
+}
+
+/** Sends one event, or holds it until the client is ready. */
+export function track(event: TelemetryEvent, properties?: TelemetryProperties): void {
+ capture(event, properties);
+}
+
+/**
+ * Sends one view of a page.
+ *
+ * The router changes the URL without a load, so the client cannot see a view by
+ * itself. `$current_url` is given by hand for the same reason: the client would
+ * read the address of the page the reader came from.
+ */
+export function trackPageView(url: URL, route: string | null): void {
+ capture('$pageview', {
+ $current_url: url.href,
+ $pathname: url.pathname,
+ route: route ?? url.pathname
+ });
+}
+
+function capture(event: string, properties?: TelemetryProperties): void {
+ if (!telemetryIsOn()) return;
+ if (client) {
+ client.capture(event, properties);
+ return;
+ }
+ // The client is still on its way. Hold the event, but do not grow without an
+ // end if the download never finishes.
+ if (pending.length < MAX_PENDING) pending.push({ event, properties });
+}
diff --git a/docs/src/routes/+layout.svelte b/docs/src/routes/+layout.svelte
index eb856061..7becc069 100644
--- a/docs/src/routes/+layout.svelte
+++ b/docs/src/routes/+layout.svelte
@@ -3,14 +3,24 @@
import '@fontsource-variable/roboto-serif';
import '../app.css';
import { dev } from '$app/environment';
+ import { afterNavigate } from '$app/navigation';
import { page } from '$app/state';
import { RenderScan } from 'svelte-render-scan';
import Seo from '$lib/docs/components/seo/seo.svelte';
+ import { startTelemetry, trackPageView } from '$lib/docs/telemetry';
let { children } = $props();
// The render overlay repaints on every DOM mutation, which would dominate
// any measurement taken under /bench.
const isBench = $derived(page.url.pathname.startsWith('/bench'));
+
+ // `afterNavigate` also runs for the first page, so this is both the start of
+ // the client and every view after it. The router swaps a prerendered page
+ // without a load, so no script can see a view by itself.
+ afterNavigate(() => {
+ startTelemetry();
+ trackPageView(page.url, page.route.id);
+ });