Ver Fonte

feat(telemetry): anonymous usage telemetry — documented schema, opt-out, public ingest worker (#834)

Adds anonymous usage statistics (commands/tools used, languages indexed,
connecting agents) with a strict, auditable allowlist. Never code, paths,
file/symbol names, queries, or IPs.

- src/telemetry/: zero-dep client — consent resolution (DO_NOT_TRACK >
  CODEGRAPH_TELEMETRY > stored choice > default-on), random machine UUID,
  in-memory counters → capped JSONL buffer → completed-day rollups; sync
  exit-append (survives process.exit) + opportunistic bounded sends; the
  first-run notice gates the first SEND, never local buffering, so the
  installer's consent toggle always precedes it. Off is off: no recording,
  no socket, buffered data deleted.
- codegraph telemetry status|on|off; per-command counting via preAction hook.
- MCP: tool counting after the reply is on the wire (session + proxy
  in-process fallback), agent attribution from initialize clientInfo,
  unref'd daemon flush interval. Zero hot-path cost, zero stdout.
- Installer: visible default-on consent toggle (asked once, never re-asked),
  install/index/uninstall lifecycle events.
- telemetry-worker/: public Cloudflare Worker behind telemetry.getcodegraph.com
  — allowlist validation, IP stripping, per-machine rate limit, forwards to
  PostHog as anonymous events. Ships nowhere with the npm package.
- TELEMETRY.md (field-by-field contract) + README section + design doc.
- 20 unit tests; suite-wide CODEGRAPH_TELEMETRY=0 guard so tests never
  pollute real telemetry. Full suite: 1448 passing.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Colby Mchenry há 1 semana atrás
pai
commit
848fde9f59

+ 1 - 0
CHANGELOG.md

@@ -16,6 +16,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ### New Features
 
+- **Anonymous usage telemetry, documented field-by-field and easy to turn off.** CodeGraph now collects a small set of anonymous usage statistics — which commands and MCP tools get used, which languages get indexed, which agents connect — so language and agent support work goes where real usage is. Never any code, file paths, file or symbol names, search queries, or IP addresses; usage aggregates locally into daily totals before anything is sent, and the ingest endpoint is public, auditable code in the repository that enforces the documented field list. The installer asks up front with a visible default-on toggle (and never re-asks); everywhere else a one-line notice prints before the first send. Disable any time with `codegraph telemetry off`, `CODEGRAPH_TELEMETRY=0`, or the cross-tool `DO_NOT_TRACK=1` standard — off means off: nothing is recorded, nothing is sent, and buffered data is deleted. `TELEMETRY.md` documents every field.
 - **Subagents and non-MCP agents can now reach CodeGraph.** Two new CLI commands — `codegraph explore "<symbols or question>"` and `codegraph node <symbol-or-file>` — print exactly what the matching MCP tools return (relevant symbols' source + call paths; one symbol's source + callers; file reads with line numbers), so any agent with a shell can use the graph. And `codegraph install` now writes a small marker-fenced CodeGraph section into each agent's instructions file (`CLAUDE.md` / `AGENTS.md` / `GEMINI.md`) pointing at both surfaces — that file is what Task-tool subagents actually see, where the MCP server's own guidance only reaches the main agent. Measured on a delegated code-exploration task: subagents went from almost never using CodeGraph (~1 in 9 runs) to using it in every run, including runs with zero grep/file-reading fallback. The section is small, survives your own content, upgrades cleanly from the old long block, and `codegraph uninstall` removes it. Thanks @liuyao37511. (#704)
 - **The MCP tool list is now a focused default of four** — `codegraph_explore`, `codegraph_node`, `codegraph_search`, and `codegraph_callers`. The other four (`codegraph_callees`, `codegraph_impact`, `codegraph_files`, `codegraph_status`) remain fully functional — the CLI and library API are unchanged, and `CODEGRAPH_MCP_TOOLS` re-enables any of them — but they're no longer listed to agents by default: measured agent behavior shows they're never or rarely picked, and the information they carry already arrives inline on the tools agents do use (explore's blast-radius section, node's dependents note, a symbol's own body as its callee list). A leaner list saves context tokens every session and steers agents to the right tool by presence alone.
 - **CodeGraph now goes quiet instead of failing loudly in unindexed projects.** When an AI agent's session starts in a workspace that has no CodeGraph index, the MCP server now announces itself as inactive with a short note and lists no tools at all — instead of presenting the full toolset and erroring on every call, which taught agents to distrust CodeGraph even where it works. Querying another project that isn't indexed likewise returns clear guidance (use your regular tools for that codebase; the user can run `codegraph init` there to enable CodeGraph) instead of an error, and genuine internal errors now tell the agent to retry once rather than give up on CodeGraph entirely. Indexing stays your decision — agents are told not to run it themselves. (#769)

+ 17 - 0
README.md

@@ -589,6 +589,23 @@ add a negation — `!vendor/`. The defaults apply uniformly, so committing a
 dependency or build directory doesn't force it into the graph; the `.gitignore`
 negation is the explicit opt-in.
 
+## Telemetry
+
+CodeGraph collects **anonymous usage statistics** — which tools and commands get
+used, which languages get indexed — to guide where language and agent support
+work goes. **Never** any code, paths, file or symbol names, queries, or IP
+addresses; usage is aggregated locally into daily totals before anything is
+sent, and the ingest endpoint is [public code in this repo](telemetry-worker/)
+that enforces the documented field list. The installer asks up front; turn it
+off any time:
+
+```bash
+codegraph telemetry off    # or: CODEGRAPH_TELEMETRY=0, or DO_NOT_TRACK=1
+```
+
+[`TELEMETRY.md`](TELEMETRY.md) lists every field, with the off-switches and the
+full data-handling story.
+
 ## Supported Platforms
 
 Every release ships a self-contained build (bundled Node runtime — nothing to

+ 87 - 0
TELEMETRY.md

@@ -0,0 +1,87 @@
+# Telemetry
+
+CodeGraph collects a small set of **anonymous usage statistics** — which commands and
+tools get used, which languages get indexed, which agents drive usage — so we can tell
+which of the 20+ languages and 8 agent integrations deserve the most work. This page is
+the complete list of what is collected. If a field isn't on this page, it isn't collected;
+the ingest endpoint enforces this list as an allowlist and is itself
+[public, auditable code](telemetry-worker/) in this repository.
+
+## Turning it off
+
+Any of these works, permanently:
+
+```bash
+codegraph telemetry off        # stores your choice (and deletes any unsent data)
+```
+
+```bash
+export CODEGRAPH_TELEMETRY=0   # per-shell / per-CI override
+export DO_NOT_TRACK=1          # the cross-tool standard — always honored
+```
+
+`codegraph telemetry status` shows the current state, what decided it, and your machine ID.
+The interactive installer (`codegraph install`) asks up front with a visible default-on
+toggle and never re-asks. If you never saw the installer (e.g. `npx` straight into `init`),
+a one-line notice is printed to stderr before the first time anything is sent.
+
+Off means off: when disabled, CodeGraph records nothing, opens no connection to the
+telemetry endpoint, and sends no "opted out" ping.
+
+## What is collected
+
+Every payload carries this envelope:
+
+| field | example | notes |
+|---|---|---|
+| `machine_id` | `b3a8c1…` | random UUID minted on first send — derived from nothing |
+| `codegraph_version` | `0.9.9` | |
+| `os` / `arch` | `darwin` / `arm64` | platform identifiers only |
+| `node_major` | `22` | major version only |
+| `ci` | `false` | whether the `CI` env var was set |
+| `schema_version` | `1` | bumped when this page changes |
+
+And one of four events:
+
+- **`install`** — when `codegraph install` configures agents: which agents
+  (`["claude","cursor",…]`), global vs project-local, and whether it was a fresh install,
+  an upgrade, or a re-run.
+- **`index`** — when a full index completes: the **language names** present (e.g.
+  `["typescript","go"]`), the file count as a **coarse bucket** (`<100`, `100-1k`,
+  `1k-10k`, `10k+`), the duration as a bucket (`<10s`, `10-60s`, `1-5m`, `5m+`), and the
+  SQLite backend (`native`/`wasm`).
+- **`usage_rollup`** — one line per day per tool: the tool or CLI command **name** (e.g.
+  `codegraph_explore`, `init`), how many times it ran, how many errored, and — for MCP
+  tools — the connecting agent's name and version from the MCP handshake (e.g.
+  `Claude Code 2.1`).
+- **`uninstall`** — when `codegraph uninstall`/`uninit` runs: which agents were removed.
+
+Usage is **aggregated locally into daily totals** before anything is sent — there is no
+per-call event stream, and nothing is sent in real time.
+
+## What is never collected
+
+- **No source code.** No file paths, file names, directory names, repository names or
+  URLs, symbol names, search queries, or anything else derived from the contents of an
+  indexed project.
+- **No IP addresses.** The ingest endpoint never reads, logs, or forwards the client IP,
+  and IP discarding is enabled at the analytics backend on top of that. No geolocation.
+- **No fingerprinting.** The machine ID is a random UUID stored in
+  `~/.codegraph/telemetry.json` — delete that file (or run `codegraph telemetry off`,
+  then `on`) and the old ID is gone forever, with no way to reconnect it.
+- **No personal data.** No usernames, hostnames, emails, or environment variables.
+
+## How it travels
+
+Events POST to `telemetry.getcodegraph.com` — a first-party endpoint whose complete
+source lives in [`telemetry-worker/`](telemetry-worker/) in this repository. It validates
+every event and property against the allowlist above (anything else is dropped), strips
+IPs, rate-limits, and forwards to a managed analytics store (PostHog, US region) as
+anonymous events. Sends are fire-and-forget with a short timeout: offline or air-gapped
+machines buffer a bounded local file (256 KB cap) and never retry-loop, log errors, or
+slow a command down. Telemetry never adds latency to MCP tool calls — recording is an
+in-memory counter.
+
+The engineering contract behind all of this — including the rule that schema changes must
+update this page, the client, and the public endpoint in one PR — is in
+[`docs/design/telemetry.md`](docs/design/telemetry.md).

+ 294 - 0
__tests__/telemetry.test.ts

@@ -0,0 +1,294 @@
+/**
+ * Anonymous usage telemetry — client module.
+ *
+ * Pins the four invariants from docs/design/telemetry.md: zero stdout, off is
+ * off (no socket, no files), fail silent, and local rollup aggregation with
+ * completed-days-only sending. All seams (dir, fetch, clock, env, stderr) are
+ * injected — no network, no real home directory.
+ */
+
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+import { Telemetry, getTelemetry, TELEMETRY_ENDPOINT } from '../src/telemetry';
+
+type FetchCall = { url: string; body: Record<string, unknown> };
+
+function mockFetch(calls: FetchCall[], opts: { fail?: boolean } = {}) {
+  return vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
+    if (opts.fail) throw new Error('network down');
+    calls.push({ url: String(input), body: JSON.parse(String(init?.body)) as Record<string, unknown> });
+    return new Response(null, { status: 204 });
+  }) as unknown as typeof globalThis.fetch;
+}
+
+describe('Telemetry', () => {
+  let dir: string;
+  let calls: FetchCall[];
+  let stderrLines: string[];
+  let nowValue: Date;
+
+  const make = (overrides: Partial<ConstructorParameters<typeof Telemetry>[0]> = {}) =>
+    new Telemetry({
+      dir,
+      fetchImpl: mockFetch(calls),
+      now: () => nowValue,
+      env: {},
+      stderr: (line) => stderrLines.push(line),
+      installExitHook: false,
+      ...overrides,
+    });
+
+  beforeEach(() => {
+    dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-telemetry-'));
+    calls = [];
+    stderrLines = [];
+    nowValue = new Date('2026-06-12T08:00:00.000Z');
+  });
+
+  afterEach(() => {
+    fs.rmSync(dir, { recursive: true, force: true });
+  });
+
+  describe('consent precedence', () => {
+    it('defaults to enabled when nothing decides otherwise', () => {
+      const t = make();
+      expect(t.getStatus()).toMatchObject({ enabled: true, decidedBy: 'default', machineId: null });
+    });
+
+    it('DO_NOT_TRACK beats everything, including a forced-on env and config', () => {
+      const t = make({ env: { DO_NOT_TRACK: '1', CODEGRAPH_TELEMETRY: '1' } });
+      t.setEnabled(true, 'cli');
+      expect(t.getStatus()).toMatchObject({ enabled: false, decidedBy: 'DO_NOT_TRACK' });
+    });
+
+    it('CODEGRAPH_TELEMETRY env beats the stored config in both directions', () => {
+      const t = make({ env: { CODEGRAPH_TELEMETRY: '0' } });
+      t.setEnabled(true, 'cli');
+      expect(t.getStatus()).toMatchObject({ enabled: false, decidedBy: 'CODEGRAPH_TELEMETRY' });
+
+      const t2 = make({ env: { CODEGRAPH_TELEMETRY: '1' } });
+      t2.setEnabled(false, 'cli');
+      expect(t2.getStatus()).toMatchObject({ enabled: true, decidedBy: 'CODEGRAPH_TELEMETRY' });
+    });
+
+    it('stored config decides when no env is set', () => {
+      const t = make();
+      t.setEnabled(false, 'installer');
+      expect(t.getStatus()).toMatchObject({ enabled: false, decidedBy: 'config' });
+    });
+  });
+
+  describe('off is off', () => {
+    it('disabled: records nothing, sends nothing, creates no files', async () => {
+      const fetchSpy = mockFetch(calls);
+      const t = make({ env: { CODEGRAPH_TELEMETRY: '0' }, fetchImpl: fetchSpy });
+      t.recordUsage('mcp_tool', 'codegraph_explore', true);
+      t.recordLifecycle('install', { scope: 'local', kind: 'fresh' });
+      t.persistSync();
+      await t.flushNow();
+      expect(fetchSpy).not.toHaveBeenCalled();
+      expect(fs.existsSync(t.configPath)).toBe(false);
+      expect(fs.existsSync(t.queuePath)).toBe(false);
+      expect(stderrLines).toEqual([]);
+    });
+
+    it('turning telemetry off deletes buffered unsent data', () => {
+      const t = make();
+      t.recordUsage('cli_command', 'init', true);
+      t.persistSync();
+      expect(fs.existsSync(t.queuePath)).toBe(true);
+      t.setEnabled(false, 'cli');
+      expect(fs.existsSync(t.queuePath)).toBe(false);
+    });
+  });
+
+  describe('first-run notice & machine id', () => {
+    it('recording only buffers — no notice, no config until something is sent', async () => {
+      const t = make();
+      t.recordUsage('mcp_tool', 'codegraph_explore', true);
+      t.recordUsage('mcp_tool', 'codegraph_node', true);
+      expect(stderrLines).toEqual([]); // local buffering is silent
+      expect(fs.existsSync(t.configPath)).toBe(false);
+      // Same-day rollups aren't sendable yet — even a flush stays silent.
+      await t.flushNow();
+      expect(stderrLines).toEqual([]);
+      expect(calls).toHaveLength(0);
+    });
+
+    it('prints the notice exactly once, before the first actual send', async () => {
+      const t = make();
+      t.recordLifecycle('index', { languages: ['go'] });
+      await t.flushNow();
+      t.recordLifecycle('index', { languages: ['rust'] });
+      await t.flushNow();
+      expect(calls).toHaveLength(2);
+      expect(stderrLines).toHaveLength(1);
+      expect(stderrLines[0]).toContain('codegraph telemetry off');
+      expect(stderrLines[0]).toContain('CODEGRAPH_TELEMETRY=0');
+      const config = JSON.parse(fs.readFileSync(t.configPath, 'utf8'));
+      expect(config.machine_id).toMatch(/^[0-9a-f-]{36}$/);
+      expect(config.consent_source).toBe('default-notice');
+    });
+
+    it('keeps the machine id stable across instances and explicit toggles', async () => {
+      const t = make();
+      t.recordLifecycle('install', { scope: 'local', kind: 'fresh' });
+      await t.flushNow();
+      const id1 = t.getStatus().machineId;
+      expect(id1).toBeTruthy();
+      const t2 = make();
+      t2.setEnabled(true, 'cli');
+      expect(t2.getStatus().machineId).toBe(id1);
+    });
+
+    it('an explicit installer choice suppresses the notice', async () => {
+      const t = make();
+      t.setEnabled(true, 'installer');
+      t.recordLifecycle('install', { scope: 'local', kind: 'fresh' });
+      await t.flushNow();
+      expect(calls).toHaveLength(1); // sent…
+      expect(stderrLines).toEqual([]); // …without ever showing the notice
+    });
+  });
+
+  describe('rollups & sending', () => {
+    it('aggregates per (day, kind, name, client) and sends only completed days', async () => {
+      const t = make();
+      const client = { name: 'Claude Code', version: '2.1' };
+      t.recordUsage('mcp_tool', 'codegraph_explore', true, client);
+      t.recordUsage('mcp_tool', 'codegraph_explore', false, client);
+      t.recordUsage('mcp_tool', 'codegraph_explore', true, client);
+      t.recordUsage('cli_command', 'query', true);
+
+      // Same day: nothing is sendable yet.
+      await t.flushNow();
+      expect(calls).toHaveLength(0);
+
+      // Next day: yesterday's rollups go out.
+      nowValue = new Date('2026-06-13T08:00:00.000Z');
+      t.recordUsage('cli_command', 'status', true); // today's — must stay queued
+      await t.flushNow();
+      expect(calls).toHaveLength(1);
+      const body = calls[0]!.body;
+      expect(body.machine_id).toBe(t.getStatus().machineId);
+      expect(body.schema_version).toBe(1);
+      const events = body.events as Array<{ event: string; ts: string; props: Record<string, unknown> }>;
+      expect(events).toHaveLength(2);
+      const explore = events.find((e) => e.props.name === 'codegraph_explore')!;
+      expect(explore).toMatchObject({
+        event: 'usage_rollup',
+        ts: '2026-06-12T12:00:00.000Z',
+        props: { kind: 'mcp_tool', count: 3, error_count: 1, client_name: 'Claude Code', client_version: '2.1' },
+      });
+      // Today's delta is still buffered for tomorrow.
+      expect(fs.readFileSync(t.queuePath, 'utf8')).toContain('"status"');
+    });
+
+    it('lifecycle events send on the next flush regardless of day', async () => {
+      const t = make();
+      t.recordLifecycle('install', { targets: ['claude'], scope: 'local', kind: 'fresh' });
+      await t.flushNow();
+      expect(calls).toHaveLength(1);
+      const events = calls[0]!.body.events as Array<{ event: string; props: Record<string, unknown> }>;
+      expect(events[0]).toMatchObject({ event: 'install', props: { scope: 'local', kind: 'fresh' } });
+    });
+
+    it('uses the production endpoint by default and honors the env override', async () => {
+      const t = make();
+      t.recordLifecycle('uninstall', {});
+      await t.flushNow();
+      expect(calls[0]!.url).toBe(TELEMETRY_ENDPOINT);
+
+      const t2 = make({ env: { CODEGRAPH_TELEMETRY_ENDPOINT: 'http://localhost:9999/v1/events' } });
+      t2.recordLifecycle('uninstall', {});
+      await t2.flushNow();
+      expect(calls[1]!.url).toBe('http://localhost:9999/v1/events');
+    });
+
+    it('re-queues on network failure and delivers on the next flush', async () => {
+      const t = make({ fetchImpl: mockFetch(calls, { fail: true }) });
+      t.recordLifecycle('install', { scope: 'global', kind: 'upgrade' });
+      await expect(t.flushNow()).resolves.toBeUndefined(); // fail silent
+      expect(calls).toHaveLength(0);
+      expect(fs.readFileSync(t.queuePath, 'utf8')).toContain('"install"');
+      // No claim files left behind.
+      expect(fs.readdirSync(dir).filter((f) => f.includes('.sending.'))).toEqual([]);
+
+      const t2 = make();
+      await t2.flushNow();
+      expect(calls).toHaveLength(1);
+      expect(fs.existsSync(t2.queuePath)).toBe(false);
+    });
+
+    it('a hung endpoint is bounded by the flush timeout', async () => {
+      const hangingFetch = ((_url: RequestInfo | URL, init?: RequestInit) =>
+        new Promise((_resolve, reject) => {
+          init?.signal?.addEventListener('abort', () => reject(new Error('aborted')));
+        })) as unknown as typeof globalThis.fetch;
+      const t = make({ fetchImpl: hangingFetch });
+      t.recordLifecycle('install', { scope: 'local', kind: 'fresh' });
+      const started = Date.now();
+      await t.flushNow(100);
+      expect(Date.now() - started).toBeLessThan(2000);
+      expect(fs.readFileSync(t.queuePath, 'utf8')).toContain('"install"'); // re-queued
+    });
+  });
+
+  describe('buffer robustness', () => {
+    it('caps the queue and drops oldest lines without leaving partial JSON', () => {
+      const t = make();
+      const bigProps = { targets: Array.from({ length: 50 }, (_, i) => `agent-${i}`) };
+      for (let i = 0; i < 600; i++) {
+        t.recordLifecycle('install', { ...bigProps, kind: `fresh`, scope: `local`, seq: i });
+        t.persistSync();
+      }
+      const content = fs.readFileSync(t.queuePath, 'utf8');
+      expect(content.length).toBeLessThanOrEqual(256 * 1024);
+      const first = content.slice(0, content.indexOf('\n'));
+      expect(() => JSON.parse(first)).not.toThrow(); // no partial first line
+      expect(JSON.parse(first).props.seq).toBeGreaterThan(0); // oldest dropped
+    });
+
+    it('skips corrupt lines and still delivers the valid ones', async () => {
+      const t = make();
+      t.recordLifecycle('index', { languages: ['typescript'] });
+      t.persistSync();
+      fs.appendFileSync(t.queuePath, 'NOT JSON{{{\n');
+      await t.flushNow();
+      expect(calls).toHaveLength(1);
+      expect((calls[0]!.body.events as unknown[])).toHaveLength(1);
+    });
+
+    it('merges back stale claim files from a crashed sender', async () => {
+      const t = make();
+      const stale = path.join(dir, 'telemetry-queue.sending.99999.jsonl');
+      fs.mkdirSync(dir, { recursive: true });
+      fs.writeFileSync(stale, JSON.stringify({ v: 1, ev: 'uninstall', ts: '2026-06-11T00:00:00.000Z', props: {} }) + '\n');
+      const old = new Date(nowValue.getTime() - 2 * 60 * 60_000);
+      fs.utimesSync(stale, old, old);
+      t.setEnabled(true, 'cli'); // config so send() has a machine id
+      await t.flushNow();
+      expect(fs.existsSync(stale)).toBe(false);
+      expect(calls).toHaveLength(1);
+      expect((calls[0]!.body.events as Array<{ event: string }>)[0]!.event).toBe('uninstall');
+    });
+  });
+
+  describe('protocol safety', () => {
+    it('never writes to stdout', async () => {
+      const stdoutSpy = vi.spyOn(process.stdout, 'write');
+      const t = make({ env: { CODEGRAPH_TELEMETRY_DEBUG: '1' } });
+      t.recordUsage('mcp_tool', 'codegraph_explore', true);
+      t.recordLifecycle('install', { scope: 'local', kind: 'fresh' });
+      await t.flushNow();
+      expect(stdoutSpy).not.toHaveBeenCalled();
+      stdoutSpy.mockRestore();
+    });
+  });
+
+  it('getTelemetry returns a process-wide singleton', () => {
+    expect(getTelemetry()).toBe(getTelemetry());
+  });
+});

+ 196 - 0
docs/design/telemetry.md

@@ -0,0 +1,196 @@
+# Anonymous usage telemetry
+
+Status: implemented — ingest Worker (`telemetry-worker/`), client (`src/telemetry/`),
+`codegraph telemetry` CLI, MCP + installer wiring, `TELEMETRY.md`. Pending: Worker deploy
++ DNS, release.
+Scope: public `codegraph` engine (CLI + MCP server + installer)
+
+CodeGraph is a local-first tool whose whole pitch is "your code never leaves your machine."
+Telemetry has to be designed so that sentence stays true and provable: a short, auditable list
+of anonymous counters, documented field-by-field, easy to turn off, and impossible to grow
+quietly. This doc is the contract; `TELEMETRY.md` (repo root, user-facing) restates it and the
+implementation must never collect anything not listed there.
+
+## Goals
+
+Answer, in aggregate and anonymously:
+
+- How many machines actively use codegraph (daily/weekly), and how does that change?
+- Which agents drive usage (Claude Code, Cursor, Codex, opencode, …) — via MCP `clientInfo`.
+- Which install targets people pick, local vs global, fresh vs upgrade.
+- Which MCP tools and CLI commands get used, how often, and how often they error.
+- Which languages people index (prioritize extractor/framework work by real usage).
+- Version adoption speed, OS/arch/Node mix, native-vs-wasm SQLite backend share.
+
+## Non-goals / never collected
+
+- **No source code, ever.** No file paths, file names, repo names, symbol names, query
+  strings, search terms, or anything derived from the contents of an indexed project.
+- No IP addresses (stripped at the edge; storage disabled at the backend too).
+- No hardware fingerprinting — the machine ID is a random UUID, not derived from anything.
+- No per-keystroke / per-call event stream — usage is aggregated locally into daily rollups
+  before anything is sent.
+- No telemetry from the `codegraph-pro` fork (see "codegraph-pro rule" below).
+
+## Principles
+
+1. **The schema is the allowlist.** Client sends only the events below; the ingest Worker
+   validates against the same allowlist and drops anything else. Adding a field = PR that
+   edits this doc + `TELEMETRY.md` + the Worker allowlist together.
+2. **Telemetry may never cost the user anything**: zero added latency on the MCP tool-call
+   hot path (the repo's core invariant), zero new npm dependencies (global `fetch`, Node ≥18),
+   zero bytes on stdout (stdio is the MCP protocol channel), zero retries, zero error noise.
+   Every failure mode is silence.
+3. **Off is off.** When disabled, no process opens a socket to the telemetry endpoint — not
+   even an "opted out" ping.
+4. **First-party endpoint.** Clients only ever talk to `telemetry.getcodegraph.com`. The URL
+   baked into a published npm version POSTs there forever, so the domain must be ours; the
+   backend behind it can change without a client release.
+
+## Events
+
+Common envelope on every batch (computed once per process):
+
+| field | example | notes |
+|---|---|---|
+| `machine_id` | `b3a8…` (UUIDv4) | random, minted at first run, stored in global config |
+| `codegraph_version` | `0.9.12` | from package.json |
+| `os` / `arch` | `darwin` / `arm64` | `process.platform` / `process.arch` |
+| `node_major` | `22` | major only |
+| `ci` | `false` | `CI` env var present |
+| `schema_version` | `1` | bump when the schema changes |
+
+Event types:
+
+- **`install`** — one per installer run. Props: `targets` (e.g. `["claude","cursor"]`),
+  `scope` (`local`/`global`), `kind` (`fresh`/`upgrade`/`reinstall`), `sqlite_backend`
+  (`native`/`wasm`).
+- **`index`** — one per full index (`init`/`index`, not per `sync`). Props: `languages`
+  (names only, e.g. `["typescript","go"]`), `file_count_bucket` (`<100`, `100-1k`, `1k-10k`,
+  `10k+`), `duration_bucket` (`<10s`, `10-60s`, `1-5m`, `5m+`), `sqlite_backend`.
+- **`usage_rollup`** — the workhorse. One event per `(day, kind, name)` per machine,
+  aggregated locally. Props: `kind` (`mcp_tool`/`cli_command`), `name`
+  (e.g. `codegraph_explore`, `affected`), `count`, `error_count`, and for MCP:
+  `client_name`/`client_version` from the `initialize` handshake (`src/mcp/session.ts`
+  `case 'initialize'` — plumbing to add; currently unread).
+- **`uninstall`** — one per `uninstall`/`uninit` run (churn signal). Props: `targets`.
+
+Volume math: rollups mean monthly events ≈ active machines × active days × distinct
+tools used (single digits) — the PostHog free tier (1M events/mo) covers tens of
+thousands of MAU. There is no per-call event by design.
+
+Events are sent as PostHog **anonymous events** (`$process_person_profile: false`):
+cheaper, no person profiles, unique-machine counts still work on `distinct_id` =
+`machine_id`. Revisit only if retention tooling demands profiles.
+
+## Consent & controls
+
+Resolution order (first match wins):
+
+1. `DO_NOT_TRACK=1` (community standard — always honored) → off
+2. `CODEGRAPH_TELEMETRY=0|1` → forced off/on for that process
+3. Global config `~/.codegraph/telemetry.json` → stored user choice
+4. Default: **on**, gated by the first-run notice below
+
+Surfaces:
+
+- **Installer (interactive):** a visible clack toggle in the existing prompt flow —
+  "Share anonymous usage data? (no code, paths, or names — see TELEMETRY.md)" — default
+  yes. Choice persisted with `consent_source: "installer"`. Re-runs/upgrades respect the
+  stored choice and don't re-ask.
+- **Headless paths** (`npx codegraph init`, MCP server — no TTY, never prompt): right
+  before the **first actual send** (recording only buffers locally and stays silent — so
+  the installer's explicit toggle always precedes any notice), print one line to
+  **stderr** and record `first_run_notice_shown`:
+  `codegraph collects anonymous usage stats (no code or paths) — "codegraph telemetry off" or CODEGRAPH_TELEMETRY=0 disables. Details: TELEMETRY.md`
+- **CLI:** `codegraph telemetry status|on|off` (status prints the machine ID, current
+  state, and what decided it). Deleting `~/.codegraph/telemetry.json` resets everything,
+  including the machine ID.
+
+`~/.codegraph/telemetry.json`:
+
+```json
+{
+  "enabled": true,
+  "machine_id": "uuid-v4",
+  "consent_source": "installer | default-notice | cli",
+  "first_run_notice_shown": true,
+  "updated_at": "2026-06-12T00:00:00Z"
+}
+```
+
+(`~/.codegraph/` is new — today nothing global exists. Coexists by filename if a user ever
+indexes `$HOME` itself, since per-project data lives in `<project>/.codegraph/` with fixed
+other filenames.)
+
+## Client architecture
+
+New module `src/telemetry/` (single small module, no deps):
+
+- **Counters in memory** — recording a tool call/CLI command is an in-memory increment.
+  Nothing on the hot path touches disk or network. MCP tool handlers call
+  `telemetry.count('mcp_tool', name, ok)` and move on.
+- **Buffer** — counters persist (debounced, async) to `~/.codegraph/telemetry-queue.jsonl`.
+  Hard cap ~256 KB; on overflow drop oldest lines. Corrupt buffer → truncate, never throw.
+- **Flush** — many CLI actions end via `process.exit()`, where `beforeExit` never fires
+  and async sends die, so the design is: a tiny **synchronous append** on `process.on('exit')`
+  persists in-memory deltas (survives `process.exit`), and actual network sends happen
+  opportunistically — at the start of long-running commands (`init`/`index`/`sync`/
+  `uninit`/`upgrade`), on an unref'd interval in the long-lived MCP server/daemon, and
+  awaited-with-cap at the end of `install`/`init`/`index`/`uninit` where a second is
+  invisible. Sends POST completed-day rollups + lifecycle events to
+  `https://telemetry.getcodegraph.com/v1/events` with `AbortSignal.timeout(1500)`,
+  fire-and-forget: any response (or none) is final — no retry, no error surfaced. The
+  queue is claimed by atomic rename so concurrent processes can't double-send (a crashed
+  sender's claim merges back after an hour). `CODEGRAPH_TELEMETRY_DEBUG=1` echoes
+  payloads to stderr for development.
+- **Offline / air-gapped:** flush fails silently, buffer stays within cap, steady state is
+  a bounded file and zero noise.
+
+## Ingest endpoint (Cloudflare Worker)
+
+`telemetry.getcodegraph.com` → small Worker living at `telemetry-worker/` in this repo —
+public on purpose, so anyone can audit exactly what the endpoint stores. It ships nowhere
+with the npm package (excluded by the `files` allowlist):
+
+- `POST /v1/events`: validate against the event/property allowlist (drop unknown events,
+  strip unknown props), enforce sane sizes, **never forward or log the client IP**
+  (drop `CF-Connecting-IP`), light per-`machine_id` rate limit so abuse can't burn the
+  ingest cap, forward to `https://us.i.posthog.com/batch/` with the project key from a
+  Worker secret. Responds `204` on accept (including events dropped by the allowlist)
+  and honest `4xx` for malformed/oversized/rate-limited requests — the client treats
+  every response as final and never retries.
+- Backend today: PostHog Cloud US, free plan, "discard client IP" enabled, GeoIP disabled,
+  autocapture/replay/heatmaps/web-vitals all off. The Worker is the seam: swapping the
+  backend later is a Worker change, not a client release.
+
+## codegraph-pro rule (do not lose this in upstream merges)
+
+The private `codegraph-pro` fork ships inside customer containers whose guarantee is
+"nothing leaves the box" — including telemetry. In the fork, telemetry must be **default-off
+and not enableable by the installer** (compile-time constant or stripped module), and the
+container sets `CODEGRAPH_TELEMETRY=0` as belt-and-braces. This rule lives in the fork's
+CLAUDE.md and must survive every upstream merge.
+
+## Rollout
+
+1. This doc + repo-root `TELEMETRY.md` (user-facing field-by-field list) + README section.
+2. Worker + DNS live first (so the first shipping client never 404s), PostHog dashboards:
+   weekly active machines, installs by target, usage by tool × client, version adoption,
+   languages indexed.
+3. Client module + config + `codegraph telemetry` subcommand + MCP `clientInfo` plumbing.
+4. Installer toggle + first-run notice. CHANGELOG entry under `[Unreleased]` announcing
+   telemetry, the default, and every off-switch. Release.
+
+Tests (no DB mocking, per repo convention; fetch mocked at `globalThis.fetch`):
+consent precedence (env > config > default), off ⇒ zero fetch calls, rollup aggregation
+across days, buffer cap + corrupt-buffer recovery, no-stdout invariant under MCP transport,
+flush abort honors timeout, installer toggle persists + re-run doesn't re-ask
+(`__tests__/installer-targets.test.ts` per house rules).
+
+## Open questions
+
+- Exact installer copy / notice wording — maintainer call before release.
+- `uninstall` event: keep or drop? (Honest churn signal vs. "pinging on the way out" optics.)
+- CI events are kept (tagged `ci: true`) because engine-in-CI is a real usage mode — revisit
+  if it ever dominates volume.

+ 88 - 0
src/bin/codegraph.ts

@@ -34,6 +34,7 @@ import { getGlyphs } from '../ui/glyphs';
 import { buildNode25BlockBanner, buildNodeTooOldBanner, MIN_NODE_MAJOR } from './node-version-check';
 import { relaunchWithWasmRuntimeFlagsIfNeeded } from '../extraction/wasm-runtime-flags';
 import { EXTRACTION_VERSION } from '../extraction/extraction-version';
+import { getTelemetry, TELEMETRY_DOCS, recordIndexEvent } from '../telemetry';
 
 // Lazy-load heavy modules (CodeGraph, runInstaller) to keep CLI startup fast.
 async function loadCodeGraph(): Promise<typeof import('../index')> {
@@ -153,6 +154,27 @@ program
   .description('Code intelligence and knowledge graph for any codebase')
   .version(packageJson.version);
 
+// Anonymous usage telemetry (see TELEMETRY.md): record the invoked subcommand
+// NAME only — never arguments or paths. Counts buffer locally; network sends
+// piggyback on commands that run long anyway (quick commands only append to
+// the local buffer at exit, costing nothing).
+// install/uninstall are absent on purpose: the installer flushes at its own
+// end, AFTER its consent prompt — a flush here would fire the first-run
+// notice before the user ever sees the toggle.
+const TELEMETRY_FLUSH_COMMANDS = new Set(['init', 'uninit', 'index', 'sync', 'upgrade']);
+program.hook('preAction', (_thisCommand, actionCommand) => {
+  try {
+    // The detached daemon re-invokes `serve --mcp` internally — not a user action.
+    if (process.env.CODEGRAPH_DAEMON_INTERNAL) return;
+    const name = actionCommand.name();
+    if (name === 'telemetry') return; // managing telemetry is not usage
+    getTelemetry().recordUsage('cli_command', name, true);
+    if (TELEMETRY_FLUSH_COMMANDS.has(name)) getTelemetry().maybeFlush();
+  } catch {
+    /* telemetry must never break the CLI */
+  }
+});
+
 // =============================================================================
 // Helper Functions
 // =============================================================================
@@ -409,6 +431,19 @@ function writeErrorLog(projectPath: string, errors: Array<{ message: string; fil
   fs.writeFileSync(logPath, lines.join('\n') + '\n');
 }
 
+/**
+ * Telemetry for a completed full index (see TELEMETRY.md). The bounded flush
+ * keeps init/index responsive (these commands just ran for seconds anyway)
+ * while delivering the event promptly.
+ */
+async function recordIndexTelemetry(
+  cg: { getStats(): { filesByLanguage: Record<string, number> }; getBackend(): string },
+  result: IndexResult,
+): Promise<void> {
+  recordIndexEvent(cg, result);
+  await getTelemetry().flushNow();
+}
+
 // =============================================================================
 // Commands
 // =============================================================================
@@ -461,6 +496,7 @@ program
         await progress.stop();
       }
       printIndexResult(clack, result, projectPath);
+      await recordIndexTelemetry(cg, result);
 
       try {
         const { offerWatchFallback } = await import('../installer');
@@ -523,6 +559,13 @@ program
       } catch { /* non-fatal */ }
 
       success(`Removed CodeGraph from ${projectPath}`);
+
+      // Churn signal — and flush now, since after an uninit there may be no
+      // "next run" to deliver it.
+      try {
+        getTelemetry().recordLifecycle('uninstall', {});
+        await getTelemetry().flushNow();
+      } catch { /* non-fatal */ }
     } catch (err) {
       error(`Failed to uninitialize: ${err instanceof Error ? err.message : String(err)}`);
       process.exit(1);
@@ -585,6 +628,7 @@ program
       }
 
       printIndexResult(clack, result, projectPath);
+      await recordIndexTelemetry(cg, result);
 
       if (!result.success) {
         process.exit(1);
@@ -1784,6 +1828,50 @@ program
     }
   });
 
+/**
+ * codegraph telemetry [on|off|status]
+ */
+program
+  .command('telemetry [action]')
+  .description('Show or change anonymous usage telemetry (status, on, off)')
+  .action((action?: string) => {
+    const t = getTelemetry();
+
+    if (action === 'on' || action === 'off') {
+      t.setEnabled(action === 'on', 'cli');
+      if (action === 'on') {
+        success('Telemetry enabled — anonymous usage stats only (no code, paths, or names).');
+      } else {
+        success('Telemetry disabled. Buffered, unsent data was deleted.');
+      }
+      const effective = t.getStatus();
+      if (effective.decidedBy === 'DO_NOT_TRACK' || effective.decidedBy === 'CODEGRAPH_TELEMETRY') {
+        warn(
+          `The ${effective.decidedBy} environment variable overrides this choice — ` +
+          `effective state right now: ${effective.enabled ? 'enabled' : 'disabled'}.`
+        );
+      }
+      return;
+    }
+
+    if (action !== undefined && action !== 'status') {
+      error(`Unknown action: ${action} (expected status, on, or off)`);
+      process.exit(1);
+    }
+
+    const s = t.getStatus();
+    const decidedBy: Record<typeof s.decidedBy, string> = {
+      DO_NOT_TRACK: 'DO_NOT_TRACK environment variable',
+      CODEGRAPH_TELEMETRY: 'CODEGRAPH_TELEMETRY environment variable',
+      config: 'your saved choice',
+      default: 'default',
+    };
+    console.log(`\nTelemetry: ${s.enabled ? chalk.green('enabled') : chalk.yellow('disabled')} ${chalk.dim(`(${decidedBy[s.decidedBy]})`)}`);
+    console.log(`Machine ID: ${s.machineId ?? chalk.dim('(random UUID, created on first use)')}`);
+    console.log(`Config:     ${s.configPath}`);
+    console.log(chalk.dim(`\nExactly what is collected (and never collected): ${TELEMETRY_DOCS}\n`));
+  });
+
 /**
  * codegraph upgrade [version]
  *

+ 53 - 0
src/installer/index.ts

@@ -29,6 +29,7 @@ import { getGlyphs } from '../ui/glyphs';
 import { watchDisabledReason } from '../sync/watch-policy';
 import { isGitRepo, isSyncHookInstalled, installGitSyncHook } from '../sync/git-hooks';
 import { getCodeGraphDir, codeGraphDirName } from '../directory';
+import { getTelemetry, recordIndexEvent, TELEMETRY_DOCS } from '../telemetry';
 
 // Backwards-compat: keep these named exports — downstream code may
 // import them. The shim in `config-writer.ts` continues to re-export
@@ -181,7 +182,33 @@ export async function runInstallerWithOptions(opts: RunInstallerOptions): Promis
     autoAllow = false;
   }
 
+  // Step 4½: anonymous usage telemetry — a visible default-on toggle, asked
+  // exactly once. Skipped when an env var (DO_NOT_TRACK / CODEGRAPH_TELEMETRY)
+  // already decides, or when a previous run stored a choice — re-runs and
+  // upgrades never re-ask.
+  if (!useDefaults && getTelemetry().getStatus().decidedBy === 'default' && !getTelemetry().hasStoredChoice()) {
+    const share = await clack.confirm({
+      message: 'Share anonymous usage stats? (No code, paths, or names — see TELEMETRY.md)',
+      initialValue: true,
+    });
+    if (clack.isCancel(share)) {
+      // Don't kill the install over the telemetry question — leave it
+      // undecided (the documented default + first-run notice applies later).
+      clack.log.info('Skipped — manage anytime with `codegraph telemetry on|off`.');
+    } else {
+      getTelemetry().setEnabled(share, 'installer');
+      clack.log.info(
+        share
+          ? `Thanks! Exactly what is collected: ${TELEMETRY_DOCS}`
+          : 'Telemetry disabled — nothing will be collected or sent.',
+      );
+    }
+  }
+
   // Step 5: per-target install loop.
+  const installedIds: TargetId[] = [];
+  let sawCreated = false;
+  let sawUpdated = false;
   for (const target of targets) {
     if (!target.supportsLocation(location)) {
       clack.log.warn(
@@ -190,7 +217,10 @@ export async function runInstallerWithOptions(opts: RunInstallerOptions): Promis
       continue;
     }
     const result = target.install(location, { autoAllow });
+    installedIds.push(target.id);
     for (const file of result.files) {
+      if (file.action === 'created') sawCreated = true;
+      if (file.action === 'updated') sawUpdated = true;
       const verb = file.action === 'unchanged'
         ? 'Unchanged'
         : file.action === 'created' ? 'Created'
@@ -203,6 +233,16 @@ export async function runInstallerWithOptions(opts: RunInstallerOptions): Promis
     }
   }
 
+  // Telemetry: which agents were configured, where, fresh-vs-upgrade (derived
+  // from the file actions above). Target IDs and the location enum only.
+  if (installedIds.length > 0) {
+    getTelemetry().recordLifecycle('install', {
+      targets: installedIds,
+      scope: location,
+      kind: sawCreated ? 'fresh' : sawUpdated ? 'upgrade' : 'reinstall',
+    });
+  }
+
   // Step 6: for local install, initialize the project.
   if (location === 'local') {
     await initializeLocalProject(clack, useDefaults);
@@ -212,6 +252,10 @@ export async function runInstallerWithOptions(opts: RunInstallerOptions): Promis
     clack.note('cd your-project\ncodegraph init -i', 'Quick start');
   }
 
+  // Deliver buffered telemetry while we're already in a long interactive
+  // command — bounded (~1.5s worst case), invisible after a multi-second install.
+  await getTelemetry().flushNow();
+
   const finalNote = targets.length > 0
     ? `Done! Restart your agent${targets.length > 1 ? 's' : ''} to use CodeGraph.`
     : 'Done!';
@@ -367,6 +411,13 @@ export async function runUninstaller(opts: RunUninstallerOptions): Promise<void>
     clack.log.info(`The ${codeGraphDirName()}/ index for this project is still here. Run \`codegraph uninit\` to delete it.`);
   }
 
+  // Telemetry churn signal (agent IDs only) — flush now, since after an
+  // uninstall there is usually no "next run" to deliver it.
+  if (removed.length > 0) {
+    getTelemetry().recordLifecycle('uninstall', { targets: removed.map((r) => r.id) });
+    await getTelemetry().flushNow();
+  }
+
   // Step 5: summary.
   if (removed.length > 0) {
     const names = removed.map((r) => r.displayName).join(', ');
@@ -488,6 +539,8 @@ async function initializeLocalProject(
     clack.log.success(`Indexed ${formatNumber(result.filesIndexed)} files (${formatNumber(result.nodesCreated)} symbols)`);
   }
 
+  recordIndexEvent(cg, result); // buffered; the installer flushes at the end
+
   cg.close();
 
   await offerWatchFallback(clack, projectPath, { yes: useDefaults });

+ 6 - 0
src/mcp/index.ts

@@ -49,6 +49,7 @@ import {
 } from './daemon';
 import { connectWithHello, runLocalHandshakeProxy } from './proxy';
 import { getDaemonSocketPath } from './daemon-paths';
+import { getTelemetry } from '../telemetry';
 import { supervisionLostReason } from './ppid-watchdog';
 import { treatStdinFailureAsShutdown } from './stdin-teardown';
 import { HOST_PPID_ENV } from '../extraction/wasm-runtime-flags';
@@ -245,6 +246,11 @@ export class MCPServer {
    * mode — a misbehaving daemon must never block a session from starting.
    */
   async start(): Promise<void> {
+    // Long-lived process (direct / proxy / daemon alike): flush buffered
+    // telemetry opportunistically. Fire-and-forget + unref'd — adds nothing
+    // to the handshake path and never keeps the process alive.
+    getTelemetry().startInterval();
+
     // The detached daemon process itself. Checked before the opt-out so the
     // daemon honors the same env it was spawned with (it never sets NO_DAEMON).
     if (daemonInternalSet()) {

+ 13 - 0
src/mcp/proxy.ts

@@ -28,6 +28,7 @@ import { CodeGraphPackageVersion } from './version';
 import { SERVER_INFO, PROTOCOL_VERSION } from './session';
 import { SERVER_INSTRUCTIONS } from './server-instructions';
 import { getStaticTools } from './tools';
+import { getTelemetry, ClientInfo } from '../telemetry';
 import type { MCPEngine } from './engine';
 
 /** Default poll cadence for the PPID watchdog (same as the direct server). */
@@ -204,6 +205,10 @@ export async function runLocalHandshakeProxy(deps: LocalHandshakeDeps): Promise<
   let daemonStatus: 'connecting' | 'ready' | 'failed' = 'connecting';
   let daemonSocket: net.Socket | null = null;
   let clientInitId: unknown = undefined;   // suppress the daemon's reply to the forwarded initialize
+  // Telemetry attribution for the in-process fallback only — calls routed to
+  // the daemon are counted by the daemon's own session (which receives the
+  // forwarded initialize, clientInfo included), never double-counted here.
+  let telemetryClient: ClientInfo | undefined;
   const pending: string[] = [];            // client lines buffered until the daemon resolves
   let engine: MCPEngine | null = null;
   let engineReady: Promise<void> | null = null;
@@ -246,6 +251,7 @@ export async function runLocalHandshakeProxy(deps: LocalHandshakeDeps): Promise<
         const params = (msg.params || {}) as { name: string; arguments?: Record<string, unknown> };
         const result = await engine!.getToolHandler().execute(params.name, params.arguments || {});
         writeClient({ jsonrpc: '2.0', id, result });
+        getTelemetry().recordUsage('mcp_tool', params.name, !result.isError, telemetryClient);
       } catch (err) {
         writeClient({ jsonrpc: '2.0', id, error: { code: -32603, message: err instanceof Error ? err.message : String(err) } });
       }
@@ -282,6 +288,13 @@ export async function runLocalHandshakeProxy(deps: LocalHandshakeDeps): Promise<
       let msg: JsonRpc; try { msg = JSON.parse(line) as JsonRpc; } catch { routeToDaemon(line); continue; }
       if (msg.method === 'initialize') {
         clientInitId = msg.id;
+        const initParams = (msg.params ?? {}) as { clientInfo?: { name?: unknown; version?: unknown } };
+        if (initParams.clientInfo) {
+          telemetryClient = {
+            name: typeof initParams.clientInfo.name === 'string' ? initParams.clientInfo.name : undefined,
+            version: typeof initParams.clientInfo.version === 'string' ? initParams.clientInfo.version : undefined,
+          };
+        }
         writeClient({ jsonrpc: '2.0', id: msg.id, result: { protocolVersion: PROTOCOL_VERSION, capabilities: { tools: {} }, serverInfo: SERVER_INFO, instructions: SERVER_INSTRUCTIONS } });
         routeToDaemon(line); // prime the daemon so it resolves the project (its reply is suppressed below)
       } else if (msg.method === 'tools/list') {

+ 13 - 0
src/mcp/session.ts

@@ -19,6 +19,7 @@ import { tools } from './tools';
 import { SERVER_INSTRUCTIONS, SERVER_INSTRUCTIONS_UNINDEXED } from './server-instructions';
 import { CodeGraphPackageVersion } from './version';
 import { findNearestCodeGraphRoot } from '../directory';
+import { getTelemetry, ClientInfo } from '../telemetry';
 
 /**
  * MCP Server Info — kept on the session because some clients log it. The
@@ -82,6 +83,8 @@ export interface MCPSessionOptions {
  */
 export class MCPSession {
   private clientSupportsRoots = false;
+  /** From the initialize handshake — attributes usage rollups to the agent host. */
+  private clientInfo: ClientInfo | undefined;
   private rootsAttempted = false;
   private resolvePromise: Promise<void> | null = null;
   private explicitProjectPath: string | null;
@@ -162,9 +165,16 @@ export class MCPSession {
       rootUri?: string;
       workspaceFolders?: Array<{ uri: string; name: string }>;
       capabilities?: { roots?: unknown };
+      clientInfo?: { name?: unknown; version?: unknown };
     } | undefined;
 
     this.clientSupportsRoots = !!params?.capabilities?.roots;
+    if (params?.clientInfo) {
+      this.clientInfo = {
+        name: typeof params.clientInfo.name === 'string' ? params.clientInfo.name : undefined,
+        version: typeof params.clientInfo.version === 'string' ? params.clientInfo.version : undefined,
+      };
+    }
 
     // Explicit project signal, strongest first: client-provided rootUri /
     // workspaceFolders (LSP-style), else the --path the server was launched
@@ -249,6 +259,9 @@ export class MCPSession {
 
     const result = await this.engine.getToolHandler().execute(toolName, toolArgs);
     this.transport.sendResult(request.id, result);
+    // After the reply is on the wire — telemetry must never delay a tool
+    // response (in-memory increment only; see src/telemetry).
+    getTelemetry().recordUsage('mcp_tool', toolName, !result.isError, this.clientInfo);
   }
 
   /**

+ 549 - 0
src/telemetry/index.ts

@@ -0,0 +1,549 @@
+/**
+ * Anonymous usage telemetry — client side.
+ *
+ * The contract for what may be collected lives in docs/design/telemetry.md
+ * (and user-facing TELEMETRY.md); the ingest endpoint that enforces it is
+ * public at telemetry-worker/. This module honors four invariants:
+ *
+ * 1. Zero hot-path cost: recording is an in-memory increment. Disk writes are
+ *    a tiny synchronous append at process exit (works under `process.exit()`,
+ *    where `beforeExit` never fires); network sends happen opportunistically
+ *    (startup of long-running commands, daemon interval, bounded await at the
+ *    end of install/init) and are fire-and-forget everywhere else.
+ * 2. Zero stdout: stdio is the MCP protocol channel. Notices and debug output
+ *    go to stderr only.
+ * 3. Off is off: when disabled, nothing is recorded, nothing is sent, and no
+ *    socket is opened — there is no "opted out" ping. Turning telemetry off
+ *    also deletes any buffered, unsent data.
+ * 4. Fail silent: offline, endpoint down, disk full — every failure mode is
+ *    silence, never a retry loop, never an error surfaced to the user/agent.
+ *
+ * Usage counts aggregate locally into per-day rollups; only *completed* (UTC)
+ * days are sent, so volume scales with active machines, not with tool calls.
+ */
+
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+import { randomUUID } from 'crypto';
+
+export const TELEMETRY_ENDPOINT = 'https://telemetry.getcodegraph.com/v1/events';
+export const TELEMETRY_DOCS = 'https://github.com/colbymchenry/codegraph/blob/main/TELEMETRY.md';
+
+const SCHEMA_VERSION = 1;
+const MAX_BUFFER_BYTES = 256 * 1024;
+const MAX_EVENTS_PER_REQUEST = 100;
+const DEFAULT_FLUSH_TIMEOUT_MS = 1500;
+/** A crashed sender's claimed file is merged back after this long. */
+const STALE_CLAIM_MS = 60 * 60_000;
+
+export type UsageKind = 'mcp_tool' | 'cli_command';
+export type LifecycleEvent = 'install' | 'index' | 'uninstall';
+
+/** Coarse buckets — exact counts are deliberately not collected. */
+export function bucketFileCount(n: number): '<100' | '100-1k' | '1k-10k' | '10k+' {
+  if (n < 100) return '<100';
+  if (n < 1000) return '100-1k';
+  if (n < 10000) return '1k-10k';
+  return '10k+';
+}
+
+export function bucketDuration(ms: number): '<10s' | '10-60s' | '1-5m' | '5m+' {
+  if (ms < 10_000) return '<10s';
+  if (ms < 60_000) return '10-60s';
+  if (ms < 300_000) return '1-5m';
+  return '5m+';
+}
+
+/** Collapse a backend identifier (e.g. `node-sqlite`) to the schema's enum. */
+export function backendKind(backend: string): 'native' | 'wasm' {
+  return backend.toLowerCase().includes('wasm') ? 'wasm' : 'native';
+}
+
+/**
+ * Shared "a full index completed" event (CLI init/index + installer local
+ * init): language names and coarse buckets only — never paths, file names,
+ * or exact counts. Structurally typed so callers don't need engine imports.
+ */
+export function recordIndexEvent(
+  cg: { getStats(): { filesByLanguage: Record<string, number> }; getBackend(): string },
+  result: { filesIndexed: number; durationMs: number },
+): void {
+  try {
+    const languages = Object.entries(cg.getStats().filesByLanguage)
+      .filter(([, count]) => count > 0)
+      .map(([lang]) => lang);
+    getTelemetry().recordLifecycle('index', {
+      languages,
+      file_count_bucket: bucketFileCount(result.filesIndexed),
+      duration_bucket: bucketDuration(result.durationMs),
+      sqlite_backend: backendKind(cg.getBackend()),
+    });
+  } catch {
+    /* telemetry must never break indexing */
+  }
+}
+
+export interface ClientInfo {
+  name?: string;
+  version?: string;
+}
+
+interface ConfigFile {
+  enabled: boolean;
+  machine_id: string;
+  consent_source: 'installer' | 'default-notice' | 'cli';
+  first_run_notice_shown?: boolean;
+  updated_at: string;
+}
+
+export interface TelemetryStatus {
+  enabled: boolean;
+  /** What decided the current state — mirrors the precedence order. */
+  decidedBy: 'DO_NOT_TRACK' | 'CODEGRAPH_TELEMETRY' | 'config' | 'default';
+  machineId: string | null;
+  configPath: string;
+}
+
+/** One buffered line: either a usage-count delta or a lifecycle event. */
+interface CountLine {
+  v: number;
+  d: string; // UTC day YYYY-MM-DD
+  k: UsageKind;
+  n: string;
+  c: number; // calls
+  e: number; // errors
+  cn?: string; // client name (mcp_tool only)
+  cv?: string; // client version
+}
+interface EventLine {
+  v: number;
+  ev: LifecycleEvent;
+  ts: string;
+  props: Record<string, unknown>;
+}
+type BufferLine = CountLine | EventLine;
+
+export interface TelemetryOptions {
+  /** Global state dir; defaults to ~/.codegraph. Tests inject a temp dir. */
+  dir?: string;
+  fetchImpl?: typeof globalThis.fetch;
+  now?: () => Date;
+  env?: NodeJS.ProcessEnv;
+  stderr?: (line: string) => void;
+  /** Tests opt out so short-lived instances don't pile onto process 'exit'. */
+  installExitHook?: boolean;
+}
+
+// One process-level 'exit' listener for ALL instances (in practice: the
+// singleton) — N instances must not mean N listeners on process.
+const exitInstances = new Set<Telemetry>();
+let exitListenerRegistered = false;
+function registerForExit(instance: Telemetry): void {
+  exitInstances.add(instance);
+  if (!exitListenerRegistered) {
+    exitListenerRegistered = true;
+    // 'exit' fires under process.exit() too (unlike beforeExit); handlers must
+    // be synchronous — persistSync is a single small file write.
+    process.on('exit', () => {
+      for (const i of exitInstances) i.persistSync();
+    });
+  }
+}
+
+export class Telemetry {
+  private readonly dir: string;
+  private readonly fetchImpl: typeof globalThis.fetch;
+  private readonly now: () => Date;
+  private readonly env: NodeJS.ProcessEnv;
+  private readonly writeStderr: (line: string) => void;
+
+  private counts = new Map<string, CountLine>();
+  private events: EventLine[] = [];
+  private readonly installExitHook: boolean;
+  private exitHookInstalled = false;
+  private configCache: ConfigFile | null | undefined; // undefined = not read yet
+  private intervalHandle: NodeJS.Timeout | null = null;
+
+  constructor(opts: TelemetryOptions = {}) {
+    this.dir = opts.dir ?? path.join(os.homedir(), '.codegraph');
+    this.fetchImpl = opts.fetchImpl ?? globalThis.fetch;
+    this.now = opts.now ?? (() => new Date());
+    this.env = opts.env ?? process.env;
+    this.writeStderr = opts.stderr ?? ((line) => process.stderr.write(line));
+    this.installExitHook = opts.installExitHook ?? true;
+  }
+
+  // ---------------------------------------------------------------- consent
+
+  get configPath(): string {
+    return path.join(this.dir, 'telemetry.json');
+  }
+  get queuePath(): string {
+    return path.join(this.dir, 'telemetry-queue.jsonl');
+  }
+
+  /**
+   * Resolution order (first match wins) — keep in sync with TELEMETRY.md:
+   * DO_NOT_TRACK=1 > CODEGRAPH_TELEMETRY=0|1 > stored config > default on.
+   */
+  getStatus(): TelemetryStatus {
+    const config = this.readConfig();
+    const machineId = config?.machine_id ?? null;
+    const dnt = this.env.DO_NOT_TRACK;
+    if (dnt !== undefined && dnt !== '' && dnt !== '0' && dnt.toLowerCase() !== 'false') {
+      return { enabled: false, decidedBy: 'DO_NOT_TRACK', machineId, configPath: this.configPath };
+    }
+    const forced = this.env.CODEGRAPH_TELEMETRY;
+    if (forced !== undefined && forced !== '') {
+      const on = forced !== '0' && forced.toLowerCase() !== 'false';
+      return { enabled: on, decidedBy: 'CODEGRAPH_TELEMETRY', machineId, configPath: this.configPath };
+    }
+    if (config) {
+      return { enabled: config.enabled, decidedBy: 'config', machineId, configPath: this.configPath };
+    }
+    return { enabled: true, decidedBy: 'default', machineId, configPath: this.configPath };
+  }
+
+  isEnabled(): boolean {
+    return this.getStatus().enabled;
+  }
+
+  /**
+   * Persist an explicit user choice (installer toggle or `codegraph
+   * telemetry on|off`). Turning telemetry off also deletes any buffered,
+   * unsent data — off means off.
+   */
+  setEnabled(enabled: boolean, source: 'installer' | 'cli'): void {
+    const existing = this.readConfig();
+    this.writeConfig({
+      enabled,
+      machine_id: existing?.machine_id ?? randomUUID(),
+      consent_source: source,
+      first_run_notice_shown: true,
+      updated_at: this.now().toISOString(),
+    });
+    if (!enabled) {
+      try { fs.rmSync(this.queuePath, { force: true }); } catch { /* fail silent */ }
+    }
+  }
+
+  /** True once any consent decision (or the first-run notice) is on disk. */
+  hasStoredChoice(): boolean {
+    return this.readConfig() !== null;
+  }
+
+  // -------------------------------------------------------------- recording
+
+  /** In-memory increment — safe on the MCP tool-call hot path. */
+  recordUsage(kind: UsageKind, name: string, ok: boolean, client?: ClientInfo): void {
+    if (!this.isEnabled()) return;
+    const day = this.utcDay();
+    const cn = client?.name?.slice(0, 64);
+    const cv = client?.version?.slice(0, 32);
+    const key = [day, kind, name, cn ?? '', cv ?? ''].join('');
+    const line = this.counts.get(key);
+    if (line) {
+      line.c += 1;
+      if (!ok) line.e += 1;
+    } else {
+      const fresh: CountLine = { v: SCHEMA_VERSION, d: day, k: kind, n: name.slice(0, 64), c: 1, e: ok ? 0 : 1 };
+      if (cn) fresh.cn = cn;
+      if (cv) fresh.cv = cv;
+      this.counts.set(key, fresh);
+    }
+    this.ensureExitHook();
+  }
+
+  /** install / index / uninstall — buffered like everything else. */
+  recordLifecycle(event: LifecycleEvent, props: Record<string, unknown>): void {
+    if (!this.isEnabled()) return;
+    this.events.push({ v: SCHEMA_VERSION, ev: event, ts: this.now().toISOString(), props });
+    this.ensureExitHook();
+  }
+
+  // ---------------------------------------------------------------- sending
+
+  /**
+   * Fire-and-forget send of everything sendable. Never throws, never logs
+   * above debug. Safe to call at startup of long-running commands.
+   */
+  maybeFlush(): void {
+    void this.flushNow().catch(() => { /* fail silent */ });
+  }
+
+  /**
+   * Drain in-memory state to the buffer, then send completed-day rollups and
+   * lifecycle events. Bounded by `timeoutMs`; leftovers stay buffered for the
+   * next process. Awaited only where latency is invisible (install/init).
+   */
+  async flushNow(timeoutMs: number = DEFAULT_FLUSH_TIMEOUT_MS): Promise<void> {
+    if (!this.isEnabled()) return;
+    try {
+      this.persistSync();
+      this.recoverStaleClaims();
+      const claim = this.claimQueue();
+      if (!claim) return;
+      const { claimPath, lines } = claim;
+      const today = this.utcDay();
+      const sendable: BufferLine[] = [];
+      const keep: BufferLine[] = [];
+      for (const line of lines) {
+        if ('ev' in line) sendable.push(line);
+        else if (line.d < today) sendable.push(line);
+        else keep.push(line);
+      }
+      let failed: BufferLine[] = [];
+      if (sendable.length > 0) {
+        // Consent gate: the one-time notice precedes the FIRST bytes that
+        // ever leave the machine (and mints the machine id). Recording only
+        // buffers locally, so it stays silent — this lets the installer show
+        // its explicit consent toggle before any notice can fire, instead of
+        // the preAction usage count pre-empting it. An explicit installer/CLI
+        // choice sets first_run_notice_shown and suppresses this permanently.
+        this.firstRunNotice();
+        failed = await this.send(sendable, timeoutMs);
+      }
+      // Whatever didn't go out returns to the queue (append — writers may
+      // have created a fresh queue file while we held the claim).
+      const back = [...failed, ...keep];
+      if (back.length > 0) this.appendLines(back);
+      try { fs.rmSync(claimPath, { force: true }); } catch { /* fail silent */ }
+    } catch {
+      /* fail silent */
+    }
+  }
+
+  /**
+   * Periodic flush for long-lived processes (MCP daemon / serve). Unref'd so
+   * it never keeps the process alive.
+   */
+  startInterval(everyMs: number = 6 * 60 * 60_000): void {
+    if (this.intervalHandle || !this.isEnabled()) return;
+    this.maybeFlush();
+    this.intervalHandle = setInterval(() => this.maybeFlush(), everyMs);
+    this.intervalHandle.unref();
+  }
+
+  stopInterval(): void {
+    if (this.intervalHandle) {
+      clearInterval(this.intervalHandle);
+      this.intervalHandle = null;
+    }
+  }
+
+  // -------------------------------------------------------------- internals
+
+  private utcDay(): string {
+    return this.now().toISOString().slice(0, 10);
+  }
+
+  private readConfig(): ConfigFile | null {
+    if (this.configCache !== undefined) return this.configCache;
+    try {
+      const raw = JSON.parse(fs.readFileSync(this.configPath, 'utf8')) as ConfigFile;
+      this.configCache = typeof raw.machine_id === 'string' && typeof raw.enabled === 'boolean' ? raw : null;
+    } catch {
+      this.configCache = null;
+    }
+    return this.configCache;
+  }
+
+  private writeConfig(config: ConfigFile): void {
+    try {
+      fs.mkdirSync(this.dir, { recursive: true, mode: 0o700 });
+      fs.writeFileSync(this.configPath, JSON.stringify(config, null, 2) + '\n');
+      this.configCache = config;
+    } catch {
+      /* fail silent */
+    }
+  }
+
+  /**
+   * Default-on consent is gated by a one-time stderr notice (interactive
+   * installs record their choice explicitly and never reach this).
+   */
+  private firstRunNotice(): void {
+    const config = this.readConfig();
+    if (config?.first_run_notice_shown) return;
+    if (!config) {
+      this.writeConfig({
+        enabled: true,
+        machine_id: randomUUID(),
+        consent_source: 'default-notice',
+        first_run_notice_shown: true,
+        updated_at: this.now().toISOString(),
+      });
+    } else {
+      this.writeConfig({ ...config, first_run_notice_shown: true, updated_at: this.now().toISOString() });
+    }
+    this.writeStderr(
+      `codegraph collects anonymous usage stats (no code, paths, or names) — ` +
+      `"codegraph telemetry off" or CODEGRAPH_TELEMETRY=0 disables. Details: ${TELEMETRY_DOCS}\n`,
+    );
+  }
+
+  /**
+   * Synchronous, tiny, exit-safe: drain in-memory deltas to the JSONL queue.
+   * Runs on `process.on('exit')`, so it must never be async or slow.
+   */
+  persistSync(): void {
+    if (this.counts.size === 0 && this.events.length === 0) return;
+    const lines: BufferLine[] = [...this.counts.values(), ...this.events];
+    this.counts.clear();
+    this.events = [];
+    // Re-check at persist time: `codegraph telemetry off` mid-process must not
+    // have its own invocation resurrect the queue file at exit.
+    if (!this.isEnabled()) return;
+    this.appendLines(lines);
+  }
+
+  private appendLines(lines: BufferLine[]): void {
+    try {
+      fs.mkdirSync(this.dir, { recursive: true, mode: 0o700 });
+      const payload = lines.map((l) => JSON.stringify(l)).join('\n') + '\n';
+      // Cap the buffer: drop oldest lines first (telemetry is best-effort —
+      // bounded disk use beats completeness).
+      let existing = '';
+      try { existing = fs.readFileSync(this.queuePath, 'utf8'); } catch { /* no queue yet */ }
+      let combined = existing + payload;
+      if (combined.length > MAX_BUFFER_BYTES) {
+        combined = combined.slice(combined.length - MAX_BUFFER_BYTES);
+        combined = combined.slice(combined.indexOf('\n') + 1); // drop the partial first line
+      }
+      fs.writeFileSync(this.queuePath, combined);
+    } catch {
+      /* fail silent */
+    }
+  }
+
+  /**
+   * Atomically claim the queue for sending (rename). Concurrent processes
+   * can't double-send; a crash mid-send leaves a claim file that
+   * `recoverStaleClaims` merges back after an hour.
+   */
+  private claimQueue(): { claimPath: string; lines: BufferLine[] } | null {
+    const claimPath = path.join(this.dir, `telemetry-queue.sending.${process.pid}.jsonl`);
+    try {
+      fs.renameSync(this.queuePath, claimPath);
+    } catch {
+      return null; // no queue, or another process just claimed it
+    }
+    const lines: BufferLine[] = [];
+    try {
+      for (const raw of fs.readFileSync(claimPath, 'utf8').split('\n')) {
+        if (!raw.trim()) continue;
+        try {
+          const parsed = JSON.parse(raw) as BufferLine;
+          if (parsed && typeof parsed === 'object' && parsed.v === SCHEMA_VERSION) lines.push(parsed);
+        } catch {
+          /* skip corrupt line */
+        }
+      }
+    } catch {
+      /* unreadable claim — treat as empty; file removed by caller */
+    }
+    return { claimPath, lines };
+  }
+
+  private recoverStaleClaims(): void {
+    try {
+      const cutoff = this.now().getTime() - STALE_CLAIM_MS;
+      for (const name of fs.readdirSync(this.dir)) {
+        if (!name.startsWith('telemetry-queue.sending.')) continue;
+        const full = path.join(this.dir, name);
+        try {
+          if (fs.statSync(full).mtimeMs < cutoff) {
+            const content = fs.readFileSync(full, 'utf8');
+            fs.rmSync(full, { force: true });
+            if (content.trim()) fs.appendFileSync(this.queuePath, content.endsWith('\n') ? content : content + '\n');
+          }
+        } catch {
+          /* fail silent */
+        }
+      }
+    } catch {
+      /* fail silent */
+    }
+  }
+
+  /** Returns the lines that did NOT make it out (to be re-queued). */
+  private async send(lines: BufferLine[], timeoutMs: number): Promise<BufferLine[]> {
+    const config = this.readConfig();
+    if (!config) return [];
+    const events = lines.map((line) =>
+      'ev' in line
+        ? { event: line.ev, ts: line.ts, props: line.props }
+        : {
+            event: 'usage_rollup',
+            ts: `${line.d}T12:00:00.000Z`,
+            props: {
+              kind: line.k,
+              name: line.n,
+              count: line.c,
+              error_count: line.e,
+              ...(line.cn ? { client_name: line.cn } : {}),
+              ...(line.cv ? { client_version: line.cv } : {}),
+            },
+          },
+    );
+    const envelope = {
+      machine_id: config.machine_id,
+      codegraph_version: this.packageVersion(),
+      os: process.platform,
+      arch: process.arch,
+      node_major: parseInt(process.versions.node.split('.')[0] ?? '0', 10),
+      ci: this.env.CI !== undefined && this.env.CI !== '' && this.env.CI !== '0' && this.env.CI !== 'false',
+      schema_version: SCHEMA_VERSION,
+    };
+    const endpoint = this.env.CODEGRAPH_TELEMETRY_ENDPOINT || TELEMETRY_ENDPOINT;
+    for (let i = 0; i < events.length; i += MAX_EVENTS_PER_REQUEST) {
+      const chunk = events.slice(i, i + MAX_EVENTS_PER_REQUEST);
+      const body = JSON.stringify({ ...envelope, events: chunk });
+      this.debug(`POST ${endpoint} (${chunk.length} events)`);
+      try {
+        // Any response — 204, 4xx, anything — is final. No retries.
+        await this.fetchImpl(endpoint, {
+          method: 'POST',
+          headers: { 'content-type': 'application/json' },
+          body,
+          signal: AbortSignal.timeout(timeoutMs),
+        });
+      } catch (err) {
+        this.debug(`send failed: ${String(err)}`);
+        return lines.slice(i); // network failure: re-queue this chunk + the rest
+      }
+    }
+    return [];
+  }
+
+  private packageVersion(): string {
+    try {
+      // dist/telemetry/index.js → ../../package.json (same layout in src/ for tests via tsx)
+      const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf8')) as { version?: string };
+      return pkg.version ?? '0.0.0';
+    } catch {
+      return '0.0.0';
+    }
+  }
+
+  private ensureExitHook(): void {
+    if (this.exitHookInstalled || !this.installExitHook) return;
+    this.exitHookInstalled = true;
+    registerForExit(this);
+  }
+
+  private debug(msg: string): void {
+    if (this.env.CODEGRAPH_TELEMETRY_DEBUG === '1') {
+      this.writeStderr(`[codegraph telemetry] ${msg}\n`);
+    }
+  }
+}
+
+// Process-wide singleton — app code goes through this; tests construct their own.
+let singleton: Telemetry | null = null;
+
+export function getTelemetry(): Telemetry {
+  if (!singleton) singleton = new Telemetry();
+  return singleton;
+}

+ 4 - 0
telemetry-worker/.dev.vars.example

@@ -0,0 +1,4 @@
+# Copy to .dev.vars for local development (`npm run dev`) and so that
+# `wrangler types` includes POSTHOG_KEY in the generated Env.
+# The real key lives only in the deployed secret (`wrangler secret put POSTHOG_KEY`).
+POSTHOG_KEY="phc_dev_placeholder"

+ 5 - 0
telemetry-worker/.gitignore

@@ -0,0 +1,5 @@
+node_modules/
+.wrangler/
+.dev.vars
+# generated by `wrangler types` (npm run types) — includes .dev.vars keys
+worker-configuration.d.ts

+ 60 - 0
telemetry-worker/README.md

@@ -0,0 +1,60 @@
+# codegraph telemetry ingest worker
+
+The first-party endpoint behind `telemetry.getcodegraph.com`. This directory is in the
+public repo **on purpose**: it is the exact code that receives codegraph's anonymous usage
+telemetry, so anyone can audit what is stored. The schema contract (every event, every
+field, and everything that is never collected) is in
+[`docs/design/telemetry.md`](../docs/design/telemetry.md).
+
+What it does, in one breath: validates incoming batches against a strict allowlist (unknown
+events dropped, unknown properties stripped), never reads or forwards the client IP,
+rate-limits per machine ID, and forwards to PostHog off the response path. It ships nowhere
+with the npm package — the engine's `files` allowlist excludes it.
+
+## Endpoint contract
+
+- `POST /v1/events` — JSON body: envelope (`machine_id` UUID, `codegraph_version`, `os`,
+  `arch`, `node_major`, `ci`, `schema_version`) + `events: [{event, ts?, props?}]`.
+  Responds `204` when accepted (including events dropped by the allowlist), honest `4xx`
+  for malformed/oversized/rate-limited requests. Clients treat every response as final —
+  no retries.
+- `GET /` — plain-text pointer to the docs and the off-switches.
+
+## Deploy
+
+Prereqs: the `getcodegraph.com` zone on the deploying Cloudflare account (the custom
+domain route auto-provisions DNS + cert), wrangler ≥ 4.36 (the `ratelimits` binding).
+
+```bash
+cd telemetry-worker
+npm install
+npx wrangler login                      # once
+npx wrangler secret put POSTHOG_KEY     # the phc_… project write key — never committed
+npm run deploy
+```
+
+The PostHog project itself must have **"Discard client IP data"** enabled — defense in
+depth on top of this worker never forwarding IPs (`$geoip_disable` is also set per event).
+
+## Local dev & checks
+
+```bash
+cp .dev.vars.example .dev.vars   # placeholder key; also feeds `wrangler types`
+npm run check                    # wrangler types + tsc --noEmit + deploy --dry-run
+npm run dev                      # http://localhost:8787
+
+curl -i localhost:8787/v1/events -H 'content-type: application/json' -d '{
+  "machine_id": "00000000-0000-4000-8000-000000000000",
+  "codegraph_version": "0.9.9", "os": "darwin", "arch": "arm64",
+  "node_major": 22, "ci": false, "schema_version": 1,
+  "events": [{ "event": "usage_rollup",
+               "props": { "kind": "mcp_tool", "name": "codegraph_explore",
+                          "count": 12, "error_count": 0, "client_name": "Claude Code" } }]
+}'
+```
+
+## Changing the schema
+
+The allowlist in `src/index.ts` mirrors `docs/design/telemetry.md` (and the user-facing
+`TELEMETRY.md`). A field is added by one PR touching all of them together — that is the
+whole point of the design.

+ 1520 - 0
telemetry-worker/package-lock.json

@@ -0,0 +1,1520 @@
+{
+  "name": "codegraph-telemetry-worker",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "codegraph-telemetry-worker",
+      "devDependencies": {
+        "typescript": "^5.0.0",
+        "wrangler": "^4.36.0"
+      }
+    },
+    "node_modules/@cloudflare/kv-asset-handler": {
+      "version": "0.5.0",
+      "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz",
+      "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==",
+      "dev": true,
+      "license": "MIT OR Apache-2.0",
+      "engines": {
+        "node": ">=22.0.0"
+      }
+    },
+    "node_modules/@cloudflare/unenv-preset": {
+      "version": "2.16.1",
+      "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz",
+      "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==",
+      "dev": true,
+      "license": "MIT OR Apache-2.0",
+      "peerDependencies": {
+        "unenv": "2.0.0-rc.24",
+        "workerd": ">1.20260305.0 <2.0.0-0"
+      },
+      "peerDependenciesMeta": {
+        "workerd": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@cloudflare/workerd-darwin-64": {
+      "version": "1.20260611.1",
+      "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260611.1.tgz",
+      "integrity": "sha512-iJICldmi4sBGgi7IrQles8cStOGXM/Tmv95C4OODVs6VIbMsJPqThUM5h3uYVQNULuJ8I/aVvnJ3Eh/wZCKwuA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=16"
+      }
+    },
+    "node_modules/@cloudflare/workerd-darwin-arm64": {
+      "version": "1.20260611.1",
+      "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260611.1.tgz",
+      "integrity": "sha512-yBbVXvbZyltR3I7NJdC4C4ItkItjZSiabcA/3HzEWOUQjLVKFqRh4so6ToHr70VCYh8VGeR8EDZL23igLhXqFQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=16"
+      }
+    },
+    "node_modules/@cloudflare/workerd-linux-64": {
+      "version": "1.20260611.1",
+      "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260611.1.tgz",
+      "integrity": "sha512-PfNjpxOlaIgZFYuhD7+neEEewCN2Ud993wEEN0fmbtSOax1AK53LGqmXUDvFhnbkHxJLFAxYCSNISW8QbzaAIg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=16"
+      }
+    },
+    "node_modules/@cloudflare/workerd-linux-arm64": {
+      "version": "1.20260611.1",
+      "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260611.1.tgz",
+      "integrity": "sha512-GEp4XbuIKjlF8pakqXcUDJfKiJosD/Q7S83J0d+r+z9XIlYGfF3ntm08e2aiF5TFTwp3fnG4yMoPUAKNhNJpvQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=16"
+      }
+    },
+    "node_modules/@cloudflare/workerd-windows-64": {
+      "version": "1.20260611.1",
+      "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260611.1.tgz",
+      "integrity": "sha512-S6JkS0kEbcCKs19RGqEPhjCRbP8GBkQwqYLp2fhBJtD/KTlwqLzOJ9E6PQ7gQKgWHtxy1NBG3oXarlNFRNU/dw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=16"
+      }
+    },
+    "node_modules/@cspotcode/source-map-support": {
+      "version": "0.8.1",
+      "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
+      "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/trace-mapping": "0.3.9"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@emnapi/runtime": {
+      "version": "1.11.0",
+      "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.0.tgz",
+      "integrity": "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "tslib": "^2.4.0"
+      }
+    },
+    "node_modules/@esbuild/aix-ppc64": {
+      "version": "0.27.3",
+      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
+      "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "aix"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-arm": {
+      "version": "0.27.3",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
+      "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-arm64": {
+      "version": "0.27.3",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
+      "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-x64": {
+      "version": "0.27.3",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
+      "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/darwin-arm64": {
+      "version": "0.27.3",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
+      "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/darwin-x64": {
+      "version": "0.27.3",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
+      "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/freebsd-arm64": {
+      "version": "0.27.3",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
+      "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/freebsd-x64": {
+      "version": "0.27.3",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
+      "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-arm": {
+      "version": "0.27.3",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
+      "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-arm64": {
+      "version": "0.27.3",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
+      "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-ia32": {
+      "version": "0.27.3",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
+      "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-loong64": {
+      "version": "0.27.3",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
+      "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-mips64el": {
+      "version": "0.27.3",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
+      "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
+      "cpu": [
+        "mips64el"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-ppc64": {
+      "version": "0.27.3",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
+      "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-riscv64": {
+      "version": "0.27.3",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
+      "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-s390x": {
+      "version": "0.27.3",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
+      "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-x64": {
+      "version": "0.27.3",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz",
+      "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/netbsd-arm64": {
+      "version": "0.27.3",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
+      "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/netbsd-x64": {
+      "version": "0.27.3",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
+      "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openbsd-arm64": {
+      "version": "0.27.3",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
+      "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openbsd-x64": {
+      "version": "0.27.3",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
+      "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openharmony-arm64": {
+      "version": "0.27.3",
+      "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
+      "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/sunos-x64": {
+      "version": "0.27.3",
+      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
+      "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "sunos"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-arm64": {
+      "version": "0.27.3",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
+      "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-ia32": {
+      "version": "0.27.3",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
+      "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-x64": {
+      "version": "0.27.3",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
+      "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@img/colour": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
+      "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@img/sharp-darwin-arm64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
+      "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-darwin-arm64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-darwin-x64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
+      "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-darwin-x64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-libvips-darwin-arm64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
+      "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-darwin-x64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
+      "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-arm": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
+      "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-arm64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
+      "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-ppc64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
+      "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-riscv64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
+      "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-s390x": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
+      "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-x64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
+      "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
+      "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linuxmusl-x64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
+      "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-linux-arm": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
+      "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-arm": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-linux-arm64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
+      "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-arm64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-linux-ppc64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
+      "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-ppc64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-linux-riscv64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
+      "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-riscv64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-linux-s390x": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
+      "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-s390x": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-linux-x64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
+      "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-x64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-linuxmusl-arm64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
+      "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-linuxmusl-x64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
+      "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linuxmusl-x64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-wasm32": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
+      "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
+      "cpu": [
+        "wasm32"
+      ],
+      "dev": true,
+      "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+      "optional": true,
+      "dependencies": {
+        "@emnapi/runtime": "^1.7.0"
+      },
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-win32-arm64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
+      "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0 AND LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-win32-ia32": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
+      "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "Apache-2.0 AND LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-win32-x64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
+      "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0 AND LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@jridgewell/resolve-uri": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+      "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@jridgewell/sourcemap-codec": {
+      "version": "1.5.5",
+      "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+      "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@jridgewell/trace-mapping": {
+      "version": "0.3.9",
+      "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
+      "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/resolve-uri": "^3.0.3",
+        "@jridgewell/sourcemap-codec": "^1.4.10"
+      }
+    },
+    "node_modules/@poppinss/colors": {
+      "version": "4.1.6",
+      "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz",
+      "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "kleur": "^4.1.5"
+      }
+    },
+    "node_modules/@poppinss/dumper": {
+      "version": "0.6.5",
+      "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz",
+      "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@poppinss/colors": "^4.1.5",
+        "@sindresorhus/is": "^7.0.2",
+        "supports-color": "^10.0.0"
+      }
+    },
+    "node_modules/@poppinss/exception": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz",
+      "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@sindresorhus/is": {
+      "version": "7.2.0",
+      "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz",
+      "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sindresorhus/is?sponsor=1"
+      }
+    },
+    "node_modules/@speed-highlight/core": {
+      "version": "1.2.16",
+      "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.16.tgz",
+      "integrity": "sha512-yNm/fYEcnpRjYduLMaddTK9XKYil6xB88+qFg79ZdZhHu1PadfoQmFW7pVTx7FZqMBNcUuThiAhxhENgtAO2/w==",
+      "dev": true,
+      "license": "CC0-1.0"
+    },
+    "node_modules/blake3-wasm": {
+      "version": "2.1.5",
+      "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz",
+      "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/cookie": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
+      "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/detect-libc": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+      "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/error-stack-parser-es": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz",
+      "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==",
+      "dev": true,
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      }
+    },
+    "node_modules/esbuild": {
+      "version": "0.27.3",
+      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
+      "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "bin": {
+        "esbuild": "bin/esbuild"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "optionalDependencies": {
+        "@esbuild/aix-ppc64": "0.27.3",
+        "@esbuild/android-arm": "0.27.3",
+        "@esbuild/android-arm64": "0.27.3",
+        "@esbuild/android-x64": "0.27.3",
+        "@esbuild/darwin-arm64": "0.27.3",
+        "@esbuild/darwin-x64": "0.27.3",
+        "@esbuild/freebsd-arm64": "0.27.3",
+        "@esbuild/freebsd-x64": "0.27.3",
+        "@esbuild/linux-arm": "0.27.3",
+        "@esbuild/linux-arm64": "0.27.3",
+        "@esbuild/linux-ia32": "0.27.3",
+        "@esbuild/linux-loong64": "0.27.3",
+        "@esbuild/linux-mips64el": "0.27.3",
+        "@esbuild/linux-ppc64": "0.27.3",
+        "@esbuild/linux-riscv64": "0.27.3",
+        "@esbuild/linux-s390x": "0.27.3",
+        "@esbuild/linux-x64": "0.27.3",
+        "@esbuild/netbsd-arm64": "0.27.3",
+        "@esbuild/netbsd-x64": "0.27.3",
+        "@esbuild/openbsd-arm64": "0.27.3",
+        "@esbuild/openbsd-x64": "0.27.3",
+        "@esbuild/openharmony-arm64": "0.27.3",
+        "@esbuild/sunos-x64": "0.27.3",
+        "@esbuild/win32-arm64": "0.27.3",
+        "@esbuild/win32-ia32": "0.27.3",
+        "@esbuild/win32-x64": "0.27.3"
+      }
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/kleur": {
+      "version": "4.1.5",
+      "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
+      "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/miniflare": {
+      "version": "4.20260611.0",
+      "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260611.0.tgz",
+      "integrity": "sha512-i+JwEo8vN96naz1WL3ntFgFyRluBDYL408zwhHKvR2jefJ464KsZ/gCmJAQ5k+oaWeb5Ug+s7yne5AyiAEswjg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@cspotcode/source-map-support": "0.8.1",
+        "sharp": "0.34.5",
+        "undici": "7.24.8",
+        "workerd": "1.20260611.1",
+        "ws": "8.20.1",
+        "youch": "4.1.0-beta.10"
+      },
+      "bin": {
+        "miniflare": "bootstrap.js"
+      },
+      "engines": {
+        "node": ">=22.0.0"
+      }
+    },
+    "node_modules/path-to-regexp": {
+      "version": "6.3.0",
+      "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz",
+      "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/pathe": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+      "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/semver": {
+      "version": "7.8.4",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz",
+      "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==",
+      "dev": true,
+      "license": "ISC",
+      "bin": {
+        "semver": "bin/semver.js"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/sharp": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
+      "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@img/colour": "^1.0.0",
+        "detect-libc": "^2.1.2",
+        "semver": "^7.7.3"
+      },
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-darwin-arm64": "0.34.5",
+        "@img/sharp-darwin-x64": "0.34.5",
+        "@img/sharp-libvips-darwin-arm64": "1.2.4",
+        "@img/sharp-libvips-darwin-x64": "1.2.4",
+        "@img/sharp-libvips-linux-arm": "1.2.4",
+        "@img/sharp-libvips-linux-arm64": "1.2.4",
+        "@img/sharp-libvips-linux-ppc64": "1.2.4",
+        "@img/sharp-libvips-linux-riscv64": "1.2.4",
+        "@img/sharp-libvips-linux-s390x": "1.2.4",
+        "@img/sharp-libvips-linux-x64": "1.2.4",
+        "@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
+        "@img/sharp-libvips-linuxmusl-x64": "1.2.4",
+        "@img/sharp-linux-arm": "0.34.5",
+        "@img/sharp-linux-arm64": "0.34.5",
+        "@img/sharp-linux-ppc64": "0.34.5",
+        "@img/sharp-linux-riscv64": "0.34.5",
+        "@img/sharp-linux-s390x": "0.34.5",
+        "@img/sharp-linux-x64": "0.34.5",
+        "@img/sharp-linuxmusl-arm64": "0.34.5",
+        "@img/sharp-linuxmusl-x64": "0.34.5",
+        "@img/sharp-wasm32": "0.34.5",
+        "@img/sharp-win32-arm64": "0.34.5",
+        "@img/sharp-win32-ia32": "0.34.5",
+        "@img/sharp-win32-x64": "0.34.5"
+      }
+    },
+    "node_modules/supports-color": {
+      "version": "10.2.2",
+      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
+      "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/supports-color?sponsor=1"
+      }
+    },
+    "node_modules/tslib": {
+      "version": "2.8.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+      "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+      "dev": true,
+      "license": "0BSD",
+      "optional": true
+    },
+    "node_modules/typescript": {
+      "version": "5.9.3",
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+      "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "tsc": "bin/tsc",
+        "tsserver": "bin/tsserver"
+      },
+      "engines": {
+        "node": ">=14.17"
+      }
+    },
+    "node_modules/undici": {
+      "version": "7.24.8",
+      "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.8.tgz",
+      "integrity": "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=20.18.1"
+      }
+    },
+    "node_modules/unenv": {
+      "version": "2.0.0-rc.24",
+      "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz",
+      "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==",
+      "dev": true,
+      "license": "MIT",
+      "peer": true,
+      "dependencies": {
+        "pathe": "^2.0.3"
+      }
+    },
+    "node_modules/workerd": {
+      "version": "1.20260611.1",
+      "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260611.1.tgz",
+      "integrity": "sha512-CS/640T7pIJ2HYX6x2DwKFGbcSckAWN3tgcdq+ptB6SaqjWUhlzIgA/YhPuwIU+/NnMnGpqOFX/hC18Oyge63w==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "Apache-2.0",
+      "peer": true,
+      "bin": {
+        "workerd": "bin/workerd"
+      },
+      "engines": {
+        "node": ">=16"
+      },
+      "optionalDependencies": {
+        "@cloudflare/workerd-darwin-64": "1.20260611.1",
+        "@cloudflare/workerd-darwin-arm64": "1.20260611.1",
+        "@cloudflare/workerd-linux-64": "1.20260611.1",
+        "@cloudflare/workerd-linux-arm64": "1.20260611.1",
+        "@cloudflare/workerd-windows-64": "1.20260611.1"
+      }
+    },
+    "node_modules/wrangler": {
+      "version": "4.100.0",
+      "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.100.0.tgz",
+      "integrity": "sha512-dSQO7DO+mD6XDzkVWIWBoGLO3yw+lacWSc/KhFvd7pgfpth+kX98qb5SGRHZN8ACCDhhfwzDLXwB6qHsIHhfBg==",
+      "dev": true,
+      "license": "MIT OR Apache-2.0",
+      "dependencies": {
+        "@cloudflare/kv-asset-handler": "0.5.0",
+        "@cloudflare/unenv-preset": "2.16.1",
+        "blake3-wasm": "2.1.5",
+        "esbuild": "0.27.3",
+        "miniflare": "4.20260611.0",
+        "path-to-regexp": "6.3.0",
+        "unenv": "2.0.0-rc.24",
+        "workerd": "1.20260611.1"
+      },
+      "bin": {
+        "cf-wrangler": "bin/cf-wrangler.js",
+        "wrangler": "bin/wrangler.js",
+        "wrangler2": "bin/wrangler.js"
+      },
+      "engines": {
+        "node": ">=22.0.0"
+      },
+      "optionalDependencies": {
+        "fsevents": "2.3.3"
+      },
+      "peerDependencies": {
+        "@cloudflare/workers-types": "^4.20260611.1"
+      },
+      "peerDependenciesMeta": {
+        "@cloudflare/workers-types": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/ws": {
+      "version": "8.20.1",
+      "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
+      "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=10.0.0"
+      },
+      "peerDependencies": {
+        "bufferutil": "^4.0.1",
+        "utf-8-validate": ">=5.0.2"
+      },
+      "peerDependenciesMeta": {
+        "bufferutil": {
+          "optional": true
+        },
+        "utf-8-validate": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/youch": {
+      "version": "4.1.0-beta.10",
+      "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz",
+      "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@poppinss/colors": "^4.1.5",
+        "@poppinss/dumper": "^0.6.4",
+        "@speed-highlight/core": "^1.2.7",
+        "cookie": "^1.0.2",
+        "youch-core": "^0.3.3"
+      }
+    },
+    "node_modules/youch-core": {
+      "version": "0.3.3",
+      "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz",
+      "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@poppinss/exception": "^1.2.2",
+        "error-stack-parser-es": "^1.0.5"
+      }
+    }
+  }
+}

+ 15 - 0
telemetry-worker/package.json

@@ -0,0 +1,15 @@
+{
+  "name": "codegraph-telemetry-worker",
+  "private": true,
+  "description": "First-party ingest endpoint for codegraph's anonymous usage telemetry (telemetry.getcodegraph.com)",
+  "scripts": {
+    "dev": "wrangler dev",
+    "deploy": "wrangler deploy",
+    "types": "wrangler types",
+    "check": "wrangler types && tsc --noEmit && wrangler deploy --dry-run"
+  },
+  "devDependencies": {
+    "typescript": "^5.0.0",
+    "wrangler": "^4.36.0"
+  }
+}

+ 256 - 0
telemetry-worker/src/index.ts

@@ -0,0 +1,256 @@
+/**
+ * codegraph telemetry ingest — telemetry.getcodegraph.com
+ *
+ * This file is public on purpose: it is the exact code that receives codegraph's
+ * anonymous usage telemetry, so anyone can audit what is (and is not) stored.
+ * The schema contract lives in docs/design/telemetry.md.
+ *
+ * Guarantees enforced here:
+ * - strict allowlist: unknown events are dropped, unknown properties are stripped
+ * - the client IP is never read, logged, or forwarded
+ * - per-machine rate limiting, bounded body/batch sizes
+ * - forwarding happens off the response path (ctx.waitUntil); bodies are never logged
+ */
+
+const MAX_BODY_BYTES = 64 * 1024;
+const MAX_EVENTS_PER_BATCH = 100;
+const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+// Bare identifiers: tool/command/target/language names, versions.
+const TOKEN_RE = /^[A-Za-z0-9_.:+-]+$/;
+// Human-ish labels: MCP clientInfo names like "Claude Code", "cursor-vscode/1.2".
+const LABEL_RE = /^[A-Za-z0-9_.:+/ @()-]+$/;
+
+const INFO_TEXT = `codegraph anonymous-telemetry ingest.
+
+What gets collected (and what never does) is documented field-by-field:
+https://github.com/colbymchenry/codegraph/blob/main/docs/design/telemetry.md
+This endpoint's full source:
+https://github.com/colbymchenry/codegraph/tree/main/telemetry-worker
+
+Disable any time: codegraph telemetry off  |  CODEGRAPH_TELEMETRY=0  |  DO_NOT_TRACK=1
+`;
+
+type JsonObject = Record<string, unknown>;
+
+/** Returns the sanitized value, or undefined to strip the property. */
+type Sanitize = (v: unknown) => unknown;
+
+const oneOf =
+  (allowed: readonly string[]): Sanitize =>
+  (v) =>
+    typeof v === 'string' && allowed.includes(v) ? v : undefined;
+
+const matching =
+  (re: RegExp, maxLen: number): Sanitize =>
+  (v) =>
+    typeof v === 'string' && v.length > 0 && v.length <= maxLen && re.test(v) ? v : undefined;
+
+const token = (maxLen: number): Sanitize => matching(TOKEN_RE, maxLen);
+const label = (maxLen: number): Sanitize => matching(LABEL_RE, maxLen);
+
+const tokenArray =
+  (maxItems: number, maxLen: number): Sanitize =>
+  (v) =>
+    Array.isArray(v) &&
+    v.length <= maxItems &&
+    v.every((s) => typeof s === 'string' && s.length > 0 && s.length <= maxLen && TOKEN_RE.test(s))
+      ? v
+      : undefined;
+
+const nonNegInt =
+  (max: number): Sanitize =>
+  (v) =>
+    typeof v === 'number' && Number.isInteger(v) && v >= 0 && v <= max ? v : undefined;
+
+/**
+ * THE allowlist. This mirrors docs/design/telemetry.md exactly — changing one
+ * without the other is a bug. Anything not listed here does not exist as far
+ * as this endpoint is concerned.
+ */
+const EVENTS: Record<string, { required: readonly string[]; props: Record<string, Sanitize> }> = {
+  install: {
+    required: ['scope', 'kind'],
+    props: {
+      targets: tokenArray(12, 24),
+      scope: oneOf(['local', 'global']),
+      kind: oneOf(['fresh', 'upgrade', 'reinstall']),
+      sqlite_backend: oneOf(['native', 'wasm']),
+    },
+  },
+  index: {
+    required: [],
+    props: {
+      languages: tokenArray(32, 24),
+      file_count_bucket: oneOf(['<100', '100-1k', '1k-10k', '10k+']),
+      duration_bucket: oneOf(['<10s', '10-60s', '1-5m', '5m+']),
+      sqlite_backend: oneOf(['native', 'wasm']),
+    },
+  },
+  usage_rollup: {
+    required: ['kind', 'name', 'count'],
+    props: {
+      kind: oneOf(['mcp_tool', 'cli_command']),
+      name: token(64),
+      count: nonNegInt(1_000_000),
+      error_count: nonNegInt(1_000_000),
+      client_name: label(64),
+      client_version: label(32),
+    },
+  },
+  uninstall: {
+    required: [],
+    props: { targets: tokenArray(12, 24) },
+  },
+};
+
+/** Envelope fields shared by every event in a batch (sanitized, all optional). */
+const ENVELOPE_PROPS: Record<string, Sanitize> = {
+  codegraph_version: token(32),
+  os: token(16),
+  arch: token(16),
+  node_major: nonNegInt(99),
+  ci: (v) => (typeof v === 'boolean' ? v : undefined),
+  schema_version: nonNegInt(99),
+};
+
+interface PostHogEvent {
+  event: string;
+  distinct_id: string;
+  timestamp?: string;
+  properties: JsonObject;
+}
+
+function clampTimestamp(v: unknown): string | undefined {
+  if (typeof v !== 'string') return undefined;
+  const t = Date.parse(v);
+  if (!Number.isFinite(t)) return undefined;
+  const now = Date.now();
+  // Rollups arrive up to a few days late (offline buffers); reject implausible times.
+  if (t > now + 10 * 60_000 || t < now - 30 * 86_400_000) return undefined;
+  return new Date(t).toISOString();
+}
+
+function sanitizeEvent(raw: unknown, machineId: string, common: JsonObject): PostHogEvent | null {
+  if (typeof raw !== 'object' || raw === null) return null;
+  const e = raw as JsonObject;
+  if (typeof e.event !== 'string') return null;
+  const spec = EVENTS[e.event];
+  if (!spec) return null;
+
+  const rawProps = (typeof e.props === 'object' && e.props !== null ? e.props : {}) as JsonObject;
+  const props: JsonObject = {};
+  for (const [key, sanitize] of Object.entries(spec.props)) {
+    const val = sanitize(rawProps[key]);
+    if (val !== undefined) props[key] = val;
+  }
+  for (const req of spec.required) {
+    if (!(req in props)) return null;
+  }
+
+  const out: PostHogEvent = {
+    event: e.event,
+    distinct_id: machineId,
+    properties: {
+      ...props,
+      ...common,
+      // Anonymous events: no person profiles, no geo enrichment.
+      $process_person_profile: false,
+      $geoip_disable: true,
+      $lib: 'codegraph-telemetry-worker',
+    },
+  };
+  const ts = clampTimestamp(e.ts);
+  if (ts !== undefined) out.timestamp = ts;
+  return out;
+}
+
+async function forwardToPostHog(env: Env, batch: PostHogEvent[]): Promise<void> {
+  try {
+    const res = await fetch(`${env.POSTHOG_HOST}/batch/`, {
+      method: 'POST',
+      headers: { 'content-type': 'application/json' },
+      body: JSON.stringify({ api_key: env.POSTHOG_KEY, batch }),
+      signal: AbortSignal.timeout(5000),
+    });
+    if (!res.ok) {
+      console.error(JSON.stringify({ msg: 'posthog forward failed', status: res.status, events: batch.length }));
+    }
+  } catch (err) {
+    console.error(JSON.stringify({ msg: 'posthog forward error', err: String(err), events: batch.length }));
+  }
+}
+
+export default {
+  async fetch(request, env, ctx): Promise<Response> {
+    try {
+      const url = new URL(request.url);
+
+      if (request.method === 'GET' && url.pathname === '/') {
+        return new Response(INFO_TEXT, { headers: { 'content-type': 'text/plain; charset=utf-8' } });
+      }
+      if (url.pathname !== '/v1/events') {
+        return new Response('not found\n', { status: 404 });
+      }
+      if (request.method !== 'POST') {
+        return new Response('method not allowed\n', { status: 405, headers: { allow: 'POST' } });
+      }
+
+      const contentLength = Number(request.headers.get('content-length'));
+      if (!Number.isFinite(contentLength) || contentLength <= 0) {
+        return new Response('length required\n', { status: 411 });
+      }
+      if (contentLength > MAX_BODY_BYTES) {
+        return new Response('payload too large\n', { status: 413 });
+      }
+
+      let body: JsonObject;
+      try {
+        const text = await request.text();
+        if (text.length > MAX_BODY_BYTES) return new Response('payload too large\n', { status: 413 });
+        const parsed: unknown = JSON.parse(text);
+        if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
+          return new Response('bad request\n', { status: 400 });
+        }
+        body = parsed as JsonObject;
+      } catch {
+        return new Response('bad request\n', { status: 400 });
+      }
+
+      const machineId = body.machine_id;
+      if (typeof machineId !== 'string' || !UUID_RE.test(machineId)) {
+        return new Response('bad request\n', { status: 400 });
+      }
+
+      // Best-effort rate limit; fails open — losing a data point beats losing availability.
+      try {
+        const { success } = await env.MACHINE_RATE_LIMITER.limit({ key: machineId });
+        if (!success) return new Response('rate limited\n', { status: 429 });
+      } catch (err) {
+        console.error(JSON.stringify({ msg: 'rate limiter unavailable', err: String(err) }));
+      }
+
+      const common: JsonObject = {};
+      for (const [key, sanitize] of Object.entries(ENVELOPE_PROPS)) {
+        const val = sanitize(body[key]);
+        if (val !== undefined) common[key] = val;
+      }
+
+      const rawEvents = Array.isArray(body.events) ? body.events.slice(0, MAX_EVENTS_PER_BATCH) : [];
+      const batch: PostHogEvent[] = [];
+      for (const raw of rawEvents) {
+        const sanitized = sanitizeEvent(raw, machineId, common);
+        if (sanitized) batch.push(sanitized);
+      }
+
+      if (batch.length > 0) {
+        ctx.waitUntil(forwardToPostHog(env, batch));
+      }
+      // Accepted (including "everything was dropped by the allowlist") — the
+      // client treats every response as final and never retries.
+      return new Response(null, { status: 204 });
+    } catch (err) {
+      console.error(JSON.stringify({ msg: 'unhandled error', err: String(err) }));
+      return new Response('internal error\n', { status: 500 });
+    }
+  },
+} satisfies ExportedHandler<Env>;

+ 17 - 0
telemetry-worker/tsconfig.json

@@ -0,0 +1,17 @@
+{
+  "compilerOptions": {
+    "target": "ES2022",
+    "module": "ES2022",
+    "moduleResolution": "Bundler",
+    "lib": ["ES2022"],
+    "strict": true,
+    "noUncheckedIndexedAccess": true,
+    "noUnusedLocals": true,
+    "noUnusedParameters": true,
+    "noEmit": true,
+    "skipLibCheck": true,
+    "forceConsistentCasingInFileNames": true,
+    "types": []
+  },
+  "include": ["src/**/*", "worker-configuration.d.ts"]
+}

+ 30 - 0
telemetry-worker/wrangler.jsonc

@@ -0,0 +1,30 @@
+// codegraph telemetry ingest — see README.md and docs/design/telemetry.md.
+// Secrets are NOT configured here: POSTHOG_KEY is set via `wrangler secret put POSTHOG_KEY`.
+{
+  "$schema": "node_modules/wrangler/config-schema.json",
+  "name": "codegraph-telemetry",
+  "main": "src/index.ts",
+  "compatibility_date": "2026-06-12",
+  "compatibility_flags": ["nodejs_compat"],
+
+  // First-party endpoint. The custom domain auto-provisions DNS + cert when the
+  // getcodegraph.com zone is on the deploying account. workers.dev stays off so
+  // the only public surface is the documented one.
+  "routes": [{ "pattern": "telemetry.getcodegraph.com", "custom_domain": true }],
+  "workers_dev": false,
+
+  "observability": { "enabled": true, "head_sampling_rate": 1 },
+
+  // Non-secret config. Swap host here if the backend ever moves (EU, self-hosted…).
+  "vars": { "POSTHOG_HOST": "https://us.i.posthog.com" },
+
+  // Per-machine_id rate limit. Legit clients flush a handful of times per day;
+  // 6/min absorbs install+index bursts while capping abuse.
+  "ratelimits": [
+    {
+      "name": "MACHINE_RATE_LIMITER",
+      "namespace_id": "1001",
+      "simple": { "limit": 6, "period": 60 }
+    }
+  ]
+}

+ 10 - 1
vitest.config.ts

@@ -18,7 +18,16 @@ export default defineConfig({
      * have installed. CI on Node 22/23 is unaffected — the guard doesn't fire
      * there, so the variable is a no-op.
      */
-    env: { CODEGRAPH_ALLOW_UNSAFE_NODE: '1' },
+    env: {
+      CODEGRAPH_ALLOW_UNSAFE_NODE: '1',
+      /**
+       * The suite spawns real CLI/MCP processes; without this they would write
+       * telemetry state into the contributor's real ~/.codegraph and count test
+       * tool calls as real usage. The telemetry unit tests are unaffected —
+       * they inject their own `env` via the Telemetry constructor.
+       */
+      CODEGRAPH_TELEMETRY: '0',
+    },
     coverage: {
       provider: 'v8',
       reporter: ['text', 'json', 'html'],