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

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