Skip to content

Commit ba13300

Browse files
Django enrich_metric_attributes from Labeler if set
1 parent 23a9dac commit ba13300

File tree

4 files changed

+175
-2
lines changed

4 files changed

+175
-2
lines changed

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,44 @@ def response_hook(span, request, response):
294294
| ``SQLCOMMENTER_WITH_DB_DRIVER`` | Database driver name used by Django. | ``db_driver='django.db.backends.postgresql'`` |
295295
+-------------------------------------+-----------------------------------------------------------+---------------------------------------------------------------------------+
296296
297+
Custom Metrics Attributes using Labeler
298+
***************************************
299+
The Django instrumentation reads from a labeler utility that supports adding custom
300+
attributes to HTTP server metrics at emit time, including:
301+
302+
- Active requests counter (``http.server.active_requests``)
303+
- Duration histogram (``http.server.duration``)
304+
- Request duration histogram (``http.server.request.duration``)
305+
306+
The custom attributes are stored only within the context of an instrumented
307+
request or operation. The instrumentor does not overwrite base attributes that
308+
exist at the same keys as any custom attributes.
309+
310+
311+
.. code:: python
312+
313+
from django.http import HttpResponse
314+
from opentelemetry.instrumentation._labeler import get_labeler
315+
from opentelemetry.instrumentation.django import DjangoInstrumentor
316+
317+
DjangoInstrumentor().instrument()
318+
319+
# Note: urlpattern `/users/<user_id>/` mapped elsewhere
320+
def my_user_view(request, user_id):
321+
# Get the labeler for the current request
322+
labeler = get_labeler()
323+
324+
# Add custom attributes to Django instrumentation metrics
325+
labeler.add("user_id", user_id)
326+
labeler.add("user_type", "registered")
327+
328+
# Or, add multiple attributes at once
329+
labeler.add_attributes({
330+
"feature_flag": "new_ui",
331+
"experiment_group": "control"
332+
})
333+
return HttpResponse("Done!")
334+
297335
API
298336
---
299337

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

Lines changed: 13 additions & 2 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 enrich_metric_attributes
2526
from opentelemetry.instrumentation._semconv import (
2627
_filter_semconv_active_request_count_attr,
2728
_filter_semconv_duration_attrs,
@@ -227,7 +228,9 @@ def process_request(self, request):
227228
)
228229
# Pass all of attributes to duration key because we will filter during response
229230
request.META[self._environ_duration_attr_key] = attributes
230-
self._active_request_counter.add(1, active_requests_count_attrs)
231+
self._active_request_counter.add(
232+
1, enrich_metric_attributes(active_requests_count_attrs)
233+
)
231234
if span.is_recording():
232235
attributes = extract_attributes_from_object(
233236
request, self._traced_request_attrs, attributes
@@ -415,6 +418,9 @@ def process_response(self, request, response):
415418
target = duration_attrs.get(HTTP_TARGET)
416419
if target:
417420
duration_attrs_old[HTTP_TARGET] = target
421+
duration_attrs_old = enrich_metric_attributes(
422+
duration_attrs_old
423+
)
418424
self._duration_histogram_old.record(
419425
max(round(duration_s * 1000), 0),
420426
duration_attrs_old,
@@ -423,11 +429,16 @@ def process_response(self, request, response):
423429
duration_attrs_new = _parse_duration_attrs(
424430
duration_attrs, _StabilityMode.HTTP
425431
)
432+
duration_attrs_new = enrich_metric_attributes(
433+
duration_attrs_new
434+
)
426435
self._duration_histogram_new.record(
427436
max(duration_s, 0),
428437
duration_attrs_new,
429438
)
430-
self._active_request_counter.add(-1, active_requests_count_attrs)
439+
self._active_request_counter.add(
440+
-1, enrich_metric_attributes(active_requests_count_attrs)
441+
)
431442

432443
if activation and span:
433444
if exception:

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

Lines changed: 115 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,
@@ -35,6 +36,9 @@
3536
DjangoInstrumentor,
3637
_DjangoMiddleware,
3738
)
39+
from opentelemetry.instrumentation.django.middleware import (
40+
otel_middleware as django_otel_middleware,
41+
)
3842
from opentelemetry.instrumentation.propagators import (
3943
TraceResponsePropagator,
4044
set_global_response_propagator,
@@ -69,6 +73,7 @@
6973
excluded_noarg2,
7074
response_with_custom_header,
7175
route_span_name,
76+
route_span_name_custom_attributes,
7277
traced,
7378
traced_template,
7479
)
@@ -95,6 +100,10 @@ def path(path_argument, *args, **kwargs):
95100
re_path(r"^excluded_noarg/", excluded_noarg),
96101
re_path(r"^excluded_noarg2/", excluded_noarg2),
97102
re_path(r"^span_name/([0-9]{4})/$", route_span_name),
103+
re_path(
104+
r"^span_name_custom_attrs/([0-9]{4})/$",
105+
route_span_name_custom_attributes,
106+
),
98107
path("", traced, name="empty"),
99108
]
100109
_django_instrumentor = DjangoInstrumentor()
@@ -117,6 +126,7 @@ def setUpClass(cls):
117126

118127
def setUp(self):
119128
super().setUp()
129+
clear_labeler()
120130
setup_test_environment()
121131
test_name = ""
122132
if hasattr(self, "_testMethodName"):
@@ -766,6 +776,111 @@ def test_wsgi_metrics(self):
766776
)
767777
self.assertTrue(histrogram_data_point_seen and number_data_point_seen)
768778

779+
def test_wsgi_metrics_custom_attributes_skip_override(self):
780+
expected_duration_attributes = {
781+
"http.method": "GET",
782+
"http.scheme": "http",
783+
"http.flavor": "1.1",
784+
"http.server_name": "testserver",
785+
"net.host.port": 80,
786+
"http.status_code": 200,
787+
"http.target": "^span_name_custom_attrs/([0-9]{4})/$",
788+
"custom_attr": "test_value",
789+
}
790+
791+
response = Client().get("/span_name_custom_attrs/1234/")
792+
self.assertEqual(response.status_code, 200)
793+
794+
metrics = self.get_sorted_metrics(SCOPE)
795+
active_requests_point_seen = False
796+
histogram_data_point_seen = False
797+
for metric in metrics:
798+
if metric.name == "http.server.active_requests":
799+
data_points = list(metric.data.data_points)
800+
for point in data_points:
801+
self.assertIsInstance(point, NumberDataPoint)
802+
if point.attributes.get("custom_attr") != "test_value":
803+
continue
804+
self.assertEqual(point.attributes["http.method"], "GET")
805+
active_requests_point_seen = True
806+
continue
807+
808+
if metric.name != "http.server.duration":
809+
continue
810+
data_points = list(metric.data.data_points)
811+
self.assertEqual(len(data_points), 1)
812+
point = data_points[0]
813+
self.assertIsInstance(point, HistogramDataPoint)
814+
histogram_data_point_seen = True
815+
self.assertDictEqual(
816+
expected_duration_attributes, dict(point.attributes)
817+
)
818+
819+
self.assertTrue(active_requests_point_seen)
820+
self.assertTrue(histogram_data_point_seen)
821+
822+
def test_wsgi_active_requests_custom_attributes_new_semconv(self):
823+
response = Client().get("/span_name_custom_attrs/1234/")
824+
self.assertEqual(response.status_code, 200)
825+
826+
metrics = self.get_sorted_metrics(SCOPE)
827+
active_requests_point_seen = False
828+
for metric in metrics:
829+
if metric.name != "http.server.active_requests":
830+
continue
831+
data_points = list(metric.data.data_points)
832+
for point in data_points:
833+
self.assertIsInstance(point, NumberDataPoint)
834+
if point.attributes.get("custom_attr") != "test_value":
835+
continue
836+
self.assertEqual(
837+
point.attributes["http.request.method"], "GET"
838+
)
839+
active_requests_point_seen = True
840+
841+
self.assertTrue(active_requests_point_seen)
842+
843+
def test_wsgi_active_requests_attrs_use_enrich_old_semconv(self):
844+
with patch(
845+
"opentelemetry.instrumentation.django.middleware.otel_middleware.enrich_metric_attributes",
846+
wraps=django_otel_middleware.enrich_metric_attributes,
847+
) as mock_enrich:
848+
response = Client().get("/span_name/1234/")
849+
self.assertEqual(response.status_code, 200)
850+
851+
enriched_active_attrs_seen = False
852+
for call in mock_enrich.call_args_list:
853+
if not call.args:
854+
continue
855+
attrs = call.args[0]
856+
if "http.method" in attrs and "http.status_code" not in attrs:
857+
enriched_active_attrs_seen = True
858+
break
859+
860+
self.assertTrue(enriched_active_attrs_seen)
861+
862+
def test_wsgi_active_requests_attrs_use_enrich_new_semconv(self):
863+
with patch(
864+
"opentelemetry.instrumentation.django.middleware.otel_middleware.enrich_metric_attributes",
865+
wraps=django_otel_middleware.enrich_metric_attributes,
866+
) as mock_enrich:
867+
response = Client().get("/span_name/1234/")
868+
self.assertEqual(response.status_code, 200)
869+
870+
enriched_active_attrs_seen = False
871+
for call in mock_enrich.call_args_list:
872+
if not call.args:
873+
continue
874+
attrs = call.args[0]
875+
if (
876+
"http.request.method" in attrs
877+
and "http.response.status_code" not in attrs
878+
):
879+
enriched_active_attrs_seen = True
880+
break
881+
882+
self.assertTrue(enriched_active_attrs_seen)
883+
769884
# pylint: disable=too-many-locals
770885
def test_wsgi_metrics_new_semconv(self):
771886
_expected_metric_names = [

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("http.method", "POST")
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)