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
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user