Skip to content

Commit 39083ff

Browse files
authored
support code attributes for django (#489)
*Description of changes:* If set environment variable OTEL_AWS_CODE_CORRELATION_ENABLED=true, django server spans have code-level information. ``` "http.route": "template/", "code.function.name": "views.TestTemplateView.get", "code.file.path": "/Volumes/workplace/extension/aws-otel-python-instrumentation/samples/django/views.py", "code.line.number": 59, "http.status_code": 200 ``` By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.
1 parent 9baabb1 commit 39083ff

File tree

8 files changed

+526
-160
lines changed

8 files changed

+526
-160
lines changed

aws-opentelemetry-distro/src/amazon/opentelemetry/distro/code_correlation/__init__.py

Lines changed: 19 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
This module provides functionality for correlating code execution with telemetry data.
88
"""
99

10+
import inspect
1011
from functools import wraps
1112
from typing import Any, Callable
1213

@@ -21,7 +22,7 @@
2122
CODE_LINE_NUMBER = "code.line.number"
2223

2324

24-
def add_code_attributes_to_span(span, func: Callable[..., Any]) -> None:
25+
def add_code_attributes_to_span(span, func_or_class: Callable[..., Any]) -> None:
2526
"""
2627
Add code-related attributes to a span based on a Python function.
2728
@@ -39,32 +40,23 @@ def add_code_attributes_to_span(span, func: Callable[..., Any]) -> None:
3940
return
4041

4142
try:
42-
# Get function name
43-
function_name = getattr(func, "__name__", str(func))
44-
span.set_attribute(CODE_FUNCTION_NAME, function_name)
45-
46-
# Get function source file from code object
47-
try:
48-
if hasattr(func, "__code__"):
49-
source_file = func.__code__.co_filename
50-
span.set_attribute(CODE_FILE_PATH, source_file)
51-
except (AttributeError, TypeError):
52-
# Handle cases where code object is not available
53-
# (e.g., built-in functions, C extensions)
54-
pass
55-
56-
# Get function line number from code object
43+
# Check if it's a class first, with proper exception handling
5744
try:
58-
if hasattr(func, "__code__"):
59-
line_number = func.__code__.co_firstlineno
60-
span.set_attribute(CODE_LINE_NUMBER, line_number)
61-
except (AttributeError, TypeError):
62-
# Handle cases where code object is not available
63-
pass
64-
45+
is_class = inspect.isclass(func_or_class)
46+
except Exception: # pylint: disable=broad-exception-caught
47+
# If inspect.isclass fails, we can't safely determine the type, so return early
48+
return
49+
50+
if is_class:
51+
span.set_attribute(CODE_FUNCTION_NAME, f"{func_or_class.__module__}.{func_or_class.__qualname__}")
52+
span.set_attribute(CODE_FILE_PATH, inspect.getfile(func_or_class))
53+
else:
54+
code = getattr(func_or_class, "__code__", None)
55+
if code:
56+
span.set_attribute(CODE_FUNCTION_NAME, f"{func_or_class.__module__}.{func_or_class.__qualname__}")
57+
span.set_attribute(CODE_FILE_PATH, code.co_filename)
58+
span.set_attribute(CODE_LINE_NUMBER, code.co_firstlineno)
6559
except Exception: # pylint: disable=broad-exception-caught
66-
# Silently handle any unexpected errors to avoid breaking
67-
# the instrumentation flow
6860
pass
6961

7062

@@ -97,9 +89,8 @@ async def my_async_function():
9789
Returns:
9890
The wrapped function with current span code attributes tracing
9991
"""
100-
# Detect async functions: check function code object flags or special attributes
101-
# CO_ITERABLE_COROUTINE = 0x80, async functions will have this flag set
102-
is_async = (hasattr(func, "__code__") and func.__code__.co_flags & 0x80) or hasattr(func, "_is_coroutine")
92+
# Detect async functions
93+
is_async = inspect.iscoroutinefunction(func)
10394

10495
if is_async:
10596
# Async function wrapper
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
# Modifications Copyright The OpenTelemetry Authors. Licensed under the Apache License 2.0 License.
4+
5+
from logging import getLogger
6+
7+
from amazon.opentelemetry.distro.aws_opentelemetry_configurator import get_code_correlation_enabled_status
8+
9+
_logger = getLogger(__name__)
10+
11+
12+
def _apply_django_instrumentation_patches() -> None:
13+
"""Django instrumentation patches
14+
15+
Applies patches to provide code attributes support for Django instrumentation.
16+
This patches the Django instrumentation to automatically add code attributes
17+
to spans by modifying the process_view method of the Django middleware.
18+
Also patches Django's path/re_path functions for URL pattern instrumentation.
19+
"""
20+
if get_code_correlation_enabled_status() is True:
21+
_apply_django_code_attributes_patch()
22+
23+
24+
def _apply_django_code_attributes_patch() -> None: # pylint: disable=too-many-statements
25+
"""Django instrumentation patch for code attributes
26+
27+
This patch modifies the Django middleware's process_view method to automatically add
28+
code attributes to the current span when a view function is about to be executed.
29+
30+
The patch includes:
31+
1. Support for class-based views by extracting the actual HTTP method handler
32+
2. Automatic addition of code.function.name, code.file.path, and code.line.number
33+
3. Graceful error handling and cleanup during uninstrument
34+
"""
35+
try:
36+
# Import Django instrumentation classes and AWS code correlation function
37+
from amazon.opentelemetry.distro.code_correlation import ( # pylint: disable=import-outside-toplevel
38+
add_code_attributes_to_span,
39+
)
40+
from opentelemetry.instrumentation.django import DjangoInstrumentor # pylint: disable=import-outside-toplevel
41+
42+
# Store the original _instrument and _uninstrument methods
43+
original_instrument = DjangoInstrumentor._instrument
44+
original_uninstrument = DjangoInstrumentor._uninstrument
45+
46+
# Store reference to original Django middleware process_view method
47+
original_process_view = None
48+
49+
def _patch_django_middleware():
50+
"""Patch Django middleware's process_view method to add code attributes."""
51+
try:
52+
# Import Django middleware class
53+
# pylint: disable=import-outside-toplevel
54+
from opentelemetry.instrumentation.django.middleware.otel_middleware import _DjangoMiddleware
55+
56+
nonlocal original_process_view
57+
if original_process_view is None:
58+
original_process_view = _DjangoMiddleware.process_view
59+
60+
def patched_process_view(
61+
self, request, view_func, *args, **kwargs
62+
): # pylint: disable=too-many-locals,too-many-nested-blocks,too-many-branches
63+
"""Patched process_view method to add code attributes to the span."""
64+
# First call the original process_view method
65+
result = original_process_view(self, request, view_func, *args, **kwargs)
66+
67+
# Add code attributes if we have a span and view function
68+
try:
69+
if (
70+
self._environ_activation_key in request.META.keys()
71+
and self._environ_span_key in request.META.keys()
72+
):
73+
span = request.META[self._environ_span_key]
74+
if span and view_func and span.is_recording():
75+
# Determine the target function/method to analyze
76+
target = view_func
77+
78+
# If it's a class-based view, get the corresponding HTTP method handler
79+
view_class = getattr(view_func, "view_class", None)
80+
if view_class:
81+
method_name = request.method.lower()
82+
handler = getattr(view_class, method_name, None) or view_class
83+
target = handler
84+
85+
# Call the existing add_code_attributes_to_span function
86+
add_code_attributes_to_span(span, target)
87+
_logger.debug(
88+
"Added code attributes to span for Django view: %s",
89+
getattr(target, "__name__", str(target)),
90+
)
91+
except Exception as exc: # pylint: disable=broad-exception-caught
92+
# Don't let code attributes addition break the request processing
93+
_logger.warning("Failed to add code attributes to Django span: %s", exc)
94+
95+
return result
96+
97+
# Apply the patch
98+
_DjangoMiddleware.process_view = patched_process_view
99+
_logger.debug("Django middleware process_view patched successfully for code attributes")
100+
101+
except Exception as exc: # pylint: disable=broad-exception-caught
102+
_logger.warning("Failed to patch Django middleware process_view: %s", exc)
103+
104+
def _unpatch_django_middleware():
105+
"""Restore original Django middleware process_view method."""
106+
try:
107+
# pylint: disable=import-outside-toplevel
108+
from opentelemetry.instrumentation.django.middleware.otel_middleware import _DjangoMiddleware
109+
110+
if original_process_view is not None:
111+
_DjangoMiddleware.process_view = original_process_view
112+
_logger.debug("Django middleware process_view restored successfully")
113+
114+
except Exception as exc: # pylint: disable=broad-exception-caught
115+
_logger.warning("Failed to restore Django middleware process_view: %s", exc)
116+
117+
def patched_instrument(self, **kwargs):
118+
"""Patched _instrument method with Django middleware patching"""
119+
# Apply Django middleware patches
120+
_patch_django_middleware()
121+
122+
# Call the original _instrument method
123+
original_instrument(self, **kwargs)
124+
125+
def patched_uninstrument(self, **kwargs):
126+
"""Patched _uninstrument method with Django middleware patch restoration"""
127+
# Call the original _uninstrument method first
128+
original_uninstrument(self, **kwargs)
129+
130+
# Restore original Django middleware
131+
_unpatch_django_middleware()
132+
133+
# Apply the patches to DjangoInstrumentor
134+
DjangoInstrumentor._instrument = patched_instrument
135+
DjangoInstrumentor._uninstrument = patched_uninstrument
136+
137+
_logger.debug("Django instrumentation code attributes patch applied successfully")
138+
139+
except Exception as exc: # pylint: disable=broad-exception-caught
140+
_logger.warning("Failed to apply Django code attributes patch: %s", exc)

aws-opentelemetry-distro/src/amazon/opentelemetry/distro/patches/_instrumentation_patch.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,13 @@ def apply_instrumentation_patches() -> None:
8585

8686
_apply_fastapi_instrumentation_patches()
8787

88+
if is_installed("django"):
89+
# pylint: disable=import-outside-toplevel
90+
# Delay import to only occur if patches is safe to apply (e.g. the instrumented library is installed).
91+
from amazon.opentelemetry.distro.patches._django_patches import _apply_django_instrumentation_patches
92+
93+
_apply_django_instrumentation_patches()
94+
8895
# No need to check if library is installed as this patches opentelemetry.sdk,
8996
# which must be installed for the distro to work at all.
9097
_apply_resource_detector_patches()

aws-opentelemetry-distro/tests/amazon/opentelemetry/distro/code_correlation/__init__.py

Lines changed: 0 additions & 2 deletions
This file was deleted.

0 commit comments

Comments
 (0)