Skip to content

Commit 03e767c

Browse files
Add AsgiInstrumentor labeler custom attrs metrics support
1 parent e50f1e2 commit 03e767c

File tree

2 files changed

+250
-0
lines changed

2 files changed

+250
-0
lines changed

instrumentation/opentelemetry-instrumentation-asgi/src/opentelemetry/instrumentation/asgi/__init__.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,39 @@ def client_response_hook(span: Span, scope: Scope, message: dict[str, Any]):
204204
Note:
205205
The environment variable names used to capture HTTP headers are still experimental, and thus are subject to change.
206206
207+
Custom Metrics Attributes using Labeler
208+
***************************************
209+
The ASGI instrumentation reads from a Labeler utility that supports adding custom attributes
210+
to the HTTP duration metrics recorded by the instrumentation.
211+
212+
.. code-block:: python
213+
214+
.. code-block:: python
215+
216+
from quart import Quart
217+
from opentelemetry.instrumentation._labeler import get_labeler
218+
from opentelemetry.instrumentation.asgi import OpenTelemetryMiddleware
219+
220+
app = Quart(__name__)
221+
app.asgi_app = OpenTelemetryMiddleware(app.asgi_app)
222+
223+
@app.route("/user/<user_id>")
224+
async def user_profile(user_id):
225+
# Get the labeler for the current request
226+
labeler = get_labeler()
227+
# Add custom attributes to ASGI instrumentation metrics
228+
labeler.add("user_id", user_id)
229+
labeler.add("user_type", "registered")
230+
# Or, add multiple attributes at once
231+
labeler.add_attributes({
232+
"feature_flag": "new_ui",
233+
"experiment_group": "control"
234+
})
235+
return f"User profile for {user_id}"
236+
237+
if __name__ == "__main__":
238+
app.run(debug=True)
239+
207240
API
208241
---
209242
"""
@@ -220,6 +253,7 @@ def client_response_hook(span: Span, scope: Scope, message: dict[str, Any]):
220253
from asgiref.compatibility import guarantee_single_callable
221254

222255
from opentelemetry import context, trace
256+
from opentelemetry.instrumentation._labeler import enhance_metric_attributes
223257
from opentelemetry.instrumentation._semconv import (
224258
HTTP_DURATION_HISTOGRAM_BUCKETS_NEW,
225259
_filter_semconv_active_request_count_attr,
@@ -782,11 +816,19 @@ async def __call__(
782816
duration_attrs_old = _parse_duration_attrs(
783817
attributes, _StabilityMode.DEFAULT
784818
)
819+
# Enhance attributes with any custom labeler attributes
820+
duration_attrs_old = enhance_metric_attributes(
821+
duration_attrs_old
822+
)
785823
if target:
786824
duration_attrs_old[SpanAttributes.HTTP_TARGET] = target
787825
duration_attrs_new = _parse_duration_attrs(
788826
attributes, _StabilityMode.HTTP
789827
)
828+
# Enhance attributes with any custom labeler attributes
829+
duration_attrs_new = enhance_metric_attributes(
830+
duration_attrs_new
831+
)
790832
if self.duration_histogram_old:
791833
self.duration_histogram_old.record(
792834
max(round(duration_s * 1000), 0), duration_attrs_old

instrumentation/opentelemetry-instrumentation-asgi/tests/test_asgi_middleware.py

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222

2323
import opentelemetry.instrumentation.asgi as otel_asgi
2424
from opentelemetry import trace as trace_api
25+
from opentelemetry.instrumentation._labeler import clear_labeler, get_labeler
2526
from opentelemetry.instrumentation._semconv import (
2627
HTTP_DURATION_HISTOGRAM_BUCKETS_NEW,
2728
OTEL_SEMCONV_STABILITY_OPT_IN,
@@ -108,6 +109,43 @@
108109
_server_active_requests_count_attrs_old
109110
)
110111

112+
_server_active_requests_count_attrs_both = (
113+
_server_active_requests_count_attrs_old
114+
)
115+
_server_active_requests_count_attrs_both.extend(
116+
_server_active_requests_count_attrs_new
117+
)
118+
119+
_custom_attributes = ["custom_attr", "endpoint_type", "feature_flag"]
120+
_server_duration_attrs_old_with_custom = _server_duration_attrs_old.copy()
121+
_server_duration_attrs_old_with_custom.append("http.target")
122+
_server_duration_attrs_old_with_custom.extend(_custom_attributes)
123+
_server_duration_attrs_new_with_custom = _server_duration_attrs_new.copy()
124+
_server_duration_attrs_new_with_custom.append("http.route")
125+
_server_duration_attrs_new_with_custom.extend(_custom_attributes)
126+
127+
_recommended_metrics_attrs_old_with_custom = {
128+
"http.server.active_requests": _server_active_requests_count_attrs_old,
129+
"http.server.duration": _server_duration_attrs_old_with_custom,
130+
"http.server.request.size": _server_duration_attrs_old_with_custom,
131+
"http.server.response.size": _server_duration_attrs_old_with_custom,
132+
}
133+
_recommended_metrics_attrs_new_with_custom = {
134+
"http.server.active_requests": _server_active_requests_count_attrs_new,
135+
"http.server.request.duration": _server_duration_attrs_new_with_custom,
136+
"http.server.request.body.size": _server_duration_attrs_new_with_custom,
137+
"http.server.response.body.size": _server_duration_attrs_new_with_custom,
138+
}
139+
_recommended_metrics_attrs_both_with_custom = {
140+
"http.server.active_requests": _server_active_requests_count_attrs_both,
141+
"http.server.duration": _server_duration_attrs_old_with_custom,
142+
"http.server.request.duration": _server_duration_attrs_new_with_custom,
143+
"http.server.request.size": _server_duration_attrs_old_with_custom,
144+
"http.server.request.body.size": _server_duration_attrs_new_with_custom,
145+
"http.server.response.size": _server_duration_attrs_old_with_custom,
146+
"http.server.response.body.size": _server_duration_attrs_new_with_custom,
147+
}
148+
111149
_SIMULATED_BACKGROUND_TASK_EXECUTION_TIME_S = 0.01
112150

113151

@@ -254,6 +292,28 @@ async def background_execution_trailers_asgi(scope, receive, send):
254292
time.sleep(_SIMULATED_BACKGROUND_TASK_EXECUTION_TIME_S)
255293

256294

295+
async def custom_attrs_asgi(scope, receive, send):
296+
assert isinstance(scope, dict)
297+
assert scope["type"] == "http"
298+
labeler = get_labeler()
299+
labeler.add("custom_attr", "test_value")
300+
labeler.add_attributes({"endpoint_type": "test", "feature_flag": True})
301+
message = await receive()
302+
scope["headers"] = [(b"content-length", b"128")]
303+
if message.get("type") == "http.request":
304+
await send(
305+
{
306+
"type": "http.response.start",
307+
"status": 200,
308+
"headers": [
309+
[b"Content-Type", b"text/plain"],
310+
[b"content-length", b"1024"],
311+
],
312+
}
313+
)
314+
await send({"type": "http.response.body", "body": b"*"})
315+
316+
257317
async def error_asgi(scope, receive, send):
258318
assert isinstance(scope, dict)
259319
assert scope["type"] == "http"
@@ -281,6 +341,7 @@ async def error_asgi(scope, receive, send):
281341
class TestAsgiApplication(AsyncAsgiTestBase):
282342
def setUp(self):
283343
super().setUp()
344+
clear_labeler()
284345

285346
test_name = ""
286347
if hasattr(self, "_testMethodName"):
@@ -1245,6 +1306,57 @@ async def test_asgi_metrics(self):
12451306
)
12461307
self.assertTrue(number_data_point_seen and histogram_data_point_seen)
12471308

1309+
# pylint: disable=too-many-nested-blocks
1310+
async def test_asgi_metrics_custom_attributes(self):
1311+
app = otel_asgi.OpenTelemetryMiddleware(custom_attrs_asgi)
1312+
self.seed_app(app)
1313+
await self.send_default_request()
1314+
await self.get_all_output()
1315+
self.seed_app(app)
1316+
await self.send_default_request()
1317+
await self.get_all_output()
1318+
self.seed_app(app)
1319+
await self.send_default_request()
1320+
await self.get_all_output()
1321+
metrics_list = self.memory_metrics_reader.get_metrics_data()
1322+
number_data_point_seen = False
1323+
histogram_data_point_seen = False
1324+
1325+
self.assertTrue(len(metrics_list.resource_metrics) != 0)
1326+
for resource_metric in metrics_list.resource_metrics:
1327+
self.assertTrue(len(resource_metric.scope_metrics) != 0)
1328+
for scope_metric in resource_metric.scope_metrics:
1329+
self.assertTrue(len(scope_metric.metrics) != 0)
1330+
self.assertEqual(
1331+
scope_metric.scope.name,
1332+
"opentelemetry.instrumentation.asgi",
1333+
)
1334+
for metric in scope_metric.metrics:
1335+
self.assertIn(metric.name, _expected_metric_names_old)
1336+
data_points = list(metric.data.data_points)
1337+
self.assertEqual(len(data_points), 1)
1338+
for point in data_points:
1339+
if isinstance(point, HistogramDataPoint):
1340+
self.assertEqual(point.count, 3)
1341+
histogram_data_point_seen = True
1342+
1343+
for attr in point.attributes:
1344+
self.assertIn(
1345+
attr,
1346+
_recommended_metrics_attrs_old_with_custom[
1347+
metric.name
1348+
],
1349+
)
1350+
1351+
if isinstance(point, NumberDataPoint):
1352+
number_data_point_seen = True
1353+
1354+
for attr in point.attributes:
1355+
self.assertIn(
1356+
attr, _recommended_attrs_old[metric.name]
1357+
)
1358+
self.assertTrue(number_data_point_seen and histogram_data_point_seen)
1359+
12481360
async def test_asgi_metrics_new_semconv(self):
12491361
# pylint: disable=too-many-nested-blocks
12501362
app = otel_asgi.OpenTelemetryMiddleware(simple_asgi)
@@ -1290,6 +1402,54 @@ async def test_asgi_metrics_new_semconv(self):
12901402
)
12911403
self.assertTrue(number_data_point_seen and histogram_data_point_seen)
12921404

1405+
async def test_asgi_metrics_new_semconv_custom_attributes(self):
1406+
# pylint: disable=too-many-nested-blocks
1407+
app = otel_asgi.OpenTelemetryMiddleware(custom_attrs_asgi)
1408+
self.seed_app(app)
1409+
await self.send_default_request()
1410+
await self.get_all_output()
1411+
self.seed_app(app)
1412+
await self.send_default_request()
1413+
await self.get_all_output()
1414+
self.seed_app(app)
1415+
await self.send_default_request()
1416+
await self.get_all_output()
1417+
metrics_list = self.memory_metrics_reader.get_metrics_data()
1418+
number_data_point_seen = False
1419+
histogram_data_point_seen = False
1420+
self.assertTrue(len(metrics_list.resource_metrics) != 0)
1421+
for resource_metric in metrics_list.resource_metrics:
1422+
self.assertTrue(len(resource_metric.scope_metrics) != 0)
1423+
for scope_metric in resource_metric.scope_metrics:
1424+
self.assertTrue(len(scope_metric.metrics) != 0)
1425+
self.assertEqual(
1426+
scope_metric.scope.name,
1427+
"opentelemetry.instrumentation.asgi",
1428+
)
1429+
for metric in scope_metric.metrics:
1430+
self.assertIn(metric.name, _expected_metric_names_new)
1431+
data_points = list(metric.data.data_points)
1432+
self.assertEqual(len(data_points), 1)
1433+
for point in data_points:
1434+
if isinstance(point, HistogramDataPoint):
1435+
self.assertEqual(point.count, 3)
1436+
if metric.name == "http.server.request.duration":
1437+
self.assertEqual(
1438+
point.explicit_bounds,
1439+
HTTP_DURATION_HISTOGRAM_BUCKETS_NEW,
1440+
)
1441+
histogram_data_point_seen = True
1442+
if isinstance(point, NumberDataPoint):
1443+
number_data_point_seen = True
1444+
for attr in point.attributes:
1445+
self.assertIn(
1446+
attr,
1447+
_recommended_metrics_attrs_new_with_custom[
1448+
metric.name
1449+
],
1450+
)
1451+
self.assertTrue(number_data_point_seen and histogram_data_point_seen)
1452+
12931453
async def test_asgi_metrics_both_semconv(self):
12941454
# pylint: disable=too-many-nested-blocks
12951455
app = otel_asgi.OpenTelemetryMiddleware(simple_asgi)
@@ -1335,6 +1495,54 @@ async def test_asgi_metrics_both_semconv(self):
13351495
)
13361496
self.assertTrue(number_data_point_seen and histogram_data_point_seen)
13371497

1498+
async def test_asgi_metrics_both_semconv_custom_attributes(self):
1499+
# pylint: disable=too-many-nested-blocks
1500+
app = otel_asgi.OpenTelemetryMiddleware(custom_attrs_asgi)
1501+
self.seed_app(app)
1502+
await self.send_default_request()
1503+
await self.get_all_output()
1504+
self.seed_app(app)
1505+
await self.send_default_request()
1506+
await self.get_all_output()
1507+
self.seed_app(app)
1508+
await self.send_default_request()
1509+
await self.get_all_output()
1510+
metrics_list = self.memory_metrics_reader.get_metrics_data()
1511+
number_data_point_seen = False
1512+
histogram_data_point_seen = False
1513+
self.assertTrue(len(metrics_list.resource_metrics) != 0)
1514+
for resource_metric in metrics_list.resource_metrics:
1515+
self.assertTrue(len(resource_metric.scope_metrics) != 0)
1516+
for scope_metric in resource_metric.scope_metrics:
1517+
self.assertTrue(len(scope_metric.metrics) != 0)
1518+
self.assertEqual(
1519+
scope_metric.scope.name,
1520+
"opentelemetry.instrumentation.asgi",
1521+
)
1522+
for metric in scope_metric.metrics:
1523+
self.assertIn(metric.name, _expected_metric_names_both)
1524+
data_points = list(metric.data.data_points)
1525+
self.assertEqual(len(data_points), 1)
1526+
for point in data_points:
1527+
if isinstance(point, HistogramDataPoint):
1528+
self.assertEqual(point.count, 3)
1529+
if metric.name == "http.server.request.duration":
1530+
self.assertEqual(
1531+
point.explicit_bounds,
1532+
HTTP_DURATION_HISTOGRAM_BUCKETS_NEW,
1533+
)
1534+
histogram_data_point_seen = True
1535+
if isinstance(point, NumberDataPoint):
1536+
number_data_point_seen = True
1537+
for attr in point.attributes:
1538+
self.assertIn(
1539+
attr,
1540+
_recommended_metrics_attrs_both_with_custom[
1541+
metric.name
1542+
],
1543+
)
1544+
self.assertTrue(number_data_point_seen and histogram_data_point_seen)
1545+
13381546
async def test_basic_metric_success(self):
13391547
app = otel_asgi.OpenTelemetryMiddleware(simple_asgi)
13401548
self.seed_app(app)

0 commit comments

Comments
 (0)