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:
Andrey Avtomonov 2026-06-05 19:36:21 +02:00 committed by GitHub
parent c3d8cedb0b
commit fb7b94b60e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 2870 additions and 140 deletions

View file

@ -529,6 +529,13 @@ export async function runCommanderKtxCli(
try {
return await runBareInteractiveCommand(program, io, context);
} catch (error) {
const telemetry = await import('./telemetry/index.js');
await telemetry.reportException({
error,
context: { source: 'bare-interactive', handled: true, fatal: false },
packageInfo: info,
io,
});
io.stderr.write(`${formatCliError(error)}\n`);
return 1;
}
@ -563,6 +570,23 @@ export async function runCommanderKtxCli(
outcome: commandOutcomeForParseResult(parseError, exitCode),
error: parseError,
});
if (
parseError &&
!isCommanderExit(parseError) &&
!isKtxProjectMissingAbortError(parseError)
) {
await telemetryModule.reportException({
error: parseError,
context: {
source: completed?.commandPath.join(' ') ?? 'commander parseAsync',
handled: true,
fatal: false,
},
projectDir: completed?.projectGroupAttached ? completed.projectDir : undefined,
packageInfo: info,
io,
});
}
await telemetryModule.emitCompletedCommand({ completed, packageInfo: info, io });
await telemetryModule.shutdownTelemetryEmitter();
}

View file

@ -129,6 +129,48 @@ function installTelemetrySignalFlush(io: KtxCliIo, info: KtxCliPackageInfo): ()
};
}
/** @internal */
export function createGlobalExceptionReporter(io: KtxCliIo, info: KtxCliPackageInfo) {
return async (source: 'uncaughtException' | 'unhandledRejection', error: unknown): Promise<void> => {
const { reportException, shutdownTelemetryEmitter } = await import('./telemetry/index.js');
await reportException({
error,
context: { source, handled: false, fatal: true },
io,
packageInfo: info,
immediate: true,
});
await shutdownTelemetryEmitter();
};
}
export function installGlobalExceptionHandlers(io: KtxCliIo, info: KtxCliPackageInfo): () => void {
const report = createGlobalExceptionReporter(io, info);
const handle = (source: 'uncaughtException' | 'unhandledRejection', error: unknown): void => {
void (async () => {
try {
await report(source, error);
} catch {
// Best-effort: preserve Node's process termination behavior.
}
if (error instanceof Error && error.stack) {
io.stderr.write(`${error.stack}\n`);
} else {
io.stderr.write(`${String(error)}\n`);
}
process.exit(1);
})();
};
const onUncaught = (error: Error): void => handle('uncaughtException', error);
const onUnhandled = (reason: unknown): void => handle('unhandledRejection', reason);
process.on('uncaughtException', onUncaught);
process.on('unhandledRejection', onUnhandled);
return () => {
process.off('uncaughtException', onUncaught);
process.off('unhandledRejection', onUnhandled);
};
}
export async function runKtxCli(
argv = process.argv.slice(2),
io: KtxCliIo = process,
@ -141,11 +183,14 @@ export async function runKtxCli(
// Real-process entry only: flush telemetry if interrupted. Test/programmatic
// callers pass their own `io`, so they never install process-level handlers.
const removeSignalFlush = (io as unknown) === process ? installTelemetrySignalFlush(io, info) : undefined;
const removeGlobalExceptionHandlers =
(io as unknown) === process ? installGlobalExceptionHandlers(io, info) : undefined;
try {
return await runCommanderKtxCli(argv, io, deps, info, {
runInit: runInitForCommander,
});
} finally {
removeGlobalExceptionHandlers?.();
removeSignalFlush?.();
}
}

View file

@ -16,7 +16,8 @@ import { bold, dim, green, red, SYMBOLS } from './io/symbols.js';
import { createKtxCliScanConnector } from './local-scan-connectors.js';
import { profileMark } from './startup-profile.js';
import { isDemoConnection } from './telemetry/demo-detect.js';
import { emitTelemetryEvent } from './telemetry/index.js';
import { emitTelemetryEvent, reportException } from './telemetry/index.js';
import { collectTelemetryRedactionSecrets } from './telemetry/redaction-secrets.js';
import { formatErrorDetail, scrubErrorClass } from './telemetry/scrubber.js';
profileMark('module:connection');
@ -324,6 +325,21 @@ async function emitConnectionTest(input: {
...(errorDetail ? { errorDetail } : {}),
},
});
if (input.error) {
await reportException({
error: input.error,
context: { source: 'connection test', handled: true, fatal: false },
projectDir: input.project.projectDir,
io: input.io,
redactionSecrets: await collectTelemetryRedactionSecrets({
project: input.project,
connectionId: input.connectionId,
includeLlm: false,
includeEmbeddings: false,
env: process.env,
}),
});
}
}
function visualWidth(text: string): number {

View file

@ -3,7 +3,13 @@ import type { ToolAnnotations } from '@modelcontextprotocol/sdk/types.js';
import { z } from 'zod';
import type { KtxCliIo } from '../../cli-runtime.js';
import type { MemoryAgentInput } from '../../context/memory/types.js';
import { emitTelemetryEvent, mcpTelemetrySampleRate, shouldEmitMcpTelemetry } from '../../telemetry/index.js';
import {
emitTelemetryEvent,
mcpTelemetrySampleRate,
reportException,
shouldEmitMcpTelemetry,
} from '../../telemetry/index.js';
import { collectTelemetryRedactionSecrets } from '../../telemetry/redaction-secrets.js';
import { scrubErrorClass } from '../../telemetry/scrubber.js';
import type {
KtxMcpClientInfo,
@ -518,11 +524,26 @@ function registerParsedTool<TSchema extends z.ZodType>(
},
schema: TSchema,
handler: (input: z.infer<TSchema>, context?: KtxMcpToolHandlerContext) => Promise<KtxMcpToolResult>,
telemetry?: { projectDir?: string; io?: KtxCliIo },
): void {
server.registerTool(name, config, async (input, context) => {
try {
return await handler(schema.parse(input), context);
} catch (error) {
if (telemetry?.io) {
await reportException({
error,
context: { source: `mcp:${name}`, handled: true, fatal: false },
projectDir: telemetry.projectDir,
io: telemetry.io,
redactionSecrets: await collectTelemetryRedactionSecrets({
projectDir: telemetry.projectDir,
includeLlm: true,
includeEmbeddings: true,
env: process.env,
}),
});
}
return jsonErrorToolResult(formatToolError(error));
}
});
@ -571,6 +592,20 @@ function instrumentMcpServer(
}
return result;
} catch (error) {
if (telemetry.io) {
await reportException({
error,
context: { source: `mcp:${name}`, handled: true, fatal: false },
projectDir: telemetry.projectDir,
io: telemetry.io,
redactionSecrets: await collectTelemetryRedactionSecrets({
projectDir: telemetry.projectDir,
includeLlm: true,
includeEmbeddings: true,
env: process.env,
}),
});
}
if (telemetry.io && telemetry.projectDir && shouldEmitMcpTelemetry()) {
const errorClass = scrubErrorClass(error);
await emitTelemetryEvent({
@ -596,6 +631,7 @@ function instrumentMcpServer(
export function registerKtxContextTools(deps: RegisterKtxContextToolsDeps): void {
const { ports, userContext } = deps;
const toolTelemetry = { projectDir: deps.projectDir, io: deps.io };
const server = instrumentMcpServer(deps.server, {
projectDir: deps.projectDir,
io: deps.io,
@ -616,6 +652,7 @@ export function registerKtxContextTools(deps: RegisterKtxContextToolsDeps): void
},
connectionListSchema,
async () => jsonToolResult({ connections: await connections.list() }),
toolTelemetry,
);
}
@ -640,6 +677,7 @@ export function registerKtxContextTools(deps: RegisterKtxContextToolsDeps): void
limit: input.limit,
}),
),
toolTelemetry,
);
registerParsedTool(
@ -657,6 +695,7 @@ export function registerKtxContextTools(deps: RegisterKtxContextToolsDeps): void
const page = await knowledge.read({ userId: userContext.userId, key: input.key });
return page ? jsonToolResult(page) : jsonErrorToolResult(`Wiki page "${input.key}" was not found.`);
},
toolTelemetry,
);
}
@ -679,6 +718,7 @@ export function registerKtxContextTools(deps: RegisterKtxContextToolsDeps): void
? jsonToolResult(source)
: jsonErrorToolResult(`Semantic-layer source "${input.sourceName}" was not found.`);
},
toolTelemetry,
);
registerParsedTool(
@ -711,6 +751,7 @@ export function registerKtxContextTools(deps: RegisterKtxContextToolsDeps): void
);
return jsonToolResult(projectSlQueryResult(result, input.include));
},
toolTelemetry,
);
}
@ -728,6 +769,7 @@ export function registerKtxContextTools(deps: RegisterKtxContextToolsDeps): void
},
entityDetailsSchema,
async (input) => jsonToolResult(await entityDetails.read(input)),
toolTelemetry,
);
}
@ -745,6 +787,7 @@ export function registerKtxContextTools(deps: RegisterKtxContextToolsDeps): void
},
dictionarySearchSchema,
async (input) => jsonToolResult(await dictionarySearch.search(input)),
toolTelemetry,
);
}
@ -762,6 +805,7 @@ export function registerKtxContextTools(deps: RegisterKtxContextToolsDeps): void
},
discoverDataSchema,
async (input) => jsonToolResult({ refs: await discover.search(input) }),
toolTelemetry,
);
}
@ -791,6 +835,7 @@ export function registerKtxContextTools(deps: RegisterKtxContextToolsDeps): void
),
);
},
toolTelemetry,
);
}
@ -818,6 +863,7 @@ export function registerKtxContextTools(deps: RegisterKtxContextToolsDeps): void
};
return jsonToolResult(await memoryIngest.ingest(ingestInput));
},
toolTelemetry,
);
registerParsedTool(
@ -835,6 +881,7 @@ export function registerKtxContextTools(deps: RegisterKtxContextToolsDeps): void
const status = await memoryIngest.status(input.runId);
return status ? jsonToolResult(status) : jsonErrorToolResult(`Memory ingest run "${input.runId}" was not found.`);
},
toolTelemetry,
);
}
}

View file

@ -23,7 +23,8 @@ import type { KtxScanArgs, KtxScanDeps } from './scan.js';
import type { KtxTableRef } from './context/scan/types.js';
import { profileMark } from './startup-profile.js';
import { isDemoConnection } from './telemetry/demo-detect.js';
import { emitProjectStackSnapshot, emitTelemetryEvent } from './telemetry/index.js';
import { emitProjectStackSnapshot, emitTelemetryEvent, reportException } from './telemetry/index.js';
import { collectTelemetryRedactionSecrets } from './telemetry/redaction-secrets.js';
import { formatErrorDetail } from './telemetry/scrubber.js';
profileMark('module:public-ingest');
@ -1119,30 +1120,63 @@ export async function runKtxPublicIngest(
feature,
});
} catch (error) {
await reportException({
error,
context: { source: 'ingest runtime', handled: true, fatal: false },
projectDir: args.projectDir,
io,
redactionSecrets: await collectTelemetryRedactionSecrets({
project,
projectDir: args.projectDir,
connectionId: args.targetConnectionId,
includeLlm: true,
includeEmbeddings: true,
env: deps.env ?? process.env,
}),
});
io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
return 1;
}
}
const { runContextBuild } = await import('./context-build-view.js');
const contextBuild = deps.runContextBuild ?? runContextBuild;
const result = await contextBuild(
project,
{
try {
const result = await contextBuild(
project,
{
projectDir: args.projectDir,
...(args.targetConnectionId ? { targetConnectionId: args.targetConnectionId } : {}),
all: args.all,
entrypoint: 'ingest',
inputMode: args.inputMode,
...(args.queryHistory ? { queryHistory: args.queryHistory } : {}),
...(args.queryHistoryWindowDays !== undefined ? { queryHistoryWindowDays: args.queryHistoryWindowDays } : {}),
...(args.scanMode ? { scanMode: args.scanMode } : {}),
...(args.detectRelationships !== undefined ? { detectRelationships: args.detectRelationships } : {}),
...(args.cliVersion ? { cliVersion: args.cliVersion } : {}),
...(args.runtimeInstallPolicy ? { runtimeInstallPolicy: args.runtimeInstallPolicy } : {}),
},
io,
);
return result.exitCode;
} catch (error) {
await reportException({
error,
context: { source: 'ingest context-build', handled: true, fatal: false },
projectDir: args.projectDir,
...(args.targetConnectionId ? { targetConnectionId: args.targetConnectionId } : {}),
all: args.all,
entrypoint: 'ingest',
inputMode: args.inputMode,
...(args.queryHistory ? { queryHistory: args.queryHistory } : {}),
...(args.queryHistoryWindowDays !== undefined ? { queryHistoryWindowDays: args.queryHistoryWindowDays } : {}),
...(args.scanMode ? { scanMode: args.scanMode } : {}),
...(args.detectRelationships !== undefined ? { detectRelationships: args.detectRelationships } : {}),
...(args.cliVersion ? { cliVersion: args.cliVersion } : {}),
...(args.runtimeInstallPolicy ? { runtimeInstallPolicy: args.runtimeInstallPolicy } : {}),
},
io,
);
return result.exitCode;
io,
redactionSecrets: await collectTelemetryRedactionSecrets({
project,
projectDir: args.projectDir,
connectionId: args.targetConnectionId,
includeLlm: true,
includeEmbeddings: true,
env: deps.env ?? process.env,
}),
});
io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
return 1;
}
}
const plan = buildPublicIngestPlan(project, args);

View file

@ -1,6 +1,6 @@
import type { KtxProgressPort, KtxScanMode, KtxScanReport, KtxScanWarning } from './context/scan/types.js';
import { runLocalScan } from './context/scan/local-scan.js';
import { loadKtxProject } from './context/project/project.js';
import { loadKtxProject, type KtxLocalProject } from './context/project/project.js';
import { getKtxCliPackageInfo } from './cli-runtime.js';
import { resolveProjectEmbeddingProvider } from './embedding-resolution.js';
import type { KtxCliIo } from './index.js';
@ -8,7 +8,8 @@ import { createKtxCliLocalIngestAdapters } from './local-adapters.js';
import { createKtxCliScanConnector } from './local-scan-connectors.js';
import type { KtxManagedPythonInstallPolicy } from './managed-python-command.js';
import { profileMark } from './startup-profile.js';
import { emitTelemetryEvent } from './telemetry/index.js';
import { emitTelemetryEvent, reportException } from './telemetry/index.js';
import { collectTelemetryRedactionSecrets } from './telemetry/redaction-secrets.js';
import { formatErrorDetail, scrubErrorClass } from './telemetry/scrubber.js';
profileMark('module:scan');
@ -322,8 +323,9 @@ export function createCliScanProgress(
export async function runKtxScan(args: KtxScanArgs, io: KtxCliIo = process, deps: KtxScanDeps = {}): Promise<number> {
const startedAt = performance.now();
let project: KtxLocalProject | undefined;
try {
const project = await loadKtxProject({ projectDir: args.projectDir });
project = await loadKtxProject({ projectDir: args.projectDir });
const resolveEmbeddingProvider = deps.resolveEmbeddingProvider ?? resolveProjectEmbeddingProvider;
const resolution = await resolveEmbeddingProvider(project, {
mode: 'ensure',
@ -397,6 +399,20 @@ export async function runKtxScan(args: KtxScanArgs, io: KtxCliIo = process, deps
...(errorDetail ? { errorDetail } : {}),
},
});
await reportException({
error,
context: { source: 'scan run', handled: true, fatal: false },
projectDir: args.projectDir,
io,
redactionSecrets: await collectTelemetryRedactionSecrets({
project,
projectDir: args.projectDir,
connectionId: args.connectionId,
includeLlm: true,
includeEmbeddings: true,
env: process.env,
}),
});
io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
return 1;
}

View file

@ -26,7 +26,8 @@ import {
type KtxManagedPythonInstallPolicy,
} from './managed-python-command.js';
import { profileMark } from './startup-profile.js';
import { emitTelemetryEvent } from './telemetry/index.js';
import { emitTelemetryEvent, reportException } from './telemetry/index.js';
import { collectTelemetryRedactionSecrets } from './telemetry/redaction-secrets.js';
import { scrubErrorClass } from './telemetry/scrubber.js';
profileMark('module:sl');
@ -202,8 +203,9 @@ function ambiguousSourceMessage(sourceName: string, connectionIds: readonly stri
export async function runKtxSl(args: KtxSlArgs, io: KtxSlIo = process, deps: KtxSlDeps = {}): Promise<number> {
const startedAt = performance.now();
let queryForTelemetry: SemanticLayerQueryInput | undefined;
let project: KtxLocalProject | undefined;
try {
const project = await (deps.loadProject ?? loadKtxProject)({ projectDir: args.projectDir });
project = await (deps.loadProject ?? loadKtxProject)({ projectDir: args.projectDir });
if (args.command === 'list') {
const sources = await listLocalSlSources(project, { connectionId: args.connectionId });
await printSlSources({
@ -320,7 +322,7 @@ export async function runKtxSl(args: KtxSlArgs, io: KtxSlIo = process, deps: Ktx
projectDir: args.projectDir,
});
const queryExecutor = args.execute ? (deps.createQueryExecutor ?? createDefaultLocalQueryExecutor)() : undefined;
const result = await compileLocalSlQuery(project as KtxLocalProject, {
const result = await compileLocalSlQuery(project, {
connectionId: args.connectionId,
query,
compute,
@ -351,6 +353,20 @@ export async function runKtxSl(args: KtxSlArgs, io: KtxSlIo = process, deps: Ktx
const _exhaustive: never = args;
throw new Error(`Unsupported sl command: ${JSON.stringify(_exhaustive)}`);
} catch (error) {
await reportException({
error,
context: { source: `sl ${args.command}`, handled: true, fatal: false },
projectDir: args.projectDir,
io,
redactionSecrets: await collectTelemetryRedactionSecrets({
project,
projectDir: args.projectDir,
connectionId: args.connectionId,
includeLlm: args.command === 'query',
includeEmbeddings: args.command === 'search' || args.command === 'query',
env: process.env,
}),
});
if (args.command === 'validate') {
const errorClass = scrubErrorClass(error);
await emitTelemetryEvent({

View file

@ -7,7 +7,8 @@ import { createKtxCliScanConnector } from './local-scan-connectors.js';
import { createManagedDaemonSqlAnalysisPort } from './managed-python-http.js';
import { profileMark } from './startup-profile.js';
import { isDemoConnection } from './telemetry/demo-detect.js';
import { emitTelemetryEvent } from './telemetry/index.js';
import { emitTelemetryEvent, reportException } from './telemetry/index.js';
import { collectTelemetryRedactionSecrets } from './telemetry/redaction-secrets.js';
import { scrubErrorClass } from './telemetry/scrubber.js';
profileMark('module:sql');
@ -142,8 +143,9 @@ export async function runKtxSql(args: KtxSqlArgs, io: KtxCliIo = process, deps:
const startedAt = performance.now();
let driver = 'unknown';
let demoConnection = false;
let project: KtxLocalProject | undefined;
try {
const project = await (deps.loadProject ?? loadKtxProject)({ projectDir: args.projectDir });
project = await (deps.loadProject ?? loadKtxProject)({ projectDir: args.projectDir });
const connection = project.config.connections[args.connectionId];
if (!connection) {
throw new Error(`Connection "${args.connectionId}" is not configured in ktx.yaml`);
@ -171,7 +173,7 @@ export async function runKtxSql(args: KtxSqlArgs, io: KtxCliIo = process, deps:
const createScanConnector = deps.createScanConnector ?? createKtxCliScanConnector;
let connector: KtxScanConnector | null = null;
try {
connector = await createScanConnector(project as KtxLocalProject, args.connectionId);
connector = await createScanConnector(project, args.connectionId);
if (!connector.capabilities.readOnlySql || !connector.executeReadOnly) {
throw new Error(`Connection "${args.connectionId}" does not support read-only SQL execution.`);
}
@ -218,6 +220,20 @@ export async function runKtxSql(args: KtxSqlArgs, io: KtxCliIo = process, deps:
...(errorClass ? { errorClass } : {}),
},
});
await reportException({
error,
context: { source: 'sql run', handled: true, fatal: false },
projectDir: args.projectDir,
io,
redactionSecrets: await collectTelemetryRedactionSecrets({
project,
projectDir: args.projectDir,
connectionId: args.connectionId,
includeLlm: false,
includeEmbeddings: false,
env: process.env,
}),
});
io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
return 1;
}

View file

@ -16,6 +16,16 @@ type PostHogClient = {
properties: Record<string, unknown>;
groups?: Record<string, string>;
}): void;
captureException(
error: unknown,
distinctId?: string,
additionalProperties?: Record<string, unknown>,
): void;
captureExceptionImmediate(
error: unknown,
distinctId?: string,
additionalProperties?: Record<string, unknown>,
): Promise<void>;
shutdown(): Promise<void> | void;
};
@ -105,6 +115,57 @@ export async function trackTelemetryEvent(input: {
}
}
function writeDebugExceptionPayload(input: {
error: Error;
distinctId: string;
properties: Record<string, unknown>;
stderr: TelemetrySink;
}): void {
input.stderr.write(
`[telemetry-exception] ${JSON.stringify({
distinctId: input.distinctId,
message: input.error.message,
name: input.error.name,
properties: input.properties,
})}\n`,
);
}
export async function trackTelemetryException(input: {
error: Error;
distinctId: string;
properties: Record<string, unknown>;
env?: TelemetryEmitterEnv;
stderr: TelemetrySink;
projectApiKey?: string;
host?: string;
immediate?: boolean;
}): Promise<void> {
const env = input.env ?? process.env;
if (debugEnabled(env)) {
writeDebugExceptionPayload(input);
return;
}
const projectApiKey = telemetryProjectApiKey(input.projectApiKey);
const host = telemetryHost(env, input.host);
const client = await getPostHogClient(projectApiKey, host);
if (!client) {
return;
}
try {
if (input.immediate) {
await client.captureExceptionImmediate(input.error, input.distinctId, input.properties);
return;
}
client.captureException(input.error, input.distinctId, input.properties);
} catch {
return;
}
}
export async function shutdownTelemetryEmitter(): Promise<void> {
const client = await clientPromise;
if (!client) {

View file

@ -0,0 +1,201 @@
import { inspect } from 'node:util';
import { getKtxCliPackageInfo, type KtxCliIo, type KtxCliPackageInfo } from '../cli-runtime.js';
import { buildCommonEnvelope } from './events.js';
import { trackTelemetryException } from './emitter.js';
import { computeTelemetryProjectId, loadTelemetryIdentity } from './identity.js';
export interface ExceptionContext {
source: string;
handled: boolean;
fatal: boolean;
extra?: Record<string, string | number | boolean>;
}
type AnyObject = object;
const reportedObjects = new WeakSet<AnyObject>();
const recentHandledPrimitives: string[] = [];
const RECENT_PRIMITIVE_LIMIT = 128;
function primitiveKey(value: unknown): string {
return `${typeof value}:${String(value)}`;
}
function rememberHandledPrimitive(value: unknown): void {
recentHandledPrimitives.push(primitiveKey(value));
if (recentHandledPrimitives.length > RECENT_PRIMITIVE_LIMIT) {
recentHandledPrimitives.splice(0, recentHandledPrimitives.length - RECENT_PRIMITIVE_LIMIT);
}
}
function consumeHandledPrimitive(value: unknown): boolean {
const key = primitiveKey(value);
const index = recentHandledPrimitives.indexOf(key);
if (index < 0) {
return false;
}
recentHandledPrimitives.splice(index, 1);
return true;
}
function shouldSkipAsAlreadyReported(error: unknown, handled: boolean): boolean {
if ((typeof error === 'object' || typeof error === 'function') && error !== null) {
if (reportedObjects.has(error)) {
return true;
}
reportedObjects.add(error);
return false;
}
if (handled) {
rememberHandledPrimitive(error);
return false;
}
return consumeHandledPrimitive(error);
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function redactStaticPatterns(value: string): string {
return value
.replace(/([a-z][a-z0-9+.-]*:\/\/[^:\s/@]+:)([^@\s/]+)(@)/gi, '$1[redacted]$3')
.replace(/\b(password|pwd)=([^;&\s]+)/gi, '$1=[redacted]')
.replace(/\bAuthorization\s*:\s*[^\r\n,;]+/gi, 'Authorization: [redacted]')
.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, 'Bearer [redacted]')
.replace(/\b(api[_-]?key)\s*[:=]\s*([^\s,;]+)/gi, '$1=[redacted]')
.replace(/\b(KTX_[A-Z0-9_]*|[A-Z0-9_]*(?:TOKEN|SECRET))\s*[:=]\s*([^\s,;]+)/g, '$1=[redacted]')
.replace(/([?&](?:X-Amz-Signature|X-Goog-Signature|sig)=)[^&\s]+/gi, '$1[redacted]');
}
function redactText(value: string, secrets: ReadonlyArray<string>): string {
let redacted = value;
for (const secret of secrets) {
if (secret) {
redacted = redacted.replace(new RegExp(escapeRegExp(secret), 'g'), '[redacted]');
}
}
return redactStaticPatterns(redacted);
}
const FORBIDDEN_EXTRA_PROPERTY_KEYS = new Set([
'argv',
'args',
'env',
'environment',
'sql',
'query',
'prompt',
'mcparguments',
'mcpargs',
'tablename',
'schemaname',
'columnname',
'databaseurl',
'connectionstring',
'url',
'password',
'token',
'apikey',
'api_key',
'authorization',
]);
function safeExtraProperties(
extra: Record<string, string | number | boolean> | undefined,
): Record<string, string | number | boolean> {
const safe: Record<string, string | number | boolean> = {};
for (const [key, value] of Object.entries(extra ?? {})) {
if (!FORBIDDEN_EXTRA_PROPERTY_KEYS.has(key.replace(/[^a-z0-9_]/gi, '').toLowerCase())) {
safe[key] = value;
}
}
return safe;
}
function toMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
if (typeof error === 'string') {
return error;
}
return inspect(error, { depth: 4, breakLength: 120 });
}
function sanitizedError(error: unknown, secrets: ReadonlyArray<string>): Error {
if (error instanceof Error) {
const cause = 'cause' in error ? (error as Error & { cause?: unknown }).cause : undefined;
const clone = new Error(redactText(error.message, secrets), {
...(cause !== undefined ? { cause: sanitizedError(cause, secrets) } : {}),
});
clone.name = error.name;
if (error.stack) {
clone.stack = redactText(error.stack, secrets);
}
return clone;
}
return new Error(redactText(toMessage(error), secrets));
}
export async function reportException(input: {
error: unknown;
context: ExceptionContext;
io: KtxCliIo;
packageInfo?: KtxCliPackageInfo;
projectDir?: string;
immediate?: boolean;
redactionSecrets?: ReadonlyArray<string>;
}): Promise<void> {
try {
if (shouldSkipAsAlreadyReported(input.error, input.context.handled)) {
return;
}
const debug = process.env.KTX_TELEMETRY_DEBUG === '1';
const identity = await loadTelemetryIdentity({
stderr: input.io.stderr,
env: process.env,
});
if ((!identity.enabled || !identity.installId) && !debug) {
return;
}
const packageInfo = input.packageInfo ?? getKtxCliPackageInfo();
const installId = identity.installId ?? 'debug';
const projectId = input.projectDir ? computeTelemetryProjectId(installId, input.projectDir) : undefined;
const safeError = sanitizedError(input.error, input.redactionSecrets ?? []);
const properties: Record<string, unknown> = {
...buildCommonEnvelope({
cliVersion: packageInfo.version,
isCi: Boolean(process.env.CI),
}),
source: input.context.source,
handled: input.context.handled,
fatal: input.context.fatal,
...(projectId ? { projectId } : {}),
...safeExtraProperties(input.context.extra),
};
delete properties.$groups;
await trackTelemetryException({
error: safeError,
distinctId: installId,
properties,
env: process.env,
stderr: input.io.stderr,
immediate: input.immediate,
});
} catch {
return;
}
}
/** @internal */
export function __resetTelemetryExceptionStateForTests(): void {
recentHandledPrimitives.length = 0;
}

View file

@ -7,6 +7,7 @@ import {
type CompletedCommandSpan,
} from './command-hook.js';
import { shutdownTelemetryEmitter, trackTelemetryEvent } from './emitter.js';
import { reportException, type ExceptionContext } from './exception.js';
import {
buildCommonEnvelope,
buildTelemetryEvent,
@ -17,8 +18,8 @@ import {
import { computeTelemetryProjectId, loadTelemetryIdentity } from './identity.js';
import { buildProjectStackSnapshotFields } from './project-snapshot.js';
export { beginCommandSpan, completeCommandSpan, shutdownTelemetryEmitter };
export type { CommandOutcome, CompletedCommandSpan };
export { beginCommandSpan, completeCommandSpan, reportException, shutdownTelemetryEmitter };
export type { CommandOutcome, CompletedCommandSpan, ExceptionContext };
export async function showTelemetryNoticeIfNeeded(io: KtxCliIo, packageInfo: KtxCliPackageInfo): Promise<void> {
const identity = await loadTelemetryIdentity({

View file

@ -0,0 +1,117 @@
import { resolveKtxConfigReference } from '../context/core/config-reference.js';
import { loadKtxProject, type KtxLocalProject } from '../context/project/project.js';
const SENSITIVE_KEY =
/(password|secret|token|api[_-]?key|auth[_-]?token|auth_token_ref|private[_-]?key|passphrase|credential|authorization|url)$/i;
type TelemetryRedactionProject = Pick<KtxLocalProject, 'config' | 'projectDir'>;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function addSecret(values: string[], value: string | undefined): void {
const trimmed = value?.trim();
if (trimmed && !values.includes(trimmed)) {
values.push(trimmed);
}
}
function tryResolve(value: string, env: NodeJS.ProcessEnv): string | undefined {
try {
return resolveKtxConfigReference(value, env);
} catch {
return undefined;
}
}
function addUrlCredentials(values: string[], value: string): void {
try {
const parsed = new URL(value);
addSecret(values, parsed.password ? decodeURIComponent(parsed.password) : undefined);
addSecret(values, parsed.username ? decodeURIComponent(parsed.username) : undefined);
} catch {
return;
}
}
function collectFromRecord(input: unknown, env: NodeJS.ProcessEnv, values: string[]): void {
if (Array.isArray(input)) {
for (const item of input) {
collectFromRecord(item, env, values);
}
return;
}
if (!isRecord(input)) {
return;
}
for (const [key, raw] of Object.entries(input)) {
if (isRecord(raw) || Array.isArray(raw)) {
collectFromRecord(raw, env, values);
continue;
}
if (typeof raw !== 'string' || !SENSITIVE_KEY.test(key)) {
continue;
}
const resolved = tryResolve(raw, env);
addSecret(values, resolved);
if (resolved) {
addUrlCredentials(values, resolved);
}
}
}
function collectLlmSecrets(project: TelemetryRedactionProject, env: NodeJS.ProcessEnv, values: string[]): void {
collectFromRecord(project.config.llm.provider, env, values);
}
function collectEmbeddingSecrets(project: TelemetryRedactionProject, env: NodeJS.ProcessEnv, values: string[]): void {
collectFromRecord(project.config.ingest.embeddings, env, values);
collectFromRecord(project.config.scan.enrichment.embeddings, env, values);
}
function collectConnectionSecrets(
project: TelemetryRedactionProject,
connectionId: string | undefined,
env: NodeJS.ProcessEnv,
values: string[],
): void {
if (!connectionId) {
return;
}
collectFromRecord(project.config.connections[connectionId], env, values);
}
export async function collectTelemetryRedactionSecrets(input: {
project?: TelemetryRedactionProject;
projectDir?: string;
connectionId?: string;
includeLlm?: boolean;
includeEmbeddings?: boolean;
env?: NodeJS.ProcessEnv;
}): Promise<string[]> {
const env = input.env ?? process.env;
let project = input.project;
if (!project && input.projectDir) {
try {
project = await loadKtxProject({ projectDir: input.projectDir });
} catch {
project = undefined;
}
}
if (!project) {
return [];
}
const values: string[] = [];
if (input.includeLlm) {
collectLlmSecrets(project, env, values);
}
if (input.includeEmbeddings) {
collectEmbeddingSecrets(project, env, values);
}
collectConnectionSecrets(project, input.connectionId, env, values);
return values;
}

View file

@ -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,
}),
);
});
});

View file

@ -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 () => {

View file

@ -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', '');

View file

@ -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();

View file

@ -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 () => {

View file

@ -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', '');

View file

@ -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 () => {

View 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);
}
});
});
});

View 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');
});
});

View file

@ -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');
});
});

View 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']);
});
});