feat: add telemetry event helpers

This commit is contained in:
Andrey Avtomonov 2026-05-22 15:48:46 +02:00
parent 269515f5fd
commit bbea0b2746
5 changed files with 262 additions and 18 deletions

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

@ -1,4 +1,5 @@
import type { KtxCliIo, KtxCliPackageInfo } from '../cli-runtime.js';
import { loadKtxProject } from '../context/project/project.js';
import {
beginCommandSpan,
completeCommandSpan,
@ -6,12 +7,26 @@ import {
type CompletedCommandSpan,
} from './command-hook.js';
import { shutdownTelemetryEmitter, trackTelemetryEvent } from './emitter.js';
import { buildCommonEnvelope, buildTelemetryEvent } from './events.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 };
type TelemetryEventFields<Name extends TelemetryEventName> = Omit<
TelemetryEventProperties<Name>,
keyof TelemetryCommonEnvelope
>;
const emittedProjectSnapshots = new Set<string>();
async function emitInstallFirstRunIfNeeded(input: {
identity: Awaited<ReturnType<typeof loadTelemetryIdentity>>;
packageInfo: KtxCliPackageInfo;
@ -36,15 +51,13 @@ async function emitInstallFirstRunIfNeeded(input: {
});
}
export async function emitCompletedCommand(input: {
completed: CompletedCommandSpan | undefined;
packageInfo: KtxCliPackageInfo;
export async function emitTelemetryEvent<Name extends TelemetryEventName>(input: {
name: Name;
fields: TelemetryEventFields<Name>;
io: KtxCliIo;
packageInfo?: KtxCliPackageInfo;
projectDir?: string;
}): Promise<void> {
if (!input.completed) {
return;
}
const identity = await loadTelemetryIdentity({
stdoutIsTTY: input.io.stdout.isTTY === true,
stderr: input.io.stderr,
@ -55,22 +68,21 @@ export async function emitCompletedCommand(input: {
return;
}
await emitInstallFirstRunIfNeeded({ identity, packageInfo: input.packageInfo, io: input.io });
const packageInfo = input.packageInfo ?? {
name: '@kaelio/ktx',
version: process.env.npm_package_version ?? '0.0.0',
};
await emitInstallFirstRunIfNeeded({ identity, packageInfo, io: input.io });
const projectId =
input.completed.projectGroupAttached && input.completed.projectDir
? computeTelemetryProjectId(identity.installId, input.completed.projectDir)
: undefined;
const { projectDir: _projectDir, ...eventFields } = input.completed;
const projectId = input.projectDir ? computeTelemetryProjectId(identity.installId, input.projectDir) : undefined;
await trackTelemetryEvent({
event: buildTelemetryEvent(
'command',
input.name,
buildCommonEnvelope({
cliVersion: input.packageInfo.version,
cliVersion: packageInfo.version,
isCi: Boolean(process.env.CI),
}),
eventFields,
input.fields,
),
distinctId: identity.installId,
projectId,
@ -78,3 +90,43 @@ export async function emitCompletedCommand(input: {
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);
const project = await loadKtxProject({ projectDir: input.projectDir });
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,
};
}