Skip to content

Commit dd1b2bf

Browse files
authored
Merge pull request #19 from devhelmhq/feat/surface-telemetry
feat(sdk): emit X-DevHelm-Surface telemetry headers
2 parents 95ce438 + 54ab977 commit dd1b2bf

4 files changed

Lines changed: 130 additions & 3 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "devhelm"
3-
version = "0.5.0"
3+
version = "0.6.0"
44
description = "DevHelm SDK for Python — typed client for monitors, incidents, alerting, and more"
55
authors = [{ name = "DevHelm", email = "hello@devhelm.io" }]
66
license = "MIT"

src/devhelm/_http.py

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
from __future__ import annotations
22

33
import os
4-
from dataclasses import dataclass
4+
from dataclasses import dataclass, field
5+
from importlib.metadata import PackageNotFoundError
6+
from importlib.metadata import version as _pkg_version
57
from typing import Any
68
from urllib.parse import quote
79

@@ -17,6 +19,26 @@
1719
DEFAULT_BASE_URL = "https://api.devhelm.io"
1820
DEFAULT_PAGE_SIZE = 200
1921

22+
# Default surface identifier sent on every authenticated request. Wrappers
23+
# (e.g. the MCP server) override this at construction time so the API can
24+
# attribute usage to the right devtool. See ``DevhelmConfig.surface``.
25+
DEFAULT_SURFACE = "sdk-py"
26+
27+
28+
def _sdk_version() -> str:
29+
"""Resolve the installed package version once at import time.
30+
31+
Uses ``importlib.metadata`` instead of hardcoding so a single source of
32+
truth (``pyproject.toml``) flows through to the wire telemetry header.
33+
Falls back to ``"unknown"`` for editable / source-tree installs that
34+
don't yet have a dist-info directory; the API treats that as "no version
35+
reported" rather than rejecting the request.
36+
"""
37+
try:
38+
return _pkg_version("devhelm")
39+
except PackageNotFoundError:
40+
return "unknown"
41+
2042

2143
@dataclass(frozen=True)
2244
class DevhelmConfig:
@@ -27,6 +49,19 @@ class DevhelmConfig:
2749
org_id: str | None = None
2850
workspace_id: str | None = None
2951
timeout: float = 30.0
52+
# Devtool surface identifier reported to the API for adoption and
53+
# version-distribution telemetry. Defaults to ``"sdk-py"``; wrappers
54+
# such as the MCP server pass ``"mcp"`` instead so their traffic is
55+
# attributed correctly. See https://devhelm.io/telemetry for the full
56+
# contract and opt-out semantics.
57+
surface: str = DEFAULT_SURFACE
58+
# Surface version. Defaults to the installed ``devhelm`` package
59+
# version; wrappers should pass their own package version.
60+
surface_version: str | None = None
61+
# Surface-specific metadata forwarded as ``X-DevHelm-*`` headers (e.g.
62+
# the MCP server attaches ``mcp_client``). Keys are normalised to
63+
# lower-kebab-case and mapped onto ``X-DevHelm-<key>`` on the wire.
64+
surface_metadata: dict[str, str] = field(default_factory=dict)
3065

3166

3267
def _resolve(value: str | None, env_key: str, label: str) -> str:
@@ -42,6 +77,30 @@ def _resolve_optional(value: str | None, env_key: str, default: str) -> str:
4277
return value or os.environ.get(env_key) or default
4378

4479

80+
def _telemetry_headers(config: DevhelmConfig) -> dict[str, str]:
81+
"""Build the ``X-DevHelm-Surface*`` headers for one client instance.
82+
83+
Returns an empty dict when ``DEVHELM_TELEMETRY=0`` so the API receives
84+
no surface signal at all. The opt-out is intentionally a single env var
85+
rather than a constructor flag — users opt out once for the whole
86+
process, not per call site. See https://devhelm.io/telemetry.
87+
"""
88+
if os.environ.get("DEVHELM_TELEMETRY", "").strip() == "0":
89+
return {}
90+
headers: dict[str, str] = {
91+
"X-DevHelm-Surface": config.surface,
92+
"X-DevHelm-Surface-Version": config.surface_version or _sdk_version(),
93+
# Always identify the underlying SDK so the API can distinguish
94+
# "raw SDK call" from "wrapper-on-top-of-SDK call" (the latter
95+
# overrides ``Surface`` to e.g. ``mcp`` but the SDK fingerprint
96+
# stays available for debugging client-version skew).
97+
"X-DevHelm-Sdk-Name": "sdk-py",
98+
}
99+
for key, value in config.surface_metadata.items():
100+
headers[f"X-DevHelm-{key}"] = value
101+
return headers
102+
103+
45104
def build_client(config: DevhelmConfig) -> httpx.Client:
46105
"""Create a configured httpx.Client with auth and tenant headers."""
47106
base_url = config.base_url.rstrip("/")
@@ -56,6 +115,7 @@ def build_client(config: DevhelmConfig) -> httpx.Client:
56115
"Content-Type": "application/json",
57116
"x-phelm-org-id": org_id,
58117
"x-phelm-workspace-id": workspace_id,
118+
**_telemetry_headers(config),
59119
},
60120
timeout=config.timeout,
61121
)

src/devhelm/client.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from __future__ import annotations
44

5-
from devhelm._http import DevhelmConfig, build_client
5+
from devhelm._http import DEFAULT_SURFACE, DevhelmConfig, build_client
66
from devhelm.resources.alert_channels import AlertChannels
77
from devhelm.resources.api_keys import ApiKeys
88
from devhelm.resources.dependencies import Dependencies
@@ -62,13 +62,24 @@ def __init__(
6262
org_id: str | None = None,
6363
workspace_id: str | None = None,
6464
timeout: float = 30.0,
65+
surface: str | None = None,
66+
surface_version: str | None = None,
67+
surface_metadata: dict[str, str] | None = None,
6568
) -> None:
69+
# ``surface`` / ``surface_version`` / ``surface_metadata`` are passthroughs
70+
# for wrappers (e.g. the MCP server) that want their traffic attributed
71+
# to a different devtool surface than the default ``sdk-py``. End users
72+
# of the SDK should leave these unset. See
73+
# https://devhelm.io/telemetry for the wire contract and opt-out.
6674
config = DevhelmConfig(
6775
token=token,
6876
base_url=base_url,
6977
org_id=org_id,
7078
workspace_id=workspace_id,
7179
timeout=timeout,
80+
surface=surface if surface is not None else DEFAULT_SURFACE,
81+
surface_version=surface_version,
82+
surface_metadata=surface_metadata if surface_metadata is not None else {},
7283
)
7384
client = build_client(config)
7485

tests/test_http.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,62 @@ def test_strips_trailing_slash(self) -> None:
6464
client.close()
6565

6666

67+
# ---------- Surface telemetry headers ----------
68+
69+
70+
class TestSurfaceTelemetry:
71+
"""The SDK reports its identity to the API on every authenticated request
72+
so the GTM rollup can attribute usage. See https://devhelm.io/telemetry."""
73+
74+
def test_default_headers_announce_sdk_py(
75+
self, monkeypatch: pytest.MonkeyPatch
76+
) -> None:
77+
monkeypatch.delenv("DEVHELM_TELEMETRY", raising=False)
78+
client = build_client(DevhelmConfig(token="t"))
79+
assert client.headers["x-devhelm-surface"] == "sdk-py"
80+
# version comes from importlib.metadata; its exact value is the
81+
# SDK release, but it must always be a non-empty string.
82+
assert client.headers["x-devhelm-surface-version"]
83+
assert client.headers["x-devhelm-sdk-name"] == "sdk-py"
84+
client.close()
85+
86+
def test_wrapper_can_override_surface(
87+
self, monkeypatch: pytest.MonkeyPatch
88+
) -> None:
89+
monkeypatch.delenv("DEVHELM_TELEMETRY", raising=False)
90+
client = build_client(
91+
DevhelmConfig(
92+
token="t",
93+
surface="mcp",
94+
surface_version="0.5.0",
95+
surface_metadata={"Mcp-Client": "cursor"},
96+
)
97+
)
98+
assert client.headers["x-devhelm-surface"] == "mcp"
99+
assert client.headers["x-devhelm-surface-version"] == "0.5.0"
100+
# SDK identity is preserved alongside the wrapper surface.
101+
assert client.headers["x-devhelm-sdk-name"] == "sdk-py"
102+
assert client.headers["x-devhelm-mcp-client"] == "cursor"
103+
client.close()
104+
105+
def test_env_opt_out_drops_all_surface_headers(
106+
self, monkeypatch: pytest.MonkeyPatch
107+
) -> None:
108+
monkeypatch.setenv("DEVHELM_TELEMETRY", "0")
109+
client = build_client(
110+
DevhelmConfig(token="t", surface="mcp", surface_metadata={"X": "y"})
111+
)
112+
# Surface, version, sdk-name, and any extras must all be absent.
113+
assert "x-devhelm-surface" not in client.headers
114+
assert "x-devhelm-surface-version" not in client.headers
115+
assert "x-devhelm-sdk-name" not in client.headers
116+
assert "x-devhelm-x" not in client.headers
117+
# Auth + tenant headers must still be there — opt-out is for
118+
# telemetry only, not for legitimate routing headers.
119+
assert client.headers["x-phelm-org-id"] == "1"
120+
client.close()
121+
122+
67123
# ---------- Pydantic validation helpers ----------
68124

69125

0 commit comments

Comments
 (0)