Repository: open-telemetry/opentelemetry-python-contrib

Issue:
response_size_histogram for ASGI instrumention
**Describe the solution you'd like**
Can we have `response_size_histogram` inside the `opentelemetry-instrumentation-asgi` logic?
Currently, we do have access to `content-length` header from the response inside the `OpenTelemetryMiddleware`, so it would be beneficial that we can have the ability to record response_size like duration.

**Describe alternatives you've considered**
Currently, I'm thinking about disable the FastAPI auto instrumentation and implement both response size and duration.

**Additional context**
The only instrumentator I could find that have the ability to record response_size is `opentelemetry-instrumentation-tornado` (`HTTP_SERVER_RESPONSE_SIZE`).

Let me know if that makes sense.
Thanks.



Discussion:
According to the [specification](https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/metrics/semantic_conventions/http-metrics.md#metric-httpserverresponsesize) it seems possible to add http.server.response.size if there is access to this attribute.
This metric is optional, so maybe it's the reason it's not implemented.
Do you want to work on this issue?




Hello @shalevr , Thank you for your response. I'm happy working on it.
I'll create a pull request.

Resolving patch:
diff --git a/instrumentation/opentelemetry-instrumentation-asgi/src/opentelemetry/instrumentation/asgi/__init__.py b/instrumentation/opentelemetry-instrumentation-asgi/src/opentelemetry/instrumentation/asgi/__init__.py
--- a/instrumentation/opentelemetry-instrumentation-asgi/src/opentelemetry/instrumentation/asgi/__init__.py
+++ b/instrumentation/opentelemetry-instrumentation-asgi/src/opentelemetry/instrumentation/asgi/__init__.py
@@ -506,6 +506,11 @@ def __init__(
             unit="ms",
             description="measures the duration of the inbound HTTP request",
         )
+        self.server_response_size_histogram = self.meter.create_histogram(
+            name=MetricInstruments.HTTP_SERVER_RESPONSE_SIZE,
+            unit="By",
+            description="measures the size of HTTP response messages (compressed).",
+        )
         self.active_requests_counter = self.meter.create_up_down_counter(
             name=MetricInstruments.HTTP_SERVER_ACTIVE_REQUESTS,
             unit="requests",
@@ -518,6 +523,7 @@ def __init__(
         self.server_request_hook = server_request_hook
         self.client_request_hook = client_request_hook
         self.client_response_hook = client_response_hook
+        self.content_length_header = None
 
     async def __call__(self, scope, receive, send):
         """The ASGI application
@@ -593,6 +599,10 @@ async def __call__(self, scope, receive, send):
                 self.active_requests_counter.add(
                     -1, active_requests_count_attrs
                 )
+                if self.content_length_header:
+                    self.server_response_size_histogram.record(
+                        self.content_length_header, duration_attrs
+                    )
             if token:
                 context.detach(token)
 
@@ -660,6 +670,13 @@ async def otel_send(message):
                         setter=asgi_setter,
                     )
 
+                content_length = asgi_getter.get(message, "content-length")
+                if content_length:
+                    try:
+                        self.content_length_header = int(content_length[0])
+                    except ValueError:
+                        pass
+
                 await send(message)
 
         return otel_send


Test patch:
diff --git a/instrumentation/opentelemetry-instrumentation-asgi/tests/test_asgi_middleware.py b/instrumentation/opentelemetry-instrumentation-asgi/tests/test_asgi_middleware.py
--- a/instrumentation/opentelemetry-instrumentation-asgi/tests/test_asgi_middleware.py
+++ b/instrumentation/opentelemetry-instrumentation-asgi/tests/test_asgi_middleware.py
@@ -46,10 +46,12 @@
 _expected_metric_names = [
     "http.server.active_requests",
     "http.server.duration",
+    "http.server.response.size",
 ]
 _recommended_attrs = {
     "http.server.active_requests": _active_requests_count_attrs,
     "http.server.duration": _duration_attrs,
+    "http.server.response.size": _duration_attrs,
 }
 
 
@@ -61,7 +63,10 @@ async def http_app(scope, receive, send):
             {
                 "type": "http.response.start",
                 "status": 200,
-                "headers": [[b"Content-Type", b"text/plain"]],
+                "headers": [
+                    [b"Content-Type", b"text/plain"],
+                    [b"content-length", b"1024"],
+                ],
             }
         )
         await send({"type": "http.response.body", "body": b"*"})
@@ -103,7 +108,10 @@ async def error_asgi(scope, receive, send):
             {
                 "type": "http.response.start",
                 "status": 200,
-                "headers": [[b"Content-Type", b"text/plain"]],
+                "headers": [
+                    [b"Content-Type", b"text/plain"],
+                    [b"content-length", b"1024"],
+                ],
             }
         )
         await send({"type": "http.response.body", "body": b"*"})
@@ -126,7 +134,8 @@ def validate_outputs(self, outputs, error=None, modifiers=None):
         # Check http response start
         self.assertEqual(response_start["status"], 200)
         self.assertEqual(
-            response_start["headers"], [[b"Content-Type", b"text/plain"]]
+            response_start["headers"],
+            [[b"Content-Type", b"text/plain"], [b"content-length", b"1024"]],
         )
 
         exc_info = self.scope.get("hack_exc_info")
@@ -352,6 +361,7 @@ def test_traceresponse_header(self):
             response_start["headers"],
             [
                 [b"Content-Type", b"text/plain"],
+                [b"content-length", b"1024"],
                 [b"traceresponse", f"{traceresponse}".encode()],
                 [b"access-control-expose-headers", b"traceresponse"],
             ],
@@ -565,6 +575,7 @@ def test_basic_metric_success(self):
             "http.flavor": "1.0",
         }
         metrics_list = self.memory_metrics_reader.get_metrics_data()
+        # pylint: disable=too-many-nested-blocks
         for resource_metric in metrics_list.resource_metrics:
             for scope_metrics in resource_metric.scope_metrics:
                 for metric in scope_metrics.metrics:
@@ -575,9 +586,12 @@ def test_basic_metric_success(self):
                                 dict(point.attributes),
                             )
                             self.assertEqual(point.count, 1)
-                            self.assertAlmostEqual(
-                                duration, point.sum, delta=5
-                            )
+                            if metric.name == "http.server.duration":
+                                self.assertAlmostEqual(
+                                    duration, point.sum, delta=5
+                                )
+                            elif metric.name == "http.server.response.size":
+                                self.assertEqual(1024, point.sum)
                         elif isinstance(point, NumberDataPoint):
                             self.assertDictEqual(
                                 expected_requests_count_attributes,
@@ -602,13 +616,12 @@ async def target_asgi(scope, receive, send):
         app = otel_asgi.OpenTelemetryMiddleware(target_asgi)
         self.seed_app(app)
         self.send_default_request()
-
         metrics_list = self.memory_metrics_reader.get_metrics_data()
         assertions = 0
         for resource_metric in metrics_list.resource_metrics:
             for scope_metrics in resource_metric.scope_metrics:
                 for metric in scope_metrics.metrics:
-                    if metric.name != "http.server.duration":
+                    if metric.name == "http.server.active_requests":
                         continue
                     for point in metric.data.data_points:
                         if isinstance(point, HistogramDataPoint):
@@ -617,7 +630,7 @@ async def target_asgi(scope, receive, send):
                                 expected_target,
                             )
                             assertions += 1
-        self.assertEqual(assertions, 1)
+        self.assertEqual(assertions, 2)
 
     def test_no_metric_for_websockets(self):
         self.scope = {
diff --git a/instrumentation/opentelemetry-instrumentation-fastapi/tests/test_fastapi_instrumentation.py b/instrumentation/opentelemetry-instrumentation-fastapi/tests/test_fastapi_instrumentation.py
--- a/instrumentation/opentelemetry-instrumentation-fastapi/tests/test_fastapi_instrumentation.py
+++ b/instrumentation/opentelemetry-instrumentation-fastapi/tests/test_fastapi_instrumentation.py
@@ -44,10 +44,15 @@
 _expected_metric_names = [
     "http.server.active_requests",
     "http.server.duration",
+    "http.server.response.size",
 ]
 _recommended_attrs = {
     "http.server.active_requests": _active_requests_count_attrs,
     "http.server.duration": {*_duration_attrs, SpanAttributes.HTTP_TARGET},
+    "http.server.response.size": {
+        *_duration_attrs,
+        SpanAttributes.HTTP_TARGET,
+    },
 }
 
 
@@ -187,7 +192,7 @@ def test_fastapi_metrics(self):
         for resource_metric in metrics_list.resource_metrics:
             self.assertTrue(len(resource_metric.scope_metrics) == 1)
             for scope_metric in resource_metric.scope_metrics:
-                self.assertTrue(len(scope_metric.metrics) == 2)
+                self.assertTrue(len(scope_metric.metrics) == 3)
                 for metric in scope_metric.metrics:
                     self.assertIn(metric.name, _expected_metric_names)
                     data_points = list(metric.data.data_points)
diff --git a/instrumentation/opentelemetry-instrumentation-starlette/tests/test_starlette_instrumentation.py b/instrumentation/opentelemetry-instrumentation-starlette/tests/test_starlette_instrumentation.py
--- a/instrumentation/opentelemetry-instrumentation-starlette/tests/test_starlette_instrumentation.py
+++ b/instrumentation/opentelemetry-instrumentation-starlette/tests/test_starlette_instrumentation.py
@@ -49,10 +49,12 @@
 _expected_metric_names = [
     "http.server.active_requests",
     "http.server.duration",
+    "http.server.response.size",
 ]
 _recommended_attrs = {
     "http.server.active_requests": _active_requests_count_attrs,
     "http.server.duration": _duration_attrs,
+    "http.server.response.size": _duration_attrs,
 }
 
 
@@ -128,7 +130,7 @@ def test_starlette_metrics(self):
         for resource_metric in metrics_list.resource_metrics:
             self.assertTrue(len(resource_metric.scope_metrics) == 1)
             for scope_metric in resource_metric.scope_metrics:
-                self.assertTrue(len(scope_metric.metrics) == 2)
+                self.assertTrue(len(scope_metric.metrics) == 3)
                 for metric in scope_metric.metrics:
                     self.assertIn(metric.name, _expected_metric_names)
                     data_points = list(metric.data.data_points)