ktx/packages/cli/test/telemetry/index.test.ts
Andrey Avtomonov cb6a67c2d7 Make telemetry reliable across interrupts and headless installs
Three reliability gaps surfaced while auditing why PostHog numbers were
untrustworthy:

1. Interrupted commands lost their events. capture() is fire-and-forget and the
   only flush guarantee lived in a finally block, which SIGINT/SIGTERM skip — so
   Ctrl-C'ing a long ingest or an MCP client killing 'ktx mcp stdio' dropped the
   command event and any queued events. Add SIGINT/SIGTERM handlers (real-process
   entry only; never under test/programmatic io) that mark the active command
   span aborted, emit it, drain the emitter, then exit. Idempotent with the
   normal finally path via the single-consume command span.

2. Headless-first installs were invisible. loadTelemetryIdentity refused to mint
   an installId unless stdout was a TTY, so a machine whose first run was an
   IDE-launched MCP server or a script emitted nothing, ever. Mint on first run
   regardless of surface (still honoring CI/DO_NOT_TRACK/KTX_TELEMETRY_DISABLED),
   writing the one-time notice to stderr — safe under the MCP stdio protocol,
   which reserves stdout. Drop the now-unused stdoutIsTTY option.

3. No guard against silent emit regressions (the 0.7.0 scan_completed blackout).
   Add tests: the shared executePublicIngestTarget chokepoint emits exactly one
   ingest_completed on success and on the preflight-failure branch, and a
   database target invokes the scan that emits scan_completed; plus coverage for
   the aborted-flush helper.

Identity is unchanged otherwise: every event still attributes to the installId
in ~/.ktx/telemetry.json. No event/field changes, so Node<->Python schema parity
is untouched. Docs updated to reflect first-run-on-any-surface activation.
2026-06-02 23:19:37 +02:00

122 lines
3.6 KiB
TypeScript

import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { KtxCliIo } from '../../src/cli-runtime.js';
import { beginCommandSpan, emitAbortedCommandAndShutdown, emitTelemetryEvent } from '../../src/telemetry/index.js';
import { resetCommandSpan } from '../../src/telemetry/command-hook.js';
function makeIo(): { io: KtxCliIo; stderr: () => string } {
let stderr = '';
return {
io: {
stdout: {
isTTY: true,
write: () => {},
},
stderr: {
write: (chunk) => {
stderr += chunk;
},
},
},
stderr: () => stderr,
};
}
describe('emitTelemetryEvent', () => {
let homeDir: string;
beforeEach(async () => {
homeDir = await mkdtemp(join(tmpdir(), 'ktx-telemetry-index-'));
vi.stubEnv('HOME', homeDir);
});
afterEach(async () => {
vi.unstubAllEnvs();
await rm(homeDir, { recursive: true, force: true });
});
it('prints debug telemetry when live telemetry is disabled without creating an identity file', async () => {
vi.stubEnv('KTX_TELEMETRY_DEBUG', '1');
vi.stubEnv('KTX_TELEMETRY_DISABLED', '1');
vi.stubEnv('DO_NOT_TRACK', '1');
const testIo = makeIo();
const projectDir = join(homeDir, 'private-project');
await emitTelemetryEvent({
name: 'connection_added',
projectDir,
io: testIo.io,
packageInfo: { name: '@kaelio/ktx', version: '0.0.0-test' },
fields: {
driver: 'sqlite',
isDemoConnection: false,
},
});
expect(testIo.stderr()).toContain('[telemetry]');
expect(testIo.stderr()).toContain('"event":"connection_added"');
expect(testIo.stderr()).not.toContain(projectDir);
await expect(readFile(join(homeDir, '.ktx', 'telemetry.json'), 'utf-8')).rejects.toThrow();
});
});
describe('emitAbortedCommandAndShutdown', () => {
let homeDir: string;
beforeEach(async () => {
homeDir = await mkdtemp(join(tmpdir(), 'ktx-telemetry-abort-'));
vi.stubEnv('HOME', homeDir);
vi.stubEnv('KTX_TELEMETRY_DEBUG', '1');
vi.stubEnv('CI', '');
vi.stubEnv('KTX_TELEMETRY_DISABLED', '');
vi.stubEnv('DO_NOT_TRACK', '');
resetCommandSpan();
});
afterEach(async () => {
resetCommandSpan();
vi.unstubAllEnvs();
await rm(homeDir, { recursive: true, force: true });
});
it('flushes the active command span as aborted (the signal path)', async () => {
const testIo = makeIo();
beginCommandSpan({
commandPath: ['ktx', 'ingest'],
flagsPresent: {},
hasProject: true,
attachProjectGroup: false,
startedAt: performance.now(),
});
await emitAbortedCommandAndShutdown({
packageInfo: { name: '@kaelio/ktx', version: '0.0.0-test' },
io: testIo.io,
});
expect(testIo.stderr()).toContain('"event":"command"');
expect(testIo.stderr()).toContain('"outcome":"aborted"');
expect(testIo.stderr()).toContain('"commandPath":["ktx","ingest"]');
});
it('is idempotent: a second call (or no active span) emits nothing', async () => {
const testIo = makeIo();
beginCommandSpan({
commandPath: ['ktx', 'ingest'],
flagsPresent: {},
hasProject: true,
attachProjectGroup: false,
startedAt: performance.now(),
});
const pkg = { name: '@kaelio/ktx', version: '0.0.0-test' };
await emitAbortedCommandAndShutdown({ packageInfo: pkg, io: testIo.io });
const secondIo = makeIo();
await emitAbortedCommandAndShutdown({ packageInfo: pkg, io: secondIo.io });
expect(secondIo.stderr()).not.toContain('"event":"command"');
});
});