mirror of
https://github.com/Kaelio/ktx.git
synced 2026-07-22 11:51:01 +02:00
feat(telemetry): collect PostHog $exception error reports in CLI and daemon (#262)
* feat(telemetry): add node exception reporter * feat(telemetry): report node cli exceptions * feat(telemetry): add daemon exception reporter * feat(telemetry): report daemon exceptions * docs(telemetry): document error reports * fix(telemetry): pass redaction snapshots from node call sites * test(telemetry): verify prepared node exception payload * fix(telemetry): close daemon exception lifecycle gaps * test(telemetry): verify prepared daemon exception payload * test(telemetry): close error collection acceptance gaps * test(telemetry): close posthog exception acceptance gaps
This commit is contained in:
parent
c3d8cedb0b
commit
fb7b94b60e
36 changed files with 2870 additions and 140 deletions
456
packages/cli/test/telemetry/exception.test.ts
Normal file
456
packages/cli/test/telemetry/exception.test.ts
Normal file
|
|
@ -0,0 +1,456 @@
|
|||
import { mkdir, mkdtemp, rm, writeFile } 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 { __resetTelemetryEmitterForTests } from '../../src/telemetry/emitter.js';
|
||||
import {
|
||||
__resetTelemetryExceptionStateForTests,
|
||||
reportException,
|
||||
} from '../../src/telemetry/exception.js';
|
||||
|
||||
const captures: unknown[] = [];
|
||||
const immediateCaptures: unknown[] = [];
|
||||
const shutdown = vi.fn(async () => {});
|
||||
|
||||
vi.mock('posthog-node', () => ({
|
||||
PostHog: vi.fn(function PostHog() {
|
||||
return {
|
||||
captureException: (
|
||||
error: unknown,
|
||||
distinctId?: string,
|
||||
properties?: Record<string, unknown>,
|
||||
) => {
|
||||
captures.push({ error, distinctId, properties });
|
||||
},
|
||||
captureExceptionImmediate: async (
|
||||
error: unknown,
|
||||
distinctId?: string,
|
||||
properties?: Record<string, unknown>,
|
||||
) => {
|
||||
immediateCaptures.push({ error, distinctId, properties });
|
||||
},
|
||||
capture: vi.fn(),
|
||||
shutdown,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
function makeIo(): { io: KtxCliIo; stderr: () => string } {
|
||||
let stderr = '';
|
||||
return {
|
||||
io: {
|
||||
stdout: { write: () => {} },
|
||||
stderr: {
|
||||
write: (chunk) => {
|
||||
stderr += chunk;
|
||||
},
|
||||
},
|
||||
},
|
||||
stderr: () => stderr,
|
||||
};
|
||||
}
|
||||
|
||||
async function writeIdentity(homeDir: string, enabled = true): Promise<void> {
|
||||
const path = join(homeDir, '.ktx', 'telemetry.json');
|
||||
await mkdir(join(homeDir, '.ktx'), { recursive: true });
|
||||
await writeFile(
|
||||
path,
|
||||
`${JSON.stringify({
|
||||
installId: '00000000-0000-4000-8000-000000000000',
|
||||
enabled,
|
||||
createdAt: '2026-06-05T00:00:00.000Z',
|
||||
})}\n`,
|
||||
'utf-8',
|
||||
);
|
||||
}
|
||||
|
||||
describe('reportException', () => {
|
||||
let homeDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
homeDir = await mkdtemp(join(tmpdir(), 'ktx-exception-'));
|
||||
await writeIdentity(homeDir);
|
||||
vi.stubEnv('HOME', homeDir);
|
||||
vi.stubEnv('CI', '');
|
||||
vi.stubEnv('KTX_TELEMETRY_DISABLED', '');
|
||||
vi.stubEnv('DO_NOT_TRACK', '');
|
||||
captures.length = 0;
|
||||
immediateCaptures.length = 0;
|
||||
shutdown.mockClear();
|
||||
__resetTelemetryEmitterForTests();
|
||||
__resetTelemetryExceptionStateForTests();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs();
|
||||
await rm(homeDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('honors telemetry kill switches', async () => {
|
||||
vi.stubEnv('KTX_TELEMETRY_DISABLED', '1');
|
||||
const { io } = makeIo();
|
||||
|
||||
await reportException({
|
||||
error: new Error('boom'),
|
||||
context: { source: 'scan run', handled: true, fatal: false },
|
||||
io,
|
||||
packageInfo: { name: '@kaelio/ktx', version: '0.0.0-test' },
|
||||
projectDir: join(homeDir, 'project'),
|
||||
});
|
||||
|
||||
expect(captures).toEqual([]);
|
||||
expect(immediateCaptures).toEqual([]);
|
||||
});
|
||||
|
||||
it('prints debug payloads without sending', async () => {
|
||||
vi.stubEnv('KTX_TELEMETRY_DEBUG', '1');
|
||||
vi.stubEnv('KTX_TELEMETRY_DISABLED', '1');
|
||||
const { io, stderr } = makeIo();
|
||||
|
||||
await reportException({
|
||||
error: new Error('debug boom'),
|
||||
context: { source: 'scan run', handled: true, fatal: false },
|
||||
io,
|
||||
packageInfo: { name: '@kaelio/ktx', version: '0.0.0-test' },
|
||||
projectDir: join(homeDir, 'project'),
|
||||
});
|
||||
|
||||
expect(stderr()).toContain('[telemetry-exception]');
|
||||
expect(stderr()).toContain('"source":"scan run"');
|
||||
expect(captures).toEqual([]);
|
||||
});
|
||||
|
||||
it('sends projectId as a property and omits $groups for Node exceptions', async () => {
|
||||
const { io } = makeIo();
|
||||
|
||||
await reportException({
|
||||
error: new Error('project boom'),
|
||||
context: { source: 'sql run', handled: true, fatal: false },
|
||||
io,
|
||||
packageInfo: { name: '@kaelio/ktx', version: '0.0.0-test' },
|
||||
projectDir: join(homeDir, 'project'),
|
||||
});
|
||||
|
||||
expect(captures).toHaveLength(1);
|
||||
expect(captures[0]).toMatchObject({
|
||||
distinctId: '00000000-0000-4000-8000-000000000000',
|
||||
properties: {
|
||||
source: 'sql run',
|
||||
handled: true,
|
||||
fatal: false,
|
||||
cliVersion: '0.0.0-test',
|
||||
runtime: 'node',
|
||||
},
|
||||
});
|
||||
expect(
|
||||
(captures[0] as { properties: Record<string, unknown> }).properties.projectId,
|
||||
).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect((captures[0] as { properties: Record<string, unknown> }).properties.$groups).toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses captureExceptionImmediate for fatal reports', async () => {
|
||||
const { io } = makeIo();
|
||||
|
||||
await reportException({
|
||||
error: new Error('fatal boom'),
|
||||
context: { source: 'uncaughtException', handled: false, fatal: true },
|
||||
io,
|
||||
packageInfo: { name: '@kaelio/ktx', version: '0.0.0-test' },
|
||||
immediate: true,
|
||||
});
|
||||
|
||||
expect(immediateCaptures).toHaveLength(1);
|
||||
expect(captures).toEqual([]);
|
||||
});
|
||||
|
||||
it('redacts snapshot secrets and static credential patterns from message and cause', async () => {
|
||||
const { io } = makeIo();
|
||||
const cause = new Error('cause has sk-live-fixture-value and Authorization: Bearer token-123');
|
||||
const error = new Error('message has sk-live-fixture-value and password=hunter2', { cause });
|
||||
|
||||
await reportException({
|
||||
error,
|
||||
context: { source: 'connection test', handled: true, fatal: false },
|
||||
io,
|
||||
packageInfo: { name: '@kaelio/ktx', version: '0.0.0-test' },
|
||||
redactionSecrets: ['sk-live-fixture-value'],
|
||||
});
|
||||
|
||||
const sent = captures[0] as { error: Error & { cause?: Error } };
|
||||
expect(sent.error.message).toContain('[redacted]');
|
||||
expect(sent.error.message).not.toContain('sk-live-fixture-value');
|
||||
expect(sent.error.message).not.toContain('hunter2');
|
||||
expect(sent.error.cause?.message).not.toContain('token-123');
|
||||
});
|
||||
|
||||
it('redacts URL userinfo credentials and non-bearer authorization values', async () => {
|
||||
const { io } = makeIo();
|
||||
const error = new Error(
|
||||
'connect postgres://svc:db-url-secret@db.example.test/analytics Authorization: Basic abc123', // pragma: allowlist secret
|
||||
);
|
||||
|
||||
await reportException({
|
||||
error,
|
||||
context: { source: 'connection test', handled: true, fatal: false },
|
||||
io,
|
||||
packageInfo: { name: '@kaelio/ktx', version: '0.0.0-test' },
|
||||
});
|
||||
|
||||
const sent = captures[0] as { error: Error };
|
||||
expect(sent.error.message).toContain('postgres://svc:[redacted]@db.example.test/analytics');
|
||||
expect(sent.error.message).toContain('Authorization: [redacted]');
|
||||
expect(sent.error.message).not.toContain('db-url-secret');
|
||||
expect(sent.error.message).not.toContain('abc123');
|
||||
});
|
||||
|
||||
it('does not use process-global secret discovery when no snapshot is supplied', async () => {
|
||||
vi.stubEnv('KTX_FAKE_SECRET', 'plain-secret-without-pattern');
|
||||
const { io } = makeIo();
|
||||
|
||||
await reportException({
|
||||
error: new Error('plain-secret-without-pattern'),
|
||||
context: { source: 'uncaughtException', handled: false, fatal: true },
|
||||
io,
|
||||
packageInfo: { name: '@kaelio/ktx', version: '0.0.0-test' },
|
||||
});
|
||||
|
||||
const sent = captures[0] as { error: Error };
|
||||
expect(sent.error.message).toContain('plain-secret-without-pattern');
|
||||
});
|
||||
|
||||
it('dedupes the same Error instance between operation and global tiers', async () => {
|
||||
const { io } = makeIo();
|
||||
const error = new Error('same object');
|
||||
|
||||
await reportException({
|
||||
error,
|
||||
context: { source: 'scan run', handled: true, fatal: false },
|
||||
io,
|
||||
packageInfo: { name: '@kaelio/ktx', version: '0.0.0-test' },
|
||||
});
|
||||
await reportException({
|
||||
error,
|
||||
context: { source: 'uncaughtException', handled: false, fatal: true },
|
||||
io,
|
||||
packageInfo: { name: '@kaelio/ktx', version: '0.0.0-test' },
|
||||
immediate: true,
|
||||
});
|
||||
|
||||
expect(captures).toHaveLength(1);
|
||||
expect(immediateCaptures).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('captures wrapped Error causes as distinct logical occurrences', async () => {
|
||||
const { io } = makeIo();
|
||||
const inner = new Error('inner');
|
||||
const wrapper = new Error('outer', { cause: inner });
|
||||
|
||||
await reportException({
|
||||
error: inner,
|
||||
context: { source: 'sl query', handled: true, fatal: false },
|
||||
io,
|
||||
packageInfo: { name: '@kaelio/ktx', version: '0.0.0-test' },
|
||||
});
|
||||
await reportException({
|
||||
error: wrapper,
|
||||
context: { source: 'uncaughtException', handled: false, fatal: true },
|
||||
io,
|
||||
packageInfo: { name: '@kaelio/ktx', version: '0.0.0-test' },
|
||||
immediate: true,
|
||||
});
|
||||
|
||||
expect(captures).toHaveLength(1);
|
||||
expect(immediateCaptures).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('dedupes primitive and plain-object throwables propagated to the global tier', async () => {
|
||||
const { io } = makeIo();
|
||||
const objectThrowable = { message: 'plain object' };
|
||||
|
||||
await reportException({
|
||||
error: 'primitive boom',
|
||||
context: { source: 'mcp:sql_execution', handled: true, fatal: false },
|
||||
io,
|
||||
packageInfo: { name: '@kaelio/ktx', version: '0.0.0-test' },
|
||||
});
|
||||
await reportException({
|
||||
error: 'primitive boom',
|
||||
context: { source: 'unhandledRejection', handled: false, fatal: true },
|
||||
io,
|
||||
packageInfo: { name: '@kaelio/ktx', version: '0.0.0-test' },
|
||||
immediate: true,
|
||||
});
|
||||
await reportException({
|
||||
error: objectThrowable,
|
||||
context: { source: 'mcp:discover_data', handled: true, fatal: false },
|
||||
io,
|
||||
packageInfo: { name: '@kaelio/ktx', version: '0.0.0-test' },
|
||||
});
|
||||
await reportException({
|
||||
error: objectThrowable,
|
||||
context: { source: 'unhandledRejection', handled: false, fatal: true },
|
||||
io,
|
||||
packageInfo: { name: '@kaelio/ktx', version: '0.0.0-test' },
|
||||
immediate: true,
|
||||
});
|
||||
|
||||
expect(captures).toHaveLength(2);
|
||||
expect(immediateCaptures).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not collapse independent primitive throw events with the same value', async () => {
|
||||
const { io } = makeIo();
|
||||
|
||||
await reportException({
|
||||
error: 'oops',
|
||||
context: { source: 'scan run', handled: true, fatal: false },
|
||||
io,
|
||||
packageInfo: { name: '@kaelio/ktx', version: '0.0.0-test' },
|
||||
});
|
||||
await reportException({
|
||||
error: 'oops',
|
||||
context: { source: 'sql run', handled: true, fatal: false },
|
||||
io,
|
||||
packageInfo: { name: '@kaelio/ktx', version: '0.0.0-test' },
|
||||
});
|
||||
|
||||
expect(captures).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('drops forbidden caller-supplied extra property keys', async () => {
|
||||
const { io } = makeIo();
|
||||
|
||||
await reportException({
|
||||
error: new Error('extra property boom'),
|
||||
context: {
|
||||
source: 'sql run',
|
||||
handled: true,
|
||||
fatal: false,
|
||||
extra: {
|
||||
sql: 'select * from private_table',
|
||||
tableName: 'private_table',
|
||||
schemaName: 'private_schema',
|
||||
columnName: 'private_column',
|
||||
argv: '--password secret',
|
||||
env: 'KTX_TOKEN=secret',
|
||||
password: 'secret-password', // pragma: allowlist secret
|
||||
token: 'secret-token',
|
||||
prompt: 'user prompt',
|
||||
safeCount: 3,
|
||||
},
|
||||
},
|
||||
io,
|
||||
packageInfo: { name: '@kaelio/ktx', version: '0.0.0-test' },
|
||||
});
|
||||
|
||||
const sent = captures[0] as { properties: Record<string, unknown> };
|
||||
expect(sent.properties.safeCount).toBe(3);
|
||||
for (const key of [
|
||||
'sql',
|
||||
'tableName',
|
||||
'schemaName',
|
||||
'columnName',
|
||||
'argv',
|
||||
'env',
|
||||
'password',
|
||||
'token',
|
||||
'prompt',
|
||||
]) {
|
||||
expect(sent.properties).not.toHaveProperty(key);
|
||||
}
|
||||
});
|
||||
|
||||
it('redacts every required static credential pattern and leaves benign text intact', async () => {
|
||||
const { io } = makeIo();
|
||||
const cases: Array<{ message: string; leaked: string; expected: string }> = [
|
||||
{
|
||||
message: 'dsn password=hunter2',
|
||||
leaked: 'hunter2',
|
||||
expected: 'password=[redacted]',
|
||||
},
|
||||
{
|
||||
message: 'dsn pwd=swordfish',
|
||||
leaked: 'swordfish',
|
||||
expected: 'pwd=[redacted]',
|
||||
},
|
||||
{
|
||||
message: 'Authorization: Basic abc123',
|
||||
leaked: 'abc123',
|
||||
expected: 'Authorization: [redacted]',
|
||||
},
|
||||
{
|
||||
message: 'Authorization: Bearer token-123',
|
||||
leaked: 'token-123',
|
||||
expected: 'Authorization: [redacted]',
|
||||
},
|
||||
{
|
||||
message: 'Bearer standalone-token',
|
||||
leaked: 'standalone-token',
|
||||
expected: 'Bearer [redacted]',
|
||||
},
|
||||
{
|
||||
message: 'api_key=sk-live-secret',
|
||||
leaked: 'sk-live-secret',
|
||||
expected: 'api_key=[redacted]',
|
||||
},
|
||||
{
|
||||
message: 'api-key: sk-dash-secret',
|
||||
leaked: 'sk-dash-secret',
|
||||
expected: 'api-key=[redacted]',
|
||||
},
|
||||
{
|
||||
message: 'KTX_PROVIDER_TOKEN=ktx-secret',
|
||||
leaked: 'ktx-secret',
|
||||
expected: 'KTX_PROVIDER_TOKEN=[redacted]',
|
||||
},
|
||||
{
|
||||
message: 'REFRESH_SECRET: refresh-secret',
|
||||
leaked: 'refresh-secret',
|
||||
expected: 'REFRESH_SECRET=[redacted]',
|
||||
},
|
||||
{
|
||||
message: 'https://s3.example.test/file?X-Amz-Signature=aws-secret&ok=1',
|
||||
leaked: 'aws-secret',
|
||||
expected: 'X-Amz-Signature=[redacted]',
|
||||
},
|
||||
{
|
||||
message: 'https://storage.example.test/file?X-Goog-Signature=goog-secret&ok=1',
|
||||
leaked: 'goog-secret',
|
||||
expected: 'X-Goog-Signature=[redacted]',
|
||||
},
|
||||
{
|
||||
message: 'https://cdn.example.test/file?sig=signed-secret&ok=1',
|
||||
leaked: 'signed-secret',
|
||||
expected: 'sig=[redacted]',
|
||||
},
|
||||
{
|
||||
message: 'postgres://svc:url-password@db.example.test/analytics', // pragma: allowlist secret
|
||||
leaked: 'url-password',
|
||||
expected: 'postgres://svc:[redacted]@db.example.test/analytics',
|
||||
},
|
||||
];
|
||||
|
||||
for (const item of cases) {
|
||||
await reportException({
|
||||
error: new Error(item.message),
|
||||
context: { source: 'connection test', handled: true, fatal: false },
|
||||
io,
|
||||
packageInfo: { name: '@kaelio/ktx', version: '0.0.0-test' },
|
||||
});
|
||||
const sent = captures[captures.length - 1] as { error: Error };
|
||||
expect(sent.error.message).toContain(item.expected);
|
||||
expect(sent.error.message).not.toContain(item.leaked);
|
||||
}
|
||||
|
||||
await reportException({
|
||||
error: new Error('token bucket metrics and passwordless auth are benign'),
|
||||
context: { source: 'connection test', handled: true, fatal: false },
|
||||
io,
|
||||
packageInfo: { name: '@kaelio/ktx', version: '0.0.0-test' },
|
||||
});
|
||||
const benign = captures[captures.length - 1] as { error: Error };
|
||||
expect(benign.error.message).toBe('token bucket metrics and passwordless auth are benign');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue