diff --git a/packages/cli/src/telemetry/demo-detect.test.ts b/packages/cli/src/telemetry/demo-detect.test.ts new file mode 100644 index 00000000..b371694e --- /dev/null +++ b/packages/cli/src/telemetry/demo-detect.test.ts @@ -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); + }); +}); diff --git a/packages/cli/src/telemetry/demo-detect.ts b/packages/cli/src/telemetry/demo-detect.ts new file mode 100644 index 00000000..099dcfb9 --- /dev/null +++ b/packages/cli/src/telemetry/demo-detect.ts @@ -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'; +} diff --git a/packages/cli/src/telemetry/index.ts b/packages/cli/src/telemetry/index.ts index 617c4b7f..c4d7953f 100644 --- a/packages/cli/src/telemetry/index.ts +++ b/packages/cli/src/telemetry/index.ts @@ -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 = Omit< + TelemetryEventProperties, + keyof TelemetryCommonEnvelope +>; + +const emittedProjectSnapshots = new Set(); + async function emitInstallFirstRunIfNeeded(input: { identity: Awaited>; packageInfo: KtxCliPackageInfo; @@ -36,15 +51,13 @@ async function emitInstallFirstRunIfNeeded(input: { }); } -export async function emitCompletedCommand(input: { - completed: CompletedCommandSpan | undefined; - packageInfo: KtxCliPackageInfo; +export async function emitTelemetryEvent(input: { + name: Name; + fields: TelemetryEventFields; io: KtxCliIo; + packageInfo?: KtxCliPackageInfo; + projectDir?: string; }): Promise { - 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 { + 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 { + 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, + }); +} diff --git a/packages/cli/src/telemetry/project-snapshot.test.ts b/packages/cli/src/telemetry/project-snapshot.test.ts new file mode 100644 index 00000000..daf4e766 --- /dev/null +++ b/packages/cli/src/telemetry/project-snapshot.test.ts @@ -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 ' }, + }, + 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'); + }); +}); diff --git a/packages/cli/src/telemetry/project-snapshot.ts b/packages/cli/src/telemetry/project-snapshot.ts new file mode 100644 index 00000000..583c3910 --- /dev/null +++ b/packages/cli/src/telemetry/project-snapshot.ts @@ -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): Promise { + 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): Promise { + 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 { + 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, + }; +}