diff --git a/CHANGELOG.md b/CHANGELOG.md index 973f9c2c7f..683f10a018 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ([#3938](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3938)) - `opentelemetry-instrumentation-aiohttp-server`: Support passing `TracerProvider` when instrumenting. ([#3819](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3819)) +- `opentelemetry-instrumentation-httpx`: add ability to capture custom headers + ([#4047](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4047)) ### Fixed diff --git a/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py b/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py index 67dc9aa960..882552d09b 100644 --- a/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py @@ -217,6 +217,97 @@ async def async_response_hook(span, request, response): will exclude requests such as ``https://site/client/123/info`` and ``https://site/xyz/healthcheck``. +Capture HTTP request and response headers +***************************************** +You can configure the agent to capture specified HTTP headers as span attributes, according to the +`semantic conventions `_. + +Request headers +*************** +To capture HTTP request headers as span attributes, set the environment variable +``OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST`` to a comma delimited list of HTTP header names. + +For example using the environment variable, +:: + + export OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST="content-type,custom_request_header" + +will extract ``content-type`` and ``custom_request_header`` from the request headers and add them as span attributes. + +Request header names in HttpX are case-insensitive. So, giving the header name as ``CUStom-Header`` in the environment +variable will capture the header named ``custom-header``. + +Regular expressions may also be used to match multiple headers that correspond to the given pattern. For example: +:: + + export OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST="Accept.*,X-.*" + +Would match all request headers that start with ``Accept`` and ``X-``. + +To capture all request headers, set ``OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST`` to ``".*"``. +:: + + export OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST=".*" + +The name of the added span attribute will follow the format ``http.request.header.`` where ```` +is the normalized HTTP header name (lowercase, with ``-`` replaced by ``_``). The value of the attribute will be a +single item list containing all the header values. + +For example: +``http.request.header.custom_request_header = ["", ""]`` + +Response headers +**************** +To capture HTTP response headers as span attributes, set the environment variable +``OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_RESPONSE`` to a comma delimited list of HTTP header names. + +For example using the environment variable, +:: + + export OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_RESPONSE="content-type,custom_response_header" + +will extract ``content-type`` and ``custom_response_header`` from the response headers and add them as span attributes. + +Response header names in HttpX are case-insensitive. So, giving the header name as ``CUStom-Header`` in the environment +variable will capture the header named ``custom-header``. + +Regular expressions may also be used to match multiple headers that correspond to the given pattern. For example: +:: + + export OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_RESPONSE="Content.*,X-.*" + +Would match all response headers that start with ``Content`` and ``X-``. + +To capture all response headers, set ``OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_RESPONSE`` to ``".*"``. +:: + + export OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_RESPONSE=".*" + +The name of the added span attribute will follow the format ``http.response.header.`` where ```` +is the normalized HTTP header name (lowercase, with ``-`` replaced by ``_``). The value of the attribute will be a +list containing the header values. + +For example: +``http.response.header.custom_response_header = ["", ""]`` + +Sanitizing headers +****************** +In order to prevent storing sensitive data such as personally identifiable information (PII), session keys, passwords, +etc, set the environment variable ``OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS`` +to a comma delimited list of HTTP header names to be sanitized. + +Regexes may be used, and all header names will be matched in a case-insensitive manner. + +For example using the environment variable, +:: + + export OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS=".*session.*,set-cookie" + +will replace the value of headers such as ``session-id`` and ``set-cookie`` with ``[REDACTED]`` in the span. + +Note: + The environment variable names used to capture HTTP headers are still experimental, and thus are subject to change. + API --- """ @@ -225,6 +316,7 @@ async def async_response_hook(span, request, response): import logging import typing +from collections import defaultdict from functools import partial from inspect import iscoroutinefunction from timeit import default_timer @@ -278,8 +370,15 @@ async def async_response_hook(span, request, response): from opentelemetry.trace.span import Span from opentelemetry.trace.status import StatusCode from opentelemetry.util.http import ( + OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST, + OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_RESPONSE, + OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS, ExcludeList, + get_custom_header_attributes, + get_custom_headers, get_excluded_urls, + normalise_request_header_name, + normalise_response_header_name, redact_url, sanitize_method, ) @@ -379,6 +478,32 @@ def _inject_propagation_headers(headers, args, kwargs): kwargs["headers"] = _headers.raw +def _normalize_headers( + headers: httpx.Headers + | dict[str, list[str] | str] + | list[tuple[bytes, bytes]] + | None, +) -> dict[str, list[str]]: + normalized_headers: defaultdict[str, list[str]] = defaultdict(list) + if isinstance(headers, httpx.Headers): + for key in headers.keys(): + normalized_headers[key.lower()].extend( + headers.get_list(key, split_commas=True) + ) + elif isinstance(headers, dict): + for key, value in headers.items(): + if isinstance(value, list): + normalized_headers[key.lower()].extend(value) + else: + normalized_headers[key.lower()].append(value) + elif isinstance(headers, list): + for key, value in headers: + normalized_headers[key.decode("latin-1").lower()].append( + value.decode("latin-1") + ) + return dict(normalized_headers) + + def _extract_response( response: httpx.Response | tuple[int, httpx.Headers, httpx.SyncByteStream, dict[str, typing.Any]], @@ -410,6 +535,9 @@ def _apply_request_client_attributes_to_span( url: str | httpx.URL, method_original: str, semconv: _StabilityMode, + headers: httpx.Headers | dict[str, list[str] | str] | None = None, + captured_headers: list[str] | None = None, + sensitive_headers: list[str] | None = None, ): url = httpx.URL(url) # http semconv transition: http.method -> http.request.method @@ -431,6 +559,15 @@ def _apply_request_client_attributes_to_span( semconv, ) + span_attributes.update( + get_custom_header_attributes( + _normalize_headers(headers), + captured_headers, + sensitive_headers, + normalise_request_header_name, + ) + ) + if _report_old(semconv): # TODO: Support opt-in for url.scheme in new semconv _set_http_scheme(metric_attributes, url.scheme, semconv) @@ -459,6 +596,9 @@ def _apply_response_client_attributes_to_span( status_code: int, http_version: str, semconv: _StabilityMode, + headers: httpx.Headers | dict[str, list[str] | str] | None = None, + captured_headers: list[str] | None = None, + sensitive_headers: list[str] | None = None, ): # http semconv transition: http.status_code -> http.response.status_code # TODO: use _set_status when it's stable for http clients @@ -471,6 +611,15 @@ def _apply_response_client_attributes_to_span( http_status_code = http_status_to_status_code(status_code) span.set_status(http_status_code) + span.set_attributes( + get_custom_header_attributes( + _normalize_headers(headers), + captured_headers, + sensitive_headers, + normalise_response_header_name, + ) + ) + if http_status_code == StatusCode.ERROR and _report_new(semconv): # http semconv transition: new error.type span_attributes[ERROR_TYPE] = str(status_code) @@ -573,6 +722,15 @@ def __init__( self._request_hook = request_hook self._response_hook = response_hook self._excluded_urls = get_excluded_urls("HTTPX") + self._captured_request_headers = get_custom_headers( + OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST + ) + self._captured_response_headers = get_custom_headers( + OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_RESPONSE + ) + self._sensitive_headers = get_custom_headers( + OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS + ) def __enter__(self) -> SyncOpenTelemetryTransport: self._transport.__enter__() @@ -619,6 +777,9 @@ def handle_request( url, method_original, self._sem_conv_opt_in_mode, + headers, + self._captured_request_headers, + self._sensitive_headers, ) request_info = RequestInfo(method, url, headers, stream, extensions) @@ -663,6 +824,9 @@ def handle_request( status_code, http_version, self._sem_conv_opt_in_mode, + headers, + self._captured_response_headers, + self._sensitive_headers, ) if callable(self._response_hook): self._response_hook( @@ -773,6 +937,15 @@ def __init__( self._request_hook = request_hook self._response_hook = response_hook self._excluded_urls = get_excluded_urls("HTTPX") + self._captured_request_headers = get_custom_headers( + OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST + ) + self._captured_response_headers = get_custom_headers( + OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_RESPONSE + ) + self._sensitive_headers = get_custom_headers( + OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS + ) async def __aenter__(self) -> "AsyncOpenTelemetryTransport": await self._transport.__aenter__() @@ -817,6 +990,9 @@ async def handle_async_request( url, method_original, self._sem_conv_opt_in_mode, + headers, + self._captured_request_headers, + self._sensitive_headers, ) request_info = RequestInfo(method, url, headers, stream, extensions) @@ -863,6 +1039,9 @@ async def handle_async_request( status_code, http_version, self._sem_conv_opt_in_mode, + headers, + self._captured_response_headers, + self._sensitive_headers, ) if callable(self._response_hook): @@ -923,6 +1102,7 @@ class HTTPXClientInstrumentor(BaseInstrumentor): def instrumentation_dependencies(self) -> typing.Collection[str]: return _instruments + # pylint: disable=too-many-locals def _instrument(self, **kwargs: typing.Any): """Instruments httpx Client and AsyncClient @@ -954,6 +1134,15 @@ def _instrument(self, **kwargs: typing.Any): else None ) excluded_urls = get_excluded_urls("HTTPX") + captured_request_headers: list[str] = get_custom_headers( + OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST + ) + captured_response_headers: list[str] = get_custom_headers( + OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_RESPONSE + ) + sensitive_headers: list[str] = get_custom_headers( + OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS + ) _OpenTelemetrySemanticConventionStability._initialize() sem_conv_opt_in_mode = _OpenTelemetrySemanticConventionStability._get_opentelemetry_stability_opt_in_mode( @@ -1003,6 +1192,9 @@ def _instrument(self, **kwargs: typing.Any): request_hook=request_hook, response_hook=response_hook, excluded_urls=excluded_urls, + captured_request_headers=captured_request_headers, + captured_response_headers=captured_response_headers, + sensitive_headers=sensitive_headers, ), ) wrap_function_wrapper( @@ -1017,6 +1209,9 @@ def _instrument(self, **kwargs: typing.Any): async_request_hook=async_request_hook, async_response_hook=async_response_hook, excluded_urls=excluded_urls, + captured_request_headers=captured_request_headers, + captured_response_headers=captured_response_headers, + sensitive_headers=sensitive_headers, ), ) @@ -1037,6 +1232,9 @@ def _handle_request_wrapper( # pylint: disable=too-many-locals request_hook: RequestHook, response_hook: ResponseHook, excluded_urls: ExcludeList | None, + captured_request_headers: list[str] | None = None, + captured_response_headers: list[str] | None = None, + sensitive_headers: list[str] | None = None, ): if not is_http_instrumentation_enabled(): return wrapped(*args, **kwargs) @@ -1059,6 +1257,9 @@ def _handle_request_wrapper( # pylint: disable=too-many-locals url, method_original, sem_conv_opt_in_mode, + headers, + captured_request_headers, + sensitive_headers, ) request_info = RequestInfo(method, url, headers, stream, extensions) @@ -1103,7 +1304,11 @@ def _handle_request_wrapper( # pylint: disable=too-many-locals status_code, http_version, sem_conv_opt_in_mode, + headers, + captured_response_headers, + sensitive_headers, ) + if callable(response_hook): response_hook( span, @@ -1158,6 +1363,9 @@ async def _handle_async_request_wrapper( # pylint: disable=too-many-locals async_request_hook: AsyncRequestHook, async_response_hook: AsyncResponseHook, excluded_urls: ExcludeList | None, + captured_request_headers: typing.Optional[list[str]] = None, + captured_response_headers: typing.Optional[list[str]] = None, + sensitive_headers: typing.Optional[list[str]] = None, ): if not is_http_instrumentation_enabled(): return await wrapped(*args, **kwargs) @@ -1180,6 +1388,9 @@ async def _handle_async_request_wrapper( # pylint: disable=too-many-locals url, method_original, sem_conv_opt_in_mode, + headers, + captured_request_headers, + sensitive_headers, ) request_info = RequestInfo(method, url, headers, stream, extensions) @@ -1224,6 +1435,9 @@ async def _handle_async_request_wrapper( # pylint: disable=too-many-locals status_code, http_version, sem_conv_opt_in_mode, + headers, + captured_response_headers, + sensitive_headers, ) if callable(async_response_hook): @@ -1341,6 +1555,15 @@ def instrument_client( async_response_hook = None excluded_urls = get_excluded_urls("HTTPX") + captured_request_headers: list[str] = get_custom_headers( + OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST + ) + captured_response_headers: list[str] = get_custom_headers( + OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_RESPONSE + ) + sensitive_headers: list[str] = get_custom_headers( + OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS + ) if hasattr(client._transport, "handle_request"): wrap_function_wrapper( @@ -1355,6 +1578,9 @@ def instrument_client( request_hook=request_hook, response_hook=response_hook, excluded_urls=excluded_urls, + captured_request_headers=captured_request_headers, + captured_response_headers=captured_response_headers, + sensitive_headers=sensitive_headers, ), ) for transport in client._mounts.values(): @@ -1371,6 +1597,9 @@ def instrument_client( request_hook=request_hook, response_hook=response_hook, excluded_urls=excluded_urls, + captured_request_headers=captured_request_headers, + captured_response_headers=captured_response_headers, + sensitive_headers=sensitive_headers, ), ) client._is_instrumented_by_opentelemetry = True @@ -1387,6 +1616,9 @@ def instrument_client( async_request_hook=async_request_hook, async_response_hook=async_response_hook, excluded_urls=excluded_urls, + captured_request_headers=captured_request_headers, + captured_response_headers=captured_response_headers, + sensitive_headers=sensitive_headers, ), ) for transport in client._mounts.values(): @@ -1403,6 +1635,9 @@ def instrument_client( async_request_hook=async_request_hook, async_response_hook=async_response_hook, excluded_urls=excluded_urls, + captured_request_headers=captured_request_headers, + captured_response_headers=captured_response_headers, + sensitive_headers=sensitive_headers, ), ) client._is_instrumented_by_opentelemetry = True diff --git a/instrumentation/opentelemetry-instrumentation-httpx/tests/test_httpx_integration.py b/instrumentation/opentelemetry-instrumentation-httpx/tests/test_httpx_integration.py index 909ec33009..86eb3ae2f8 100644 --- a/instrumentation/opentelemetry-instrumentation-httpx/tests/test_httpx_integration.py +++ b/instrumentation/opentelemetry-instrumentation-httpx/tests/test_httpx_integration.py @@ -1011,6 +1011,422 @@ def test_ignores_excluded_urls(self): client=client ) + def test_request_header_capture(self): + test_cases = [ + { + "name": "single_header", + "env_vars": { + "OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST": "X-Custom-Header", + }, + "request_headers": {"X-Custom-Header": "custom-value"}, + "expected_attrs": { + "http.request.header.x_custom_header": ( + "custom-value", + ), + }, + }, + { + "name": "multiple_headers", + "env_vars": { + "OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST": "X-First-Header,X-Second-Header", + }, + "request_headers": { + "X-First-Header": "value1", + "X-Second-Header": "value2", + }, + "expected_attrs": { + "http.request.header.x_first_header": ("value1",), + "http.request.header.x_second_header": ("value2",), + }, + }, + { + "name": "regex_pattern", + "env_vars": { + "OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST": "X-Test-.*", + }, + "request_headers": { + "X-Test-One": "one", + "X-Test-Two": "two", + "X-Not-Matched": "ignored", + }, + "expected_attrs": { + "http.request.header.x_test_one": ("one",), + "http.request.header.x_test_two": ("two",), + }, + "unexpected_attrs": ["http.request.header.x_not_matched"], + }, + { + "name": "wildcard_all_headers", + "env_vars": { + "OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST": ".*", + }, + "request_headers": { + "X-Any-Header": "any-value", + }, + "expected_attrs": { + "http.request.header.x_any_header": ("any-value",), + }, + }, + { + "name": "case_insensitive", + "env_vars": { + "OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST": "x-lowercase-config", + }, + "request_headers": {"X-Lowercase-Config": "mixed-case"}, + "expected_attrs": { + "http.request.header.x_lowercase_config": ( + "mixed-case", + ), + }, + }, + ] + + for test_case in test_cases: + with self.subTest(name=test_case["name"]): + self.memory_exporter.clear() + with mock.patch.dict( + "os.environ", + test_case["env_vars"], + ): + client = self.create_client() + HTTPXClientInstrumentor().instrument_client(client) + self.perform_request( + self.URL, + headers=test_case["request_headers"], + client=client, + ) + + span = self.assert_span() + for attr_name, expected_value in test_case[ + "expected_attrs" + ].items(): + self.assertEqual( + span.attributes.get(attr_name), + expected_value, + f"Expected {attr_name} to be {expected_value}", + ) + for attr_name in test_case.get("unexpected_attrs", []): + self.assertIsNone( + span.attributes.get(attr_name), + f"Expected {attr_name} to not be present", + ) + HTTPXClientInstrumentor().uninstrument_client(client) + + def test_response_header_capture(self): + test_cases = [ + { + "name": "single_header", + "env_vars": { + "OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_RESPONSE": "X-Response-Header", + }, + "response_headers": { + "X-Response-Header": "response-value" + }, + "expected_attrs": { + "http.response.header.x_response_header": ( + "response-value", + ), + }, + }, + { + "name": "multiple_headers", + "env_vars": { + "OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_RESPONSE": "X-Response-One,X-Response-Two", + }, + "response_headers": { + "X-Response-One": "value1", + "X-Response-Two": "value2", + }, + "expected_attrs": { + "http.response.header.x_response_one": ("value1",), + "http.response.header.x_response_two": ("value2",), + }, + }, + { + "name": "regex_pattern", + "env_vars": { + "OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_RESPONSE": "X-Custom-.*", + }, + "response_headers": { + "X-Custom-First": "first", + "X-Custom-Second": "second", + "Content-Type": "text/plain", + }, + "expected_attrs": { + "http.response.header.x_custom_first": ("first",), + "http.response.header.x_custom_second": ("second",), + }, + "unexpected_attrs": ["http.response.header.content_type"], + }, + { + "name": "wildcard_all_headers", + "env_vars": { + "OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_RESPONSE": ".*", + }, + "response_headers": {"X-Any-Response": "any-value"}, + "expected_attrs": { + "http.response.header.x_any_response": ("any-value",), + }, + }, + ] + + for test_case in test_cases: + with self.subTest(name=test_case["name"]): + self.memory_exporter.clear() + respx.get(self.URL).mock( + httpx.Response( + 200, + text="Test", + headers=test_case["response_headers"], + ) + ) + + with mock.patch.dict("os.environ", test_case["env_vars"]): + client = self.create_client() + HTTPXClientInstrumentor().instrument_client(client) + self.perform_request(self.URL, client=client) + + span = self.assert_span() + for attr_name, expected_value in test_case[ + "expected_attrs" + ].items(): + self.assertEqual( + span.attributes.get(attr_name), + expected_value, + f"Expected {attr_name} to be {expected_value}", + ) + for attr_name in test_case.get("unexpected_attrs", []): + self.assertIsNone( + span.attributes.get(attr_name), + f"Expected {attr_name} to not be present", + ) + HTTPXClientInstrumentor().uninstrument_client(client) + + def test_header_sanitization(self): + test_cases = [ + { + "name": "request_header_sanitization", + "env_vars": { + "OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST": "Authorization,X-Api-Key,X-Safe-Header", + "OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS": "authorization,.*api-key.*", + }, + "request_headers": { + "Authorization": "Bearer secret", + "X-Api-Key": "secret-key", + "X-Safe-Header": "safe-value", + }, + "response_headers": {}, + "expected_attrs": { + "http.request.header.authorization": ("[REDACTED]",), + "http.request.header.x_api_key": ("[REDACTED]",), + "http.request.header.x_safe_header": ("safe-value",), + }, + }, + { + "name": "response_header_sanitization", + "env_vars": { + "OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_RESPONSE": "X-Secret-Token,X-Normal-Header", + "OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS": ".*secret.*", + }, + "request_headers": {}, + "response_headers": { + "X-Secret-Token": "secret-value", + "X-Normal-Header": "normal-value", + }, + "expected_attrs": { + "http.response.header.x_secret_token": ("[REDACTED]",), + "http.response.header.x_normal_header": ( + "normal-value", + ), + }, + }, + { + "name": "both_request_and_response_sanitization", + "env_vars": { + "OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST": "X-Secret-Request", + "OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_RESPONSE": "X-Secret-Response", + "OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS": ".*secret.*", + }, + "request_headers": {"X-Secret-Request": "req-secret"}, + "response_headers": {"X-Secret-Response": "resp-secret"}, + "expected_attrs": { + "http.request.header.x_secret_request": ( + "[REDACTED]", + ), + "http.response.header.x_secret_response": ( + "[REDACTED]", + ), + }, + }, + ] + + for test_case in test_cases: + with self.subTest(name=test_case["name"]): + self.memory_exporter.clear() + respx.get(self.URL).mock( + httpx.Response( + 200, + text="Test", + headers=test_case.get("response_headers") or {}, + ) + ) + + with mock.patch.dict("os.environ", test_case["env_vars"]): + client = self.create_client() + HTTPXClientInstrumentor().instrument_client(client) + self.perform_request( + self.URL, + headers=test_case.get("request_headers") or None, + client=client, + ) + + span = self.assert_span() + for attr_name, expected_value in test_case[ + "expected_attrs" + ].items(): + self.assertEqual( + span.attributes.get(attr_name), + expected_value, + f"Expected {attr_name} to be {expected_value}", + ) + HTTPXClientInstrumentor().uninstrument_client(client) + + def test_no_headers_captured_when_not_configured(self): + respx.get(self.URL).mock( + httpx.Response( + 200, + text="Test", + headers={"X-Custom-Response": "value"}, + ) + ) + + client = self.create_client() + HTTPXClientInstrumentor().instrument_client(client) + self.perform_request( + self.URL, + headers={"X-Custom-Request": "value"}, + client=client, + ) + + span = self.assert_span() + self.assertIsNone( + span.attributes.get("http.request.header.x_custom_request") + ) + self.assertIsNone( + span.attributes.get("http.response.header.x_custom_response") + ) + HTTPXClientInstrumentor().uninstrument_client(client) + + def test_both_request_and_response_headers_captured(self): + """Test capturing both request and response headers simultaneously.""" + respx.get(self.URL).mock( + httpx.Response( + 200, + text="Test", + headers={"X-Response-Id": "response-123"}, + ) + ) + + with mock.patch.dict( + "os.environ", + { + "OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST": "X-Request-Id", + "OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_RESPONSE": "X-Response-Id", + }, + ): + client = self.create_client() + HTTPXClientInstrumentor().instrument_client(client) + self.perform_request( + self.URL, + headers={"X-Request-Id": "req-456"}, + client=client, + ) + + span = self.assert_span() + self.assertEqual( + span.attributes.get("http.request.header.x_request_id"), + ("req-456",), + ) + self.assertEqual( + span.attributes.get("http.response.header.x_response_id"), + ("response-123",), + ) + HTTPXClientInstrumentor().uninstrument_client(client) + + def test_header_capture_via_transport(self): + test_cases = [ + { + "name": "request_header_capture", + "env_vars": { + "OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST": "X-Transport-Request", + }, + "request_headers": {"X-Transport-Request": "req-value"}, + "response_headers": {}, + "expected_attrs": { + "http.request.header.x_transport_request": ( + "req-value", + ), + }, + }, + { + "name": "response_header_capture", + "env_vars": { + "OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_RESPONSE": "X-Transport-Response", + }, + "request_headers": {}, + "response_headers": {"X-Transport-Response": "resp-value"}, + "expected_attrs": { + "http.response.header.x_transport_response": ( + "resp-value", + ), + }, + }, + { + "name": "header_sanitization", + "env_vars": { + "OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST": "X-Secret", + "OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_RESPONSE": "X-Secret", + "OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS": "x-secret", + }, + "request_headers": {"X-Secret": "secret-request"}, + "response_headers": {"X-Secret": "secret-response"}, + "expected_attrs": { + "http.request.header.x_secret": ("[REDACTED]",), + "http.response.header.x_secret": ("[REDACTED]",), + }, + }, + ] + + for test_case in test_cases: + with self.subTest(name=test_case["name"]): + self.memory_exporter.clear() + respx.get(self.URL).mock( + httpx.Response( + 200, + text="Test", + headers=test_case.get("response_headers") or {}, + ) + ) + + with mock.patch.dict("os.environ", test_case["env_vars"]): + transport = self.create_transport() + client = self.create_client(transport) + self.perform_request( + self.URL, + headers=test_case.get("request_headers") or None, + client=client, + ) + + span = self.assert_span() + for attr_name, expected_value in test_case[ + "expected_attrs" + ].items(): + self.assertEqual( + span.attributes.get(attr_name), + expected_value, + f"Expected {attr_name} to be {expected_value}", + ) + @mock.patch.dict("os.environ", {"NO_PROXY": ""}, clear=True) class BaseInstrumentorTest(BaseTest, metaclass=abc.ABCMeta): @abc.abstractmethod @@ -1388,6 +1804,94 @@ def test_ignores_excluded_urls(self): self.assert_metrics(num_metrics=0) HTTPXClientInstrumentor().uninstrument_client(client) + def test_header_capture_with_instrument(self): + test_cases = [ + { + "name": "request_header_capture", + "env_vars": { + "OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST": "X-Request", + }, + "request_headers": {"X-Request": "req-value"}, + "response_headers": {}, + "expected_attrs": { + "http.request.header.x_request": ("req-value",), + }, + }, + { + "name": "response_header_capture", + "env_vars": { + "OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_RESPONSE": "X-Response", + }, + "request_headers": {}, + "response_headers": {"X-Response": "resp-value"}, + "expected_attrs": { + "http.response.header.x_response": ("resp-value",), + }, + }, + { + "name": "header_capture_and_sanitization", + "env_vars": { + "OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST": "X-Public-Request,X-Secret-Request", + "OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_RESPONSE": "X-Public-Response,X-Secret-Response", + "OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS": ".*secret.*", + }, + "request_headers": { + "X-Public-Request": "public-req", + "X-Secret-Request": "secret-req", + }, + "response_headers": { + "X-Public-Response": "public-resp", + "X-Secret-Response": "secret-resp", + }, + "expected_attrs": { + "http.request.header.x_public_request": ( + "public-req", + ), + "http.request.header.x_secret_request": ( + "[REDACTED]", + ), + "http.response.header.x_public_response": ( + "public-resp", + ), + "http.response.header.x_secret_response": ( + "[REDACTED]", + ), + }, + }, + ] + + for test_case in test_cases: + with self.subTest(name=test_case["name"]): + self.memory_exporter.clear() + HTTPXClientInstrumentor().uninstrument() + + respx.get(self.URL).mock( + httpx.Response( + 200, + text="Test", + headers=test_case.get("response_headers") or {}, + ) + ) + + with mock.patch.dict("os.environ", test_case["env_vars"]): + HTTPXClientInstrumentor().instrument() + client = self.create_client() + self.perform_request( + self.URL, + headers=test_case.get("request_headers") or None, + client=client, + ) + + span = self.assert_span() + for attr_name, expected_value in test_case[ + "expected_attrs" + ].items(): + self.assertEqual( + span.attributes.get(attr_name), + expected_value, + f"Expected {attr_name} to be {expected_value}", + ) + class TestSyncIntegration(BaseTestCases.BaseManualTest): def setUp(self):