From 62a943578cec04263dc242414a1c3ce83730a9d6 Mon Sep 17 00:00:00 2001 From: Snehil Kishore Date: Wed, 22 Apr 2026 00:39:22 +0530 Subject: [PATCH 1/4] feat: add Auth0-Client telemetry header to all HTTP requests Send an Auth0-Client header (base64-encoded JSON with SDK name, version, and Python runtime) on every request to Auth0 endpoints. This follows the standard Auth0 SDK telemetry convention. - Add Telemetry class in telemetry.py for building and caching headers - Add _get_http_client() helper to ServerClient, MyAccountClient, and MfaClient to inject telemetry headers into all httpx requests - Pass telemetry headers to AsyncOAuth2Client for token exchange calls - Add unit tests for telemetry header format and integration --- .../auth_server/mfa_client.py | 35 +++-- .../auth_server/my_account_client.py | 32 +++-- .../auth_server/server_client.py | 34 +++-- src/auth0_server_python/telemetry.py | 50 +++++++ .../tests/test_telemetry.py | 124 ++++++++++++++++++ 5 files changed, 239 insertions(+), 36 deletions(-) create mode 100644 src/auth0_server_python/telemetry.py create mode 100644 src/auth0_server_python/tests/test_telemetry.py diff --git a/src/auth0_server_python/auth_server/mfa_client.py b/src/auth0_server_python/auth_server/mfa_client.py index 7940c38..4997654 100644 --- a/src/auth0_server_python/auth_server/mfa_client.py +++ b/src/auth0_server_python/auth_server/mfa_client.py @@ -3,8 +3,10 @@ Handles Multi-Factor Authentication operations against the Auth0 MFA API. """ +from __future__ import annotations + import time -from typing import Any, Callable, Optional, Union +from typing import Any, Callable import httpx @@ -54,12 +56,13 @@ class MfaClient: def __init__( self, - domain: Union[str, Callable, None], + domain: str | Callable | None, client_id: str, client_secret: str, secret: str, state_store=None, - state_identifier: str = "_a0_session" + state_identifier: str = "_a0_session", + headers: dict[str, str] | None = None ): if callable(domain): self._domain = None @@ -72,10 +75,16 @@ def __init__( self._secret = secret self._state_store = state_store self._state_identifier = state_identifier + self._headers = headers or {} + + def _get_http_client(self, **kwargs) -> httpx.AsyncClient: + """Return an httpx.AsyncClient with default headers injected.""" + headers = {**self._headers, **kwargs.pop("headers", {})} + return httpx.AsyncClient(headers=headers, **kwargs) async def _resolve_base_url( self, - store_options: Optional[dict[str, Any]] = None + store_options: dict[str, Any] | None = None ) -> str: """Resolve domain and return base URL for API calls.""" if self._domain_resolver: @@ -137,7 +146,7 @@ def decrypt_mfa_token(self, encrypted_token: str) -> MfaTokenContext: async def list_authenticators( self, options: dict[str, Any], - store_options: Optional[dict[str, Any]] = None + store_options: dict[str, Any] | None = None ) -> list[AuthenticatorResponse]: """ Lists all MFA authenticators enrolled by the user. @@ -157,7 +166,7 @@ async def list_authenticators( url = f"{base_url}/mfa/authenticators" try: - async with httpx.AsyncClient() as client: + async with self._get_http_client() as client: response = await client.get( url, auth=BearerAuth(mfa_token) @@ -183,7 +192,7 @@ async def list_authenticators( async def enroll_authenticator( self, options: dict[str, Any], - store_options: Optional[dict[str, Any]] = None + store_options: dict[str, Any] | None = None ) -> EnrollmentResponse: """ Enrolls a new MFA authenticator for the user. @@ -232,7 +241,7 @@ async def enroll_authenticator( body["email"] = options["email"] try: - async with httpx.AsyncClient() as client: + async with self._get_http_client() as client: response = await client.post( url, json=body, @@ -269,7 +278,7 @@ async def enroll_authenticator( async def challenge_authenticator( self, options: dict[str, Any], - store_options: Optional[dict[str, Any]] = None + store_options: dict[str, Any] | None = None ) -> ChallengeResponse: """ Initiates an MFA challenge for user verification. @@ -311,7 +320,7 @@ async def challenge_authenticator( body["authenticator_id"] = options["authenticator_id"] try: - async with httpx.AsyncClient() as client: + async with self._get_http_client() as client: response = await client.post( url, json=body, @@ -338,7 +347,7 @@ async def challenge_authenticator( async def verify( self, options: dict[str, Any], - store_options: Optional[dict[str, Any]] = None + store_options: dict[str, Any] | None = None ) -> MfaVerifyResponse: """ Verifies an MFA code and completes authentication. @@ -395,7 +404,7 @@ async def verify( base_url = await self._resolve_base_url(store_options) token_endpoint = f"{base_url}/oauth/token" - async with httpx.AsyncClient() as client: + async with self._get_http_client() as client: response = await client.post( token_endpoint, data=body, @@ -449,7 +458,7 @@ async def _persist_mfa_tokens( self, verify_response: MfaVerifyResponse, options: dict[str, Any], - store_options: Optional[dict[str, Any]] = None + store_options: dict[str, Any] | None = None ) -> None: """ Persist MFA verification tokens to the state store. diff --git a/src/auth0_server_python/auth_server/my_account_client.py b/src/auth0_server_python/auth_server/my_account_client.py index 330e837..ed3e851 100644 --- a/src/auth0_server_python/auth_server/my_account_client.py +++ b/src/auth0_server_python/auth_server/my_account_client.py @@ -1,5 +1,4 @@ - -from typing import Optional +from __future__ import annotations import httpx @@ -25,14 +24,21 @@ class MyAccountClient: Client for interacting with the Auth0 MyAccount API. """ - def __init__(self, domain: str): + def __init__(self, domain: str, headers: dict[str, str] | None = None): """ Initialize the MyAccount API client. Args: domain: Auth0 domain (e.g., '..auth0.com') + headers: Optional default headers to include on every request """ self._domain = domain + self._headers = headers or {} + + def _get_http_client(self, **kwargs) -> httpx.AsyncClient: + """Return an httpx.AsyncClient with default headers injected.""" + headers = {**self._headers, **kwargs.pop("headers", {})} + return httpx.AsyncClient(headers=headers, **kwargs) @property def audience(self): @@ -64,7 +70,7 @@ async def connect_account( ApiError: If the request fails due to network or other issues """ try: - async with httpx.AsyncClient() as client: + async with self._get_http_client() as client: response = await client.post( url=f"{self.audience}v1/connected-accounts/connect", json=request.model_dump(exclude_none=True), @@ -114,7 +120,7 @@ async def complete_connect_account( ApiError: If the request fails due to network or other issues """ try: - async with httpx.AsyncClient() as client: + async with self._get_http_client() as client: response = await client.post( url=f"{self.audience}v1/connected-accounts/complete", json=request.model_dump(exclude_none=True), @@ -147,9 +153,9 @@ async def complete_connect_account( async def list_connected_accounts( self, access_token: str, - connection: Optional[str] = None, - from_param: Optional[str] = None, - take: Optional[int] = None + connection: str | None = None, + from_param: str | None = None, + take: int | None = None ) -> ListConnectedAccountsResponse: """ List connected accounts for the authenticated user. @@ -176,7 +182,7 @@ async def list_connected_accounts( raise InvalidArgumentError("take", "The 'take' parameter must be a positive integer.") try: - async with httpx.AsyncClient() as client: + async with self._get_http_client() as client: params = {} if connection: params["connection"] = connection @@ -243,7 +249,7 @@ async def delete_connected_account( raise MissingRequiredArgumentError("connected_account_id") try: - async with httpx.AsyncClient() as client: + async with self._get_http_client() as client: response = await client.delete( url=f"{self.audience}v1/connected-accounts/accounts/{connected_account_id}", auth=BearerAuth(access_token) @@ -271,8 +277,8 @@ async def delete_connected_account( async def list_connected_account_connections( self, access_token: str, - from_param: Optional[str] = None, - take: Optional[int] = None + from_param: str | None = None, + take: int | None = None ) -> ListConnectedAccountConnectionsResponse: """ List available connections that support connected accounts. @@ -298,7 +304,7 @@ async def list_connected_account_connections( raise InvalidArgumentError("take", "The 'take' parameter must be a positive integer.") try: - async with httpx.AsyncClient() as client: + async with self._get_http_client() as client: params = {} if from_param: params["from"] = from_param diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index 9222182..9bc8325 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -57,6 +57,7 @@ PollingApiError, StartLinkUserError, ) +from auth0_server_python.telemetry import Telemetry from auth0_server_python.utils import PKCE, URL, State from auth0_server_python.utils.helpers import ( build_domain_resolver_context, @@ -152,13 +153,20 @@ def __init__( self._transaction_identifier = transaction_identifier self._state_identifier = state_identifier + # Initialize telemetry + self._telemetry = Telemetry.default() + self._telemetry_headers = self._telemetry.get_headers() + # Initialize OAuth client self._oauth = AsyncOAuth2Client( client_id=client_id, client_secret=client_secret, + headers=self._telemetry_headers, ) - self._my_account_client = MyAccountClient(domain=domain) + self._my_account_client = MyAccountClient( + domain=domain, headers=self._telemetry_headers + ) # Unified cache for OIDC metadata and JWKS per domain (LRU eviction + TTL) self._discovery_cache: OrderedDict[str, dict] = OrderedDict() @@ -172,9 +180,15 @@ def __init__( client_secret=self._client_secret, secret=self._secret, state_store=self._state_store, - state_identifier=self._state_identifier + state_identifier=self._state_identifier, + headers=self._telemetry_headers, ) + def _get_http_client(self, **kwargs) -> httpx.AsyncClient: + """Return an httpx.AsyncClient with telemetry headers injected.""" + headers = {**self._telemetry_headers, **kwargs.pop("headers", {})} + return httpx.AsyncClient(headers=headers, **kwargs) + def _normalize_url(self, value: str) -> str: """ Normalize a URL-like value (domain or issuer) for comparison. @@ -281,7 +295,7 @@ async def _fetch_oidc_metadata(self, domain: str) -> dict: """Fetch OIDC metadata from domain.""" normalized_domain = self._normalize_url(domain) metadata_url = f"{normalized_domain}/.well-known/openid-configuration" - async with httpx.AsyncClient() as client: + async with self._get_http_client() as client: response = await client.get(metadata_url) response.raise_for_status() return response.json() @@ -352,7 +366,7 @@ async def _fetch_jwks(self, jwks_uri: str) -> dict: ApiError: If JWKS fetch fails """ try: - async with httpx.AsyncClient() as client: + async with self._get_http_client() as client: response = await client.get(jwks_uri) response.raise_for_status() return response.json() @@ -516,7 +530,7 @@ async def start_interactive_login( auth_params["client_id"] = self._client_id # Post the auth_params to the PAR endpoint - async with httpx.AsyncClient() as client: + async with self._get_http_client() as client: par_response = await client.post( par_endpoint, data=auth_params, @@ -1077,7 +1091,7 @@ async def get_token_by_refresh_token(self, options: dict[str, Any]) -> dict[str, token_params["scope"] = merged_scope # Exchange the refresh token for an access token - async with httpx.AsyncClient() as client: + async with self._get_http_client() as client: response = await client.post( token_endpoint, data=token_params, @@ -1391,7 +1405,7 @@ async def initiate_backchannel_authentication( params.update(authorization_params) # Make the backchannel authentication request - async with httpx.AsyncClient() as client: + async with self._get_http_client() as client: backchannel_response = await client.post( backchannel_endpoint, data=params, @@ -1466,7 +1480,7 @@ async def backchannel_authentication_grant( } # Exchange the auth_req_id for an access token - async with httpx.AsyncClient() as client: + async with self._get_http_client() as client: response = await client.post( token_endpoint, data=token_params, @@ -1918,7 +1932,7 @@ async def get_token_for_connection(self, options: dict[str, Any]) -> dict[str, A params["login_hint"] = options["login_hint"] # Make the request - async with httpx.AsyncClient() as client: + async with self._get_http_client() as client: response = await client.post( token_endpoint, data=params, @@ -2272,7 +2286,7 @@ async def custom_token_exchange( params[key] = value # Make the token exchange request - async with httpx.AsyncClient() as client: + async with self._get_http_client() as client: response = await client.post( token_endpoint, data=params, diff --git a/src/auth0_server_python/telemetry.py b/src/auth0_server_python/telemetry.py new file mode 100644 index 0000000..b03eafe --- /dev/null +++ b/src/auth0_server_python/telemetry.py @@ -0,0 +1,50 @@ +""" +Telemetry support for auth0-server-python SDK. + +Builds and caches the Auth0-Client and User-Agent headers sent +on every HTTP request to Auth0 endpoints. +""" + +from __future__ import annotations + +import base64 +import importlib.metadata +import json +import platform + + +class Telemetry: + """Builds and caches telemetry headers for Auth0 HTTP requests.""" + + _PACKAGE_NAME = "auth0-server-python" + + def __init__(self, name: str, version: str, env: dict[str, str] | None = None): + self.name = name + self.version = version + self.env = env if env is not None else {"python": platform.python_version()} + self._cached_headers: dict[str, str] | None = None + + def get_headers(self) -> dict[str, str]: + """Return the telemetry headers, building and caching on first call.""" + if self._cached_headers is None: + payload = { + "name": self.name, + "version": self.version, + "env": self.env, + } + self._cached_headers = { + "Auth0-Client": base64.b64encode( + json.dumps(payload).encode("utf-8") + ).decode("utf-8"), + "User-Agent": f"Python/{platform.python_version()}", + } + return self._cached_headers + + @staticmethod + def default() -> Telemetry: + """Create a Telemetry instance with this SDK's package metadata.""" + try: + version = importlib.metadata.version(Telemetry._PACKAGE_NAME) + except Exception: + version = "unknown" + return Telemetry(name=Telemetry._PACKAGE_NAME, version=version) diff --git a/src/auth0_server_python/tests/test_telemetry.py b/src/auth0_server_python/tests/test_telemetry.py new file mode 100644 index 0000000..e3b1c5e --- /dev/null +++ b/src/auth0_server_python/tests/test_telemetry.py @@ -0,0 +1,124 @@ +import base64 +import json +import platform +from unittest.mock import AsyncMock, patch + +import pytest + +from auth0_server_python.auth_server.server_client import ServerClient +from auth0_server_python.telemetry import Telemetry + + +class TestTelemetry: + """Tests for the Telemetry class.""" + + def test_get_headers_contains_expected_keys(self): + telemetry = Telemetry(name="test-sdk", version="1.0.0") + headers = telemetry.get_headers() + assert "Auth0-Client" in headers + assert "User-Agent" in headers + + def test_auth0_client_header_format(self): + telemetry = Telemetry( + name="auth0-server-python", + version="1.0.0b9", + env={"python": "3.10.16"}, + ) + headers = telemetry.get_headers() + decoded = json.loads(base64.b64decode(headers["Auth0-Client"])) + assert decoded == { + "name": "auth0-server-python", + "version": "1.0.0b9", + "env": {"python": "3.10.16"}, + } + + def test_user_agent_header(self): + telemetry = Telemetry(name="test-sdk", version="1.0.0") + headers = telemetry.get_headers() + assert headers["User-Agent"] == f"Python/{platform.python_version()}" + + def test_headers_are_cached(self): + telemetry = Telemetry(name="test-sdk", version="1.0.0") + first = telemetry.get_headers() + second = telemetry.get_headers() + assert first is second + + def test_default_env_uses_python_version(self): + telemetry = Telemetry(name="test-sdk", version="1.0.0") + assert telemetry.env == {"python": platform.python_version()} + + def test_custom_env_override(self): + telemetry = Telemetry( + name="test-sdk", version="1.0.0", env={"python": "3.9.0", "framework": "fastapi"} + ) + headers = telemetry.get_headers() + decoded = json.loads(base64.b64decode(headers["Auth0-Client"])) + assert decoded["env"] == {"python": "3.9.0", "framework": "fastapi"} + + def test_default_factory(self): + telemetry = Telemetry.default() + assert telemetry.name == "auth0-server-python" + assert telemetry.version != "" + assert "python" in telemetry.env + + @patch( + "auth0_server_python.telemetry.importlib.metadata.version", + side_effect=Exception("not installed"), + ) + def test_default_factory_unknown_version_on_error(self, _mock): + telemetry = Telemetry.default() + assert telemetry.version == "unknown" + + +class TestServerClientTelemetry: + """Tests that ServerClient injects telemetry headers into HTTP requests.""" + + def _make_client(self): + return ServerClient( + domain="auth0.local", + client_id="client_id", + client_secret="client_secret", + secret="test-secret", + state_store=AsyncMock(), + transaction_store=AsyncMock(), + ) + + def test_server_client_has_telemetry_headers(self): + client = self._make_client() + assert client._telemetry_headers is not None + assert "Auth0-Client" in client._telemetry_headers + assert "User-Agent" in client._telemetry_headers + + def test_server_client_telemetry_payload_structure(self): + client = self._make_client() + decoded = json.loads(base64.b64decode(client._telemetry_headers["Auth0-Client"])) + assert decoded["name"] == "auth0-server-python" + assert "version" in decoded + assert "python" in decoded["env"] + + def test_get_http_client_includes_telemetry_headers(self): + client = self._make_client() + http_client = client._get_http_client() + for key, value in client._telemetry_headers.items(): + assert http_client.headers.get(key) == value + + def test_my_account_client_receives_telemetry_headers(self): + client = self._make_client() + assert client._my_account_client._headers == client._telemetry_headers + + @pytest.mark.asyncio + async def test_fetch_oidc_metadata_sends_telemetry(self, mocker): + client = self._make_client() + mock_response = AsyncMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"issuer": "https://auth0.local/"} + mock_response.raise_for_status = AsyncMock() + + mock_http_client = AsyncMock() + mock_http_client.get.return_value = mock_response + mock_http_client.__aenter__ = AsyncMock(return_value=mock_http_client) + mock_http_client.__aexit__ = AsyncMock(return_value=False) + + mocker.patch.object(client, "_get_http_client", return_value=mock_http_client) + await client._fetch_oidc_metadata("auth0.local") + client._get_http_client.assert_called_once() From ca835f48521818d97700f9bc00982e6f5d16d33d Mon Sep 17 00:00:00 2001 From: Snehil Kishore Date: Wed, 22 Apr 2026 01:02:41 +0530 Subject: [PATCH 2/4] fix: use Optional for Python 3.9 compatibility Replace `from __future__ import annotations` with `Optional[dict[str, str]]` syntax for the headers parameter in telemetry.py, mfa_client.py, and my_account_client.py. This avoids triggering lint warnings on existing Optional[X] annotations while maintaining Python 3.9 compatibility. --- .../auth_server/mfa_client.py | 20 +++++++++---------- .../auth_server/my_account_client.py | 15 +++++++------- src/auth0_server_python/telemetry.py | 9 ++++----- 3 files changed, 21 insertions(+), 23 deletions(-) diff --git a/src/auth0_server_python/auth_server/mfa_client.py b/src/auth0_server_python/auth_server/mfa_client.py index 4997654..f8187e6 100644 --- a/src/auth0_server_python/auth_server/mfa_client.py +++ b/src/auth0_server_python/auth_server/mfa_client.py @@ -3,10 +3,8 @@ Handles Multi-Factor Authentication operations against the Auth0 MFA API. """ -from __future__ import annotations - import time -from typing import Any, Callable +from typing import Any, Callable, Optional, Union import httpx @@ -56,13 +54,13 @@ class MfaClient: def __init__( self, - domain: str | Callable | None, + domain: Union[str, Callable, None], client_id: str, client_secret: str, secret: str, state_store=None, state_identifier: str = "_a0_session", - headers: dict[str, str] | None = None + headers: Optional[dict[str, str]] = None ): if callable(domain): self._domain = None @@ -84,7 +82,7 @@ def _get_http_client(self, **kwargs) -> httpx.AsyncClient: async def _resolve_base_url( self, - store_options: dict[str, Any] | None = None + store_options: Optional[dict[str, Any]] = None ) -> str: """Resolve domain and return base URL for API calls.""" if self._domain_resolver: @@ -146,7 +144,7 @@ def decrypt_mfa_token(self, encrypted_token: str) -> MfaTokenContext: async def list_authenticators( self, options: dict[str, Any], - store_options: dict[str, Any] | None = None + store_options: Optional[dict[str, Any]] = None ) -> list[AuthenticatorResponse]: """ Lists all MFA authenticators enrolled by the user. @@ -192,7 +190,7 @@ async def list_authenticators( async def enroll_authenticator( self, options: dict[str, Any], - store_options: dict[str, Any] | None = None + store_options: Optional[dict[str, Any]] = None ) -> EnrollmentResponse: """ Enrolls a new MFA authenticator for the user. @@ -278,7 +276,7 @@ async def enroll_authenticator( async def challenge_authenticator( self, options: dict[str, Any], - store_options: dict[str, Any] | None = None + store_options: Optional[dict[str, Any]] = None ) -> ChallengeResponse: """ Initiates an MFA challenge for user verification. @@ -347,7 +345,7 @@ async def challenge_authenticator( async def verify( self, options: dict[str, Any], - store_options: dict[str, Any] | None = None + store_options: Optional[dict[str, Any]] = None ) -> MfaVerifyResponse: """ Verifies an MFA code and completes authentication. @@ -458,7 +456,7 @@ async def _persist_mfa_tokens( self, verify_response: MfaVerifyResponse, options: dict[str, Any], - store_options: dict[str, Any] | None = None + store_options: Optional[dict[str, Any]] = None ) -> None: """ Persist MFA verification tokens to the state store. diff --git a/src/auth0_server_python/auth_server/my_account_client.py b/src/auth0_server_python/auth_server/my_account_client.py index ed3e851..6637b80 100644 --- a/src/auth0_server_python/auth_server/my_account_client.py +++ b/src/auth0_server_python/auth_server/my_account_client.py @@ -1,4 +1,5 @@ -from __future__ import annotations + +from typing import Optional import httpx @@ -24,7 +25,7 @@ class MyAccountClient: Client for interacting with the Auth0 MyAccount API. """ - def __init__(self, domain: str, headers: dict[str, str] | None = None): + def __init__(self, domain: str, headers: Optional[dict[str, str]] = None): """ Initialize the MyAccount API client. @@ -153,9 +154,9 @@ async def complete_connect_account( async def list_connected_accounts( self, access_token: str, - connection: str | None = None, - from_param: str | None = None, - take: int | None = None + connection: Optional[str] = None, + from_param: Optional[str] = None, + take: Optional[int] = None ) -> ListConnectedAccountsResponse: """ List connected accounts for the authenticated user. @@ -277,8 +278,8 @@ async def delete_connected_account( async def list_connected_account_connections( self, access_token: str, - from_param: str | None = None, - take: int | None = None + from_param: Optional[str] = None, + take: Optional[int] = None ) -> ListConnectedAccountConnectionsResponse: """ List available connections that support connected accounts. diff --git a/src/auth0_server_python/telemetry.py b/src/auth0_server_python/telemetry.py index b03eafe..81b48a0 100644 --- a/src/auth0_server_python/telemetry.py +++ b/src/auth0_server_python/telemetry.py @@ -5,12 +5,11 @@ on every HTTP request to Auth0 endpoints. """ -from __future__ import annotations - import base64 import importlib.metadata import json import platform +from typing import Optional class Telemetry: @@ -18,11 +17,11 @@ class Telemetry: _PACKAGE_NAME = "auth0-server-python" - def __init__(self, name: str, version: str, env: dict[str, str] | None = None): + def __init__(self, name: str, version: str, env: Optional[dict[str, str]] = None): self.name = name self.version = version self.env = env if env is not None else {"python": platform.python_version()} - self._cached_headers: dict[str, str] | None = None + self._cached_headers: Optional[dict[str, str]] = None def get_headers(self) -> dict[str, str]: """Return the telemetry headers, building and caching on first call.""" @@ -41,7 +40,7 @@ def get_headers(self) -> dict[str, str]: return self._cached_headers @staticmethod - def default() -> Telemetry: + def default() -> "Telemetry": """Create a Telemetry instance with this SDK's package metadata.""" try: version = importlib.metadata.version(Telemetry._PACKAGE_NAME) From 2ef148be86bb316fba237e5266701a63d00cfaee Mon Sep 17 00:00:00 2001 From: Snehil Kishore Date: Fri, 24 Apr 2026 12:59:08 +0530 Subject: [PATCH 3/4] fix: address review feedback for telemetry PR --- .../auth_server/mfa_client.py | 2 +- .../auth_server/my_account_client.py | 2 +- .../auth_server/server_client.py | 2 +- src/auth0_server_python/telemetry.py | 2 +- .../tests/test_telemetry.py | 28 +++++++++++++++---- 5 files changed, 27 insertions(+), 9 deletions(-) diff --git a/src/auth0_server_python/auth_server/mfa_client.py b/src/auth0_server_python/auth_server/mfa_client.py index f8187e6..c42e71b 100644 --- a/src/auth0_server_python/auth_server/mfa_client.py +++ b/src/auth0_server_python/auth_server/mfa_client.py @@ -77,7 +77,7 @@ def __init__( def _get_http_client(self, **kwargs) -> httpx.AsyncClient: """Return an httpx.AsyncClient with default headers injected.""" - headers = {**self._headers, **kwargs.pop("headers", {})} + headers = {**kwargs.pop("headers", {}), **self._headers} return httpx.AsyncClient(headers=headers, **kwargs) async def _resolve_base_url( diff --git a/src/auth0_server_python/auth_server/my_account_client.py b/src/auth0_server_python/auth_server/my_account_client.py index 6637b80..499b981 100644 --- a/src/auth0_server_python/auth_server/my_account_client.py +++ b/src/auth0_server_python/auth_server/my_account_client.py @@ -38,7 +38,7 @@ def __init__(self, domain: str, headers: Optional[dict[str, str]] = None): def _get_http_client(self, **kwargs) -> httpx.AsyncClient: """Return an httpx.AsyncClient with default headers injected.""" - headers = {**self._headers, **kwargs.pop("headers", {})} + headers = {**kwargs.pop("headers", {}), **self._headers} return httpx.AsyncClient(headers=headers, **kwargs) @property diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index 9bc8325..68afeda 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -186,7 +186,7 @@ def __init__( def _get_http_client(self, **kwargs) -> httpx.AsyncClient: """Return an httpx.AsyncClient with telemetry headers injected.""" - headers = {**self._telemetry_headers, **kwargs.pop("headers", {})} + headers = {**kwargs.pop("headers", {}), **self._telemetry_headers} return httpx.AsyncClient(headers=headers, **kwargs) def _normalize_url(self, value: str) -> str: diff --git a/src/auth0_server_python/telemetry.py b/src/auth0_server_python/telemetry.py index 81b48a0..6e1c86f 100644 --- a/src/auth0_server_python/telemetry.py +++ b/src/auth0_server_python/telemetry.py @@ -44,6 +44,6 @@ def default() -> "Telemetry": """Create a Telemetry instance with this SDK's package metadata.""" try: version = importlib.metadata.version(Telemetry._PACKAGE_NAME) - except Exception: + except importlib.metadata.PackageNotFoundError: version = "unknown" return Telemetry(name=Telemetry._PACKAGE_NAME, version=version) diff --git a/src/auth0_server_python/tests/test_telemetry.py b/src/auth0_server_python/tests/test_telemetry.py index e3b1c5e..5ffdb19 100644 --- a/src/auth0_server_python/tests/test_telemetry.py +++ b/src/auth0_server_python/tests/test_telemetry.py @@ -1,7 +1,8 @@ import base64 +import importlib.metadata import json import platform -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -63,7 +64,7 @@ def test_default_factory(self): @patch( "auth0_server_python.telemetry.importlib.metadata.version", - side_effect=Exception("not installed"), + side_effect=importlib.metadata.PackageNotFoundError("not installed"), ) def test_default_factory_unknown_version_on_error(self, _mock): telemetry = Telemetry.default() @@ -96,23 +97,40 @@ def test_server_client_telemetry_payload_structure(self): assert "version" in decoded assert "python" in decoded["env"] - def test_get_http_client_includes_telemetry_headers(self): + @pytest.mark.asyncio + async def test_get_http_client_includes_telemetry_headers(self): client = self._make_client() http_client = client._get_http_client() for key, value in client._telemetry_headers.items(): assert http_client.headers.get(key) == value + await http_client.aclose() + + @pytest.mark.asyncio + async def test_get_http_client_per_request_headers_do_not_override_telemetry(self): + client = self._make_client() + http_client = client._get_http_client(headers={"User-Agent": "custom", "X-Custom": "val"}) + # Telemetry headers must win over caller-provided duplicates + assert http_client.headers.get("User-Agent") == client._telemetry_headers["User-Agent"] + assert http_client.headers.get("Auth0-Client") == client._telemetry_headers["Auth0-Client"] + # Non-conflicting caller headers are preserved + assert http_client.headers.get("X-Custom") == "val" + await http_client.aclose() def test_my_account_client_receives_telemetry_headers(self): client = self._make_client() assert client._my_account_client._headers == client._telemetry_headers + def test_mfa_client_receives_telemetry_headers(self): + client = self._make_client() + assert client._mfa_client._headers == client._telemetry_headers + @pytest.mark.asyncio async def test_fetch_oidc_metadata_sends_telemetry(self, mocker): client = self._make_client() - mock_response = AsyncMock() + mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = {"issuer": "https://auth0.local/"} - mock_response.raise_for_status = AsyncMock() + mock_response.raise_for_status = MagicMock() mock_http_client = AsyncMock() mock_http_client.get.return_value = mock_response From 81736216fd2b3b12d75aa49c769e37a527c5adba Mon Sep 17 00:00:00 2001 From: Snehil Kishore Date: Fri, 24 Apr 2026 15:12:58 +0530 Subject: [PATCH 4/4] fix: address PR review feedback for telemetry - Build headers eagerly in Telemetry.__init__ instead of lazy caching - Narrow exception catch to PackageNotFoundError in Telemetry.default() - Reverse header merge order so telemetry headers cannot be overwritten - Fix resource leak in test by closing httpx.AsyncClient - Replace mock-based OIDC test with direct header assertion - Add test for AsyncOAuth2Client telemetry header propagation - Add tests for MFA client header propagation and merge behavior --- .../auth_server/server_client.py | 2 +- src/auth0_server_python/telemetry.py | 26 +++------- .../tests/test_telemetry.py | 51 ++++++++----------- 3 files changed, 29 insertions(+), 50 deletions(-) diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index 68afeda..b5d2dbc 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -155,7 +155,7 @@ def __init__( # Initialize telemetry self._telemetry = Telemetry.default() - self._telemetry_headers = self._telemetry.get_headers() + self._telemetry_headers = self._telemetry.headers # Initialize OAuth client self._oauth = AsyncOAuth2Client( diff --git a/src/auth0_server_python/telemetry.py b/src/auth0_server_python/telemetry.py index 6e1c86f..2f07165 100644 --- a/src/auth0_server_python/telemetry.py +++ b/src/auth0_server_python/telemetry.py @@ -13,7 +13,7 @@ class Telemetry: - """Builds and caches telemetry headers for Auth0 HTTP requests.""" + """Builds telemetry headers for Auth0 HTTP requests.""" _PACKAGE_NAME = "auth0-server-python" @@ -21,23 +21,13 @@ def __init__(self, name: str, version: str, env: Optional[dict[str, str]] = None self.name = name self.version = version self.env = env if env is not None else {"python": platform.python_version()} - self._cached_headers: Optional[dict[str, str]] = None - - def get_headers(self) -> dict[str, str]: - """Return the telemetry headers, building and caching on first call.""" - if self._cached_headers is None: - payload = { - "name": self.name, - "version": self.version, - "env": self.env, - } - self._cached_headers = { - "Auth0-Client": base64.b64encode( - json.dumps(payload).encode("utf-8") - ).decode("utf-8"), - "User-Agent": f"Python/{platform.python_version()}", - } - return self._cached_headers + payload = {"name": self.name, "version": self.version, "env": self.env} + self.headers: dict[str, str] = { + "Auth0-Client": base64.b64encode( + json.dumps(payload).encode("utf-8") + ).decode("utf-8"), + "User-Agent": f"Python/{platform.python_version()}", + } @staticmethod def default() -> "Telemetry": diff --git a/src/auth0_server_python/tests/test_telemetry.py b/src/auth0_server_python/tests/test_telemetry.py index 5ffdb19..a980a34 100644 --- a/src/auth0_server_python/tests/test_telemetry.py +++ b/src/auth0_server_python/tests/test_telemetry.py @@ -2,7 +2,7 @@ import importlib.metadata import json import platform -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, patch import pytest @@ -13,11 +13,10 @@ class TestTelemetry: """Tests for the Telemetry class.""" - def test_get_headers_contains_expected_keys(self): + def test_headers_contains_expected_keys(self): telemetry = Telemetry(name="test-sdk", version="1.0.0") - headers = telemetry.get_headers() - assert "Auth0-Client" in headers - assert "User-Agent" in headers + assert "Auth0-Client" in telemetry.headers + assert "User-Agent" in telemetry.headers def test_auth0_client_header_format(self): telemetry = Telemetry( @@ -25,8 +24,7 @@ def test_auth0_client_header_format(self): version="1.0.0b9", env={"python": "3.10.16"}, ) - headers = telemetry.get_headers() - decoded = json.loads(base64.b64decode(headers["Auth0-Client"])) + decoded = json.loads(base64.b64decode(telemetry.headers["Auth0-Client"])) assert decoded == { "name": "auth0-server-python", "version": "1.0.0b9", @@ -35,14 +33,7 @@ def test_auth0_client_header_format(self): def test_user_agent_header(self): telemetry = Telemetry(name="test-sdk", version="1.0.0") - headers = telemetry.get_headers() - assert headers["User-Agent"] == f"Python/{platform.python_version()}" - - def test_headers_are_cached(self): - telemetry = Telemetry(name="test-sdk", version="1.0.0") - first = telemetry.get_headers() - second = telemetry.get_headers() - assert first is second + assert telemetry.headers["User-Agent"] == f"Python/{platform.python_version()}" def test_default_env_uses_python_version(self): telemetry = Telemetry(name="test-sdk", version="1.0.0") @@ -52,8 +43,7 @@ def test_custom_env_override(self): telemetry = Telemetry( name="test-sdk", version="1.0.0", env={"python": "3.9.0", "framework": "fastapi"} ) - headers = telemetry.get_headers() - decoded = json.loads(base64.b64decode(headers["Auth0-Client"])) + decoded = json.loads(base64.b64decode(telemetry.headers["Auth0-Client"])) assert decoded["env"] == {"python": "3.9.0", "framework": "fastapi"} def test_default_factory(self): @@ -125,18 +115,17 @@ def test_mfa_client_receives_telemetry_headers(self): assert client._mfa_client._headers == client._telemetry_headers @pytest.mark.asyncio - async def test_fetch_oidc_metadata_sends_telemetry(self, mocker): + async def test_fetch_oidc_metadata_sends_telemetry(self): client = self._make_client() - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = {"issuer": "https://auth0.local/"} - mock_response.raise_for_status = MagicMock() - - mock_http_client = AsyncMock() - mock_http_client.get.return_value = mock_response - mock_http_client.__aenter__ = AsyncMock(return_value=mock_http_client) - mock_http_client.__aexit__ = AsyncMock(return_value=False) - - mocker.patch.object(client, "_get_http_client", return_value=mock_http_client) - await client._fetch_oidc_metadata("auth0.local") - client._get_http_client.assert_called_once() + http_client = client._get_http_client() + # Verify the client that _fetch_oidc_metadata would use has telemetry headers + for key, value in client._telemetry_headers.items(): + assert http_client.headers.get(key) == value + await http_client.aclose() + + def test_oauth_client_receives_telemetry_headers(self): + client = self._make_client() + # AsyncOAuth2Client stores headers passed at construction on its session + oauth_headers = client._oauth.headers + for key, value in client._telemetry_headers.items(): + assert oauth_headers.get(key) == value