Skip to content

Commit 8cd8c81

Browse files
Add DjangoInstrumentor labeler custom attrs metrics support
1 parent 3c73113 commit 8cd8c81

File tree

5 files changed

+249
-1
lines changed

5 files changed

+249
-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`, `opentelemetry-instrumentation-wsgi`: Add Labeler utility. Add FlaskInstrumentor, WsgiInstrumentor support of custom attributes merging for HTTP duration metrics.
25+
- `opentelemetry-instrumentation`, `opentelemetry-instrumentation-flask`, `opentelemetry-instrumentation-wsgi`, `opentelemetry-instrumentation-django`: Add Labeler utility. Add FlaskInstrumentor, DjangoInstrumentor, 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-django/src/opentelemetry/instrumentation/django/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,12 @@ def response_hook(span, request, response):
232232
Note:
233233
The environment variable names used to capture HTTP headers are still experimental, and thus are subject to change.
234234
235+
236+
Custom Metrics Attributes using Labeler
237+
***************************************
238+
The Django instrumentation reads from a Labeler utility that supports adding custom attributes to the HTTP duration metrics recorded by the instrumentation.
239+
240+
235241
API
236242
---
237243

instrumentation/opentelemetry-instrumentation-django/src/opentelemetry/instrumentation/django/middleware/otel_middleware.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from django.http import HttpRequest, HttpResponse
2323

2424
from opentelemetry.context import detach
25+
from opentelemetry.instrumentation._labeler import enhance_metric_attributes
2526
from opentelemetry.instrumentation._semconv import (
2627
_filter_semconv_active_request_count_attr,
2728
_filter_semconv_duration_attrs,
@@ -436,13 +437,21 @@ def process_response(self, request, response):
436437
target = duration_attrs.get(SpanAttributes.HTTP_TARGET)
437438
if target:
438439
duration_attrs_old[SpanAttributes.HTTP_TARGET] = target
440+
# Enhance attributes with any custom labeler attributes
441+
duration_attrs_old = enhance_metric_attributes(
442+
duration_attrs_old
443+
)
439444
self._duration_histogram_old.record(
440445
max(round(duration_s * 1000), 0), duration_attrs_old
441446
)
442447
if self._duration_histogram_new:
443448
duration_attrs_new = _parse_duration_attrs(
444449
duration_attrs, _StabilityMode.HTTP
445450
)
451+
# Enhance attributes with any custom labeler attributes
452+
duration_attrs_new = enhance_metric_attributes(
453+
duration_attrs_new
454+
)
446455
self._duration_histogram_new.record(
447456
max(duration_s, 0), duration_attrs_new
448457
)

instrumentation/opentelemetry-instrumentation-django/tests/test_middleware.py

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from django.test.utils import setup_test_environment, teardown_test_environment
2626

2727
from opentelemetry import trace
28+
from opentelemetry.instrumentation._labeler import clear_labeler
2829
from opentelemetry.instrumentation._semconv import (
2930
HTTP_DURATION_HISTOGRAM_BUCKETS_NEW,
3031
HTTP_DURATION_HISTOGRAM_BUCKETS_OLD,
@@ -69,6 +70,7 @@
6970
excluded_noarg2,
7071
response_with_custom_header,
7172
route_span_name,
73+
route_span_name_custom_attributes,
7274
traced,
7375
traced_template,
7476
)
@@ -95,6 +97,10 @@ def path(path_argument, *args, **kwargs):
9597
re_path(r"^excluded_noarg/", excluded_noarg),
9698
re_path(r"^excluded_noarg2/", excluded_noarg2),
9799
re_path(r"^span_name/([0-9]{4})/$", route_span_name),
100+
re_path(
101+
r"^span_name_custom_attrs/([0-9]{4})/$",
102+
route_span_name_custom_attributes,
103+
),
98104
path("", traced, name="empty"),
99105
]
100106
_django_instrumentor = DjangoInstrumentor()
@@ -115,6 +121,7 @@ def setUpClass(cls):
115121

116122
def setUp(self):
117123
super().setUp()
124+
clear_labeler()
118125
setup_test_environment()
119126
test_name = ""
120127
if hasattr(self, "_testMethodName"):
@@ -770,6 +777,67 @@ def test_wsgi_metrics(self):
770777
)
771778
self.assertTrue(histrogram_data_point_seen and number_data_point_seen)
772779

780+
def test_wsgi_metrics_custom_attributes(self):
781+
_expected_metric_names = [
782+
"http.server.active_requests",
783+
"http.server.duration",
784+
]
785+
expected_duration_attributes = {
786+
"http.method": "GET",
787+
"http.scheme": "http",
788+
"http.flavor": "1.1",
789+
"http.server_name": "testserver",
790+
"net.host.port": 80,
791+
"http.status_code": 200,
792+
"http.target": "^span_name_custom_attrs/([0-9]{4})/$",
793+
"custom_attr": "test_value",
794+
"endpoint_type": "test",
795+
"feature_flag": True,
796+
}
797+
expected_requests_count_attributes = {
798+
"http.method": "GET",
799+
"http.scheme": "http",
800+
"http.flavor": "1.1",
801+
"http.server_name": "testserver",
802+
}
803+
start = default_timer()
804+
for _ in range(3):
805+
response = Client().get("/span_name_custom_attrs/1234/")
806+
self.assertEqual(response.status_code, 200)
807+
duration = max(round((default_timer() - start) * 1000), 0)
808+
metrics_list = self.memory_metrics_reader.get_metrics_data()
809+
number_data_point_seen = False
810+
histrogram_data_point_seen = False
811+
812+
self.assertTrue(len(metrics_list.resource_metrics) != 0)
813+
for resource_metric in metrics_list.resource_metrics:
814+
self.assertTrue(len(resource_metric.scope_metrics) != 0)
815+
for scope_metric in resource_metric.scope_metrics:
816+
self.assertTrue(len(scope_metric.metrics) != 0)
817+
for metric in scope_metric.metrics:
818+
self.assertIn(metric.name, _expected_metric_names)
819+
data_points = list(metric.data.data_points)
820+
self.assertEqual(len(data_points), 1)
821+
for point in data_points:
822+
if isinstance(point, HistogramDataPoint):
823+
self.assertEqual(point.count, 3)
824+
histrogram_data_point_seen = True
825+
self.assertAlmostEqual(
826+
duration, point.sum, delta=100
827+
)
828+
self.assertDictEqual(
829+
expected_duration_attributes,
830+
dict(point.attributes),
831+
)
832+
if isinstance(point, NumberDataPoint):
833+
number_data_point_seen = True
834+
self.assertEqual(point.value, 0)
835+
self.assertDictEqual(
836+
expected_requests_count_attributes,
837+
dict(point.attributes),
838+
)
839+
self.assertTrue(histrogram_data_point_seen and number_data_point_seen)
840+
773841
# pylint: disable=too-many-locals
774842
def test_wsgi_metrics_new_semconv(self):
775843
_expected_metric_names = [
@@ -829,6 +897,68 @@ def test_wsgi_metrics_new_semconv(self):
829897
)
830898
self.assertTrue(histrogram_data_point_seen and number_data_point_seen)
831899

900+
# pylint: disable=too-many-locals
901+
def test_wsgi_metrics_new_semconv_custom_attributes(self):
902+
_expected_metric_names = [
903+
"http.server.active_requests",
904+
"http.server.request.duration",
905+
]
906+
expected_duration_attributes = {
907+
"http.request.method": "GET",
908+
"url.scheme": "http",
909+
"network.protocol.version": "1.1",
910+
"http.response.status_code": 200,
911+
"http.route": "^span_name_custom_attrs/([0-9]{4})/$",
912+
"custom_attr": "test_value",
913+
"endpoint_type": "test",
914+
"feature_flag": True,
915+
}
916+
expected_requests_count_attributes = {
917+
"http.request.method": "GET",
918+
"url.scheme": "http",
919+
}
920+
start = default_timer()
921+
for _ in range(3):
922+
response = Client().get("/span_name_custom_attrs/1234/")
923+
self.assertEqual(response.status_code, 200)
924+
duration_s = default_timer() - start
925+
metrics_list = self.memory_metrics_reader.get_metrics_data()
926+
number_data_point_seen = False
927+
histrogram_data_point_seen = False
928+
929+
self.assertTrue(len(metrics_list.resource_metrics) != 0)
930+
for resource_metric in metrics_list.resource_metrics:
931+
self.assertTrue(len(resource_metric.scope_metrics) != 0)
932+
for scope_metric in resource_metric.scope_metrics:
933+
self.assertTrue(len(scope_metric.metrics) != 0)
934+
for metric in scope_metric.metrics:
935+
self.assertIn(metric.name, _expected_metric_names)
936+
data_points = list(metric.data.data_points)
937+
self.assertEqual(len(data_points), 1)
938+
for point in data_points:
939+
if isinstance(point, HistogramDataPoint):
940+
self.assertEqual(point.count, 3)
941+
histrogram_data_point_seen = True
942+
self.assertAlmostEqual(
943+
duration_s, point.sum, places=1
944+
)
945+
self.assertDictEqual(
946+
expected_duration_attributes,
947+
dict(point.attributes),
948+
)
949+
self.assertEqual(
950+
point.explicit_bounds,
951+
HTTP_DURATION_HISTOGRAM_BUCKETS_NEW,
952+
)
953+
if isinstance(point, NumberDataPoint):
954+
number_data_point_seen = True
955+
self.assertEqual(point.value, 0)
956+
self.assertDictEqual(
957+
expected_requests_count_attributes,
958+
dict(point.attributes),
959+
)
960+
self.assertTrue(histrogram_data_point_seen and number_data_point_seen)
961+
832962
# pylint: disable=too-many-locals
833963
# pylint: disable=too-many-nested-blocks
834964
def test_wsgi_metrics_both_semconv(self):
@@ -917,6 +1047,100 @@ def test_wsgi_metrics_both_semconv(self):
9171047
)
9181048
self.assertTrue(histrogram_data_point_seen and number_data_point_seen)
9191049

1050+
# pylint: disable=too-many-locals
1051+
# pylint: disable=too-many-nested-blocks
1052+
def test_wsgi_metrics_both_semconv_custom_attributes(self):
1053+
_expected_metric_names = [
1054+
"http.server.duration",
1055+
"http.server.active_requests",
1056+
"http.server.request.duration",
1057+
]
1058+
expected_duration_attributes_old = {
1059+
"http.method": "GET",
1060+
"http.scheme": "http",
1061+
"http.flavor": "1.1",
1062+
"http.server_name": "testserver",
1063+
"net.host.port": 80,
1064+
"http.status_code": 200,
1065+
"http.target": "^span_name_custom_attrs/([0-9]{4})/$",
1066+
"custom_attr": "test_value",
1067+
"endpoint_type": "test",
1068+
"feature_flag": True,
1069+
}
1070+
expected_duration_attributes_new = {
1071+
"http.request.method": "GET",
1072+
"url.scheme": "http",
1073+
"network.protocol.version": "1.1",
1074+
"http.response.status_code": 200,
1075+
"http.route": "^span_name_custom_attrs/([0-9]{4})/$",
1076+
"custom_attr": "test_value",
1077+
"endpoint_type": "test",
1078+
"feature_flag": True,
1079+
}
1080+
expected_requests_count_attributes = {
1081+
"http.method": "GET",
1082+
"http.scheme": "http",
1083+
"http.flavor": "1.1",
1084+
"http.server_name": "testserver",
1085+
"http.request.method": "GET",
1086+
"url.scheme": "http",
1087+
}
1088+
start = default_timer()
1089+
for _ in range(3):
1090+
response = Client().get("/span_name/1234/")
1091+
self.assertEqual(response.status_code, 200)
1092+
duration_s = max(default_timer() - start, 0)
1093+
duration = max(round(duration_s * 1000), 0)
1094+
metrics_list = self.memory_metrics_reader.get_metrics_data()
1095+
number_data_point_seen = False
1096+
histrogram_data_point_seen = False
1097+
1098+
self.assertTrue(len(metrics_list.resource_metrics) != 0)
1099+
for resource_metric in metrics_list.resource_metrics:
1100+
self.assertTrue(len(resource_metric.scope_metrics) != 0)
1101+
for scope_metric in resource_metric.scope_metrics:
1102+
self.assertTrue(len(scope_metric.metrics) != 0)
1103+
for metric in scope_metric.metrics:
1104+
self.assertIn(metric.name, _expected_metric_names)
1105+
data_points = list(metric.data.data_points)
1106+
self.assertEqual(len(data_points), 1)
1107+
for point in data_points:
1108+
if isinstance(point, HistogramDataPoint):
1109+
self.assertEqual(point.count, 3)
1110+
histrogram_data_point_seen = True
1111+
if metric.name == "http.server.request.duration":
1112+
self.assertAlmostEqual(
1113+
duration_s, point.sum, places=1
1114+
)
1115+
self.assertDictEqual(
1116+
expected_duration_attributes_new,
1117+
dict(point.attributes),
1118+
)
1119+
self.assertEqual(
1120+
point.explicit_bounds,
1121+
HTTP_DURATION_HISTOGRAM_BUCKETS_NEW,
1122+
)
1123+
elif metric.name == "http.server.duration":
1124+
self.assertAlmostEqual(
1125+
duration, point.sum, delta=100
1126+
)
1127+
self.assertDictEqual(
1128+
expected_duration_attributes_old,
1129+
dict(point.attributes),
1130+
)
1131+
self.assertEqual(
1132+
point.explicit_bounds,
1133+
HTTP_DURATION_HISTOGRAM_BUCKETS_OLD,
1134+
)
1135+
if isinstance(point, NumberDataPoint):
1136+
number_data_point_seen = True
1137+
self.assertEqual(point.value, 0)
1138+
self.assertDictEqual(
1139+
expected_requests_count_attributes,
1140+
dict(point.attributes),
1141+
)
1142+
self.assertTrue(histrogram_data_point_seen and number_data_point_seen)
1143+
9201144
def test_wsgi_metrics_unistrument(self):
9211145
Client().get("/span_name/1234/")
9221146
_django_instrumentor.uninstrument()

instrumentation/opentelemetry-instrumentation-django/tests/views.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from django.http import HttpResponse
22

3+
from opentelemetry.instrumentation._labeler import get_labeler
4+
35

46
def traced(request): # pylint: disable=unused-argument
57
return HttpResponse()
@@ -29,6 +31,13 @@ def route_span_name(request, *args, **kwargs): # pylint: disable=unused-argumen
2931
return HttpResponse()
3032

3133

34+
def route_span_name_custom_attributes(request, *args, **kwargs): # pylint: disable=unused-argument
35+
labeler = get_labeler()
36+
labeler.add("custom_attr", "test_value")
37+
labeler.add_attributes({"endpoint_type": "test", "feature_flag": True})
38+
return HttpResponse()
39+
40+
3241
def response_with_custom_header(request):
3342
response = HttpResponse()
3443
response["custom-test-header-1"] = "test-header-value-1"

0 commit comments

Comments
 (0)