mirror of
https://github.com/Kaelio/ktx.git
synced 2026-07-19 11:41:02 +02:00
fix(telemetry): classify daemon query rejections as expected, not faults (#335)
* fix(telemetry): classify daemon query rejections as expected, not faults Semantic-layer query rejections and warehouse-execution rejections from the sl_query MCP tool were wrapped as generic Errors, so reportException filed them as PostHog $exception faults indistinguishable from real ktx bugs. The daemon already separates a caller rejection (planner ValueError -> exit 3 / HTTP 400) from a crash. The Node runner now carries that distinction as a typed KtxDaemonComputeError, and a shared throwClassifiedQueryError promotes daemon input-rejections and warehouse rejections to KtxQueryError while daemon crashes and native JS faults still reach Error Tracking. query_semantic_layer stops report_exception-ing expected ValueErrors, and a missing 'file:' secret now raises KtxExpectedError so absent .ktx/secrets/<conn>-password stops filing faults. * chore: sync uv.lock to ktx-daemon/ktx-sl 0.15.0
This commit is contained in:
parent
a651b82e2f
commit
5d17469601
10 changed files with 347 additions and 28 deletions
|
|
@ -3,6 +3,7 @@ import { tmpdir } from 'node:os';
|
|||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { resolveKtxConfigReference, resolveKtxHomePath } from '../../../src/context/core/config-reference.js';
|
||||
import { KtxExpectedError } from '../../../src/errors.js';
|
||||
|
||||
describe('ktx config references', () => {
|
||||
it('resolves env references without returning empty values', () => {
|
||||
|
|
@ -22,6 +23,17 @@ describe('ktx config references', () => {
|
|||
expect(resolveKtxConfigReference(`file:${keyPath}`, {})).toBe('file-gateway-key');
|
||||
});
|
||||
|
||||
it('raises an expected error when a file reference is missing', () => {
|
||||
const missing = join(tmpdir(), `ktx-config-reference-missing-${process.pid}`, 'absent-password');
|
||||
try {
|
||||
resolveKtxConfigReference(`file:${missing}`, {});
|
||||
expect.unreachable('expected a thrown error for the missing secret file');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(KtxExpectedError);
|
||||
expect((error as Error).message).toContain(missing);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns literal values unchanged after trimming blank-only values', () => {
|
||||
expect(resolveKtxConfigReference('provider/model', {})).toBe('provider/model');
|
||||
expect(resolveKtxConfigReference(' ', {})).toBeUndefined();
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import { once } from 'node:events';
|
||||
import { createServer } from 'node:http';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { createHttpSemanticLayerComputePort, createPythonSemanticLayerComputePort } from '../../../src/context/daemon/semantic-layer-compute.js';
|
||||
import {
|
||||
createHttpSemanticLayerComputePort,
|
||||
createPythonSemanticLayerComputePort,
|
||||
KtxDaemonComputeError,
|
||||
} from '../../../src/context/daemon/semantic-layer-compute.js';
|
||||
|
||||
const source = {
|
||||
name: 'orders',
|
||||
|
|
@ -174,6 +178,79 @@ describe('createPythonSemanticLayerComputePort', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('KtxDaemonComputeError classification', () => {
|
||||
const query = { sources: [source], dialect: 'postgres', query: { measures: ['count(*)'], dimensions: [] } };
|
||||
|
||||
function exitingPort(code: number, stderr: string) {
|
||||
return createPythonSemanticLayerComputePort({
|
||||
command: process.execPath,
|
||||
args: [
|
||||
'-e',
|
||||
`process.stdin.on('data',()=>{});process.stdin.on('end',()=>{process.stderr.write(${JSON.stringify(stderr)});process.exit(${code})});`,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async function rejection(promise: Promise<unknown>): Promise<KtxDaemonComputeError> {
|
||||
const error = await promise.then(
|
||||
() => null,
|
||||
(thrown: unknown) => thrown,
|
||||
);
|
||||
expect(error).toBeInstanceOf(KtxDaemonComputeError);
|
||||
return error as KtxDaemonComputeError;
|
||||
}
|
||||
|
||||
it('marks a subprocess input-rejection (exit 3) as inputRejected', async () => {
|
||||
const error = await rejection(exitingPort(3, 'Measure expr does not reference any source').query(query));
|
||||
expect(error.inputRejected).toBe(true);
|
||||
expect(error.detail).toContain('does not reference any source');
|
||||
});
|
||||
|
||||
it('marks a subprocess fault (exit 1) as not inputRejected', async () => {
|
||||
const error = await rejection(exitingPort(1, 'Traceback: boom').query(query));
|
||||
expect(error.inputRejected).toBe(false);
|
||||
expect(error.detail).toContain('boom');
|
||||
});
|
||||
|
||||
async function statusPort(statusCode: number, body: string): Promise<{ port: ReturnType<typeof createHttpSemanticLayerComputePort>; close: () => void }> {
|
||||
const server = createServer((_request, response) => {
|
||||
response.writeHead(statusCode, { 'content-type': 'application/json' });
|
||||
response.end(body);
|
||||
});
|
||||
server.listen(0, '127.0.0.1');
|
||||
await once(server, 'listening');
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('expected TCP server address');
|
||||
}
|
||||
return {
|
||||
port: createHttpSemanticLayerComputePort({ baseUrl: `http://127.0.0.1:${address.port}` }),
|
||||
close: () => server.close(),
|
||||
};
|
||||
}
|
||||
|
||||
it('marks an HTTP 400 as inputRejected and unwraps the daemon detail', async () => {
|
||||
const { port, close } = await statusPort(400, JSON.stringify({ detail: 'Measure expr does not reference any source' }));
|
||||
try {
|
||||
const error = await rejection(port.query(query));
|
||||
expect(error.inputRejected).toBe(true);
|
||||
expect(error.detail).toBe('Measure expr does not reference any source');
|
||||
} finally {
|
||||
close();
|
||||
}
|
||||
});
|
||||
|
||||
it('marks an HTTP 500 as not inputRejected', async () => {
|
||||
const { port, close } = await statusPort(500, JSON.stringify({ detail: 'Daemon request failed: boom' }));
|
||||
try {
|
||||
const error = await rejection(port.query(query));
|
||||
expect(error.inputRejected).toBe(false);
|
||||
} finally {
|
||||
close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('createHttpSemanticLayerComputePort', () => {
|
||||
it('calls semantic query and validate HTTP endpoints through an injected runner', async () => {
|
||||
const requestJson = vi.fn(async (path: string) => {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { join } from 'node:path';
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { initKtxProject } from '../../../src/context/project/project.js';
|
||||
import { KtxExpectedError, KtxQueryError } from '../../../src/errors.js';
|
||||
import { KtxDaemonComputeError } from '../../../src/context/daemon/semantic-layer-compute.js';
|
||||
import { createKtxConnectorCapabilities, type KtxQueryResult, type KtxScanConnector, type KtxSchemaSnapshot } from '../../../src/context/scan/types.js';
|
||||
import { SemanticLayerService } from '../../../src/context/sl/semantic-layer.service.js';
|
||||
import type { SemanticLayerSource } from '../../../src/context/sl/types.js';
|
||||
|
|
@ -1177,4 +1178,86 @@ describe('createLocalProjectMcpContextPorts', () => {
|
|||
}),
|
||||
);
|
||||
});
|
||||
|
||||
async function seedOrdersWarehouse() {
|
||||
const project = await initKtxProject({ projectDir: tempDir });
|
||||
project.config.connections.warehouse = { driver: 'postgres', url: 'env:DATABASE_URL' };
|
||||
await seedSlSourceFile(project, {
|
||||
connectionId: 'warehouse',
|
||||
sourceName: 'orders',
|
||||
yaml: ['name: orders', 'table: public.orders', 'grain:', ' - id', 'columns:', ' - name: id', ' type: number', 'joins: []', 'measures: []', ''].join('\n'),
|
||||
});
|
||||
return project;
|
||||
}
|
||||
|
||||
it('promotes a daemon input-rejection to an expected KtxQueryError carrying the daemon detail', async () => {
|
||||
const project = await seedOrdersWarehouse();
|
||||
const semanticLayerCompute = {
|
||||
validateSources: vi.fn(),
|
||||
generateSources: vi.fn(),
|
||||
query: vi.fn(async () => {
|
||||
throw new KtxDaemonComputeError("ktx-daemon semantic-query failed: Measure expr 'count(*)' does not reference any source", {
|
||||
inputRejected: true,
|
||||
detail: "Measure expr 'count(*)' does not reference any source",
|
||||
});
|
||||
}),
|
||||
};
|
||||
const ports = createLocalProjectMcpContextPorts(project, { semanticLayerCompute, embeddingService: null });
|
||||
|
||||
const rejection = ports.semanticLayer?.query({
|
||||
connectionId: 'warehouse',
|
||||
query: { measures: [{ expr: 'count(*)', name: 'n' }], dimensions: [] },
|
||||
});
|
||||
await expect(rejection).rejects.toBeInstanceOf(KtxQueryError);
|
||||
await expect(rejection).rejects.toThrow("Measure expr 'count(*)' does not reference any source");
|
||||
});
|
||||
|
||||
it('leaves a daemon crash as an unexpected fault', async () => {
|
||||
const project = await seedOrdersWarehouse();
|
||||
const semanticLayerCompute = {
|
||||
validateSources: vi.fn(),
|
||||
generateSources: vi.fn(),
|
||||
query: vi.fn(async () => {
|
||||
throw new KtxDaemonComputeError('ktx-daemon semantic-query failed: KeyError: boom', {
|
||||
inputRejected: false,
|
||||
detail: 'KeyError: boom',
|
||||
});
|
||||
}),
|
||||
};
|
||||
const ports = createLocalProjectMcpContextPorts(project, { semanticLayerCompute, embeddingService: null });
|
||||
|
||||
const rejection = ports.semanticLayer?.query({
|
||||
connectionId: 'warehouse',
|
||||
query: { measures: ['orders.order_count'], dimensions: [] },
|
||||
});
|
||||
await expect(rejection).rejects.toBeInstanceOf(KtxDaemonComputeError);
|
||||
await expect(rejection).rejects.not.toBeInstanceOf(KtxQueryError);
|
||||
});
|
||||
|
||||
it('wraps a warehouse execution rejection from sl_query as KtxQueryError', async () => {
|
||||
const project = await seedOrdersWarehouse();
|
||||
const semanticLayerCompute = {
|
||||
validateSources: vi.fn(),
|
||||
generateSources: vi.fn(),
|
||||
query: vi.fn(async () => ({
|
||||
sql: 'select count(*) from public.orders',
|
||||
dialect: 'postgres',
|
||||
columns: [{ name: 'orders.order_count' }],
|
||||
plan: {},
|
||||
})),
|
||||
};
|
||||
const queryExecutor = {
|
||||
execute: vi.fn(async () => {
|
||||
throw new Error("Unknown column '검사 유형' in 'SELECT'");
|
||||
}),
|
||||
};
|
||||
const ports = createLocalProjectMcpContextPorts(project, { semanticLayerCompute, queryExecutor, embeddingService: null });
|
||||
|
||||
const rejection = ports.semanticLayer?.query({
|
||||
connectionId: 'warehouse',
|
||||
query: { measures: ['orders.order_count'], dimensions: [], limit: 5 },
|
||||
});
|
||||
await expect(rejection).rejects.toBeInstanceOf(KtxQueryError);
|
||||
await expect(rejection).rejects.toThrow("Unknown column '검사 유형' in 'SELECT'");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue