mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-10 08:05:14 +02:00
feat: add telemetry event helpers
This commit is contained in:
parent
269515f5fd
commit
bbea0b2746
5 changed files with 262 additions and 18 deletions
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';
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
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,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue