From 4dd75fd398ee984935294b903c08525064ed2334 Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Tue, 25 Nov 2025 22:19:21 -0500 Subject: [PATCH 1/8] opentelemetry-instrumentation-requests: add ability to capture request and response headers --- .../instrumentation/requests/__init__.py | 67 ++++++++++++++++++- .../src/opentelemetry/util/http/__init__.py | 6 ++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/instrumentation/opentelemetry-instrumentation-requests/src/opentelemetry/instrumentation/requests/__init__.py b/instrumentation/opentelemetry-instrumentation-requests/src/opentelemetry/instrumentation/requests/__init__.py index d834c1bb6c..4ea7e048c5 100644 --- a/instrumentation/opentelemetry-instrumentation-requests/src/opentelemetry/instrumentation/requests/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-requests/src/opentelemetry/instrumentation/requests/__init__.py @@ -93,9 +93,10 @@ def response_hook(span, request_obj, response): from __future__ import annotations import functools +import os import types from timeit import default_timer -from typing import Any, Callable, Collection, Optional +from typing import Any, Callable, Collection, Mapping, Optional from urllib.parse import urlparse from requests.models import PreparedRequest, Response @@ -150,9 +151,15 @@ def response_hook(span, request_obj, response): from opentelemetry.trace import SpanKind, Tracer, get_tracer from opentelemetry.trace.span import Span 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, + SanitizeValue, detect_synthetic_user_agent, get_excluded_urls, + normalise_request_header_name, + normalise_response_header_name, parse_excluded_urls, redact_url, sanitize_method, @@ -191,6 +198,36 @@ def _set_http_status_code_attribute( ) +def _get_custom_header_attributes( + headers: Mapping[str, str | list[str]] | None, + captured_headers: list[str] | None, + sensitive_headers: list[str] | None, + normalize_function: Callable[[str], str], +) -> dict[str, list[str]]: + """Extract and sanitize HTTP headers for span attributes. + + Args: + headers: The HTTP headers to process, either from a request or response. + Can be None if no headers are available. + captured_headers: List of header regexes to capture as span attributes. + If None or empty, no headers will be captured. + sensitive_headers: List of header regexes whose values should be sanitized + (redacted). If None, no sanitization is applied. + normalize_function: Function to normalize header names + (e.g., normalise_request_header_name or normalise_response_header_name). + + Returns: + Dictionary of normalized header attribute names to their values + as lists of strings. + """ + if not headers or not captured_headers: + return {} + sanitize: SanitizeValue = SanitizeValue(sensitive_headers or ()) + return sanitize.sanitize_header_values( + headers, captured_headers, normalize_function + ) + + # pylint: disable=unused-argument # pylint: disable=R0915 def _instrument( @@ -201,6 +238,9 @@ def _instrument( response_hook: _ResponseHookT = None, excluded_urls: ExcludeList | None = None, sem_conv_opt_in_mode: _StabilityMode = _StabilityMode.DEFAULT, + captured_request_headers: list[str] | None = None, + captured_response_headers: list[str] | None = None, + sensitive_headers: list[str] | None = None, ): """Enables tracing of all requests calls that go through :code:`requests.session.Session.request` (this includes @@ -258,6 +298,14 @@ def get_or_create_headers(): span_attributes[USER_AGENT_SYNTHETIC_TYPE] = synthetic_type if user_agent: span_attributes[USER_AGENT_ORIGINAL] = user_agent + span_attributes.update( + _get_custom_header_attributes( + headers, + captured_request_headers, + sensitive_headers, + normalise_request_header_name, + ) + ) metric_labels = {} _set_http_method( @@ -350,6 +398,14 @@ def get_or_create_headers(): version_text, sem_conv_opt_in_mode, ) + span_attributes.update( + _get_custom_header_attributes( + result.headers, + captured_response_headers, + sensitive_headers, + normalise_response_header_name, + ) + ) for key, val in span_attributes.items(): span.set_attribute(key, val) @@ -501,6 +557,15 @@ def _instrument(self, **kwargs: Any): else parse_excluded_urls(excluded_urls) ), sem_conv_opt_in_mode=semconv_opt_in_mode, + captured_request_headers=os.environ.get( + OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST + ), + captured_response_headers=os.environ.get( + OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_RESPONSE + ), + sensitive_headers=os.environ.get( + OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS + ), ) def _uninstrument(self, **kwargs: Any): diff --git a/util/opentelemetry-util-http/src/opentelemetry/util/http/__init__.py b/util/opentelemetry-util-http/src/opentelemetry/util/http/__init__.py index e23e03dede..fcb5eaa289 100644 --- a/util/opentelemetry-util-http/src/opentelemetry/util/http/__init__.py +++ b/util/opentelemetry-util-http/src/opentelemetry/util/http/__init__.py @@ -48,6 +48,12 @@ OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE = ( "OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE" ) +OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST = ( + "OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST" +) +OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_RESPONSE = ( + "OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_RESPONSE" +) OTEL_PYTHON_INSTRUMENTATION_HTTP_CAPTURE_ALL_METHODS = ( "OTEL_PYTHON_INSTRUMENTATION_HTTP_CAPTURE_ALL_METHODS" From dda34f0a6751aa7ebf72750ef11689d5e68cb498 Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Sun, 30 Nov 2025 21:13:41 -0500 Subject: [PATCH 2/8] update unit tests --- .../instrumentation/requests/__init__.py | 11 +- .../tests/test_requests_integration.py | 228 +++++++++++++++++- 2 files changed, 232 insertions(+), 7 deletions(-) diff --git a/instrumentation/opentelemetry-instrumentation-requests/src/opentelemetry/instrumentation/requests/__init__.py b/instrumentation/opentelemetry-instrumentation-requests/src/opentelemetry/instrumentation/requests/__init__.py index 4ea7e048c5..5235447204 100644 --- a/instrumentation/opentelemetry-instrumentation-requests/src/opentelemetry/instrumentation/requests/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-requests/src/opentelemetry/instrumentation/requests/__init__.py @@ -93,7 +93,6 @@ def response_hook(span, request_obj, response): from __future__ import annotations import functools -import os import types from timeit import default_timer from typing import Any, Callable, Collection, Mapping, Optional @@ -157,6 +156,7 @@ def response_hook(span, request_obj, response): ExcludeList, SanitizeValue, detect_synthetic_user_agent, + get_custom_headers, get_excluded_urls, normalise_request_header_name, normalise_response_header_name, @@ -213,8 +213,7 @@ def _get_custom_header_attributes( If None or empty, no headers will be captured. sensitive_headers: List of header regexes whose values should be sanitized (redacted). If None, no sanitization is applied. - normalize_function: Function to normalize header names - (e.g., normalise_request_header_name or normalise_response_header_name). + normalize_function: Function to normalize header names. Returns: Dictionary of normalized header attribute names to their values @@ -557,13 +556,13 @@ def _instrument(self, **kwargs: Any): else parse_excluded_urls(excluded_urls) ), sem_conv_opt_in_mode=semconv_opt_in_mode, - captured_request_headers=os.environ.get( + captured_request_headers=get_custom_headers( OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST ), - captured_response_headers=os.environ.get( + captured_response_headers=get_custom_headers( OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_RESPONSE ), - sensitive_headers=os.environ.get( + sensitive_headers=get_custom_headers( OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS ), ) diff --git a/instrumentation/opentelemetry-instrumentation-requests/tests/test_requests_integration.py b/instrumentation/opentelemetry-instrumentation-requests/tests/test_requests_integration.py index ac9c0529f5..c8dfa404c1 100644 --- a/instrumentation/opentelemetry-instrumentation-requests/tests/test_requests_integration.py +++ b/instrumentation/opentelemetry-instrumentation-requests/tests/test_requests_integration.py @@ -69,7 +69,12 @@ from opentelemetry.test.mock_textmap import MockTextMapPropagator from opentelemetry.test.test_base import TestBase from opentelemetry.trace import StatusCode -from opentelemetry.util.http import get_excluded_urls +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, + get_excluded_urls, +) class TransportMock: @@ -717,6 +722,227 @@ def test_if_headers_equals_none(self): self.assertEqual(result.text, "Hello!") self.assert_span() + @mock.patch.dict( + "os.environ", + { + OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST: "X-Custom-Header,X-Another-Header", + }, + ) + def test_custom_request_headers_captured(self): + """Test that specified request headers are captured as span attributes.""" + RequestsInstrumentor().uninstrument() + RequestsInstrumentor().instrument() + + headers = { + "X-Custom-Header": "custom-value", + "X-Another-Header": "another-value", + "X-Excluded-Header": "excluded-value", + } + httpretty.register_uri(httpretty.GET, self.URL, body="Hello!") + result = requests.get(self.URL, headers=headers, timeout=5) + self.assertEqual(result.text, "Hello!") + + span = self.assert_span() + self.assertEqual( + span.attributes["http.request.header.x_custom_header"], + ("custom-value",), + ) + self.assertEqual( + span.attributes["http.request.header.x_another_header"], + ("another-value",), + ) + self.assertNotIn("http.request.x_excluded_header", span.attributes) + + @mock.patch.dict( + "os.environ", + { + OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_RESPONSE: "X-Custom-Header,X-Another-Header", + }, + ) + def test_custom_response_headers_captured(self): + """Test that specified request headers are captured as span attributes.""" + RequestsInstrumentor().uninstrument() + RequestsInstrumentor().instrument() + + headers = { + "X-Custom-Header": "custom-value", + "X-Another-Header": "another-value", + "X-Excluded-Header": "excluded-value", + } + httpretty.register_uri( + httpretty.GET, self.URL, body="Hello!", adding_headers=headers + ) + result = requests.get(self.URL, timeout=5) + self.assertEqual(result.text, "Hello!") + + span = self.assert_span() + self.assertEqual( + span.attributes["http.response.header.x_custom_header"], + ("custom-value",), + ) + self.assertEqual( + span.attributes["http.response.header.x_another_header"], + ("another-value",), + ) + self.assertNotIn("http.response.x_excluded_header", span.attributes) + + @mock.patch.dict("os.environ", {}) + def test_custom_headers_not_captured_when_not_configured(self): + """Test that headers are not captured when env vars are not set.""" + RequestsInstrumentor().uninstrument() + RequestsInstrumentor().instrument() + headers = {"X-Request-Header": "request-value"} + httpretty.register_uri( + httpretty.GET, + self.URL, + body="Hello!", + adding_headers={"X-Response-Header": "response-value"}, + ) + result = requests.get(self.URL, headers=headers, timeout=5) + self.assertEqual(result.text, "Hello!") + + span = self.assert_span() + self.assertNotIn( + "http.request.header.x_request_header", span.attributes + ) + self.assertNotIn( + "http.response.header.x_response_header", span.attributes + ) + + @mock.patch.dict( + "os.environ", + { + OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_RESPONSE: "Set-Cookie,X-Secret", + OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST: "Authorization,X-Api-Key", + OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS: "Authorization,X-Api-Key,Set-Cookie,X-Secret", + }, + ) + def test_sensitive_headers_sanitized(self): + """Test that sensitive header values are redacted.""" + RequestsInstrumentor().uninstrument() + RequestsInstrumentor().instrument() + + request_headers = { + "Authorization": "Bearer secret-token", + "X-Api-Key": "secret-key", + } + response_headers = { + "Set-Cookie": "session=abc123", + "X-Secret": "secret", + } + httpretty.register_uri( + httpretty.GET, + self.URL, + body="Hello!", + adding_headers=response_headers, + ) + result = requests.get(self.URL, headers=request_headers, timeout=5) + self.assertEqual(result.text, "Hello!") + + span = self.assert_span() + self.assertEqual( + span.attributes["http.request.header.authorization"], + ("[REDACTED]",), + ) + self.assertEqual( + span.attributes["http.request.header.x_api_key"], + ("[REDACTED]",), + ) + self.assertEqual( + span.attributes["http.response.header.set_cookie"], + ("[REDACTED]",), + ) + self.assertEqual( + span.attributes["http.response.header.x_secret"], + ("[REDACTED]",), + ) + + @mock.patch.dict( + "os.environ", + { + OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_RESPONSE: "X-Custom-Response-.*", + OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST: "X-Custom-Request-.*", + }, + ) + def test_custom_headers_with_regex(self): + """Test that header capture works with regex patterns.""" + RequestsInstrumentor().uninstrument() + RequestsInstrumentor().instrument() + request_headers = { + "X-Custom-Request-One": "value-one", + "X-Custom-Request-Two": "value-two", + "X-Other-Request-Header": "other-value", + } + response_headers = { + "X-Custom-Response-A": "value-A", + "X-Custom-Response-B": "value-B", + "X-Other-Response-Header": "other-value", + } + httpretty.register_uri( + httpretty.GET, + self.URL, + body="Hello!", + adding_headers=response_headers, + ) + result = requests.get(self.URL, headers=request_headers, timeout=5) + self.assertEqual(result.text, "Hello!") + + span = self.assert_span() + self.assertEqual( + span.attributes["http.request.header.x_custom_request_one"], + ("value-one",), + ) + self.assertEqual( + span.attributes["http.request.header.x_custom_request_two"], + ("value-two",), + ) + self.assertNotIn( + "http.request.header.x_other_request_header", span.attributes + ) + self.assertEqual( + span.attributes["http.response.header.x_custom_response_a"], + ("value-A",), + ) + self.assertEqual( + span.attributes["http.response.header.x_custom_response_b"], + ("value-B",), + ) + self.assertNotIn( + "http.response.header.x_other_response_header", span.attributes + ) + + @mock.patch.dict( + "os.environ", + { + OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_RESPONSE: "x-response-header", + OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST: "x-request-header", + }, + ) + def test_custom_headers_case_insensitive(self): + """Test that header capture is case-insensitive.""" + RequestsInstrumentor().uninstrument() + RequestsInstrumentor().instrument() + request_headers = {"X-ReQuESt-HeaDER": "custom-value"} + response_headers = {"X-ReSPoNse-HeaDER": "custom-value"} + httpretty.register_uri( + httpretty.GET, + self.URL, + body="Hello!", + adding_headers=response_headers, + ) + result = requests.get(self.URL, headers=request_headers, timeout=5) + self.assertEqual(result.text, "Hello!") + + span = self.assert_span() + self.assertEqual( + span.attributes["http.request.header.x_request_header"], + ("custom-value",), + ) + self.assertEqual( + span.attributes["http.response.header.x_response_header"], + ("custom-value",), + ) + class TestRequestsIntegrationPreparedRequest( RequestsIntegrationTestBase, TestBase From 334a9ea1935abdc6bcd231b83cd9f8fae24b5c7b Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Sun, 30 Nov 2025 21:26:51 -0500 Subject: [PATCH 3/8] update documentation --- .../instrumentation/requests/__init__.py | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/instrumentation/opentelemetry-instrumentation-requests/src/opentelemetry/instrumentation/requests/__init__.py b/instrumentation/opentelemetry-instrumentation-requests/src/opentelemetry/instrumentation/requests/__init__.py index 5235447204..5447dbc8bb 100644 --- a/instrumentation/opentelemetry-instrumentation-requests/src/opentelemetry/instrumentation/requests/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-requests/src/opentelemetry/instrumentation/requests/__init__.py @@ -57,6 +57,97 @@ def response_hook(span, request_obj, response): request_hook=request_hook, response_hook=response_hook ) +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 Requests 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 Requests 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. + Custom Duration Histogram Boundaries ************************************ To customize the duration histogram bucket boundaries used for HTTP client request duration metrics, From 789d2fadd366cb164895bf6f3a90221b2c0f4f10 Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Sun, 30 Nov 2025 21:35:45 -0500 Subject: [PATCH 4/8] update CHANGELOG.md --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d79a899ba..0ecededea1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ([#3967](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3967)) - `opentelemetry-instrumentation-redis`: add missing copyright header for opentelemetry-instrumentation-redis ([#3976](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3976)) +- `opentelemetry-instrumentation-requests`: add ability to capture custom headers + ([#3987](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3987)) ### Fixed From dd573b12d5da889b692e2e31b6bfdffa301ba9fb Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Sun, 30 Nov 2025 21:39:34 -0500 Subject: [PATCH 5/8] disable 'too-many-lines' check for tests --- .../tests/test_requests_integration.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/instrumentation/opentelemetry-instrumentation-requests/tests/test_requests_integration.py b/instrumentation/opentelemetry-instrumentation-requests/tests/test_requests_integration.py index c8dfa404c1..326dece594 100644 --- a/instrumentation/opentelemetry-instrumentation-requests/tests/test_requests_integration.py +++ b/instrumentation/opentelemetry-instrumentation-requests/tests/test_requests_integration.py @@ -12,6 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +# pylint: disable=too-many-lines + import abc from unittest import mock From 889fa203e24479bc1fcc47f258d46affe3ee2531 Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Mon, 1 Dec 2025 20:53:58 -0500 Subject: [PATCH 6/8] add 'get_custom_header_attributes' helper to opentelemetry-util-http --- .../instrumentation/requests/__init__.py | 37 ++----------------- .../src/opentelemetry/util/http/__init__.py | 29 +++++++++++++++ 2 files changed, 33 insertions(+), 33 deletions(-) diff --git a/instrumentation/opentelemetry-instrumentation-requests/src/opentelemetry/instrumentation/requests/__init__.py b/instrumentation/opentelemetry-instrumentation-requests/src/opentelemetry/instrumentation/requests/__init__.py index 5447dbc8bb..bfa16b9fb4 100644 --- a/instrumentation/opentelemetry-instrumentation-requests/src/opentelemetry/instrumentation/requests/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-requests/src/opentelemetry/instrumentation/requests/__init__.py @@ -186,7 +186,7 @@ def response_hook(span, request_obj, response): import functools import types from timeit import default_timer -from typing import Any, Callable, Collection, Mapping, Optional +from typing import Any, Callable, Collection, Optional from urllib.parse import urlparse from requests.models import PreparedRequest, Response @@ -245,8 +245,8 @@ def response_hook(span, request_obj, response): OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_RESPONSE, OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS, ExcludeList, - SanitizeValue, detect_synthetic_user_agent, + get_custom_header_attributes, get_custom_headers, get_excluded_urls, normalise_request_header_name, @@ -289,35 +289,6 @@ def _set_http_status_code_attribute( ) -def _get_custom_header_attributes( - headers: Mapping[str, str | list[str]] | None, - captured_headers: list[str] | None, - sensitive_headers: list[str] | None, - normalize_function: Callable[[str], str], -) -> dict[str, list[str]]: - """Extract and sanitize HTTP headers for span attributes. - - Args: - headers: The HTTP headers to process, either from a request or response. - Can be None if no headers are available. - captured_headers: List of header regexes to capture as span attributes. - If None or empty, no headers will be captured. - sensitive_headers: List of header regexes whose values should be sanitized - (redacted). If None, no sanitization is applied. - normalize_function: Function to normalize header names. - - Returns: - Dictionary of normalized header attribute names to their values - as lists of strings. - """ - if not headers or not captured_headers: - return {} - sanitize: SanitizeValue = SanitizeValue(sensitive_headers or ()) - return sanitize.sanitize_header_values( - headers, captured_headers, normalize_function - ) - - # pylint: disable=unused-argument # pylint: disable=R0915 def _instrument( @@ -389,7 +360,7 @@ def get_or_create_headers(): if user_agent: span_attributes[USER_AGENT_ORIGINAL] = user_agent span_attributes.update( - _get_custom_header_attributes( + get_custom_header_attributes( headers, captured_request_headers, sensitive_headers, @@ -489,7 +460,7 @@ def get_or_create_headers(): sem_conv_opt_in_mode, ) span_attributes.update( - _get_custom_header_attributes( + get_custom_header_attributes( result.headers, captured_response_headers, sensitive_headers, diff --git a/util/opentelemetry-util-http/src/opentelemetry/util/http/__init__.py b/util/opentelemetry-util-http/src/opentelemetry/util/http/__init__.py index fcb5eaa289..00ac611b34 100644 --- a/util/opentelemetry-util-http/src/opentelemetry/util/http/__init__.py +++ b/util/opentelemetry-util-http/src/opentelemetry/util/http/__init__.py @@ -257,6 +257,35 @@ def get_custom_headers(env_var: str) -> list[str]: return [] +def get_custom_header_attributes( + headers: Mapping[str, str | list[str]] | None, + captured_headers: list[str] | None, + sensitive_headers: list[str] | None, + normalize_function: Callable[[str], str], +) -> dict[str, list[str]]: + """Extract and sanitize HTTP headers for span attributes. + + Args: + headers: The HTTP headers to process, either from a request or response. + Can be None if no headers are available. + captured_headers: List of header regexes to capture as span attributes. + If None or empty, no headers will be captured. + sensitive_headers: List of header regexes whose values should be sanitized + (redacted). If None, no sanitization is applied. + normalize_function: Function to normalize header names. + + Returns: + Dictionary of normalized header attribute names to their values + as lists of strings. + """ + if not headers or not captured_headers: + return {} + sanitize: SanitizeValue = SanitizeValue(sensitive_headers or ()) + return sanitize.sanitize_header_values( + headers, captured_headers, normalize_function + ) + + def _parse_active_request_count_attrs(req_attrs): active_requests_count_attrs = { key: req_attrs[key] From f54f15016dfcf4db66644b3bd6d970088a884972 Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Mon, 1 Dec 2025 21:01:25 -0500 Subject: [PATCH 7/8] fix formatting issues --- .../src/opentelemetry/util/http/__init__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/util/opentelemetry-util-http/src/opentelemetry/util/http/__init__.py b/util/opentelemetry-util-http/src/opentelemetry/util/http/__init__.py index 00ac611b34..ee3752a7f1 100644 --- a/util/opentelemetry-util-http/src/opentelemetry/util/http/__init__.py +++ b/util/opentelemetry-util-http/src/opentelemetry/util/http/__init__.py @@ -258,10 +258,10 @@ def get_custom_headers(env_var: str) -> list[str]: def get_custom_header_attributes( - headers: Mapping[str, str | list[str]] | None, - captured_headers: list[str] | None, - sensitive_headers: list[str] | None, - normalize_function: Callable[[str], str], + headers: Mapping[str, str | list[str]] | None, + captured_headers: list[str] | None, + sensitive_headers: list[str] | None, + normalize_function: Callable[[str], str], ) -> dict[str, list[str]]: """Extract and sanitize HTTP headers for span attributes. From a16f0190cabfadc8b3d03525cca692b9b87de76c Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Fri, 5 Dec 2025 09:55:54 -0500 Subject: [PATCH 8/8] fix CHANGELOG.md format --- CHANGELOG.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ecededea1..e401d209d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- `opentelemetry-instrumentation-requests`: add ability to capture custom headers + ([#3987](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3987)) + ## Version 1.39.0/0.60b0 (2025-12-03) ### Added @@ -40,8 +45,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ([#3967](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3967)) - `opentelemetry-instrumentation-redis`: add missing copyright header for opentelemetry-instrumentation-redis ([#3976](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3976)) -- `opentelemetry-instrumentation-requests`: add ability to capture custom headers - ([#3987](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3987)) ### Fixed