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>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
7db4c1d2f8
commit
848fde9f59
@@ -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(' | ||||