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/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 <hello@devhelm.io>",
"license": "MIT",
Expand Down
32 changes: 32 additions & 0 deletions src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof createClient<paths>>

/**
* 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<string, string> {
if ((process.env['DEVHELM_TELEMETRY'] ?? '').trim() === '0') {
return {}
}
const headers: Record<string, string> = {
'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'
Expand All @@ -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),
},
})
}
Expand Down
15 changes: 15 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-<key>` headers
* (e.g. an MCP wrapper might attach `{ "Mcp-Client": "cursor" }`).
*/
surfaceMetadata?: Record<string, string>
}
16 changes: 16 additions & 0 deletions src/version.ts
Original file line number Diff line number Diff line change
@@ -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'
71 changes: 71 additions & 0 deletions test/http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Headers> {
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<unknown>}).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']
}
})
})
Loading