Skip to content

Commit c37bdb0

Browse files
committed
fix(traces): detach before ending and record only Exception on scoped exit
A generator closed early or a cancelled task raised GeneratorExit or CancelledError through __exit__ and shipped as an error span. The scoped form now records an Exception only, still ending on every exit. The span is detached before end() so nothing started from the on_end path nests under a span that is over. end() flips its flag under the handle's lock, and a deactivate that finds no token for the context says so at debug.
1 parent 4c1b20e commit c37bdb0

3 files changed

Lines changed: 70 additions & 14 deletions

File tree

posthog/test/tracing/test_span.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import asyncio
2+
import contextvars
23
import gc
34
import sys
45
import threading
@@ -360,6 +361,32 @@ def test_records_a_raised_exception_and_reraises_it_unchanged(self):
360361
"exception.message": "boom",
361362
}
362363

364+
@pytest.mark.parametrize(
365+
"error", [GeneratorExit(), asyncio.CancelledError(), KeyboardInterrupt()]
366+
)
367+
def test_ends_without_recording_a_base_exception_that_is_control_flow(self, error):
368+
records: list = []
369+
with pytest.raises(type(error)):
370+
with make_span(records):
371+
raise error
372+
assert len(records) == 1
373+
assert records[0].status is None
374+
assert records[0].events == []
375+
376+
def test_a_closed_generator_holding_a_span_is_not_an_error(self):
377+
records: list = []
378+
379+
def stream():
380+
with make_span(records):
381+
yield 1
382+
yield 2
383+
384+
consumer = stream()
385+
next(consumer)
386+
consumer.close()
387+
assert len(records) == 1
388+
assert records[0].status is None
389+
363390
def test_treats_an_explicit_ok_status_as_final_when_the_block_raises(self):
364391
records: list = []
365392
with pytest.raises(ValueError):
@@ -377,6 +404,25 @@ def test_deactivates_even_when_the_block_raises(self):
377404
raise RuntimeError("x")
378405
assert active.get() is None
379406

407+
def test_is_no_longer_active_when_on_end_runs(self):
408+
active: ContextVar = ContextVar("active", default=None)
409+
seen: list = []
410+
span = make_span(active_var=active, on_end=lambda _: seen.append(active.get()))
411+
with span:
412+
pass
413+
assert seen == [None]
414+
415+
def test_exiting_in_a_context_that_never_entered_leaves_it_active(self, caplog):
416+
active: ContextVar = ContextVar("active", default=None)
417+
span = make_span(active_var=active)
418+
span.__enter__()
419+
with caplog.at_level("DEBUG", logger="posthog"):
420+
contextvars.copy_context().run(span.__exit__, None, None, None)
421+
assert active.get() is span
422+
assert "never entered it" in caplog.text
423+
span.__exit__(None, None, None)
424+
assert active.get() is None
425+
380426
def test_ending_inside_the_block_does_not_double_record(self):
381427
records: list = []
382428
with make_span(records) as span:

posthog/tracing/_span.py

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,10 @@ def _deactivate(self) -> None:
8686
continue
8787
del self._tokens[index]
8888
return
89+
log.debug(
90+
"Span exited in a context that never entered it; it stays active "
91+
"where it was entered"
92+
)
8993

9094

9195
class PassThroughSpan(_Activatable, NoopSpan):
@@ -299,10 +303,11 @@ def _child_context(self) -> ParentContext:
299303
)
300304

301305
def end(self, end_time: Optional[SpanTimeInput] = None) -> None:
302-
if self._ended:
303-
log.debug("Ignoring end() on a span that has already ended")
304-
return
305-
self._ended = True
306+
with self._tokens_lock:
307+
if self._ended:
308+
log.debug("Ignoring end() on a span that has already ended")
309+
return
310+
self._ended = True
306311

307312
derived = self._now_ns()
308313
resolved = resolve_supplied_ns(end_time, derived, "end time")
@@ -331,9 +336,11 @@ def __enter__(self) -> "Span":
331336
return self
332337

333338
def __exit__(self, exc_type, exc, tb) -> None:
334-
try:
335-
if exc is not None and not self._ended:
336-
self._record_exception(exc, keep_ok=True)
337-
self.end()
338-
finally:
339-
self._deactivate()
339+
# Detached before it ends, so a span started from the on_end path
340+
# does not become a child of one that is already over.
341+
self._deactivate()
342+
# GeneratorExit, CancelledError and the like are control flow, not
343+
# failures of the span's work.
344+
if isinstance(exc, Exception) and not self._ended:
345+
self._record_exception(exc, keep_ok=True)
346+
self.end()

posthog/tracing/span.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@ class Span:
1212
Every method is safe to call on any handle, including after ``end()`` and
1313
on the inert handles returned when tracing is off, so calling code never
1414
branches on whether tracing is running. Entering a handle (``with span:``)
15-
makes it the active span for the block and ends it on exit.
15+
makes it the active span for the block and ends it on exit, recording an
16+
``Exception`` raised inside it. A ``BaseException`` that is not an
17+
``Exception`` (``GeneratorExit``, ``CancelledError``, ``KeyboardInterrupt``)
18+
still ends the span but is not recorded as a failure.
1619
"""
1720

1821
def set_attribute(self, key: str, value: Any) -> "Span":
@@ -65,7 +68,7 @@ def set_status(self, code: str, message: Optional[str] = None) -> "Span":
6568
"""Set the span status to ``"ok"`` or ``"error"``. Ignored after ``end()``.
6669
6770
Unset by default. Any other ``code`` is ignored. ``ok`` is final for the
68-
scoped form: an exception raised inside ``with span:`` does not override
71+
scoped form: an ``Exception`` raised inside ``with span:`` does not override
6972
it. Returns the span, so calls chain.
7073
7174
Examples:
@@ -80,8 +83,8 @@ def record_exception(self, exception: BaseException) -> "Span":
8083
8184
Ignored after ``end()``. The event carries ``exception.type`` and
8285
``exception.message``. Returns the span, so calls chain. Inside
83-
``with span:`` a raised exception is recorded automatically, so this
84-
is for exceptions that are caught and handled.
86+
``with span:`` a raised ``Exception`` is recorded automatically, so
87+
this is for exceptions that are caught and handled.
8588
8689
Examples:
8790
```python

0 commit comments

Comments
 (0)