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

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