Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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 <hello@devhelm.io>",
"license": "MIT",
Expand Down
5 changes: 5 additions & 0 deletions src/lib/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}

Expand Down Expand Up @@ -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(),
},
})

Expand Down
98 changes: 98 additions & 0 deletions src/lib/surface-telemetry.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> {
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(),
}
}
65 changes: 65 additions & 0 deletions test/lib/surface-telemetry.test.ts
Original file line number Diff line number Diff line change
@@ -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')
}
})
})
Loading