fix(telemetry): emit install_first_run from notice path and derive flagsPresent from commander

This commit is contained in:
Andrey Avtomonov 2026-05-22 17:51:23 +02:00
parent 3414d19916
commit 2532d4db56
6 changed files with 75 additions and 79 deletions

View file

@ -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"');

View file

@ -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<string, boolean> | undefined;
const program = new Command()
.name('ktx')
.option('--project-dir <dir>', 'project directory')
.option('--json', 'json output', false);
program
.command('sql')
.argument('<sql...>')
.requiredOption('-c, --connection <id>', 'connection id')
.option('--max-rows <n>', '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');
});
});

View file

@ -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<string, boolean> {
const flags: Record<string, boolean> = {};
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,

View file

@ -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,

View file

@ -4,7 +4,7 @@ export type CommandOutcome = 'ok' | 'error' | 'aborted';
interface CommandSpan {
commandPath: string[];
argv: string[];
flagsPresent: Record<string, boolean>;
projectDir?: string;
hasProject: boolean;
attachProjectGroup: boolean;
@ -24,29 +24,6 @@ export interface CompletedCommandSpan {
let activeCommandSpan: CommandSpan | undefined;
/** @internal */
export function extractFlagsPresent(argv: string[]): Record<string, boolean> {
const flags: Record<string, boolean> = {};
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,

View file

@ -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<void> {
await loadTelemetryIdentity({
export async function showTelemetryNoticeIfNeeded(io: KtxCliIo, packageInfo: KtxCliPackageInfo): Promise<void> {
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<Name extends TelemetryEventName> = Omit<
@ -46,30 +64,6 @@ export function mcpTelemetrySampleRate(): 0.1 {
return MCP_SAMPLE_RATE;
}
async function emitInstallFirstRunIfNeeded(input: {
identity: Awaited<ReturnType<typeof loadTelemetryIdentity>>;
packageInfo: KtxCliPackageInfo;
io: KtxCliIo;
}): Promise<void> {
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<Name extends TelemetryEventName>(input: {
name: Name;
fields: TelemetryEventFields<Name>;
@ -91,7 +85,6 @@ export async function emitTelemetryEvent<Name extends TelemetryEventName>(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({