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 pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
62 changes: 61 additions & 1 deletion src/devhelm/_http.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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:
Expand All @@ -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-<key>`` on the wire.
surface_metadata: dict[str, str] = field(default_factory=dict)


def _resolve(value: str | None, env_key: str, label: str) -> str:
Expand All @@ -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("/")
Expand All @@ -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,
)
Expand Down
13 changes: 12 additions & 1 deletion src/devhelm/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
56 changes: 56 additions & 0 deletions tests/test_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ----------


Expand Down
Loading