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
@@ -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]
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user