diff --git a/nemoguardrails/http/__init__.py b/nemoguardrails/http/__init__.py index d599a01e87..e4821eed0a 100644 --- a/nemoguardrails/http/__init__.py +++ b/nemoguardrails/http/__init__.py @@ -23,6 +23,7 @@ HTTPStatusError, HTTPTimeoutError, ) +from nemoguardrails.http.instrumented import InstrumentedHTTPClient, instrument_http_client from nemoguardrails.http.request import http_call from nemoguardrails.http.retry import RetryingHTTPClient, RetryPolicy from nemoguardrails.http.runtime import create_http_client @@ -40,8 +41,10 @@ "HTTPStatusError", "HTTPTimeoutError", "HttpxHTTPClient", + "InstrumentedHTTPClient", "RetryPolicy", "RetryingHTTPClient", "create_http_client", "http_call", + "instrument_http_client", ] diff --git a/nemoguardrails/http/instrumented.py b/nemoguardrails/http/instrumented.py new file mode 100644 index 0000000000..dc27b2bc5f --- /dev/null +++ b/nemoguardrails/http/instrumented.py @@ -0,0 +1,170 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import warnings +from contextlib import nullcontext +from typing import TYPE_CHECKING, Any, Mapping, overload + +from nemoguardrails.http.client import ClosableHTTPClient, HTTPClient +from nemoguardrails.http.telemetry import ( + http_call_span, + http_request_duration, + set_http_response_attributes, +) +from nemoguardrails.http.types import HTTPResponse + +if TYPE_CHECKING: + from opentelemetry.trace import Tracer + + +class InstrumentedHTTPClient: + """Decorate an HTTP client with privacy-safe tracing and metrics. + + The decorator preserves request behavior and client ownership. Wrapping an + existing ``InstrumentedHTTPClient`` is idempotent, and tracing and metrics + can be enabled independently. + """ + + def __new__(cls, client: HTTPClient, *args: Any, **kwargs: Any): + """Return an existing instrumented client without wrapping it again.""" + if isinstance(client, cls): + return client + return super().__new__(cls) + + def __init__( + self, + client: HTTPClient, + tracer: "Tracer | None", + *, + metrics_enabled: bool = False, + ): + """Configure optional telemetry for the wrapped client.""" + if client is self: + if tracer is not self._tracer or metrics_enabled != self._metrics_enabled: + warnings.warn( + "InstrumentedHTTPClient is already instrumented; new instrumentation " + "settings are ignored. Re-instrument the underlying wrapped_client instead.", + stacklevel=2, + ) + return + self._client = client + self._tracer = tracer + self._metrics_enabled = metrics_enabled + self._closed = False + + @property + def wrapped_client(self) -> HTTPClient: + """Return the underlying client for direct access or re-instrumentation.""" + return self._client + + async def request( + self, + method: str, + url: str, + *, + headers: Mapping[str, str] | None = None, + params: Mapping[str, Any] | None = None, + json: Any = None, + content: bytes | str | None = None, + timeout: float | None = None, + ) -> HTTPResponse: + """Forward one request while recording privacy-safe HTTP telemetry. + + Telemetry includes the method, sanitized URL, payload sizes, response + status, and retry count. Header, query, body, and credential values are + never recorded. + """ + if self._tracer is None and not self._metrics_enabled: + return await self._client.request( + method, + url, + headers=headers, + params=params, + json=json, + content=content, + timeout=timeout, + ) + + normalized_method = method.upper() + with http_call_span(self._tracer, normalized_method, url, content) as span: + duration = http_request_duration(normalized_method, url) if self._metrics_enabled else nullcontext(None) + with duration as metric_state: + response = await self._client.request( + method, + url, + headers=headers, + params=params, + json=json, + content=content, + timeout=timeout, + ) + if metric_state is not None: + metric_state.response_status_code = response.status_code + set_http_response_attributes(span, response) + return response + + async def close(self) -> None: + """Close the wrapped client without taking ownership from its caller. + + Repeated calls are safe. A failed or cancelled close remains retryable. + """ + if self._closed: + return + self._closed = True + try: + if isinstance(self._client, ClosableHTTPClient): + await self._client.close() + except BaseException: + self._closed = False + raise + + +@overload +def instrument_http_client( + client: ClosableHTTPClient, + *, + tracer: "Tracer | None" = None, + metrics_enabled: bool = False, +) -> ClosableHTTPClient: ... + + +@overload +def instrument_http_client( + client: HTTPClient, + *, + tracer: "Tracer | None" = None, + metrics_enabled: bool = False, +) -> HTTPClient: ... + + +def instrument_http_client( + client: HTTPClient, + *, + tracer: "Tracer | None" = None, + metrics_enabled: bool = False, +) -> HTTPClient: + """Return ``client`` decorated with the requested HTTP telemetry. + + The original client is returned when telemetry is disabled or the client is + already instrumented. + """ + if isinstance(client, InstrumentedHTTPClient): + return client + if tracer is None and not metrics_enabled: + return client + return InstrumentedHTTPClient(client, tracer, metrics_enabled=metrics_enabled) + + +__all__ = ["InstrumentedHTTPClient", "instrument_http_client"] diff --git a/nemoguardrails/http/telemetry.py b/nemoguardrails/http/telemetry.py new file mode 100644 index 0000000000..2c2cd6b6de --- /dev/null +++ b/nemoguardrails/http/telemetry.py @@ -0,0 +1,201 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging +import time +from contextlib import contextmanager, suppress +from dataclasses import dataclass +from typing import TYPE_CHECKING, Generator + +from nemoguardrails.http._url import sanitize_url, split_url +from nemoguardrails.http.types import HTTPResponse +from nemoguardrails.tracing.constants import HTTPAttributes, _ensure_http_instruments + +if TYPE_CHECKING: + from opentelemetry.trace import Span, Tracer + +log = logging.getLogger(__name__) + + +@dataclass(slots=True) +class _HTTPRequestDurationState: + """Carry response status from request execution to metric recording.""" + + response_status_code: int | None = None + + +def _http_metric_attributes(method: str, url: str) -> dict[str, str | int] | None: + parts = split_url(url) + if parts.hostname is None: + return None + port = parts.port + if port is None: + port = {"http": 80, "https": 443}.get(parts.scheme) + if port is None: + return None + return { + HTTPAttributes.REQUEST_METHOD: method, + HTTPAttributes.SERVER_ADDRESS: parts.hostname, + HTTPAttributes.SERVER_PORT: port, + } + + +@contextmanager +def http_request_duration( + method: str, + url: str, +) -> Generator[_HTTPRequestDurationState, None, None]: + """Record one HTTP client request-duration observation. + + The metric contains only low-cardinality endpoint attributes plus response + status or exception type. Telemetry failures never affect the request. + """ + state = _HTTPRequestDurationState() + try: + instruments = _ensure_http_instruments() + except Exception: + instruments = None + if instruments is None: + yield state + return + + started_at = time.monotonic() + error_type: str | None = None + try: + yield state + except BaseException as error: + error_type = type(error).__name__ + raise + finally: + with suppress(Exception): + attributes = _http_metric_attributes(method, url) + if attributes is not None: + if state.response_status_code is not None: + attributes[HTTPAttributes.RESPONSE_STATUS_CODE] = state.response_status_code + if state.response_status_code >= 400: + error_type = str(state.response_status_code) + if error_type is not None: + attributes[HTTPAttributes.ERROR_TYPE] = error_type + instruments.request_duration.record( + time.monotonic() - started_at, + attributes=attributes, + ) + + +def set_http_request_attributes( + span: "Span | None", + method: str, + url: str, + content: bytes | str | None, +) -> None: + """Record privacy-safe HTTP request attributes on a span. + + The URL is stripped of credentials, query parameters, and fragments. Raw + headers and body content are never recorded. + """ + if span is None: + return + with suppress(Exception): + parts = split_url(url) + span.set_attribute(HTTPAttributes.REQUEST_METHOD, method) + span.set_attribute(HTTPAttributes.URL_FULL, sanitize_url(url)) + if parts.scheme: + span.set_attribute(HTTPAttributes.URL_SCHEME, parts.scheme) + if parts.hostname: + span.set_attribute(HTTPAttributes.SERVER_ADDRESS, parts.hostname) + with suppress(ValueError): + if parts.port is not None: + span.set_attribute(HTTPAttributes.SERVER_PORT, parts.port) + if content is not None: + size = len(content) if isinstance(content, bytes) else len(content.encode()) + span.set_attribute(HTTPAttributes.REQUEST_BODY_SIZE, size) + + +def set_http_response_attributes(span: "Span | None", response: HTTPResponse) -> None: + """Record response status, body size, and retry count on a span.""" + if span is None: + return + with suppress(Exception): + from opentelemetry.trace import StatusCode + + span.set_attribute(HTTPAttributes.RESPONSE_STATUS_CODE, response.status_code) + span.set_attribute(HTTPAttributes.RESPONSE_BODY_SIZE, len(response.content)) + retry_count = response.extensions.get("retry_count") + if isinstance(retry_count, int) and retry_count > 0: + span.set_attribute(HTTPAttributes.REQUEST_RESEND_COUNT, retry_count) + if response.status_code >= 400: + span.set_attribute(HTTPAttributes.ERROR_TYPE, str(response.status_code)) + span.set_status(StatusCode.ERROR) + + +def record_http_error(span: "Span | None", error: BaseException) -> None: + """Record an exception type and retry count without exposing its message.""" + if span is None: + return + from opentelemetry.trace import StatusCode + + try: + span.set_attribute(HTTPAttributes.ERROR_TYPE, type(error).__name__) + retry_count = getattr(error, "retry_count", 0) + if isinstance(retry_count, int) and retry_count > 0: + span.set_attribute(HTTPAttributes.REQUEST_RESEND_COUNT, retry_count) + span.add_event("exception", {HTTPAttributes.EXCEPTION_TYPE: type(error).__name__}) + span.set_status(StatusCode.ERROR) + except Exception as telemetry_error: + log.warning( + "Failed to record HTTP error telemetry: %s", + type(telemetry_error).__name__, + ) + + +@contextmanager +def http_call_span( + tracer: "Tracer | None", + method: str, + url: str, + content: bytes | str | None, +) -> Generator["Span | None", None, None]: + """Create a privacy-safe client span for one HTTP call. + + Yields ``None`` when no tracer is configured. Request exceptions are + recorded and re-raised unchanged. + """ + if tracer is None: + yield None + return + + from opentelemetry.trace import SpanKind + + with tracer.start_as_current_span( + f"HTTP {method}", + kind=SpanKind.CLIENT, + record_exception=False, + set_status_on_exception=False, + ) as span: + set_http_request_attributes(span, method, url, content) + try: + yield span + except BaseException as error: + record_http_error(span, error) + raise + + +__all__ = [ + "http_call_span", + "http_request_duration", + "record_http_error", + "set_http_request_attributes", + "set_http_response_attributes", +] diff --git a/nemoguardrails/tracing/constants.py b/nemoguardrails/tracing/constants.py index bc44f66c91..290c0f8b45 100644 --- a/nemoguardrails/tracing/constants.py +++ b/nemoguardrails/tracing/constants.py @@ -14,9 +14,9 @@ # limitations under the License. """OpenTelemetry constants, semantic conventions, and engine-agnostic -GenAI client-side metric instruments for NeMo Guardrails. +client-side metric instruments for NeMo Guardrails. -The OTEL GenAI client-side metric helpers (``LLMInstruments``, +The OTEL client-side metric helpers (``LLMInstruments``, ``record_token_usage``, ``llm_operation_duration``, ``record_time_to_first_chunk``, ``record_time_per_output_chunk``) live here next to the metric-name and attribute constants they emit. They @@ -161,6 +161,22 @@ class GenAIAttributes: GEN_AI_SYSTEM_INSTRUCTIONS = "gen_ai.system_instructions" +class HTTPAttributes: + """HTTP semantic convention attributes.""" + + REQUEST_METHOD = "http.request.method" + REQUEST_BODY_SIZE = "http.request.body.size" + REQUEST_RESEND_COUNT = "http.request.resend_count" + RESPONSE_STATUS_CODE = "http.response.status_code" + RESPONSE_BODY_SIZE = "http.response.body.size" + URL_FULL = "url.full" + URL_SCHEME = "url.scheme" + SERVER_ADDRESS = "server.address" + SERVER_PORT = "server.port" + ERROR_TYPE = "error.type" + EXCEPTION_TYPE = "exception.type" + + class CommonAttributes: """Common OpenTelemetry attributes used across spans.""" @@ -242,7 +258,7 @@ class SpanNames: class MetricNames: - """OTEL metric names emitted by the IORails engine. + """OTEL metric names emitted by NeMo Guardrails. These names are part of the library's public API — customers point dashboards and alerts at them. Tests deliberately assert on the raw @@ -273,6 +289,7 @@ class MetricNames: GEN_AI_CLIENT_OPERATION_DURATION = "gen_ai.client.operation.duration" GEN_AI_CLIENT_OPERATION_TIME_TO_FIRST_CHUNK = "gen_ai.client.operation.time_to_first_chunk" GEN_AI_CLIENT_OPERATION_TIME_PER_OUTPUT_CHUNK = "gen_ai.client.operation.time_per_output_chunk" + HTTP_CLIENT_REQUEST_DURATION = "http.client.request.duration" class TokenType: @@ -356,6 +373,7 @@ class GuardrailsEventTypes: # first access is harmless because OTEL guarantees ``meter.create_*`` # returns equivalent instances for the same instrumentation scope. _llm_instruments: Optional["LLMInstruments"] = None +_http_instruments: Optional["HTTPInstruments"] = None @dataclass(frozen=True, slots=True) @@ -388,6 +406,31 @@ class LLMInstruments: time_per_output_chunk: "Histogram" +@dataclass(frozen=True, slots=True) +class HTTPInstruments: + """HTTP-client OpenTelemetry instruments shared across callers.""" + + request_duration: "Histogram" + + +_HTTP_DURATION_BUCKETS = [ + 0.005, + 0.01, + 0.025, + 0.05, + 0.075, + 0.1, + 0.25, + 0.5, + 0.75, + 1, + 2.5, + 5, + 7.5, + 10, +] + + # Bucket boundaries recommended in the OTEL GenAI semantic-conventions # spec page: # https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-metrics/#generative-ai-client-metrics @@ -474,6 +517,26 @@ def _ensure_llm_instruments() -> Optional[LLMInstruments]: return _llm_instruments +def _ensure_http_instruments() -> Optional[HTTPInstruments]: + """Lazily create HTTP instruments when an OpenTelemetry meter is available.""" + from nemoguardrails.guardrails.telemetry import get_meter + + global _http_instruments + meter = get_meter() + if meter is None: + return None + if _http_instruments is None: + _http_instruments = HTTPInstruments( + request_duration=meter.create_histogram( + MetricNames.HTTP_CLIENT_REQUEST_DURATION, + description="Duration of HTTP client requests", + unit="s", + explicit_bucket_boundaries_advisory=_HTTP_DURATION_BUCKETS, + ) + ) + return _http_instruments + + def _llm_call_attributes( model_name: str, provider_name: str, diff --git a/tests/http/test_instrumentation.py b/tests/http/test_instrumentation.py new file mode 100644 index 0000000000..bf61ac1afe --- /dev/null +++ b/tests/http/test_instrumentation.py @@ -0,0 +1,360 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import asyncio +import logging +import warnings +from unittest.mock import MagicMock, patch + +import pytest +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import InMemoryMetricReader +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 SpanKind, StatusCode + +from nemoguardrails.guardrails import telemetry as guardrails_telemetry +from nemoguardrails.http import ( + HTTPConnectionError, + HTTPResponse, + InstrumentedHTTPClient, + RetryingHTTPClient, + RetryPolicy, + instrument_http_client, +) +from nemoguardrails.http.telemetry import record_http_error +from nemoguardrails.testing.http import RecordingHTTPClient +from nemoguardrails.tracing import constants as tracing_constants +from nemoguardrails.tracing.constants import SystemConstants +from tests.guardrails.metric_helpers import collect_metric_points + + +@pytest.fixture +def otel(): + exporter = InMemorySpanExporter() + provider = TracerProvider() + provider.add_span_processor(SimpleSpanProcessor(exporter)) + return provider.get_tracer("test"), exporter + + +@pytest.fixture +def metric_reader(): + reader = InMemoryMetricReader() + provider = MeterProvider(metric_readers=[reader]) + previous_meter = guardrails_telemetry._meter + previous_instruments = tracing_constants._http_instruments + guardrails_telemetry._meter = provider.get_meter(SystemConstants.SYSTEM_NAME) + tracing_constants._http_instruments = None + yield reader + guardrails_telemetry._meter = previous_meter + tracing_constants._http_instruments = previous_instruments + + +@pytest.mark.asyncio +async def test_instrumented_client_records_safe_http_attributes(otel): + tracer, exporter = otel + transport = RecordingHTTPClient( + [ + HTTPResponse( + status_code=200, + headers={"Content-Type": "application/json", "Set-Cookie": "session=secret"}, + content=b'{"token":"response-secret"}', + extensions={"retry_count": 2}, + ) + ] + ) + client = InstrumentedHTTPClient(transport, tracer) + + response = await client.request( + "post", + "https://user:password@example.com:8443/check?api_key=query-secret#fragment", + headers={"Authorization": "Bearer header-secret", "Content-Type": "application/json"}, + params={"token": "param-secret"}, + json={"token": "request-secret"}, + ) + + assert response.status_code == 200 + spans = exporter.get_finished_spans() + assert len(spans) == 1 + span = spans[0] + assert span.name == "HTTP POST" + assert span.kind == SpanKind.CLIENT + assert span.status.status_code == StatusCode.UNSET + assert span.attributes == { + "http.request.method": "POST", + "url.full": "https://example.com:8443/check", + "url.scheme": "https", + "server.address": "example.com", + "server.port": 8443, + "http.response.status_code": 200, + "http.response.body.size": 27, + "http.request.resend_count": 2, + } + assert span.events == () + serialized = repr(span.attributes) + assert "password" not in serialized + assert "secret" not in serialized + + +@pytest.mark.asyncio +async def test_instrumented_client_creates_one_observation_for_all_retry_attempts(otel, metric_reader): + tracer, exporter = otel + transport = RecordingHTTPClient([HTTPResponse(status_code=503), HTTPResponse(status_code=200)]) + + async def sleep(delay: float) -> None: + return None + + retrying = RetryingHTTPClient( + transport, + RetryPolicy(retryable_methods=frozenset({"POST"})), + sleep=sleep, + ) + client = InstrumentedHTTPClient(retrying, tracer, metrics_enabled=True) + + await client.request("POST", "https://example.com/check", json={"text": "hello"}) + + spans = exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].attributes["http.request.resend_count"] == 1 + assert len(collect_metric_points(metric_reader)["http.client.request.duration"]) == 1 + assert len(transport.requests) == 2 + + +@pytest.mark.asyncio +async def test_instrumented_client_records_status_errors(otel): + tracer, exporter = otel + client = InstrumentedHTTPClient(RecordingHTTPClient([HTTPResponse(status_code=503)]), tracer) + + response = await client.request("GET", "https://example.com/check") + + assert response.status_code == 503 + span = exporter.get_finished_spans()[0] + assert span.attributes["error.type"] == "503" + assert span.status.status_code == StatusCode.ERROR + + +@pytest.mark.asyncio +async def test_instrumented_client_preserves_exceptions(otel): + tracer, exporter = otel + error = HTTPConnectionError("request failed with token=secret") + error.retry_count = 2 + client = InstrumentedHTTPClient(RecordingHTTPClient([error]), tracer) + + with pytest.raises(HTTPConnectionError) as exc_info: + await client.request("GET", "https://example.com/check") + + assert exc_info.value is error + span = exporter.get_finished_spans()[0] + assert span.attributes["error.type"] == "HTTPConnectionError" + assert span.attributes["http.request.resend_count"] == 2 + assert span.status.status_code == StatusCode.ERROR + assert span.events[0].name == "exception" + assert span.events[0].attributes == {"exception.type": "HTTPConnectionError"} + assert "secret" not in repr(span.events) + + +def test_instrumented_client_reports_error_telemetry_failures(caplog): + span = MagicMock() + span.set_attribute.side_effect = TypeError("invalid attribute") + + with caplog.at_level(logging.WARNING): + record_http_error(span, HTTPConnectionError("token=secret")) + + assert "Failed to record HTTP error telemetry: TypeError" in caplog.text + assert "secret" not in caplog.text + + +@pytest.mark.asyncio +async def test_instrumented_client_uses_active_parent(otel): + tracer, exporter = otel + client = InstrumentedHTTPClient(RecordingHTTPClient([HTTPResponse(status_code=200)]), tracer) + + with tracer.start_as_current_span("parent") as parent: + parent_span_id = parent.get_span_context().span_id + await client.request("GET", "https://example.com/check") + + spans = {span.name: span for span in exporter.get_finished_spans()} + assert spans["HTTP GET"].parent.span_id == parent_span_id + + +@pytest.mark.asyncio +async def test_disabled_instrumentation_is_a_passthrough(otel): + _, exporter = otel + response = HTTPResponse(status_code=200) + transport = RecordingHTTPClient([response]) + client = InstrumentedHTTPClient(transport, None) + + result = await client.request("GET", "https://example.com/check") + + assert result is response + assert exporter.get_finished_spans() == () + + +@pytest.mark.asyncio +async def test_instrumented_client_closes_wrapped_client_once(): + transport = RecordingHTTPClient() + client = InstrumentedHTTPClient(transport, None) + + await asyncio.gather(client.close(), client.close()) + + assert transport.close_calls == 1 + + +@pytest.mark.asyncio +async def test_instrumentation_is_idempotent(otel, metric_reader): + tracer, exporter = otel + transport = RecordingHTTPClient([HTTPResponse(status_code=200)]) + + first = instrument_http_client(transport, tracer=tracer, metrics_enabled=True) + second = instrument_http_client(first, tracer=tracer, metrics_enabled=True) + third = InstrumentedHTTPClient(second, tracer, metrics_enabled=True) + + assert second is first + assert third is first + assert isinstance(first, InstrumentedHTTPClient) + assert first.wrapped_client is transport + + await third.request("GET", "https://example.com/check") + + assert len(exporter.get_finished_spans()) == 1 + assert len(collect_metric_points(metric_reader)["http.client.request.duration"]) == 1 + + +def test_reinstrument_with_changed_tracer_warns_and_keeps_original(otel): + tracer, _ = otel + original = InstrumentedHTTPClient(RecordingHTTPClient(), tracer) + + with pytest.warns(UserWarning, match="already instrumented"): + result = InstrumentedHTTPClient(original, MagicMock()) + + assert result is original + + +def test_reinstrument_with_identical_tracer_does_not_warn(otel): + tracer, _ = otel + original = InstrumentedHTTPClient(RecordingHTTPClient(), tracer) + + with warnings.catch_warnings(): + warnings.simplefilter("error") + result = InstrumentedHTTPClient(original, tracer) + + assert result is original + + +def test_instrument_http_client_disabled_returns_original(): + transport = RecordingHTTPClient() + + assert instrument_http_client(transport) is transport + + +@pytest.mark.asyncio +async def test_instrumented_client_preserves_wrapped_response_and_ownership(otel): + tracer, exporter = otel + response = HTTPResponse(status_code=200, content=b"ok") + transport = RecordingHTTPClient([response]) + client = instrument_http_client(transport, tracer=tracer) + + result = await client.request("GET", "https://example.com/check") + + assert result is response + assert len(exporter.get_finished_spans()) == 1 + assert transport.close_calls == 0 + + +@pytest.mark.asyncio +async def test_attribute_telemetry_failures_do_not_change_http_result(): + span = MagicMock() + span.set_attribute.side_effect = TypeError("invalid attribute") + tracer = MagicMock() + tracer.start_as_current_span.return_value.__enter__.return_value = span + response = HTTPResponse(status_code=200, content=b"ok") + client = InstrumentedHTTPClient(RecordingHTTPClient([response]), tracer) + + result = await client.request("GET", "https://example.com/check") + + assert result is response + + +@pytest.mark.asyncio +async def test_metrics_only_records_http_request_duration(metric_reader): + response = HTTPResponse(status_code=200) + transport = RecordingHTTPClient([response]) + client = instrument_http_client(transport, metrics_enabled=True) + + result = await client.request("post", "https://example.com/check") + + assert result is response + points = collect_metric_points(metric_reader)["http.client.request.duration"] + assert len(points) == 1 + assert points[0].attributes == { + "http.request.method": "POST", + "server.address": "example.com", + "server.port": 443, + "http.response.status_code": 200, + } + + +@pytest.mark.asyncio +async def test_http_error_metrics_include_error_type(metric_reader): + response = HTTPResponse(status_code=503) + client = instrument_http_client( + RecordingHTTPClient([response]), + metrics_enabled=True, + ) + + result = await client.request("GET", "http://example.com/check") + + assert result is response + point = collect_metric_points(metric_reader)["http.client.request.duration"][0] + assert point.attributes["server.port"] == 80 + assert point.attributes["http.response.status_code"] == 503 + assert point.attributes["error.type"] == "503" + + +@pytest.mark.asyncio +async def test_http_exception_metrics_include_error_type(metric_reader): + error = HTTPConnectionError("unavailable") + client = instrument_http_client( + RecordingHTTPClient([error]), + metrics_enabled=True, + ) + + with pytest.raises(HTTPConnectionError) as exc_info: + await client.request("GET", "https://example.com/check") + + assert exc_info.value is error + point = collect_metric_points(metric_reader)["http.client.request.duration"][0] + assert point.attributes["error.type"] == "HTTPConnectionError" + assert "http.response.status_code" not in point.attributes + + +@pytest.mark.asyncio +async def test_metric_failures_do_not_change_http_result(): + instruments = MagicMock() + instruments.request_duration.record.side_effect = TypeError("invalid metric") + response = HTTPResponse(status_code=200) + client = instrument_http_client( + RecordingHTTPClient([response]), + metrics_enabled=True, + ) + + with patch( + "nemoguardrails.http.telemetry._ensure_http_instruments", + return_value=instruments, + ): + result = await client.request("GET", "https://example.com/check") + + assert result is response