From bdfd22cca9b8f037218b4a529f663279aeb1b3e1 Mon Sep 17 00:00:00 2001 From: caballeto Date: Fri, 1 May 2026 18:21:22 +0200 Subject: [PATCH] feat(sdk): emit X-DevHelm-Surface telemetry headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds optional surface identification to every authenticated API call so the API can attribute usage to the right devtool. Wire contract docs at https://devhelm.io/telemetry; matching API-side handler in mono#332. Headers emitted by default (every request): - X-DevHelm-Surface: sdk-js - X-DevHelm-Surface-Version: package.json version (resolved at module load via createRequire so the constant tracks pkg without a release-script edit) - X-DevHelm-Sdk-Name: sdk-js Wrappers can override `surface` / `surfaceVersion` / `surfaceMetadata` at construction time so their traffic is attributed correctly: new Devhelm({ token: ..., surface: 'mcp', surfaceVersion: '0.5.0', surfaceMetadata: { 'Mcp-Client': 'cursor' }, }) The SDK identity (X-DevHelm-Sdk-Name) is preserved alongside the wrapper surface so the API can still distinguish wrapper-on-top-of-v0.5-SDK from wrapper-on-top-of-v0.6-SDK when debugging client-version skew. Opt-out is a single env var, not per-call: DEVHELM_TELEMETRY=0 drops every X-DevHelm-Surface* header at the client level. Auth + tenant headers are unaffected. Zero callsite changes — the surface is set once when the Devhelm client is constructed; every existing tool/test using `new Devhelm(...)` keeps working unchanged. Tests: 3 new tests in http.test.ts using a stub global fetch to assert the outbound Headers, covering defaults, wrapper override, and env opt-out. Full suite (1306) green; eslint + tsc clean. Bumped to 0.6.0 (additive feature; matches sdk-python release cadence). Co-authored-by: Cursor --- package.json | 2 +- src/http.ts | 32 +++++++++++++++++++++ src/types.ts | 15 ++++++++++ src/version.ts | 16 +++++++++++ test/http.test.ts | 71 +++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 src/version.ts diff --git a/package.json b/package.json index 0bd8d73..408ab39 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@devhelm/sdk", - "version": "0.5.0", + "version": "0.6.0", "description": "DevHelm SDK for TypeScript and JavaScript — typed client for monitors, incidents, alerting, and more", "author": "DevHelm ", "license": "MIT", diff --git a/src/http.ts b/src/http.ts index 2b68423..3726ee8 100644 --- a/src/http.ts +++ b/src/http.ts @@ -4,12 +4,43 @@ import type {paths} from './generated/api.js' import type {DevhelmConfig, Page, CursorPage} from './types.js' import {DevhelmTransportError, errorFromResponse} from './errors.js' import {parseSingle, parsePage, parseCursorPage} from './validation.js' +import {SDK_VERSION} from './version.js' const DEFAULT_BASE_URL = 'https://api.devhelm.io' const DEFAULT_PAGE_SIZE = 200 +const DEFAULT_SURFACE = 'sdk-js' export type ApiClient = ReturnType> +/** + * Build the X-DevHelm-Surface* telemetry headers for one client instance. + * + * Returns an empty object when DEVHELM_TELEMETRY=0 so the API receives no + * surface signal at all. Opt-out is intentionally a single env var rather + * than a constructor flag — users opt out once for the whole process, not + * per call site. See https://devhelm.io/telemetry. + */ +function buildTelemetryHeaders(config: DevhelmConfig): Record { + if ((process.env['DEVHELM_TELEMETRY'] ?? '').trim() === '0') { + return {} + } + const headers: Record = { + 'X-DevHelm-Surface': config.surface ?? DEFAULT_SURFACE, + 'X-DevHelm-Surface-Version': config.surfaceVersion ?? SDK_VERSION, + // Always identify the underlying SDK so the API can distinguish a raw + // SDK call from a wrapper-on-top-of-SDK call (the latter overrides + // Surface but the SDK fingerprint stays available for client-version + // skew debugging). + 'X-DevHelm-Sdk-Name': DEFAULT_SURFACE, + } + if (config.surfaceMetadata) { + for (const [key, value] of Object.entries(config.surfaceMetadata)) { + headers[`X-DevHelm-${key}`] = value + } + } + return headers +} + export function buildClient(config: DevhelmConfig): ApiClient { const baseUrl = (config.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, '') const orgId = config.orgId ?? process.env['DEVHELM_ORG_ID'] ?? '1' @@ -22,6 +53,7 @@ export function buildClient(config: DevhelmConfig): ApiClient { 'Content-Type': 'application/json', 'x-phelm-org-id': orgId, 'x-phelm-workspace-id': workspaceId, + ...buildTelemetryHeaders(config), }, }) } diff --git a/src/types.ts b/src/types.ts index cf0ef7c..e2946a6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -122,4 +122,19 @@ export interface DevhelmConfig { orgId?: string /** Workspace ID header. Defaults to env DEVHELM_WORKSPACE_ID or "1". */ workspaceId?: string + /** + * Devtool surface identifier reported to the API for adoption / version + * telemetry. Defaults to `"sdk-js"`. Wrappers (a CLI, an MCP server, a + * custom SDK build) can override this so their traffic is attributed + * correctly. End users of the SDK should leave it unset. + * See https://devhelm.io/telemetry for the wire contract and opt-out. + */ + surface?: string + /** Surface version. Defaults to the installed `@devhelm/sdk` package version. */ + surfaceVersion?: string + /** + * Surface-specific metadata forwarded as `X-DevHelm-` headers + * (e.g. an MCP wrapper might attach `{ "Mcp-Client": "cursor" }`). + */ + surfaceMetadata?: Record } diff --git a/src/version.ts b/src/version.ts new file mode 100644 index 0000000..7731a80 --- /dev/null +++ b/src/version.ts @@ -0,0 +1,16 @@ +import {createRequire} from 'node:module' + +// Resolve the @devhelm/sdk version from package.json once at module load. +// createRequire(import.meta.url) gives us a CommonJS-style require anchored +// at this file's URL so the relative path stays valid both in src/ (during +// vitest runs) and in dist/ (after tsc emits compiled output) — package.json +// sits one level above either location. +// +// Why not import package.json with assertions: `with { type: 'json' }` is +// only stable on Node ≥ 20.10 and downstream bundlers (esbuild, Bun) +// handle it inconsistently. createRequire is universally supported on every +// Node version this SDK targets and adds zero build-time machinery. +const require = createRequire(import.meta.url) +const pkg = require('../package.json') as {version?: string} + +export const SDK_VERSION: string = pkg.version ?? 'unknown' diff --git a/test/http.test.ts b/test/http.test.ts index 606f5c9..500245f 100644 --- a/test/http.test.ts +++ b/test/http.test.ts @@ -116,3 +116,74 @@ describe('buildClient configuration', () => { expect(typeof client.POST).toBe('function') }) }) + +describe('surface telemetry headers', () => { + // openapi-fetch ultimately delegates to globalThis.fetch, so the cleanest + // way to assert outbound headers is to stub fetch, fire one request, and + // inspect what the SDK handed to the network layer. The server response + // is irrelevant — we just need the request to be issued. + async function captureRequest( + config: import('../src/types.js').DevhelmConfig, + ): Promise { + const original = globalThis.fetch + let captured: Headers | undefined + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = input instanceof Request ? input : new Request(input, init) + captured = req.headers + return new Response('{}', {status: 200, headers: {'Content-Type': 'application/json'}}) + } + try { + const {buildClient} = await import('../src/http.js') + const client = buildClient({...config, baseUrl: 'http://localhost:0'}) + // openapi-fetch types path strings as path literals; cast via unknown is + // local to this test and matches the same boundary used in src/http.ts. + await (client as unknown as {GET: (p: string) => Promise}).GET('/api/v1/_') + } finally { + globalThis.fetch = original + } + if (!captured) throw new Error('fetch was never called') + return captured + } + + it('emits sdk-js surface headers by default', async () => { + delete process.env['DEVHELM_TELEMETRY'] + const headers = await captureRequest({token: 't'}) + expect(headers.get('x-devhelm-surface')).toBe('sdk-js') + expect(headers.get('x-devhelm-sdk-name')).toBe('sdk-js') + expect(headers.get('x-devhelm-surface-version')).toBeTruthy() + }) + + it('lets a wrapper override surface but keeps sdk-name', async () => { + delete process.env['DEVHELM_TELEMETRY'] + const headers = await captureRequest({ + token: 't', + surface: 'mcp', + surfaceVersion: '0.5.0', + surfaceMetadata: {'Mcp-Client': 'cursor'}, + }) + expect(headers.get('x-devhelm-surface')).toBe('mcp') + expect(headers.get('x-devhelm-surface-version')).toBe('0.5.0') + expect(headers.get('x-devhelm-sdk-name')).toBe('sdk-js') + expect(headers.get('x-devhelm-mcp-client')).toBe('cursor') + }) + + it('drops every surface header when DEVHELM_TELEMETRY=0', async () => { + process.env['DEVHELM_TELEMETRY'] = '0' + try { + const headers = await captureRequest({ + token: 't', + surface: 'mcp', + surfaceMetadata: {'X': 'y'}, + }) + expect(headers.get('x-devhelm-surface')).toBeNull() + expect(headers.get('x-devhelm-surface-version')).toBeNull() + expect(headers.get('x-devhelm-sdk-name')).toBeNull() + expect(headers.get('x-devhelm-x')).toBeNull() + // Auth + tenant must still ride along. + expect(headers.get('authorization')).toBe('Bearer t') + expect(headers.get('x-phelm-org-id')).toBe('1') + } finally { + delete process.env['DEVHELM_TELEMETRY'] + } + }) +})