Skip to content

Commit 71b482d

Browse files
committed
fix(traces): flush the lanes before an inline span flush, and skip the span thread when nothing is queued
When no thread could start at exit, the span flush ran before the event lanes and could spend the whole exit budget. _start_span_flush now hands back a waiter the caller runs after the lanes, and starts nothing when the span queue is empty. The flush docstring describes the retry-within- budget contract. Tracing test fixtures move to a conftest, and the client tests reuse the shared ids and one slow sender.
1 parent 810c868 commit 71b482d

8 files changed

Lines changed: 129 additions & 90 deletions

File tree

‎posthog/client.py‎

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2639,10 +2639,13 @@ def flush(self, timeout_seconds: Optional[float] = 10) -> None:
26392639
Defaults to 10 seconds. Pass ``None`` to wait indefinitely.
26402640
Queued spans are sent at the same time, within the same
26412641
budget: at least one span request is attempted even when the
2642-
budget is already spent, no further one starts once it is, and
2643-
each request is bounded by ``timeout``. The wait for that first
2644-
request is not cut short, so a flush can take up to
2645-
``timeout_seconds`` plus ``timeout`` in the worst case.
2642+
budget is already spent, a retriable failure is retried after
2643+
its backoff while budget remains, no other request starts once
2644+
it is spent, and each request is bounded by ``timeout``. The
2645+
wait for the last request is not cut short, so a flush can
2646+
take up to ``timeout_seconds`` plus ``timeout`` in the worst
2647+
case. A span flush already in flight for the whole wait is
2648+
left to finish instead.
26462649
26472650
Examples:
26482651
```python
@@ -2664,19 +2667,25 @@ def flush(self, timeout_seconds: Optional[float] = 10) -> None:
26642667
for lane in self._lanes:
26652668
lane.flush(max(0.0, deadline - time.monotonic()))
26662669
if span_flush is not None:
2667-
# The first span request is exempt from the budget and bounded
2668-
# only by the request timeout, so the join is not.
2669-
span_flush.join()
2670+
# The last span request is bounded only by the request
2671+
# timeout, so the wait is not.
2672+
span_flush(None)
26702673
except Exception as e:
26712674
self.log.exception("error flushing queue: %s", e)
26722675
return
26732676

26742677
def _start_span_flush(
26752678
self, timeout_seconds: Optional[float]
2676-
) -> Optional[threading.Thread]:
2677-
"""Flush spans alongside the events, so a handler waits one round trip, not two."""
2679+
) -> Optional[Callable[[Optional[float]], None]]:
2680+
"""Flush spans alongside the events, so a handler waits one round trip, not two.
2681+
2682+
Returns a waiter taking the seconds to wait, or ``None`` when nothing
2683+
is queued. When no thread can start (interpreter shutdown), the waiter
2684+
runs the flush on the calling thread, after the caller has flushed the
2685+
event lanes.
2686+
"""
26782687
traces = self._traces
2679-
if traces is None:
2688+
if traces is None or not traces.has_queued_spans():
26802689
return None
26812690

26822691
def flush_spans() -> None:
@@ -2691,10 +2700,8 @@ def flush_spans() -> None:
26912700
try:
26922701
flusher.start()
26932702
except RuntimeError:
2694-
# No new threads at interpreter shutdown; flush on this one.
2695-
flush_spans()
2696-
return None
2697-
return flusher
2703+
return lambda _seconds: flush_spans()
2704+
return flusher.join
26982705

26992706
def _is_consumer_thread(self) -> bool:
27002707
current = threading.current_thread()
@@ -3009,10 +3016,10 @@ def _atexit_spans(self) -> None:
30093016
self._join_span_flush(span_flush, deadline)
30103017

30113018
def _join_span_flush(
3012-
self, flusher: Optional[threading.Thread], deadline: float
3019+
self, waiter: Optional[Callable[[Optional[float]], None]], deadline: float
30133020
) -> None:
3014-
if flusher is not None:
3015-
flusher.join(max(0.0, deadline - time.monotonic()))
3021+
if waiter is not None:
3022+
waiter(max(0.0, deadline - time.monotonic()))
30163023
if self._traces is not None:
30173024
# Not close(): an app's own shutdown() hook may still run and send them.
30183025
self._traces.warn_if_queued()

‎posthog/test/tracing/conftest.py‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"""Fixtures shared by the tracing tests."""
2+
3+
import threading
4+
import time
5+
from unittest import mock
6+
7+
import pytest
8+
9+
from posthog.test.tracing.helpers import FakeTimer
10+
from posthog.tracing import _export as export_module
11+
12+
13+
@pytest.fixture
14+
def fake_timers():
15+
"""Timers that fire only when a test says so."""
16+
FakeTimer.instances = []
17+
with mock.patch.object(threading, "Timer", FakeTimer):
18+
yield FakeTimer
19+
20+
21+
@pytest.fixture
22+
def no_jitter():
23+
# Backoff delays are asserted exactly; TestJitter covers the spread.
24+
with mock.patch.object(export_module, "_draw_jitter", return_value=1.0):
25+
yield
26+
27+
28+
@pytest.fixture
29+
def clock():
30+
state = {"now": 1000.0}
31+
with mock.patch.object(time, "monotonic", lambda: state["now"]):
32+
yield state

‎posthog/test/tracing/helpers.py‎

Lines changed: 3 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,9 @@
11
"""Shared fakes for the tracing pipeline and export tests."""
22

33
import threading
4-
import time
54
from contextvars import ContextVar
65
from types import SimpleNamespace
7-
from unittest import mock
86

9-
import pytest
10-
11-
from posthog.tracing import _export as export_module
127
from posthog.tracing._config import resolve_traces_config
138
from posthog.tracing._drops import DropLog
149
from posthog.tracing._export import SpanExporter
@@ -80,31 +75,13 @@ def close(self):
8075
def warn_if_queued(self):
8176
pass
8277

78+
def has_queued(self):
79+
return bool(self.records)
80+
8381
def reinit_after_fork(self):
8482
self.reinitialized = True
8583

8684

87-
@pytest.fixture(autouse=True)
88-
def fake_timers():
89-
FakeTimer.instances = []
90-
with mock.patch.object(threading, "Timer", FakeTimer):
91-
yield FakeTimer
92-
93-
94-
@pytest.fixture(autouse=True)
95-
def no_jitter():
96-
# Backoff delays are asserted exactly; TestJitter covers the spread.
97-
with mock.patch.object(export_module, "_draw_jitter", return_value=1.0):
98-
yield
99-
100-
101-
@pytest.fixture
102-
def clock():
103-
state = {"now": 1000.0}
104-
with mock.patch.object(time, "monotonic", lambda: state["now"]):
105-
yield state
106-
107-
10885
def make(client=None, context=None, **config):
10986
"""A pipeline whose ended spans collect on a ``RecordingExporter``."""
11087
client = client or SimpleNamespace(disabled=False, send=True)

‎posthog/test/tracing/test_client_traces.py‎

Lines changed: 59 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,12 @@
1111
from posthog import Posthog
1212
from posthog.client import Client
1313
from posthog.contexts import identify_context, new_context, set_context_session
14-
from posthog.test.tracing.helpers import FakeTimer
14+
from posthog.test.tracing.helpers import SPAN_ID, TRACE_ID
1515
from posthog.tracing._transport import OK
1616
from posthog.tracing._span import NOOP_SPAN, RecordingSpan, Span
1717
from posthog.version import VERSION
1818

1919
FAKE_API_KEY = "phc_test_key"
20-
TRACE_ID = "4bf92f3577b34da6a3ce929d0e0e4736"
21-
SPAN_ID = "00f067aa0ba902b7"
2220

2321

2422
def make_client(**kwargs):
@@ -33,11 +31,15 @@ def mock_session(status_code=200):
3331
return session
3432

3533

36-
@pytest.fixture
37-
def no_timers():
38-
# No background drain racing the test.
39-
with mock.patch.object(threading, "Timer", FakeTimer):
40-
yield
34+
def slow_send(requests, delay=0.2):
35+
"""A sender that records each payload and takes ``delay`` seconds to answer."""
36+
37+
def send(pipeline_client, payload):
38+
requests.append(payload)
39+
time.sleep(delay)
40+
return OK
41+
42+
return send
4143

4244

4345
@pytest.fixture(autouse=True)
@@ -135,6 +137,14 @@ def test_a_failed_init_is_not_retried_on_the_next_call(self):
135137
assert resolve.call_count == 1
136138
client.shutdown()
137139

140+
def test_a_non_callable_hook_turns_tracing_off(self, caplog):
141+
caplog.set_level("ERROR", logger="posthog")
142+
client = make_client(traces={"before_span_send": "scrub"})
143+
assert client.start_span("x") is NOOP_SPAN
144+
assert "Error initializing traces" in caplog.text
145+
assert "not callable" in caplog.text
146+
client.shutdown()
147+
138148
def test_never_starts_a_pipeline_on_a_client_without_traces(self):
139149
client = make_client()
140150
client.flush()
@@ -370,19 +380,13 @@ def test_flush_resolves_when_the_span_export_fails(self):
370380
client.shutdown()
371381

372382
def test_flush_stops_starting_span_requests_once_its_budget_is_spent(
373-
self, no_timers
383+
self, fake_timers
374384
):
375385
client = make_client(traces={"max_export_batch_size": 1})
376386
client.start_span("x").end()
377387
client.start_span("y").end()
378388
requests = []
379-
380-
def slow_send(pipeline_client, payload):
381-
requests.append(payload)
382-
time.sleep(0.2)
383-
return OK
384-
385-
client._traces._exporter._send = slow_send
389+
client._traces._exporter._send = slow_send(requests)
386390
client.flush(timeout_seconds=0.05)
387391
assert len(requests) == 1
388392
assert len(client._traces._exporter._queue) == 1
@@ -404,7 +408,7 @@ def test_flush_without_a_timeout_drains_every_queued_span(self):
404408
assert client._traces._exporter._queue == []
405409
client.shutdown()
406410

407-
def test_flush_sends_spans_while_events_are_still_draining(self, no_timers):
411+
def test_flush_sends_spans_while_events_are_still_draining(self, fake_timers):
408412
client = make_client(traces={}, sync_mode=False)
409413
client.start_span("x").end()
410414
span_sent = threading.Event()
@@ -421,7 +425,7 @@ def send(pipeline_client, payload):
421425
assert overlapped and all(overlapped)
422426
client.shutdown()
423427

424-
def test_flush_sends_spans_inline_when_no_thread_can_start(self, no_timers):
428+
def test_flush_sends_spans_inline_when_no_thread_can_start(self, fake_timers):
425429
client = make_client(traces={})
426430
client.start_span("x").end()
427431
with mock.patch("posthog.client.threading.Thread") as thread:
@@ -432,6 +436,32 @@ def test_flush_sends_spans_inline_when_no_thread_can_start(self, no_timers):
432436
assert len(spans_from(payload)) == 1
433437
client.shutdown()
434438

439+
def test_flush_starts_no_span_thread_when_nothing_is_queued(self, fake_timers):
440+
client = make_client(traces={})
441+
client.start_span("x").end()
442+
client.flush()
443+
with mock.patch("posthog.client.threading.Thread") as thread:
444+
client.flush()
445+
thread.assert_not_called()
446+
client.shutdown()
447+
448+
def test_exit_flushes_the_lanes_before_an_inline_span_flush(self, fake_timers):
449+
client = make_client(traces={}, sync_mode=False)
450+
client.start_span("x").end()
451+
order = []
452+
client._traces.flush = lambda timeout: order.append("spans")
453+
for lane in client._lanes:
454+
lane.flush = lambda timeout, _lane=lane: order.append("lanes")
455+
with (
456+
mock.patch("posthog.client.threading.Thread") as thread,
457+
mock.patch("posthog.client._atexit_deadline", None),
458+
):
459+
thread.return_value.start.side_effect = RuntimeError("no threads")
460+
client._atexit()
461+
assert order[0] == "lanes"
462+
assert order[-1] == "spans"
463+
client.shutdown()
464+
435465
def test_shutdown_flushes_pending_spans(self):
436466
client = make_client(traces={})
437467
client.start_span("x").end()
@@ -444,33 +474,27 @@ def test_shutdown_flushes_pending_spans(self):
444474
assert client._traces._exporter._queue == []
445475

446476
def test_shutdown_bounds_the_final_span_flush_and_warns_about_the_rest(
447-
self, no_timers, caplog
477+
self, fake_timers, caplog
448478
):
449479
caplog.set_level("WARNING", logger="posthog")
450480
client = make_client(traces={"max_export_batch_size": 1})
451481
for name in ("a", "b", "c"):
452482
client.start_span(name).end()
453483
requests = []
454-
455-
def slow_send(pipeline_client, payload):
456-
requests.append(payload)
457-
time.sleep(0.2)
458-
return OK
459-
460-
client._traces._exporter._send = slow_send
484+
client._traces._exporter._send = slow_send(requests)
461485
with mock.patch("posthog.client._TRACES_SHUTDOWN_FLUSH_SECONDS", 0.05):
462486
client.shutdown()
463487
assert len(requests) == 1
464488
assert any("Discarding 2 span(s)" in r.getMessage() for r in caplog.records)
465489

466-
def test_tracing_is_inert_after_shutdown(self, no_timers):
490+
def test_tracing_is_inert_after_shutdown(self, fake_timers):
467491
client = make_client(traces={})
468492
client.start_span("before").end()
469493
client.shutdown()
470494
assert client.start_span("late") is NOOP_SPAN
471495
assert client._traces._exporter._flush_timer is None
472496

473-
def test_shutdown_closes_a_pipeline_still_initializing(self, no_timers):
497+
def test_shutdown_closes_a_pipeline_still_initializing(self, fake_timers):
474498
client = make_client(traces={})
475499
shutdown = threading.Thread(target=client.shutdown)
476500
resolve = posthog.client.resolve_traces_config
@@ -488,13 +512,13 @@ def resolve_while_shutting_down(*args):
488512
assert client._traces._closed
489513
assert client._traces._exporter._flush_timer is None
490514

491-
def test_tracing_never_starts_after_shutdown(self, no_timers):
515+
def test_tracing_never_starts_after_shutdown(self, fake_timers):
492516
client = make_client(traces={})
493517
client.shutdown()
494518
assert client.start_span("late") is NOOP_SPAN
495519
assert client._traces is None
496520

497-
def test_exit_drains_spans_the_timer_would_have_sent(self, no_timers):
521+
def test_exit_drains_spans_the_timer_would_have_sent(self, fake_timers):
498522
client = make_client(traces={}, sync_mode=False)
499523
client.start_span("x").end()
500524
session = mock_session()
@@ -516,7 +540,7 @@ def test_exit_drains_spans_the_timer_would_have_sent(self, no_timers):
516540
assert session.post.called
517541

518542
def test_exit_flushes_spans_alongside_events_that_use_up_the_budget(
519-
self, no_timers
543+
self, fake_timers
520544
):
521545
client = make_client(traces={}, sync_mode=False)
522546
client.start_span("x").end()
@@ -535,7 +559,7 @@ def slow_lane_flush(timeout_seconds):
535559
assert session.post.called
536560
client.shutdown()
537561

538-
def test_exit_does_not_wait_on_a_hung_span_request(self, no_timers, caplog):
562+
def test_exit_does_not_wait_on_a_hung_span_request(self, fake_timers, caplog):
539563
caplog.set_level("WARNING", logger="posthog")
540564
client = make_client(traces={}, sync_mode=False)
541565
client.start_span("x").end()
@@ -570,7 +594,7 @@ def hung_post(*args, **kwargs):
570594
"sync_mode, hook", [(False, "_atexit"), (True, "_atexit_spans")]
571595
)
572596
def test_exit_warns_about_spans_it_could_not_send(
573-
self, no_timers, caplog, sync_mode, hook
597+
self, fake_timers, caplog, sync_mode, hook
574598
):
575599
caplog.set_level("WARNING", logger="posthog")
576600
client = make_client(traces={}, sync_mode=sync_mode)
@@ -591,18 +615,13 @@ def test_exit_warns_about_spans_it_could_not_send(
591615

592616
@pytest.mark.parametrize("sync_mode", [True, False])
593617
def test_an_app_exit_hook_registered_earlier_still_gets_to_flush_spans(
594-
self, no_timers, caplog, sync_mode
618+
self, fake_timers, caplog, sync_mode
595619
):
596620
caplog.set_level("WARNING", logger="posthog")
597621
hooks = []
598622
holder = {}
599623
requests = []
600624

601-
def slow_send(pipeline_client, payload):
602-
requests.append(payload)
603-
time.sleep(0.2)
604-
return OK
605-
606625
with (
607626
mock.patch("posthog.client.atexit.register", side_effect=hooks.append),
608627
mock.patch("posthog.client._ATEXIT_FLUSH_TIMEOUT_SECONDS", 0.1),
@@ -614,7 +633,7 @@ def slow_send(pipeline_client, payload):
614633
traces={"max_export_batch_size": 1}, sync_mode=sync_mode
615634
)
616635
client.start_span("a").end()
617-
client._traces._exporter._send = slow_send
636+
client._traces._exporter._send = slow_send(requests)
618637
client.start_span("b").end()
619638
client.start_span("c").end()
620639
assert len(hooks) == 2

0 commit comments

Comments
 (0)