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 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/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..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 @@ -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( @@ -1281,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): @@ -1617,6 +1625,73 @@ 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.handler_with_body + ) + 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.handler_with_body + ) + 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.handler_with_body + ) + 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.handler_with_body + ) + span = self._assert_spans(1) + self.assertNotIn(HTTP_RESPONSE_BODY_SIZE, span.attributes) + class TestLoadingAioHttpInstrumentor(unittest.TestCase): def test_loading_instrumentor(self): 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..aa1ad292e2 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, @@ -275,6 +279,15 @@ def get_custom_header_attributes( ) +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 = { key: req_attrs[key]