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.
Answer, in aggregate and anonymously:
clientInfo.node:sqlite now — there is no native-vs-wasm split left to measure.)codegraph-pro fork (see "codegraph-pro rule" below).TELEMETRY.md + the Worker allowlist together.fetch, Node ≥18),
zero bytes on stdout (stdio is the MCP protocol channel), zero retries, zero error noise.
Every failure mode is silence.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.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 |
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 — 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).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.
Resolution order (first match wins):
DO_NOT_TRACK=1 (community standard — always honored) → offCODEGRAPH_TELEMETRY=0|1 → forced off/on for that process~/.codegraph/telemetry.json → stored user choiceSurfaces:
consent_source: "installer". Re-runs/upgrades respect the
stored choice and don't re-ask.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.mdcodegraph 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:
{
"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.)
New module src/telemetry/ (single small module, no deps):
telemetry.count('mcp_tool', name, ok) and move on.~/.codegraph/telemetry-queue.jsonl.
Hard cap ~256 KB; on overflow drop oldest lines. Corrupt buffer → truncate, never throw.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.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.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/0001_init.sql,
checked in for the same reason the Worker's source is public.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.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. The same job purges raw
events 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.Operational detail — deploy, migrations, the cron, the POST /admin/rollup backfill hatch,
and the D1 quota arithmetic — lives in
telemetry-worker/README.md.
stats.getcodegraph.com → a second Worker at
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-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./api/activation is the one exception — "did this machine ever run an index"
is not a daily aggregate — so it reads raw events and is bounded by the retention window,
which it reports as raw_events_from.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.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.
TELEMETRY.md (user-facing field-by-field list) + README section.codegraph telemetry subcommand + MCP clientInfo plumbing.[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).
uninstall event: keep or drop? (Honest churn signal vs. "pinging on the way out" optics.)ci: true) because engine-in-CI is a real usage mode — revisit
if it ever dominates volume.