Skip to content

Commit e85c964

Browse files
authored
Support code attributes for client libraries (#491)
*Description of changes:* A generic solution adding code information in span for any client library instrumentation. 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 7745ce0 commit e85c964

File tree

16 files changed

+9615
-446
lines changed

16 files changed

+9615
-446
lines changed

.codespellrc

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
[codespell]
22
# skipping auto generated folders
3-
skip = ./.tox,./.mypy_cache,./target,*/LICENSE,./venv,*/sql_dialect_keywords.json
4-
ignore-words-list = afterall,assertIn, crate
3+
skip = ./.tox,./.mypy_cache,./target,*/LICENSE,./venv,*/sql_dialect_keywords.json,*/3rd.txt
4+
ignore-words-list = afterall,assertIn, crate

aws-opentelemetry-distro/src/amazon/opentelemetry/distro/aws_opentelemetry_configurator.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -473,6 +473,13 @@ def _customize_logs_exporter(log_exporter: LogExporter) -> LogExporter:
473473

474474

475475
def _customize_span_processors(provider: TracerProvider, resource: Resource) -> None:
476+
477+
if get_code_correlation_enabled_status() is True:
478+
# pylint: disable=import-outside-toplevel
479+
from amazon.opentelemetry.distro.code_correlation import CodeAttributesSpanProcessor
480+
481+
provider.add_span_processor(CodeAttributesSpanProcessor())
482+
476483
# Add LambdaSpanProcessor to list of processors regardless of application signals.
477484
if _is_lambda_environment():
478485
provider.add_span_processor(AwsLambdaSpanProcessor())

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

Lines changed: 26 additions & 118 deletions
Original file line numberDiff line numberDiff line change
@@ -6,123 +6,31 @@
66
77
This module provides functionality for correlating code execution with telemetry data.
88
"""
9-
10-
import inspect
11-
from functools import wraps
12-
from typing import Any, Callable
13-
14-
from opentelemetry import trace
15-
9+
# Import code attributes span processor
10+
from .code_attributes_span_processor import CodeAttributesSpanProcessor
11+
12+
# Import main utilities to maintain API compatibility
13+
from .utils import (
14+
add_code_attributes_to_span,
15+
add_code_attributes_to_span_from_frame,
16+
get_callable_fullname,
17+
get_function_fullname_from_frame,
18+
record_code_attributes,
19+
)
20+
21+
# Version information
1622
__version__ = "1.0.0"
1723

18-
19-
# Code correlation attribute constants
20-
CODE_FUNCTION_NAME = "code.function.name"
21-
CODE_FILE_PATH = "code.file.path"
22-
CODE_LINE_NUMBER = "code.line.number"
23-
24-
25-
def add_code_attributes_to_span(span, func_or_class: Callable[..., Any]) -> None:
26-
"""
27-
Add code-related attributes to a span based on a Python function.
28-
29-
This utility method extracts function metadata and adds the following
30-
span attributes:
31-
- CODE_FUNCTION_NAME: The name of the function
32-
- CODE_FILE_PATH: The file path where the function is defined
33-
- CODE_LINE_NUMBER: The line number where the function is defined
34-
35-
Args:
36-
span: The OpenTelemetry span to add attributes to
37-
func: The Python function to extract metadata from
38-
"""
39-
if not span.is_recording():
40-
return
41-
42-
try:
43-
# Check if it's a class first, with proper exception handling
44-
try:
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)
59-
except Exception: # pylint: disable=broad-exception-caught
60-
pass
61-
62-
63-
def record_code_attributes(func: Callable[..., Any]) -> Callable[..., Any]:
64-
"""
65-
Decorator to automatically add code attributes to the current OpenTelemetry span.
66-
67-
This decorator extracts metadata from the decorated function and adds it as
68-
attributes to the current active span. The attributes added are:
69-
- code.function.name: The name of the function
70-
- code.file.path: The file path where the function is defined
71-
- code.line.number: The line number where the function is defined
72-
73-
This decorator supports both synchronous and asynchronous functions.
74-
75-
Usage:
76-
@record_code_attributes
77-
def my_sync_function():
78-
# Sync function implementation
79-
pass
80-
81-
@record_code_attributes
82-
async def my_async_function():
83-
# Async function implementation
84-
pass
85-
86-
Args:
87-
func: The function to be decorated
88-
89-
Returns:
90-
The wrapped function with current span code attributes tracing
91-
"""
92-
# Detect async functions
93-
is_async = inspect.iscoroutinefunction(func)
94-
95-
if is_async:
96-
# Async function wrapper
97-
@wraps(func)
98-
async def async_wrapper(*args, **kwargs):
99-
# Add code attributes to current span
100-
try:
101-
current_span = trace.get_current_span()
102-
if current_span:
103-
add_code_attributes_to_span(current_span, func)
104-
except Exception: # pylint: disable=broad-exception-caught
105-
# Silently handle any unexpected errors
106-
pass
107-
108-
# Call and await the original async function
109-
return await func(*args, **kwargs)
110-
111-
return async_wrapper
112-
113-
# Sync function wrapper
114-
@wraps(func)
115-
def sync_wrapper(*args, **kwargs):
116-
# Add code attributes to current span
117-
try:
118-
current_span = trace.get_current_span()
119-
if current_span:
120-
add_code_attributes_to_span(current_span, func)
121-
except Exception: # pylint: disable=broad-exception-caught
122-
# Silently handle any unexpected errors
123-
pass
124-
125-
# Call the original sync function
126-
return func(*args, **kwargs)
127-
128-
return sync_wrapper
24+
# Define public API
25+
__all__ = [
26+
# Functions
27+
"add_code_attributes_to_span",
28+
"add_code_attributes_to_span_from_frame",
29+
"get_callable_fullname",
30+
"get_function_fullname_from_frame",
31+
"record_code_attributes",
32+
# Classes
33+
"CodeAttributesSpanProcessor",
34+
# Version
35+
"__version__",
36+
]
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""
5+
Code Attributes Span Processor implementation for OpenTelemetry Python.
6+
7+
This processor captures stack traces and attaches them to spans as attributes.
8+
It's based on the OpenTelemetry Java contrib StackTraceSpanProcessor.
9+
"""
10+
11+
import sys
12+
import typing as t
13+
from types import FrameType
14+
from typing import Optional
15+
16+
from opentelemetry.context import Context
17+
from opentelemetry.sdk.trace import ReadableSpan, Span, SpanProcessor
18+
from opentelemetry.semconv.attributes.code_attributes import CODE_FUNCTION_NAME
19+
from opentelemetry.trace import SpanKind
20+
21+
from .internal.packages_resolver import _build_package_mapping, _load_third_party_packages, is_user_code
22+
from .utils import add_code_attributes_to_span_from_frame
23+
24+
25+
class CodeAttributesSpanProcessor(SpanProcessor):
26+
"""
27+
A SpanProcessor that captures and attaches code attributes to spans.
28+
29+
This processor adds stack trace information as span attributes, which can be
30+
useful for debugging and understanding the call flow that led to span creation.
31+
"""
32+
33+
# Maximum number of stack frames to examine
34+
MAX_STACK_FRAMES = 50
35+
36+
@staticmethod
37+
def _iter_stack_frames(frame: FrameType) -> t.Iterator[FrameType]:
38+
"""Iterate through stack frames starting from the given frame."""
39+
_frame: t.Optional[FrameType] = frame
40+
while _frame is not None:
41+
yield _frame
42+
_frame = _frame.f_back
43+
44+
def __init__(self):
45+
"""Initialize the CodeAttributesSpanProcessor."""
46+
# Pre-initialize expensive operations to avoid runtime performance overhead
47+
# These @execute_once methods are slow, so we call them during initialization
48+
# to cache their results ahead of time
49+
_build_package_mapping()
50+
_load_third_party_packages()
51+
52+
def on_start(
53+
self,
54+
span: Span,
55+
parent_context: Optional[Context] = None,
56+
) -> None:
57+
"""
58+
Called when a span is started. Captures and attaches code attributes from stack trace.
59+
60+
Args:
61+
span: The span that was started
62+
parent_context: The parent context (unused)
63+
"""
64+
# Skip if span should not be processed
65+
if not self._should_process_span(span):
66+
return
67+
68+
# Capture code attributes from stack trace
69+
self._capture_code_attributes(span)
70+
71+
@staticmethod
72+
def _should_process_span(span: Span) -> bool:
73+
"""
74+
Determine if span should be processed for code attributes.
75+
76+
Returns False if:
77+
- Span already has code attributes
78+
- Span is SERVER or INTERNAL span
79+
"""
80+
# Skip if span already has code attributes
81+
if span.attributes is not None and CODE_FUNCTION_NAME in span.attributes:
82+
return False
83+
84+
# Process spans except SERVER and INTERNAL spans
85+
return span.kind not in (SpanKind.SERVER, SpanKind.INTERNAL)
86+
87+
def _capture_code_attributes(self, span: Span) -> None:
88+
"""Capture and attach code attributes from current stack trace."""
89+
try:
90+
current_frame = sys._getframe(1)
91+
92+
for frame_index, frame in enumerate(self._iter_stack_frames(current_frame)):
93+
if frame_index >= self.MAX_STACK_FRAMES:
94+
break
95+
96+
code = frame.f_code
97+
98+
if is_user_code(code.co_filename):
99+
add_code_attributes_to_span_from_frame(frame, span)
100+
break # Only capture the first user code frame
101+
102+
except (OSError, ValueError):
103+
# sys._getframe may not be available on all platforms
104+
pass
105+
106+
def on_end(self, span: ReadableSpan) -> None:
107+
"""
108+
Called when a span is ended. Captures and attaches stack trace if conditions are met.
109+
"""
110+
111+
def shutdown(self) -> None:
112+
"""Called when the processor is shutdown. No cleanup needed."""
113+
# No cleanup needed for code attributes processor
114+
115+
def force_flush(self, timeout_millis: int = 30000) -> bool: # pylint: disable=no-self-use,unused-argument
116+
"""Force flush any pending spans. Always returns True as no pending work."""
117+
return True

0 commit comments

Comments
 (0)