diff --git a/packages/cli/src/cli-program-telemetry.test.ts b/packages/cli/src/cli-program-telemetry.test.ts index 63215f0b..936a0aa4 100644 --- a/packages/cli/src/cli-program-telemetry.test.ts +++ b/packages/cli/src/cli-program-telemetry.test.ts @@ -77,6 +77,8 @@ describe('runCommanderKtxCli telemetry', () => { expect(statusIo.stderr()).toContain('[telemetry]'); expect(statusIo.stderr()).toContain('"event":"command"'); expect(statusIo.stderr()).toContain('"commandPath":["ktx","status"]'); + expect(statusIo.stderr()).toContain('"event":"project_stack_snapshot"'); + expect(statusIo.stderr()).toContain('"connectionCount"'); expect(statusIo.stderr()).not.toContain(tempDir); }); diff --git a/packages/cli/src/commands/status-commands.ts b/packages/cli/src/commands/status-commands.ts index e2adf8f1..ec429576 100644 --- a/packages/cli/src/commands/status-commands.ts +++ b/packages/cli/src/commands/status-commands.ts @@ -2,6 +2,7 @@ import type { Command } from '@commander-js/extra-typings'; import type { KtxCliCommandContext } from '../cli-program.js'; import { resolveCommandProjectDir, resolveCommandProjectDirOverride } from '../cli-program.js'; import { findNearestKtxProjectDir } from '../project-resolver.js'; +import { emitProjectStackSnapshot } from '../telemetry/index.js'; function outputMode(options: { json?: boolean }): 'plain' | 'json' { return options.json === true ? 'json' : 'plain'; @@ -58,11 +59,12 @@ export function registerStatusCommands(program: Command, context: KtxCliCommandC ); return; } + const projectDir = resolveCommandProjectDir(command); context.setExitCode( await runner( { command: 'project', - projectDir: resolveCommandProjectDir(command), + projectDir, outputMode: outputMode(options), verbose: options.verbose === true, fast: options.fast === true, @@ -71,6 +73,11 @@ export function registerStatusCommands(program: Command, context: KtxCliCommandC context.io, ), ); + await emitProjectStackSnapshot({ + projectDir, + io: context.io, + packageInfo: context.packageInfo, + }); }, ); } diff --git a/packages/cli/src/connection.test.ts b/packages/cli/src/connection.test.ts index b05a6f16..7cfc5b93 100644 --- a/packages/cli/src/connection.test.ts +++ b/packages/cli/src/connection.test.ts @@ -20,6 +20,7 @@ function makeIo() { return { io: { stdout: { + isTTY: true, write: (chunk: string) => { stdout += chunk; }, @@ -72,6 +73,7 @@ describe('runKtxConnection', () => { }); afterEach(async () => { + vi.unstubAllEnvs(); await rm(tempDir, { recursive: true, force: true }); }); @@ -137,6 +139,27 @@ describe('runKtxConnection', () => { expect(io.stdout()).toContain('Status: ok'); }); + it('emits debug telemetry for connection tests without project paths', async () => { + vi.stubEnv('KTX_TELEMETRY_DEBUG', '1'); + vi.stubEnv('CI', ''); + const projectDir = join(tempDir, 'project'); + await initKtxProject({ projectDir }); + await writeConnections(projectDir, { + warehouse: { driver: 'postgres', url: 'env:DATABASE_URL' }, + }); + const { connector } = nativeConnector('postgres'); + const io = makeIo(); + + const code = await runKtxConnection({ command: 'test', projectDir, connectionId: 'warehouse' }, io.io, { + createScanConnector: vi.fn(async () => connector), + }); + + expect(code).toBe(0); + expect(io.stderr()).toContain('"event":"connection_test"'); + expect(io.stderr()).toContain('"driver":"postgres"'); + expect(io.stderr()).not.toContain(projectDir); + }); + it('reports the connector error and still cleans up when native testConnection fails', async () => { const projectDir = join(tempDir, 'project'); await initKtxProject({ projectDir }); diff --git a/packages/cli/src/connection.ts b/packages/cli/src/connection.ts index 174fe5ad..bb99d4fd 100644 --- a/packages/cli/src/connection.ts +++ b/packages/cli/src/connection.ts @@ -14,6 +14,9 @@ import type { KtxCliIo } from './index.js'; import { bold, dim, green, red, SYMBOLS } from './io/symbols.js'; import { createKtxCliScanConnector } from './local-scan-connectors.js'; import { profileMark } from './startup-profile.js'; +import { isDemoConnection } from './telemetry/demo-detect.js'; +import { emitTelemetryEvent } from './telemetry/index.js'; +import { scrubErrorClass } from './telemetry/scrubber.js'; profileMark('module:connection'); @@ -300,6 +303,30 @@ interface ConnectionTestRow { detail: string; } +async function emitConnectionTest(input: { + project: KtxLocalProject; + connectionId: string; + driver: string; + outcome: 'ok' | 'error'; + durationMs: number; + error?: unknown; + io: KtxCliIo; +}): Promise { + const errorClass = input.error ? scrubErrorClass(input.error) : undefined; + await emitTelemetryEvent({ + name: 'connection_test', + projectDir: input.project.projectDir, + io: input.io, + fields: { + driver: input.driver, + isDemoConnection: isDemoConnection(input.connectionId, input.project.config.connections[input.connectionId]), + outcome: input.outcome, + durationMs: input.durationMs, + ...(errorClass ? { errorClass } : {}), + }, + }); +} + function visualWidth(text: string): number { // styleText wraps content in ANSI escape sequences; strip them before measuring. return text.replace(/\[[0-9;]*m/g, '').length; @@ -352,8 +379,17 @@ async function runTestAll( const rows = await Promise.all( entries.map(async ([connectionId, connection]): Promise => { const declaredDriver = String(connection.driver ?? '').trim().toLowerCase() || 'unknown'; + const startedAt = performance.now(); try { const outcome = await testConnectionByDriver(project, connectionId, deps); + await emitConnectionTest({ + project, + connectionId, + driver: outcome.driver || declaredDriver, + outcome: 'ok', + durationMs: Math.max(0, performance.now() - startedAt), + io, + }); return { connectionId, driver: outcome.driver || declaredDriver, @@ -361,6 +397,15 @@ async function runTestAll( detail: `${outcome.detailKey}: ${outcome.detailValue}`, }; } catch (error) { + await emitConnectionTest({ + project, + connectionId, + driver: declaredDriver, + outcome: 'error', + durationMs: Math.max(0, performance.now() - startedAt), + error, + io, + }); return { connectionId, driver: declaredDriver, @@ -403,7 +448,35 @@ export async function runKtxConnection( return await runTestAll(project, io, deps); } - const { driver, detailKey, detailValue } = await testConnectionByDriver(project, args.connectionId, deps); + const startedAt = performance.now(); + let driver = normalizedConnectionDriver(project, args.connectionId) || 'unknown'; + let detailKey: string; + let detailValue: string; + try { + const outcome = await testConnectionByDriver(project, args.connectionId, deps); + driver = outcome.driver; + detailKey = outcome.detailKey; + detailValue = outcome.detailValue; + await emitConnectionTest({ + project, + connectionId: args.connectionId, + driver, + outcome: 'ok', + durationMs: Math.max(0, performance.now() - startedAt), + io, + }); + } catch (error) { + await emitConnectionTest({ + project, + connectionId: args.connectionId, + driver, + outcome: 'error', + durationMs: Math.max(0, performance.now() - startedAt), + error, + io, + }); + throw error; + } io.stdout.write(`Connection test passed: ${args.connectionId}\n`); io.stdout.write(`Driver: ${driver}\n`); io.stdout.write(`${detailKey}: ${detailValue}\n`);