Skip to content

Commit fe12714

Browse files
authored
Add docs page warning about generators (#385)
1 parent 850a13b commit fe12714

File tree

4 files changed

+213
-0
lines changed

4 files changed

+213
-0
lines changed

docs/guides/advanced/generators.md

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
# Generators
2+
3+
The body of a `with logfire.span` statement or a function decorated with `@logfire.instrument` should not contain the `yield` keyword, except in functions decorated with `@contextlib.contextmanager` or `@contextlib.asynccontextmanager`. To see the problem, consider this example:
4+
5+
```python
6+
import logfire
7+
8+
logfire.configure()
9+
10+
11+
def generate_items():
12+
with logfire.span('Generating items'):
13+
for i in range(3):
14+
yield i
15+
16+
17+
# Or equivalently:
18+
@logfire.instrument('Generating items')
19+
def generate_items():
20+
for i in range(3):
21+
yield i
22+
23+
24+
def main():
25+
items = generate_items()
26+
for item in items:
27+
logfire.info(f'Got item {item}')
28+
# break
29+
logfire.info('After processing items')
30+
31+
32+
main()
33+
```
34+
35+
If you run this, everything seems fine:
36+
37+
![Generating items going fine](../../images/guide/generator-fine.png)
38+
39+
The `Got item` log lines are inside the `Generating items` span, and the `After processing items` log is outside it, as expected.
40+
41+
But if you uncomment the `break` line, you'll see that the `After processing items` log line is also inside the `Generating items` span:
42+
43+
![Generating items going wrong](../../images/guide/generator-break.png)
44+
45+
This is because the `generate_items` generator is left suspended at the `yield` statement, and the `with logfire.span('Generating items'):` block is still active, so the `After processing items` log sees that span as its parent. This is confusing, and can happen anytime that iteration over a generator is interrupted, including by exceptions.
46+
47+
If you run the same code with async generators:
48+
49+
```python
50+
import asyncio
51+
52+
import logfire
53+
54+
logfire.configure()
55+
56+
57+
async def generate_items():
58+
with logfire.span('Generating items'):
59+
for i in range(3):
60+
yield i
61+
62+
63+
async def main():
64+
items = generate_items()
65+
async for item in items:
66+
logfire.info(f'Got item {item}')
67+
break
68+
logfire.info('After processing items')
69+
70+
71+
asyncio.run(main())
72+
```
73+
74+
You'll see the same problem, as well as an exception like this in the logs:
75+
76+
```
77+
Failed to detach context
78+
Traceback (most recent call last):
79+
File "async_generator_example.py", line 11, in generate_items
80+
yield i
81+
asyncio.exceptions.CancelledError
82+
83+
During handling of the above exception, another exception occurred:
84+
85+
Traceback (most recent call last):
86+
File "opentelemetry/context/__init__.py", line 154, in detach
87+
_RUNTIME_CONTEXT.detach(token)
88+
File "opentelemetry/context/contextvars_context.py", line 50, in detach
89+
self._current_context.reset(token)
90+
ValueError: <Token var=<ContextVar name='current_context' default={} at 0x10afa3f60> at 0x10de034c0> was created in a different Context
91+
```
92+
93+
## What you can do
94+
95+
### Move the span outside the generator
96+
97+
If you're looping over a generator, wrapping the loop in a span is safe, e.g:
98+
99+
```python
100+
import logfire
101+
102+
logfire.configure()
103+
104+
105+
def generate_items():
106+
for i in range(3):
107+
yield i
108+
109+
110+
def main():
111+
items = generate_items()
112+
with logfire.span('Generating items'):
113+
for item in items:
114+
logfire.info(f'Got item {item}')
115+
break
116+
logfire.info('After processing items')
117+
118+
119+
main()
120+
```
121+
122+
This is fine because the `with logfire.span` block doesn't contain the `yield` directly in its body.
123+
124+
### Use a generator as a context manager
125+
126+
`yield` is OK when used to implement a context manager, e.g:
127+
128+
```python
129+
from contextlib import contextmanager
130+
131+
import logfire
132+
133+
logfire.configure()
134+
135+
136+
@contextmanager
137+
def my_context():
138+
with logfire.span('Context manager span'):
139+
yield
140+
141+
142+
try:
143+
with my_context():
144+
logfire.info('Inside context manager')
145+
raise ValueError()
146+
except Exception:
147+
logfire.exception('Error!')
148+
logfire.info('After context manager')
149+
```
150+
151+
This is fine because even if there's an exception inside the context manager, the `with` statement will ensure that the `my_context` generator is promptly closed, and the span will be closed with it. This is in contrast to using a generator as an iterator, where the loop can be interrupted more easily.
152+
153+
### Create a context manager that closes the generator
154+
155+
`with closing(generator)` can be used to ensure that the generator and thus the span within is closed even if the loop is interrupted, e.g:
156+
157+
```python
158+
from contextlib import closing
159+
160+
import logfire
161+
162+
logfire.configure()
163+
164+
165+
def generate_items():
166+
with logfire.span('Generating items'):
167+
for i in range(3):
168+
yield i
169+
170+
171+
def main():
172+
with closing(generate_items()) as items:
173+
for item in items:
174+
logfire.info(f'Got item {item}')
175+
break
176+
logfire.info('After processing items')
177+
178+
179+
main()
180+
```
181+
182+
However this means that users of `generate_items` must always remember to use `with closing`. To ensure that they have no choice but to do so, you can make `generate_items` a context manager itself:
183+
184+
```python
185+
from contextlib import closing, contextmanager
186+
187+
import logfire
188+
189+
logfire.configure()
190+
191+
192+
@contextmanager
193+
def generate_items():
194+
def generator():
195+
with logfire.span('Generating items'):
196+
for i in range(3):
197+
yield i
198+
199+
with closing(generator()) as items:
200+
yield items
201+
202+
203+
def main():
204+
with generate_items() as items:
205+
for item in items:
206+
logfire.info(f'Got item {item}')
207+
break
208+
logfire.info('After processing items')
209+
210+
211+
main()
212+
```
20.1 KB
Loading
27.3 KB
Loading

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ nav:
8888
- Alternative Backends: guides/advanced/alternative_backends.md
8989
- Sampling: guides/advanced/sampling.md
9090
- Scrubbing: guides/advanced/scrubbing.md
91+
- Generators: guides/advanced/generators.md
9192
- Testing: guides/advanced/testing.md
9293
- Backfill: guides/advanced/backfill.md
9394
- Creating Write Tokens: guides/advanced/creating_write_tokens.md

0 commit comments

Comments
 (0)