index.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546
  1. /**
  2. * Anonymous usage telemetry — client side.
  3. *
  4. * The contract for what may be collected lives in docs/design/telemetry.md
  5. * (and user-facing TELEMETRY.md); the ingest endpoint that enforces it is
  6. * public at telemetry-worker/. This module honors four invariants:
  7. *
  8. * 1. Zero hot-path cost: recording is an in-memory increment. Disk writes are
  9. * a tiny synchronous append at process exit (works under `process.exit()`,
  10. * where `beforeExit` never fires); network sends happen opportunistically
  11. * (startup of long-running commands, daemon interval, bounded await at the
  12. * end of install/init) and are fire-and-forget everywhere else.
  13. * 2. Zero stdout: stdio is the MCP protocol channel. Notices and debug output
  14. * go to stderr only.
  15. * 3. Off is off: when disabled, nothing is recorded, nothing is sent, and no
  16. * socket is opened — there is no "opted out" ping. Turning telemetry off
  17. * also deletes any buffered, unsent data.
  18. * 4. Fail silent: offline, endpoint down, disk full — every failure mode is
  19. * silence, never a retry loop, never an error surfaced to the user/agent.
  20. *
  21. * Usage counts aggregate locally into per-day rollups; only *completed* (UTC)
  22. * days are sent, so volume scales with active machines, not with tool calls.
  23. */
  24. import * as fs from 'fs';
  25. import * as path from 'path';
  26. import * as os from 'os';
  27. import { randomUUID } from 'crypto';
  28. export const TELEMETRY_ENDPOINT = 'https://telemetry.getcodegraph.com/v1/events';
  29. export const TELEMETRY_DOCS = 'https://github.com/colbymchenry/codegraph/blob/main/TELEMETRY.md';
  30. // v2: dropped the `sqlite_backend` field from the `index` event — node:sqlite is
  31. // now the only backend (the better-sqlite3-native / wasm-fallback split is gone),
  32. // so the value was a constant carrying no signal. See TELEMETRY.md.
  33. const SCHEMA_VERSION = 2;
  34. const MAX_BUFFER_BYTES = 256 * 1024;
  35. const MAX_EVENTS_PER_REQUEST = 100;
  36. const DEFAULT_FLUSH_TIMEOUT_MS = 1500;
  37. /** A crashed sender's claimed file is merged back after this long. */
  38. const STALE_CLAIM_MS = 60 * 60_000;
  39. export type UsageKind = 'mcp_tool' | 'cli_command';
  40. export type LifecycleEvent = 'install' | 'index' | 'uninstall';
  41. /** Coarse buckets — exact counts are deliberately not collected. */
  42. export function bucketFileCount(n: number): '<100' | '100-1k' | '1k-10k' | '10k+' {
  43. if (n < 100) return '<100';
  44. if (n < 1000) return '100-1k';
  45. if (n < 10000) return '1k-10k';
  46. return '10k+';
  47. }
  48. export function bucketDuration(ms: number): '<10s' | '10-60s' | '1-5m' | '5m+' {
  49. if (ms < 10_000) return '<10s';
  50. if (ms < 60_000) return '10-60s';
  51. if (ms < 300_000) return '1-5m';
  52. return '5m+';
  53. }
  54. /**
  55. * Shared "a full index completed" event (CLI init/index + installer local
  56. * init): language names and coarse buckets only — never paths, file names,
  57. * or exact counts. Structurally typed so callers don't need engine imports.
  58. */
  59. export function recordIndexEvent(
  60. cg: { getStats(): { filesByLanguage: Record<string, number> } },
  61. result: { filesIndexed: number; durationMs: number },
  62. ): void {
  63. try {
  64. const languages = Object.entries(cg.getStats().filesByLanguage)
  65. .filter(([, count]) => count > 0)
  66. .map(([lang]) => lang);
  67. getTelemetry().recordLifecycle('index', {
  68. languages,
  69. file_count_bucket: bucketFileCount(result.filesIndexed),
  70. duration_bucket: bucketDuration(result.durationMs),
  71. });
  72. } catch {
  73. /* telemetry must never break indexing */
  74. }
  75. }
  76. export interface ClientInfo {
  77. name?: string;
  78. version?: string;
  79. }
  80. interface ConfigFile {
  81. enabled: boolean;
  82. machine_id: string;
  83. consent_source: 'installer' | 'default-notice' | 'cli';
  84. first_run_notice_shown?: boolean;
  85. updated_at: string;
  86. }
  87. export interface TelemetryStatus {
  88. enabled: boolean;
  89. /** What decided the current state — mirrors the precedence order. */
  90. decidedBy: 'DO_NOT_TRACK' | 'CODEGRAPH_TELEMETRY' | 'config' | 'default';
  91. machineId: string | null;
  92. configPath: string;
  93. }
  94. /** One buffered line: either a usage-count delta or a lifecycle event. */
  95. interface CountLine {
  96. v: number;
  97. d: string; // UTC day YYYY-MM-DD
  98. k: UsageKind;
  99. n: string;
  100. c: number; // calls
  101. e: number; // errors
  102. cn?: string; // client name (mcp_tool only)
  103. cv?: string; // client version
  104. }
  105. interface EventLine {
  106. v: number;
  107. ev: LifecycleEvent;
  108. ts: string;
  109. props: Record<string, unknown>;
  110. }
  111. type BufferLine = CountLine | EventLine;
  112. export interface TelemetryOptions {
  113. /** Global state dir; defaults to ~/.codegraph. Tests inject a temp dir. */
  114. dir?: string;
  115. fetchImpl?: typeof globalThis.fetch;
  116. now?: () => Date;
  117. env?: NodeJS.ProcessEnv;
  118. stderr?: (line: string) => void;
  119. /** Tests opt out so short-lived instances don't pile onto process 'exit'. */
  120. installExitHook?: boolean;
  121. }
  122. // One process-level 'exit' listener for ALL instances (in practice: the
  123. // singleton) — N instances must not mean N listeners on process.
  124. const exitInstances = new Set<Telemetry>();
  125. let exitListenerRegistered = false;
  126. function registerForExit(instance: Telemetry): void {
  127. exitInstances.add(instance);
  128. if (!exitListenerRegistered) {
  129. exitListenerRegistered = true;
  130. // 'exit' fires under process.exit() too (unlike beforeExit); handlers must
  131. // be synchronous — persistSync is a single small file write.
  132. process.on('exit', () => {
  133. for (const i of exitInstances) i.persistSync();
  134. });
  135. }
  136. }
  137. export class Telemetry {
  138. private readonly dir: string;
  139. private readonly fetchImpl: typeof globalThis.fetch;
  140. private readonly now: () => Date;
  141. private readonly env: NodeJS.ProcessEnv;
  142. private readonly writeStderr: (line: string) => void;
  143. private counts = new Map<string, CountLine>();
  144. private events: EventLine[] = [];
  145. private readonly installExitHook: boolean;
  146. private exitHookInstalled = false;
  147. private configCache: ConfigFile | null | undefined; // undefined = not read yet
  148. private intervalHandle: NodeJS.Timeout | null = null;
  149. constructor(opts: TelemetryOptions = {}) {
  150. this.dir = opts.dir ?? path.join(os.homedir(), '.codegraph');
  151. this.fetchImpl = opts.fetchImpl ?? globalThis.fetch;
  152. this.now = opts.now ?? (() => new Date());
  153. this.env = opts.env ?? process.env;
  154. this.writeStderr = opts.stderr ?? ((line) => process.stderr.write(line));
  155. this.installExitHook = opts.installExitHook ?? true;
  156. }
  157. // ---------------------------------------------------------------- consent
  158. get configPath(): string {
  159. return path.join(this.dir, 'telemetry.json');
  160. }
  161. get queuePath(): string {
  162. return path.join(this.dir, 'telemetry-queue.jsonl');
  163. }
  164. /**
  165. * Resolution order (first match wins) — keep in sync with TELEMETRY.md:
  166. * DO_NOT_TRACK=1 > CODEGRAPH_TELEMETRY=0|1 > stored config > default on.
  167. */
  168. getStatus(): TelemetryStatus {
  169. const config = this.readConfig();
  170. const machineId = config?.machine_id ?? null;
  171. const dnt = this.env.DO_NOT_TRACK;
  172. if (dnt !== undefined && dnt !== '' && dnt !== '0' && dnt.toLowerCase() !== 'false') {
  173. return { enabled: false, decidedBy: 'DO_NOT_TRACK', machineId, configPath: this.configPath };
  174. }
  175. const forced = this.env.CODEGRAPH_TELEMETRY;
  176. if (forced !== undefined && forced !== '') {
  177. const on = forced !== '0' && forced.toLowerCase() !== 'false';
  178. return { enabled: on, decidedBy: 'CODEGRAPH_TELEMETRY', machineId, configPath: this.configPath };
  179. }
  180. if (config) {
  181. return { enabled: config.enabled, decidedBy: 'config', machineId, configPath: this.configPath };
  182. }
  183. return { enabled: true, decidedBy: 'default', machineId, configPath: this.configPath };
  184. }
  185. isEnabled(): boolean {
  186. return this.getStatus().enabled;
  187. }
  188. /**
  189. * Persist an explicit user choice (installer toggle or `codegraph
  190. * telemetry on|off`). Turning telemetry off also deletes any buffered,
  191. * unsent data — off means off.
  192. */
  193. setEnabled(enabled: boolean, source: 'installer' | 'cli'): void {
  194. const existing = this.readConfig();
  195. this.writeConfig({
  196. enabled,
  197. machine_id: existing?.machine_id ?? randomUUID(),
  198. consent_source: source,
  199. first_run_notice_shown: true,
  200. updated_at: this.now().toISOString(),
  201. });
  202. if (!enabled) {
  203. try { fs.rmSync(this.queuePath, { force: true }); } catch { /* fail silent */ }
  204. }
  205. }
  206. /** True once any consent decision (or the first-run notice) is on disk. */
  207. hasStoredChoice(): boolean {
  208. return this.readConfig() !== null;
  209. }
  210. // -------------------------------------------------------------- recording
  211. /** In-memory increment — safe on the MCP tool-call hot path. */
  212. recordUsage(kind: UsageKind, name: string, ok: boolean, client?: ClientInfo): void {
  213. if (!this.isEnabled()) return;
  214. const day = this.utcDay();
  215. const cn = client?.name?.slice(0, 64);
  216. const cv = client?.version?.slice(0, 32);
  217. const key = [day, kind, name, cn ?? '', cv ?? ''].join('�');
  218. const line = this.counts.get(key);
  219. if (line) {
  220. line.c += 1;
  221. if (!ok) line.e += 1;
  222. } else {
  223. const fresh: CountLine = { v: SCHEMA_VERSION, d: day, k: kind, n: name.slice(0, 64), c: 1, e: ok ? 0 : 1 };
  224. if (cn) fresh.cn = cn;
  225. if (cv) fresh.cv = cv;
  226. this.counts.set(key, fresh);
  227. }
  228. this.ensureExitHook();
  229. }
  230. /** install / index / uninstall — buffered like everything else. */
  231. recordLifecycle(event: LifecycleEvent, props: Record<string, unknown>): void {
  232. if (!this.isEnabled()) return;
  233. this.events.push({ v: SCHEMA_VERSION, ev: event, ts: this.now().toISOString(), props });
  234. this.ensureExitHook();
  235. }
  236. // ---------------------------------------------------------------- sending
  237. /**
  238. * Fire-and-forget send of everything sendable. Never throws, never logs
  239. * above debug. Safe to call at startup of long-running commands.
  240. */
  241. maybeFlush(): void {
  242. void this.flushNow().catch(() => { /* fail silent */ });
  243. }
  244. /**
  245. * Drain in-memory state to the buffer, then send completed-day rollups and
  246. * lifecycle events. Bounded by `timeoutMs`; leftovers stay buffered for the
  247. * next process. Awaited only where latency is invisible (install/init).
  248. */
  249. async flushNow(timeoutMs: number = DEFAULT_FLUSH_TIMEOUT_MS): Promise<void> {
  250. if (!this.isEnabled()) return;
  251. try {
  252. this.persistSync();
  253. this.recoverStaleClaims();
  254. const claim = this.claimQueue();
  255. if (!claim) return;
  256. const { claimPath, lines } = claim;
  257. const today = this.utcDay();
  258. const sendable: BufferLine[] = [];
  259. const keep: BufferLine[] = [];
  260. for (const line of lines) {
  261. if ('ev' in line) sendable.push(line);
  262. else if (line.d < today) sendable.push(line);
  263. else keep.push(line);
  264. }
  265. let failed: BufferLine[] = [];
  266. if (sendable.length > 0) {
  267. // Consent gate: the one-time notice precedes the FIRST bytes that
  268. // ever leave the machine (and mints the machine id). Recording only
  269. // buffers locally, so it stays silent — this lets the installer show
  270. // its explicit consent toggle before any notice can fire, instead of
  271. // the preAction usage count pre-empting it. An explicit installer/CLI
  272. // choice sets first_run_notice_shown and suppresses this permanently.
  273. this.firstRunNotice();
  274. failed = await this.send(sendable, timeoutMs);
  275. }
  276. // Whatever didn't go out returns to the queue (append — writers may
  277. // have created a fresh queue file while we held the claim).
  278. const back = [...failed, ...keep];
  279. if (back.length > 0) this.appendLines(back);
  280. try { fs.rmSync(claimPath, { force: true }); } catch { /* fail silent */ }
  281. } catch {
  282. /* fail silent */
  283. }
  284. }
  285. /**
  286. * Periodic flush for long-lived processes (MCP daemon / serve). Unref'd so
  287. * it never keeps the process alive.
  288. */
  289. startInterval(everyMs: number = 6 * 60 * 60_000): void {
  290. if (this.intervalHandle || !this.isEnabled()) return;
  291. this.maybeFlush();
  292. this.intervalHandle = setInterval(() => this.maybeFlush(), everyMs);
  293. this.intervalHandle.unref();
  294. }
  295. stopInterval(): void {
  296. if (this.intervalHandle) {
  297. clearInterval(this.intervalHandle);
  298. this.intervalHandle = null;
  299. }
  300. }
  301. // -------------------------------------------------------------- internals
  302. private utcDay(): string {
  303. return this.now().toISOString().slice(0, 10);
  304. }
  305. private readConfig(): ConfigFile | null {
  306. if (this.configCache !== undefined) return this.configCache;
  307. try {
  308. const raw = JSON.parse(fs.readFileSync(this.configPath, 'utf8')) as ConfigFile;
  309. this.configCache = typeof raw.machine_id === 'string' && typeof raw.enabled === 'boolean' ? raw : null;
  310. } catch {
  311. this.configCache = null;
  312. }
  313. return this.configCache;
  314. }
  315. private writeConfig(config: ConfigFile): void {
  316. try {
  317. fs.mkdirSync(this.dir, { recursive: true, mode: 0o700 });
  318. fs.writeFileSync(this.configPath, JSON.stringify(config, null, 2) + '\n');
  319. this.configCache = config;
  320. } catch {
  321. /* fail silent */
  322. }
  323. }
  324. /**
  325. * Default-on consent is gated by a one-time stderr notice (interactive
  326. * installs record their choice explicitly and never reach this).
  327. */
  328. private firstRunNotice(): void {
  329. const config = this.readConfig();
  330. if (config?.first_run_notice_shown) return;
  331. if (!config) {
  332. this.writeConfig({
  333. enabled: true,
  334. machine_id: randomUUID(),
  335. consent_source: 'default-notice',
  336. first_run_notice_shown: true,
  337. updated_at: this.now().toISOString(),
  338. });
  339. } else {
  340. this.writeConfig({ ...config, first_run_notice_shown: true, updated_at: this.now().toISOString() });
  341. }
  342. this.writeStderr(
  343. `codegraph collects anonymous usage stats (no code, paths, or names) — ` +
  344. `"codegraph telemetry off" or CODEGRAPH_TELEMETRY=0 disables. Details: ${TELEMETRY_DOCS}\n`,
  345. );
  346. }
  347. /**
  348. * Synchronous, tiny, exit-safe: drain in-memory deltas to the JSONL queue.
  349. * Runs on `process.on('exit')`, so it must never be async or slow.
  350. */
  351. persistSync(): void {
  352. if (this.counts.size === 0 && this.events.length === 0) return;
  353. const lines: BufferLine[] = [...this.counts.values(), ...this.events];
  354. this.counts.clear();
  355. this.events = [];
  356. // Re-check at persist time: `codegraph telemetry off` mid-process must not
  357. // have its own invocation resurrect the queue file at exit.
  358. if (!this.isEnabled()) return;
  359. this.appendLines(lines);
  360. }
  361. private appendLines(lines: BufferLine[]): void {
  362. try {
  363. fs.mkdirSync(this.dir, { recursive: true, mode: 0o700 });
  364. const payload = lines.map((l) => JSON.stringify(l)).join('\n') + '\n';
  365. // Cap the buffer: drop oldest lines first (telemetry is best-effort —
  366. // bounded disk use beats completeness).
  367. let existing = '';
  368. try { existing = fs.readFileSync(this.queuePath, 'utf8'); } catch { /* no queue yet */ }
  369. let combined = existing + payload;
  370. if (combined.length > MAX_BUFFER_BYTES) {
  371. combined = combined.slice(combined.length - MAX_BUFFER_BYTES);
  372. combined = combined.slice(combined.indexOf('\n') + 1); // drop the partial first line
  373. }
  374. fs.writeFileSync(this.queuePath, combined);
  375. } catch {
  376. /* fail silent */
  377. }
  378. }
  379. /**
  380. * Atomically claim the queue for sending (rename). Concurrent processes
  381. * can't double-send; a crash mid-send leaves a claim file that
  382. * `recoverStaleClaims` merges back after an hour.
  383. */
  384. private claimQueue(): { claimPath: string; lines: BufferLine[] } | null {
  385. const claimPath = path.join(this.dir, `telemetry-queue.sending.${process.pid}.jsonl`);
  386. try {
  387. fs.renameSync(this.queuePath, claimPath);
  388. } catch {
  389. return null; // no queue, or another process just claimed it
  390. }
  391. const lines: BufferLine[] = [];
  392. try {
  393. for (const raw of fs.readFileSync(claimPath, 'utf8').split('\n')) {
  394. if (!raw.trim()) continue;
  395. try {
  396. const parsed = JSON.parse(raw) as BufferLine;
  397. if (parsed && typeof parsed === 'object' && parsed.v === SCHEMA_VERSION) lines.push(parsed);
  398. } catch {
  399. /* skip corrupt line */
  400. }
  401. }
  402. } catch {
  403. /* unreadable claim — treat as empty; file removed by caller */
  404. }
  405. return { claimPath, lines };
  406. }
  407. private recoverStaleClaims(): void {
  408. try {
  409. const cutoff = this.now().getTime() - STALE_CLAIM_MS;
  410. for (const name of fs.readdirSync(this.dir)) {
  411. if (!name.startsWith('telemetry-queue.sending.')) continue;
  412. const full = path.join(this.dir, name);
  413. try {
  414. if (fs.statSync(full).mtimeMs < cutoff) {
  415. const content = fs.readFileSync(full, 'utf8');
  416. fs.rmSync(full, { force: true });
  417. if (content.trim()) fs.appendFileSync(this.queuePath, content.endsWith('\n') ? content : content + '\n');
  418. }
  419. } catch {
  420. /* fail silent */
  421. }
  422. }
  423. } catch {
  424. /* fail silent */
  425. }
  426. }
  427. /** Returns the lines that did NOT make it out (to be re-queued). */
  428. private async send(lines: BufferLine[], timeoutMs: number): Promise<BufferLine[]> {
  429. const config = this.readConfig();
  430. if (!config) return [];
  431. const events = lines.map((line) =>
  432. 'ev' in line
  433. ? { event: line.ev, ts: line.ts, props: line.props }
  434. : {
  435. event: 'usage_rollup',
  436. ts: `${line.d}T12:00:00.000Z`,
  437. props: {
  438. kind: line.k,
  439. name: line.n,
  440. count: line.c,
  441. error_count: line.e,
  442. ...(line.cn ? { client_name: line.cn } : {}),
  443. ...(line.cv ? { client_version: line.cv } : {}),
  444. },
  445. },
  446. );
  447. const envelope = {
  448. machine_id: config.machine_id,
  449. codegraph_version: this.packageVersion(),
  450. os: process.platform,
  451. arch: process.arch,
  452. node_major: parseInt(process.versions.node.split('.')[0] ?? '0', 10),
  453. ci: this.env.CI !== undefined && this.env.CI !== '' && this.env.CI !== '0' && this.env.CI !== 'false',
  454. schema_version: SCHEMA_VERSION,
  455. };
  456. const endpoint = this.env.CODEGRAPH_TELEMETRY_ENDPOINT || TELEMETRY_ENDPOINT;
  457. for (let i = 0; i < events.length; i += MAX_EVENTS_PER_REQUEST) {
  458. const chunk = events.slice(i, i + MAX_EVENTS_PER_REQUEST);
  459. const body = JSON.stringify({ ...envelope, events: chunk });
  460. this.debug(`POST ${endpoint} (${chunk.length} events)`);
  461. try {
  462. // Any response — 204, 4xx, anything — is final. No retries.
  463. await this.fetchImpl(endpoint, {
  464. method: 'POST',
  465. headers: { 'content-type': 'application/json' },
  466. body,
  467. signal: AbortSignal.timeout(timeoutMs),
  468. });
  469. } catch (err) {
  470. this.debug(`send failed: ${String(err)}`);
  471. return lines.slice(i); // network failure: re-queue this chunk + the rest
  472. }
  473. }
  474. return [];
  475. }
  476. private packageVersion(): string {
  477. try {
  478. // dist/telemetry/index.js → ../../package.json (same layout in src/ for tests via tsx)
  479. const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf8')) as { version?: string };
  480. return pkg.version ?? '0.0.0';
  481. } catch {
  482. return '0.0.0';
  483. }
  484. }
  485. private ensureExitHook(): void {
  486. if (this.exitHookInstalled || !this.installExitHook) return;
  487. this.exitHookInstalled = true;
  488. registerForExit(this);
  489. }
  490. private debug(msg: string): void {
  491. if (this.env.CODEGRAPH_TELEMETRY_DEBUG === '1') {
  492. this.writeStderr(`[codegraph telemetry] ${msg}\n`);
  493. }
  494. }
  495. }
  496. // Process-wide singleton — app code goes through this; tests construct their own.
  497. let singleton: Telemetry | null = null;
  498. export function getTelemetry(): Telemetry {
  499. if (!singleton) singleton = new Telemetry();
  500. return singleton;
  501. }