Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### Fixes

- Turning telemetry off now resets its identity and stops running processes from recording, sending, or restoring unsent data. (#1869)

- Calls between JavaScript, JSX and TypeScript files keep their callers and callback flows.
- Zustand actions keep their callers when read through typed stores, destructured from store state, or selected by a hook.
- Steps diagrams retain database operations made through external client chains without inventing internal dependencies.
Expand Down
13 changes: 10 additions & 3 deletions TELEMETRY.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,14 @@ toggle and never re-asks. If you never saw the installer (e.g. `npx` straight in
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.
telemetry endpoint, and sends no "opted out" ping. Running processes recheck the stored
choice before recording, persisting, and each send. Turning it off removes the local
identity and unsent queues (including claimed queues); turning it back on creates a new
identity. An HTTP request already started cannot be recalled, but opt-out prevents later
request chunks and prevents its unsent data from being requeued.

Environment overrides still apply: `CODEGRAPH_TELEMETRY=1` explicitly forces telemetry
on for that process even when the stored choice is off; `DO_NOT_TRACK=1` takes precedence.

Separately from telemetry, the MCP server checks GitHub for a newer release in the
background (at most once a day) so it can tell you an update exists — it fetches a
Expand Down Expand Up @@ -96,8 +103,8 @@ source lives in [`telemetry-worker/`](telemetry-worker/) in this repository. It
every event and property against the allowlist above (anything else is dropped), never
reads the client IP, and rate-limits per machine ID. 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.
and never retry-loop, log errors, or slow a command down. Recording refreshes the small local consent file, then increments an in-memory counter;
MCP tool calls never wait for telemetry network requests or queue writes.

## Where it is stored

Expand Down
92 changes: 92 additions & 0 deletions __tests__/telemetry-optout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { Telemetry } from '../src/telemetry';

describe('telemetry opt-out across running instances (#1869)', () => {
let dir: string;
let now: Date;
let sends: any[];
const make = (env = {}, fetchImpl: typeof fetch = async (_url, init) => {
sends.push(JSON.parse(String(init?.body)));
return new Response(null, { status: 204 });
}) => new Telemetry({ dir, env, fetchImpl, now: () => now, stderr: () => {}, installExitHook: false });
const queued = () => fs.readdirSync(dir).filter(n => n.startsWith('telemetry-queue'));
beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-telemetry-off-')); now = new Date('2026-06-12T08:00:00Z'); sends = []; });
afterEach(() => { vi.useRealTimers(); fs.rmSync(dir, { recursive: true, force: true }); });

it('removes the identity on off and assigns a new one on on', () => {
const a = make(); a.setEnabled(true, 'cli'); const old = a.getStatus().machineId;
make().setEnabled(false, 'cli');
expect(a.getStatus()).toMatchObject({ enabled: false, machineId: null });
expect(fs.readFileSync(a.configPath, 'utf8')).not.toContain(old!);
make().setEnabled(true, 'cli');
expect(a.getStatus().machineId).toBeTruthy(); expect(a.getStatus().machineId).not.toBe(old);
});

it.each(['persist', 'flush'] as const)('drops memory and new recording after another instance opts out: %s', async action => {
const a = make(); a.setEnabled(true, 'cli'); a.recordLifecycle('install', {});
make().setEnabled(false, 'cli'); a.recordLifecycle('index', {}); a.recordUsage('mcp_tool', 'codegraph_explore', true);
if (action === 'persist') a.persistSync(); else await a.flushNow();
expect(sends).toEqual([]); expect(queued()).toEqual([]);
make().setEnabled(true, 'cli'); await a.flushNow();
expect(sends).toEqual([]); expect(queued()).toEqual([]);
});

it('observes external off at the completed-day interval', async () => {
vi.useFakeTimers(); const a = make(); a.setEnabled(true, 'cli'); a.recordUsage('cli_command', 'query', true);
a.startInterval(); await vi.advanceTimersByTimeAsync(0);
make().setEnabled(false, 'cli'); now = new Date('2026-06-13T08:00:00Z');
try { await vi.advanceTimersByTimeAsync(6 * 60 * 60_000); expect(sends).toEqual([]); expect(queued()).toEqual([]); }
finally { a.stopInterval(); }
});

it('does not reuse pre-opt-out memory after an off/on cycle the process missed', async () => {
const a = make(); a.setEnabled(true, 'cli'); a.recordLifecycle('install', {});
const b = make(); b.setEnabled(false, 'cli'); b.setEnabled(true, 'cli');
a.recordLifecycle('index', {}); await a.flushNow();
expect(sends).toHaveLength(1); expect(sends[0].events.map((e: any) => e.event)).toEqual(['index']);
expect(sends[0].machine_id).toBe(b.getStatus().machineId);
});

it.each([false, true])('in-flight failure cannot recreate old data after off (re-enable=%s)', async reEnable => {
let reject!: (error: Error) => void;
const a = make({}, async () => { sends.push('started'); return new Promise((_resolve, fail) => { reject = fail; }); });
a.setEnabled(true, 'cli'); a.recordLifecycle('install', {}); a.recordUsage('cli_command', 'query', true);
const flushing = a.flushNow(); expect(sends).toEqual(['started']);
const b = make(); b.setEnabled(false, 'cli'); if (reEnable) b.setEnabled(true, 'cli');
reject(new Error('network failure')); await flushing;
expect(queued()).toEqual([]); await b.flushNow(); expect(sends).toEqual(['started']);
});

it('checks consent before each request chunk after an in-flight request returns', async () => {
let finish!: (r: Response) => void;
const a = make({}, async () => { sends.push('started'); if (sends.length > 1) return new Response(null, { status: 204 }); return new Promise(resolve => { finish = resolve; }); });
a.setEnabled(true, 'cli'); for (let i = 0; i < 105; i++) a.recordLifecycle('index', {});
const flushing = a.flushNow(); expect(sends).toHaveLength(1);
make().setEnabled(false, 'cli'); finish(new Response(null, { status: 204 })); await flushing;
expect(sends).toHaveLength(1); expect(queued()).toEqual([]);
});

it('off removes stale claims as well as the queue so on cannot revive them', async () => {
const a = make(); a.setEnabled(true, 'cli');
const claim = path.join(dir, 'telemetry-queue.sending.98765.jsonl');
fs.writeFileSync(claim, JSON.stringify({ v: 2, ev: 'install', ts: now.toISOString(), props: {} }) + '\n');
const old = new Date(now.getTime() - 2 * 60 * 60_000); fs.utimesSync(claim, old, old);
a.setEnabled(false, 'cli'); expect(queued()).toEqual([]); a.setEnabled(true, 'cli'); await a.flushNow(); expect(sends).toEqual([]);
});

it.each(['DO_NOT_TRACK', 'CODEGRAPH_TELEMETRY'])('environment off drops pending memory: %s', async key => {
const env: NodeJS.ProcessEnv = {}; const a = make(env); a.setEnabled(true, 'cli'); a.recordLifecycle('install', {});
env[key] = key === 'DO_NOT_TRACK' ? '1' : '0'; await a.flushNow(); a.persistSync(); delete env[key]; await a.flushNow();
expect(sends).toEqual([]); expect(queued()).toEqual([]);
});

it('retains the documented explicit environment-on override without resurrecting the old identity', async () => {
const a = make(); a.setEnabled(true, 'cli'); const old = a.getStatus().machineId; a.setEnabled(false, 'cli');
const forced = make({ CODEGRAPH_TELEMETRY: '1' }); forced.recordLifecycle('index', {}); await forced.flushNow();
expect(sends).toHaveLength(1); expect(sends[0].machine_id).toMatch(/^[0-9a-f-]{36}$/); expect(sends[0].machine_id).not.toBe(old);
expect(make().isEnabled()).toBe(false);
});
});
16 changes: 11 additions & 5 deletions docs/design/telemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ Answer, in aggregate and anonymously:
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),
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
Expand All @@ -53,7 +53,7 @@ Answer, in aggregate and anonymously:

## Events

Common envelope on every batch (computed once per process):
Common envelope on every batch (identity revalidated before each request):

| field | example | notes |
|---|---|---|
Expand Down Expand Up @@ -131,7 +131,12 @@ Surfaces:
`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.
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`:

Expand All @@ -154,7 +159,8 @@ other filenames.)
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
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.
Expand Down
Loading