mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-25 08:48:08 +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
|
|
@ -7,6 +7,12 @@ import { runCommanderKtxCli } from '../src/cli-program.js';
|
|||
import type { KtxCliDeps, KtxCliIo, KtxCliPackageInfo } from '../src/cli-runtime.js';
|
||||
import { TELEMETRY_NOTICE } from '../src/telemetry/identity.js';
|
||||
|
||||
const reportExceptionMock = vi.hoisted(() => vi.fn(async () => {}));
|
||||
|
||||
vi.mock('../src/telemetry/exception.js', () => ({
|
||||
reportException: reportExceptionMock,
|
||||
}));
|
||||
|
||||
function makeIo(stdoutIsTTY = true): { io: KtxCliIo; stdout: () => string; stderr: () => string } {
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
|
@ -43,6 +49,7 @@ describe('runCommanderKtxCli telemetry', () => {
|
|||
vi.stubEnv('CI', '');
|
||||
vi.stubEnv('KTX_TELEMETRY_DISABLED', '');
|
||||
vi.stubEnv('DO_NOT_TRACK', '');
|
||||
reportExceptionMock.mockClear();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
|
|
@ -131,4 +138,30 @@ describe('runCommanderKtxCli telemetry', () => {
|
|||
await expect(runCommanderKtxCli(['unknown'], unknownIo.io, {}, info, { runInit: async () => 0 })).resolves.toBe(1);
|
||||
expect(unknownIo.stderr()).not.toContain('[telemetry]');
|
||||
});
|
||||
|
||||
it('reports genuine top-level command catches as handled exceptions', async () => {
|
||||
const io = makeIo(true);
|
||||
const deps: KtxCliDeps = {
|
||||
doctor: async () => {
|
||||
throw new Error('status failed');
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
runCommanderKtxCli(
|
||||
['--project-dir', tempDir, 'status', '--json'],
|
||||
io.io,
|
||||
deps,
|
||||
info,
|
||||
{ runInit: async () => 0 },
|
||||
),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(reportExceptionMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
context: expect.objectContaining({ source: 'ktx status', handled: true, fatal: false }),
|
||||
projectDir: tempDir,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,6 +10,12 @@ import type { KtxConnectionDriver, KtxScanConnector } from '../src/context/scan/
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { runKtxConnection } from '../src/connection.js';
|
||||
|
||||
const reportExceptionMock = vi.hoisted(() => vi.fn(async () => {}));
|
||||
|
||||
vi.mock('../src/telemetry/exception.js', () => ({
|
||||
reportException: reportExceptionMock,
|
||||
}));
|
||||
|
||||
function stripAnsi(s: string): string {
|
||||
return s.replace(/\[[0-9;]*m/g, '');
|
||||
}
|
||||
|
|
@ -72,6 +78,7 @@ describe('runKtxConnection', () => {
|
|||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'ktx-cli-connection-'));
|
||||
reportExceptionMock.mockClear();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
|
|
@ -165,12 +172,13 @@ describe('runKtxConnection', () => {
|
|||
it('records the raw errorDetail in connection_test telemetry when a native test fails', async () => {
|
||||
vi.stubEnv('KTX_TELEMETRY_DEBUG', '1');
|
||||
vi.stubEnv('CI', '');
|
||||
vi.stubEnv('DATABASE_URL', 'postgres://svc:db-url-password@db.example.test/analytics'); // pragma: allowlist secret
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKtxProject({ projectDir });
|
||||
await writeConnections(projectDir, {
|
||||
warehouse: { driver: 'sqlite' },
|
||||
warehouse: { driver: 'postgres', url: 'env:DATABASE_URL' },
|
||||
});
|
||||
const { connector } = nativeConnector('sqlite', { success: false, error: 'database file is unreadable' });
|
||||
const { connector } = nativeConnector('postgres', { success: false, error: 'database file is unreadable' });
|
||||
const io = makeIo();
|
||||
|
||||
const code = await runKtxConnection({ command: 'test', projectDir, connectionId: 'warehouse' }, io.io, {
|
||||
|
|
@ -181,6 +189,16 @@ describe('runKtxConnection', () => {
|
|||
expect(io.stderr()).toContain('"event":"connection_test"');
|
||||
expect(io.stderr()).toContain('"outcome":"error"');
|
||||
expect(io.stderr()).toContain('"errorDetail":"database file is unreadable"');
|
||||
expect(reportExceptionMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
context: expect.objectContaining({ source: 'connection test', handled: true, fatal: false }),
|
||||
projectDir,
|
||||
redactionSecrets: expect.arrayContaining([
|
||||
'postgres://svc:db-url-password@db.example.test/analytics', // pragma: allowlist secret
|
||||
'db-url-password',
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves the driver error class and code in connection_test telemetry', async () => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { access, mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||
import { access, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
|
|
@ -7,6 +7,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
|||
import { createLocalProjectMemoryIngest } from '../../../src/context/memory/local-memory.js';
|
||||
import { detectCaptureSignals } from '../../../src/context/memory/capture-signals.js';
|
||||
import type { MemoryAgentInput } from '../../../src/context/memory/types.js';
|
||||
import { parseKtxProjectConfig, serializeKtxProjectConfig } from '../../../src/context/project/config.js';
|
||||
import { initKtxProject } from '../../../src/context/project/project.js';
|
||||
import { jsonToolResult } from '../../../src/context/mcp/context-tools.js';
|
||||
import { createDefaultKtxMcpServer, createKtxMcpServer } from '../../../src/context/mcp/server.js';
|
||||
|
|
@ -23,6 +24,12 @@ import type {
|
|||
MemoryIngestPort,
|
||||
} from '../../../src/context/mcp/types.js';
|
||||
|
||||
const reportExceptionMock = vi.hoisted(() => vi.fn(async () => {}));
|
||||
|
||||
vi.mock('../../../src/telemetry/exception.js', () => ({
|
||||
reportException: reportExceptionMock,
|
||||
}));
|
||||
|
||||
type RegisteredTool = {
|
||||
name: string;
|
||||
config: {
|
||||
|
|
@ -280,6 +287,60 @@ describe('createKtxMcpServer', () => {
|
|||
expect(io.stderrText()).not.toContain('mcpClientVersion');
|
||||
});
|
||||
|
||||
it('reports MCP tool exceptions with a tool-derived source', async () => {
|
||||
reportExceptionMock.mockClear();
|
||||
vi.stubEnv('ANTHROPIC_API_KEY', 'mcp-anthropic-secret'); // pragma: allowlist secret
|
||||
const fake = makeFakeServer();
|
||||
const io = makeIo();
|
||||
const projectDir = await mkdtemp(join(tmpdir(), 'ktx-mcp-exception-'));
|
||||
try {
|
||||
await initKtxProject({ projectDir });
|
||||
const config = parseKtxProjectConfig(await readFile(join(projectDir, 'ktx.yaml'), 'utf-8'));
|
||||
await writeFile(
|
||||
join(projectDir, 'ktx.yaml'),
|
||||
serializeKtxProjectConfig({
|
||||
...config,
|
||||
llm: {
|
||||
...config.llm,
|
||||
provider: {
|
||||
backend: 'anthropic',
|
||||
anthropic: { api_key: 'env:ANTHROPIC_API_KEY' }, // pragma: allowlist secret
|
||||
},
|
||||
models: { default: 'claude-sonnet-4-6' },
|
||||
},
|
||||
}),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
createKtxMcpServer({
|
||||
server: fake.server,
|
||||
userContext: { userId: 'local-user' },
|
||||
projectDir,
|
||||
io,
|
||||
contextTools: {
|
||||
knowledge: {
|
||||
search: vi.fn<KtxKnowledgeMcpPort['search']>().mockRejectedValue(new Error('wiki failed')),
|
||||
read: vi.fn<KtxKnowledgeMcpPort['read']>().mockResolvedValue(null),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(getTool(fake.tools, 'wiki_search').handler({ query: 'revenue recognition', limit: 5 })).resolves.toMatchObject({
|
||||
isError: true,
|
||||
});
|
||||
|
||||
expect(reportExceptionMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
context: expect.objectContaining({ source: 'mcp:wiki_search', handled: true, fatal: false }),
|
||||
projectDir,
|
||||
redactionSecrets: expect.arrayContaining(['mcp-anthropic-secret']),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await rm(projectDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('captures the connecting MCP client name and version', async () => {
|
||||
vi.stubEnv('KTX_TELEMETRY_DEBUG', '1');
|
||||
vi.stubEnv('CI', '');
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { tmpdir } from 'node:os';
|
|||
import { join } from 'node:path';
|
||||
import { buildDefaultKtxProjectConfig, type KtxProjectConfig } from '../src/context/project/config.js';
|
||||
import { initKtxProject } from '../src/context/project/project.js';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
buildPublicIngestPlan,
|
||||
executePublicIngestTarget,
|
||||
|
|
@ -13,6 +13,12 @@ import {
|
|||
runKtxPublicIngest,
|
||||
} from '../src/public-ingest.js';
|
||||
|
||||
const reportExceptionMock = vi.hoisted(() => vi.fn(async () => {}));
|
||||
|
||||
vi.mock('../src/telemetry/exception.js', () => ({
|
||||
reportException: reportExceptionMock,
|
||||
}));
|
||||
|
||||
/** Count non-overlapping occurrences of `needle` in `haystack`. */
|
||||
function occurrences(haystack: string, needle: string): number {
|
||||
return haystack.split(needle).length - 1;
|
||||
|
|
@ -377,6 +383,10 @@ describe('publicProgressMessage', () => {
|
|||
});
|
||||
|
||||
describe('runKtxPublicIngest', () => {
|
||||
beforeEach(() => {
|
||||
reportExceptionMock.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
|
@ -1208,6 +1218,104 @@ describe('runKtxPublicIngest', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('reports foreground runtime preflight exceptions', async () => {
|
||||
const io = makeIo({ isTTY: true, interactive: true });
|
||||
const project = projectWithConnections({
|
||||
warehouse: { driver: 'postgres' },
|
||||
});
|
||||
const ensureRuntime = vi.fn(async (): Promise<ManagedPythonCommandRuntime> => {
|
||||
throw new Error('runtime unavailable');
|
||||
});
|
||||
const runContextBuild = vi.fn(async () => ({ exitCode: 0 }));
|
||||
|
||||
await expect(
|
||||
runKtxPublicIngest(
|
||||
{
|
||||
command: 'run',
|
||||
projectDir: '/tmp/project',
|
||||
targetConnectionId: 'warehouse',
|
||||
all: false,
|
||||
json: false,
|
||||
inputMode: 'auto',
|
||||
queryHistory: 'enabled',
|
||||
cliVersion: '0.2.0',
|
||||
runtimeInstallPolicy: 'prompt',
|
||||
},
|
||||
io.io,
|
||||
{
|
||||
loadProject: vi.fn(async () => project),
|
||||
ensureRuntime,
|
||||
runContextBuild,
|
||||
},
|
||||
),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(runContextBuild).not.toHaveBeenCalled();
|
||||
expect(io.stderr()).toContain('runtime unavailable');
|
||||
expect(reportExceptionMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
context: expect.objectContaining({ source: 'ingest runtime', handled: true, fatal: false }),
|
||||
projectDir: '/tmp/project',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('reports foreground context-build exceptions', async () => {
|
||||
const io = makeIo({ isTTY: true, interactive: true });
|
||||
const config = buildDefaultKtxProjectConfig();
|
||||
const project: KtxPublicIngestProject = {
|
||||
projectDir: '/tmp/project',
|
||||
config: {
|
||||
...config,
|
||||
connections: { warehouse: { driver: 'postgres', password: 'env:INGEST_DB_PASSWORD' } }, // pragma: allowlist secret
|
||||
llm: {
|
||||
...config.llm,
|
||||
provider: {
|
||||
backend: 'anthropic',
|
||||
anthropic: { api_key: 'env:ANTHROPIC_API_KEY' }, // pragma: allowlist secret
|
||||
},
|
||||
models: { default: 'claude-sonnet-4-6' },
|
||||
},
|
||||
},
|
||||
};
|
||||
const runContextBuild = vi.fn(async () => {
|
||||
throw new Error('context build failed');
|
||||
});
|
||||
|
||||
await expect(
|
||||
runKtxPublicIngest(
|
||||
{
|
||||
command: 'run',
|
||||
projectDir: '/tmp/project',
|
||||
targetConnectionId: 'warehouse',
|
||||
all: false,
|
||||
json: false,
|
||||
inputMode: 'auto',
|
||||
queryHistory: 'default',
|
||||
},
|
||||
io.io,
|
||||
{
|
||||
loadProject: vi.fn(async () => project),
|
||||
runContextBuild,
|
||||
env: {
|
||||
...process.env,
|
||||
ANTHROPIC_API_KEY: 'ingest-anthropic-secret', // pragma: allowlist secret
|
||||
INGEST_DB_PASSWORD: 'ingest-db-password', // pragma: allowlist secret
|
||||
},
|
||||
},
|
||||
),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(io.stderr()).toContain('context build failed');
|
||||
expect(reportExceptionMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
context: expect.objectContaining({ source: 'ingest context-build', handled: true, fatal: false }),
|
||||
projectDir: '/tmp/project',
|
||||
redactionSecrets: expect.arrayContaining(['ingest-anthropic-secret', 'ingest-db-password']),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('preflights foreground managed embeddings runtime before starting the context-build view', async () => {
|
||||
const io = makeIo({ isTTY: true, interactive: true });
|
||||
const config = buildDefaultKtxProjectConfig();
|
||||
|
|
|
|||
|
|
@ -2,12 +2,19 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import type { SourceAdapter } from '../src/context/ingest/types.js';
|
||||
import { parseKtxProjectConfig, serializeKtxProjectConfig } from '../src/context/project/config.js';
|
||||
import { initKtxProject } from '../src/context/project/project.js';
|
||||
import type { KtxScanReport } from '../src/context/scan/types.js';
|
||||
import type { LocalScanRunResult, RunLocalScanOptions } from '../src/context/scan/local-scan.js';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createCliScanProgress, runKtxScan, type KtxScanDeps } from '../src/scan.js';
|
||||
|
||||
const reportExceptionMock = vi.hoisted(() => vi.fn(async () => {}));
|
||||
|
||||
vi.mock('../src/telemetry/exception.js', () => ({
|
||||
reportException: reportExceptionMock,
|
||||
}));
|
||||
|
||||
const sqlServerExtractSchema = vi.hoisted(() =>
|
||||
vi.fn(async (connectionId: string) => ({
|
||||
connectionId,
|
||||
|
|
@ -317,6 +324,7 @@ describe('runKtxScan', () => {
|
|||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'ktx-cli-scan-'));
|
||||
reportExceptionMock.mockClear();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
|
|
@ -426,7 +434,28 @@ describe('runKtxScan', () => {
|
|||
it('records the raw errorDetail in scan_completed telemetry when the scan throws', async () => {
|
||||
vi.stubEnv('KTX_TELEMETRY_DEBUG', '1');
|
||||
vi.stubEnv('CI', '');
|
||||
vi.stubEnv('ANTHROPIC_API_KEY', 'anthropic-callsite-secret'); // pragma: allowlist secret
|
||||
vi.stubEnv('DATABASE_URL', 'postgres://svc:scan-db-password@db.example.test/analytics'); // pragma: allowlist secret
|
||||
await initKtxProject({ projectDir: tempDir });
|
||||
const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8'));
|
||||
await writeFile(
|
||||
join(tempDir, 'ktx.yaml'),
|
||||
serializeKtxProjectConfig({
|
||||
...config,
|
||||
connections: {
|
||||
warehouse: { driver: 'postgres', url: 'env:DATABASE_URL' },
|
||||
},
|
||||
llm: {
|
||||
...config.llm,
|
||||
provider: {
|
||||
backend: 'anthropic',
|
||||
anthropic: { api_key: 'env:ANTHROPIC_API_KEY' }, // pragma: allowlist secret
|
||||
},
|
||||
models: { default: 'claude-sonnet-4-6' },
|
||||
},
|
||||
}),
|
||||
'utf-8',
|
||||
);
|
||||
const runLocalScan = vi.fn(async (): Promise<LocalScanRunResult> => {
|
||||
const error = new Error('introspection timed out');
|
||||
(error as { code?: unknown }).code = 'ETIMEDOUT';
|
||||
|
|
@ -452,6 +481,17 @@ describe('runKtxScan', () => {
|
|||
expect(io.stderr()).toContain('"event":"scan_completed"');
|
||||
expect(io.stderr()).toContain('"outcome":"error"');
|
||||
expect(io.stderr()).toContain('"errorDetail":"ETIMEDOUT: introspection timed out"');
|
||||
expect(reportExceptionMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
context: expect.objectContaining({ source: 'scan run', handled: true, fatal: false }),
|
||||
projectDir: tempDir,
|
||||
redactionSecrets: expect.arrayContaining([
|
||||
'anthropic-callsite-secret',
|
||||
'postgres://svc:scan-db-password@db.example.test/analytics', // pragma: allowlist secret
|
||||
'scan-db-password',
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('passes KTX daemon options to local ingest adapters when no explicit daemon URL is set', async () => {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,19 @@
|
|||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { stripVTControlCharacters } from 'node:util';
|
||||
import Database from 'better-sqlite3';
|
||||
import { parseKtxProjectConfig, serializeKtxProjectConfig } from '../src/context/project/config.js';
|
||||
import { initKtxProject } from '../src/context/project/project.js';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { runKtxSl } from '../src/sl.js';
|
||||
|
||||
const reportExceptionMock = vi.hoisted(() => vi.fn(async () => {}));
|
||||
|
||||
vi.mock('../src/telemetry/exception.js', () => ({
|
||||
reportException: reportExceptionMock,
|
||||
}));
|
||||
|
||||
const ORDERS_YAML = [
|
||||
'name: orders',
|
||||
'table: public.orders',
|
||||
|
|
@ -61,6 +68,7 @@ describe('runKtxSl', () => {
|
|||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'ktx-cli-sl-'));
|
||||
reportExceptionMock.mockClear();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
|
|
@ -351,6 +359,12 @@ describe('runKtxSl', () => {
|
|||
|
||||
expect(validateIo.stdout()).toBe('');
|
||||
expect(validateIo.stderr()).toBe('Semantic-layer source "missing_orders" was not found\n');
|
||||
expect(reportExceptionMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
context: expect.objectContaining({ source: 'sl validate', handled: true, fatal: false }),
|
||||
projectDir,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps scoped validation not-found wording', async () => {
|
||||
|
|
@ -552,6 +566,53 @@ joins: []
|
|||
expect(stderr.write).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports sl query exceptions at the query catch boundary', async () => {
|
||||
vi.stubEnv('ANTHROPIC_API_KEY', 'sl-anthropic-secret'); // pragma: allowlist secret
|
||||
const projectDir = join(tempDir, 'missing-query-input');
|
||||
await seedSlSource({ projectDir });
|
||||
const config = parseKtxProjectConfig(await readFile(join(projectDir, 'ktx.yaml'), 'utf-8'));
|
||||
await writeFile(
|
||||
join(projectDir, 'ktx.yaml'),
|
||||
serializeKtxProjectConfig({
|
||||
...config,
|
||||
llm: {
|
||||
...config.llm,
|
||||
provider: {
|
||||
backend: 'anthropic',
|
||||
anthropic: { api_key: 'env:ANTHROPIC_API_KEY' }, // pragma: allowlist secret
|
||||
},
|
||||
models: { default: 'claude-sonnet-4-6' },
|
||||
},
|
||||
}),
|
||||
'utf-8',
|
||||
);
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKtxSl(
|
||||
{
|
||||
command: 'query',
|
||||
projectDir,
|
||||
connectionId: 'warehouse',
|
||||
format: 'json',
|
||||
execute: false,
|
||||
cliVersion: '0.2.0',
|
||||
runtimeInstallPolicy: 'auto',
|
||||
},
|
||||
io.io,
|
||||
),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(io.stderr()).toContain('sl query requires query input');
|
||||
expect(reportExceptionMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
context: expect.objectContaining({ source: 'sl query', handled: true, fatal: false }),
|
||||
projectDir,
|
||||
redactionSecrets: expect.arrayContaining(['sl-anthropic-secret']),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('emits debug telemetry for sl query without project paths', async () => {
|
||||
vi.stubEnv('KTX_TELEMETRY_DEBUG', '1');
|
||||
vi.stubEnv('CI', '');
|
||||
|
|
|
|||
|
|
@ -8,6 +8,12 @@ import type { SqlAnalysisPort } from '../src/context/sql-analysis/ports.js';
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { runKtxSql } from '../src/sql.js';
|
||||
|
||||
const reportExceptionMock = vi.hoisted(() => vi.fn(async () => {}));
|
||||
|
||||
vi.mock('../src/telemetry/exception.js', () => ({
|
||||
reportException: reportExceptionMock,
|
||||
}));
|
||||
|
||||
function makeIo(options: { isTTY?: boolean } = {}) {
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
|
@ -76,6 +82,7 @@ describe('runKtxSql', () => {
|
|||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'ktx-cli-sql-'));
|
||||
reportExceptionMock.mockClear();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
|
|
@ -236,9 +243,10 @@ describe('runKtxSql', () => {
|
|||
});
|
||||
|
||||
it('rejects non-read-only SQL before executing connector SQL', async () => {
|
||||
vi.stubEnv('SQL_DB_PASSWORD', 'sql-db-password'); // pragma: allowlist secret
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKtxProject({ projectDir });
|
||||
await writeConnections(projectDir, { warehouse: { driver: 'sqlite', path: 'warehouse.db' } });
|
||||
await writeConnections(projectDir, { warehouse: { driver: 'postgres', password: 'env:SQL_DB_PASSWORD' } }); // pragma: allowlist secret
|
||||
const connector = makeConnector();
|
||||
const io = makeIo();
|
||||
|
||||
|
|
@ -265,6 +273,13 @@ describe('runKtxSql', () => {
|
|||
expect(connector.executeReadOnly).not.toHaveBeenCalled();
|
||||
expect(connector.cleanup).not.toHaveBeenCalled();
|
||||
expect(io.stderr()).toContain('SQL contains read/write operation: Delete');
|
||||
expect(reportExceptionMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
context: expect.objectContaining({ source: 'sql run', handled: true, fatal: false }),
|
||||
projectDir,
|
||||
redactionSecrets: expect.arrayContaining(['sql-db-password']),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects missing connections', async () => {
|
||||
|
|
|
|||
150
packages/cli/test/telemetry/exception-payload.test.ts
Normal file
150
packages/cli/test/telemetry/exception-payload.test.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { createServer, type IncomingMessage } from 'node:http';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { gunzipSync } from 'node:zlib';
|
||||
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';
|
||||
|
||||
function makeIo(): KtxCliIo {
|
||||
return {
|
||||
stdout: { write: () => {} },
|
||||
stderr: { write: () => {} },
|
||||
};
|
||||
}
|
||||
|
||||
async function body(req: IncomingMessage): Promise<string> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of req) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
const raw = Buffer.concat(chunks);
|
||||
return req.headers['content-encoding'] === 'gzip' ? gunzipSync(raw).toString('utf-8') : raw.toString('utf-8');
|
||||
}
|
||||
|
||||
async function withCaptureServer<T>(run: (url: string, payloads: unknown[]) => Promise<T>): Promise<T> {
|
||||
const payloads: unknown[] = [];
|
||||
const server = createServer(async (req, res) => {
|
||||
if (req.method === 'POST') {
|
||||
payloads.push(JSON.parse(await body(req)));
|
||||
}
|
||||
res.statusCode = 200;
|
||||
res.setHeader('content-type', 'application/json');
|
||||
res.end('{}');
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('test server did not bind to a TCP port');
|
||||
}
|
||||
try {
|
||||
return await run(`http://127.0.0.1:${address.port}`, payloads);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
}
|
||||
|
||||
function findExceptionEvent(payloads: unknown[]): Record<string, unknown> {
|
||||
for (const payload of payloads) {
|
||||
if (typeof payload !== 'object' || payload === null) {
|
||||
continue;
|
||||
}
|
||||
const record = payload as Record<string, unknown>;
|
||||
const batch = Array.isArray(record.batch) ? record.batch : [record];
|
||||
for (const item of batch) {
|
||||
if (typeof item === 'object' && item !== null && (item as Record<string, unknown>).event === '$exception') {
|
||||
return item as Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error(`No $exception payload found: ${JSON.stringify(payloads)}`);
|
||||
}
|
||||
|
||||
describe('prepared Node exception payload', () => {
|
||||
let homeDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
homeDir = await mkdtemp(join(tmpdir(), 'ktx-node-exception-payload-'));
|
||||
await mkdir(join(homeDir, '.ktx'), { recursive: true });
|
||||
await writeFile(
|
||||
join(homeDir, '.ktx', 'telemetry.json'),
|
||||
`${JSON.stringify({
|
||||
installId: '00000000-0000-4000-8000-000000000000',
|
||||
enabled: true,
|
||||
createdAt: '2026-06-05T00:00:00.000Z',
|
||||
})}\n`,
|
||||
'utf-8',
|
||||
);
|
||||
vi.stubEnv('HOME', homeDir);
|
||||
vi.stubEnv('CI', '');
|
||||
vi.stubEnv('KTX_TELEMETRY_DISABLED', '');
|
||||
vi.stubEnv('DO_NOT_TRACK', '');
|
||||
__resetTelemetryEmitterForTests();
|
||||
__resetTelemetryExceptionStateForTests();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs();
|
||||
await rm(homeDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('sends projectId, omits $groups, and redacts the serialized exception list', async () => {
|
||||
await withCaptureServer(async (endpoint, payloads) => {
|
||||
vi.stubEnv('KTX_TELEMETRY_ENDPOINT', endpoint);
|
||||
const projectDir = join(homeDir, 'project');
|
||||
const snapshotSecret = ['plain', 'secret', 'value'].join('-');
|
||||
const dbPassword = ['db', 'url', 'secret'].join('-');
|
||||
const authToken = ['abc', '123'].join('');
|
||||
const error = new Error(
|
||||
`${snapshotSecret} postgres://svc:${dbPassword}@db.example.test/analytics Authorization: Basic ${authToken}`,
|
||||
);
|
||||
|
||||
await reportException({
|
||||
error,
|
||||
context: { source: 'scan run', handled: true, fatal: false },
|
||||
io: makeIo(),
|
||||
packageInfo: { name: '@kaelio/ktx', version: '0.0.0-test' },
|
||||
projectDir,
|
||||
immediate: true,
|
||||
redactionSecrets: [snapshotSecret],
|
||||
});
|
||||
|
||||
const event = findExceptionEvent(payloads);
|
||||
const properties = event.properties as Record<string, unknown>;
|
||||
expect(properties.projectId).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(properties.$groups).toBeUndefined();
|
||||
expect(JSON.stringify(properties.$exception_list)).toContain('[redacted]');
|
||||
expect(JSON.stringify(properties.$exception_list)).not.toContain(snapshotSecret);
|
||||
expect(JSON.stringify(properties.$exception_list)).not.toContain(dbPassword);
|
||||
expect(JSON.stringify(properties.$exception_list)).not.toContain(authToken);
|
||||
for (const key of [
|
||||
'argv',
|
||||
'args',
|
||||
'env',
|
||||
'environment',
|
||||
'sql',
|
||||
'query',
|
||||
'prompt',
|
||||
'mcpArguments',
|
||||
'tableName',
|
||||
'schemaName',
|
||||
'columnName',
|
||||
'databaseUrl',
|
||||
'connectionString',
|
||||
'url',
|
||||
'password',
|
||||
'token',
|
||||
'apiKey',
|
||||
'authorization',
|
||||
]) {
|
||||
expect(properties).not.toHaveProperty(key);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
|
@ -3,7 +3,7 @@ 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 { createGlobalExceptionReporter, type KtxCliIo } from '../../src/cli-runtime.js';
|
||||
import { beginCommandSpan, emitAbortedCommandAndShutdown, emitTelemetryEvent } from '../../src/telemetry/index.js';
|
||||
import { resetCommandSpan } from '../../src/telemetry/command-hook.js';
|
||||
|
||||
|
|
@ -120,3 +120,36 @@ describe('emitAbortedCommandAndShutdown', () => {
|
|||
expect(secondIo.stderr()).not.toContain('"event":"command"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('global exception reporting contract', () => {
|
||||
let homeDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
homeDir = await mkdtemp(join(tmpdir(), 'ktx-telemetry-global-exception-'));
|
||||
vi.stubEnv('HOME', homeDir);
|
||||
vi.stubEnv('KTX_TELEMETRY_DEBUG', '1');
|
||||
vi.stubEnv('KTX_TELEMETRY_DISABLED', '1');
|
||||
vi.stubEnv('DO_NOT_TRACK', '');
|
||||
vi.stubEnv('CI', '');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs();
|
||||
await rm(homeDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('reports uncaughtException through the fatal debug payload', async () => {
|
||||
const testIo = makeIo();
|
||||
const report = createGlobalExceptionReporter(testIo.io, {
|
||||
name: '@kaelio/ktx',
|
||||
version: '0.0.0-test',
|
||||
});
|
||||
|
||||
await report('uncaughtException', new Error('global boom'));
|
||||
|
||||
expect(testIo.stderr()).toContain('[telemetry-exception]');
|
||||
expect(testIo.stderr()).toContain('"source":"uncaughtException"');
|
||||
expect(testIo.stderr()).toContain('"handled":false');
|
||||
expect(testIo.stderr()).toContain('"fatal":true');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
127
packages/cli/test/telemetry/redaction-secrets.test.ts
Normal file
127
packages/cli/test/telemetry/redaction-secrets.test.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import { mkdir, mkdtemp, readFile, 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 { parseKtxProjectConfig, serializeKtxProjectConfig } from '../../src/context/project/config.js';
|
||||
import { initKtxProject } from '../../src/context/project/project.js';
|
||||
import { collectTelemetryRedactionSecrets } from '../../src/telemetry/redaction-secrets.js';
|
||||
|
||||
describe('collectTelemetryRedactionSecrets', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'ktx-redaction-secrets-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs();
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function writeConfig(projectDir: string): Promise<void> {
|
||||
const configPath = join(projectDir, 'ktx.yaml');
|
||||
const config = parseKtxProjectConfig(await readFile(configPath, 'utf-8'));
|
||||
await writeFile(
|
||||
configPath,
|
||||
serializeKtxProjectConfig({
|
||||
...config,
|
||||
llm: {
|
||||
...config.llm,
|
||||
provider: {
|
||||
backend: 'anthropic',
|
||||
anthropic: { api_key: 'env:ANTHROPIC_API_KEY' }, // pragma: allowlist secret
|
||||
},
|
||||
models: { default: 'claude-sonnet-4-6' },
|
||||
},
|
||||
ingest: {
|
||||
...config.ingest,
|
||||
embeddings: {
|
||||
backend: 'openai',
|
||||
model: 'text-embedding-3-small',
|
||||
dimensions: 1536,
|
||||
openai: { api_key: 'file:~/.ktx/secrets/openai-key' }, // pragma: allowlist secret
|
||||
},
|
||||
},
|
||||
scan: {
|
||||
...config.scan,
|
||||
enrichment: {
|
||||
...config.scan.enrichment,
|
||||
embeddings: {
|
||||
backend: 'openai',
|
||||
model: 'text-embedding-3-small',
|
||||
dimensions: 1536,
|
||||
openai: { api_key: 'env:SCAN_OPENAI_API_KEY' }, // pragma: allowlist secret
|
||||
},
|
||||
},
|
||||
},
|
||||
connections: {
|
||||
warehouse: {
|
||||
driver: 'postgres',
|
||||
url: 'env:DATABASE_URL',
|
||||
password: 'file:~/.ktx/secrets/db-password', // pragma: allowlist secret
|
||||
},
|
||||
docs: {
|
||||
driver: 'notion',
|
||||
auth_token_ref: 'env:NOTION_TOKEN', // pragma: allowlist secret
|
||||
},
|
||||
},
|
||||
}),
|
||||
'utf-8',
|
||||
);
|
||||
}
|
||||
|
||||
it('derives only declared project secrets and parsed URL credentials', async () => {
|
||||
const homeDir = join(tempDir, 'home');
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await mkdir(join(homeDir, '.ktx', 'secrets'), { recursive: true });
|
||||
await writeFile(join(homeDir, '.ktx', 'secrets', 'openai-key'), 'openai-file-secret\n', 'utf-8');
|
||||
await writeFile(join(homeDir, '.ktx', 'secrets', 'db-password'), 'db-file-password\n', 'utf-8');
|
||||
vi.stubEnv('HOME', homeDir);
|
||||
vi.stubEnv('ANTHROPIC_API_KEY', 'anthropic-env-secret');
|
||||
vi.stubEnv('SCAN_OPENAI_API_KEY', 'scan-openai-env-secret');
|
||||
vi.stubEnv('DATABASE_URL', 'postgres://svc:db-url-password@db.example.test/analytics'); // pragma: allowlist secret
|
||||
vi.stubEnv('NOTION_TOKEN', 'notion-env-secret');
|
||||
vi.stubEnv('UNDECLARED_SECRET', 'must-not-appear');
|
||||
await initKtxProject({ projectDir });
|
||||
await writeConfig(projectDir);
|
||||
|
||||
const secrets = await collectTelemetryRedactionSecrets({
|
||||
projectDir,
|
||||
connectionId: 'warehouse',
|
||||
includeLlm: true,
|
||||
includeEmbeddings: true,
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
expect(secrets).toEqual(
|
||||
expect.arrayContaining([
|
||||
'anthropic-env-secret',
|
||||
'openai-file-secret',
|
||||
'scan-openai-env-secret',
|
||||
'postgres://svc:db-url-password@db.example.test/analytics', // pragma: allowlist secret
|
||||
'db-url-password',
|
||||
'db-file-password',
|
||||
]),
|
||||
);
|
||||
expect(secrets).not.toContain('notion-env-secret');
|
||||
expect(secrets).not.toContain('must-not-appear');
|
||||
});
|
||||
|
||||
it('can derive a named non-database connection secret', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
vi.stubEnv('NOTION_TOKEN', 'notion-env-secret');
|
||||
await initKtxProject({ projectDir });
|
||||
await writeConfig(projectDir);
|
||||
|
||||
const secrets = await collectTelemetryRedactionSecrets({
|
||||
projectDir,
|
||||
connectionId: 'docs',
|
||||
includeLlm: false,
|
||||
includeEmbeddings: false,
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
expect(secrets).toEqual(['notion-env-secret']);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue