feat: add telemetry schema sync artifact

This commit is contained in:
Andrey Avtomonov 2026-05-22 16:14:57 +02:00
parent 9dcead31ed
commit 1c6c6c853f
6 changed files with 194 additions and 3 deletions

View file

@ -11,6 +11,7 @@
"packages/cli": {
"entry": [
"src/print-command-tree.ts!",
"src/telemetry/schema-writer.ts!",
"src/telemetry/index.ts!",
"scripts/**/*.mjs",
"src/**/*.test-utils.ts",

View file

@ -29,7 +29,7 @@
},
"scripts": {
"assets:demo": "node scripts/build-demo-assets.mjs",
"build": "tsc -p tsconfig.json && node scripts/copy-runtime-assets.mjs && node ../../scripts/prepare-cli-bin.mjs",
"build": "tsc -p tsconfig.json && node dist/telemetry/schema-writer.js src/telemetry/events.schema.json ../../python/ktx-daemon/src/ktx_daemon/telemetry/events.schema.json && node scripts/copy-runtime-assets.mjs && node ../../scripts/prepare-cli-bin.mjs",
"clean": "node -e \"fs.rmSync('dist', { recursive: true, force: true })\"",
"docs:commands": "pnpm run build && node dist/print-command-tree.js",
"smoke": "vitest run src/standalone-smoke.test.ts src/example-smoke.test.ts --testTimeout 30000",

View file

@ -18,7 +18,7 @@ const envelope: TelemetryCommonEnvelope = {
};
describe('telemetry event schemas', () => {
it('catalogs all Node v1 telemetry events', () => {
it('catalogs all v1 telemetry events', () => {
expect(telemetryEventCatalog.map((event) => event.name)).toEqual([
'install_first_run',
'command',
@ -33,9 +33,50 @@ describe('telemetry event schemas', () => {
'sql_completed',
'wiki_query_completed',
'mcp_request_completed',
'daemon_started',
'daemon_stopped',
'sl_plan_completed',
'sql_gen_completed',
]);
});
it('builds strict daemon telemetry events', () => {
const daemonEnvelope = {
...envelope,
runtime: 'daemon-py' as const,
nodeVersion: '3.13.0',
};
expect(
buildTelemetryEvent('sl_plan_completed', daemonEnvelope, {
outcome: 'ok',
stage: 'transpile',
durationMs: 25,
sourceCount: 2,
joinCount: 1,
}),
).toMatchObject({
name: 'sl_plan_completed',
properties: {
runtime: 'daemon-py',
outcome: 'ok',
stage: 'transpile',
sourceCount: 2,
joinCount: 1,
},
});
expect(() =>
telemetryEventSchemas.sql_gen_completed.parse({
...daemonEnvelope,
outcome: 'ok',
dialect: 'postgres',
durationMs: 4,
sql: 'select * from private_table',
}),
).toThrow();
});
it('builds a strict install_first_run event', () => {
expect(buildTelemetryEvent('install_first_run', envelope, {})).toEqual({
name: 'install_first_run',

View file

@ -8,7 +8,7 @@ const telemetryCommonEnvelopeSchema = z
osPlatform: z.string(),
osRelease: z.string(),
arch: z.string(),
runtime: z.literal('node'),
runtime: z.enum(['node', 'daemon-py']),
isCi: z.boolean(),
})
.strict();
@ -161,6 +161,42 @@ const mcpRequestCompletedSchema = telemetryCommonEnvelopeSchema
})
.strict();
const daemonStartedSchema = telemetryCommonEnvelopeSchema
.extend({
daemonVersion: z.string(),
pythonVersion: z.string(),
runtimeVersion: z.string(),
startupDurationMs: z.number().nonnegative(),
})
.strict();
const daemonStoppedSchema = telemetryCommonEnvelopeSchema
.extend({
reason: z.enum(['signal', 'request', 'crash']),
uptimeMs: z.number().nonnegative(),
})
.strict();
const slPlanCompletedSchema = telemetryCommonEnvelopeSchema
.extend({
outcome: z.enum(['ok', 'error']),
stage: z.enum(['parse', 'resolve', 'compile', 'transpile']),
errorClass: z.string().optional(),
durationMs: z.number().nonnegative(),
sourceCount: z.number().int().nonnegative(),
joinCount: z.number().int().nonnegative(),
})
.strict();
const sqlGenCompletedSchema = telemetryCommonEnvelopeSchema
.extend({
outcome: z.enum(['ok', 'error']),
dialect: z.string(),
errorClass: z.string().optional(),
durationMs: z.number().nonnegative(),
})
.strict();
/** @internal */
export const telemetryEventSchemas = {
install_first_run: installFirstRunSchema,
@ -176,6 +212,10 @@ export const telemetryEventSchemas = {
sql_completed: sqlCompletedSchema,
wiki_query_completed: wikiQueryCompletedSchema,
mcp_request_completed: mcpRequestCompletedSchema,
daemon_started: daemonStartedSchema,
daemon_stopped: daemonStoppedSchema,
sl_plan_completed: slPlanCompletedSchema,
sql_gen_completed: sqlGenCompletedSchema,
} as const;
/** @internal */
@ -288,6 +328,26 @@ export const telemetryEventCatalog = [
description: 'Emitted for sampled MCP tool requests.',
fields: ['toolName', 'outcome', 'durationMs', 'errorClass', 'sampleRate'],
},
{
name: 'daemon_started',
description: 'Emitted when the long-lived ktx-daemon HTTP server starts.',
fields: ['daemonVersion', 'pythonVersion', 'runtimeVersion', 'startupDurationMs'],
},
{
name: 'daemon_stopped',
description: 'Emitted when the long-lived ktx-daemon HTTP server shuts down.',
fields: ['reason', 'uptimeMs'],
},
{
name: 'sl_plan_completed',
description: 'Emitted after a daemon semantic-layer planning pass completes.',
fields: ['outcome', 'stage', 'errorClass', 'durationMs', 'sourceCount', 'joinCount'],
},
{
name: 'sql_gen_completed',
description: 'Emitted after daemon SQL generation completes.',
fields: ['outcome', 'dialect', 'errorClass', 'durationMs'],
},
] as const;
export type TelemetryEventName = keyof typeof telemetryEventSchemas;

View file

@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import { buildTelemetrySchemaArtifact } from './schema-writer.js';
describe('telemetry schema writer', () => {
it('exports a schema artifact with the full catalog and strict metadata', () => {
const artifact = buildTelemetrySchemaArtifact();
expect(artifact.$schema).toBe('https://json-schema.org/draft/2020-12/schema');
expect(artifact['x-ktx-common-fields']).toEqual([
'cliVersion',
'nodeVersion',
'osPlatform',
'osRelease',
'arch',
'runtime',
'isCi',
]);
expect(artifact['x-ktx-catalog'].map((event) => event.name)).toContain('daemon_started');
expect(artifact['x-ktx-catalog'].map((event) => event.name)).toContain('sql_gen_completed');
expect(artifact.$defs.sql_gen_completed).toMatchObject({
type: 'object',
additionalProperties: false,
});
});
});

View file

@ -0,0 +1,63 @@
import { mkdir, writeFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { z } from 'zod';
import { telemetryEventCatalog, telemetryEventSchemas } from './events.js';
const commonFields = ['cliVersion', 'nodeVersion', 'osPlatform', 'osRelease', 'arch', 'runtime', 'isCi'] as const;
export interface TelemetrySchemaArtifact {
$schema: 'https://json-schema.org/draft/2020-12/schema';
title: 'ktx telemetry events';
type: 'object';
additionalProperties: false;
'x-ktx-common-fields': string[];
'x-ktx-catalog': Array<{ name: string; description: string; fields: readonly string[] }>;
$defs: Record<string, unknown>;
}
/** @internal */
export function buildTelemetrySchemaArtifact(): TelemetrySchemaArtifact {
return {
$schema: 'https://json-schema.org/draft/2020-12/schema',
title: 'ktx telemetry events',
type: 'object',
additionalProperties: false,
'x-ktx-common-fields': [...commonFields],
'x-ktx-catalog': telemetryEventCatalog.map((event) => ({
name: event.name,
description: event.description,
fields: event.fields,
})),
$defs: Object.fromEntries(
Object.entries(telemetryEventSchemas).map(([name, schema]) => [
name,
z.toJSONSchema(schema, { target: 'draft-2020-12' }),
]),
),
};
}
async function writeTelemetrySchemaArtifact(path: string): Promise<void> {
const target = resolve(path);
await mkdir(dirname(target), { recursive: true });
await writeFile(target, `${JSON.stringify(buildTelemetrySchemaArtifact(), null, 2)}\n`, 'utf-8');
}
async function main(argv: string[]): Promise<void> {
const targets = argv.slice(2);
if (targets.length === 0) {
throw new Error('Usage: node dist/telemetry/schema-writer.js <target> [target...]');
}
for (const target of targets) {
await writeTelemetrySchemaArtifact(target);
}
}
if (import.meta.url === pathToFileURL(fileURLToPath(import.meta.url)).href && process.argv[1]) {
const invoked = pathToFileURL(resolve(process.argv[1])).href;
if (import.meta.url === invoked) {
await main(process.argv);
}
}