diff --git a/pyproject.toml b/pyproject.toml index af584e8..e4a7289 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,9 @@ dependencies = [ [project.optional-dependencies] cli = ["typer[all]>=0.12,<1.0"] +otel = [ + "opentelemetry-api>=1.20", +] dev = [ "pytest>=9.0.3", "pytest-cov", @@ -41,6 +44,8 @@ dev = [ "pydantic", "pip-audit>=2.7", "typer[all]>=0.12,<1.0", + "opentelemetry-api>=1.20", + "opentelemetry-sdk>=1.20", ] [project.scripts] diff --git a/src/hyperping/_async_client.py b/src/hyperping/_async_client.py index 6cf128d..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 ( @@ -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,79 @@ 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): + 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" + ) from last_exception # ==================== Health Check ==================== diff --git a/src/hyperping/_async_mcp_transport.py b/src/hyperping/_async_mcp_transport.py index ca60bd1..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 ( @@ -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,49 @@ 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: + if last_exc is not None: + 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..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 ( @@ -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,49 @@ 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: + if last_exc is not None: + 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/_otel.py b/src/hyperping/_otel.py new file mode 100644 index 0000000..59d858e --- /dev/null +++ b/src/hyperping/_otel.py @@ -0,0 +1,97 @@ +"""Optional OpenTelemetry instrumentation for the Hyperping SDK. + +When opentelemetry-api is not installed, all functions are no-ops and the SDK +behaves identically to today with zero overhead. + +Install ``hyperping[otel]`` to enable tracing. +""" + +from __future__ import annotations + +import contextlib +from collections.abc import Generator +from typing import TYPE_CHECKING +from urllib.parse import urlsplit + +try: + from opentelemetry import trace + from opentelemetry.trace import Span, Status, StatusCode, Tracer # noqa: F401 + + HAS_OTEL: bool = True +except ImportError: + HAS_OTEL = False + +if TYPE_CHECKING: + from opentelemetry.trace import Span, Tracer + +from hyperping._version import __version__ + + +def get_tracer() -> Tracer | None: + """Return a Tracer for the hyperping SDK, or None when OTel is not installed.""" + if not HAS_OTEL: + return None + return trace.get_tracer("hyperping", __version__) + + +@contextlib.contextmanager +def start_request_span( + tracer: Tracer | None, + method: str, + path: str, + base_url: str, +) -> Generator[Span | None, None, None]: + """Context manager wrapping a REST API call in an OTel span. + + Yields None (no-op) when tracer is None. Compatible with both sync and + async callers because the span lifecycle is synchronous regardless. + """ + if tracer is None: + yield None + return + + span_name = f"hyperping {method} {path}" + server_address = urlsplit(base_url).hostname or base_url + + with tracer.start_as_current_span(span_name) as span: + span.set_attribute("http.request.method", method) + span.set_attribute("url.full", base_url.rstrip("/") + path) + span.set_attribute("server.address", server_address) + span.set_attribute("hyperping.sdk.version", __version__) + yield span + + +@contextlib.contextmanager +def start_rpc_span( + tracer: Tracer | None, + rpc_method: str, + url: str, +) -> Generator[Span | None, None, None]: + """Context manager wrapping an MCP JSON-RPC call in an OTel span. + + Yields None (no-op) when tracer is None. + """ + if tracer is None: + yield None + return + + span_name = f"hyperping.mcp {rpc_method}" + server_address = urlsplit(url).hostname or url + + with tracer.start_as_current_span(span_name) as span: + span.set_attribute("rpc.method", rpc_method) + span.set_attribute("rpc.system", "jsonrpc") + span.set_attribute("server.address", server_address) + span.set_attribute("hyperping.sdk.version", __version__) + yield span + + +def record_error(span: Span | None, exception: BaseException) -> None: + """Record an exception on a span and set its status to ERROR. + + No-op when span is None. + """ + if span is None or not HAS_OTEL: + return + span.set_status(Status(StatusCode.ERROR)) + span.record_exception(exception) diff --git a/src/hyperping/client.py b/src/hyperping/client.py index 97096e7..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 @@ -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,81 @@ 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): + 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 + "Request failed after all retries" + ) from last_exception # ==================== Health Check ==================== diff --git a/tests/unit/test_otel.py b/tests/unit/test_otel.py new file mode 100644 index 0000000..0f5a28f --- /dev/null +++ b/tests/unit/test_otel.py @@ -0,0 +1,429 @@ +"""Tests for OpenTelemetry instrumentation in the Hyperping SDK.""" + +from __future__ import annotations + +import json as _json +from collections.abc import Generator +from typing import Any +from unittest.mock import patch + +import httpx +import pytest +import respx +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter +from opentelemetry.trace import StatusCode + +from hyperping._async_client import AsyncHyperpingClient +from hyperping._async_mcp_transport import AsyncMcpTransport +from hyperping._mcp_transport import McpTransport +from hyperping._version import __version__ as _SDK_VERSION # noqa: N812 +from hyperping.client import HyperpingClient, RetryConfig +from hyperping.endpoints import API_BASE, MCP_URL, Endpoint +from hyperping.exceptions import HyperpingAPIError, HyperpingAuthError + +# ── Helpers ──────────────────────────────────────────────────────────────────── + + +def _tool_resp(data: dict[str, Any], *, req_id: int = 2) -> httpx.Response: + return httpx.Response( + 200, + json={ + "jsonrpc": "2.0", + "id": req_id, + "result": {"content": [{"type": "text", "text": _json.dumps(data)}]}, + }, + ) + + +# ── Fixtures ─────────────────────────────────────────────────────────────────── + + +@pytest.fixture +def _span_provider() -> Generator[tuple[InMemorySpanExporter, TracerProvider], None, None]: + """Create an isolated local TracerProvider and InMemorySpanExporter. + + Does NOT modify the global OTel TracerProvider (which can only be set once + per process). Each test gets its own provider and exporter. + """ + exporter = InMemorySpanExporter() + provider = TracerProvider() + provider.add_span_processor(SimpleSpanProcessor(exporter)) + yield exporter, provider + exporter.clear() + + +@pytest.fixture +def span_exporter( + _span_provider: tuple[InMemorySpanExporter, TracerProvider], +) -> InMemorySpanExporter: + """Expose just the InMemorySpanExporter from the local provider.""" + exporter, _ = _span_provider + return exporter + + +@pytest.fixture +def otel_tracer( + _span_provider: tuple[InMemorySpanExporter, TracerProvider], +) -> Any: + """Return a test-local tracer from the isolated TracerProvider.""" + _, provider = _span_provider + return provider.get_tracer("hyperping", _SDK_VERSION) + + +@pytest.fixture +def otel_client(otel_tracer: Any) -> Generator[HyperpingClient, None, None]: + """HyperpingClient with the test-local tracer injected (bypasses global OTel).""" + c = HyperpingClient( + api_key="sk_test_key", + base_url=API_BASE, + retry_config=RetryConfig(max_retries=0), + ) + c._tracer = otel_tracer # type: ignore[assignment] + yield c + c.close() + + +@pytest.fixture +def otel_async_client(otel_tracer: Any) -> Generator[AsyncHyperpingClient, None, None]: + """AsyncHyperpingClient with the test-local tracer injected.""" + c = AsyncHyperpingClient( + api_key="sk_test_key", + base_url=API_BASE, + retry_config=RetryConfig(max_retries=0), + ) + c._tracer = otel_tracer # type: ignore[assignment] + yield c + + +@pytest.fixture +def otel_mcp(otel_tracer: Any) -> Generator[McpTransport, None, None]: + """McpTransport with test-local tracer and pre-initialized state.""" + t = McpTransport(api_key="sk_test", base_url=MCP_URL) + t._tracer = otel_tracer # type: ignore[assignment] + t._initialized = True + t._init_result = {"protocolVersion": "2025-03-26"} + yield t + t.close() + + +@pytest.fixture +def otel_async_mcp(otel_tracer: Any) -> Generator[AsyncMcpTransport, None, None]: + """AsyncMcpTransport with test-local tracer and pre-initialized state.""" + t = AsyncMcpTransport(api_key="sk_test", base_url=MCP_URL) + t._tracer = otel_tracer # type: ignore[assignment] + t._initialized = True + t._init_result = {"protocolVersion": "2025-03-26"} + yield t + + +# ── TestOtelModule ───────────────────────────────────────────────────────────── + + +class TestOtelModule: + def test_has_otel_flag_true_when_installed(self) -> None: + """HAS_OTEL is True in the test environment (opentelemetry-api is installed).""" + from hyperping._otel import HAS_OTEL + + assert HAS_OTEL is True + + def test_get_tracer_returns_tracer(self) -> None: + """get_tracer() returns a non-None tracer when OTel is installed.""" + from hyperping._otel import get_tracer + + t = get_tracer() + assert t is not None + + def test_get_tracer_returns_none_when_missing(self) -> None: + """get_tracer() returns None when HAS_OTEL is False.""" + import hyperping._otel as otel_mod + + with patch.object(otel_mod, "HAS_OTEL", False): + t = otel_mod.get_tracer() + assert t is None + + +# ── TestSyncClientSpans ──────────────────────────────────────────────────────── + + +class TestSyncClientSpans: + @respx.mock + def test_request_creates_span( + self, otel_client: HyperpingClient, span_exporter: InMemorySpanExporter + ) -> None: + """GET /v1/monitors emits a span named 'hyperping GET /v1/monitors'.""" + respx.get(f"{API_BASE}{Endpoint.MONITORS}").mock(return_value=httpx.Response(200, json=[])) + otel_client.list_monitors() + spans = span_exporter.get_finished_spans() + assert any(s.name == f"hyperping GET {Endpoint.MONITORS}" for s in spans) + + @respx.mock + def test_span_has_http_method_attribute( + self, otel_client: HyperpingClient, span_exporter: InMemorySpanExporter + ) -> None: + """Span carries http.request.method = 'GET'.""" + respx.get(f"{API_BASE}{Endpoint.MONITORS}").mock(return_value=httpx.Response(200, json=[])) + otel_client.list_monitors() + spans = [s for s in span_exporter.get_finished_spans() if "GET" in s.name] + assert spans, "No matching span found" + assert spans[0].attributes.get("http.request.method") == "GET" # type: ignore[union-attr] + + @respx.mock + def test_span_has_url_attribute( + self, otel_client: HyperpingClient, span_exporter: InMemorySpanExporter + ) -> None: + """Span carries url.full.""" + respx.get(f"{API_BASE}{Endpoint.MONITORS}").mock(return_value=httpx.Response(200, json=[])) + otel_client.list_monitors() + spans = [s for s in span_exporter.get_finished_spans() if "GET" in s.name] + assert spans + assert "url.full" in (spans[0].attributes or {}) + + @respx.mock + def test_span_has_status_ok_on_success( + self, otel_client: HyperpingClient, span_exporter: InMemorySpanExporter + ) -> None: + """Span status is UNSET (interpreted as OK) on successful 200 response.""" + respx.get(f"{API_BASE}{Endpoint.MONITORS}").mock(return_value=httpx.Response(200, json=[])) + otel_client.list_monitors() + spans = [s for s in span_exporter.get_finished_spans() if "GET" in s.name] + assert spans + assert spans[0].status.status_code == StatusCode.UNSET + + @respx.mock + def test_span_records_error_on_api_error( + self, otel_client: HyperpingClient, span_exporter: InMemorySpanExporter + ) -> None: + """Span status is ERROR and exception is recorded on 500 response.""" + respx.get(f"{API_BASE}{Endpoint.MONITORS}").mock( + return_value=httpx.Response(500, json={"error": "Internal Server Error"}) + ) + with pytest.raises(HyperpingAPIError): + otel_client.list_monitors() + spans = [s for s in span_exporter.get_finished_spans() if "GET" in s.name] + assert spans + assert spans[0].status.status_code == StatusCode.ERROR + + @respx.mock + def test_span_records_error_on_auth_error( + self, otel_client: HyperpingClient, span_exporter: InMemorySpanExporter + ) -> None: + """Span status is ERROR on 401 (authentication failure).""" + respx.get(f"{API_BASE}{Endpoint.MONITORS}").mock( + return_value=httpx.Response(401, json={"error": "Unauthorized"}) + ) + with pytest.raises(HyperpingAuthError): + otel_client.list_monitors() + spans = [s for s in span_exporter.get_finished_spans() if "GET" in s.name] + assert spans + assert spans[0].status.status_code == StatusCode.ERROR + + @respx.mock + def test_no_span_when_tracer_is_none(self, span_exporter: InMemorySpanExporter) -> None: + """When _tracer is None, no span is emitted and the client works normally.""" + c = HyperpingClient( + api_key="sk_test_key", + base_url=API_BASE, + retry_config=RetryConfig(max_retries=0), + ) + c._tracer = None # type: ignore[assignment] + respx.get(f"{API_BASE}{Endpoint.MONITORS}").mock(return_value=httpx.Response(200, json=[])) + c.list_monitors() + spans = [s for s in span_exporter.get_finished_spans() if "hyperping" in s.name] + assert len(spans) == 0 + c.close() + + +# ── TestAsyncClientSpans ─────────────────────────────────────────────────────── + + +class TestAsyncClientSpans: + @respx.mock + async def test_async_request_creates_span( + self, otel_async_client: AsyncHyperpingClient, span_exporter: InMemorySpanExporter + ) -> None: + """Async GET /v1/monitors emits a span.""" + respx.get(f"{API_BASE}{Endpoint.MONITORS}").mock(return_value=httpx.Response(200, json=[])) + await otel_async_client.list_monitors() + spans = span_exporter.get_finished_spans() + assert any(s.name == f"hyperping GET {Endpoint.MONITORS}" for s in spans) + await otel_async_client.close() + + @respx.mock + async def test_async_span_has_attributes( + self, otel_async_client: AsyncHyperpingClient, span_exporter: InMemorySpanExporter + ) -> None: + """Async span carries http.request.method and url.full.""" + respx.get(f"{API_BASE}{Endpoint.MONITORS}").mock(return_value=httpx.Response(200, json=[])) + await otel_async_client.list_monitors() + spans = [s for s in span_exporter.get_finished_spans() if "GET" in s.name] + assert spans + attrs = spans[0].attributes or {} + assert attrs.get("http.request.method") == "GET" + assert "url.full" in attrs + await otel_async_client.close() + + @respx.mock + async def test_async_span_records_error( + self, otel_async_client: AsyncHyperpingClient, span_exporter: InMemorySpanExporter + ) -> None: + """Async span status is ERROR on 500.""" + respx.get(f"{API_BASE}{Endpoint.MONITORS}").mock( + return_value=httpx.Response(500, json={"error": "Server Error"}) + ) + with pytest.raises(HyperpingAPIError): + await otel_async_client.list_monitors() + spans = [s for s in span_exporter.get_finished_spans() if "GET" in s.name] + assert spans + assert spans[0].status.status_code == StatusCode.ERROR + await otel_async_client.close() + + +# ── TestMcpTransportSpans ────────────────────────────────────────────────────── + + +class TestMcpTransportSpans: + @respx.mock + def test_mcp_rpc_creates_span( + self, otel_mcp: McpTransport, span_exporter: InMemorySpanExporter + ) -> None: + """tools/call emits a span named 'hyperping.mcp tools/call'.""" + respx.post(MCP_URL).mock(return_value=_tool_resp({"ok": True})) + otel_mcp.call_tool("test_tool") + spans = span_exporter.get_finished_spans() + assert any(s.name == "hyperping.mcp tools/call" for s in spans) + + @respx.mock + def test_mcp_span_has_rpc_method_attribute( + self, otel_mcp: McpTransport, span_exporter: InMemorySpanExporter + ) -> None: + """MCP span carries rpc.method = 'tools/call'.""" + respx.post(MCP_URL).mock(return_value=_tool_resp({"ok": True})) + otel_mcp.call_tool("test_tool") + spans = [s for s in span_exporter.get_finished_spans() if "tools/call" in s.name] + assert spans + assert spans[0].attributes.get("rpc.method") == "tools/call" # type: ignore[union-attr] + + @respx.mock + def test_mcp_span_has_rpc_system_attribute( + self, otel_mcp: McpTransport, span_exporter: InMemorySpanExporter + ) -> None: + """MCP span carries rpc.system = 'jsonrpc'.""" + respx.post(MCP_URL).mock(return_value=_tool_resp({"ok": True})) + otel_mcp.call_tool("test_tool") + spans = [s for s in span_exporter.get_finished_spans() if "tools/call" in s.name] + assert spans + assert spans[0].attributes.get("rpc.system") == "jsonrpc" # type: ignore[union-attr] + + @respx.mock + def test_mcp_span_records_error_on_rpc_error( + self, otel_mcp: McpTransport, span_exporter: InMemorySpanExporter + ) -> None: + """MCP span status is ERROR when JSON-RPC returns an error.""" + respx.post(MCP_URL).mock( + return_value=httpx.Response( + 200, + json={"jsonrpc": "2.0", "id": 2, "error": {"code": -32600, "message": "Bad req"}}, + ) + ) + with pytest.raises(HyperpingAPIError): + otel_mcp.call_tool("test_tool") + spans = [s for s in span_exporter.get_finished_spans() if "tools/call" in s.name] + assert spans + assert spans[0].status.status_code == StatusCode.ERROR + + @respx.mock + def test_mcp_span_records_error_on_http_error( + self, otel_mcp: McpTransport, span_exporter: InMemorySpanExporter + ) -> None: + """MCP span status is ERROR on HTTP 500.""" + respx.post(MCP_URL).mock(return_value=httpx.Response(500)) + with pytest.raises(HyperpingAPIError): + otel_mcp.call_tool("test_tool") + spans = [s for s in span_exporter.get_finished_spans() if "tools/call" in s.name] + assert spans + assert spans[0].status.status_code == StatusCode.ERROR + + +# ── TestAsyncMcpTransportSpans ───────────────────────────────────────────────── + + +class TestAsyncMcpTransportSpans: + @respx.mock + async def test_async_mcp_rpc_creates_span( + self, otel_async_mcp: AsyncMcpTransport, span_exporter: InMemorySpanExporter + ) -> None: + """Async tools/call emits 'hyperping.mcp tools/call'.""" + respx.post(MCP_URL).mock(return_value=_tool_resp({"ok": True})) + await otel_async_mcp.call_tool("test_tool") + spans = span_exporter.get_finished_spans() + assert any(s.name == "hyperping.mcp tools/call" for s in spans) + await otel_async_mcp.close() + + @respx.mock + async def test_async_mcp_span_records_error( + self, otel_async_mcp: AsyncMcpTransport, span_exporter: InMemorySpanExporter + ) -> None: + """Async MCP span status is ERROR on HTTP 500.""" + respx.post(MCP_URL).mock(return_value=httpx.Response(500)) + with pytest.raises(HyperpingAPIError): + await otel_async_mcp.call_tool("test_tool") + spans = [s for s in span_exporter.get_finished_spans() if "tools/call" in s.name] + assert spans + assert spans[0].status.status_code == StatusCode.ERROR + await otel_async_mcp.close() + + +# ── TestNoopWhenUninstalled ──────────────────────────────────────────────────── + + +class TestNoopWhenUninstalled: + @respx.mock + def test_sync_client_works_without_otel(self) -> None: + """Client works normally when _tracer is None (simulates HAS_OTEL=False).""" + import hyperping._otel as otel_mod + + with patch.object(otel_mod, "HAS_OTEL", False): + c = HyperpingClient( + api_key="sk_test_key", + base_url=API_BASE, + retry_config=RetryConfig(max_retries=0), + ) + assert c._tracer is None + respx.get(f"{API_BASE}{Endpoint.MONITORS}").mock(return_value=httpx.Response(200, json=[])) + result = c.list_monitors() + assert result == [] + c.close() + + @respx.mock + async def test_async_client_works_without_otel(self) -> None: + """Async client works normally when _tracer is None.""" + import hyperping._otel as otel_mod + + with patch.object(otel_mod, "HAS_OTEL", False): + c = AsyncHyperpingClient( + api_key="sk_test_key", + base_url=API_BASE, + retry_config=RetryConfig(max_retries=0), + ) + assert c._tracer is None + respx.get(f"{API_BASE}{Endpoint.MONITORS}").mock(return_value=httpx.Response(200, json=[])) + result = await c.list_monitors() + assert result == [] + await c.close() + + @respx.mock + def test_mcp_transport_works_without_otel(self) -> None: + """MCP transport works normally when _tracer is None.""" + import hyperping._otel as otel_mod + + with patch.object(otel_mod, "HAS_OTEL", False): + t = McpTransport(api_key="sk_test", base_url=MCP_URL) + assert t._tracer is None + t._initialized = True + t._init_result = {"protocolVersion": "2025-03-26"} + respx.post(MCP_URL).mock(return_value=_tool_resp({"ok": True})) + result = t.call_tool("test_tool") + assert result == {"ok": True} + t.close() diff --git a/uv.lock b/uv.lock index f4435d2..87c5bde 100644 --- a/uv.lock +++ b/uv.lock @@ -386,6 +386,8 @@ cli = [ ] dev = [ { name = "mypy" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-sdk" }, { name = "pip-audit" }, { name = "pydantic" }, { name = "pytest" }, @@ -394,6 +396,9 @@ dev = [ { name = "ruff" }, { name = "typer" }, ] +otel = [ + { name = "opentelemetry-api" }, +] [package.dev-dependencies] dev = [ @@ -404,6 +409,9 @@ dev = [ requires-dist = [ { name = "httpx2", specifier = ">=2.4,<3.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10" }, + { name = "opentelemetry-api", marker = "extra == 'dev'", specifier = ">=1.20" }, + { name = "opentelemetry-api", marker = "extra == 'otel'", specifier = ">=1.20" }, + { name = "opentelemetry-sdk", marker = "extra == 'dev'", specifier = ">=1.20" }, { name = "pip-audit", marker = "extra == 'dev'", specifier = ">=2.7" }, { name = "pydantic", specifier = ">=2.0,<3.0" }, { name = "pydantic", marker = "extra == 'dev'" }, @@ -414,7 +422,7 @@ requires-dist = [ { name = "typer", extras = ["all"], marker = "extra == 'cli'", specifier = ">=0.12,<1.0" }, { name = "typer", extras = ["all"], marker = "extra == 'dev'", specifier = ">=0.12,<1.0" }, ] -provides-extras = ["cli", "dev"] +provides-extras = ["cli", "otel", "dev"] [package.metadata.requires-dev] dev = [{ name = "pytest-asyncio", specifier = ">=0.23.0" }] @@ -644,6 +652,45 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] +[[package]] +name = "opentelemetry-api" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/1c/125e1c936c0873796771b7f04f6c93b9f1bf5d424cea90fda94a99f61da8/opentelemetry_api-1.42.1.tar.gz", hash = "sha256:56c63bea9f77b62856be8c47600474acad853b2924b99b1687c4cb6297166716", size = 72296, upload-time = "2026-05-21T16:32:49.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/ca/9520cc1f3dfbbd03ac5903bbf55833e257bc64b1cf30fa8b0d6df374d821/opentelemetry_api-1.42.1-py3-none-any.whl", hash = "sha256:51a69edacadbc03a8950ace1c4c21099cacc538820ac2c9e36277e78cebba714", size = 61311, upload-time = "2026-05-21T16:32:28.822Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/f7/b390bd9bfd703bf98a68fea1f27786c6872331fd617164a54b8a59bdc008/opentelemetry_sdk-1.42.1.tar.gz", hash = "sha256:8c834e8f8c9ba4171d4ec843d0cb8a67e4c7394d3f9e9297e582cbd9456ddbf7", size = 239262, upload-time = "2026-05-21T16:33:04.641Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/6b/4287766cfbde577ae2272e8884abac325aeaac0d64f41c61d5b8cc595105/opentelemetry_sdk-1.42.1-py3-none-any.whl", hash = "sha256:083cd4bbfaa5aa7b5a9e552430d9951219967cfb27aa61feb13a77aba1fc839d", size = 170907, upload-time = "2026-05-21T16:32:45.894Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.63b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/93/99/4d7dd6df64795951413ce6e815f8cf1eb191daf7196ae86574589643d5f3/opentelemetry_semantic_conventions-0.63b1.tar.gz", hash = "sha256:3daf963611334b365e98a57438183eb012d3bfb40b2d931a9af613476b8701a9", size = 148340, upload-time = "2026-05-21T16:33:05.455Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/7a/7fe66f5f3682b1dd47d88cc4e11f1c6c0966b737de2d16671146e23c39a5/opentelemetry_semantic_conventions-0.63b1-py3-none-any.whl", hash = "sha256:dfe5ef4dee82586b746f522b818ceb298d00b3d59f660042bd79404bff8d0682", size = 203713, upload-time = "2026-05-21T16:32:47.016Z" }, +] + [[package]] name = "packageurl-python" version = "0.17.6"