diff --git a/pyproject.toml b/pyproject.toml index ddc15c7..0dea9b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "devhelm" -version = "0.5.0" +version = "0.6.0" description = "DevHelm SDK for Python — typed client for monitors, incidents, alerting, and more" authors = [{ name = "DevHelm", email = "hello@devhelm.io" }] license = "MIT" diff --git a/src/devhelm/_http.py b/src/devhelm/_http.py index d4d20a2..9d08a99 100644 --- a/src/devhelm/_http.py +++ b/src/devhelm/_http.py @@ -1,7 +1,9 @@ from __future__ import annotations import os -from dataclasses import dataclass +from dataclasses import dataclass, field +from importlib.metadata import PackageNotFoundError +from importlib.metadata import version as _pkg_version from typing import Any from urllib.parse import quote @@ -17,6 +19,26 @@ DEFAULT_BASE_URL = "https://api.devhelm.io" DEFAULT_PAGE_SIZE = 200 +# Default surface identifier sent on every authenticated request. Wrappers +# (e.g. the MCP server) override this at construction time so the API can +# attribute usage to the right devtool. See ``DevhelmConfig.surface``. +DEFAULT_SURFACE = "sdk-py" + + +def _sdk_version() -> str: + """Resolve the installed package version once at import time. + + Uses ``importlib.metadata`` instead of hardcoding so a single source of + truth (``pyproject.toml``) flows through to the wire telemetry header. + Falls back to ``"unknown"`` for editable / source-tree installs that + don't yet have a dist-info directory; the API treats that as "no version + reported" rather than rejecting the request. + """ + try: + return _pkg_version("devhelm") + except PackageNotFoundError: + return "unknown" + @dataclass(frozen=True) class DevhelmConfig: @@ -27,6 +49,19 @@ class DevhelmConfig: org_id: str | None = None workspace_id: str | None = None timeout: float = 30.0 + # Devtool surface identifier reported to the API for adoption and + # version-distribution telemetry. Defaults to ``"sdk-py"``; wrappers + # such as the MCP server pass ``"mcp"`` instead so their traffic is + # attributed correctly. See https://devhelm.io/telemetry for the full + # contract and opt-out semantics. + surface: str = DEFAULT_SURFACE + # Surface version. Defaults to the installed ``devhelm`` package + # version; wrappers should pass their own package version. + surface_version: str | None = None + # Surface-specific metadata forwarded as ``X-DevHelm-*`` headers (e.g. + # the MCP server attaches ``mcp_client``). Keys are normalised to + # lower-kebab-case and mapped onto ``X-DevHelm-`` on the wire. + surface_metadata: dict[str, str] = field(default_factory=dict) 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: return value or os.environ.get(env_key) or default +def _telemetry_headers(config: DevhelmConfig) -> dict[str, str]: + """Build the ``X-DevHelm-Surface*`` headers for one client instance. + + Returns an empty dict 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 constructor flag — users opt out once for the whole + process, not per call site. See https://devhelm.io/telemetry. + """ + if os.environ.get("DEVHELM_TELEMETRY", "").strip() == "0": + return {} + headers: dict[str, str] = { + "X-DevHelm-Surface": config.surface, + "X-DevHelm-Surface-Version": config.surface_version or _sdk_version(), + # Always identify the underlying SDK so the API can distinguish + # "raw SDK call" from "wrapper-on-top-of-SDK call" (the latter + # overrides ``Surface`` to e.g. ``mcp`` but the SDK fingerprint + # stays available for debugging client-version skew). + "X-DevHelm-Sdk-Name": "sdk-py", + } + for key, value in config.surface_metadata.items(): + headers[f"X-DevHelm-{key}"] = value + return headers + + def build_client(config: DevhelmConfig) -> httpx.Client: """Create a configured httpx.Client with auth and tenant headers.""" base_url = config.base_url.rstrip("/") @@ -56,6 +115,7 @@ def build_client(config: DevhelmConfig) -> httpx.Client: "Content-Type": "application/json", "x-phelm-org-id": org_id, "x-phelm-workspace-id": workspace_id, + **_telemetry_headers(config), }, timeout=config.timeout, ) diff --git a/src/devhelm/client.py b/src/devhelm/client.py index 75bb44b..2c3f9a0 100644 --- a/src/devhelm/client.py +++ b/src/devhelm/client.py @@ -2,7 +2,7 @@ from __future__ import annotations -from devhelm._http import DevhelmConfig, build_client +from devhelm._http import DEFAULT_SURFACE, DevhelmConfig, build_client from devhelm.resources.alert_channels import AlertChannels from devhelm.resources.api_keys import ApiKeys from devhelm.resources.dependencies import Dependencies @@ -62,13 +62,24 @@ def __init__( org_id: str | None = None, workspace_id: str | None = None, timeout: float = 30.0, + surface: str | None = None, + surface_version: str | None = None, + surface_metadata: dict[str, str] | None = None, ) -> None: + # ``surface`` / ``surface_version`` / ``surface_metadata`` are passthroughs + # for wrappers (e.g. the MCP server) that want their traffic attributed + # to a different devtool surface than the default ``sdk-py``. End users + # of the SDK should leave these unset. See + # https://devhelm.io/telemetry for the wire contract and opt-out. config = DevhelmConfig( token=token, base_url=base_url, org_id=org_id, workspace_id=workspace_id, timeout=timeout, + surface=surface if surface is not None else DEFAULT_SURFACE, + surface_version=surface_version, + surface_metadata=surface_metadata if surface_metadata is not None else {}, ) client = build_client(config) diff --git a/tests/test_http.py b/tests/test_http.py index 0652d6a..8e95560 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -64,6 +64,62 @@ def test_strips_trailing_slash(self) -> None: client.close() +# ---------- Surface telemetry headers ---------- + + +class TestSurfaceTelemetry: + """The SDK reports its identity to the API on every authenticated request + so the GTM rollup can attribute usage. See https://devhelm.io/telemetry.""" + + def test_default_headers_announce_sdk_py( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.delenv("DEVHELM_TELEMETRY", raising=False) + client = build_client(DevhelmConfig(token="t")) + assert client.headers["x-devhelm-surface"] == "sdk-py" + # version comes from importlib.metadata; its exact value is the + # SDK release, but it must always be a non-empty string. + assert client.headers["x-devhelm-surface-version"] + assert client.headers["x-devhelm-sdk-name"] == "sdk-py" + client.close() + + def test_wrapper_can_override_surface( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.delenv("DEVHELM_TELEMETRY", raising=False) + client = build_client( + DevhelmConfig( + token="t", + surface="mcp", + surface_version="0.5.0", + surface_metadata={"Mcp-Client": "cursor"}, + ) + ) + assert client.headers["x-devhelm-surface"] == "mcp" + assert client.headers["x-devhelm-surface-version"] == "0.5.0" + # SDK identity is preserved alongside the wrapper surface. + assert client.headers["x-devhelm-sdk-name"] == "sdk-py" + assert client.headers["x-devhelm-mcp-client"] == "cursor" + client.close() + + def test_env_opt_out_drops_all_surface_headers( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("DEVHELM_TELEMETRY", "0") + client = build_client( + DevhelmConfig(token="t", surface="mcp", surface_metadata={"X": "y"}) + ) + # Surface, version, sdk-name, and any extras must all be absent. + assert "x-devhelm-surface" not in client.headers + assert "x-devhelm-surface-version" not in client.headers + assert "x-devhelm-sdk-name" not in client.headers + assert "x-devhelm-x" not in client.headers + # Auth + tenant headers must still be there — opt-out is for + # telemetry only, not for legitimate routing headers. + assert client.headers["x-phelm-org-id"] == "1" + client.close() + + # ---------- Pydantic validation helpers ----------