Skip to content

Commit 695a961

Browse files
committed
Implement tracing decorator and add it to example service
1 parent 55b9ce4 commit 695a961

File tree

4 files changed

+221
-5
lines changed

4 files changed

+221
-5
lines changed

.idea/inspectionProfiles/Project_Default.xml

Lines changed: 6 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/common/tracing.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import asyncio
2+
from functools import wraps
3+
4+
from opentelemetry import trace
5+
6+
# Get the _tracer instance (You can set your own _tracer name)
7+
tracer = trace.get_tracer(__name__)
8+
9+
10+
def trace_function(trace_attributes: bool = True, trace_result: bool = True):
11+
"""
12+
Decorator to trace callables using OpenTelemetry spans.
13+
14+
Parameters:
15+
- trace_attributes (bool): If False, disables adding function arguments to the span.
16+
- trace_result (bool): If False, disables adding the function's result to the span.
17+
"""
18+
19+
def decorator(func):
20+
@wraps(func)
21+
def sync_or_async_wrapper(*args, **kwargs):
22+
with tracer.start_as_current_span(func.__name__) as span:
23+
try:
24+
# Set function arguments as attributes
25+
if trace_attributes:
26+
span.set_attribute("function.args", str(args))
27+
span.set_attribute("function.kwargs", str(kwargs))
28+
29+
async def async_handler():
30+
result = await func(*args, **kwargs)
31+
# Add result to span
32+
if trace_result:
33+
span.set_attribute("function.result", str(result))
34+
return result
35+
36+
def sync_handler():
37+
result = func(*args, **kwargs)
38+
# Add result to span
39+
if trace_result:
40+
span.set_attribute("function.result", str(result))
41+
return result
42+
43+
if asyncio.iscoroutinefunction(func):
44+
return async_handler()
45+
else:
46+
return sync_handler()
47+
48+
except Exception as e:
49+
# Record the exception in the span
50+
span.record_exception(e)
51+
span.set_status(trace.status.Status(trace.status.StatusCode.ERROR))
52+
raise
53+
54+
return sync_or_async_wrapper
55+
56+
return decorator

src/domains/books/_service.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,16 @@
44
from dependency_injector.wiring import Provide, inject
55
from structlog import get_logger
66

7+
from common.tracing import trace_function
8+
from common.utils import apply_decorator_to_methods
79
from ._gateway_interfaces import BookEventGatewayInterface, BookRepositoryInterface
810
from ._models import BookModel
911
from ._tasks import book_cpu_intensive_task
1012
from .dto import Book, BookData
1113
from .events import BookCreatedV1, BookCreatedV1Data
1214

1315

16+
@apply_decorator_to_methods(trace_function())
1417
class BookService:
1518
_book_repository: BookRepositoryInterface
1619
_event_gateway: BookEventGatewayInterface

tests/common/test_tracing.py

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import asyncio
2+
from unittest.mock import MagicMock, call, patch
3+
4+
import pytest
5+
6+
from common.tracing import trace_function
7+
8+
9+
@pytest.fixture
10+
def mock_tracer():
11+
"""
12+
Fixture to mock the OpenTelemetry tracer and span.
13+
"""
14+
mock_tracer = MagicMock()
15+
mock_span = MagicMock()
16+
mock_tracer.start_as_current_span.return_value.__enter__.return_value = mock_span
17+
18+
with (
19+
patch("opentelemetry.trace.get_tracer", return_value=mock_tracer),
20+
patch("common.tracing.tracer", mock_tracer),
21+
):
22+
yield mock_tracer, mock_span
23+
24+
25+
def test_sync_function_default_params(mock_tracer):
26+
"""
27+
Test a synchronous function with default decorator parameters.
28+
"""
29+
mock_tracer, mock_span = mock_tracer
30+
31+
# Define a sync function to wrap with the decorator
32+
@trace_function()
33+
def add_nums(a, b):
34+
return a + b
35+
36+
# Call the function
37+
result = add_nums(2, 3)
38+
39+
# Assertions
40+
assert result == 5
41+
mock_tracer.start_as_current_span.assert_called_once_with("add_nums")
42+
mock_span.set_attribute.assert_any_call("function.args", "(2, 3)")
43+
mock_span.set_attribute.assert_any_call("function.result", "5")
44+
45+
46+
@pytest.mark.asyncio
47+
async def test_async_function_default_params(mock_tracer):
48+
"""
49+
Test an asynchronous function with default decorator parameters.
50+
"""
51+
mock_tracer, mock_span = mock_tracer
52+
53+
# Define an async function to wrap with the decorator
54+
@trace_function()
55+
async def async_func(a, b):
56+
await asyncio.sleep(0.1)
57+
return a * b
58+
59+
# Run the async function
60+
result = await async_func(4, 5)
61+
62+
# Assertions
63+
assert result == 20
64+
mock_tracer.start_as_current_span.assert_called_once_with("async_func")
65+
mock_span.set_attribute.assert_any_call("function.args", "(4, 5)")
66+
mock_span.set_attribute.assert_any_call("function.result", "20")
67+
68+
69+
def test_disable_function_attributes(mock_tracer):
70+
"""
71+
Test a synchronous function with `add_function_attributes` set to False.
72+
"""
73+
mock_tracer, mock_span = mock_tracer
74+
75+
# Define a sync function with attributes disabled
76+
@trace_function(trace_attributes=False)
77+
def sync_func(a, b):
78+
return a - b
79+
80+
# Call the function
81+
result = sync_func(10, 6)
82+
83+
# Assertions
84+
assert result == 4
85+
mock_tracer.start_as_current_span.assert_called_once_with("sync_func")
86+
mock_span.set_attribute.assert_any_call("function.result", "4")
87+
assert (
88+
call("function.args", "(10, 6)") not in mock_span.set_attribute.call_args_list
89+
)
90+
91+
92+
def test_disable_result_in_span_sync(mock_tracer):
93+
"""
94+
Test an asynchronous function with `add_result_to_span` set to False.
95+
"""
96+
mock_tracer, mock_span = mock_tracer
97+
98+
# Define an async function with result disabled
99+
@trace_function(trace_result=False)
100+
def sync_func(a, b):
101+
return a / b
102+
103+
# Run the async function
104+
result = sync_func(10, 2)
105+
106+
# Assertions
107+
assert result == 5.0
108+
mock_tracer.start_as_current_span.assert_called_once_with("sync_func")
109+
mock_span.set_attribute.assert_any_call("function.args", "(10, 2)")
110+
assert call("function.result") not in mock_span.set_attribute.call_args_list
111+
112+
113+
@pytest.mark.asyncio
114+
async def test_disable_result_in_span(mock_tracer):
115+
"""
116+
Test an asynchronous function with `add_result_to_span` set to False.
117+
"""
118+
mock_tracer, mock_span = mock_tracer
119+
120+
# Define an async function with result disabled
121+
@trace_function(trace_result=False)
122+
async def async_func(a, b):
123+
await asyncio.sleep(0.1)
124+
return a / b
125+
126+
# Run the async function
127+
result = await async_func(10, 2)
128+
129+
# Assertions
130+
assert result == 5.0
131+
mock_tracer.start_as_current_span.assert_called_once_with("async_func")
132+
mock_span.set_attribute.assert_any_call("function.args", "(10, 2)")
133+
assert call("function.result") not in mock_span.set_attribute.call_args_list
134+
135+
136+
def test_exception_in_function(mock_tracer):
137+
"""
138+
Test behavior when the function raises an exception.
139+
"""
140+
mock_tracer, mock_span = mock_tracer
141+
142+
# Define a failing function
143+
@trace_function()
144+
def failing_func(a, b):
145+
if b == 0:
146+
raise ValueError("Division by zero!")
147+
return a / b
148+
149+
# Use pytest to assert the exception is raised
150+
with pytest.raises(ValueError, match="Division by zero!"):
151+
failing_func(10, 0)
152+
153+
# Assertions
154+
mock_tracer.start_as_current_span.assert_called_once_with("failing_func")
155+
mock_span.record_exception.assert_called_once()
156+
mock_span.set_status.assert_called_once()

0 commit comments

Comments
 (0)