Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changelog/4572.added
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
`opentelemetry-instrumentation-aiohttp-client`: add optional `http.client.response.body.size` span attribute & metric to the aiohttp client instrumentation
Original file line number Diff line number Diff line change
Expand Up @@ -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"``.

@herin049 herin049 May 17, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Personally, I don't think that having an opt-in is really needed here. From my understanding, when the Spec states that a metric or attribute is "optional", it usually indicates that library authors can optionally add it - not necessarily that users need the ability to enable/disable it. Typically, the spec will explicitly state if behavior should be opt-in only.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same understanding here.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So... this is suggesting removing the env var and have this enabled directly? But this is what I get from the spec for the definition of 'Opt-In' level requirement, which sounds requesting such attribute cannot be emitted if user s did not explicitly enable.


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
---
"""
Expand Down Expand Up @@ -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]
Expand All @@ -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,
Expand Down Expand Up @@ -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):
Expand All @@ -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],
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand All @@ -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(
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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)

Comment on lines +1652 to +1655
@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)

Comment on lines +1667 to +1679
@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):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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]
Expand Down
Loading