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
+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 });