mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-10 08:05:14 +02:00
fix(telemetry): emit install_first_run from notice path and derive flagsPresent from commander
This commit is contained in:
parent
3414d19916
commit
2532d4db56
6 changed files with 75 additions and 79 deletions
|
|
@ -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"');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue