diff --git a/.changelog/4840.added b/.changelog/4840.added new file mode 100644 index 0000000000..5bdffd781f --- /dev/null +++ b/.changelog/4840.added @@ -0,0 +1 @@ +`opentelemetry-instrumentation-django`: add `code.function.name`, `code.file.path` and `code.line.number` span attributes for the resolved view diff --git a/instrumentation/opentelemetry-instrumentation-django/src/opentelemetry/instrumentation/django/middleware/otel_middleware.py b/instrumentation/opentelemetry-instrumentation-django/src/opentelemetry/instrumentation/django/middleware/otel_middleware.py index be8bd70b13..54753278fe 100644 --- a/instrumentation/opentelemetry-instrumentation-django/src/opentelemetry/instrumentation/django/middleware/otel_middleware.py +++ b/instrumentation/opentelemetry-instrumentation-django/src/opentelemetry/instrumentation/django/middleware/otel_middleware.py @@ -1,11 +1,13 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 +import functools +import inspect import types from logging import getLogger from time import time from timeit import default_timer -from typing import Callable +from typing import Any, Callable from django import VERSION as django_version from django.http import HttpRequest, HttpResponse @@ -45,6 +47,11 @@ from opentelemetry.semconv._incubating.attributes.http_attributes import ( HTTP_TARGET, ) +from opentelemetry.semconv.attributes.code_attributes import ( + CODE_FILE_PATH, + CODE_FUNCTION_NAME, + CODE_LINE_NUMBER, +) from opentelemetry.semconv.attributes.http_attributes import HTTP_ROUTE from opentelemetry.trace import Span, SpanKind, use_span from opentelemetry.util.http import ( @@ -104,6 +111,56 @@ def _is_asgi_request(request: HttpRequest) -> bool: return ASGIRequest is not None and isinstance(request, ASGIRequest) +def _collect_code_attributes( + view_func: Callable[..., Any], +) -> dict[str, str | int]: + """Best-effort extraction of ``code.*`` span attributes from a view. + + Handles plain view functions, class-based views (``View.as_view()``), + ``functools.partial`` wrappers and decorated views. Returns an empty + dict when nothing can be extracted. + """ + attributes: dict[str, str | int] = {} + try: + target: Any = view_func + while isinstance(target, functools.partial): + target = target.func + # View.as_view() returns a wrapper exposing the original class + target = getattr(target, "view_class", target) + # follow decorator chains (functools.wraps sets __wrapped__) + target = inspect.unwrap(target) + if not (inspect.isroutine(target) or inspect.isclass(target)): + # callable instances: report the class implementing __call__ + target = type(target) + + qual_name = getattr(target, "__qualname__", None) + if qual_name: + module_name = getattr(target, "__module__", None) + if module_name: + attributes[CODE_FUNCTION_NAME] = f"{module_name}.{qual_name}" + else: + attributes[CODE_FUNCTION_NAME] = qual_name + + code = getattr(target, "__code__", None) + if code is not None: + attributes[CODE_FILE_PATH] = code.co_filename + attributes[CODE_LINE_NUMBER] = code.co_firstlineno + else: + # classes have no __code__; the source file is still cheap to + # resolve while the line number would require parsing the source + try: + attributes[CODE_FILE_PATH] = inspect.getfile(target) + except (TypeError, OSError): + pass + except Exception: # pylint: disable=broad-exception-caught + _logger.debug( + "Failed to collect code attributes for view %r", + view_func, + exc_info=True, + ) + return attributes + + class _DjangoMiddleware: """Django Middleware for OpenTelemetry""" @@ -291,6 +348,11 @@ def process_view(self, request, view_func, *args, **kwargs): ): span = request.META[self._environ_span_key] + if span.is_recording(): + code_attributes = _collect_code_attributes(view_func) + if code_attributes: + span.set_attributes(code_attributes) + match = getattr(request, "resolver_match", None) if match: route = getattr(match, "route", None) diff --git a/instrumentation/opentelemetry-instrumentation-django/tests/test_middleware.py b/instrumentation/opentelemetry-instrumentation-django/tests/test_middleware.py index 3d00443a6b..a7368894a1 100644 --- a/instrumentation/opentelemetry-instrumentation-django/tests/test_middleware.py +++ b/instrumentation/opentelemetry-instrumentation-django/tests/test_middleware.py @@ -35,6 +35,11 @@ ) from opentelemetry.sdk.trace import Span from opentelemetry.sdk.trace.id_generator import RandomIdGenerator +from opentelemetry.semconv.attributes.code_attributes import ( + CODE_FILE_PATH, + CODE_FUNCTION_NAME, + CODE_LINE_NUMBER, +) from opentelemetry.test.wsgitestutil import WsgiTestBase from opentelemetry.trace import ( SpanKind, @@ -52,6 +57,7 @@ # pylint: disable=import-error from .views import ( + TracedClassView, error, excluded, excluded_noarg, @@ -59,7 +65,10 @@ response_with_custom_header, route_span_name, traced, + traced_decorated, + traced_partial, traced_template, + traced_with_arg, ) DJANGO_2_0 = VERSION >= (2, 0) @@ -84,6 +93,9 @@ def path(path_argument, *args, **kwargs): re_path(r"^excluded_noarg/", excluded_noarg), re_path(r"^excluded_noarg2/", excluded_noarg2), re_path(r"^span_name/([0-9]{4})/$", route_span_name), + re_path(r"^traced_class/", TracedClassView.as_view()), + re_path(r"^traced_partial/", traced_partial), + re_path(r"^traced_decorated/", traced_decorated), path("", traced, name="empty"), ] _django_instrumentor = DjangoInstrumentor() @@ -242,6 +254,85 @@ def test_traced_get(self): self.assertEqual(span.attributes["http.scheme"], "http") self.assertEqual(span.attributes["http.status_code"], 200) + def test_code_attributes_function_view(self): + Client().get("/traced/") + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + + span = spans[0] + self.assertEqual( + span.attributes[CODE_FUNCTION_NAME], + f"{traced.__module__}.traced", + ) + self.assertEqual( + span.attributes[CODE_FILE_PATH], + traced.__code__.co_filename, + ) + self.assertEqual( + span.attributes[CODE_LINE_NUMBER], + traced.__code__.co_firstlineno, + ) + + def test_code_attributes_class_based_view(self): + Client().get("/traced_class/") + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + + span = spans[0] + self.assertEqual( + span.attributes[CODE_FUNCTION_NAME], + f"{TracedClassView.__module__}.TracedClassView", + ) + self.assertEqual( + span.attributes[CODE_FILE_PATH], + traced.__code__.co_filename, + ) + # line numbers are not resolved for class-based views + self.assertNotIn(CODE_LINE_NUMBER, span.attributes) + + def test_code_attributes_partial_view(self): + Client().get("/traced_partial/") + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + + span = spans[0] + self.assertEqual( + span.attributes[CODE_FUNCTION_NAME], + f"{traced_with_arg.__module__}.traced_with_arg", + ) + self.assertEqual( + span.attributes[CODE_FILE_PATH], + traced_with_arg.__code__.co_filename, + ) + self.assertEqual( + span.attributes[CODE_LINE_NUMBER], + traced_with_arg.__code__.co_firstlineno, + ) + + def test_code_attributes_decorated_view(self): + Client().get("/traced_decorated/") + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + + wrapped = traced_decorated.__wrapped__ + span = spans[0] + self.assertEqual( + span.attributes[CODE_FUNCTION_NAME], + f"{wrapped.__module__}.traced_decorated", + ) + self.assertEqual( + span.attributes[CODE_FILE_PATH], + wrapped.__code__.co_filename, + ) + self.assertEqual( + span.attributes[CODE_LINE_NUMBER], + wrapped.__code__.co_firstlineno, + ) + def test_traced_get_new_semconv(self): Client().get("/traced/") diff --git a/instrumentation/opentelemetry-instrumentation-django/tests/test_middleware_asgi.py b/instrumentation/opentelemetry-instrumentation-django/tests/test_middleware_asgi.py index 7df58bba67..890afc99d6 100644 --- a/instrumentation/opentelemetry-instrumentation-django/tests/test_middleware_asgi.py +++ b/instrumentation/opentelemetry-instrumentation-django/tests/test_middleware_asgi.py @@ -10,7 +10,11 @@ from django import VERSION, conf from django.http import HttpRequest, HttpResponse from django.test import SimpleTestCase -from django.test.utils import setup_test_environment, teardown_test_environment +from django.test.utils import ( + override_settings, + setup_test_environment, + teardown_test_environment, +) from opentelemetry import trace as trace_api from opentelemetry.instrumentation._semconv import ( @@ -35,6 +39,11 @@ HTTP_URL, ) from opentelemetry.semconv.attributes.client_attributes import CLIENT_ADDRESS +from opentelemetry.semconv.attributes.code_attributes import ( + CODE_FILE_PATH, + CODE_FUNCTION_NAME, + CODE_LINE_NUMBER, +) from opentelemetry.semconv.attributes.exception_attributes import ( EXCEPTION_MESSAGE, EXCEPTION_TYPE, @@ -248,6 +257,31 @@ async def test_traced_get(self): self.assertEqual(span.attributes[HTTP_SCHEME], "http") self.assertEqual(span.attributes[HTTP_STATUS_CODE], 200) + async def test_code_attributes_async_view(self): + # override_settings mutates the settings object Django internals + # hold a reference to; the plain settings.configure() done in + # setUpClass is not visible to the URL resolver when another test + # module configured settings first in the same process. + with override_settings(ROOT_URLCONF=modules[__name__]): + await self.async_client.get("/traced/") + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + + span = spans[0] + self.assertEqual( + span.attributes[CODE_FUNCTION_NAME], + f"{async_traced.__module__}.async_traced", + ) + self.assertEqual( + span.attributes[CODE_FILE_PATH], + async_traced.__code__.co_filename, + ) + self.assertEqual( + span.attributes[CODE_LINE_NUMBER], + async_traced.__code__.co_firstlineno, + ) + async def test_traced_get_new_semconv(self): await self.async_client.get("/traced/") diff --git a/instrumentation/opentelemetry-instrumentation-django/tests/views.py b/instrumentation/opentelemetry-instrumentation-django/tests/views.py index d9aa62a5c1..f42dd804ac 100644 --- a/instrumentation/opentelemetry-instrumentation-django/tests/views.py +++ b/instrumentation/opentelemetry-instrumentation-django/tests/views.py @@ -1,13 +1,42 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 +from functools import partial, wraps + from django.http import HttpResponse +from django.views.generic import View def traced(request): # pylint: disable=unused-argument return HttpResponse() +class TracedClassView(View): + # pylint: disable=no-self-use + def get(self, request): # pylint: disable=unused-argument + return HttpResponse() + + +def traced_with_arg(request, extra=None): # pylint: disable=unused-argument + return HttpResponse() + + +traced_partial = partial(traced_with_arg, extra="partial") + + +def _pass_through_decorator(func): + @wraps(func) + def wrapper(request, *args, **kwargs): + return func(request, *args, **kwargs) + + return wrapper + + +@_pass_through_decorator +def traced_decorated(request): # pylint: disable=unused-argument + return HttpResponse() + + def traced_template(request, year): # pylint: disable=unused-argument return HttpResponse()