From cd757e5969e9e0255b13907e175752f358cf4ad2 Mon Sep 17 00:00:00 2001 From: Khaled Salhab Date: Sun, 14 Jun 2026 03:52:25 +0300 Subject: [PATCH 1/2] feat(otel): add OTel instrumentation to async client methods Co-Authored-By: Claude Haiku 4.5 --- src/hyperping/_async_client.py | 133 +++++++++++++------------ src/hyperping/_async_mcp_transport.py | 76 +++++++------- src/hyperping/_mcp_transport.py | 77 ++++++++------- src/hyperping/client.py | 137 ++++++++++++++------------ 4 files changed, 228 insertions(+), 195 deletions(-) diff --git a/src/hyperping/_async_client.py b/src/hyperping/_async_client.py index 6cf128d..4698d6e 100644 --- a/src/hyperping/_async_client.py +++ b/src/hyperping/_async_client.py @@ -48,6 +48,7 @@ HyperpingAuthError, HyperpingRateLimitError, ) +from hyperping._otel import get_tracer, record_error, start_request_span logger = logging.getLogger(__name__) @@ -138,6 +139,7 @@ def __init__( }, timeout=self.timeout, ) + self._tracer = get_tracer() def __repr__(self) -> str: return f"AsyncHyperpingClient(base_url={self.base_url!r})" @@ -388,68 +390,77 @@ async def _request( Raises: HyperpingAPIError: On API errors after retries exhausted """ - breaker = self._breaker_for(path) - if not breaker.call_allowed(): - raise HyperpingAPIError(self._circuit_open_message(breaker, path)) - - last_exception: Exception | None = None - delay = self.retry_config.initial_delay - max_attempts = self.retry_config.max_retries + 1 - - for attempt in range(max_attempts): - try: - result = await self._execute_single_attempt(method, path, json, params) - - if not isinstance(result, httpx.Response): - return result - - response = result - if self._should_retry(response.status_code, attempt): - sleep_time = self._compute_sleep_time(response, delay) - logger.warning( - "Retrying after %.2fs due to %d (attempt %d/%d)", - sleep_time, - response.status_code, - attempt + 1, - max_attempts, - ) - await asyncio.sleep(sleep_time) - delay = min( - delay * self.retry_config.backoff_factor, - self.retry_config.max_delay, - ) - continue - - if response.status_code >= 500: + with start_request_span(self._tracer, method, path, self.base_url) as span: + breaker = self._breaker_for(path) + if not breaker.call_allowed(): + raise HyperpingAPIError(self._circuit_open_message(breaker, path)) + + last_exception: Exception | None = None + delay = self.retry_config.initial_delay + max_attempts = self.retry_config.max_retries + 1 + + for attempt in range(max_attempts): + try: + result = await self._execute_single_attempt(method, path, json, params) + + if not isinstance(result, httpx.Response): + return result + + response = result + if self._should_retry(response.status_code, attempt): + sleep_time = self._compute_sleep_time(response, delay) + logger.warning( + "Retrying after %.2fs due to %d (attempt %d/%d)", + sleep_time, + response.status_code, + attempt + 1, + max_attempts, + ) + await asyncio.sleep(sleep_time) + delay = min( + delay * self.retry_config.backoff_factor, + self.retry_config.max_delay, + ) + continue + + if response.status_code >= 500: + breaker.record_failure() + try: + self._handle_response_error(response) + except HyperpingAPIError as exc: + record_error(span, exc) + raise + + except (httpx.TimeoutException, httpx.RequestError) as e: + last_exception = e + if attempt < self.retry_config.max_retries: + label = "timeout" if isinstance(e, httpx.TimeoutException) else str(e) + sleep_time = delay + random.uniform(0, delay * 0.25) + logger.warning( + "Request %s, retrying after %.2fs (attempt %d/%d)", + label, + sleep_time, + attempt + 1, + max_attempts, + ) + await asyncio.sleep(sleep_time) + delay = min( + delay * self.retry_config.backoff_factor, + self.retry_config.max_delay, + ) + continue breaker.record_failure() - self._handle_response_error(response) - - except (httpx.TimeoutException, httpx.RequestError) as e: - last_exception = e - if attempt < self.retry_config.max_retries: - label = "timeout" if isinstance(e, httpx.TimeoutException) else str(e) - sleep_time = delay + random.uniform(0, delay * 0.25) - logger.warning( - "Request %s, retrying after %.2fs (attempt %d/%d)", - label, - sleep_time, - attempt + 1, - max_attempts, - ) - await asyncio.sleep(sleep_time) - delay = min( - delay * self.retry_config.backoff_factor, - self.retry_config.max_delay, - ) - continue - breaker.record_failure() - if isinstance(e, httpx.TimeoutException): - raise HyperpingAPIError(f"Request timeout after {max_attempts} attempts") from e - raise HyperpingAPIError(f"Request failed: {e}") from e - - raise HyperpingAPIError( # pragma: no cover - "Request failed after all retries" - ) from last_exception + if isinstance(e, httpx.TimeoutException): + exc = HyperpingAPIError(f"Request timeout after {max_attempts} attempts") + record_error(span, exc) + raise exc from e + exc = HyperpingAPIError(f"Request failed: {e}") + record_error(span, exc) + raise exc from e + + raise HyperpingAPIError( # pragma: no cover + "Request failed after all retries" + ) from last_exception # ==================== Health Check ==================== diff --git a/src/hyperping/_async_mcp_transport.py b/src/hyperping/_async_mcp_transport.py index ca60bd1..f9d011e 100644 --- a/src/hyperping/_async_mcp_transport.py +++ b/src/hyperping/_async_mcp_transport.py @@ -22,6 +22,7 @@ HyperpingRateLimitError, HyperpingValidationError, ) +from hyperping._otel import get_tracer, record_error, start_rpc_span _PROTOCOL_VERSION = "2025-03-26" @@ -84,6 +85,7 @@ def __init__( self._init_blocked_status_code: int = 200 self._init_result: dict[str, Any] = {} self._max_retries = max_retries + self._tracer = get_tracer() async def _next_id(self) -> int: async with self._lock: @@ -273,44 +275,48 @@ async def call_tool( """ await self.initialize() - last_exc: Exception | None = None - for attempt in range(self._max_retries + 1): - try: - result = await self._send_rpc( - "tools/call", - {"name": tool_name, "arguments": arguments or {}}, - ) - break - except HyperpingAPIError as exc: - if exc.status_code and exc.status_code in (500, 502, 503, 504): - last_exc = exc - if attempt < self._max_retries: - await asyncio.sleep(min(2**attempt, 10)) - continue - raise - else: - raise last_exc # type: ignore[misc] - if result is None: - return None + with start_rpc_span(self._tracer, "tools/call", self._url) as span: + last_exc: Exception | None = None + for attempt in range(self._max_retries + 1): + try: + result = await self._send_rpc( + "tools/call", + {"name": tool_name, "arguments": arguments or {}}, + ) + break + except HyperpingAPIError as exc: + if exc.status_code and exc.status_code in (500, 502, 503, 504): + last_exc = exc + if attempt < self._max_retries: + await asyncio.sleep(min(2**attempt, 10)) + continue + record_error(span, exc) + raise + else: + record_error(span, last_exc) + raise last_exc # type: ignore[misc] - content = result.get("result", {}).get("content", []) - if not content: - return None + if result is None: + return None - text = content[0].get("text", "") - if not text: - return None + content = result.get("result", {}).get("content", []) + if not content: + return None - try: - return json.loads(text) - except json.JSONDecodeError as exc: - # Server-controlled ``text`` may carry PII; drop it instead of - # embedding the first 500 bytes into the exception. - raise HyperpingAPIError( - f"Failed to parse MCP tool response: {exc}", - status_code=200, - response_body=None, - ) from exc + text = content[0].get("text", "") + if not text: + return None + + try: + return json.loads(text) + except json.JSONDecodeError as exc: + # Server-controlled ``text`` may carry PII; drop it instead of + # embedding the first 500 bytes into the exception. + raise HyperpingAPIError( + f"Failed to parse MCP tool response: {exc}", + status_code=200, + response_body=None, + ) from exc async def close(self) -> None: await self._client.aclose() diff --git a/src/hyperping/_mcp_transport.py b/src/hyperping/_mcp_transport.py index a9cb5ad..22c7c73 100644 --- a/src/hyperping/_mcp_transport.py +++ b/src/hyperping/_mcp_transport.py @@ -22,6 +22,7 @@ HyperpingRateLimitError, HyperpingValidationError, ) +from hyperping._otel import get_tracer, record_error, start_rpc_span _PROTOCOL_VERSION = "2025-03-26" @@ -84,6 +85,7 @@ def __init__( self._init_blocked_status_code: int = 200 self._init_result: dict[str, Any] = {} self._max_retries = max_retries + self._tracer = get_tracer() def _next_id(self) -> int: with self._lock: @@ -274,45 +276,48 @@ def call_tool( """ self.initialize() - last_exc: Exception | None = None - for attempt in range(self._max_retries + 1): - try: - result = self._send_rpc( - "tools/call", - {"name": tool_name, "arguments": arguments or {}}, - ) - break - except HyperpingAPIError as exc: - if exc.status_code and exc.status_code in (500, 502, 503, 504): - last_exc = exc - if attempt < self._max_retries: - time.sleep(min(2**attempt, 10)) - continue - raise - else: - raise last_exc # type: ignore[misc] - - if result is None: - return None + with start_rpc_span(self._tracer, "tools/call", self._url) as span: + last_exc: Exception | None = None + for attempt in range(self._max_retries + 1): + try: + result = self._send_rpc( + "tools/call", + {"name": tool_name, "arguments": arguments or {}}, + ) + break + except HyperpingAPIError as exc: + if exc.status_code and exc.status_code in (500, 502, 503, 504): + last_exc = exc + if attempt < self._max_retries: + time.sleep(min(2**attempt, 10)) + continue + record_error(span, exc) + raise + else: + record_error(span, last_exc) + raise last_exc # type: ignore[misc] - content = result.get("result", {}).get("content", []) - if not content: - return None + if result is None: + return None - text = content[0].get("text", "") - if not text: - return None + content = result.get("result", {}).get("content", []) + if not content: + return None - try: - return json.loads(text) - except json.JSONDecodeError as exc: - # Server-controlled ``text`` may carry PII; drop it instead of - # embedding the first 500 bytes into the exception. - raise HyperpingAPIError( - f"Failed to parse MCP tool response: {exc}", - status_code=200, - response_body=None, - ) from exc + text = content[0].get("text", "") + if not text: + return None + + try: + return json.loads(text) + except json.JSONDecodeError as exc: + # Server-controlled ``text`` may carry PII; drop it instead of + # embedding the first 500 bytes into the exception. + raise HyperpingAPIError( + f"Failed to parse MCP tool response: {exc}", + status_code=200, + response_body=None, + ) from exc def close(self) -> None: self._client.close() diff --git a/src/hyperping/client.py b/src/hyperping/client.py index 97096e7..6b31bae 100644 --- a/src/hyperping/client.py +++ b/src/hyperping/client.py @@ -47,6 +47,7 @@ HyperpingRateLimitError, HyperpingValidationError, ) +from hyperping._otel import get_tracer, record_error, start_request_span logger = logging.getLogger(__name__) @@ -167,6 +168,7 @@ def __init__( }, timeout=self.timeout, ) + self._tracer = get_tracer() def __repr__(self) -> str: return f"HyperpingClient(base_url={self.base_url!r})" @@ -471,70 +473,79 @@ def _request( Raises: HyperpingAPIError: On API errors after retries exhausted """ - breaker = self._breaker_for(path) - if not breaker.call_allowed(): - raise HyperpingAPIError(self._circuit_open_message(breaker, path)) - - last_exception: Exception | None = None - delay = self.retry_config.initial_delay - max_attempts = self.retry_config.max_retries + 1 - - for attempt in range(max_attempts): - try: - result = self._execute_single_attempt(method, path, json, params) - - if not isinstance(result, httpx.Response): - return result - - response = result - if self._should_retry(response.status_code, attempt): - sleep_time = self._compute_sleep_time(response, delay) - logger.warning( - "Retrying after %.2fs due to %d (attempt %d/%d)", - sleep_time, - response.status_code, - attempt + 1, - max_attempts, - ) - time.sleep(sleep_time) - delay = min( - delay * self.retry_config.backoff_factor, - self.retry_config.max_delay, - ) - continue - - # Only trip circuit breaker on server errors, not client errors - if response.status_code >= 500: + with start_request_span(self._tracer, method, path, self.base_url) as span: + breaker = self._breaker_for(path) + if not breaker.call_allowed(): + raise HyperpingAPIError(self._circuit_open_message(breaker, path)) + + last_exception: Exception | None = None + delay = self.retry_config.initial_delay + max_attempts = self.retry_config.max_retries + 1 + + for attempt in range(max_attempts): + try: + result = self._execute_single_attempt(method, path, json, params) + + if not isinstance(result, httpx.Response): + return result + + response = result + if self._should_retry(response.status_code, attempt): + sleep_time = self._compute_sleep_time(response, delay) + logger.warning( + "Retrying after %.2fs due to %d (attempt %d/%d)", + sleep_time, + response.status_code, + attempt + 1, + max_attempts, + ) + time.sleep(sleep_time) + delay = min( + delay * self.retry_config.backoff_factor, + self.retry_config.max_delay, + ) + continue + + # Only trip circuit breaker on server errors, not client errors + if response.status_code >= 500: + breaker.record_failure() + try: + self._handle_response_error(response) + except HyperpingAPIError as exc: + record_error(span, exc) + raise + + except (httpx.TimeoutException, httpx.RequestError) as e: + last_exception = e + if attempt < self.retry_config.max_retries: + label = "timeout" if isinstance(e, httpx.TimeoutException) else str(e) + sleep_time = delay + random.uniform(0, delay * 0.25) + logger.warning( + "Request %s, retrying after %.2fs (attempt %d/%d)", + label, + sleep_time, + attempt + 1, + max_attempts, + ) + time.sleep(sleep_time) + delay = min( + delay * self.retry_config.backoff_factor, + self.retry_config.max_delay, + ) + continue breaker.record_failure() - self._handle_response_error(response) - - except (httpx.TimeoutException, httpx.RequestError) as e: - last_exception = e - if attempt < self.retry_config.max_retries: - label = "timeout" if isinstance(e, httpx.TimeoutException) else str(e) - sleep_time = delay + random.uniform(0, delay * 0.25) - logger.warning( - "Request %s, retrying after %.2fs (attempt %d/%d)", - label, - sleep_time, - attempt + 1, - max_attempts, - ) - time.sleep(sleep_time) - delay = min( - delay * self.retry_config.backoff_factor, - self.retry_config.max_delay, - ) - continue - breaker.record_failure() - if isinstance(e, httpx.TimeoutException): - raise HyperpingAPIError(f"Request timeout after {max_attempts} attempts") from e - raise HyperpingAPIError(f"Request failed: {e}") from e - - # Should not reach here, but just in case - raise HyperpingAPIError( # pragma: no cover - "Request failed after all retries" - ) from last_exception + if isinstance(e, httpx.TimeoutException): + exc = HyperpingAPIError(f"Request timeout after {max_attempts} attempts") + record_error(span, exc) + raise exc from e + exc = HyperpingAPIError(f"Request failed: {e}") + record_error(span, exc) + raise exc from e + + # Should not reach here, but just in case + raise HyperpingAPIError( # pragma: no cover + "Request failed after all retries" + ) from last_exception # ==================== Health Check ==================== From ba895b8fc3f0adcd8db899c63b9d12a552452d7a Mon Sep 17 00:00:00 2001 From: Khaled Salhab Date: Sun, 14 Jun 2026 03:54:17 +0300 Subject: [PATCH 2/2] feat(otel): wire OTel spans into HyperpingClient, AsyncHyperpingClient, and MCP transports (PY-11) Each class now calls get_tracer() in __init__ and wraps its request chokepoint (_request for REST clients, call_tool for MCP transports) in a start_request_span / start_rpc_span context manager. record_error is called on HyperpingAPIError, httpx timeout, and network failure paths so spans carry ERROR status on failures. All 23 OTel tests now pass; full suite (603 tests) unaffected. --- src/hyperping/_async_client.py | 16 +++++++++------- src/hyperping/_async_mcp_transport.py | 5 +++-- src/hyperping/_mcp_transport.py | 5 +++-- src/hyperping/client.py | 16 +++++++++------- 4 files changed, 24 insertions(+), 18 deletions(-) diff --git a/src/hyperping/_async_client.py b/src/hyperping/_async_client.py index 4698d6e..ad5ffad 100644 --- a/src/hyperping/_async_client.py +++ b/src/hyperping/_async_client.py @@ -41,6 +41,7 @@ sanitize_for_log, validate_base_url, ) +from hyperping._otel import get_tracer, record_error, start_request_span from hyperping.client import _ENDPOINT_BREAKERS_MAX, DEFAULT_RETRY_CONFIG, RetryConfig from hyperping.endpoints import API_BASE, Endpoint from hyperping.exceptions import ( @@ -48,7 +49,6 @@ HyperpingAuthError, HyperpingRateLimitError, ) -from hyperping._otel import get_tracer, record_error, start_request_span logger = logging.getLogger(__name__) @@ -451,12 +451,14 @@ async def _request( continue breaker.record_failure() if isinstance(e, httpx.TimeoutException): - exc = HyperpingAPIError(f"Request timeout after {max_attempts} attempts") - record_error(span, exc) - raise exc from e - exc = HyperpingAPIError(f"Request failed: {e}") - record_error(span, exc) - raise exc from e + api_exc = HyperpingAPIError( + f"Request timeout after {max_attempts} attempts" + ) + record_error(span, api_exc) + raise api_exc from e + api_exc = HyperpingAPIError(f"Request failed: {e}") + record_error(span, api_exc) + raise api_exc from e raise HyperpingAPIError( # pragma: no cover "Request failed after all retries" diff --git a/src/hyperping/_async_mcp_transport.py b/src/hyperping/_async_mcp_transport.py index f9d011e..6577101 100644 --- a/src/hyperping/_async_mcp_transport.py +++ b/src/hyperping/_async_mcp_transport.py @@ -13,6 +13,7 @@ from pydantic import SecretStr from hyperping._internals import validate_base_url +from hyperping._otel import get_tracer, record_error, start_rpc_span from hyperping._version import __version__ from hyperping.endpoints import MCP_URL from hyperping.exceptions import ( @@ -22,7 +23,6 @@ HyperpingRateLimitError, HyperpingValidationError, ) -from hyperping._otel import get_tracer, record_error, start_rpc_span _PROTOCOL_VERSION = "2025-03-26" @@ -293,7 +293,8 @@ async def call_tool( record_error(span, exc) raise else: - record_error(span, last_exc) + if last_exc is not None: + record_error(span, last_exc) raise last_exc # type: ignore[misc] if result is None: diff --git a/src/hyperping/_mcp_transport.py b/src/hyperping/_mcp_transport.py index 22c7c73..c93c412 100644 --- a/src/hyperping/_mcp_transport.py +++ b/src/hyperping/_mcp_transport.py @@ -13,6 +13,7 @@ from pydantic import SecretStr from hyperping._internals import validate_base_url +from hyperping._otel import get_tracer, record_error, start_rpc_span from hyperping._version import __version__ from hyperping.endpoints import MCP_URL from hyperping.exceptions import ( @@ -22,7 +23,6 @@ HyperpingRateLimitError, HyperpingValidationError, ) -from hyperping._otel import get_tracer, record_error, start_rpc_span _PROTOCOL_VERSION = "2025-03-26" @@ -294,7 +294,8 @@ def call_tool( record_error(span, exc) raise else: - record_error(span, last_exc) + if last_exc is not None: + record_error(span, last_exc) raise last_exc # type: ignore[misc] if result is None: diff --git a/src/hyperping/client.py b/src/hyperping/client.py index 6b31bae..98a6bc3 100644 --- a/src/hyperping/client.py +++ b/src/hyperping/client.py @@ -37,6 +37,7 @@ ) from hyperping._maintenance_mixin import MaintenanceMixin from hyperping._monitors_mixin import MonitorsMixin +from hyperping._otel import get_tracer, record_error, start_request_span from hyperping._outages_mixin import OutagesMixin from hyperping._statuspages_mixin import StatusPagesMixin from hyperping.endpoints import API_BASE, Endpoint @@ -47,7 +48,6 @@ HyperpingRateLimitError, HyperpingValidationError, ) -from hyperping._otel import get_tracer, record_error, start_request_span logger = logging.getLogger(__name__) @@ -535,12 +535,14 @@ def _request( continue breaker.record_failure() if isinstance(e, httpx.TimeoutException): - exc = HyperpingAPIError(f"Request timeout after {max_attempts} attempts") - record_error(span, exc) - raise exc from e - exc = HyperpingAPIError(f"Request failed: {e}") - record_error(span, exc) - raise exc from e + api_exc = HyperpingAPIError( + f"Request timeout after {max_attempts} attempts" + ) + record_error(span, api_exc) + raise api_exc from e + api_exc = HyperpingAPIError(f"Request failed: {e}") + record_error(span, api_exc) + raise api_exc from e # Should not reach here, but just in case raise HyperpingAPIError( # pragma: no cover