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:
Colby Mchenry
2026-06-12 10:37:19 -05:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 7db4c1d2f8
commit 848fde9f59
20 changed files with 3234 additions and 1 deletions
+88
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}