diff --git a/knip.json b/knip.json index 74c325fc..178ff87e 100644 --- a/knip.json +++ b/knip.json @@ -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", diff --git a/packages/cli/package.json b/packages/cli/package.json index ebe8c55c..8d809e9d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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", diff --git a/packages/cli/src/telemetry/events.test.ts b/packages/cli/src/telemetry/events.test.ts index 2b61d084..3726ddde 100644 --- a/packages/cli/src/telemetry/events.test.ts +++ b/packages/cli/src/telemetry/events.test.ts @@ -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', diff --git a/packages/cli/src/telemetry/events.ts b/packages/cli/src/telemetry/events.ts index 534a07a6..e73001ed 100644 --- a/packages/cli/src/telemetry/events.ts +++ b/packages/cli/src/telemetry/events.ts @@ -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; diff --git a/packages/cli/src/telemetry/schema-writer.test.ts b/packages/cli/src/telemetry/schema-writer.test.ts new file mode 100644 index 00000000..a6539421 --- /dev/null +++ b/packages/cli/src/telemetry/schema-writer.test.ts @@ -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, + }); + }); +}); diff --git a/packages/cli/src/telemetry/schema-writer.ts b/packages/cli/src/telemetry/schema-writer.ts new file mode 100644 index 00000000..5921b4d9 --- /dev/null +++ b/packages/cli/src/telemetry/schema-writer.ts @@ -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; +} + +/** @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 { + 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 { + const targets = argv.slice(2); + if (targets.length === 0) { + throw new Error('Usage: node dist/telemetry/schema-writer.js [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); + } +}