From acc18b032fccd35a6949fb1ae4cceda381d52b8d Mon Sep 17 00:00:00 2001 From: Zhikuan Wei Date: Mon, 11 May 2026 10:18:37 -0400 Subject: [PATCH 1/5] add opt-in response body size span attribute + metric --- .../aiohttp_client/__init__.py | 63 +++++++++++++++++++ .../src/opentelemetry/util/http/__init__.py | 11 ++++ 2 files changed, 74 insertions(+) diff --git a/instrumentation/opentelemetry-instrumentation-aiohttp-client/src/opentelemetry/instrumentation/aiohttp_client/__init__.py b/instrumentation/opentelemetry-instrumentation-aiohttp-client/src/opentelemetry/instrumentation/aiohttp_client/__init__.py index 206ba8b2e1..86122364ea 100644 --- a/instrumentation/opentelemetry-instrumentation-aiohttp-client/src/opentelemetry/instrumentation/aiohttp_client/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-aiohttp-client/src/opentelemetry/instrumentation/aiohttp_client/__init__.py @@ -178,6 +178,25 @@ def response_hook(span: Span, params: typing.Union[ Note: The environment variable names used to capture HTTP headers are still experimental, and thus are subject to change. +Capturing response body size +**************************** +To capture the ``http.response.body.size`` span attribute and record the +``http.client.response.body.size`` metric histogram, set the environment variable +``OTEL_PYTHON_INSTRUMENTATION_HTTP_RESPONSE_BODY_SIZE`` to ``"true"``. + +This is an opt-in attribute per the semantic conventions specification. It is +only emitted when the new HTTP semantic conventions are active +(``OTEL_SEMCONV_STABILITY_OPT_IN`` includes ``http`` or ``http/dup``). + +:: + + export OTEL_PYTHON_INSTRUMENTATION_HTTP_RESPONSE_BODY_SIZE="true" + export OTEL_SEMCONV_STABILITY_OPT_IN="http" + +The body size is derived from the ``Content-Length`` response header. For +chunked responses that lack a ``Content-Length`` header, the attribute and +metric are not recorded. + API --- """ @@ -232,6 +251,12 @@ def response_hook(span: Span, params: typing.Union[ ) from opentelemetry.metrics import MeterProvider, get_meter from opentelemetry.propagate import inject +from opentelemetry.semconv._incubating.attributes.http_attributes import ( + HTTP_RESPONSE_BODY_SIZE, +) +from opentelemetry.semconv._incubating.metrics.http_metrics import ( + create_http_client_response_body_size, +) from opentelemetry.semconv.attributes.error_attributes import ERROR_TYPE from opentelemetry.semconv.metrics import ( MetricInstruments, # type: ignore[reportDeprecated] @@ -248,6 +273,7 @@ def response_hook(span: Span, params: typing.Union[ get_custom_header_attributes, get_custom_headers, get_excluded_urls, + is_capture_response_body_size_enabled, normalise_request_header_name, normalise_response_header_name, redact_url, @@ -413,6 +439,14 @@ def create_trace_config( explicit_bucket_boundaries_advisory=HTTP_DURATION_HISTOGRAM_BUCKETS_NEW, ) + capture_response_body_size = is_capture_response_body_size_enabled() + + response_body_size_histogram = None + if capture_response_body_size and _report_new(sem_conv_opt_in_mode): + response_body_size_histogram = create_http_client_response_body_size( + meter + ) + excluded_urls = get_excluded_urls("AIOHTTP_CLIENT") def _end_trace(trace_config_ctx: types.SimpleNamespace): @@ -422,6 +456,24 @@ def _end_trace(trace_config_ctx: types.SimpleNamespace): if trace_config_ctx.span: trace_config_ctx.span.end() + if ( + trace_config_ctx.response_body_size_histogram is not None + and trace_config_ctx.response_body_size is not None + ): + body_size_attrs = cast( + dict[str, Any], + _filter_semconv_duration_attrs( + trace_config_ctx.metric_attributes, + _client_duration_attrs_old, + _client_duration_attrs_new, + _StabilityMode.HTTP, + ), + ) + trace_config_ctx.response_body_size_histogram.record( + trace_config_ctx.response_body_size, + attributes=body_size_attrs, + ) + if trace_config_ctx.duration_histogram_old is not None: duration_attrs_old = cast( dict[str, Any], @@ -575,6 +627,15 @@ async def on_request_end( ) ) + if capture_response_body_size and _report_new(sem_conv_opt_in_mode): + content_length = params.response.content_length + if content_length is not None: + if trace_config_ctx.span.is_recording(): + trace_config_ctx.span.set_attribute( + HTTP_RESPONSE_BODY_SIZE, content_length + ) + trace_config_ctx.response_body_size = content_length + _end_trace(trace_config_ctx) async def on_request_exception( @@ -609,6 +670,8 @@ def _trace_config_ctx_factory(**kwargs: Any) -> types.SimpleNamespace: token=None, duration_histogram_old=duration_histogram_old, duration_histogram_new=duration_histogram_new, + response_body_size_histogram=response_body_size_histogram, + response_body_size=None, metric_attributes={}, url_filter=url_filter, excluded_urls=excluded_urls, 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 1e129be053..33ca6c8424 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,10 @@ "OTEL_PYTHON_INSTRUMENTATION_HTTP_CAPTURE_ALL_METHODS" ) +OTEL_PYTHON_INSTRUMENTATION_HTTP_RESPONSE_BODY_SIZE = ( + "OTEL_PYTHON_INSTRUMENTATION_HTTP_RESPONSE_BODY_SIZE" +) + # List of recommended metrics attributes _duration_attrs = { HTTP_METHOD, @@ -274,6 +278,13 @@ def get_custom_header_attributes( headers, captured_headers, normalize_function ) +def is_capture_response_body_size_enabled() -> bool: + return ( + environ.get( + OTEL_PYTHON_INSTRUMENTATION_HTTP_RESPONSE_BODY_SIZE, "" + ).lower() + == "true" + ) def _parse_active_request_count_attrs(req_attrs): active_requests_count_attrs = { From aa5df6da15ea52745b252521f23d7fd07ff267fe Mon Sep 17 00:00:00 2001 From: Zhikuan Wei Date: Mon, 11 May 2026 10:28:29 -0400 Subject: [PATCH 2/5] add unit tests --- .../tests/test_aiohttp_client_integration.py | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/instrumentation/opentelemetry-instrumentation-aiohttp-client/tests/test_aiohttp_client_integration.py b/instrumentation/opentelemetry-instrumentation-aiohttp-client/tests/test_aiohttp_client_integration.py index a1e2e12ded..f1036380ce 100644 --- a/instrumentation/opentelemetry-instrumentation-aiohttp-client/tests/test_aiohttp_client_integration.py +++ b/instrumentation/opentelemetry-instrumentation-aiohttp-client/tests/test_aiohttp_client_integration.py @@ -36,6 +36,7 @@ from opentelemetry.semconv._incubating.attributes.http_attributes import ( HTTP_HOST, HTTP_METHOD, + HTTP_RESPONSE_BODY_SIZE, HTTP_STATUS_CODE, HTTP_URL, ) @@ -57,6 +58,9 @@ from opentelemetry.test.test_base import TestBase from opentelemetry.trace import Span, StatusCode from opentelemetry.util._importlib_metadata import entry_points +from opentelemetry.util.http import ( + OTEL_PYTHON_INSTRUMENTATION_HTTP_RESPONSE_BODY_SIZE, +) def run_with_test_server( @@ -1617,6 +1621,80 @@ def test_ignores_excluded_urls(self): self._assert_spans(0) self._assert_metrics(0) + @mock.patch.dict( + os.environ, {OTEL_SEMCONV_STABILITY_OPT_IN: "http"} + ) + def test_response_body_size_not_set_by_default(self): + AioHttpClientInstrumentor().uninstrument() + AioHttpClientInstrumentor().instrument() + + run_with_test_server( + self.get_default_request(), self.URL, self.default_handler + ) + span = self._assert_spans(1) + self.assertNotIn(HTTP_RESPONSE_BODY_SIZE, span.attributes) + + @mock.patch.dict( + os.environ, + { + OTEL_SEMCONV_STABILITY_OPT_IN: "http", + OTEL_PYTHON_INSTRUMENTATION_HTTP_RESPONSE_BODY_SIZE: "true", + }, + ) + def test_response_body_size_set_on_span(self): + AioHttpClientInstrumentor().uninstrument() + AioHttpClientInstrumentor().instrument() + run_with_test_server( + self.get_default_request(), self.URL, self.default_handler + ) + span = self._assert_spans(1) + self.assertIn(HTTP_RESPONSE_BODY_SIZE, span.attributes) + self.assertIsInstance( + span.attributes[HTTP_RESPONSE_BODY_SIZE], int + ) + + @mock.patch.dict( + "os.environ", + { + OTEL_SEMCONV_STABILITY_OPT_IN: "http", + OTEL_PYTHON_INSTRUMENTATION_HTTP_RESPONSE_BODY_SIZE: "true", + }, + ) + def test_response_body_size_metric_recorded(self): + AioHttpClientInstrumentor().uninstrument() + AioHttpClientInstrumentor().instrument() + run_with_test_server( + self.get_default_request(), self.URL, self.default_handler + ) + metrics = self._assert_metrics(2) + metric_names = {m.name for m in metrics} + self.assertIn( + "http.client.response.body.size", metric_names + ) + body_size_metric = next( + m + for m in metrics + if m.name == "http.client.response.body.size" + ) + data_point = body_size_metric.data.data_points[0] + self.assertEqual(data_point.count, 1) + self.assertTrue(data_point.sum > 0) + + @mock.patch.dict( + "os.environ", + { + OTEL_PYTHON_INSTRUMENTATION_HTTP_RESPONSE_BODY_SIZE: "true", + }, + ) + def test_response_body_size_not_set_without_new_semconv(self): + AioHttpClientInstrumentor().uninstrument() + AioHttpClientInstrumentor().instrument() + run_with_test_server( + self.get_default_request(), self.URL, self.default_handler + ) + span = self._assert_spans(1) + self.assertNotIn(HTTP_RESPONSE_BODY_SIZE, span.attributes) + class TestLoadingAioHttpInstrumentor(unittest.TestCase): def test_loading_instrumentor(self): From 9387b017ac621dc2fcaaecdaae6bcc00d914834e Mon Sep 17 00:00:00 2001 From: Zhikuan Wei Date: Thu, 14 May 2026 09:58:28 -0400 Subject: [PATCH 3/5] fix format --- .../tests/test_aiohttp_client_integration.py | 16 ++++------------ .../src/opentelemetry/util/http/__init__.py | 2 ++ 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/instrumentation/opentelemetry-instrumentation-aiohttp-client/tests/test_aiohttp_client_integration.py b/instrumentation/opentelemetry-instrumentation-aiohttp-client/tests/test_aiohttp_client_integration.py index f1036380ce..be4d0d11bf 100644 --- a/instrumentation/opentelemetry-instrumentation-aiohttp-client/tests/test_aiohttp_client_integration.py +++ b/instrumentation/opentelemetry-instrumentation-aiohttp-client/tests/test_aiohttp_client_integration.py @@ -1621,9 +1621,7 @@ def test_ignores_excluded_urls(self): self._assert_spans(0) self._assert_metrics(0) - @mock.patch.dict( - os.environ, {OTEL_SEMCONV_STABILITY_OPT_IN: "http"} - ) + @mock.patch.dict(os.environ, {OTEL_SEMCONV_STABILITY_OPT_IN: "http"}) def test_response_body_size_not_set_by_default(self): AioHttpClientInstrumentor().uninstrument() AioHttpClientInstrumentor().instrument() @@ -1649,9 +1647,7 @@ def test_response_body_size_set_on_span(self): ) span = self._assert_spans(1) self.assertIn(HTTP_RESPONSE_BODY_SIZE, span.attributes) - self.assertIsInstance( - span.attributes[HTTP_RESPONSE_BODY_SIZE], int - ) + self.assertIsInstance(span.attributes[HTTP_RESPONSE_BODY_SIZE], int) @mock.patch.dict( "os.environ", @@ -1668,13 +1664,9 @@ def test_response_body_size_metric_recorded(self): ) metrics = self._assert_metrics(2) metric_names = {m.name for m in metrics} - self.assertIn( - "http.client.response.body.size", metric_names - ) + self.assertIn("http.client.response.body.size", metric_names) body_size_metric = next( - m - for m in metrics - if m.name == "http.client.response.body.size" + m for m in metrics if m.name == "http.client.response.body.size" ) data_point = body_size_metric.data.data_points[0] self.assertEqual(data_point.count, 1) 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 33ca6c8424..aa1ad292e2 100644 --- a/util/opentelemetry-util-http/src/opentelemetry/util/http/__init__.py +++ b/util/opentelemetry-util-http/src/opentelemetry/util/http/__init__.py @@ -278,6 +278,7 @@ def get_custom_header_attributes( headers, captured_headers, normalize_function ) + def is_capture_response_body_size_enabled() -> bool: return ( environ.get( @@ -286,6 +287,7 @@ def is_capture_response_body_size_enabled() -> bool: == "true" ) + def _parse_active_request_count_attrs(req_attrs): active_requests_count_attrs = { key: req_attrs[key] From af2c3691050669fc54a39fb2d5bf467f9af7e74c Mon Sep 17 00:00:00 2001 From: Zhikuan Wei Date: Thu, 14 May 2026 10:11:39 -0400 Subject: [PATCH 4/5] add changelog fragment --- .changelog/4572.added | 1 + 1 file changed, 1 insertion(+) create mode 100644 .changelog/4572.added diff --git a/.changelog/4572.added b/.changelog/4572.added new file mode 100644 index 0000000000..d913b16832 --- /dev/null +++ b/.changelog/4572.added @@ -0,0 +1 @@ +`opentelemetry-instrumentation-aiohttp-client`: add optional `http.client.response.body.size` span attribute & metric to the aiohttp client instrumentation From aec8fe55adbce00083a32f93802ace6678a9e2f5 Mon Sep 17 00:00:00 2001 From: Zhikuan Wei Date: Thu, 14 May 2026 22:33:01 -0400 Subject: [PATCH 5/5] add handler with response body to unit tests --- .../tests/test_aiohttp_client_integration.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/instrumentation/opentelemetry-instrumentation-aiohttp-client/tests/test_aiohttp_client_integration.py b/instrumentation/opentelemetry-instrumentation-aiohttp-client/tests/test_aiohttp_client_integration.py index be4d0d11bf..d0405049bd 100644 --- a/instrumentation/opentelemetry-instrumentation-aiohttp-client/tests/test_aiohttp_client_integration.py +++ b/instrumentation/opentelemetry-instrumentation-aiohttp-client/tests/test_aiohttp_client_integration.py @@ -1285,6 +1285,10 @@ def tearDown(self): async def default_handler(request): return aiohttp.web.Response(status=int(200)) + @staticmethod + async def handler_with_body(request): + return aiohttp.web.Response(status=200, body=b"hello") + @staticmethod def get_default_request(url: str = URL): async def default_request(server: aiohttp.test_utils.TestServer): @@ -1627,7 +1631,7 @@ def test_response_body_size_not_set_by_default(self): AioHttpClientInstrumentor().instrument() run_with_test_server( - self.get_default_request(), self.URL, self.default_handler + self.get_default_request(), self.URL, self.handler_with_body ) span = self._assert_spans(1) self.assertNotIn(HTTP_RESPONSE_BODY_SIZE, span.attributes) @@ -1643,7 +1647,7 @@ def test_response_body_size_set_on_span(self): AioHttpClientInstrumentor().uninstrument() AioHttpClientInstrumentor().instrument() run_with_test_server( - self.get_default_request(), self.URL, self.default_handler + self.get_default_request(), self.URL, self.handler_with_body ) span = self._assert_spans(1) self.assertIn(HTTP_RESPONSE_BODY_SIZE, span.attributes) @@ -1659,8 +1663,9 @@ def test_response_body_size_set_on_span(self): def test_response_body_size_metric_recorded(self): AioHttpClientInstrumentor().uninstrument() AioHttpClientInstrumentor().instrument() + run_with_test_server( - self.get_default_request(), self.URL, self.default_handler + self.get_default_request(), self.URL, self.handler_with_body ) metrics = self._assert_metrics(2) metric_names = {m.name for m in metrics} @@ -1682,7 +1687,7 @@ def test_response_body_size_not_set_without_new_semconv(self): AioHttpClientInstrumentor().uninstrument() AioHttpClientInstrumentor().instrument() run_with_test_server( - self.get_default_request(), self.URL, self.default_handler + self.get_default_request(), self.URL, self.handler_with_body ) span = self._assert_spans(1) self.assertNotIn(HTTP_RESPONSE_BODY_SIZE, span.attributes)