Skip to content

Commit 8101991

Browse files
committed
fix(traces): close the shutdown and failure gaps in span creation
Re-check closed under the lock so a close() that lands mid-start cannot reserve a live span after the registry was cleared. Release the reserved slot when building the span fails, so it does not wait for age eviction. Contain a raising logging handler inside the drop warning, and reset the warning throttle in a forked child with the rest of its state.
1 parent ceb7a29 commit 8101991

3 files changed

Lines changed: 82 additions & 1 deletion

File tree

posthog/test/tracing/test_pipeline.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import gc
2+
import logging
23
import threading
34
import time
45
import weakref
@@ -15,6 +16,7 @@
1516
make,
1617
queued,
1718
)
19+
from posthog.tracing import _pipeline as pipeline_module
1820
from posthog.tracing import _span as span_module
1921
from posthog.tracing._drops import DropLog
2022
from posthog.tracing._span import NOOP_SPAN, PassThroughSpan, RecordingSpan
@@ -392,8 +394,30 @@ def test_drops_a_span_whose_client_was_disabled_mid_trace_without_raising(self):
392394
assert queued(pipeline) == []
393395
assert pipeline._live_spans == {}
394396

397+
def test_a_close_that_lands_mid_start_makes_the_span_inert(self):
398+
pipeline, _, _ = make()
399+
resolve = pipeline._resolve_parent
400+
401+
def close_then_resolve(parent, tracestate):
402+
pipeline.close()
403+
return resolve(parent, tracestate)
404+
405+
with mock.patch.object(pipeline, "_resolve_parent", close_then_resolve):
406+
span = pipeline.start_span("late")
407+
assert span is NOOP_SPAN
408+
assert pipeline._live_spans == {}
409+
395410

396411
class TestLiveSpanBounds:
412+
def test_returns_the_slot_when_building_the_span_fails(self):
413+
pipeline, _, _ = make(max_live_spans=1)
414+
with mock.patch.object(
415+
pipeline_module, "copy_user_attributes", side_effect=RuntimeError("no")
416+
):
417+
assert pipeline.start_span("a") is NOOP_SPAN
418+
assert pipeline._live_spans == {}
419+
assert isinstance(pipeline.start_span("b"), RecordingSpan)
420+
397421
def test_returns_an_inert_handle_once_max_live_spans_are_live(self):
398422
pipeline, _, _ = make(max_live_spans=2)
399423
a, b = pipeline.start_span("a"), pipeline.start_span("b")
@@ -456,6 +480,32 @@ def test_names_reasons_in_the_order_they_happened(self, caplog):
456480
"Dropping 4 span(s): the queue is full; before_span_send dropped it"
457481
)
458482

483+
def test_a_raising_log_handler_does_not_escape(self):
484+
class Raising(logging.Handler):
485+
def emit(self, record):
486+
raise RuntimeError("handler broke")
487+
488+
handler = Raising()
489+
logging.getLogger("posthog").addHandler(handler)
490+
try:
491+
drops = DropLog(5)
492+
drops.record(1, "the queue is full")
493+
drops.warn_if_due(force=True)
494+
finally:
495+
logging.getLogger("posthog").removeHandler(handler)
496+
497+
def test_a_forked_child_does_not_wait_out_the_parents_warning_interval(
498+
self, caplog
499+
):
500+
caplog.set_level("WARNING", logger="posthog")
501+
drops = DropLog(5)
502+
drops.record(1, "the queue is full")
503+
drops.warn_if_due()
504+
drops.reinit_after_fork()
505+
drops.record(1, "the queue is full")
506+
drops.warn_if_due()
507+
assert len(caplog.records) == 2
508+
459509

460510
class TestCloseAndFork:
461511
def test_close_counts_spans_still_open_and_drops_them_when_they_end(self, caplog):

posthog/tracing/_drops.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,14 @@ def warn_if_due(self, force: bool = False) -> None:
4141
self._count = 0
4242
self._reasons.clear()
4343
self._last_warning_at = now
44-
log.warning(message)
44+
try:
45+
log.warning(message)
46+
except Exception:
47+
# A raising logging handler must not surface through span creation.
48+
pass
4549

4650
def reinit_after_fork(self) -> None:
4751
self._lock = threading.Lock()
4852
self._count = 0
4953
self._reasons.clear()
54+
self._last_warning_at = 0.0

posthog/tracing/_pipeline.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,9 @@ def _start_span(
146146
parent_context = self._resolve_parent(parent, tracestate)
147147

148148
with self._lock:
149+
if self._is_closed():
150+
# close() may have run since the check above.
151+
return inert_span(parent, tracestate, self._active_var)
149152
# Swept first, so a process that leaked its way to the bound
150153
# recovers once the leaks age out.
151154
aged = self._evict_aged_spans_locked()
@@ -167,6 +170,25 @@ def _start_span(
167170
)
168171
return inert_span(parent, tracestate, self._active_var)
169172

173+
try:
174+
return self._build_span(
175+
span_id, name, kind, attributes, parent_context, start_time
176+
)
177+
except Exception:
178+
# The reservation would otherwise hold its slot until age eviction.
179+
with self._lock:
180+
self._live_spans.pop(span_id, None)
181+
raise
182+
183+
def _build_span(
184+
self,
185+
span_id: str,
186+
name: str,
187+
kind: Optional[str],
188+
attributes: Optional[Mapping[str, Any]],
189+
parent_context: Optional[ParentContext],
190+
start_time: Any,
191+
) -> RecordingSpan:
170192
now_ns = time.time_ns()
171193
start_ns = resolve_start_ns(start_time, now_ns)
172194
auto_attributes = self._auto_context_attributes()
@@ -192,6 +214,10 @@ def _start_span(
192214
else None,
193215
)
194216

217+
def _is_closed(self) -> bool:
218+
# A method, so the re-check under the lock is not narrowed away.
219+
return self._closed
220+
195221
def _resolve_parent(self, parent: Any, tracestate: Any) -> Optional[ParentContext]:
196222
"""An explicit parent, else the active span, else a fresh root."""
197223
if isinstance(parent, str):

0 commit comments

Comments
 (0)