From 701fedaf81e72dc66153f020c70a3aea91aff37f Mon Sep 17 00:00:00 2001 From: Eric Gan Date: Wed, 30 Sep 2026 11:23:58 -0400 Subject: [PATCH 1/4] cli: record command dispatches in AEL Emit one best-effort event for each resolved CLI or MCP command so usage can be measured by command, CLI version, and detected AI agent. Reuse the dedicated AEL client configuration without counting underlying API retries. Record dispatches before flag validation; outcome is intentionally absent because Incur does not expose a reliable command result to shared middleware. Committed-By-Agent: codex Co-authored-by: codex --- .changeset/link-cli-command-telemetry.md | 5 + CLAUDE.md | 2 + README.md | 13 + .../src/__tests__/command-telemetry.test.ts | 364 ++++++++++++++++++ packages/cli/src/cli.tsx | 18 +- packages/cli/src/telemetry/client.ts | 103 +++++ packages/cli/src/telemetry/events.ts | 41 ++ packages/cli/src/telemetry/opt-out.ts | 7 + packages/cli/src/telemetry/transport.ts | 38 ++ packages/cli/src/utils/ai-agent.ts | 4 + packages/cli/vitest.config.ts | 8 +- 11 files changed, 601 insertions(+), 2 deletions(-) create mode 100644 .changeset/link-cli-command-telemetry.md create mode 100644 packages/cli/src/__tests__/command-telemetry.test.ts create mode 100644 packages/cli/src/telemetry/client.ts create mode 100644 packages/cli/src/telemetry/events.ts create mode 100644 packages/cli/src/telemetry/opt-out.ts create mode 100644 packages/cli/src/telemetry/transport.ts diff --git a/.changeset/link-cli-command-telemetry.md b/.changeset/link-cli-command-telemetry.md new file mode 100644 index 00000000..bfc39d6d --- /dev/null +++ b/.changeset/link-cli-command-telemetry.md @@ -0,0 +1,5 @@ +--- +'@stripe/link-cli': patch +--- + +Record best-effort command usage analytics for direct CLI and MCP invocations. diff --git a/CLAUDE.md b/CLAUDE.md index ba39e1eb..81953dbc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -247,4 +247,6 @@ Rules: | `LINK_API_BASE_URL` | Override API base URL | | `LINK_AUTH_BASE_URL` | Override auth base URL | | `LINK_HTTP_PROXY` | Route all SDK requests through an HTTP proxy (requires `undici` installed) | +| `DO_NOT_TRACK` / `LINK_CLI_TELEMETRY_OPTOUT` | Disable command analytics when either equals `1` or `true` (case-insensitive) | +| `LINK_CLI_TELEMETRY_URL` | Override AEL destination for development; invalid values disable telemetry | | `LINK_IDENTITY_COMMANDS` | When `1` or `true`, register the unlisted `identity` command group. Omitted from `--help`, `--llms`, and MCP otherwise. | diff --git a/README.md b/README.md index 52e76949..c0b73252 100644 --- a/README.md +++ b/README.md @@ -678,6 +678,19 @@ link-cli demo --only-card # virtual card flow only link-cli demo --only-spt # machine payment (SPT) flow only ``` +## Command analytics + +Link CLI sends a best-effort `CLI Command` event to `https://r.stripe.com/0` +when a command starts, including commands invoked through MCP. Events contain +the command name, CLI version, and a recognized AI agent name when available. +They do not contain command arguments, credentials, API requests, or results. +Failed commands count as dispatches; the event does not report success or +failure. Help, version, and MCP discovery requests are not counted. + +Set `DO_NOT_TRACK=1` or `LINK_CLI_TELEMETRY_OPTOUT=1` to disable these events. +`LINK_CLI_TELEMETRY_URL` overrides the destination for local development. +Sending failures do not affect command output or exit status. + ## Development Workspace development requires Node.js 24+. diff --git a/packages/cli/src/__tests__/command-telemetry.test.ts b/packages/cli/src/__tests__/command-telemetry.test.ts new file mode 100644 index 00000000..645db9d0 --- /dev/null +++ b/packages/cli/src/__tests__/command-telemetry.test.ts @@ -0,0 +1,364 @@ +import { execFile, spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { mkdtemp, rm } from 'node:fs/promises'; +import http from 'node:http'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createInterface } from 'node:readline'; +import { promisify } from 'node:util'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +const execFileAsync = promisify(execFile); +const CLI_PATH = new URL('../../dist/cli.js', import.meta.url).pathname; +const AGENT_SIGNALS = [ + 'ANTIGRAVITY_CLI_ALIAS', + 'CLAUDECODE', + 'CLINE_ACTIVE', + 'CODEX_SANDBOX', + 'CODEX_THREAD_ID', + 'CODEX_SANDBOX_NETWORK_DISABLED', + 'CODEX_CI', + 'CURSOR_AGENT', + 'GEMINI_CLI', + 'OPENCODE', + 'OPENCLAW_SHELL', + 'CLAUDE_CODE_ENTRYPOINT', + 'CODEX_INTERNAL_ORIGINATOR_OVERRIDE', +]; + +interface Event { + fields: Record; + headers: http.IncomingHttpHeaders; +} + +let api: http.Server; +let recorder: http.Server; +let apiUrl: string; +let telemetryUrl: string; +let directory: string; +let apiRequests: string[] = []; +let events: Event[] = []; +let refreshRequired = false; +let telemetryMode: 'ok' | 'hang' | 'reject' = 'ok'; + +async function listen(server: http.Server): Promise { + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + server.removeListener('error', reject); + resolve(); + }); + }); + return `http://127.0.0.1:${(server.address() as { port: number }).port}`; +} + +async function close(server: http.Server): Promise { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); +} + +async function freePort(): Promise { + const server = http.createServer(); + await listen(server); + const port = (server.address() as { port: number }).port; + await close(server); + return port; +} + +function environment(extra: Record = {}): NodeJS.ProcessEnv { + return { + ...process.env, + ...Object.fromEntries(AGENT_SIGNALS.map((name) => [name, ''])), + LINK_AUTH_FILE: join(directory, 'auth.json'), + LINK_API_BASE_URL: apiUrl, + LINK_AUTH_BASE_URL: apiUrl, + LINK_ACCESS_TOKEN: 'secret-original-token', + LINK_REFRESH_TOKEN: '', + LINK_HTTP_PROXY: '', + DO_NOT_TRACK: '', + LINK_CLI_TELEMETRY_OPTOUT: '', + LINK_CLI_TELEMETRY_URL: telemetryUrl, + ...extra, + }; +} + +async function run( + args: string[], + extra: Record = {}, +): Promise<{ stdout: string; stderr: string; exitCode: number }> { + try { + const result = await execFileAsync(process.execPath, [CLI_PATH, ...args], { + env: environment(extra), + timeout: 10_000, + }); + return { ...result, exitCode: 0 }; + } catch (error) { + const failure = error as { + stdout: string; + stderr: string; + code?: number | string; + }; + if (typeof failure.code !== 'number') throw error; + return { + stdout: failure.stdout, + stderr: failure.stderr, + exitCode: failure.code, + }; + } +} + +beforeAll(async () => { + directory = await mkdtemp(join(tmpdir(), 'link-command-telemetry-')); + api = http.createServer((req, res) => { + apiRequests.push(req.url ?? ''); + req.resume(); + res.setHeader('Content-Type', 'application/json'); + if (req.url === '/device/token') { + res.end( + JSON.stringify({ + access_token: 'secret-refreshed-token', + refresh_token: 'secret-refresh', + expires_in: 3600, + token_type: 'Bearer', + }), + ); + return; + } + if ( + refreshRequired && + req.headers.authorization !== 'Bearer secret-refreshed-token' + ) { + res.writeHead(401); + res.end(JSON.stringify({ error: 'expired' })); + return; + } + res.end(JSON.stringify({ payment_details: [] })); + }); + recorder = http.createServer((req, res) => { + let body = ''; + req.on('data', (chunk) => { + body += chunk; + }); + req.on('end', () => { + events.push({ + fields: Object.fromEntries(new URLSearchParams(body)), + headers: req.headers, + }); + if (telemetryMode === 'hang') return; + res.writeHead(telemetryMode === 'reject' ? 503 : 204); + res.end(); + }); + }); + apiUrl = await listen(api); + telemetryUrl = `${await listen(recorder)}/0`; +}); + +afterAll(async () => { + await Promise.all([close(api), close(recorder)]); + await rm(directory, { recursive: true, force: true }); +}); + +beforeEach(() => { + apiRequests = []; + events = []; + refreshRequired = false; + telemetryMode = 'ok'; +}); + +describe('built CLI command telemetry', () => { + it('records one safe event for a command without an API request', async () => { + const result = await run(['auth', 'status', '--json'], { + CODEX_THREAD_ID: 'private-thread-id', + }); + expect(result.exitCode, result.stderr || result.stdout).toBe(0); + expect(apiRequests).toHaveLength(0); + expect(events).toHaveLength(1); + expect(events[0].fields).toMatchObject({ + client_id: 'link-cli', + event_name: 'CLI Command', + command_path: 'auth status', + ai_agent: 'codex_cli', + }); + expect(events[0].fields.cli_version).toMatch(/^\d+\.\d+\.\d+/); + expect(events[0].fields.event_id).toMatch(/^[a-f\d-]{36}$/); + expect(Number(events[0].fields.created)).toBeGreaterThan(0); + expect(events[0].headers.origin).toBe('link-cli'); + expect(events[0].headers.authorization).toBeUndefined(); + expect(JSON.stringify(events)).not.toMatch(/secret-|private-thread-id/); + }); + + it('counts one command despite an API retry and token refresh', async () => { + refreshRequired = true; + const result = await run( + ['payment-methods', 'list', '--json'], + { LINK_REFRESH_TOKEN: 'secret-refresh-token' }, + ); + expect(result.exitCode, result.stderr || result.stdout).toBe(0); + expect(apiRequests).toHaveLength(3); + expect(events).toHaveLength(1); + expect(events[0].fields.command_path).toBe('payment-methods list'); + expect(events[0].fields.ai_agent).toBeUndefined(); + expect(JSON.stringify(events)).not.toContain('secret-'); + }); + + it('counts resolved commands that fail flag validation', async () => { + const result = await run(['payment-methods', 'list', '--unknown', '--json']); + expect(result.exitCode).toBe(1); + expect(apiRequests).toHaveLength(0); + expect(events).toHaveLength(1); + expect(events[0].fields.command_path).toBe('payment-methods list'); + }); + + it('skips help, version, and unknown commands', async () => { + await run(['--help']); + await run(['--version']); + await run(['not-a-command']); + expect(events).toHaveLength(0); + }); + + it.each>([ + { DO_NOT_TRACK: '1' }, + { LINK_CLI_TELEMETRY_OPTOUT: 'true' }, + ])( + 'honors opt-out: %j', + async (env) => { + const result = await run(['auth', 'status', '--json'], env); + expect(result.exitCode).toBe(0); + expect(events).toHaveLength(0); + }, + ); + + it.each(['hang', 'reject'] as const)( + 'preserves output and exit status when AEL %s', + async (mode) => { + const baseline = await run(['auth', 'status', '--json'], { + DO_NOT_TRACK: '1', + }); + telemetryMode = mode; + const tracked = await run(['auth', 'status', '--json']); + expect(tracked.exitCode).toBe(baseline.exitCode); + expect(JSON.parse(tracked.stdout)).toMatchObject(JSON.parse(baseline.stdout)); + expect(events).toHaveLength(1); + }, + ); + + it('records each MCP tool call without recording MCP control messages', async () => { + const child = spawn(process.execPath, [CLI_PATH, '--mcp'], { + env: environment(), + stdio: ['pipe', 'pipe', 'pipe'], + }); + const lines = createInterface({ input: child.stdout }); + const callbacks = new Map void>(); + lines.on('line', (line) => { + const message = JSON.parse(line) as { id?: number }; + if (message.id !== undefined) callbacks.get(message.id)?.(message); + }); + const rpc = (id: number, method: string, params: unknown) => + new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error(`MCP ${method} timed out`)), + 5_000, + ); + callbacks.set(id, (message) => { + clearTimeout(timer); + callbacks.delete(id); + resolve(message); + }); + child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`); + }); + try { + expect( + await rpc(1, 'initialize', { + protocolVersion: '2025-03-26', + capabilities: {}, + clientInfo: { name: 'telemetry-test', version: '1.0.0' }, + }), + ).toHaveProperty('result'); + child.stdin.write( + `${JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' })}\n`, + ); + const responses = await Promise.all( + [2, 3].map((id) => + rpc(id, 'tools/call', { + name: 'call_write_tool', + arguments: { name: 'payment-methods_list', arguments: {} }, + }), + ), + ); + for (const response of responses) + expect(response).toHaveProperty('result'); + await expect.poll(() => events.length).toBe(2); + expect(events.map(({ fields }) => fields.command_path)).toEqual([ + 'payment-methods list', + 'payment-methods list', + ]); + expect(new Set(events.map(({ fields }) => fields.event_id)).size).toBe(2); + } finally { + lines.close(); + if (child.exitCode === null && child.signalCode === null) { + child.kill('SIGTERM'); + await once(child, 'exit'); + } + } + }, 15_000); + + it('records MCP tool calls served over HTTP', async () => { + const port = await freePort(); + const child = spawn(process.execPath, [CLI_PATH, 'serve', '--port', String(port)], { + env: environment(), + stdio: ['ignore', 'pipe', 'pipe'], + }); + try { + await new Promise((resolve, reject) => { + let stderr = ''; + const timer = setTimeout(() => reject(new Error(stderr)), 5_000); + child.stderr?.on('data', (chunk: Buffer) => { + stderr += chunk.toString(); + if (stderr.includes('link-cli MCP server listening')) { + clearTimeout(timer); + resolve(); + } + }); + child.once('exit', (code) => { + clearTimeout(timer); + reject(new Error(`serve exited with ${code}: ${stderr}`)); + }); + }); + const rpc = async (id: number, method: string, params: unknown) => { + const response = await fetch(`http://127.0.0.1:${port}/mcp`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + }, + body: JSON.stringify({ jsonrpc: '2.0', id, method, params }), + }); + expect(response.status).toBe(200); + return response.json(); + }; + expect( + await rpc(1, 'initialize', { + protocolVersion: '2025-03-26', + capabilities: {}, + clientInfo: { name: 'telemetry-test', version: '1.0.0' }, + }), + ).toHaveProperty('result'); + expect( + await rpc(2, 'tools/call', { + name: 'call_write_tool', + arguments: { name: 'payment-methods_list', arguments: {} }, + }), + ).toHaveProperty('result'); + await expect.poll(() => events.length).toBe(2); + expect(events.map(({ fields }) => fields.command_path)).toEqual([ + 'serve', + 'payment-methods list', + ]); + } finally { + if (child.exitCode === null && child.signalCode === null) { + child.kill('SIGTERM'); + await once(child, 'exit'); + } + } + }, 15_000); +}); diff --git a/packages/cli/src/cli.tsx b/packages/cli/src/cli.tsx index 400b5c7a..05328233 100644 --- a/packages/cli/src/cli.tsx +++ b/packages/cli/src/cli.tsx @@ -16,6 +16,7 @@ import { createSpendRequestCli } from './commands/spend-request'; import { createTransactionsCli } from './commands/transactions'; import { createUcpCli } from './commands/ucp'; import { createUserInfoCli } from './commands/user-info'; +import { createTelemetry } from './telemetry/client'; import { detectAIAgent } from './utils/ai-agent'; import { buildMcpCommand } from './utils/package-runner'; import { ResourceFactory } from './utils/resource-factory'; @@ -31,6 +32,7 @@ declare const __CLI_NAME__: string; const cliVersion = __CLI_VERSION__; const cliName = __CLI_NAME__; const agent = detectAIAgent(process.env); +const telemetry = createTelemetry({ cliVersion, aiAgent: agent }); const defaultHeaders = { 'User-Agent': `link-cli/${cliVersion}${agent ? ` AIAgent/${agent}` : ''}`, }; @@ -79,6 +81,10 @@ const cli = Cli.create('link-cli', { include: ['skills/*'], }, }); +cli.use((context, next) => { + telemetry.send({ commandPath: context.command }); + return next(); +}); const isAgent = process.argv.includes('--format') || process.argv.includes('--mcp'); @@ -204,6 +210,16 @@ cli.command( ); cli.command(createServeCli(cli)); -cli.serve(); +let requestedExitCode: number | undefined; +try { + await cli.serve(undefined, { + exit(code) { + requestedExitCode = code; + }, + }); +} finally { + await telemetry.flush(); +} +if (requestedExitCode !== undefined) process.exit(requestedExitCode); export default cli; diff --git a/packages/cli/src/telemetry/client.ts b/packages/cli/src/telemetry/client.ts new file mode 100644 index 00000000..afca6923 --- /dev/null +++ b/packages/cli/src/telemetry/client.ts @@ -0,0 +1,103 @@ +import { + type CommandEvent, + type EventMetadata, + serializeEvent, +} from './events'; +import { telemetryOptedOut } from './opt-out'; +import { postTelemetry } from './transport'; + +const REQUEST_TIMEOUT_MS = 3_000; +const FLUSH_TIMEOUT_MS = 300; +const MAX_IN_FLIGHT = 8; + +export interface TelemetryClient { + readonly disabled: boolean; + send(event: CommandEvent): void; + flush(): Promise; +} + +const disabledClient: TelemetryClient = { + disabled: true, + send() {}, + async flush() {}, +}; + +export function createTelemetry(options: { + cliVersion: string; + aiAgent?: string; + env?: Readonly>; + transport?: typeof postTelemetry; +}): TelemetryClient { + const env = options.env ?? process.env; + if (telemetryOptedOut(env)) return disabledClient; + + let endpoint: URL; + try { + endpoint = new URL(env.LINK_CLI_TELEMETRY_URL ?? 'https://r.stripe.com/0'); + if ( + !['http:', 'https:'].includes(endpoint.protocol) || + endpoint.username || + endpoint.password || + endpoint.hash + ) + return disabledClient; + } catch { + return disabledClient; + } + + const transport = options.transport ?? postTelemetry; + const metadata: EventMetadata = { + cliVersion: options.cliVersion, + aiAgent: options.aiAgent, + }; + const pending = new Set<{ done: Promise; abort(): void }>(); + + return { + disabled: false, + send(event) { + try { + if (pending.size >= MAX_IN_FLIGHT) return; + const body = serializeEvent(metadata, event); + if (!body) return; + const controller = new AbortController(); + let resolveDone: () => void = () => {}; + const done = new Promise((resolve) => { + resolveDone = resolve; + }); + const finish = () => { + clearTimeout(timer); + pending.delete(entry); + resolveDone(); + }; + const entry = { + done, + abort() { + controller.abort(); + finish(); + }, + }; + const timer = setTimeout(() => entry.abort(), REQUEST_TIMEOUT_MS); + timer.unref(); + pending.add(entry); + void Promise.resolve() + .then(() => transport(endpoint, body, controller.signal)) + .catch(() => {}) + .finally(finish); + } catch { + // Telemetry failures must never escape into CLI behavior. + } + }, + async flush() { + const entries = [...pending]; + if (entries.length === 0) return; + const timer = setTimeout(() => { + for (const entry of entries) entry.abort(); + }, FLUSH_TIMEOUT_MS); + try { + await Promise.all(entries.map((entry) => entry.done)); + } finally { + clearTimeout(timer); + } + }, + }; +} diff --git a/packages/cli/src/telemetry/events.ts b/packages/cli/src/telemetry/events.ts new file mode 100644 index 00000000..32223ff2 --- /dev/null +++ b/packages/cli/src/telemetry/events.ts @@ -0,0 +1,41 @@ +import { randomUUID } from 'node:crypto'; +import { isKnownAIAgent } from '../utils/ai-agent'; + +export interface CommandEvent { + commandPath: string; +} + +export interface EventMetadata { + cliVersion: string; + aiAgent?: string; +} + +/** Only resolved command names and fixed metadata cross the analytics boundary. */ +export function serializeEvent( + metadata: EventMetadata, + event: CommandEvent, +): URLSearchParams | undefined { + // Incur uses underscores between command segments in MCP tool names. + const commandPath = event.commandPath.replaceAll('_', ' '); + if (!/^[a-z][a-z0-9-]*(?: [a-z][a-z0-9-]*)*$/.test(commandPath)) + return undefined; + + const body = new URLSearchParams(); + body.set('client_id', 'link-cli'); + body.set('event_name', 'CLI Command'); + body.set('event_id', randomUUID()); + body.set('created', String(Math.floor(Date.now() / 1000))); + body.set('command_path', commandPath); + body.set( + 'cli_version', + metadata.cliVersion.length <= 100 && + /^\d+\.\d+\.\d+(?:-[\da-zA-Z.-]+)?(?:\+[\da-zA-Z.-]+)?$/.test( + metadata.cliVersion, + ) + ? metadata.cliVersion + : 'unknown', + ); + if (metadata.aiAgent && isKnownAIAgent(metadata.aiAgent)) + body.set('ai_agent', metadata.aiAgent); + return body; +} diff --git a/packages/cli/src/telemetry/opt-out.ts b/packages/cli/src/telemetry/opt-out.ts new file mode 100644 index 00000000..12b3eda0 --- /dev/null +++ b/packages/cli/src/telemetry/opt-out.ts @@ -0,0 +1,7 @@ +export function telemetryOptedOut( + env: Readonly>, +): boolean { + return [env.DO_NOT_TRACK, env.LINK_CLI_TELEMETRY_OPTOUT].some( + (value) => value === '1' || value?.toLowerCase() === 'true', + ); +} diff --git a/packages/cli/src/telemetry/transport.ts b/packages/cli/src/telemetry/transport.ts new file mode 100644 index 00000000..94685a4a --- /dev/null +++ b/packages/cli/src/telemetry/transport.ts @@ -0,0 +1,38 @@ +import { request as httpRequest } from 'node:http'; +import { request as httpsRequest } from 'node:https'; + +/** AEL-only transport: no API headers, redirect following, or retained sockets. */ +export function postTelemetry( + endpoint: URL, + body: URLSearchParams, + signal: AbortSignal, +): Promise { + return new Promise((resolve, reject) => { + const payload = body.toString(); + const request = endpoint.protocol === 'https:' ? httpsRequest : httpRequest; + const req = request( + endpoint, + { + method: 'POST', + headers: { + Origin: 'link-cli', + 'Content-Type': 'application/x-www-form-urlencoded', + 'Content-Length': Buffer.byteLength(payload), + }, + agent: false, + signal, + }, + (response) => { + // Any response finishes the attempt. Do not read bodies or follow redirects. + response.destroy(); + resolve(); + }, + ); + req.on('error', reject); + // The command and bounded flush own process lifetime, not analytics sockets. + // ClientRequest's abort signal also destroys a pending TLS handshake; fetch + // can leave that connection alive after rejecting its request promise. + req.on('socket', (socket) => socket.unref()); + req.end(payload); + }); +} diff --git a/packages/cli/src/utils/ai-agent.ts b/packages/cli/src/utils/ai-agent.ts index 62cd10f2..8ece58e4 100644 --- a/packages/cli/src/utils/ai-agent.ts +++ b/packages/cli/src/utils/ai-agent.ts @@ -17,6 +17,10 @@ const agentSignals = [ ['CODEX_INTERNAL_ORIGINATOR_OVERRIDE', 'codex_cli'], ] as const; +export function isKnownAIAgent(agent: string): boolean { + return agentSignals.some(([, slug]) => slug === agent); +} + export function detectAIAgent( env: Readonly>, ): string { diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts index 94ede10e..908be004 100644 --- a/packages/cli/vitest.config.ts +++ b/packages/cli/vitest.config.ts @@ -1,3 +1,9 @@ import { defineConfig } from 'vitest/config'; -export default defineConfig({}); +export default defineConfig({ + test: { + env: { + LINK_CLI_TELEMETRY_OPTOUT: '1', + }, + }, +}); From 0b48ba376b9051e871162c51a50982c9ed80cdaa Mon Sep 17 00:00:00 2001 From: Eric Gan Date: Wed, 30 Sep 2026 11:31:15 -0400 Subject: [PATCH 2/4] cli: format command telemetry test Match the repository Biome formatter so the CI formatting gate passes. Committed-By-Agent: codex Co-authored-by: codex --- .../src/__tests__/command-telemetry.test.ts | 47 +++++++++++-------- 1 file changed, 28 insertions(+), 19 deletions(-) diff --git a/packages/cli/src/__tests__/command-telemetry.test.ts b/packages/cli/src/__tests__/command-telemetry.test.ts index 645db9d0..27a92d86 100644 --- a/packages/cli/src/__tests__/command-telemetry.test.ts +++ b/packages/cli/src/__tests__/command-telemetry.test.ts @@ -189,10 +189,9 @@ describe('built CLI command telemetry', () => { it('counts one command despite an API retry and token refresh', async () => { refreshRequired = true; - const result = await run( - ['payment-methods', 'list', '--json'], - { LINK_REFRESH_TOKEN: 'secret-refresh-token' }, - ); + const result = await run(['payment-methods', 'list', '--json'], { + LINK_REFRESH_TOKEN: 'secret-refresh-token', + }); expect(result.exitCode, result.stderr || result.stdout).toBe(0); expect(apiRequests).toHaveLength(3); expect(events).toHaveLength(1); @@ -202,7 +201,12 @@ describe('built CLI command telemetry', () => { }); it('counts resolved commands that fail flag validation', async () => { - const result = await run(['payment-methods', 'list', '--unknown', '--json']); + const result = await run([ + 'payment-methods', + 'list', + '--unknown', + '--json', + ]); expect(result.exitCode).toBe(1); expect(apiRequests).toHaveLength(0); expect(events).toHaveLength(1); @@ -219,14 +223,11 @@ describe('built CLI command telemetry', () => { it.each>([ { DO_NOT_TRACK: '1' }, { LINK_CLI_TELEMETRY_OPTOUT: 'true' }, - ])( - 'honors opt-out: %j', - async (env) => { - const result = await run(['auth', 'status', '--json'], env); - expect(result.exitCode).toBe(0); - expect(events).toHaveLength(0); - }, - ); + ])('honors opt-out: %j', async (env) => { + const result = await run(['auth', 'status', '--json'], env); + expect(result.exitCode).toBe(0); + expect(events).toHaveLength(0); + }); it.each(['hang', 'reject'] as const)( 'preserves output and exit status when AEL %s', @@ -237,7 +238,9 @@ describe('built CLI command telemetry', () => { telemetryMode = mode; const tracked = await run(['auth', 'status', '--json']); expect(tracked.exitCode).toBe(baseline.exitCode); - expect(JSON.parse(tracked.stdout)).toMatchObject(JSON.parse(baseline.stdout)); + expect(JSON.parse(tracked.stdout)).toMatchObject( + JSON.parse(baseline.stdout), + ); expect(events).toHaveLength(1); }, ); @@ -264,7 +267,9 @@ describe('built CLI command telemetry', () => { callbacks.delete(id); resolve(message); }); - child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`); + child.stdin.write( + `${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`, + ); }); try { expect( @@ -304,10 +309,14 @@ describe('built CLI command telemetry', () => { it('records MCP tool calls served over HTTP', async () => { const port = await freePort(); - const child = spawn(process.execPath, [CLI_PATH, 'serve', '--port', String(port)], { - env: environment(), - stdio: ['ignore', 'pipe', 'pipe'], - }); + const child = spawn( + process.execPath, + [CLI_PATH, 'serve', '--port', String(port)], + { + env: environment(), + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); try { await new Promise((resolve, reject) => { let stderr = ''; From e695c51e4387255c440e22e29fbb8c80be039ddd Mon Sep 17 00:00:00 2001 From: Eric Gan Date: Wed, 30 Sep 2026 11:38:51 -0400 Subject: [PATCH 3/4] cli: allow time for command telemetry subprocesses The combined help, version, and unknown-command test starts three CLI processes; give it enough time under loaded CI runners. Committed-By-Agent: codex Co-authored-by: codex --- packages/cli/src/__tests__/command-telemetry.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/__tests__/command-telemetry.test.ts b/packages/cli/src/__tests__/command-telemetry.test.ts index 27a92d86..950eb963 100644 --- a/packages/cli/src/__tests__/command-telemetry.test.ts +++ b/packages/cli/src/__tests__/command-telemetry.test.ts @@ -218,7 +218,7 @@ describe('built CLI command telemetry', () => { await run(['--version']); await run(['not-a-command']); expect(events).toHaveLength(0); - }); + }, 20_000); it.each>([ { DO_NOT_TRACK: '1' }, From cd1086c6580eaa1403a88cf79a3f388850ca72c5 Mon Sep 17 00:00:00 2001 From: Eric Gan Date: Wed, 30 Sep 2026 11:47:24 -0400 Subject: [PATCH 4/4] cli: allow time for AEL failure test The comparison launches two CLI processes and exercises the bounded telemetry flush, which can exceed the default test timeout on loaded CI runners. Committed-By-Agent: codex Co-authored-by: codex --- packages/cli/src/__tests__/command-telemetry.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/cli/src/__tests__/command-telemetry.test.ts b/packages/cli/src/__tests__/command-telemetry.test.ts index 950eb963..10b81e13 100644 --- a/packages/cli/src/__tests__/command-telemetry.test.ts +++ b/packages/cli/src/__tests__/command-telemetry.test.ts @@ -243,6 +243,7 @@ describe('built CLI command telemetry', () => { ); expect(events).toHaveLength(1); }, + 20_000, ); it('records each MCP tool call without recording MCP control messages', async () => {