Skip to content

Commit ac13894

Browse files
feat(tracer): add support for generators (#13377)
# PR Description **Fix support for wrapping generator functions with `tracer.wrap()` and ensure they generate traces in Datadog** Related issue: [#5403](#5403) --- ## Summary of the problem Currently, when a generator function is wrapped with `tracer.wrap()`, two major issues arise: 1. **Accessing the current span fails:** Inside the wrapped generator, `tracer.current_span()` returns `None`. As a result, any attempt to set tags or interact with the span raises: ``` AttributeError: 'NoneType' object has no attribute 'set_tag' ``` Example: ```python @tracer.wrap() def foobar(): current_span = tracer.current_span() current_span.set_tag("hello", "world") yield 1 ``` Calling `list(foobar())` → crashes with `AttributeError`. 2. **Traces are reported to Datadog with incorrect duration:** Even if the generator runs without explicit span interaction, traces emitted to Datadog do not correctly report the execution time of the function. This is because the `tracer.wrap()` decorator does not maintain the span context during generator iteration (`next()` or `async for`), so the span gets opened and closed at the same time. --- ## Root cause - The `wrap()` logic does not correctly handle Python generators (`def ... yield`) or async generators (`async def ... yield`). - Without this, both local span interactions (`current_span()`) and backend reporting (sending traces to Datadog) break. --- ## Proposed fix This PR updates the `tracer.wrap()` decorator to: - Add proper handling for **sync generators**: - Ensure `tracer.current_span()` is available. - Finalize the span after the generator is exhausted or on error. - Report traces to Datadog as expected. - Add dedicated support for **async generators**: - Use an `async for` wrapper. - Maintain the tracing context. With this change: - Spans inside generators work (`current_span()` is valid). - Traces from generator functions are correctly sent to Datadog. --- ## How to reproduce + verify the fix Minimal reproducible example: ```python @tracer.wrap() def foobar(): current_span = tracer.current_span() current_span.set_tag("hello", "world") yield 1 assert list(foobar()) == [1] ``` **Expected result:** - No errors. - Span is created and tagged. - Trace is visible in Datadog with the tag `hello: world`. **Added test cases:** - Sync generator with span tags. - Async generator with span tags. - Backward compatibility for sync/async functions. --- ## Checklist - [x] PR author has checked that all the criteria below are met - The PR description includes an overview of the change - The PR description articulates the motivation for the change - The change includes tests OR the PR description describes a testing strategy - The PR description notes risks associated with the change, if any - Newly-added code is easy to change - The change follows the [library release note guidelines](https://ddtrace.readthedocs.io/en/stable/releasenotes.html) - The change includes or references documentation updates if necessary - Backport labels are set (if [applicable](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting)) ## Reviewer Checklist - [x] Reviewer has checked that all the criteria below are met - Title is accurate - All changes are related to the pull request's stated goal - Avoids breaking [API](https://ddtrace.readthedocs.io/en/stable/versioning.html#interfaces) changes - Testing strategy adequately addresses listed risks - Newly-added code is easy to change - Release note makes sense to a user of the library - If necessary, author has acknowledged and discussed the performance implications of this PR as reported in the benchmarks PR comment - Backport labels are set in a manner that is consistent with the [release branch maintenance policy](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting) --------- Co-authored-by: Brett Langdon <[email protected]> Co-authored-by: Brett Langdon <[email protected]>
1 parent 4607d9e commit ac13894

File tree

4 files changed

+141
-3
lines changed

4 files changed

+141
-3
lines changed

ddtrace/_trace/tracer.py

Lines changed: 79 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from contextlib import contextmanager
22
import functools
3+
import inspect
34
from inspect import iscoroutinefunction
45
from itertools import chain
56
import logging
@@ -775,6 +776,56 @@ def flush(self):
775776
"""Flush the buffer of the trace writer. This does nothing if an unbuffered trace writer is used."""
776777
self._span_aggregator.writer.flush_queue()
777778

779+
def _wrap_generator(
780+
self,
781+
f: AnyCallable,
782+
span_name: str,
783+
service: Optional[str] = None,
784+
resource: Optional[str] = None,
785+
span_type: Optional[str] = None,
786+
) -> AnyCallable:
787+
"""Wrap a generator function with tracing."""
788+
789+
@functools.wraps(f)
790+
def func_wrapper(*args, **kwargs):
791+
if getattr(self, "_wrap_executor", None):
792+
return self._wrap_executor(
793+
self,
794+
f,
795+
args,
796+
kwargs,
797+
span_name,
798+
service=service,
799+
resource=resource,
800+
span_type=span_type,
801+
)
802+
803+
with self.trace(span_name, service=service, resource=resource, span_type=span_type):
804+
gen = f(*args, **kwargs)
805+
for value in gen:
806+
yield value
807+
808+
return func_wrapper
809+
810+
def _wrap_generator_async(
811+
self,
812+
f: AnyCallable,
813+
span_name: str,
814+
service: Optional[str] = None,
815+
resource: Optional[str] = None,
816+
span_type: Optional[str] = None,
817+
) -> AnyCallable:
818+
"""Wrap a generator function with tracing."""
819+
820+
@functools.wraps(f)
821+
async def func_wrapper(*args, **kwargs):
822+
with self.trace(span_name, service=service, resource=resource, span_type=span_type):
823+
agen = f(*args, **kwargs)
824+
async for value in agen:
825+
yield value
826+
827+
return func_wrapper
828+
778829
def wrap(
779830
self,
780831
name: Optional[str] = None,
@@ -812,6 +863,15 @@ async def coroutine():
812863
def coroutine():
813864
return 'executed'
814865
866+
>>> # or use it on generators
867+
@tracer.wrap()
868+
def gen():
869+
yield 'executed'
870+
871+
>>> @tracer.wrap()
872+
async def gen():
873+
yield 'executed'
874+
815875
You can access the current span using `tracer.current_span()` to set
816876
tags:
817877
@@ -825,10 +885,26 @@ def wrap_decorator(f: AnyCallable) -> AnyCallable:
825885
# FIXME[matt] include the class name for methods.
826886
span_name = name if name else "%s.%s" % (f.__module__, f.__name__)
827887

828-
# detect if the the given function is a coroutine to use the
829-
# right decorator; this initial check ensures that the
888+
# detect if the the given function is a coroutine and/or a generator
889+
# to use the right decorator; this initial check ensures that the
830890
# evaluation is done only once for each @tracer.wrap
831-
if iscoroutinefunction(f):
891+
if inspect.isgeneratorfunction(f):
892+
func_wrapper = self._wrap_generator(
893+
f,
894+
span_name,
895+
service=service,
896+
resource=resource,
897+
span_type=span_type,
898+
)
899+
elif inspect.isasyncgenfunction(f):
900+
func_wrapper = self._wrap_generator_async(
901+
f,
902+
span_name,
903+
service=service,
904+
resource=resource,
905+
span_type=span_type,
906+
)
907+
elif iscoroutinefunction(f):
832908
# create an async wrapper that awaits the coroutine and traces it
833909
@functools.wraps(f)
834910
async def func_wrapper(*args, **kwargs):
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
fixes:
3+
- |
4+
tracing: Fixes support for wrapping generator and async generator functions with `tracer.wrap()`. Previously, calling `tracer.current_span()` inside a wrapped generator function would return `None`, leading to `AttributeError` when interacting with the span. Additionally, traces reported to Datadog showed incorrect durations, as span context was not maintained across generator iteration. This change ensures that `tracer.wrap()` now correctly handles both sync and async generators by preserving the tracing context throughout their execution and finalizing spans correctly. Users can now safely use `tracer.current_span()` within generator functions and expect accurate trace reporting.

tests/contrib/asyncio/test_tracer.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
"""Ensure that the tracer works with asynchronous executions within the same ``IOLoop``."""
2+
23
import asyncio
34
import os
45
import re
@@ -223,3 +224,31 @@ async def my_function():
223224
rb"created at .*/dd-trace-py/ddtrace/contrib/internal/asyncio/patch.py:.* took .* seconds"
224225
match = re.match(pattern, err)
225226
assert match, err
227+
228+
229+
@pytest.mark.asyncio
230+
async def test_wrapped_generator(tracer):
231+
@tracer.wrap("decorated_generator", service="s", resource="r", span_type="t")
232+
async def f(tag_name, tag_value):
233+
# make sure we can still set tags
234+
span = tracer.current_span()
235+
span.set_tag(tag_name, tag_value)
236+
237+
for i in range(3):
238+
yield i
239+
240+
result = [item async for item in f("a", "b")]
241+
assert result == [0, 1, 2]
242+
243+
traces = tracer.pop_traces()
244+
245+
assert 1 == len(traces)
246+
spans = traces[0]
247+
assert 1 == len(spans)
248+
span = spans[0]
249+
250+
assert span.name == "decorated_generator"
251+
assert span.service == "s"
252+
assert span.resource == "r"
253+
assert span.span_type == "t"
254+
assert span.get_tag("a") == "b"

tests/tracer/test_tracer.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import logging
99
from os import getpid
1010
import threading
11+
import time
1112
from unittest.case import SkipTest
1213

1314
import mock
@@ -284,6 +285,34 @@ def wrapped_function(param, kw_param=None):
284285
(dict(name="wrap.overwrite", service="webserver", meta=dict(args="(42,)", kwargs="{'kw_param': 42}")),),
285286
)
286287

288+
def test_tracer_wrap_generator(self):
289+
@self.tracer.wrap("decorated_generator", service="s", resource="r", span_type="t")
290+
def f(tag_name, tag_value):
291+
# make sure we can still set tags
292+
span = self.tracer.current_span()
293+
span.set_tag(tag_name, tag_value)
294+
295+
for i in range(3):
296+
time.sleep(0.01)
297+
yield i
298+
299+
result = list(f("a", "b"))
300+
assert result == [0, 1, 2]
301+
302+
self.assert_span_count(1)
303+
span = self.get_root_span()
304+
span.assert_matches(
305+
name="decorated_generator",
306+
service="s",
307+
resource="r",
308+
span_type="t",
309+
meta=dict(a="b"),
310+
)
311+
312+
# tracer should finish _after_ the generator has been exhausted
313+
assert span.duration is not None
314+
assert span.duration > 0.03
315+
287316
def test_tracer_disabled(self):
288317
self.tracer.enabled = True
289318
with self.trace("foo") as s:

0 commit comments

Comments
 (0)