Files
codegraph/docs/design/telemetry.md
T
Colby MchenryandClaude Opus 5.5 475e56b209 fix(cli): read commands and the prompt hook open the index read-only (#2936)
Running query, files, node, callers, callees, impact, context, explore or
affected while `codegraph index` rebuilt a project could make the rebuild
fail with "database is locked", and so could the hidden prompt-hook, which
Claude Code runs on its own for structural prompts. Each opened the index
read-write, so its open ran the writer's repairs: during the rebuild's bulk
load they recreated the secondary indexes the indexer had dropped, and the
indexer's next write, whose transaction had already read, failed with
SQLITE_BUSY_SNAPSHOT (517). PR #2930 fixed the same thing for status.

These commands now open the index read-only, through one helper, and leave
migrations, index recovery and WAL maintenance to the writer:
- query, files, node, callers, callees, impact, context, explore, affected
  and prompt-hook are read-only. explore and node run the MCP tool handler
  in process without a lifecycle, so they never sync or open another
  project.
- While another live process holds rebuild.pid they say so in one line on
  stderr (a --json answer stays parseable), as status does.
- The prompt hook stays out of a running rebuild altogether: the index is
  half-built until it ends, the MCP tools answer "rebuild in progress"
  meanwhile, and not opening it keeps the hook from holding the file the
  rebuild replaces. Its gate records the new outcome noop-rebuild.
- The hook's one write, filling an empty segment vocabulary (issue #1142),
  goes through a writer connection of its own, opened only once it holds
  the index lock, which every index and sync holds while it writes.
- status (read-only since PR #2930), sync, index, init and uninit are
  unchanged; the last four write.

A read-only open migrates nothing. QueryBuilder.hasSchema lets each read
that needs a later table or column answer as that migration's defaults
would: files.generated (v9), unresolved_refs.status and name_tail (v8), and
name_segment_vocab (v7). The status pending count from PR #2930 uses it too.

Validation (Windows 11, Node 22.22):
- The issue's interleaving, each command in another process inside the
  writer's edge transaction: on main all 14 command forms make the insert
  fail with errcode 517 and bring the 5 dropped edge indexes back; with
  this change all 14 inserts succeed and the indexes stay dropped.
- Real rebuilds of excalidraw 95307fa1 (736 files) with query and the
  prompt hook looping beside them, default settings and with the
  resolution window forced: main failed 6 of 14 ("store worker: database
  is locked" with an empty index, or "database is locked" partway through
  resolution), and 12 of its 109 query runs failed at open with
  SQLITE_BUSY. This change failed 0 of 14, with 0 failures in 299 query and
  284 hook runs and the same graph every time (14,236 nodes, 61,210 edges).
- Old indexes: 24 command forms on indexes at schemas 4 to 16 (11 schemas
  rebuilt from a current index, plus indexes built by 0.9.9, 1.4.0, 1.5.0,
  1.6.0 and 1.6.2): output identical to main's, which migrates first, 384 of
  384. The schema stays as it was, except where the hook fills the
  vocabulary of a pre-v7 index; that writer's open migrates it, as every
  read-write open did.
- Timing on excalidraw at rest, 22 interleaved runs per command: medians
  within -5.8% to +3.3%, -0.9% in total.
- read-commands-during-rebuild.test.ts: 38/38 pass. With main's sources 35
  fail; with only the CLI opens reverted 33, only the schema fallbacks 19,
  only the read-only vocabulary fill 2.

Follow-up to PR #2930.
Fixes #2913

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 22:26:03 +00:00

277 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Anonymous usage telemetry
Status: implemented — client (`src/telemetry/`), `codegraph telemetry` CLI, MCP + installer
wiring, `TELEMETRY.md`, ingest Worker (`telemetry-worker/`) storing to its own Cloudflare D1
database, nightly rollup + retention cron, and the admin dashboard Worker
(`telemetry-dashboard/`).
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. (The SQLite backend is always the built-in `node:sqlite` now — there is no native-vs-wasm split left to measure.)
## 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 — never read at the edge, and there is no downstream backend that could
see one.
- No third-party analytics vendor. Events are stored only in our own database; the ingest
Worker makes no outbound requests at all.
- 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**: no network requests or queue writes on the MCP tool-call
hot path (only a small local consent-file read), 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 (identity revalidated before each request):
| 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` | `2` | bump when the schema changes (v2 dropped `index.sqlite_backend`) |
Event types:
- **`install`** — one per installer run. Props: `targets` (e.g. `["claude","cursor"]`),
`scope` (`local`/`global`), `kind` (`fresh`/`upgrade`/`reinstall`).
- **`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+`).
- **`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` captured from the `initialize` handshake
(`src/mcp/session.ts`) and passed through on every `recordUsage` call.
The prompt hook additionally rolls up its gate DECISION as `cli_command`
counters named `prompt-hook-gate-<outcome>`, outcome ∈ `high-keyword` /
`high-token` / `medium-segment` / `nudge-projects` / `noop-shape` /
`noop-no-index` / `noop-unverified` / `noop-explore-keyword` /
`noop-explore-token` / `noop-vocab-empty` / `noop-rebuild` — decision names only, never
prompt content. This is the gate's measured recall/precision funnel: a
rising `noop-*` share against the `high`/`medium` tiers is the signal that
the gate (keyword table or segment matching) is missing real questions.
A `high-*` outcome means context was actually injected — a gate decision
whose `codegraph_explore` errored or returned nothing records
`noop-explore-<trigger>` instead (#1143), and a MEDIUM-eligible prompt
hitting a not-yet-backfilled segment vocabulary records `noop-vocab-empty`
rather than polluting `noop-unverified` (#1142).
A prompt that arrives while `codegraph index` rebuilds the project records
`noop-rebuild`: the hook leaves a rebuilding index alone (#2913).
- **`uninstall`** — one per `uninstall`/`uninit` run (churn signal). Props: `targets`.
One legacy field is still *accepted* and belongs in the mirror even though nothing sends
it: `sqlite_backend` (`native`/`wasm`) on `install` and `index`. Pre-schema-v2 clients
(≤ June 2026) sent it; `node:sqlite` is the only backend now, so current clients omit it.
It is never `required`, and it is safe to drop from the Worker once those clients'
share is negligible.
Volume math: rollups mean monthly events ≈ active machines × active days × distinct tools
used (single digits) — there is no per-call event by design. At ~97k accepted POSTs/day
that is ≈30M D1 row writes/month against the 50M included on **Workers Paid**, roughly
doubling to ≈48M once the retention purge reaches steady state (a delete bills like an
insert). Storage is the binding constraint, not writes: raw events grow ≈74 MB/day, so the
90-day window lands at ≈6.7 GB against D1's 10 GB per-database cap — which is what sets the
window. Full arithmetic and the remaining levers are in the migration's footer comment.
There are no person profiles to opt out of: `machine_id` is the only identifier that exists
anywhere in the system, it is a client-minted random UUID, and unique-machine counts are
computed from it directly in SQL.
## 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. Turning telemetry off stores a null `machine_id` and removes
both queued and claimed unsent data. Turning it back on mints a new ID; processes
discard memory from the previous identity even if they missed the off/on transition.
Requests already in flight cannot be recalled, but every later request chunk and
requeue checks current consent and identity again. Config writes use atomic replacement
so concurrent readers never see a half-written choice.
`~/.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.
The small consent file is refreshed before recording so another process's opt-out is
observed. No queue writes or network requests run on this path. 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 read or log the client IP**, light
per-`machine_id` rate limit so abuse can't burn the ingest cap, then write the survivors
to D1. 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.
- **Storage: our own Cloudflare D1 database** (`codegraph-telemetry`, bound as `env.DB`).
The Worker makes **no outbound requests** — nothing is forwarded to a third-party
analytics vendor, so there is no vendor-side privacy setting to get wrong and no second
copy of the data anywhere. The complete stored schema is
[`telemetry-worker/migrations/`](../../telemetry-worker/migrations/),
checked in for the same reason the Worker's source is public.
- The write is off the response path (`ctx.waitUntil`, one `batch()` = one transaction) and
deliberately **fail-silent**: a D1 error is logged as counts only, never the payload, and
the client still gets its `204`. Clients never retry, so losing a datapoint beats losing
availability.
- **Nightly cron (00:30 UTC, `src/rollup.ts`)** rolls each finished day into anonymous daily
counts (`daily_machines`, `daily_event_counts`, `daily_dim_counts`) and re-runs the two
days before it, since offline clients ship completed-day rollups late. Aggregation is
`INSERT … SELECT … ON CONFLICT DO UPDATE` inside D1 — no event row crosses the wire, and
re-running a day is a no-op rather than a double count. Usage counters are stored one
row per machine × day × tool (`usage_daily`), each upload adding to its row, so storage
does not depend on how often a client uploads. The same job **purges raw `events` and
`usage_daily` rows older than `RETENTION_DAYS`** (90; a var in `wrangler.jsonc`). Rollups and
`machine_days`/`machine_first_seen` are kept forever, so shortening the window costs
ad-hoc drill-back, never a chart.
- The Worker remains the seam: changing storage later is a Worker change, not a client
release. The client only ever knows the domain.
Operational detail — deploy, migrations, the cron, the `POST /admin/rollup` backfill hatch,
and the D1 quota arithmetic — lives in
[`telemetry-worker/README.md`](../../telemetry-worker/README.md).
## Admin dashboard (Cloudflare Worker)
`stats.getcodegraph.com` → a second Worker at
[`telemetry-dashboard/`](../../telemetry-dashboard/) — the read side, and the reason
self-hosting the data costs us no analysis capability. Also public source, for the same
reason: the code that touches telemetry should be readable by the people it collects from.
Full documentation is [`telemetry-dashboard/README.md`](../../telemetry-dashboard/README.md).
- **Same D1 database, read-only.** It never migrates and never writes; schema changes belong
to the ingest Worker. The two Workers are separate deployments that agree on a list of
dimension names by convention alone, which is exactly the seam
`telemetry-worker/scripts/smoke-cutover.sh` exists to cover — a mismatch there is silent,
showing up as a panel that reads zero forever rather than as an error.
- **Reads rollups, not raw events**, so a chart stays correct for days whose raw rows have
been purged. The activation funnel's "did this machine ever run an index" is not a daily
aggregate, so it reads `machine_first_seen.first_index_day` instead, which the ingest Worker
lowers as each index event is stored and the nightly cron re-derives from raw `events`.
- **Auth is a shared password and a signed cookie**, sized for exactly two people:
`ADMIN_PASSWORD` + `SESSION_SECRET` as Worker secrets, constant-time compare, HMAC-signed
cookie with no session store, everything except `/login` and `robots.txt` gated. Rotating
the password signs everyone out; that is the revocation story.
- This Worker *does* read the client IP, solely as a login rate-limit key, never stored or
logged — the one deliberate difference from the ingest Worker, which never reads it at all.
## 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), then the dashboard
Worker over the same D1: 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.