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..10b81e13 --- /dev/null +++ b/packages/cli/src/__tests__/command-telemetry.test.ts @@ -0,0 +1,374 @@ +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); + }, 20_000); + + 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); + }, + 20_000, + ); + + 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', + }, + }, +});