mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-25 08:48:08 +02:00
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:
parent
c87d14a554
commit
b0dd13ce7c
73 changed files with 6576 additions and 48 deletions
37
packages/cli/src/telemetry/command-hook.test.ts
Normal file
37
packages/cli/src/telemetry/command-hook.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
59
packages/cli/src/telemetry/command-hook.ts
Normal file
59
packages/cli/src/telemetry/command-hook.ts
Normal 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;
|
||||
}
|
||||
33
packages/cli/src/telemetry/demo-detect.test.ts
Normal file
33
packages/cli/src/telemetry/demo-detect.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
15
packages/cli/src/telemetry/demo-detect.ts
Normal file
15
packages/cli/src/telemetry/demo-detect.ts
Normal 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';
|
||||
}
|
||||
123
packages/cli/src/telemetry/emitter.test.ts
Normal file
123
packages/cli/src/telemetry/emitter.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
125
packages/cli/src/telemetry/emitter.ts
Normal file
125
packages/cli/src/telemetry/emitter.ts
Normal 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;
|
||||
}
|
||||
1407
packages/cli/src/telemetry/events.schema.json
Normal file
1407
packages/cli/src/telemetry/events.schema.json
Normal file
File diff suppressed because it is too large
Load diff
141
packages/cli/src/telemetry/events.snapshot.test.ts
Normal file
141
packages/cli/src/telemetry/events.snapshot.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
165
packages/cli/src/telemetry/events.test.ts
Normal file
165
packages/cli/src/telemetry/events.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
387
packages/cli/src/telemetry/events.ts
Normal file
387
packages/cli/src/telemetry/events.ts
Normal 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>,
|
||||
};
|
||||
}
|
||||
209
packages/cli/src/telemetry/identity.test.ts
Normal file
209
packages/cli/src/telemetry/identity.test.ts
Normal 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(`[2m${TELEMETRY_NOTICE}[22m\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();
|
||||
});
|
||||
});
|
||||
151
packages/cli/src/telemetry/identity.ts
Normal file
151
packages/cli/src/telemetry/identity.ts
Normal 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 `[2m${notice}[22m`;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
146
packages/cli/src/telemetry/index.ts
Normal file
146
packages/cli/src/telemetry/index.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
77
packages/cli/src/telemetry/project-snapshot.test.ts
Normal file
77
packages/cli/src/telemetry/project-snapshot.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
67
packages/cli/src/telemetry/project-snapshot.ts
Normal file
67
packages/cli/src/telemetry/project-snapshot.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
26
packages/cli/src/telemetry/schema-writer.test.ts
Normal file
26
packages/cli/src/telemetry/schema-writer.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
63
packages/cli/src/telemetry/schema-writer.ts
Normal file
63
packages/cli/src/telemetry/schema-writer.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
25
packages/cli/src/telemetry/scrubber.test.ts
Normal file
25
packages/cli/src/telemetry/scrubber.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
28
packages/cli/src/telemetry/scrubber.ts
Normal file
28
packages/cli/src/telemetry/scrubber.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue