Skip to content

Commit 2eb751c

Browse files
fix(traces): keep the scheduled flush when a replacement timer fails to start, and count an unencodable span once
The replacement timer now starts before the old one is cancelled, so a thread that cannot be created leaves the earlier flush in place. Records that fail to encode leave the queue before the send is settled, so a failed send no longer counts them a second time.
1 parent f4fe475 commit 2eb751c

2 files changed

Lines changed: 49 additions & 19 deletions

File tree

posthog/test/tracing/test_export.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -180,10 +180,12 @@ def start(self):
180180
pipeline, sender, _ = make_traces(max_export_batch_size=3)
181181
for _ in range(3):
182182
pipeline.start_span("a").end()
183-
assert pipeline._exporter._flush_timer is None
183+
interval_timer = pipeline._exporter._flush_timer
184+
assert interval_timer.delay == 5 and not interval_timer.cancelled
184185
pipeline.start_span("b").end()
185186
timer = pipeline._exporter._flush_timer
186-
assert timer is not None and timer.started and timer.delay == 0
187+
assert timer is not interval_timer and timer.started and timer.delay == 0
188+
assert interval_timer.cancelled
187189
timer.fire()
188190
assert queued(pipeline) == []
189191
assert [len(b) for b in sender.batches()] == [3, 1]
@@ -358,6 +360,26 @@ def test_never_returns_a_span_it_failed_to_encode(self):
358360
assert sender.payloads == []
359361
assert queued(pipeline) == []
360362

363+
def test_counts_a_span_it_failed_to_encode_once(self, caplog):
364+
caplog.set_level("WARNING", logger="posthog")
365+
sender = FakeSender(SendOutcome("fatal"))
366+
pipeline, _, _ = make_traces(sender=sender, max_export_batch_size=2)
367+
pipeline.start_span("bad").end()
368+
pipeline.start_span("fine").end()
369+
encode = export_module.build_otlp_span
370+
371+
def encode_unless_bad(record):
372+
if record.name == "bad":
373+
raise RuntimeError("bad")
374+
return encode(record)
375+
376+
with mock.patch.object(export_module, "build_otlp_span", encode_unless_bad):
377+
pipeline.flush()
378+
assert [[s["name"] for s in b] for b in sender.batches()] == [["fine"]]
379+
assert queued(pipeline) == []
380+
(record,) = [r for r in caplog.records if "Dropping" in r.getMessage()]
381+
assert "Dropping 2 span(s)" in record.getMessage()
382+
361383

362384
class TestDroppedBatchesEndTheFailureSequence:
363385
@pytest.mark.parametrize(

posthog/tracing/_export.py

Lines changed: 25 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -255,16 +255,22 @@ def _drain(self, deadline: Optional[float]) -> Tuple[int, bool]:
255255
and not self._retry_after.is_open()
256256
)
257257

258-
spans = self._encode(batch)
259-
if not spans:
258+
spans, failed = self._encode(batch)
259+
if failed:
260+
# Out of the queue before the send is settled, so a failed
261+
# send does not count them a second time.
260262
with self._lock:
261263
if self._is_closed():
262264
return removed, True
263-
del self._queue[:size]
264-
self._reset_head_batch_budget_locked()
265-
remaining -= size
266-
removed += size
267-
continue
265+
for index in reversed(failed):
266+
del self._queue[index]
267+
if not spans:
268+
self._reset_head_batch_budget_locked()
269+
size -= len(failed)
270+
remaining -= len(failed)
271+
removed += len(failed)
272+
if not spans:
273+
continue
268274

269275
# The first request is exempt, so a flush called with no budget left
270276
# (a serverless handler, say) still ships a batch.
@@ -361,15 +367,18 @@ def _is_closed(self) -> bool:
361367
# A method, so the check after each send is not narrowed away.
362368
return self._closed
363369

364-
def _encode(self, batch: List[SpanRecord]) -> List[dict]:
370+
def _encode(self, batch: List[SpanRecord]) -> Tuple[List[dict], List[int]]:
371+
"""The batch as OTLP spans, and the indexes of records that could not be encoded."""
365372
encoded: List[dict] = []
366-
for record in batch:
373+
failed: List[int] = []
374+
for index, record in enumerate(batch):
367375
try:
368376
encoded.append(build_otlp_span(record))
369377
except Exception:
370378
log.debug("Failed to encode a span; dropping it", exc_info=True)
371379
self._drops.record(1, "its attributes could not be encoded")
372-
return encoded
380+
failed.append(index)
381+
return encoded, failed
373382

374383
def _discard_if_disabled_locked(self) -> bool:
375384
if not getattr(self._client, "disabled", False):
@@ -431,16 +440,15 @@ def _rearm_after_pass_locked(self) -> None:
431440
self._arm_timer_locked(delay, replace=True)
432441

433442
def _arm_timer_locked(self, delay: float, replace: bool = False) -> None:
434-
if self._flush_timer is not None:
435-
if not replace:
436-
return
437-
self._flush_timer.cancel()
438-
self._flush_timer = None
443+
if self._flush_timer is not None and not replace:
444+
return
439445
timer = threading.Timer(delay, lambda: self._timer_flush(timer))
440446
timer.daemon = True
441-
# Registered only once started, so a failed start leaves nothing
442-
# behind and the next span end arms again.
447+
# Started before the old timer is cancelled, so a failed start keeps
448+
# whatever flush was already scheduled.
443449
timer.start()
450+
if self._flush_timer is not None:
451+
self._flush_timer.cancel()
444452
self._flush_timer = timer
445453
self._flush_timer_fires_at = time.monotonic() + delay
446454

0 commit comments

Comments
 (0)