Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions .changelog/4840.added
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
`opentelemetry-instrumentation-django`: add `code.function.name`, `code.file.path` and `code.line.number` span attributes for the resolved view
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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"""

Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -52,14 +57,18 @@

# pylint: disable=import-error
from .views import (
TracedClassView,
error,
excluded,
excluded_noarg,
excluded_noarg2,
response_with_custom_header,
route_span_name,
traced,
traced_decorated,
traced_partial,
traced_template,
traced_with_arg,
)

DJANGO_2_0 = VERSION >= (2, 0)
Expand All @@ -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()
Expand Down Expand Up @@ -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/")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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,
Expand Down Expand Up @@ -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/")

Expand Down
Original file line number Diff line number Diff line change
@@ -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()

Expand Down