telemetry-optout.test.ts 5.7 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192
  1. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
  2. import * as fs from 'fs';
  3. import * as os from 'os';
  4. import * as path from 'path';
  5. import { Telemetry } from '../src/telemetry';
  6. describe('telemetry opt-out across running instances (#1869)', () => {
  7. let dir: string;
  8. let now: Date;
  9. let sends: any[];
  10. const make = (env = {}, fetchImpl: typeof fetch = async (_url, init) => {
  11. sends.push(JSON.parse(String(init?.body)));
  12. return new Response(null, { status: 204 });
  13. }) => new Telemetry({ dir, env, fetchImpl, now: () => now, stderr: () => {}, installExitHook: false });
  14. const queued = () => fs.readdirSync(dir).filter(n => n.startsWith('telemetry-queue'));
  15. beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-telemetry-off-')); now = new Date('2026-06-12T08:00:00Z'); sends = []; });
  16. afterEach(() => { vi.useRealTimers(); fs.rmSync(dir, { recursive: true, force: true }); });
  17. it('removes the identity on off and assigns a new one on on', () => {
  18. const a = make(); a.setEnabled(true, 'cli'); const old = a.getStatus().machineId;
  19. make().setEnabled(false, 'cli');
  20. expect(a.getStatus()).toMatchObject({ enabled: false, machineId: null });
  21. expect(fs.readFileSync(a.configPath, 'utf8')).not.toContain(old!);
  22. make().setEnabled(true, 'cli');
  23. expect(a.getStatus().machineId).toBeTruthy(); expect(a.getStatus().machineId).not.toBe(old);
  24. });
  25. it.each(['persist', 'flush'] as const)('drops memory and new recording after another instance opts out: %s', async action => {
  26. const a = make(); a.setEnabled(true, 'cli'); a.recordLifecycle('install', {});
  27. make().setEnabled(false, 'cli'); a.recordLifecycle('index', {}); a.recordUsage('mcp_tool', 'codegraph_explore', true);
  28. if (action === 'persist') a.persistSync(); else await a.flushNow();
  29. expect(sends).toEqual([]); expect(queued()).toEqual([]);
  30. make().setEnabled(true, 'cli'); await a.flushNow();
  31. expect(sends).toEqual([]); expect(queued()).toEqual([]);
  32. });
  33. it('observes external off at the completed-day interval', async () => {
  34. vi.useFakeTimers(); const a = make(); a.setEnabled(true, 'cli'); a.recordUsage('cli_command', 'query', true);
  35. a.startInterval(); await vi.advanceTimersByTimeAsync(0);
  36. make().setEnabled(false, 'cli'); now = new Date('2026-06-13T08:00:00Z');
  37. try { await vi.advanceTimersByTimeAsync(6 * 60 * 60_000); expect(sends).toEqual([]); expect(queued()).toEqual([]); }
  38. finally { a.stopInterval(); }
  39. });
  40. it('does not reuse pre-opt-out memory after an off/on cycle the process missed', async () => {
  41. const a = make(); a.setEnabled(true, 'cli'); a.recordLifecycle('install', {});
  42. const b = make(); b.setEnabled(false, 'cli'); b.setEnabled(true, 'cli');
  43. a.recordLifecycle('index', {}); await a.flushNow();
  44. expect(sends).toHaveLength(1); expect(sends[0].events.map((e: any) => e.event)).toEqual(['index']);
  45. expect(sends[0].machine_id).toBe(b.getStatus().machineId);
  46. });
  47. it.each([false, true])('in-flight failure cannot recreate old data after off (re-enable=%s)', async reEnable => {
  48. let reject!: (error: Error) => void;
  49. const a = make({}, async () => { sends.push('started'); return new Promise((_resolve, fail) => { reject = fail; }); });
  50. a.setEnabled(true, 'cli'); a.recordLifecycle('install', {}); a.recordUsage('cli_command', 'query', true);
  51. const flushing = a.flushNow(); expect(sends).toEqual(['started']);
  52. const b = make(); b.setEnabled(false, 'cli'); if (reEnable) b.setEnabled(true, 'cli');
  53. reject(new Error('network failure')); await flushing;
  54. expect(queued()).toEqual([]); await b.flushNow(); expect(sends).toEqual(['started']);
  55. });
  56. it('checks consent before each request chunk after an in-flight request returns', async () => {
  57. let finish!: (r: Response) => void;
  58. const a = make({}, async () => { sends.push('started'); if (sends.length > 1) return new Response(null, { status: 204 }); return new Promise(resolve => { finish = resolve; }); });
  59. a.setEnabled(true, 'cli'); for (let i = 0; i < 105; i++) a.recordLifecycle('index', {});
  60. const flushing = a.flushNow(); expect(sends).toHaveLength(1);
  61. make().setEnabled(false, 'cli'); finish(new Response(null, { status: 204 })); await flushing;
  62. expect(sends).toHaveLength(1); expect(queued()).toEqual([]);
  63. });
  64. it('off removes stale claims as well as the queue so on cannot revive them', async () => {
  65. const a = make(); a.setEnabled(true, 'cli');
  66. const claim = path.join(dir, 'telemetry-queue.sending.98765.jsonl');
  67. fs.writeFileSync(claim, JSON.stringify({ v: 2, ev: 'install', ts: now.toISOString(), props: {} }) + '\n');
  68. const old = new Date(now.getTime() - 2 * 60 * 60_000); fs.utimesSync(claim, old, old);
  69. a.setEnabled(false, 'cli'); expect(queued()).toEqual([]); a.setEnabled(true, 'cli'); await a.flushNow(); expect(sends).toEqual([]);
  70. });
  71. it.each(['DO_NOT_TRACK', 'CODEGRAPH_TELEMETRY'])('environment off drops pending memory: %s', async key => {
  72. const env: NodeJS.ProcessEnv = {}; const a = make(env); a.setEnabled(true, 'cli'); a.recordLifecycle('install', {});
  73. env[key] = key === 'DO_NOT_TRACK' ? '1' : '0'; await a.flushNow(); a.persistSync(); delete env[key]; await a.flushNow();
  74. expect(sends).toEqual([]); expect(queued()).toEqual([]);
  75. });
  76. it('retains the documented explicit environment-on override without resurrecting the old identity', async () => {
  77. const a = make(); a.setEnabled(true, 'cli'); const old = a.getStatus().machineId; a.setEnabled(false, 'cli');
  78. const forced = make({ CODEGRAPH_TELEMETRY: '1' }); forced.recordLifecycle('index', {}); await forced.flushNow();
  79. expect(sends).toHaveLength(1); expect(sends[0].machine_id).toMatch(/^[0-9a-f-]{36}$/); expect(sends[0].machine_id).not.toBe(old);
  80. expect(make().isEnabled()).toBe(false);
  81. });
  82. });