Files
codegraph/__tests__/telemetry-optout.test.ts
Colby Mchenry 51116a26cb fix(telemetry): honor opt-out across running processes (#1880)
Refresh consent across processes, reset identity on opt-out, discard obsolete buffers and stop subsequent requests/requeues. Preserve environment override precedence and document in-flight semantics.

Fixes #1869.
2026-09-16 06:51:19 +00:00

93 lines
5.7 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { Telemetry } from '../src/telemetry';
describe('telemetry opt-out across running instances (#1869)', () => {
let dir: string;
let now: Date;
let sends: any[];
const make = (env = {}, fetchImpl: typeof fetch = async (_url, init) => {
sends.push(JSON.parse(String(init?.body)));
return new Response(null, { status: 204 });
}) => new Telemetry({ dir, env, fetchImpl, now: () => now, stderr: () => {}, installExitHook: false });
const queued = () => fs.readdirSync(dir).filter(n => n.startsWith('telemetry-queue'));
beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-telemetry-off-')); now = new Date('2026-06-12T08:00:00Z'); sends = []; });
afterEach(() => { vi.useRealTimers(); fs.rmSync(dir, { recursive: true, force: true }); });
it('removes the identity on off and assigns a new one on on', () => {
const a = make(); a.setEnabled(true, 'cli'); const old = a.getStatus().machineId;
make().setEnabled(false, 'cli');
expect(a.getStatus()).toMatchObject({ enabled: false, machineId: null });
expect(fs.readFileSync(a.configPath, 'utf8')).not.toContain(old!);
make().setEnabled(true, 'cli');
expect(a.getStatus().machineId).toBeTruthy(); expect(a.getStatus().machineId).not.toBe(old);
});
it.each(['persist', 'flush'] as const)('drops memory and new recording after another instance opts out: %s', async action => {
const a = make(); a.setEnabled(true, 'cli'); a.recordLifecycle('install', {});
make().setEnabled(false, 'cli'); a.recordLifecycle('index', {}); a.recordUsage('mcp_tool', 'codegraph_explore', true);
if (action === 'persist') a.persistSync(); else await a.flushNow();
expect(sends).toEqual([]); expect(queued()).toEqual([]);
make().setEnabled(true, 'cli'); await a.flushNow();
expect(sends).toEqual([]); expect(queued()).toEqual([]);
});
it('observes external off at the completed-day interval', async () => {
vi.useFakeTimers(); const a = make(); a.setEnabled(true, 'cli'); a.recordUsage('cli_command', 'query', true);
a.startInterval(); await vi.advanceTimersByTimeAsync(0);
make().setEnabled(false, 'cli'); now = new Date('2026-06-13T08:00:00Z');
try { await vi.advanceTimersByTimeAsync(6 * 60 * 60_000); expect(sends).toEqual([]); expect(queued()).toEqual([]); }
finally { a.stopInterval(); }
});
it('does not reuse pre-opt-out memory after an off/on cycle the process missed', async () => {
const a = make(); a.setEnabled(true, 'cli'); a.recordLifecycle('install', {});
const b = make(); b.setEnabled(false, 'cli'); b.setEnabled(true, 'cli');
a.recordLifecycle('index', {}); await a.flushNow();
expect(sends).toHaveLength(1); expect(sends[0].events.map((e: any) => e.event)).toEqual(['index']);
expect(sends[0].machine_id).toBe(b.getStatus().machineId);
});
it.each([false, true])('in-flight failure cannot recreate old data after off (re-enable=%s)', async reEnable => {
let reject!: (error: Error) => void;
const a = make({}, async () => { sends.push('started'); return new Promise((_resolve, fail) => { reject = fail; }); });
a.setEnabled(true, 'cli'); a.recordLifecycle('install', {}); a.recordUsage('cli_command', 'query', true);
const flushing = a.flushNow(); expect(sends).toEqual(['started']);
const b = make(); b.setEnabled(false, 'cli'); if (reEnable) b.setEnabled(true, 'cli');
reject(new Error('network failure')); await flushing;
expect(queued()).toEqual([]); await b.flushNow(); expect(sends).toEqual(['started']);
});
it('checks consent before each request chunk after an in-flight request returns', async () => {
let finish!: (r: Response) => void;
const a = make({}, async () => { sends.push('started'); if (sends.length > 1) return new Response(null, { status: 204 }); return new Promise(resolve => { finish = resolve; }); });
a.setEnabled(true, 'cli'); for (let i = 0; i < 105; i++) a.recordLifecycle('index', {});
const flushing = a.flushNow(); expect(sends).toHaveLength(1);
make().setEnabled(false, 'cli'); finish(new Response(null, { status: 204 })); await flushing;
expect(sends).toHaveLength(1); expect(queued()).toEqual([]);
});
it('off removes stale claims as well as the queue so on cannot revive them', async () => {
const a = make(); a.setEnabled(true, 'cli');
const claim = path.join(dir, 'telemetry-queue.sending.98765.jsonl');
fs.writeFileSync(claim, JSON.stringify({ v: 2, ev: 'install', ts: now.toISOString(), props: {} }) + '\n');
const old = new Date(now.getTime() - 2 * 60 * 60_000); fs.utimesSync(claim, old, old);
a.setEnabled(false, 'cli'); expect(queued()).toEqual([]); a.setEnabled(true, 'cli'); await a.flushNow(); expect(sends).toEqual([]);
});
it.each(['DO_NOT_TRACK', 'CODEGRAPH_TELEMETRY'])('environment off drops pending memory: %s', async key => {
const env: NodeJS.ProcessEnv = {}; const a = make(env); a.setEnabled(true, 'cli'); a.recordLifecycle('install', {});
env[key] = key === 'DO_NOT_TRACK' ? '1' : '0'; await a.flushNow(); a.persistSync(); delete env[key]; await a.flushNow();
expect(sends).toEqual([]); expect(queued()).toEqual([]);
});
it('retains the documented explicit environment-on override without resurrecting the old identity', async () => {
const a = make(); a.setEnabled(true, 'cli'); const old = a.getStatus().machineId; a.setEnabled(false, 'cli');
const forced = make({ CODEGRAPH_TELEMETRY: '1' }); forced.recordLifecycle('index', {}); await forced.flushNow();
expect(sends).toHaveLength(1); expect(sends[0].machine_id).toMatch(/^[0-9a-f-]{36}$/); expect(sends[0].machine_id).not.toBe(old);
expect(make().isEnabled()).toBe(false);
});
});