Skip to content

tracer.wrap does not work on generators correctly #5403

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
MichaelAquilina opened this issue Mar 24, 2023 · 7 comments
Open

tracer.wrap does not work on generators correctly #5403

MichaelAquilina opened this issue Mar 24, 2023 · 7 comments
Labels
stale Tracing Distributed Tracing

Comments

@MichaelAquilina
Copy link

MichaelAquilina commented Mar 24, 2023

Summary of problem

Generator functions wrapped by tracer.wrap() do not seem to work correctly.

In particular, trying to get the current span inside the wrapped function does not seem to work

Which version of dd-trace-py are you using?

1.6.1 (also tried on latest 1.10.1)

Which version of pip are you using?

poetry

Which libraries and their versions are you using?

`pip freeze`

How can we reproduce your problem?

Here is a minimal code example:

from ddtrace import tracer

@tracer.wrap()
def foobar():
     current_span = tracer.current_span()
     current_span.set_tag("hello", "world")
     yield 1

What is the result that you get?

Calling list(foobar()) results in the following error:

AttributeError: 'NoneType' object has no attribute 'set_tag'

What is the result that you expected?

current_span is found and able to have tags set.

@P403n1x87 P403n1x87 added the Tracing Distributed Tracing label Mar 25, 2023
@P403n1x87
Copy link
Contributor

I suspect this is because tracer.wrap is not unwinding the generator, just wrapping the call to foobar that returns the generator object.

@MichaelAquilina
Copy link
Author

@P403n1x87 I think that is correct. The main issue here is that its hard for tracer.wrap to work generically for both functions and generators because as soon as you use a yield statement within wrap_decorator, it will start returning generators instead.

I think the best solution is to create a separate wrapped specifically for generators. I am writing what that would look like now but thought it would be good to get early feedback in the meanwhile?

@majorgreys
Copy link
Contributor

majorgreys commented Mar 28, 2023

@MichaelAquilina Thank you for starting this discussion and starting a PR!

It's been a while since I looked closely at Tracer.wrap. If it's possible, we should keep that interface and have the function also handle generators.

Internally, we use wrapt for wrapping functions and objects. I wonder if we should be extending it's decorator.

For example, here's one approach that doesn't hit the issue you are finding:

import inspect

from ddtrace import tracer
import wrapt


@wrapt.decorator
def wrapper(wrapped, instance, args, kwargs):
  with tracer.trace(wrapped.__name__):
    if not inspect.isgeneratorfunction(wrapped):
      return wrapped(*args, **kwargs)
      
    yield wrapped(*args, **kwargs)


@wrapper
def foobar():
  current_span = tracer.current_span()
  current_span.set_tag("hello", "world")
  yield 1
  
  
if __name__ == "__main__":
  assert inspect.isgeneratorfunction(foobar)
  print("main")
  list(foobar())

Would this resolve your issue?

@majorgreys majorgreys self-assigned this Mar 28, 2023
@MichaelAquilina
Copy link
Author

@majorgreys that is actually similar to the approach I started off with, but the problem is that once you include a yield inside a function, python turns it into a generator.

Unless wrapt somehow deals with this (Which I couldnt find in their docs), then the returned value will be incorrect for standard functions.

I used this code to test it out:

import inspect

from ddtrace import tracer
import wrapt


@wrapt.decorator
def wrapper(wrapped, instance, args, kwargs):
  with tracer.trace(wrapped.__name__):
    if not inspect.isgeneratorfunction(wrapped):
      return wrapped(*args, **kwargs)

    yield wrapped(*args, **kwargs)


@wrapper
def foobar():
    current_span = tracer.current_span()
    current_span.set_tag("hello", "world")
    return [1]

@wrapper
def bazbang():
    current_span = tracer.current_span()
    current_span.set_tag("hello", "world")
    yield 1


def main():
    print(foobar())
    print(list(bazbang()))


if __name__ == "__main__":
    main()

The output is:

<generator object wrapper at 0x7fe3f54ce1f0>
[<generator object bazbang at 0x7fe3f54ce260>]

In this case, the code doesnt even seem to return the expected type for either so I guess there is a bug in the implementation, bt notice how both cases are generators.

I implemented something similar in tracer.wrap() with inspect previously :

This is the diff:

diff --git a/ddtrace/tracer.py b/ddtrace/tracer.py
index 4122aae3..038cfee3 100644
--- a/ddtrace/tracer.py
+++ b/ddtrace/tracer.py
@@ -1,3 +1,4 @@
+import inspect
 import functools
 import json
 import logging
@@ -980,6 +981,11 @@ class Tracer(object):
                             span_type=span_type,
                         )
 
+                    if inspect.isgeneratorfunction(f):
+                        # otherwise fallback to a default tracing
+                        with self.trace(span_name, service=service, resource=resource, span_type=span_type):
+                            yield from f(*args, **kwargs)
+
                     # otherwise fallback to a default tracing
                     with self.trace(span_name, service=service, resource=resource, span_type=span_type):
                         return f(*args, **kwargs)

and this is what was returned

<generator object foobar at 0x7f4937ee9070>
[1]

@emmettbutler
Copy link
Collaborator

Manually doing what the Github Action was supposed to do (fixed in #7835):

This issue has been automatically closed after six months of inactivity. If it's a feature request, it has been added to the maintainers' internal backlog and will be included in an upcoming round of feature prioritization. Please comment or reopen if you think this issue was closed in error.

@emmettbutler emmettbutler closed this as not planned Won't fix, can't repro, duplicate, stale Dec 4, 2023
@MatejBalantic
Copy link

MatejBalantic commented Jan 22, 2025

We see a similar issue. Anything that happens inside generator functions fails to get traced. Specifically the SQL queries done with the psycopg do not show up when executed from within the generator functions. This is a big issue, since almost all of the SQL queries get executed from within the generator functions in our case.

Is there no workaround for this issue?

This is our SQL store code:

    @contextmanager
    def with_transaction(self, transaction: Transaction | None = None) -> Generator[Transaction, None, None]:
        with tracer.trace("SQLStore.with_transaction"):
            try:
                if transaction is not None:
                    yield transaction

                else:
                    with self.db_connection.get_connection() as conn, conn.transaction() as tx:
                        with tracer.trace("SQLStore.with_transaction"):
                            yield tx

I can see the manual traces (SQLStore.with_transaction and SQLStore.with_transaction), but nothing happening inside there is traced, not even the SQL queries from the patched psycopg

@mabdinur mabdinur reopened this Feb 4, 2025
@github-actions github-actions bot removed the stale label Feb 5, 2025
@tommyhe6
Copy link

tommyhe6 commented Mar 8, 2025

Wanted to follow up on this! We use generators often in the context of streaming LLM token results back to users, and I think that is a very common use case.

@github-actions github-actions bot added the stale label May 8, 2025
brettlangdon added a commit that referenced this issue Jun 3, 2025
# 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]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
stale Tracing Distributed Tracing
Projects
None yet
Development

No branches or pull requests

7 participants