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} - +