mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-13 08:15:14 +02:00
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.
122 lines
3.6 KiB
TypeScript
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"');
|
|
});
|
|
});
|