11from __future__ import annotations
22
33import 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
57from typing import Any
68from urllib .parse import quote
79
1719DEFAULT_BASE_URL = "https://api.devhelm.io"
1820DEFAULT_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 )
2244class 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
3267def _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+
45104def 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 )
0 commit comments