diff --git a/packages/cli/src/cli-program-telemetry.test.ts b/packages/cli/src/cli-program-telemetry.test.ts index 2b2b28b9..351bfc7a 100644 --- a/packages/cli/src/cli-program-telemetry.test.ts +++ b/packages/cli/src/cli-program-telemetry.test.ts @@ -75,6 +75,7 @@ describe('runCommanderKtxCli telemetry', () => { ).resolves.toBe(0); expect(statusIo.stderr()).toContain('[telemetry]'); + expect(statusIo.stderr()).toContain('"event":"install_first_run"'); expect(statusIo.stderr()).toContain('"event":"command"'); expect(statusIo.stderr()).toContain('"commandPath":["ktx","status"]'); expect(statusIo.stderr()).toContain('"event":"project_stack_snapshot"'); diff --git a/packages/cli/src/cli-program.test.ts b/packages/cli/src/cli-program.test.ts index 565a5d56..009dfb8a 100644 --- a/packages/cli/src/cli-program.test.ts +++ b/packages/cli/src/cli-program.test.ts @@ -1,6 +1,6 @@ -import type { Command } from '@commander-js/extra-typings'; +import { Command, type CommandUnknownOpts } from '@commander-js/extra-typings'; import { describe, expect, it } from 'vitest'; -import { buildKtxProgram } from './cli-program.js'; +import { buildKtxProgram, collectCommandFlagsPresent } from './cli-program.js'; import type { KtxCliIo, KtxCliPackageInfo } from './cli-runtime.js'; function stubIo(): KtxCliIo { @@ -55,3 +55,31 @@ describe('buildKtxProgram', () => { expect(wrote).toBe(''); }); }); + +describe('collectCommandFlagsPresent', () => { + it('records only CLI-sourced flags and ignores positional content that looks like a flag', async () => { + let captured: Record | undefined; + const program = new Command() + .name('ktx') + .option('--project-dir ', 'project directory') + .option('--json', 'json output', false); + program + .command('sql') + .argument('') + .requiredOption('-c, --connection ', 'connection id') + .option('--max-rows ', 'cap rows') + .action(function () { + captured = collectCommandFlagsPresent(this as unknown as CommandUnknownOpts); + }); + + await program.parseAsync( + ['--project-dir', '/tmp/p', 'sql', '-c', 'warehouse', '--', '--customer_table', 'SELECT', '1'], + { from: 'user' }, + ); + + expect(captured).toEqual({ projectDir: true, connection: true }); + expect(captured).not.toHaveProperty('customer_table'); + expect(captured).not.toHaveProperty('json'); + expect(captured).not.toHaveProperty('maxRows'); + }); +}); diff --git a/packages/cli/src/cli-program.ts b/packages/cli/src/cli-program.ts index 6e4a33b9..a3c27375 100644 --- a/packages/cli/src/cli-program.ts +++ b/packages/cli/src/cli-program.ts @@ -1,6 +1,6 @@ import { existsSync } from 'node:fs'; import { join } from 'node:path'; -import { Command, InvalidArgumentError } from '@commander-js/extra-typings'; +import { Command, type CommandUnknownOpts, InvalidArgumentError } from '@commander-js/extra-typings'; import type { KtxCliDeps, KtxCliIo, KtxCliPackageInfo } from './cli-runtime.js'; import { registerConnectionCommands } from './commands/connection-commands.js'; import { registerIngestCommands } from './commands/ingest-commands.js'; @@ -412,12 +412,28 @@ async function runBareInteractiveCommand( return 0; } +/** @internal */ +export function collectCommandFlagsPresent(command: CommandUnknownOpts): Record { + const flags: Record = {}; + let current: CommandUnknownOpts | null = command; + while (current) { + for (const option of current.options) { + const key = option.attributeName(); + if (current.getOptionValueSource(key) === 'cli') { + flags[key] = true; + } + } + current = current.parent; + } + return flags; +} + export function buildKtxProgram(options: BuildKtxProgramOptions): Command { const program = createBaseProgram(options.packageInfo, options.io); program.hook('preAction', async (_thisCommand, actionCommand) => { const telemetry = await import('./telemetry/index.js'); options.setTelemetryModule?.(telemetry); - await telemetry.showTelemetryNoticeIfNeeded(options.io); + await telemetry.showTelemetryNoticeIfNeeded(options.io, options.packageInfo); const commandNode = actionCommand as CommandPathNode; const path = commandPath(commandNode); const projectDir = resolveCommandProjectDir(commandNode); @@ -425,7 +441,7 @@ export function buildKtxProgram(options: BuildKtxProgramOptions): Command { const attachProjectGroup = shouldAttachCommandProjectGroup(path, hasProject); telemetry.beginCommandSpan({ commandPath: path, - argv: options.argv ?? [], + flagsPresent: collectCommandFlagsPresent(commandNode as unknown as CommandUnknownOpts), projectDir: attachProjectGroup ? projectDir : undefined, hasProject, attachProjectGroup, diff --git a/packages/cli/src/telemetry/command-hook.test.ts b/packages/cli/src/telemetry/command-hook.test.ts index 1db68f66..ffd0485b 100644 --- a/packages/cli/src/telemetry/command-hook.test.ts +++ b/packages/cli/src/telemetry/command-hook.test.ts @@ -1,29 +1,13 @@ import { describe, expect, it } from 'vitest'; -import { - beginCommandSpan, - completeCommandSpan, - extractFlagsPresent, - resetCommandSpan, -} from './command-hook.js'; +import { beginCommandSpan, completeCommandSpan, resetCommandSpan } from './command-hook.js'; describe('telemetry command hook', () => { - it('extracts only flag names, never flag values', () => { - expect( - extractFlagsPresent(['--project-dir', '/Users/alice/private', '--json', '--limit=5', '-v', 'status']), - ).toEqual({ - 'project-dir': true, - json: true, - limit: true, - v: true, - }); - }); - it('builds a completed command event from a span', () => { resetCommandSpan(); beginCommandSpan({ commandPath: ['ktx', 'status'], - argv: ['--project-dir', '/tmp/private', 'status', '--json'], + flagsPresent: { projectDir: true, json: true }, projectDir: '/tmp/private', hasProject: true, attachProjectGroup: true, @@ -39,10 +23,7 @@ describe('telemetry command hook', () => { commandPath: ['ktx', 'status'], durationMs: 25, outcome: 'ok', - flagsPresent: { - 'project-dir': true, - json: true, - }, + flagsPresent: { projectDir: true, json: true }, hasProject: true, projectDir: '/tmp/private', projectGroupAttached: true, diff --git a/packages/cli/src/telemetry/command-hook.ts b/packages/cli/src/telemetry/command-hook.ts index c748a398..e4f003d7 100644 --- a/packages/cli/src/telemetry/command-hook.ts +++ b/packages/cli/src/telemetry/command-hook.ts @@ -4,7 +4,7 @@ export type CommandOutcome = 'ok' | 'error' | 'aborted'; interface CommandSpan { commandPath: string[]; - argv: string[]; + flagsPresent: Record; projectDir?: string; hasProject: boolean; attachProjectGroup: boolean; @@ -24,29 +24,6 @@ export interface CompletedCommandSpan { let activeCommandSpan: CommandSpan | undefined; -/** @internal */ -export function extractFlagsPresent(argv: string[]): Record { - const flags: Record = {}; - - for (const arg of argv) { - if (arg.startsWith('--') && arg.length > 2) { - const [name] = arg.slice(2).split('=', 1); - if (name) { - flags[name] = true; - } - continue; - } - - if (arg.startsWith('-') && arg.length > 1) { - for (const shortFlag of arg.slice(1)) { - flags[shortFlag] = true; - } - } - } - - return flags; -} - export function beginCommandSpan(input: CommandSpan): void { activeCommandSpan = input; } @@ -69,7 +46,7 @@ export function completeCommandSpan(input: { durationMs: Math.max(0, input.completedAt - span.startedAt), outcome: input.outcome, ...(errorClass ? { errorClass } : {}), - flagsPresent: extractFlagsPresent(span.argv), + flagsPresent: span.flagsPresent, hasProject: span.hasProject, projectDir: span.projectDir, projectGroupAttached: span.attachProjectGroup, diff --git a/packages/cli/src/telemetry/index.ts b/packages/cli/src/telemetry/index.ts index badd424e..54790f19 100644 --- a/packages/cli/src/telemetry/index.ts +++ b/packages/cli/src/telemetry/index.ts @@ -20,12 +20,30 @@ import { buildProjectStackSnapshotFields } from './project-snapshot.js'; export { beginCommandSpan, completeCommandSpan, shutdownTelemetryEmitter }; export type { CommandOutcome, CompletedCommandSpan }; -export async function showTelemetryNoticeIfNeeded(io: KtxCliIo): Promise { - await loadTelemetryIdentity({ +export async function showTelemetryNoticeIfNeeded(io: KtxCliIo, packageInfo: KtxCliPackageInfo): Promise { + 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 = Omit< @@ -46,30 +64,6 @@ export function mcpTelemetrySampleRate(): 0.1 { return MCP_SAMPLE_RATE; } -async function emitInstallFirstRunIfNeeded(input: { - identity: Awaited>; - packageInfo: KtxCliPackageInfo; - io: KtxCliIo; -}): Promise { - if (!input.identity.enabled || !input.identity.createdFile || !input.identity.installId) { - return; - } - - await trackTelemetryEvent({ - event: buildTelemetryEvent( - 'install_first_run', - buildCommonEnvelope({ - cliVersion: input.packageInfo.version, - isCi: Boolean(process.env.CI), - }), - {}, - ), - distinctId: input.identity.installId, - env: process.env, - stderr: input.io.stderr, - }); -} - export async function emitTelemetryEvent(input: { name: Name; fields: TelemetryEventFields; @@ -91,7 +85,6 @@ export async function emitTelemetryEvent(input: name: '@kaelio/ktx', version: process.env.npm_package_version ?? '0.0.0', }; - await emitInstallFirstRunIfNeeded({ identity, packageInfo, io: input.io }); const projectId = input.projectDir ? computeTelemetryProjectId(identity.installId, input.projectDir) : undefined; await trackTelemetryEvent({