From ba2f9544d4e79e376ee7b6515bc099d2cbeb4e24 Mon Sep 17 00:00:00 2001 From: caballeto Date: Fri, 1 May 2026 18:24:21 +0200 Subject: [PATCH] feat(api-client): emit X-DevHelm-Surface telemetry headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds CLI surface identification to every authenticated API call so the GTM rollup can attribute usage to the CLI vs. SDKs / MCP / Terraform. Wire contract docs at https://devhelm.io/telemetry; matching API-side handler in mono#332. Headers emitted by default (every request): - X-DevHelm-Surface: cli - X-DevHelm-Surface-Version: package.json version (read at module load via createRequire — no release-script edit needed) - X-DevHelm-Cli-Os: - (e.g. darwin-arm64) - X-DevHelm-Cli-Install-Source: npm | brew | other - DEVHELM_INSTALL_SOURCE env var overrides (set this in the brew formula or any other distribution channel that knows better than the heuristics) - Heuristic checks the realpath of process.argv[1] for /Cellar/, /opt/homebrew/, /home/linuxbrew/ → "brew"; /node_modules/ → "npm"; falls back to "other" rather than guessing wrong Opt-out is a single env var, not per-command flag: DEVHELM_TELEMETRY=0 returns an empty headers object and the API receives no surface signal at all. Auth + tenant headers are unaffected. Strict equality on "0" so DEVHELM_TELEMETRY=on / true / yes don't accidentally read as opt-out. Zero callsite changes — `createApiClient` is the single bottleneck for every CLI command, so spreading the headers in there covers all 73 commands without touching one of them. The telemetry module (lib/surface-telemetry.ts) is a pure function of process.* with no external state. Tests: 6 new tests in test/lib/surface-telemetry.test.ts covering defaults, OS detection, install-source override, env opt-out, and the strict-on-"0" parsing. Full suite (899) green; eslint + tsc clean. Bumped to 0.6.3 (additive feature; patch bump because no public CLI surface changed). Co-authored-by: Cursor --- package.json | 2 +- src/lib/api-client.ts | 5 ++ src/lib/surface-telemetry.ts | 98 ++++++++++++++++++++++++++++++ test/lib/surface-telemetry.test.ts | 65 ++++++++++++++++++++ 4 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 src/lib/surface-telemetry.ts create mode 100644 test/lib/surface-telemetry.test.ts diff --git a/package.json b/package.json index e5d77b4..cf5859a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "devhelm", - "version": "0.6.2", + "version": "0.6.3", "description": "DevHelm CLI — manage monitors, deployments, and infrastructure as code", "author": "DevHelm ", "license": "MIT", diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 55daf4c..d158d09 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -10,6 +10,7 @@ import { type DevhelmApiErrorOptions, } from './errors.js' import {parseSingle, parsePage, parseCursorPage, type Page, type CursorPage as ValidatedCursorPage} from './response-validation.js' +import {buildSurfaceHeaders} from './surface-telemetry.js' export type {paths, components} @@ -94,6 +95,10 @@ export function createApiClient(opts: { 'Content-Type': 'application/json', 'x-phelm-org-id': opts.orgId ?? process.env.DEVHELM_ORG_ID ?? '1', 'x-phelm-workspace-id': opts.workspaceId ?? process.env.DEVHELM_WORKSPACE_ID ?? '1', + // Devtool surface telemetry — see lib/surface-telemetry.ts and + // https://devhelm.io/telemetry. Empty object when the user has set + // DEVHELM_TELEMETRY=0; otherwise four short identification headers. + ...buildSurfaceHeaders(), }, }) diff --git a/src/lib/surface-telemetry.ts b/src/lib/surface-telemetry.ts new file mode 100644 index 0000000..f8b453a --- /dev/null +++ b/src/lib/surface-telemetry.ts @@ -0,0 +1,98 @@ +// Surface telemetry — what the CLI tells the API about itself on every +// authenticated request so the GTM rollup can attribute usage to the CLI +// (vs. SDKs / MCP / Terraform). Wire contract documented at +// https://devhelm.io/telemetry. The matching API-side handler is in mono. +// +// All exports are pure functions of `process.*` so the headers are computed +// once at client construction and never re-evaluated. Opt-out is honoured +// here too — `DEVHELM_TELEMETRY=0` returns an empty headers object and the +// API receives no surface signal at all. + +import {createRequire} from 'node:module' +import {realpathSync} from 'node:fs' + +const require = createRequire(import.meta.url) +const pkg = require('../../package.json') as {version?: string} + +const SURFACE = 'cli' +const CLI_VERSION: string = pkg.version ?? 'unknown' + +/** + * Tag for the OS+arch the CLI is running on. We deliberately keep the + * alphabet tiny ("darwin-arm64", "linux-x64", "win32-x64", ...) — the API + * pivots on the prefix when sizing platform-specific test matrices, so a + * verbose UA string would just churn the cardinality. + */ +function detectOs(): string { + return `${process.platform}-${process.arch}` +} + +/** + * Heuristic for "how was this CLI installed?". Used by the rollout team to + * decide where to invest install-experience polish (npm flow vs. brew flow + * vs. raw download). Not authoritative — happy to return "other" rather + * than guess wrong. + * + * Detection order: + * 1. Explicit env var DEVHELM_INSTALL_SOURCE — set by the brew formula + * (or any other distribution channel) to short-circuit guessing. + * 2. Path of the running script: brew installs realpath under + * /opt/homebrew or /usr/local/Cellar; npm globals live under a + * `node_modules` segment somewhere in the path. + * 3. Otherwise "other". + */ +function detectInstallSource(): string { + const explicit = process.env['DEVHELM_INSTALL_SOURCE'] + if (explicit && explicit.trim().length > 0) { + return explicit.trim() + } + + // process.argv[1] is the script path. Resolve symlinks (brew + nvm both + // symlink heavily) so we see the actual install location rather than + // ~/.local/bin/devhelm or similar. + const scriptPath = process.argv[1] + if (!scriptPath) { + return 'other' + } + + let resolved: string + try { + resolved = realpathSync(scriptPath) + } catch { + // Fall back to the raw path; some sandboxed envs (CI containers, + // Snap, AppImage) refuse realpath but still give a usable argv[1]. + resolved = scriptPath + } + + if ( + resolved.includes('/Cellar/') || + resolved.startsWith('/opt/homebrew/') || + resolved.startsWith('/home/linuxbrew/') + ) { + return 'brew' + } + if (resolved.includes('/node_modules/')) { + return 'npm' + } + return 'other' +} + +/** + * Build the X-DevHelm-Surface* headers for one CLI invocation. + * + * Returns an empty object when `DEVHELM_TELEMETRY=0` so the API receives + * no surface signal at all. The opt-out is intentionally a single env var + * rather than a per-command flag — users opt out once for their shell, not + * per call site. + */ +export function buildSurfaceHeaders(): Record { + if ((process.env['DEVHELM_TELEMETRY'] ?? '').trim() === '0') { + return {} + } + return { + 'X-DevHelm-Surface': SURFACE, + 'X-DevHelm-Surface-Version': CLI_VERSION, + 'X-DevHelm-Cli-Os': detectOs(), + 'X-DevHelm-Cli-Install-Source': detectInstallSource(), + } +} diff --git a/test/lib/surface-telemetry.test.ts b/test/lib/surface-telemetry.test.ts new file mode 100644 index 0000000..41604e4 --- /dev/null +++ b/test/lib/surface-telemetry.test.ts @@ -0,0 +1,65 @@ +import {describe, it, expect, beforeEach, afterEach} from 'vitest' +import {buildSurfaceHeaders} from '../../src/lib/surface-telemetry.js' + +describe('surface telemetry', () => { + // Snapshot the env we touch so tests don't pollute one another or the + // surrounding shell. Each test owns its env state explicitly. + const SAVED_TELEMETRY = process.env.DEVHELM_TELEMETRY + const SAVED_INSTALL_SOURCE = process.env.DEVHELM_INSTALL_SOURCE + + beforeEach(() => { + delete process.env.DEVHELM_TELEMETRY + delete process.env.DEVHELM_INSTALL_SOURCE + }) + + afterEach(() => { + if (SAVED_TELEMETRY !== undefined) process.env.DEVHELM_TELEMETRY = SAVED_TELEMETRY + if (SAVED_INSTALL_SOURCE !== undefined) process.env.DEVHELM_INSTALL_SOURCE = SAVED_INSTALL_SOURCE + }) + + it('emits the canonical surface + version headers by default', () => { + const h = buildSurfaceHeaders() + expect(h['X-DevHelm-Surface']).toBe('cli') + // Version comes from package.json via createRequire; we just assert + // it's a non-empty string so the test isn't pinned to whatever is + // currently in package.json. + expect(h['X-DevHelm-Surface-Version']).toBeTruthy() + }) + + it('reports OS as platform-arch', () => { + const h = buildSurfaceHeaders() + expect(h['X-DevHelm-Cli-Os']).toBe(`${process.platform}-${process.arch}`) + }) + + it('honours DEVHELM_INSTALL_SOURCE override', () => { + process.env.DEVHELM_INSTALL_SOURCE = 'brew' + expect(buildSurfaceHeaders()['X-DevHelm-Cli-Install-Source']).toBe('brew') + + process.env.DEVHELM_INSTALL_SOURCE = 'docker' + expect(buildSurfaceHeaders()['X-DevHelm-Cli-Install-Source']).toBe('docker') + }) + + it('reports a sensible install source when no override is set', () => { + // Detection is heuristic-based; the exact value depends on where the + // test runner sits (npm vs ts-node vs vitest CLI). We only assert it's + // one of the closed-set tags the API knows how to bucket. + const source = buildSurfaceHeaders()['X-DevHelm-Cli-Install-Source'] + expect(['npm', 'brew', 'other']).toContain(source) + }) + + it('drops every surface header when DEVHELM_TELEMETRY=0', () => { + process.env.DEVHELM_TELEMETRY = '0' + expect(buildSurfaceHeaders()).toEqual({}) + }) + + it('still emits headers when DEVHELM_TELEMETRY is any other value (including "1")', () => { + // Opt-out semantics are strict: only "0" disables. Any other value (or + // unset) means telemetry on. This avoids accidentally interpreting + // "DEVHELM_TELEMETRY=on", "DEVHELM_TELEMETRY=true", etc. as opt-out. + for (const value of ['1', 'on', 'true', 'yes', '']) { + process.env.DEVHELM_TELEMETRY = value + const h = buildSurfaceHeaders() + expect(h['X-DevHelm-Surface']).toBe('cli') + } + }) +})