feat(telemetry): anonymous posthog usage telemetry across node cli and python daemon (#205)

* feat: add telemetry phase 1

* feat: add node telemetry event catalog

* feat: add telemetry event helpers

* feat: emit setup and connection telemetry

* feat: emit connection and stack telemetry

* feat: emit ingest and scan telemetry

* feat: emit query telemetry

* feat: emit sampled mcp telemetry

* docs: expand telemetry event catalog

* feat: add telemetry schema sync artifact

* feat: pass telemetry project id to semantic daemon

* feat: add daemon telemetry foundation

* feat: emit semantic daemon telemetry

* feat: emit daemon lifecycle telemetry

* docs: document full telemetry event catalog

* feat(telemetry): dim first-run notice

* feat(telemetry): show first-run notice before command output

* feat(telemetry): wire ktx PostHog project for live ingestion

* docs(telemetry): drop posthog project name and host from storage section

* docs(telemetry): trim to general overview and disclaimer

* docs(agents): add short telemetry guidelines

* feat(telemetry): enable posthog geoip enrichment

* docs(telemetry): drop ip-geoip note from public overview

* refactor(telemetry): drop no-op groupIdentify, rely on capture groups field

* fix(telemetry): respect CI kill switch in python daemon identity

* fix(sql): route table-count analysis to existing analyze-batch endpoint

* fix(telemetry): emit install_first_run from notice path and derive flagsPresent from commander

* fix(telemetry): read package info via getKtxCliPackageInfo to satisfy boundary check

* fix(telemetry): make python identity env={} bypass os.environ and unset CI in tests

* fix(telemetry): unset CI kill switch in cli-program-telemetry tests
This commit is contained in:
Andrey Avtomonov 2026-05-22 18:18:47 +02:00 committed by GitHub
parent c87d14a554
commit b0dd13ce7c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
73 changed files with 6576 additions and 48 deletions

View file

@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest';
import { beginCommandSpan, completeCommandSpan, resetCommandSpan } from './command-hook.js';
describe('telemetry command hook', () => {
it('builds a completed command event from a span', () => {
resetCommandSpan();
beginCommandSpan({
commandPath: ['ktx', 'status'],
flagsPresent: { projectDir: true, json: true },
projectDir: '/tmp/private',
hasProject: true,
attachProjectGroup: true,
startedAt: 100,
});
expect(
completeCommandSpan({
completedAt: 125,
outcome: 'ok',
}),
).toEqual({
commandPath: ['ktx', 'status'],
durationMs: 25,
outcome: 'ok',
flagsPresent: { projectDir: true, json: true },
hasProject: true,
projectDir: '/tmp/private',
projectGroupAttached: true,
});
});
it('returns undefined when no preAction span exists', () => {
resetCommandSpan();
expect(completeCommandSpan({ completedAt: 200, outcome: 'ok' })).toBeUndefined();
});
});

View file

@ -0,0 +1,59 @@
import { scrubErrorClass } from './scrubber.js';
export type CommandOutcome = 'ok' | 'error' | 'aborted';
interface CommandSpan {
commandPath: string[];
flagsPresent: Record<string, boolean>;
projectDir?: string;
hasProject: boolean;
attachProjectGroup: boolean;
startedAt: number;
}
export interface CompletedCommandSpan {
commandPath: string[];
durationMs: number;
outcome: CommandOutcome;
errorClass?: string;
flagsPresent: Record<string, boolean>;
hasProject: boolean;
projectDir?: string;
projectGroupAttached: boolean;
}
let activeCommandSpan: CommandSpan | undefined;
export function beginCommandSpan(input: CommandSpan): void {
activeCommandSpan = input;
}
export function completeCommandSpan(input: {
completedAt: number;
outcome: CommandOutcome;
error?: unknown;
}): CompletedCommandSpan | undefined {
const span = activeCommandSpan;
activeCommandSpan = undefined;
if (!span) {
return undefined;
}
const errorClass = input.error ? scrubErrorClass(input.error) : undefined;
return {
commandPath: span.commandPath,
durationMs: Math.max(0, input.completedAt - span.startedAt),
outcome: input.outcome,
...(errorClass ? { errorClass } : {}),
flagsPresent: span.flagsPresent,
hasProject: span.hasProject,
projectDir: span.projectDir,
projectGroupAttached: span.attachProjectGroup,
};
}
/** @internal */
export function resetCommandSpan(): void {
activeCommandSpan = undefined;
}

View file

@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';
import { isDemoConnection } from './demo-detect.js';
describe('isDemoConnection', () => {
it('detects only the packaged Orbit SQLite demo recipe', () => {
expect(
isDemoConnection('orbit_demo', {
driver: 'sqlite',
path: '/tmp/ktx-demo/demo.db',
}),
).toBe(true);
expect(
isDemoConnection('orbit_demo', {
driver: 'postgres',
path: '/tmp/ktx-demo/demo.db',
}),
).toBe(false);
expect(
isDemoConnection('warehouse', {
driver: 'sqlite',
path: '/tmp/ktx-demo/demo.db',
}),
).toBe(false);
expect(
isDemoConnection('orbit_demo', {
driver: 'sqlite',
path: '/tmp/ktx-demo/private.db',
}),
).toBe(false);
});
});

View file

@ -0,0 +1,15 @@
import { basename } from 'node:path';
import type { KtxProjectConnectionConfig } from '../context/project/config.js';
import { DEMO_CONNECTION_ID } from '../demo-assets.js';
export function isDemoConnection(
connectionId: string,
connection: KtxProjectConnectionConfig | undefined,
): boolean {
if (!connection) {
return false;
}
const path = typeof connection.path === 'string' ? connection.path : '';
return connectionId === DEMO_CONNECTION_ID && connection.driver === 'sqlite' && basename(path) === 'demo.db';
}

View file

@ -0,0 +1,123 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
__resetTelemetryEmitterForTests,
shutdownTelemetryEmitter,
trackTelemetryEvent,
} from './emitter.js';
import type { BuiltTelemetryEvent } from './events.js';
const captures: unknown[] = [];
const shutdown = vi.fn(async () => {});
function liveConfigId(): string {
return 'fixture';
}
vi.mock('posthog-node', () => ({
PostHog: vi.fn().mockImplementation(function () {
return {
capture: (event: unknown) => captures.push(event),
shutdown,
};
}),
}));
function commandEvent(): BuiltTelemetryEvent<'command'> {
return {
name: 'command',
properties: {
cliVersion: '0.4.1',
nodeVersion: 'v22.0.0',
osPlatform: 'darwin',
osRelease: '25.0.0',
arch: 'arm64',
runtime: 'node',
isCi: false,
commandPath: ['ktx', 'status'],
durationMs: 1,
outcome: 'ok',
flagsPresent: {},
hasProject: true,
projectGroupAttached: true,
},
};
}
describe('telemetry emitter', () => {
beforeEach(() => {
captures.length = 0;
shutdown.mockClear();
__resetTelemetryEmitterForTests();
});
it('prints debug payloads without importing or sending to PostHog', async () => {
const stderr: string[] = [];
await trackTelemetryEvent({
event: commandEvent(),
distinctId: 'install-1',
projectId: 'project-1',
env: { KTX_TELEMETRY_DEBUG: '1' },
stderr: { write: (chunk) => stderr.push(chunk) },
});
expect(stderr.join('')).toContain('[telemetry]');
expect(stderr.join('')).toContain('"event":"command"');
expect(captures).toEqual([]);
});
it('sends to PostHog by default once config constants are populated', async () => {
await trackTelemetryEvent({
event: commandEvent(),
distinctId: 'install-1',
projectId: 'project-1',
env: {},
stderr: { write: () => {} },
});
expect(captures).toHaveLength(1);
expect(captures[0]).toMatchObject({
distinctId: 'install-1',
event: 'command',
groups: { project: 'project-1' },
});
});
it('captures with distinctId, properties, and groups when live config is supplied', async () => {
await trackTelemetryEvent({
event: commandEvent(),
distinctId: 'install-1',
projectId: 'project-1',
projectApiKey: liveConfigId(),
host: 'https://us.i.posthog.com',
env: {},
stderr: { write: () => {} },
});
expect(captures).toHaveLength(1);
expect(captures[0]).toMatchObject({
distinctId: 'install-1',
event: 'command',
groups: { project: 'project-1' },
properties: {
cliVersion: '0.4.1',
commandPath: ['ktx', 'status'],
},
});
});
it('shuts down the client without throwing', async () => {
await trackTelemetryEvent({
event: commandEvent(),
distinctId: 'install-1',
projectApiKey: liveConfigId(),
host: 'https://us.i.posthog.com',
env: {},
stderr: { write: () => {} },
});
await expect(shutdownTelemetryEmitter()).resolves.toBeUndefined();
expect(shutdown).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,125 @@
import type { BuiltTelemetryEvent } from './events.js';
export interface TelemetryEmitterEnv {
KTX_TELEMETRY_DEBUG?: string;
KTX_TELEMETRY_ENDPOINT?: string;
}
export interface TelemetrySink {
write(chunk: string): void;
}
type PostHogClient = {
capture(event: {
distinctId: string;
event: string;
properties: Record<string, unknown>;
groups?: Record<string, string>;
}): void;
shutdown(): Promise<void> | void;
};
// PostHog public project ingestion key — safe to embed; capture-only, no read access.
const POSTHOG_PROJECT_API_KEY = 'phc_xbvZpbu8ZNLnogTbY7MEMWhCF2rzzApYsDndjKaRBXXx'; // pragma: allowlist secret
const POSTHOG_HOST = 'https://us.i.posthog.com';
const SHUTDOWN_TIMEOUT_MS = 1500;
let clientPromise: Promise<PostHogClient | null> | undefined;
function telemetryHost(env: TelemetryEmitterEnv, explicitHost?: string): string {
return explicitHost ?? env.KTX_TELEMETRY_ENDPOINT ?? POSTHOG_HOST;
}
function telemetryProjectApiKey(explicitProjectApiKey?: string): string {
return explicitProjectApiKey ?? POSTHOG_PROJECT_API_KEY;
}
function liveTelemetryConfigured(projectApiKey: string, host: string): boolean {
return projectApiKey.trim() !== '' && host.trim() !== '';
}
async function getPostHogClient(projectApiKey: string, host: string): Promise<PostHogClient | null> {
if (!liveTelemetryConfigured(projectApiKey, host)) {
return null;
}
clientPromise ??= import('posthog-node')
.then(({ PostHog }) => new PostHog(projectApiKey, { host, flushAt: 1, flushInterval: 0 }))
.catch(() => null);
return await clientPromise;
}
function debugEnabled(env: TelemetryEmitterEnv): boolean {
return env.KTX_TELEMETRY_DEBUG === '1';
}
function writeDebugPayload(input: {
event: BuiltTelemetryEvent;
distinctId: string;
projectId?: string;
stderr: TelemetrySink;
}): void {
input.stderr.write(
`[telemetry] ${JSON.stringify({
distinctId: input.distinctId,
event: input.event.name,
properties: input.event.properties,
groups: input.projectId ? { project: input.projectId } : undefined,
})}\n`,
);
}
export async function trackTelemetryEvent(input: {
event: BuiltTelemetryEvent;
distinctId: string;
projectId?: string;
env?: TelemetryEmitterEnv;
stderr: TelemetrySink;
projectApiKey?: string;
host?: string;
}): Promise<void> {
const env = input.env ?? process.env;
if (debugEnabled(env)) {
writeDebugPayload(input);
return;
}
const projectApiKey = telemetryProjectApiKey(input.projectApiKey);
const host = telemetryHost(env, input.host);
const client = await getPostHogClient(projectApiKey, host);
if (!client) {
return;
}
try {
client.capture({
distinctId: input.distinctId,
event: input.event.name,
properties: input.event.properties,
groups: input.projectId ? { project: input.projectId } : undefined,
});
} catch {
return;
}
}
export async function shutdownTelemetryEmitter(): Promise<void> {
const client = await clientPromise;
if (!client) {
return;
}
await Promise.race([
Promise.resolve(client.shutdown()).catch(() => undefined),
new Promise<void>((resolve) => {
setTimeout(resolve, SHUTDOWN_TIMEOUT_MS);
}),
]);
}
/** @internal */
export function __resetTelemetryEmitterForTests(): void {
clientPromise = undefined;
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,141 @@
import { describe, expect, it } from 'vitest';
import { buildTelemetryEvent, type TelemetryCommonEnvelope } from './events.js';
const BLACKLIST = [
'/Users/',
'/home/',
'C:\\',
'localhost',
'.local',
'kaelio.com',
'select ',
'SELECT ',
'INSERT',
'CREATE',
'@',
'password',
'secret',
'token',
'key',
];
const envelope: TelemetryCommonEnvelope = {
cliVersion: '0.4.1',
nodeVersion: 'v22.0.0',
osPlatform: 'darwin',
osRelease: '25.0.0',
arch: 'arm64',
runtime: 'node',
isCi: false,
};
describe('telemetry privacy snapshot', () => {
it('does not emit known private substrings from phase 1 event payloads', () => {
const events = [
buildTelemetryEvent('install_first_run', envelope, {}),
buildTelemetryEvent('command', envelope, {
commandPath: ['ktx', 'sql'],
durationMs: 10,
outcome: 'error',
errorClass: 'KtxProjectMissingAbortError',
flagsPresent: {
'project-dir': true,
connection: true,
c: true,
},
hasProject: false,
projectGroupAttached: false,
}),
buildTelemetryEvent('setup_step', envelope, {
step: 'databases',
outcome: 'completed',
durationMs: 42,
}),
buildTelemetryEvent('connection_added', envelope, {
driver: 'postgres',
isDemoConnection: false,
}),
buildTelemetryEvent('connection_test', envelope, {
driver: 'postgres',
isDemoConnection: false,
outcome: 'error',
errorClass: 'KtxConnectionTestAbortError',
durationMs: 34,
serverVersion: '16',
}),
buildTelemetryEvent('project_stack_snapshot', envelope, {
connectors: [
{ driver: 'sqlite', isDemo: true },
{ driver: 'postgres', isDemo: false },
],
connectionCount: 2,
hasSl: true,
hasWiki: true,
hasMcp: true,
hasManagedRuntime: true,
}),
buildTelemetryEvent('ingest_completed', envelope, {
driver: 'postgres',
isDemoConnection: false,
schemaCount: 2,
tableCount: 4,
columnCount: 20,
rowsBucket: '<100k',
durationMs: 100,
outcome: 'ok',
}),
buildTelemetryEvent('scan_completed', envelope, {
driver: 'postgres',
tableCount: 4,
columnCount: 20,
inferredFkCount: 2,
declaredFkCount: 1,
durationMs: 70,
outcome: 'ok',
}),
buildTelemetryEvent('sl_validate_completed', envelope, {
sourceCount: 1,
modelCount: 3,
validationErrorCount: 0,
outcome: 'ok',
durationMs: 15,
}),
buildTelemetryEvent('sl_query_completed', envelope, {
mode: 'compile',
referencedSourceCount: 1,
referencedDimensionCount: 2,
referencedMeasureCount: 1,
durationMs: 18,
outcome: 'ok',
}),
buildTelemetryEvent('sql_completed', envelope, {
driver: 'postgres',
isDemoConnection: false,
queryVerb: 'select',
referencedTableCount: 3,
durationMs: 20,
outcome: 'ok',
}),
buildTelemetryEvent('wiki_query_completed', envelope, {
queryLength: 'select private_table from /Users/alice'.length,
resultCount: 2,
durationMs: 8,
outcome: 'ok',
}),
buildTelemetryEvent('mcp_request_completed', envelope, {
toolName: 'sl_query',
outcome: 'error',
errorClass: 'KtxProjectMissingAbortError',
durationMs: 12,
sampleRate: 0.1,
}),
];
const payload = JSON.stringify(events);
for (const forbidden of BLACKLIST) {
expect(payload).not.toContain(forbidden);
}
});
});

View file

@ -0,0 +1,165 @@
import { describe, expect, it } from 'vitest';
import {
buildTelemetryEvent,
telemetryEventCatalog,
telemetryEventSchemas,
type TelemetryCommonEnvelope,
} from './events.js';
const envelope: TelemetryCommonEnvelope = {
cliVersion: '0.4.1',
nodeVersion: 'v22.0.0',
osPlatform: 'darwin',
osRelease: '25.0.0',
arch: 'arm64',
runtime: 'node',
isCi: false,
};
describe('telemetry event schemas', () => {
it('catalogs all v1 telemetry events', () => {
expect(telemetryEventCatalog.map((event) => event.name)).toEqual([
'install_first_run',
'command',
'setup_step',
'connection_added',
'connection_test',
'project_stack_snapshot',
'ingest_completed',
'scan_completed',
'sl_validate_completed',
'sl_query_completed',
'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',
properties: envelope,
});
});
it('builds a strict command event with project grouping fields', () => {
expect(
buildTelemetryEvent('command', envelope, {
commandPath: ['ktx', 'status'],
durationMs: 12,
outcome: 'ok',
flagsPresent: { json: true },
hasProject: true,
projectGroupAttached: true,
}),
).toEqual({
name: 'command',
properties: {
...envelope,
commandPath: ['ktx', 'status'],
durationMs: 12,
outcome: 'ok',
flagsPresent: { json: true },
hasProject: true,
projectGroupAttached: true,
},
});
});
it('rejects unmodeled event properties', () => {
expect(() =>
telemetryEventSchemas.command.parse({
...envelope,
commandPath: ['ktx', 'status'],
durationMs: 12,
outcome: 'ok',
flagsPresent: {},
hasProject: true,
projectGroupAttached: true,
tableName: 'private_table',
}),
).toThrow();
});
it('builds strict Phase 2 events without private names or text', () => {
expect(
buildTelemetryEvent('connection_test', envelope, {
driver: 'postgres',
isDemoConnection: false,
outcome: 'ok',
durationMs: 34,
serverVersion: '16',
}),
).toMatchObject({
name: 'connection_test',
properties: {
driver: 'postgres',
isDemoConnection: false,
outcome: 'ok',
durationMs: 34,
serverVersion: '16',
},
});
expect(() =>
telemetryEventSchemas.sql_completed.parse({
...envelope,
driver: 'postgres',
isDemoConnection: false,
queryVerb: 'select',
referencedTableCount: 1,
durationMs: 10,
outcome: 'ok',
sql: 'select * from private_table',
}),
).toThrow();
});
it('rejects raw private field names that are not in the telemetry schemas', () => {
expect(JSON.stringify(telemetryEventSchemas)).not.toContain('tableName');
expect(Object.keys(telemetryEventSchemas.sql_completed.shape)).not.toContain('sql');
expect(JSON.stringify(telemetryEventSchemas)).not.toContain('path');
});
});

View file

@ -0,0 +1,387 @@
import { arch, platform, release } from 'node:os';
import { z } from 'zod';
const telemetryCommonEnvelopeSchema = z
.object({
cliVersion: z.string(),
nodeVersion: z.string(),
osPlatform: z.string(),
osRelease: z.string(),
arch: z.string(),
runtime: z.enum(['node', 'daemon-py']),
isCi: z.boolean(),
})
.strict();
const installFirstRunSchema = telemetryCommonEnvelopeSchema.strict();
const commandSchema = telemetryCommonEnvelopeSchema
.extend({
commandPath: z.array(z.string()).min(1),
durationMs: z.number().nonnegative(),
outcome: z.enum(['ok', 'error', 'aborted']),
errorClass: z.string().optional(),
flagsPresent: z.record(z.string(), z.boolean()),
hasProject: z.boolean(),
projectGroupAttached: z.boolean(),
})
.strict();
const outcomeSchema = z.enum(['ok', 'error']);
const setupStepSchema = telemetryCommonEnvelopeSchema
.extend({
step: z.enum([
'project',
'runtime',
'models',
'embeddings',
'secrets',
'databases',
'database-context-depth',
'sources',
'context',
'agents',
'demo-tour',
]),
outcome: z.enum(['completed', 'skipped', 'abandoned']),
durationMs: z.number().nonnegative(),
})
.strict();
const connectionAddedSchema = telemetryCommonEnvelopeSchema
.extend({
driver: z.string(),
isDemoConnection: z.boolean(),
})
.strict();
const connectionTestSchema = telemetryCommonEnvelopeSchema
.extend({
driver: z.string(),
isDemoConnection: z.boolean(),
outcome: outcomeSchema,
errorClass: z.string().optional(),
durationMs: z.number().nonnegative(),
serverVersion: z.string().optional(),
})
.strict();
const projectStackSnapshotSchema = telemetryCommonEnvelopeSchema
.extend({
connectors: z.array(z.object({ driver: z.string(), isDemo: z.boolean() }).strict()),
connectionCount: z.number().int().nonnegative(),
hasSl: z.boolean(),
hasWiki: z.boolean(),
hasMcp: z.boolean(),
hasManagedRuntime: z.boolean(),
})
.strict();
const rowsBucketSchema = z.enum(['<10k', '<100k', '<1M', '<10M', '>=10M']);
const ingestCompletedSchema = telemetryCommonEnvelopeSchema
.extend({
driver: z.string(),
isDemoConnection: z.boolean(),
schemaCount: z.number().int().nonnegative(),
tableCount: z.number().int().nonnegative(),
columnCount: z.number().int().nonnegative(),
rowsBucket: rowsBucketSchema,
durationMs: z.number().nonnegative(),
outcome: outcomeSchema,
errorClass: z.string().optional(),
})
.strict();
const scanCompletedSchema = telemetryCommonEnvelopeSchema
.extend({
driver: z.string(),
tableCount: z.number().int().nonnegative(),
columnCount: z.number().int().nonnegative(),
inferredFkCount: z.number().int().nonnegative(),
declaredFkCount: z.number().int().nonnegative(),
durationMs: z.number().nonnegative(),
outcome: outcomeSchema,
errorClass: z.string().optional(),
})
.strict();
const slValidateCompletedSchema = telemetryCommonEnvelopeSchema
.extend({
sourceCount: z.number().int().nonnegative(),
modelCount: z.number().int().nonnegative(),
validationErrorCount: z.number().int().nonnegative(),
outcome: outcomeSchema,
errorClass: z.string().optional(),
durationMs: z.number().nonnegative(),
})
.strict();
const slQueryCompletedSchema = telemetryCommonEnvelopeSchema
.extend({
mode: z.enum(['compile', 'execute']),
referencedSourceCount: z.number().int().nonnegative(),
referencedDimensionCount: z.number().int().nonnegative(),
referencedMeasureCount: z.number().int().nonnegative(),
durationMs: z.number().nonnegative(),
outcome: outcomeSchema,
errorClass: z.string().optional(),
})
.strict();
const sqlCompletedSchema = telemetryCommonEnvelopeSchema
.extend({
driver: z.string(),
isDemoConnection: z.boolean(),
queryVerb: z.enum(['select', 'explain', 'show', 'with', 'other']),
referencedTableCount: z.number().int().nonnegative(),
durationMs: z.number().nonnegative(),
outcome: outcomeSchema,
errorClass: z.string().optional(),
})
.strict();
const wikiQueryCompletedSchema = telemetryCommonEnvelopeSchema
.extend({
queryLength: z.number().int().nonnegative(),
resultCount: z.number().int().nonnegative(),
durationMs: z.number().nonnegative(),
outcome: outcomeSchema,
})
.strict();
const mcpRequestCompletedSchema = telemetryCommonEnvelopeSchema
.extend({
toolName: z.string(),
outcome: outcomeSchema,
durationMs: z.number().nonnegative(),
errorClass: z.string().optional(),
sampleRate: z.literal(0.1),
})
.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,
command: commandSchema,
setup_step: setupStepSchema,
connection_added: connectionAddedSchema,
connection_test: connectionTestSchema,
project_stack_snapshot: projectStackSnapshotSchema,
ingest_completed: ingestCompletedSchema,
scan_completed: scanCompletedSchema,
sl_validate_completed: slValidateCompletedSchema,
sl_query_completed: slQueryCompletedSchema,
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 */
export const telemetryEventCatalog = [
{
name: 'install_first_run',
description: 'Emitted once when ~/.ktx/telemetry.json is created.',
fields: [],
},
{
name: 'command',
description: 'Emitted once for each Commander action that reaches preAction.',
fields: [
'commandPath',
'durationMs',
'outcome',
'errorClass',
'flagsPresent',
'hasProject',
'projectGroupAttached',
],
},
{
name: 'setup_step',
description: 'Emitted after an interactive setup step completes, skips, or aborts.',
fields: ['step', 'outcome', 'durationMs'],
},
{
name: 'connection_added',
description: 'Emitted when setup writes a database, source, or demo connection.',
fields: ['driver', 'isDemoConnection'],
},
{
name: 'connection_test',
description: 'Emitted after ktx connection test completes.',
fields: ['driver', 'isDemoConnection', 'outcome', 'errorClass', 'durationMs', 'serverVersion'],
},
{
name: 'project_stack_snapshot',
description: 'Emitted after commands that can summarize the local project stack.',
fields: ['connectors', 'connectionCount', 'hasSl', 'hasWiki', 'hasMcp', 'hasManagedRuntime'],
},
{
name: 'ingest_completed',
description: 'Emitted after a public ingest target completes.',
fields: [
'driver',
'isDemoConnection',
'schemaCount',
'tableCount',
'columnCount',
'rowsBucket',
'durationMs',
'outcome',
'errorClass',
],
},
{
name: 'scan_completed',
description: 'Emitted after schema scan or relationship inference completes.',
fields: [
'driver',
'tableCount',
'columnCount',
'inferredFkCount',
'declaredFkCount',
'durationMs',
'outcome',
'errorClass',
],
},
{
name: 'sl_validate_completed',
description: 'Emitted after ktx sl validate completes.',
fields: ['sourceCount', 'modelCount', 'validationErrorCount', 'outcome', 'errorClass', 'durationMs'],
},
{
name: 'sl_query_completed',
description: 'Emitted after ktx sl query compiles or executes.',
fields: [
'mode',
'referencedSourceCount',
'referencedDimensionCount',
'referencedMeasureCount',
'durationMs',
'outcome',
'errorClass',
],
},
{
name: 'sql_completed',
description: 'Emitted after ktx sql completes validation and execution.',
fields: [
'driver',
'isDemoConnection',
'queryVerb',
'referencedTableCount',
'durationMs',
'outcome',
'errorClass',
],
},
{
name: 'wiki_query_completed',
description: 'Emitted after a wiki query completes.',
fields: ['queryLength', 'resultCount', 'durationMs', 'outcome'],
},
{
name: 'mcp_request_completed',
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;
export type TelemetryCommonEnvelope = z.infer<typeof telemetryCommonEnvelopeSchema>;
export type TelemetryEventProperties<Name extends TelemetryEventName> = z.infer<
(typeof telemetryEventSchemas)[Name]
>;
export interface BuiltTelemetryEvent<Name extends TelemetryEventName = TelemetryEventName> {
name: Name;
properties: TelemetryEventProperties<Name>;
}
export function buildCommonEnvelope(input: { cliVersion: string; isCi: boolean }): TelemetryCommonEnvelope {
return {
cliVersion: input.cliVersion,
nodeVersion: process.version,
osPlatform: platform(),
osRelease: release(),
arch: arch(),
runtime: 'node',
isCi: input.isCi,
};
}
export function buildTelemetryEvent<Name extends TelemetryEventName>(
name: Name,
envelope: TelemetryCommonEnvelope,
fields: Omit<TelemetryEventProperties<Name>, keyof TelemetryCommonEnvelope>,
): BuiltTelemetryEvent<Name> {
const schema = telemetryEventSchemas[name];
return {
name,
properties: schema.parse({ ...envelope, ...fields }) as TelemetryEventProperties<Name>,
};
}

View file

@ -0,0 +1,209 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
computeTelemetryProjectId,
loadTelemetryIdentity,
readExistingTelemetryProjectId,
TELEMETRY_NOTICE,
type TelemetryIdentityEnv,
} from './identity.js';
function makeIo(stdoutIsTTY = true) {
let stderr = '';
return {
io: {
stdout: { isTTY: stdoutIsTTY, write: () => {} },
stderr: {
write: (chunk: string) => {
stderr += chunk;
},
},
},
stderr: () => stderr,
};
}
describe('telemetry identity', () => {
let homeDir: string;
let env: TelemetryIdentityEnv;
beforeEach(async () => {
homeDir = await mkdtemp(join(tmpdir(), 'ktx-telemetry-home-'));
env = {};
});
afterEach(async () => {
await rm(homeDir, { recursive: true, force: true });
});
it('creates the telemetry file and one-line notice on first interactive enabled load', async () => {
const testIo = makeIo(true);
const identity = await loadTelemetryIdentity({
homeDir,
env,
stdoutIsTTY: true,
stderr: testIo.io.stderr,
now: () => new Date('2026-05-22T14:33:02.000Z'),
});
expect(identity.enabled).toBe(true);
expect(identity.installId).toMatch(/^[0-9a-f-]{36}$/);
expect(identity.createdFile).toBe(true);
expect(identity.noticeShown).toBe(true);
expect(testIo.stderr()).toBe(`${TELEMETRY_NOTICE}\n`);
const stored = JSON.parse(await readFile(join(homeDir, '.ktx', 'telemetry.json'), 'utf-8')) as {
enabled: boolean;
noticeShownVersion: number;
};
expect(stored.enabled).toBe(true);
expect(stored.noticeShownVersion).toBe(1);
});
it('emits the notice without ANSI when NO_COLOR is set', async () => {
const testIo = makeIo(true);
await loadTelemetryIdentity({
homeDir,
env: { NO_COLOR: '1' },
stdoutIsTTY: true,
stderr: testIo.io.stderr,
now: () => new Date('2026-05-22T14:33:02.000Z'),
});
expect(testIo.stderr()).toBe(`${TELEMETRY_NOTICE}\n`);
});
it('does not create a file when env disables telemetry', async () => {
const identity = await loadTelemetryIdentity({
homeDir,
env: { KTX_TELEMETRY_DISABLED: '1' },
stdoutIsTTY: true,
stderr: makeIo(true).io.stderr,
now: () => new Date('2026-05-22T14:33:02.000Z'),
});
expect(identity.enabled).toBe(false);
await expect(readFile(join(homeDir, '.ktx', 'telemetry.json'), 'utf-8')).rejects.toThrow();
});
it('does not create a file for CI or non-TTY command invocations', async () => {
await expect(
loadTelemetryIdentity({
homeDir,
env: { CI: '1' },
stdoutIsTTY: true,
stderr: makeIo(true).io.stderr,
now: () => new Date('2026-05-22T14:33:02.000Z'),
}),
).resolves.toMatchObject({ enabled: false, createdFile: false });
await expect(
loadTelemetryIdentity({
homeDir,
env: {},
stdoutIsTTY: false,
stderr: makeIo(false).io.stderr,
now: () => new Date('2026-05-22T14:33:02.000Z'),
}),
).resolves.toMatchObject({ enabled: false, createdFile: false });
});
it('honors persistent enabled false', async () => {
await mkdir(join(homeDir, '.ktx'), { recursive: true });
await writeFile(
join(homeDir, '.ktx', 'telemetry.json'),
JSON.stringify(
{
installId: '00000000-0000-4000-8000-000000000000',
enabled: false,
noticeShownAt: '2026-05-22T14:33:02.000Z',
noticeShownVersion: 1,
createdAt: '2026-05-22T14:33:02.000Z',
},
null,
2,
) + '\n',
'utf-8',
);
await expect(
loadTelemetryIdentity({
homeDir,
env,
stdoutIsTTY: true,
stderr: makeIo(true).io.stderr,
now: () => new Date('2026-05-22T15:00:00.000Z'),
}),
).resolves.toMatchObject({
installId: '00000000-0000-4000-8000-000000000000',
enabled: false,
createdFile: false,
});
});
it('recreates a corrupted file instead of surfacing an error to users', async () => {
await mkdir(join(homeDir, '.ktx'), { recursive: true });
await writeFile(join(homeDir, '.ktx', 'telemetry.json'), '{bad json', 'utf-8');
const identity = await loadTelemetryIdentity({
homeDir,
env,
stdoutIsTTY: true,
stderr: makeIo(true).io.stderr,
now: () => new Date('2026-05-22T14:33:02.000Z'),
});
expect(identity.enabled).toBe(true);
expect(identity.createdFile).toBe(true);
});
it('derives a salted project hash without exposing the path', () => {
const projectDir = resolve('/tmp/acme-private-project');
const projectId = computeTelemetryProjectId('00000000-0000-4000-8000-000000000000', projectDir);
expect(projectId).toMatch(/^[a-f0-9]{64}$/);
expect(projectId).not.toContain('acme');
expect(computeTelemetryProjectId('00000000-0000-4000-8000-000000000000', projectDir)).toBe(projectId);
expect(computeTelemetryProjectId('11111111-1111-4111-8111-111111111111', projectDir)).not.toBe(projectId);
});
it('reads an existing project id for Python telemetry without creating identity', async () => {
await mkdir(join(homeDir, '.ktx'), { recursive: true });
await writeFile(
join(homeDir, '.ktx', 'telemetry.json'),
JSON.stringify(
{
installId: '00000000-0000-4000-8000-000000000000',
enabled: true,
noticeShownAt: '2026-05-22T14:33:02.000Z',
noticeShownVersion: 1,
createdAt: '2026-05-22T14:33:02.000Z',
},
null,
2,
) + '\n',
'utf-8',
);
await expect(
readExistingTelemetryProjectId({
homeDir,
projectDir: '/tmp/acme-private-project',
env: {},
}),
).resolves.toMatch(/^[a-f0-9]{64}$/);
await expect(
readExistingTelemetryProjectId({
homeDir,
projectDir: '/tmp/acme-private-project',
env: { KTX_TELEMETRY_DISABLED: '1' },
}),
).resolves.toBeUndefined();
});
});

View file

@ -0,0 +1,151 @@
import { createHash, randomUUID } from 'node:crypto';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { z } from 'zod';
/** @internal */
export const TELEMETRY_NOTICE =
'ktx collects anonymous usage data to improve the product. Opt out: set KTX_TELEMETRY_DISABLED=1.';
const NOTICE_VERSION = 1;
const telemetryFileSchema = z
.object({
installId: z.uuid(),
enabled: z.boolean(),
noticeShownAt: z.string().optional(),
noticeShownVersion: z.number().int().optional(),
createdAt: z.string(),
})
.strict();
/** @internal */
export interface TelemetryIdentityEnv {
KTX_TELEMETRY_DISABLED?: string;
DO_NOT_TRACK?: string;
CI?: string;
NO_COLOR?: string;
TERM?: string;
}
function styleNotice(notice: string, env: TelemetryIdentityEnv): string {
if (env.NO_COLOR || env.TERM === 'dumb') return notice;
return `${notice}`;
}
export interface LoadTelemetryIdentityOptions {
homeDir?: string;
env?: TelemetryIdentityEnv;
stdoutIsTTY: boolean;
stderr: { write(chunk: string): void };
now?: () => Date;
}
export interface TelemetryIdentityState {
installId?: string;
enabled: boolean;
createdFile: boolean;
noticeShown: boolean;
path: string;
}
function telemetryPath(homeDir: string): string {
return join(homeDir, '.ktx', 'telemetry.json');
}
function envDisablesTelemetry(env: TelemetryIdentityEnv): boolean {
return Boolean(env.KTX_TELEMETRY_DISABLED || env.DO_NOT_TRACK || env.CI);
}
async function readTelemetryFile(path: string): Promise<z.infer<typeof telemetryFileSchema> | null> {
try {
return telemetryFileSchema.parse(JSON.parse(await readFile(path, 'utf-8')));
} catch {
return null;
}
}
async function writeTelemetryFile(path: string, value: z.infer<typeof telemetryFileSchema>): Promise<void> {
await mkdir(dirname(path), { recursive: true });
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, 'utf-8');
}
export async function loadTelemetryIdentity(options: LoadTelemetryIdentityOptions): Promise<TelemetryIdentityState> {
const env = options.env ?? process.env;
const path = telemetryPath(options.homeDir ?? homedir());
if (envDisablesTelemetry(env) || options.stdoutIsTTY !== true) {
const existing = await readTelemetryFile(path);
return {
installId: existing?.installId,
enabled: false,
createdFile: false,
noticeShown: false,
path,
};
}
const existing = await readTelemetryFile(path);
if (existing) {
return {
installId: existing.installId,
enabled: existing.enabled,
createdFile: false,
noticeShown: false,
path,
};
}
const timestamp = (options.now ?? (() => new Date()))().toISOString();
const next = {
installId: randomUUID(),
enabled: true,
noticeShownAt: timestamp,
noticeShownVersion: NOTICE_VERSION,
createdAt: timestamp,
};
try {
await writeTelemetryFile(path, next);
} catch {
return {
enabled: false,
createdFile: false,
noticeShown: false,
path,
};
}
options.stderr.write(`${styleNotice(TELEMETRY_NOTICE, env)}\n`);
return {
installId: next.installId,
enabled: true,
createdFile: true,
noticeShown: true,
path,
};
}
export function computeTelemetryProjectId(installId: string, projectDir: string): string {
return createHash('sha256').update(`${installId}:${resolve(projectDir)}`).digest('hex');
}
export async function readExistingTelemetryProjectId(options: {
projectDir: string;
homeDir?: string;
env?: Pick<TelemetryIdentityEnv, 'KTX_TELEMETRY_DISABLED' | 'DO_NOT_TRACK'>;
}): Promise<string | undefined> {
const env = options.env ?? process.env;
if (env.KTX_TELEMETRY_DISABLED || env.DO_NOT_TRACK) {
return undefined;
}
const existing = await readTelemetryFile(telemetryPath(options.homeDir ?? homedir()));
if (!existing?.enabled) {
return undefined;
}
return computeTelemetryProjectId(existing.installId, options.projectDir);
}

View file

@ -0,0 +1,146 @@
import { getKtxCliPackageInfo, type KtxCliIo, type KtxCliPackageInfo } from '../cli-runtime.js';
import { loadKtxProject } from '../context/project/project.js';
import {
beginCommandSpan,
completeCommandSpan,
type CommandOutcome,
type CompletedCommandSpan,
} from './command-hook.js';
import { shutdownTelemetryEmitter, trackTelemetryEvent } from './emitter.js';
import {
buildCommonEnvelope,
buildTelemetryEvent,
type TelemetryCommonEnvelope,
type TelemetryEventName,
type TelemetryEventProperties,
} from './events.js';
import { computeTelemetryProjectId, loadTelemetryIdentity } from './identity.js';
import { buildProjectStackSnapshotFields } from './project-snapshot.js';
export { beginCommandSpan, completeCommandSpan, shutdownTelemetryEmitter };
export type { CommandOutcome, CompletedCommandSpan };
export async function showTelemetryNoticeIfNeeded(io: KtxCliIo, packageInfo: KtxCliPackageInfo): Promise<void> {
const identity = await loadTelemetryIdentity({
stdoutIsTTY: io.stdout.isTTY === true,
stderr: io.stderr,
env: process.env,
});
if (!identity.enabled || !identity.createdFile || !identity.installId) {
return;
}
await trackTelemetryEvent({
event: buildTelemetryEvent(
'install_first_run',
buildCommonEnvelope({
cliVersion: packageInfo.version,
isCi: Boolean(process.env.CI),
}),
{},
),
distinctId: identity.installId,
env: process.env,
stderr: io.stderr,
});
}
type TelemetryEventFields<Name extends TelemetryEventName> = Omit<
TelemetryEventProperties<Name>,
keyof TelemetryCommonEnvelope
>;
const emittedProjectSnapshots = new Set<string>();
const MCP_SAMPLE_RATE = 0.1 as const;
let mcpSampled: boolean | undefined;
export function shouldEmitMcpTelemetry(): boolean {
mcpSampled ??= Math.random() < MCP_SAMPLE_RATE;
return mcpSampled;
}
export function mcpTelemetrySampleRate(): 0.1 {
return MCP_SAMPLE_RATE;
}
export async function emitTelemetryEvent<Name extends TelemetryEventName>(input: {
name: Name;
fields: TelemetryEventFields<Name>;
io: KtxCliIo;
packageInfo?: KtxCliPackageInfo;
projectDir?: string;
}): Promise<void> {
const identity = await loadTelemetryIdentity({
stdoutIsTTY: input.io.stdout.isTTY === true,
stderr: input.io.stderr,
env: process.env,
});
if (!identity.enabled || !identity.installId) {
return;
}
const packageInfo = input.packageInfo ?? getKtxCliPackageInfo();
const projectId = input.projectDir ? computeTelemetryProjectId(identity.installId, input.projectDir) : undefined;
await trackTelemetryEvent({
event: buildTelemetryEvent(
input.name,
buildCommonEnvelope({
cliVersion: packageInfo.version,
isCi: Boolean(process.env.CI),
}),
input.fields,
),
distinctId: identity.installId,
projectId,
env: process.env,
stderr: input.io.stderr,
});
}
export async function emitProjectStackSnapshot(input: {
projectDir: string;
io: KtxCliIo;
packageInfo?: KtxCliPackageInfo;
}): Promise<void> {
if (emittedProjectSnapshots.has(input.projectDir)) {
return;
}
emittedProjectSnapshots.add(input.projectDir);
let project: Awaited<ReturnType<typeof loadKtxProject>>;
try {
project = await loadKtxProject({ projectDir: input.projectDir });
} catch {
return;
}
await emitTelemetryEvent({
name: 'project_stack_snapshot',
fields: await buildProjectStackSnapshotFields(project),
projectDir: input.projectDir,
io: input.io,
packageInfo: input.packageInfo,
});
}
export async function emitCompletedCommand(input: {
completed: CompletedCommandSpan | undefined;
packageInfo: KtxCliPackageInfo;
io: KtxCliIo;
}): Promise<void> {
if (!input.completed) {
return;
}
const projectDir = input.completed.projectGroupAttached ? input.completed.projectDir : undefined;
const { projectDir: _projectDir, ...eventFields } = input.completed;
await emitTelemetryEvent({
name: 'command',
fields: eventFields,
projectDir,
io: input.io,
packageInfo: input.packageInfo,
});
}

View file

@ -0,0 +1,77 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { buildProjectStackSnapshotFields } from './project-snapshot.js';
describe('buildProjectStackSnapshotFields', () => {
let projectDir: string;
beforeEach(async () => {
projectDir = await mkdtemp(join(tmpdir(), 'ktx-stack-snapshot-'));
});
afterEach(async () => {
await rm(projectDir, { recursive: true, force: true });
});
it('summarizes connectors and project capabilities without names or paths', async () => {
await mkdir(join(projectDir, 'semantic-layer', 'warehouse'), { recursive: true });
await mkdir(join(projectDir, 'wiki', 'global'), { recursive: true });
await writeFile(join(projectDir, 'semantic-layer', 'warehouse', 'orders.yaml'), 'name: orders\n');
await writeFile(join(projectDir, 'wiki', 'global', 'revenue.md'), '# Revenue\n');
await writeFile(join(projectDir, '.mcp.json'), '{"mcpServers":{"ktx":{}}}\n');
const fields = await buildProjectStackSnapshotFields({
projectDir,
config: {
connections: {
orbit_demo: { driver: 'sqlite', path: join(projectDir, 'demo.db') },
warehouse: { driver: 'postgres', readonly: true },
},
ingest: {
adapters: [],
embeddings: { backend: 'sentence-transformers', dimensions: 384 },
workUnits: { stepBudget: 40, maxConcurrency: 1, failureMode: 'continue' },
},
llm: { provider: { backend: 'none' }, models: {}, promptCaching: {} },
scan: {
enrichment: { mode: 'none' },
relationships: {
enabled: true,
llmProposals: true,
validationRequiredForManifest: true,
acceptThreshold: 0.85,
reviewThreshold: 0.55,
maxLlmTablesPerBatch: 40,
maxCandidatesPerColumn: 25,
profileSampleRows: 10000,
validationConcurrency: 4,
},
},
storage: {
state: 'sqlite',
search: 'sqlite-fts5',
git: { auto_commit: true, author: 'ktx <ktx@example.com>' },
},
agent: { run_research: { enabled: false, max_iterations: 20, default_toolset: [] } },
memory: { auto_commit: true },
},
});
expect(fields).toEqual({
connectors: [
{ driver: 'sqlite', isDemo: true },
{ driver: 'postgres', isDemo: false },
],
connectionCount: 2,
hasSl: true,
hasWiki: true,
hasMcp: true,
hasManagedRuntime: true,
});
expect(JSON.stringify(fields)).not.toContain(projectDir);
expect(JSON.stringify(fields)).not.toContain('warehouse');
});
});

View file

@ -0,0 +1,67 @@
import { readdir } from 'node:fs/promises';
import { join } from 'node:path';
import type { KtxProjectConfig } from '../context/project/config.js';
import { resolveProjectRuntimeRequirements } from '../runtime-requirements.js';
import { isDemoConnection } from './demo-detect.js';
async function hasFileWithExtension(dir: string, extensions: Set<string>): Promise<boolean> {
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return false;
}
for (const entry of entries) {
const path = join(dir, entry.name);
if (entry.isDirectory() && (await hasFileWithExtension(path, extensions))) {
return true;
}
if (entry.isFile() && extensions.has(entry.name.slice(entry.name.lastIndexOf('.')))) {
return true;
}
}
return false;
}
async function hasFileNamed(dir: string, filenames: Set<string>): Promise<boolean> {
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return false;
}
return entries.some((entry) => entry.isFile() && filenames.has(entry.name));
}
async function hasMcpConfig(projectDir: string): Promise<boolean> {
return (
(await hasFileWithExtension(join(projectDir, '.ktx'), new Set(['.json']))) ||
(await hasFileWithExtension(join(projectDir, '.cursor'), new Set(['.json']))) ||
(await hasFileNamed(projectDir, new Set(['.mcp.json'])))
);
}
export async function buildProjectStackSnapshotFields(input: {
projectDir: string;
config: KtxProjectConfig;
}) {
const connectors = Object.entries(input.config.connections).map(([connectionId, connection]) => ({
driver: String(connection.driver ?? 'unknown').trim().toLowerCase() || 'unknown',
isDemo: isDemoConnection(connectionId, connection),
}));
const runtimeRequirements = resolveProjectRuntimeRequirements(input.config, {
env: process.env,
});
return {
connectors,
connectionCount: connectors.length,
hasSl: await hasFileWithExtension(join(input.projectDir, 'semantic-layer'), new Set(['.yaml', '.yml'])),
hasWiki: await hasFileWithExtension(join(input.projectDir, 'wiki'), new Set(['.md', '.mdx'])),
hasMcp: await hasMcpConfig(input.projectDir),
hasManagedRuntime: runtimeRequirements.features.length > 0,
};
}

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);
}
}

View file

@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import { scrubErrorClass } from './scrubber.js';
class KtxProjectMissingAbortError extends Error {}
describe('scrubErrorClass', () => {
it('keeps normal JavaScript class names', () => {
expect(scrubErrorClass(new KtxProjectMissingAbortError('missing'))).toBe('KtxProjectMissingAbortError');
});
it('drops path-like, URL-like, email-like, and long values', () => {
expect(scrubErrorClass({ constructor: { name: '/Users/alice/project' } })).toBeUndefined();
expect(scrubErrorClass({ constructor: { name: 'https://example.test/error' } })).toBeUndefined();
expect(scrubErrorClass({ constructor: { name: 'alice@example.test' } })).toBeUndefined();
expect(scrubErrorClass({ constructor: { name: 'A'.repeat(81) } })).toBeUndefined();
});
it('drops lowercase, spaced, and non-error-like values', () => {
expect(scrubErrorClass({ constructor: { name: 'lowercaseError' } })).toBeUndefined();
expect(scrubErrorClass({ constructor: { name: 'Bad Error' } })).toBeUndefined();
expect(scrubErrorClass('plain string')).toBeUndefined();
expect(scrubErrorClass(null)).toBeUndefined();
});
});

View file

@ -0,0 +1,28 @@
const MAX_ERROR_CLASS_LENGTH = 80;
const ERROR_CLASS_PATTERN = /^[A-Z][A-Za-z0-9_]*$/;
const PRIVATE_STRING_MARKERS = ['/', '\\', '@', '://'];
export function scrubErrorClass(error: unknown): string | undefined {
if (typeof error !== 'object' || error === null) {
return undefined;
}
const constructorName = (error as { constructor?: { name?: unknown } }).constructor?.name;
if (typeof constructorName !== 'string') {
return undefined;
}
if (constructorName.length > MAX_ERROR_CLASS_LENGTH) {
return undefined;
}
if (PRIVATE_STRING_MARKERS.some((marker) => constructorName.includes(marker))) {
return undefined;
}
if (!ERROR_CLASS_PATTERN.test(constructorName)) {
return undefined;
}
return constructorName;
}