Skip to content

Commit 3c73113

Browse files
Add WsgiInstrumentor labeler support for custom metrics
1 parent 98c375e commit 3c73113

File tree

3 files changed

+199
-1
lines changed

3 files changed

+199
-1
lines changed

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2222

2323
- `opentelemetry-instrumentation-confluent-kafka` Add support for confluent-kafka <=2.11.0
2424
([#3685](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3685))
25-
- `opentelemetry-instrumentation`, `opentelemetry-instrumentation-flask`: Add Labeler utility. Add FlaskInstrumentor support of custom attributes merging for HTTP duration metrics.
25+
- `opentelemetry-instrumentation`, `opentelemetry-instrumentation-flask`, `opentelemetry-instrumentation-wsgi`: Add Labeler utility. Add FlaskInstrumentor, WsgiInstrumentor support of custom attributes merging for HTTP duration metrics.
2626
([#3689](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3689))
2727

2828
## Version 1.36.0/0.57b0 (2025-07-29)

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

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,39 @@ def GET(self):
7979
)
8080
server.start()
8181
82+
Custom Metrics Attributes using Labeler
83+
***************************************
84+
The WSGI instrumentation reads from a Labeler utility that supports adding custom attributes
85+
to the HTTP duration metrics recorded by the instrumentation.
86+
87+
88+
.. code-block:: python
89+
90+
from flask import Flask
91+
from opentelemetry.instrumentation._labeler import get_labeler
92+
from opentelemetry.instrumentation.wsgi import OpenTelemetryMiddleware
93+
94+
app = Flask(__name__)
95+
app.wsgi_app = OpenTelemetryMiddleware(app.wsgi_app)
96+
97+
@app.route("/user/<user_id>")
98+
def user_profile(user_id):
99+
# Get the labeler for the current request
100+
labeler = get_labeler()
101+
# Add custom attributes to WSGI instrumentation metrics
102+
labeler.add("user_id", user_id)
103+
labeler.add("user_type", "registered")
104+
# Or, add multiple attributes at once
105+
labeler.add_attributes({
106+
"feature_flag": "new_ui",
107+
"experiment_group": "control"
108+
})
109+
return f"User profile for {user_id}"
110+
111+
if __name__ == "__main__":
112+
app.run(debug=True)
113+
114+
82115
Configuration
83116
-------------
84117
@@ -223,6 +256,7 @@ def response_hook(span: Span, environ: WSGIEnvironment, status: str, response_he
223256
from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, TypeVar, cast
224257

225258
from opentelemetry import context, trace
259+
from opentelemetry.instrumentation._labeler import enhance_metric_attributes
226260
from opentelemetry.instrumentation._semconv import (
227261
HTTP_DURATION_HISTOGRAM_BUCKETS_NEW,
228262
_filter_semconv_active_request_count_attr,
@@ -712,13 +746,21 @@ def __call__(
712746
duration_attrs_old = _parse_duration_attrs(
713747
req_attrs, _StabilityMode.DEFAULT
714748
)
749+
# Enhance attributes with any custom labeler attributes
750+
duration_attrs_old = enhance_metric_attributes(
751+
duration_attrs_old
752+
)
715753
self.duration_histogram_old.record(
716754
max(round(duration_s * 1000), 0), duration_attrs_old
717755
)
718756
if self.duration_histogram_new:
719757
duration_attrs_new = _parse_duration_attrs(
720758
req_attrs, _StabilityMode.HTTP
721759
)
760+
# Enhance attributes with any custom labeler attributes
761+
duration_attrs_new = enhance_metric_attributes(
762+
duration_attrs_new
763+
)
722764
self.duration_histogram_new.record(
723765
max(duration_s, 0), duration_attrs_new
724766
)

instrumentation/opentelemetry-instrumentation-wsgi/tests/test_wsgi_middleware.py

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

2323
import opentelemetry.instrumentation.wsgi as otel_wsgi
2424
from opentelemetry import trace as trace_api
25+
from opentelemetry.instrumentation._labeler import (
26+
clear_labeler,
27+
get_labeler,
28+
)
2529
from opentelemetry.instrumentation._semconv import (
2630
HTTP_DURATION_HISTOGRAM_BUCKETS_NEW,
2731
OTEL_SEMCONV_STABILITY_OPT_IN,
@@ -139,6 +143,14 @@ def error_wsgi_unhandled(environ, start_response):
139143
raise ValueError
140144

141145

146+
def error_wsgi_unhandled_custom_attrs(environ, start_response):
147+
labeler = get_labeler()
148+
labeler.add("custom_attr", "test_value")
149+
labeler.add_attributes({"endpoint_type": "test", "feature_flag": True})
150+
assert isinstance(environ, dict)
151+
raise ValueError
152+
153+
142154
def wsgi_with_custom_response_headers(environ, start_response):
143155
assert isinstance(environ, dict)
144156
start_response(
@@ -201,6 +213,28 @@ def wsgi_with_repeat_custom_response_headers(environ, start_response):
201213
"http.server.request.duration": _server_duration_attrs_new,
202214
}
203215

216+
_custom_attributes = ["custom_attr", "endpoint_type", "feature_flag"]
217+
_server_duration_attrs_old_with_custom = _server_duration_attrs_old.copy()
218+
_server_duration_attrs_old_with_custom.append("http.target")
219+
_server_duration_attrs_old_with_custom.extend(_custom_attributes)
220+
_server_duration_attrs_new_with_custom = _server_duration_attrs_new.copy()
221+
_server_duration_attrs_new_with_custom.append("http.route")
222+
_server_duration_attrs_new_with_custom.extend(_custom_attributes)
223+
224+
_recommended_metrics_attrs_old_with_custom = {
225+
"http.server.active_requests": _server_active_requests_count_attrs_old,
226+
"http.server.duration": _server_duration_attrs_old_with_custom,
227+
}
228+
_recommended_metrics_attrs_new_with_custom = {
229+
"http.server.active_requests": _server_active_requests_count_attrs_new,
230+
"http.server.request.duration": _server_duration_attrs_new_with_custom,
231+
}
232+
_recommended_metrics_attrs_both_with_custom = {
233+
"http.server.active_requests": _server_active_requests_count_attrs_both,
234+
"http.server.duration": _server_duration_attrs_old_with_custom,
235+
"http.server.request.duration": _server_duration_attrs_new_with_custom,
236+
}
237+
204238

205239
class TestWsgiApplication(WsgiTestBase):
206240
def setUp(self):
@@ -221,6 +255,8 @@ def setUp(self):
221255
},
222256
)
223257

258+
clear_labeler()
259+
224260
_OpenTelemetrySemanticConventionStability._initialized = False
225261

226262
self.env_patch.start()
@@ -415,6 +451,41 @@ def test_wsgi_metrics(self):
415451
)
416452
self.assertTrue(number_data_point_seen and histogram_data_point_seen)
417453

454+
def test_wsgi_metrics_custom_attributes(self):
455+
app = otel_wsgi.OpenTelemetryMiddleware(
456+
error_wsgi_unhandled_custom_attrs
457+
)
458+
self.assertRaises(ValueError, app, self.environ, self.start_response)
459+
self.assertRaises(ValueError, app, self.environ, self.start_response)
460+
self.assertRaises(ValueError, app, self.environ, self.start_response)
461+
metrics_list = self.memory_metrics_reader.get_metrics_data()
462+
number_data_point_seen = False
463+
histogram_data_point_seen = False
464+
465+
self.assertTrue(len(metrics_list.resource_metrics) != 0)
466+
for resource_metric in metrics_list.resource_metrics:
467+
self.assertTrue(len(resource_metric.scope_metrics) != 0)
468+
for scope_metric in resource_metric.scope_metrics:
469+
self.assertTrue(len(scope_metric.metrics) != 0)
470+
for metric in scope_metric.metrics:
471+
self.assertIn(metric.name, _expected_metric_names_old)
472+
data_points = list(metric.data.data_points)
473+
self.assertEqual(len(data_points), 1)
474+
for point in data_points:
475+
if isinstance(point, HistogramDataPoint):
476+
self.assertEqual(point.count, 3)
477+
histogram_data_point_seen = True
478+
if isinstance(point, NumberDataPoint):
479+
number_data_point_seen = True
480+
for attr in point.attributes:
481+
self.assertIn(
482+
attr,
483+
_recommended_metrics_attrs_old_with_custom[
484+
metric.name
485+
],
486+
)
487+
self.assertTrue(number_data_point_seen and histogram_data_point_seen)
488+
418489
def test_wsgi_metrics_new_semconv(self):
419490
# pylint: disable=too-many-nested-blocks
420491
app = otel_wsgi.OpenTelemetryMiddleware(error_wsgi_unhandled)
@@ -452,6 +523,45 @@ def test_wsgi_metrics_new_semconv(self):
452523
)
453524
self.assertTrue(number_data_point_seen and histogram_data_point_seen)
454525

526+
def test_wsgi_metrics_new_semconv_custom_attributes(self):
527+
# pylint: disable=too-many-nested-blocks
528+
app = otel_wsgi.OpenTelemetryMiddleware(error_wsgi_unhandled)
529+
self.assertRaises(ValueError, app, self.environ, self.start_response)
530+
self.assertRaises(ValueError, app, self.environ, self.start_response)
531+
self.assertRaises(ValueError, app, self.environ, self.start_response)
532+
metrics_list = self.memory_metrics_reader.get_metrics_data()
533+
number_data_point_seen = False
534+
histogram_data_point_seen = False
535+
536+
self.assertTrue(len(metrics_list.resource_metrics) != 0)
537+
for resource_metric in metrics_list.resource_metrics:
538+
self.assertTrue(len(resource_metric.scope_metrics) != 0)
539+
for scope_metric in resource_metric.scope_metrics:
540+
self.assertTrue(len(scope_metric.metrics) != 0)
541+
for metric in scope_metric.metrics:
542+
self.assertIn(metric.name, _expected_metric_names_new)
543+
data_points = list(metric.data.data_points)
544+
self.assertEqual(len(data_points), 1)
545+
for point in data_points:
546+
if isinstance(point, HistogramDataPoint):
547+
self.assertEqual(point.count, 3)
548+
if metric.name == "http.server.request.duration":
549+
self.assertEqual(
550+
point.explicit_bounds,
551+
HTTP_DURATION_HISTOGRAM_BUCKETS_NEW,
552+
)
553+
histogram_data_point_seen = True
554+
if isinstance(point, NumberDataPoint):
555+
number_data_point_seen = True
556+
for attr in point.attributes:
557+
self.assertIn(
558+
attr,
559+
_recommended_metrics_attrs_new_with_custom[
560+
metric.name
561+
],
562+
)
563+
self.assertTrue(number_data_point_seen and histogram_data_point_seen)
564+
455565
def test_wsgi_metrics_both_semconv(self):
456566
# pylint: disable=too-many-nested-blocks
457567
app = otel_wsgi.OpenTelemetryMiddleware(error_wsgi_unhandled)
@@ -496,6 +606,52 @@ def test_wsgi_metrics_both_semconv(self):
496606
)
497607
self.assertTrue(number_data_point_seen and histogram_data_point_seen)
498608

609+
def test_wsgi_metrics_both_semconv_custom_attributes(self):
610+
# pylint: disable=too-many-nested-blocks
611+
app = otel_wsgi.OpenTelemetryMiddleware(error_wsgi_unhandled)
612+
self.assertRaises(ValueError, app, self.environ, self.start_response)
613+
metrics_list = self.memory_metrics_reader.get_metrics_data()
614+
number_data_point_seen = False
615+
histogram_data_point_seen = False
616+
617+
self.assertTrue(len(metrics_list.resource_metrics) != 0)
618+
for resource_metric in metrics_list.resource_metrics:
619+
self.assertTrue(len(resource_metric.scope_metrics) != 0)
620+
for scope_metric in resource_metric.scope_metrics:
621+
self.assertTrue(len(scope_metric.metrics) != 0)
622+
for metric in scope_metric.metrics:
623+
if metric.unit == "ms":
624+
self.assertEqual(metric.name, "http.server.duration")
625+
elif metric.unit == "s":
626+
self.assertEqual(
627+
metric.name, "http.server.request.duration"
628+
)
629+
else:
630+
self.assertEqual(
631+
metric.name, "http.server.active_requests"
632+
)
633+
data_points = list(metric.data.data_points)
634+
self.assertEqual(len(data_points), 1)
635+
for point in data_points:
636+
if isinstance(point, HistogramDataPoint):
637+
self.assertEqual(point.count, 1)
638+
if metric.name == "http.server.request.duration":
639+
self.assertEqual(
640+
point.explicit_bounds,
641+
HTTP_DURATION_HISTOGRAM_BUCKETS_NEW,
642+
)
643+
histogram_data_point_seen = True
644+
if isinstance(point, NumberDataPoint):
645+
number_data_point_seen = True
646+
for attr in point.attributes:
647+
self.assertIn(
648+
attr,
649+
_recommended_metrics_attrs_both_with_custom[
650+
metric.name
651+
],
652+
)
653+
self.assertTrue(number_data_point_seen and histogram_data_point_seen)
654+
499655
def test_nonstandard_http_method(self):
500656
self.environ["REQUEST_METHOD"] = "NONSTANDARD"
501657
app = otel_wsgi.OpenTelemetryMiddleware(simple_wsgi)

0 commit comments

Comments
 (0)