diff --git a/README.md b/README.md index c31d38f..5daaaba 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,8 @@ lfc --profile local builds list # one-off profile override Config lives at `~/.config/lifecycle-cli/config.json`. Environment overrides: `LFC_PROFILE`, `LIFECYCLE_API_URL`, `LFC_JSON=1`, `LFC_CONFIG_DIR`. +The CLI reports anonymous usage telemetry to your own deployment's API (command and flag names, duration, and outcome — never arguments, values, error messages, or identity). Set `LFC_TELEMETRY_DISABLED=1` to turn it off. + ## Builds (preview environments) ```bash diff --git a/package.json b/package.json index d47bfb6..c712017 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lfc-cli", - "version": "0.2.2", + "version": "0.3.0", "description": "Command-line interface for Lifecycle — preview environments, services, and static sites", "license": "Apache-2.0", "type": "module", diff --git a/src/lib/config.ts b/src/lib/config.ts index 25e4482..15e2d9d 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -17,6 +17,8 @@ export interface Profile { export interface ConfigFile { currentProfile: string; profiles: Record; + /** Anonymous telemetry install id — a random UUID, never tied to a user. */ + installId?: string; } export const DEFAULT_PROFILE_NAME = 'default'; diff --git a/src/lib/context.ts b/src/lib/context.ts index 86a4969..22892cb 100644 --- a/src/lib/context.ts +++ b/src/lib/context.ts @@ -4,6 +4,7 @@ import pc from 'picocolors'; import { ApiClient, ApiError } from './api.js'; import { AuthError } from './auth.js'; import { loadConfig, resolveProfile, type ConfigFile, type Profile } from './config.js'; +import { reportInvocation, type TelemetryOutcome } from './telemetry.js'; export interface Ctx { config: ConfigFile; @@ -40,21 +41,29 @@ export function runAction( return async (...args) => { const cmd = args[args.length - 1] as Command; const rest = args.slice(0, -1) as unknown as A; + let ctx: Ctx | undefined; + const startedAt = Date.now(); + let outcome: TelemetryOutcome = { status: 'success', exitCode: 0 }; try { - const ctx = buildCtx(cmd); + ctx = buildCtx(cmd); await fn(ctx, ...rest); } catch (err) { if (err instanceof AuthError) { process.stderr.write(`${pc.red('auth error:')} ${err.message}\n`); process.exitCode = 4; + outcome = { status: 'error', exitCode: 4, errorClass: 'AuthError' }; } else if (err instanceof ApiError) { const reqId = err.requestId ? pc.dim(` (request_id: ${err.requestId})`) : ''; process.stderr.write(`${pc.red(`api error (${err.status}):`)} ${err.message}${reqId}\n`); - process.exitCode = err.status === 404 ? 3 : 1; + const exitCode = err.status === 404 ? 3 : 1; + process.exitCode = exitCode; + outcome = { status: 'error', exitCode, errorClass: 'ApiError', errorHttpStatus: err.status, errorCode: err.code }; } else { process.stderr.write(`${pc.red('error:')} ${(err as Error).message}\n`); process.exitCode = 1; + outcome = { status: 'error', exitCode: 1, errorClass: 'Error' }; } } + await reportInvocation(ctx, cmd, Date.now() - startedAt, outcome); }; } diff --git a/src/lib/generated/createTelemetryEventBody.ts b/src/lib/generated/createTelemetryEventBody.ts new file mode 100644 index 0000000..ce1f36f --- /dev/null +++ b/src/lib/generated/createTelemetryEventBody.ts @@ -0,0 +1,49 @@ +/** + * Generated by orval v7.4.1 🍺 + * Do not edit manually. + * Lifecycle API + * API documentation for lifecycle + * OpenAPI spec version: 2.0.0 + */ +import type { CreateTelemetryEventBodySource } from './createTelemetryEventBodySource.js'; +import type { CreateTelemetryEventBodyAttributes } from './createTelemetryEventBodyAttributes.js'; +import type { CreateTelemetryEventBodyStatus } from './createTelemetryEventBodyStatus.js'; + +export type CreateTelemetryEventBody = { + /** Which client type reported the event. */ + source: CreateTelemetryEventBodySource; + /** Anonymous per-client identifier. */ + clientId: string; + /** + * Event name. For the CLI this is the space-joined command path, e.g. "builds list". + * @maxLength 200 + */ + event: string; + /** Arbitrary event attributes. Values limited to strings, numbers, booleans, or string arrays; at most 2KB serialized. */ + attributes?: CreateTelemetryEventBodyAttributes; + /** + * @minimum 0 + * @nullable + */ + durationMs?: number | null; + status: CreateTelemetryEventBodyStatus; + /** + * Process exit code (CLI-only). + * @nullable + */ + exitCode?: number | null; + /** @nullable */ + errorClass?: string | null; + /** @nullable */ + errorHttpStatus?: number | null; + /** @nullable */ + errorCode?: string | null; + /** Version of the reporting client. */ + clientVersion: string; + /** @nullable */ + runtimeVersion?: string | null; + /** @nullable */ + platform?: string | null; + /** @nullable */ + arch?: string | null; +}; diff --git a/src/lib/generated/createTelemetryEventBodyAttributes.ts b/src/lib/generated/createTelemetryEventBodyAttributes.ts new file mode 100644 index 0000000..58f2023 --- /dev/null +++ b/src/lib/generated/createTelemetryEventBodyAttributes.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval v7.4.1 🍺 + * Do not edit manually. + * Lifecycle API + * API documentation for lifecycle + * OpenAPI spec version: 2.0.0 + */ + +/** + * Arbitrary event attributes. Values limited to strings, numbers, booleans, or string arrays; at most 2KB serialized. + */ +export type CreateTelemetryEventBodyAttributes = { [key: string]: unknown }; diff --git a/src/lib/generated/createTelemetryEventBodySource.ts b/src/lib/generated/createTelemetryEventBodySource.ts new file mode 100644 index 0000000..eedf3ba --- /dev/null +++ b/src/lib/generated/createTelemetryEventBodySource.ts @@ -0,0 +1,19 @@ +/** + * Generated by orval v7.4.1 🍺 + * Do not edit manually. + * Lifecycle API + * API documentation for lifecycle + * OpenAPI spec version: 2.0.0 + */ + +/** + * Which client type reported the event. + */ +export type CreateTelemetryEventBodySource = typeof CreateTelemetryEventBodySource[keyof typeof CreateTelemetryEventBodySource]; + + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const CreateTelemetryEventBodySource = { + cli: 'cli', + ui: 'ui', +} as const; diff --git a/src/lib/generated/createTelemetryEventBodyStatus.ts b/src/lib/generated/createTelemetryEventBodyStatus.ts new file mode 100644 index 0000000..1aca2dd --- /dev/null +++ b/src/lib/generated/createTelemetryEventBodyStatus.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval v7.4.1 🍺 + * Do not edit manually. + * Lifecycle API + * API documentation for lifecycle + * OpenAPI spec version: 2.0.0 + */ + +export type CreateTelemetryEventBodyStatus = typeof CreateTelemetryEventBodyStatus[keyof typeof CreateTelemetryEventBodyStatus]; + + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const CreateTelemetryEventBodyStatus = { + success: 'success', + error: 'error', +} as const; diff --git a/src/lib/generated/createTelemetryEventSuccessResponse.ts b/src/lib/generated/createTelemetryEventSuccessResponse.ts new file mode 100644 index 0000000..5159669 --- /dev/null +++ b/src/lib/generated/createTelemetryEventSuccessResponse.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v7.4.1 🍺 + * Do not edit manually. + * Lifecycle API + * API documentation for lifecycle + * OpenAPI spec version: 2.0.0 + */ +import type { SuccessApiResponse } from './successApiResponse.js'; +import type { CreateTelemetryEventSuccessResponseAllOf } from './createTelemetryEventSuccessResponseAllOf.js'; + +export type CreateTelemetryEventSuccessResponse = SuccessApiResponse & CreateTelemetryEventSuccessResponseAllOf; diff --git a/src/lib/generated/createTelemetryEventSuccessResponseAllOf.ts b/src/lib/generated/createTelemetryEventSuccessResponseAllOf.ts new file mode 100644 index 0000000..6d107be --- /dev/null +++ b/src/lib/generated/createTelemetryEventSuccessResponseAllOf.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval v7.4.1 🍺 + * Do not edit manually. + * Lifecycle API + * API documentation for lifecycle + * OpenAPI spec version: 2.0.0 + */ +import type { CreateTelemetryEventSuccessResponseAllOfData } from './createTelemetryEventSuccessResponseAllOfData.js'; + +export type CreateTelemetryEventSuccessResponseAllOf = { + data: CreateTelemetryEventSuccessResponseAllOfData; +}; diff --git a/src/lib/generated/createTelemetryEventSuccessResponseAllOfData.ts b/src/lib/generated/createTelemetryEventSuccessResponseAllOfData.ts new file mode 100644 index 0000000..bb297c4 --- /dev/null +++ b/src/lib/generated/createTelemetryEventSuccessResponseAllOfData.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval v7.4.1 🍺 + * Do not edit manually. + * Lifecycle API + * API documentation for lifecycle + * OpenAPI spec version: 2.0.0 + */ +import type { CreateTelemetryEventSuccessResponseAllOfDataEvent } from './createTelemetryEventSuccessResponseAllOfDataEvent.js'; + +export type CreateTelemetryEventSuccessResponseAllOfData = { + event: CreateTelemetryEventSuccessResponseAllOfDataEvent; +}; diff --git a/src/lib/generated/createTelemetryEventSuccessResponseAllOfDataEvent.ts b/src/lib/generated/createTelemetryEventSuccessResponseAllOfDataEvent.ts new file mode 100644 index 0000000..074f59e --- /dev/null +++ b/src/lib/generated/createTelemetryEventSuccessResponseAllOfDataEvent.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval v7.4.1 🍺 + * Do not edit manually. + * Lifecycle API + * API documentation for lifecycle + * OpenAPI spec version: 2.0.0 + */ + +export type CreateTelemetryEventSuccessResponseAllOfDataEvent = { + id: number; + createdAt: string; +}; diff --git a/src/lib/generated/getTelemetryStatsInterval.ts b/src/lib/generated/getTelemetryStatsInterval.ts new file mode 100644 index 0000000..a519fa4 --- /dev/null +++ b/src/lib/generated/getTelemetryStatsInterval.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval v7.4.1 🍺 + * Do not edit manually. + * Lifecycle API + * API documentation for lifecycle + * OpenAPI spec version: 2.0.0 + */ + +export type GetTelemetryStatsInterval = typeof GetTelemetryStatsInterval[keyof typeof GetTelemetryStatsInterval]; + + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const GetTelemetryStatsInterval = { + day: 'day', + week: 'week', +} as const; diff --git a/src/lib/generated/getTelemetryStatsParams.ts b/src/lib/generated/getTelemetryStatsParams.ts new file mode 100644 index 0000000..a326019 --- /dev/null +++ b/src/lib/generated/getTelemetryStatsParams.ts @@ -0,0 +1,28 @@ +/** + * Generated by orval v7.4.1 🍺 + * Do not edit manually. + * Lifecycle API + * API documentation for lifecycle + * OpenAPI spec version: 2.0.0 + */ +import type { GetTelemetryStatsSource } from './getTelemetryStatsSource.js'; +import type { GetTelemetryStatsInterval } from './getTelemetryStatsInterval.js'; + +export type GetTelemetryStatsParams = { +/** + * Which client type to aggregate. + */ +source: GetTelemetryStatsSource; +/** + * ISO date for the start of the range. Defaults to 30 days before now. + */ +from?: string; +/** + * ISO date for the end of the range. Defaults to now. + */ +to?: string; +/** + * Bucket size for time series. + */ +interval?: GetTelemetryStatsInterval; +}; diff --git a/src/lib/generated/getTelemetryStatsSource.ts b/src/lib/generated/getTelemetryStatsSource.ts new file mode 100644 index 0000000..4f2c445 --- /dev/null +++ b/src/lib/generated/getTelemetryStatsSource.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval v7.4.1 🍺 + * Do not edit manually. + * Lifecycle API + * API documentation for lifecycle + * OpenAPI spec version: 2.0.0 + */ + +export type GetTelemetryStatsSource = typeof GetTelemetryStatsSource[keyof typeof GetTelemetryStatsSource]; + + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const GetTelemetryStatsSource = { + cli: 'cli', + ui: 'ui', +} as const; diff --git a/src/lib/generated/getTelemetryStatsSuccessResponse.ts b/src/lib/generated/getTelemetryStatsSuccessResponse.ts new file mode 100644 index 0000000..2a9f13e --- /dev/null +++ b/src/lib/generated/getTelemetryStatsSuccessResponse.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v7.4.1 🍺 + * Do not edit manually. + * Lifecycle API + * API documentation for lifecycle + * OpenAPI spec version: 2.0.0 + */ +import type { SuccessApiResponse } from './successApiResponse.js'; +import type { GetTelemetryStatsSuccessResponseAllOf } from './getTelemetryStatsSuccessResponseAllOf.js'; + +export type GetTelemetryStatsSuccessResponse = SuccessApiResponse & GetTelemetryStatsSuccessResponseAllOf; diff --git a/src/lib/generated/getTelemetryStatsSuccessResponseAllOf.ts b/src/lib/generated/getTelemetryStatsSuccessResponseAllOf.ts new file mode 100644 index 0000000..d466256 --- /dev/null +++ b/src/lib/generated/getTelemetryStatsSuccessResponseAllOf.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval v7.4.1 🍺 + * Do not edit manually. + * Lifecycle API + * API documentation for lifecycle + * OpenAPI spec version: 2.0.0 + */ +import type { GetTelemetryStatsSuccessResponseAllOfData } from './getTelemetryStatsSuccessResponseAllOfData.js'; + +export type GetTelemetryStatsSuccessResponseAllOf = { + data: GetTelemetryStatsSuccessResponseAllOfData; +}; diff --git a/src/lib/generated/getTelemetryStatsSuccessResponseAllOfData.ts b/src/lib/generated/getTelemetryStatsSuccessResponseAllOfData.ts new file mode 100644 index 0000000..caff5be --- /dev/null +++ b/src/lib/generated/getTelemetryStatsSuccessResponseAllOfData.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v7.4.1 🍺 + * Do not edit manually. + * Lifecycle API + * API documentation for lifecycle + * OpenAPI spec version: 2.0.0 + */ +import type { TelemetryStatsRange } from './telemetryStatsRange.js'; +import type { TelemetryStats } from './telemetryStats.js'; + +export type GetTelemetryStatsSuccessResponseAllOfData = { + range: TelemetryStatsRange; + stats: TelemetryStats; +}; diff --git a/src/lib/generated/index.ts b/src/lib/generated/index.ts index 27f5685..5533ac0 100644 --- a/src/lib/generated/index.ts +++ b/src/lib/generated/index.ts @@ -404,6 +404,14 @@ export * from './createSandboxAgentSessionBodyOneOfFiveWorkspace.js'; export * from './createSandboxAgentSessionBodyOneOfService.js'; export * from './createSandboxAgentSessionBodyOneOfServiceOneOf.js'; export * from './createSandboxAgentSessionBodyOneOfWorkspace.js'; +export * from './createTelemetryEventBody.js'; +export * from './createTelemetryEventBodyAttributes.js'; +export * from './createTelemetryEventBodySource.js'; +export * from './createTelemetryEventBodyStatus.js'; +export * from './createTelemetryEventSuccessResponse.js'; +export * from './createTelemetryEventSuccessResponseAllOf.js'; +export * from './createTelemetryEventSuccessResponseAllOfData.js'; +export * from './createTelemetryEventSuccessResponseAllOfDataEvent.js'; export * from './creatorCapabilityAvailability.js'; export * from './creatorCapabilityAvailabilityMap.js'; export * from './customAgentCreationMode.js'; @@ -575,6 +583,12 @@ export * from './getSandboxServiceCandidates200DataServicesItem.js'; export * from './getSandboxServiceCandidates200DataStatus.js'; export * from './getSandboxServiceCandidates200Error.js'; export * from './getSandboxServiceCandidatesParams.js'; +export * from './getTelemetryStatsInterval.js'; +export * from './getTelemetryStatsParams.js'; +export * from './getTelemetryStatsSource.js'; +export * from './getTelemetryStatsSuccessResponse.js'; +export * from './getTelemetryStatsSuccessResponseAllOf.js'; +export * from './getTelemetryStatsSuccessResponseAllOfData.js'; export * from './getWebhooksSuccessResponse.js'; export * from './getWebhooksSuccessResponseAllOf.js'; export * from './installedRepository.js'; @@ -735,6 +749,15 @@ export * from './systemAgentDefinitionId.js'; export * from './tearDownBuildSuccessResponse.js'; export * from './tearDownBuildSuccessResponseAllOf.js'; export * from './tearDownBuildSuccessResponseAllOfData.js'; +export * from './telemetryBucketCount.js'; +export * from './telemetryEventStats.js'; +export * from './telemetryStats.js'; +export * from './telemetryStatsActiveClients.js'; +export * from './telemetryStatsPlatformsItem.js'; +export * from './telemetryStatsRange.js'; +export * from './telemetryStatsRangeInterval.js'; +export * from './telemetryStatsRangeSource.js'; +export * from './telemetryStatsVersionsItem.js'; export * from './updateAdminAgentCapabilitiesParams.js'; export * from './updateAdminAgentCapabilitiesRequest.js'; export * from './updateAdminCustomAgentCreationPolicyRequest.js'; diff --git a/src/lib/generated/telemetryBucketCount.ts b/src/lib/generated/telemetryBucketCount.ts new file mode 100644 index 0000000..b66eabe --- /dev/null +++ b/src/lib/generated/telemetryBucketCount.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval v7.4.1 🍺 + * Do not edit manually. + * Lifecycle API + * API documentation for lifecycle + * OpenAPI spec version: 2.0.0 + */ + +export interface TelemetryBucketCount { + bucket: string; + count: number; +} diff --git a/src/lib/generated/telemetryEventStats.ts b/src/lib/generated/telemetryEventStats.ts new file mode 100644 index 0000000..a003853 --- /dev/null +++ b/src/lib/generated/telemetryEventStats.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.4.1 🍺 + * Do not edit manually. + * Lifecycle API + * API documentation for lifecycle + * OpenAPI spec version: 2.0.0 + */ + +export interface TelemetryEventStats { + event: string; + count: number; + errorCount: number; + errorRate: number; + /** @nullable */ + p50DurationMs: number | null; + /** @nullable */ + p95DurationMs: number | null; +} diff --git a/src/lib/generated/telemetryStats.ts b/src/lib/generated/telemetryStats.ts new file mode 100644 index 0000000..5b1984a --- /dev/null +++ b/src/lib/generated/telemetryStats.ts @@ -0,0 +1,20 @@ +/** + * Generated by orval v7.4.1 🍺 + * Do not edit manually. + * Lifecycle API + * API documentation for lifecycle + * OpenAPI spec version: 2.0.0 + */ +import type { TelemetryBucketCount } from './telemetryBucketCount.js'; +import type { TelemetryEventStats } from './telemetryEventStats.js'; +import type { TelemetryStatsActiveClients } from './telemetryStatsActiveClients.js'; +import type { TelemetryStatsVersionsItem } from './telemetryStatsVersionsItem.js'; +import type { TelemetryStatsPlatformsItem } from './telemetryStatsPlatformsItem.js'; + +export interface TelemetryStats { + usageOverTime: TelemetryBucketCount[]; + topEvents: TelemetryEventStats[]; + activeClients: TelemetryStatsActiveClients; + versions: TelemetryStatsVersionsItem[]; + platforms: TelemetryStatsPlatformsItem[]; +} diff --git a/src/lib/generated/telemetryStatsActiveClients.ts b/src/lib/generated/telemetryStatsActiveClients.ts new file mode 100644 index 0000000..a541cd7 --- /dev/null +++ b/src/lib/generated/telemetryStatsActiveClients.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v7.4.1 🍺 + * Do not edit manually. + * Lifecycle API + * API documentation for lifecycle + * OpenAPI spec version: 2.0.0 + */ +import type { TelemetryBucketCount } from './telemetryBucketCount.js'; + +export type TelemetryStatsActiveClients = { + total: number; + overTime: TelemetryBucketCount[]; +}; diff --git a/src/lib/generated/telemetryStatsPlatformsItem.ts b/src/lib/generated/telemetryStatsPlatformsItem.ts new file mode 100644 index 0000000..433ce34 --- /dev/null +++ b/src/lib/generated/telemetryStatsPlatformsItem.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v7.4.1 🍺 + * Do not edit manually. + * Lifecycle API + * API documentation for lifecycle + * OpenAPI spec version: 2.0.0 + */ + +export type TelemetryStatsPlatformsItem = { + /** @nullable */ + platform: string | null; + count: number; +}; diff --git a/src/lib/generated/telemetryStatsRange.ts b/src/lib/generated/telemetryStatsRange.ts new file mode 100644 index 0000000..baafdf6 --- /dev/null +++ b/src/lib/generated/telemetryStatsRange.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval v7.4.1 🍺 + * Do not edit manually. + * Lifecycle API + * API documentation for lifecycle + * OpenAPI spec version: 2.0.0 + */ +import type { TelemetryStatsRangeSource } from './telemetryStatsRangeSource.js'; +import type { TelemetryStatsRangeInterval } from './telemetryStatsRangeInterval.js'; + +export interface TelemetryStatsRange { + source: TelemetryStatsRangeSource; + from: string; + to: string; + interval: TelemetryStatsRangeInterval; +} diff --git a/src/lib/generated/telemetryStatsRangeInterval.ts b/src/lib/generated/telemetryStatsRangeInterval.ts new file mode 100644 index 0000000..01b4da3 --- /dev/null +++ b/src/lib/generated/telemetryStatsRangeInterval.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval v7.4.1 🍺 + * Do not edit manually. + * Lifecycle API + * API documentation for lifecycle + * OpenAPI spec version: 2.0.0 + */ + +export type TelemetryStatsRangeInterval = typeof TelemetryStatsRangeInterval[keyof typeof TelemetryStatsRangeInterval]; + + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const TelemetryStatsRangeInterval = { + day: 'day', + week: 'week', +} as const; diff --git a/src/lib/generated/telemetryStatsRangeSource.ts b/src/lib/generated/telemetryStatsRangeSource.ts new file mode 100644 index 0000000..b30b998 --- /dev/null +++ b/src/lib/generated/telemetryStatsRangeSource.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval v7.4.1 🍺 + * Do not edit manually. + * Lifecycle API + * API documentation for lifecycle + * OpenAPI spec version: 2.0.0 + */ + +export type TelemetryStatsRangeSource = typeof TelemetryStatsRangeSource[keyof typeof TelemetryStatsRangeSource]; + + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const TelemetryStatsRangeSource = { + cli: 'cli', + ui: 'ui', +} as const; diff --git a/src/lib/generated/telemetryStatsVersionsItem.ts b/src/lib/generated/telemetryStatsVersionsItem.ts new file mode 100644 index 0000000..b6b6e54 --- /dev/null +++ b/src/lib/generated/telemetryStatsVersionsItem.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval v7.4.1 🍺 + * Do not edit manually. + * Lifecycle API + * API documentation for lifecycle + * OpenAPI spec version: 2.0.0 + */ + +export type TelemetryStatsVersionsItem = { + clientVersion: string; + count: number; +}; diff --git a/src/lib/telemetry.ts b/src/lib/telemetry.ts new file mode 100644 index 0000000..e596587 --- /dev/null +++ b/src/lib/telemetry.ts @@ -0,0 +1,102 @@ +import crypto from 'node:crypto'; + +import type { Command } from 'commander'; + +import { getAccessToken } from './auth.js'; +import { loadConfig, saveConfig } from './config.js'; +import type { Ctx } from './context.js'; +import type { CreateTelemetryEventBody } from './generated/index.js'; + +import pkg from '../../package.json' with { type: 'json' }; + +export const TELEMETRY_TIMEOUT_MS = 3000; + +export interface TelemetryOutcome { + status: 'success' | 'error'; + exitCode: number; + errorClass?: string; + errorHttpStatus?: number; + errorCode?: string; +} + +export function isTelemetryDisabled(value = process.env.LFC_TELEMETRY_DISABLED): boolean { + if (value === undefined) return false; + const v = value.trim().toLowerCase(); + return v !== '' && v !== '0' && v !== 'false'; +} + +/** Full subcommand path (e.g. "builds env set"), excluding the root program name. */ +export function commandPath(cmd: Command): string { + const names: string[] = []; + for (let c: Command | null = cmd; c && c.parent; c = c.parent) names.unshift(c.name()); + return names.join(' '); +} + +/** Long names of options explicitly passed on the command line, across the command chain. Values are never collected. */ +export function explicitFlags(cmd: Command): string[] { + const flags = new Set(); + for (let c: Command | null = cmd; c; c = c.parent) { + for (const opt of c.options) { + if (c.getOptionValueSource(opt.attributeName()) === 'cli') { + flags.add(opt.long ?? opt.short ?? opt.name()); + } + } + } + return [...flags]; +} + +/** Anonymous per-install id, created on first use and persisted in the CLI config. */ +export function getInstallId(): string { + const config = loadConfig(); + if (config.installId) return config.installId; + const installId = crypto.randomUUID(); + try { + saveConfig({ ...config, installId }); + } catch { + // unwritable config dir — report with an ephemeral id rather than fail + } + return installId; +} + +/** + * Report one command invocation to the deployment's telemetry endpoint. + * Must never affect the command: every failure (offline, logged out, old server + * without the endpoint, timeout) is swallowed, bounded by TELEMETRY_TIMEOUT_MS. + */ +export async function reportInvocation( + ctx: Ctx | undefined, + cmd: Command, + durationMs: number, + outcome: TelemetryOutcome +): Promise { + if (isTelemetryDisabled() || !ctx) return; + try { + const token = await getAccessToken(ctx.profileName, ctx.profile); + const headers: Record = { 'Content-Type': 'application/json' }; + if (token) headers.Authorization = `Bearer ${token}`; + const body: CreateTelemetryEventBody = { + source: 'cli', + clientId: getInstallId(), + event: commandPath(cmd), + attributes: { flags: explicitFlags(cmd) }, + durationMs: Math.round(durationMs), + status: outcome.status, + exitCode: outcome.exitCode, + errorClass: outcome.errorClass ?? null, + errorHttpStatus: outcome.errorHttpStatus ?? null, + errorCode: outcome.errorCode ?? null, + clientVersion: pkg.version, + runtimeVersion: process.version, + platform: process.platform, + arch: process.arch, + }; + await fetch(`${ctx.api.baseUrl}/api/v2/telemetry/events`, { + method: 'POST', + headers, + body: JSON.stringify(body), + signal: AbortSignal.timeout(TELEMETRY_TIMEOUT_MS), + }); + } catch { + // telemetry is strictly best-effort + } +} diff --git a/tests/telemetry.test.ts b/tests/telemetry.test.ts new file mode 100644 index 0000000..b859623 --- /dev/null +++ b/tests/telemetry.test.ts @@ -0,0 +1,203 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { Command } from 'commander'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { loadConfig, saveConfig } from '../src/lib/config.js'; +import type { Ctx } from '../src/lib/context.js'; +import { + commandPath, + explicitFlags, + getInstallId, + isTelemetryDisabled, + reportInvocation, +} from '../src/lib/telemetry.js'; + +let dir: string; + +beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lfc-telemetry-test-')); + process.env.LFC_CONFIG_DIR = dir; + delete process.env.LFC_TELEMETRY_DISABLED; +}); + +afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + delete process.env.LFC_CONFIG_DIR; + delete process.env.LFC_TELEMETRY_DISABLED; + vi.unstubAllGlobals(); +}); + +async function parsedCommand(argv: string[]): Promise { + const program = new Command().name('lfc').option('--json').option('--profile '); + let captured: Command | undefined; + program + .command('builds') + .command('list') + .option('--limit ', 'page size', '20') + .action((_opts: unknown, cmd: Command) => { + captured = cmd; + }); + await program.parseAsync(argv, { from: 'user' }); + if (!captured) throw new Error('command did not run'); + return captured; +} + +function fakeCtx(profile?: Partial): Ctx { + return { + profileName: 'default', + profile: { apiUrl: 'https://lifecycle.example.com', authEnabled: false, ...profile }, + api: { baseUrl: 'https://lifecycle.example.com' }, + } as unknown as Ctx; +} + +describe('isTelemetryDisabled', () => { + it('is enabled by default and for falsy-looking values', () => { + expect(isTelemetryDisabled(undefined)).toBe(false); + expect(isTelemetryDisabled('')).toBe(false); + expect(isTelemetryDisabled('0')).toBe(false); + expect(isTelemetryDisabled('false')).toBe(false); + expect(isTelemetryDisabled('FALSE')).toBe(false); + }); + + it('is disabled for any truthy value', () => { + expect(isTelemetryDisabled('1')).toBe(true); + expect(isTelemetryDisabled('true')).toBe(true); + expect(isTelemetryDisabled('yes')).toBe(true); + }); +}); + +describe('command metadata', () => { + it('joins the subcommand path without the program name', async () => { + const cmd = await parsedCommand(['builds', 'list']); + expect(commandPath(cmd)).toBe('builds list'); + }); + + it('collects only flags explicitly passed on the CLI, across the command chain', async () => { + const cmd = await parsedCommand(['--json', 'builds', 'list', '--limit', '5']); + expect(explicitFlags(cmd).sort()).toEqual(['--json', '--limit']); + }); + + it('does not report defaulted or unset flags', async () => { + const cmd = await parsedCommand(['builds', 'list']); + expect(explicitFlags(cmd)).toEqual([]); + }); +}); + +describe('getInstallId', () => { + it('generates a UUID once and persists it in the config file', () => { + const first = getInstallId(); + expect(first).toMatch(/^[0-9a-f-]{36}$/); + expect(getInstallId()).toBe(first); + expect(loadConfig().installId).toBe(first); + }); + + it('keeps existing profiles intact when persisting the id', () => { + saveConfig({ + currentProfile: 'staging', + profiles: { staging: { apiUrl: 'https://staging.example.com', authEnabled: false } }, + }); + const id = getInstallId(); + const config = loadConfig(); + expect(config.installId).toBe(id); + expect(config.currentProfile).toBe('staging'); + expect(config.profiles.staging).toBeDefined(); + }); +}); + +describe('reportInvocation', () => { + it('POSTs one event with names-only payload', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response('{}', { status: 201 })); + vi.stubGlobal('fetch', fetchMock); + const cmd = await parsedCommand(['builds', 'list', '--limit', '5']); + + await reportInvocation(fakeCtx(), cmd, 123.6, { status: 'success', exitCode: 0 }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('https://lifecycle.example.com/api/v2/telemetry/events'); + expect(init.method).toBe('POST'); + const body = JSON.parse(init.body as string); + expect(body).toMatchObject({ + source: 'cli', + event: 'builds list', + attributes: { flags: ['--limit'] }, + durationMs: 124, + status: 'success', + exitCode: 0, + errorClass: null, + platform: process.platform, + }); + expect(body.clientId).toMatch(/^[0-9a-f-]{36}$/); + expect(body.clientVersion).toBeTruthy(); + expect(Object.keys(body)).not.toContain('args'); + }); + + it('sends error details on failure without message text', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response('{}', { status: 201 })); + vi.stubGlobal('fetch', fetchMock); + const cmd = await parsedCommand(['builds', 'list']); + + await reportInvocation(fakeCtx(), cmd, 50, { + status: 'error', + exitCode: 3, + errorClass: 'ApiError', + errorHttpStatus: 404, + errorCode: 'not_found', + }); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(init.body as string); + expect(body).toMatchObject({ + status: 'error', + exitCode: 3, + errorClass: 'ApiError', + errorHttpStatus: 404, + errorCode: 'not_found', + }); + }); + + it('sends nothing when LFC_TELEMETRY_DISABLED is truthy', async () => { + process.env.LFC_TELEMETRY_DISABLED = '1'; + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + const cmd = await parsedCommand(['builds', 'list']); + + await reportInvocation(fakeCtx(), cmd, 10, { status: 'success', exitCode: 0 }); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('sends nothing when the command never built a context', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + const cmd = await parsedCommand(['builds', 'list']); + + await reportInvocation(undefined, cmd, 10, { status: 'error', exitCode: 1 }); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('skips silently when auth is enabled but the user is logged out', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + const cmd = await parsedCommand(['builds', 'list']); + const ctx = fakeCtx({ + authEnabled: true, + keycloak: { issuer: 'https://kc.example.com/realms/lifecycle', clientId: 'lifecycle-cli' }, + }); + + await expect(reportInvocation(ctx, cmd, 10, { status: 'success', exitCode: 0 })).resolves.toBeUndefined(); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('never throws when the network fails', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('ECONNREFUSED'))); + const cmd = await parsedCommand(['builds', 'list']); + + await expect(reportInvocation(fakeCtx(), cmd, 10, { status: 'success', exitCode: 0 })).resolves.toBeUndefined(); + }); +});