mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-13 08:15:14 +02:00
feat(telemetry): include error details for failures (#254)
This commit is contained in:
parent
494618ab14
commit
6da8c3452a
18 changed files with 1259 additions and 999 deletions
|
|
@ -162,6 +162,27 @@ describe('runKtxConnection', () => {
|
|||
expect(io.stderr()).not.toContain(projectDir);
|
||||
});
|
||||
|
||||
it('records the raw errorDetail in connection_test telemetry when a native test fails', async () => {
|
||||
vi.stubEnv('KTX_TELEMETRY_DEBUG', '1');
|
||||
vi.stubEnv('CI', '');
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKtxProject({ projectDir });
|
||||
await writeConnections(projectDir, {
|
||||
warehouse: { driver: 'sqlite' },
|
||||
});
|
||||
const { connector } = nativeConnector('sqlite', { success: false, error: 'database file is unreadable' });
|
||||
const io = makeIo();
|
||||
|
||||
const code = await runKtxConnection({ command: 'test', projectDir, connectionId: 'warehouse' }, io.io, {
|
||||
createScanConnector: vi.fn(async () => connector),
|
||||
});
|
||||
|
||||
expect(code).toBe(1);
|
||||
expect(io.stderr()).toContain('"event":"connection_test"');
|
||||
expect(io.stderr()).toContain('"outcome":"error"');
|
||||
expect(io.stderr()).toContain('"errorDetail":"database file is unreadable"');
|
||||
});
|
||||
|
||||
it('reports the connector error and still cleans up when native testConnection fails', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKtxProject({ projectDir });
|
||||
|
|
|
|||
|
|
@ -431,6 +431,32 @@ describe('runKtxPublicIngest', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('records errorDetail in ingest_completed telemetry when a target fails', async () => {
|
||||
vi.stubEnv('KTX_TELEMETRY_DEBUG', '1');
|
||||
vi.stubEnv('CI', '');
|
||||
const projectDir = await mkdtemp(join(tmpdir(), 'ktx-public-ingest-telemetry-fail-'));
|
||||
try {
|
||||
await initKtxProject({ projectDir });
|
||||
const io = makeIo({ isTTY: true });
|
||||
const project = deepReadyProject({
|
||||
warehouse: { driver: 'sqlite', path: join(projectDir, 'warehouse.sqlite') },
|
||||
});
|
||||
|
||||
const code = await runKtxPublicIngest(
|
||||
{ command: 'run', projectDir, targetConnectionId: 'warehouse', all: false, json: false, inputMode: 'disabled' },
|
||||
io.io,
|
||||
{ loadProject: vi.fn(async () => project), runScan: vi.fn(async () => 1) },
|
||||
);
|
||||
|
||||
expect(code).toBe(1);
|
||||
expect(io.stderr()).toContain('"event":"ingest_completed"');
|
||||
expect(io.stderr()).toContain('"outcome":"error"');
|
||||
expect(io.stderr()).toContain('"errorDetail"');
|
||||
} finally {
|
||||
await rm(projectDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('runs query history after schema ingest with current-run window override', async () => {
|
||||
const io = makeIo();
|
||||
const runtimeIo = makeIo({ isTTY: true });
|
||||
|
|
|
|||
|
|
@ -423,6 +423,37 @@ describe('runKtxScan', () => {
|
|||
expect(io.stderr()).not.toContain(tempDir);
|
||||
});
|
||||
|
||||
it('records the raw errorDetail in scan_completed telemetry when the scan throws', async () => {
|
||||
vi.stubEnv('KTX_TELEMETRY_DEBUG', '1');
|
||||
vi.stubEnv('CI', '');
|
||||
await initKtxProject({ projectDir: tempDir });
|
||||
const runLocalScan = vi.fn(async (): Promise<LocalScanRunResult> => {
|
||||
const error = new Error('introspection timed out');
|
||||
(error as { code?: unknown }).code = 'ETIMEDOUT';
|
||||
throw error;
|
||||
});
|
||||
const io = makeIo({ isTTY: true });
|
||||
|
||||
const code = await runKtxScan(
|
||||
{
|
||||
command: 'run',
|
||||
projectDir: tempDir,
|
||||
connectionId: 'warehouse',
|
||||
mode: 'structural',
|
||||
detectRelationships: false,
|
||||
dryRun: false,
|
||||
databaseIntrospectionUrl: 'http://127.0.0.1:8765',
|
||||
},
|
||||
io.io,
|
||||
{ runLocalScan, createLocalIngestAdapters: noLocalIngestAdapters },
|
||||
);
|
||||
|
||||
expect(code).toBe(1);
|
||||
expect(io.stderr()).toContain('"event":"scan_completed"');
|
||||
expect(io.stderr()).toContain('"outcome":"error"');
|
||||
expect(io.stderr()).toContain('"errorDetail":"ETIMEDOUT: introspection timed out"');
|
||||
});
|
||||
|
||||
it('passes KTX daemon options to local ingest adapters when no explicit daemon URL is set', async () => {
|
||||
await initKtxProject({ projectDir: tempDir });
|
||||
const createLocalIngestAdapters = vi.fn(() => []);
|
||||
|
|
|
|||
|
|
@ -332,6 +332,30 @@ describe('setup context build state', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('captures the raw errorDetail on the result when the context build throws', async () => {
|
||||
await writeReadyProject(tempDir);
|
||||
const io = makeIo();
|
||||
const runContextBuildMock = vi.fn<NonNullable<KtxSetupContextDeps['runContextBuild']>>(async () => {
|
||||
throw new Error('managed runtime exited with code 1');
|
||||
});
|
||||
|
||||
await expect(
|
||||
runKtxSetupContextStep(
|
||||
{ projectDir: tempDir, inputMode: 'disabled' },
|
||||
io.io,
|
||||
{
|
||||
runIdFactory: () => 'setup-context-local-throw',
|
||||
now: () => new Date('2026-05-09T10:00:00.000Z'),
|
||||
runContextBuild: runContextBuildMock,
|
||||
},
|
||||
),
|
||||
).resolves.toEqual({
|
||||
status: 'failed',
|
||||
projectDir: tempDir,
|
||||
errorDetail: 'managed runtime exited with code 1',
|
||||
});
|
||||
});
|
||||
|
||||
it('marks context complete without prompting when initial source ingest already made agent context', async () => {
|
||||
await writeReadyProject(tempDir);
|
||||
await mkdir(join(tempDir, 'semantic-layer', 'dbt-main'), { recursive: true });
|
||||
|
|
|
|||
|
|
@ -34,4 +34,23 @@ describe('telemetry command hook', () => {
|
|||
resetCommandSpan();
|
||||
expect(completeCommandSpan({ completedAt: 200, outcome: 'ok' })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('captures errorClass and raw errorDetail on a failed command', () => {
|
||||
resetCommandSpan();
|
||||
beginCommandSpan({
|
||||
commandPath: ['ktx', 'ingest'],
|
||||
flagsPresent: {},
|
||||
hasProject: true,
|
||||
attachProjectGroup: false,
|
||||
startedAt: 0,
|
||||
});
|
||||
|
||||
class KtxConnectionError extends Error {}
|
||||
const error = new KtxConnectionError('connect ECONNREFUSED 127.0.0.1:5432');
|
||||
|
||||
const completed = completeCommandSpan({ completedAt: 10, outcome: 'error', error });
|
||||
expect(completed?.outcome).toBe('error');
|
||||
expect(completed?.errorClass).toBe('KtxConnectionError');
|
||||
expect(completed?.errorDetail).toBe('connect ECONNREFUSED 127.0.0.1:5432');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { scrubErrorClass } from '../../src/telemetry/scrubber.js';
|
||||
import { formatErrorDetail, scrubErrorClass } from '../../src/telemetry/scrubber.js';
|
||||
|
||||
class KtxProjectMissingAbortError extends Error {}
|
||||
|
||||
|
|
@ -23,3 +23,39 @@ describe('scrubErrorClass', () => {
|
|||
expect(scrubErrorClass(null)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatErrorDetail', () => {
|
||||
it('prefixes a string or numeric .code onto the message', () => {
|
||||
const refused = new Error('connect failed');
|
||||
(refused as { code?: unknown }).code = 'ECONNREFUSED';
|
||||
expect(formatErrorDetail(refused)).toBe('ECONNREFUSED: connect failed');
|
||||
|
||||
const forbidden = new Error('forbidden');
|
||||
(forbidden as { code?: unknown }).code = 403;
|
||||
expect(formatErrorDetail(forbidden)).toBe('403: forbidden');
|
||||
});
|
||||
|
||||
it('uses the bare message when there is no .code', () => {
|
||||
expect(formatErrorDetail(new Error('password authentication failed for user "x"'))).toBe(
|
||||
'password authentication failed for user "x"',
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts non-Error values', () => {
|
||||
expect(formatErrorDetail('boom')).toBe('boom');
|
||||
});
|
||||
|
||||
it('collapses whitespace to a single line', () => {
|
||||
expect(formatErrorDetail(new Error('line one\n line two'))).toBe('line one line two');
|
||||
});
|
||||
|
||||
it('caps the length at 1000 characters', () => {
|
||||
expect(formatErrorDetail(new Error('x'.repeat(2000)))?.length).toBe(1000);
|
||||
});
|
||||
|
||||
it('returns undefined for empty, null, or undefined input', () => {
|
||||
expect(formatErrorDetail(new Error(' '))).toBeUndefined();
|
||||
expect(formatErrorDetail(null)).toBeUndefined();
|
||||
expect(formatErrorDetail(undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue