Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
([#3904](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3904))
- build: bump ruff to 0.14.1
([#3842](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3842))
- `opentelemetry-instrumentation-django`: Fix exemplars generation for http.server.(request.)duration
([#3945](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3945))

## Version 1.38.0/0.59b0 (2025-10-16)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from django import VERSION as django_version
from django.http import HttpRequest, HttpResponse

from opentelemetry import context
from opentelemetry.context import detach
from opentelemetry.instrumentation._semconv import (
_filter_semconv_active_request_count_attr,
Expand Down Expand Up @@ -56,6 +57,7 @@
from opentelemetry.semconv.attributes.http_attributes import HTTP_ROUTE
from opentelemetry.semconv.trace import SpanAttributes
from opentelemetry.trace import Span, SpanKind, use_span
from opentelemetry.trace.propagation import _SPAN_KEY
from opentelemetry.util.http import (
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS,
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST,
Expand Down Expand Up @@ -427,6 +429,12 @@ def process_response(self, request, response):
activation.__exit__(None, None, None)

if request_start_time is not None:
# Get the span and re-create context manually to pass to
# histogram for exemplars generation
metrics_context = (
context.set_value(_SPAN_KEY, span) if span else None
)

duration_s = default_timer() - request_start_time
if self._duration_histogram_old:
duration_attrs_old = _parse_duration_attrs(
Expand All @@ -437,14 +445,18 @@ def process_response(self, request, response):
if target:
duration_attrs_old[SpanAttributes.HTTP_TARGET] = target
self._duration_histogram_old.record(
max(round(duration_s * 1000), 0), duration_attrs_old
max(round(duration_s * 1000), 0),
duration_attrs_old,
context=metrics_context,
)
if self._duration_histogram_new:
duration_attrs_new = _parse_duration_attrs(
duration_attrs, _StabilityMode.HTTP
)
self._duration_histogram_new.record(
max(duration_s, 0), duration_attrs_new
max(duration_s, 0),
duration_attrs_new,
context=metrics_context,
)
self._active_request_counter.add(-1, active_requests_count_attrs)
if request.META.get(self._environ_token, None) is not None:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -917,6 +917,29 @@ def test_wsgi_metrics_both_semconv(self):
)
self.assertTrue(histrogram_data_point_seen and number_data_point_seen)

def test_wsgi_metrics_context_propagation(self):
with (
patch.object(
_DjangoMiddleware,
"_duration_histogram_old",
) as mock_histogram_old,
patch.object(
_DjangoMiddleware,
"_duration_histogram_new",
) as mock_histogram_new,
):
mock_histogram_old.record = Mock()
mock_histogram_new.record = Mock()

Client().get("/traced/")

self.assertTrue(mock_histogram_old.record.called)
call_args = mock_histogram_old.record.call_args
self.assertIsNotNone(call_args)
self.assertIn("context", call_args.kwargs)
context_arg = call_args.kwargs["context"]
self.assertIsNotNone(context_arg)

def test_wsgi_metrics_unistrument(self):
Client().get("/span_name/1234/")
_django_instrumentor.uninstrument()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
# Copyright The OpenTelemetry Authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from sys import modules

from django import VERSION, conf
from django.http import HttpResponse
from django.test.client import Client
from django.test.utils import setup_test_environment, teardown_test_environment

from opentelemetry import metrics as metrics_api
from opentelemetry.instrumentation.django import DjangoInstrumentor
from opentelemetry.sdk.metrics import AlwaysOnExemplarFilter
from opentelemetry.test.globals_test import (
reset_metrics_globals,
)
from opentelemetry.test.test_base import TestBase
from opentelemetry.trace import (
INVALID_SPAN_ID,
INVALID_TRACE_ID,
)

DJANGO_2_0 = VERSION >= (2, 0)

if DJANGO_2_0:
from django.urls import re_path
else:
from django.conf.urls import url as re_path


def view_test_route(request): # pylint: disable=unused-argument
return HttpResponse("Test response")


urlpatterns = [
re_path(r"^test/", view_test_route),
]


class TestFunctionalDjango(TestBase):
def setUp(self):
super().setUp()
self.memory_exporter.clear()
# This is done because set_meter_provider cannot override the
# current meter provider.
reset_metrics_globals()
(
self.meter_provider,
self.memory_metrics_reader,
) = self.create_meter_provider(
exemplar_filter=AlwaysOnExemplarFilter(),
)
metrics_api.set_meter_provider(self.meter_provider)

conf.settings.configure(
ROOT_URLCONF=modules[__name__],
DATABASES={
"default": {},
},
)

setup_test_environment()
self._client = Client()

DjangoInstrumentor().instrument(
meter_provider=self.meter_provider,
)

def tearDown(self):
DjangoInstrumentor().uninstrument()
teardown_test_environment()
conf.settings = conf.LazySettings()
super().tearDown()

def test_duration_metrics_exemplars(self):
"""Should generate exemplars with trace and span IDs for Django HTTP requests."""
self._client.get("/test/")
self._client.get("/test/")
self._client.get("/test/")

metrics_data = self.memory_metrics_reader.get_metrics_data()
self.assertIsNotNone(metrics_data)
self.assertTrue(len(metrics_data.resource_metrics) > 0)

duration_metric = None
metric_names = []
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the purpose of this variable?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wasn't used! Repurposed for more assertions in 10d7c56

for resource_metric in metrics_data.resource_metrics:
for scope_metric in resource_metric.scope_metrics:
for metric in scope_metric.metrics:
metric_names.append(metric.name)
if metric.name in [
"http.server.request.duration",
"http.server.duration",
]:
duration_metric = metric
break
if duration_metric:
break
if duration_metric:
break

self.assertIsNotNone(duration_metric)
data_points = list(duration_metric.data.data_points)
self.assertTrue(len(data_points) > 0)

exemplar_count = 0
for data_point in data_points:
if hasattr(data_point, "exemplars") and data_point.exemplars:
for exemplar in data_point.exemplars:
exemplar_count += 1
# Exemplar has required fields and valid span context
self.assertIsNotNone(exemplar.value)
self.assertIsNotNone(exemplar.time_unix_nano)
self.assertIsNotNone(exemplar.span_id)
self.assertNotEqual(exemplar.span_id, INVALID_SPAN_ID)
self.assertIsNotNone(exemplar.trace_id)
self.assertNotEqual(exemplar.trace_id, INVALID_TRACE_ID)

# Trace and span ID of exemplar are part of finished spans
finished_spans = self.memory_exporter.get_finished_spans()
finished_span_ids = [
span.context.span_id for span in finished_spans
]
finished_trace_ids = [
span.context.trace_id for span in finished_spans
]
self.assertIn(exemplar.span_id, finished_span_ids)
self.assertIn(exemplar.trace_id, finished_trace_ids)

# At least one exemplar was generated
self.assertGreater(exemplar_count, 0)
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ click-repl==0.3.0
cryptography==44.0.1
Deprecated==1.2.14
distro==1.9.0
Django==4.2.17
dnspython==2.6.1
docker==5.0.3
docker-compose==1.29.2
Expand Down
3 changes: 3 additions & 0 deletions tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -1008,6 +1008,7 @@ deps =
-e {toxinidir}/instrumentation/opentelemetry-instrumentation-kafka-python
-e {toxinidir}/instrumentation/opentelemetry-instrumentation-confluent-kafka
-e {toxinidir}/instrumentation/opentelemetry-instrumentation-dbapi
-e {toxinidir}/instrumentation/opentelemetry-instrumentation-django
-e {toxinidir}/instrumentation/opentelemetry-instrumentation-mysql
-e {toxinidir}/instrumentation/opentelemetry-instrumentation-mysqlclient
-e {toxinidir}/instrumentation/opentelemetry-instrumentation-psycopg
Expand All @@ -1019,6 +1020,8 @@ deps =
-e {toxinidir}/instrumentation/opentelemetry-instrumentation-aiopg
-e {toxinidir}/instrumentation/opentelemetry-instrumentation-redis
-e {toxinidir}/instrumentation/opentelemetry-instrumentation-remoulade
-e {toxinidir}/instrumentation/opentelemetry-instrumentation-wsgi
-e {toxinidir}/util/opentelemetry-util-http
opentelemetry-exporter-opencensus@{env:CORE_REPO}\#egg=opentelemetry-exporter-opencensus&subdirectory=exporter/opentelemetry-exporter-opencensus

changedir =
Expand Down