log-foundry 0.7.1.dev3__tar.gz → 0.7.2.dev2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/PKG-INFO +25 -7
  2. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/README.md +24 -6
  3. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/pyproject.toml +1 -1
  4. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/__init__.py +10 -6
  5. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/worker.py +138 -23
  6. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/LICENSE +0 -0
  7. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/api.py +0 -0
  8. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/config.py +0 -0
  9. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/console.py +0 -0
  10. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/context.py +0 -0
  11. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/decorator.py +0 -0
  12. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/ids.py +0 -0
  13. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/model.py +0 -0
  14. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/py.typed +0 -0
  15. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sanitize.py +0 -0
  16. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/__init__.py +0 -0
  17. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/_batch.py +0 -0
  18. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/_chunk.py +0 -0
  19. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/_socket.py +0 -0
  20. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/_time.py +0 -0
  21. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/base.py +0 -0
  22. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/callback.py +0 -0
  23. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/clickhouse.py +0 -0
  24. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/datadog.py +0 -0
  25. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/elasticsearch.py +0 -0
  26. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/eventhubs.py +0 -0
  27. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/file.py +0 -0
  28. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/filtering.py +0 -0
  29. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/firehose.py +0 -0
  30. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/honeycomb.py +0 -0
  31. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/http.py +0 -0
  32. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/kafka.py +0 -0
  33. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/kinesis.py +0 -0
  34. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/logging_sink.py +0 -0
  35. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/logstash.py +0 -0
  36. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/loki.py +0 -0
  37. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/mongodb.py +0 -0
  38. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/multi.py +0 -0
  39. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/nats.py +0 -0
  40. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/newrelic.py +0 -0
  41. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/postgres.py +0 -0
  42. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/pubsub.py +0 -0
  43. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/rabbitmq.py +0 -0
  44. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/redis.py +0 -0
  45. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/sentry.py +0 -0
  46. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/sns.py +0 -0
  47. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/splunk.py +0 -0
  48. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/sqlite.py +0 -0
  49. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/sqs.py +0 -0
  50. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/stdout.py +0 -0
  51. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/syslog.py +0 -0
  52. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/transform.py +0 -0
  53. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/util.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: log-foundry
3
- Version: 0.7.1.dev3
3
+ Version: 0.7.2.dev2
4
4
  Summary: Generate logs for your console and JSON events for downstream consumption.
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -681,9 +681,19 @@ lf.shutdown() # drain, close the sink, and stop for good; blocks until drained
681
681
  explicitly when you need to be certain the tail reached the sink before a fast exit, e.g. at the
682
682
  end of a short script. It is idempotent.
683
683
 
684
- `flush(timeout=5.0)` returns `True` when every event submitted before the call has been passed
685
- to the sink, and `False` if that did not happen within `timeout` (or the worker was already shut
686
- down). It never raises a logging call must not be the reason your function fails. Passing
684
+ `flush(timeout=5.0)` returns `True` when **nothing was lost while the call was outstanding** — the
685
+ drain it forces reached the sink, and so did anything else the worker emitted while it waited its
686
+ turn. It returns `False` on timeout, when the worker was already shut down or has died, and when
687
+ any batch was abandoned inside that window. A `True` is evidence of delivery, not merely that a
688
+ drain took place.
689
+
690
+ The window starts when you call it. A batch abandoned *before* that is deliberately not its
691
+ business: the loss is already counted in `health().failed_batches` and reported on stderr, and
692
+ folding it in would make every later `flush()` in the process report a failure it did not incur.
693
+ So `flush()` answers "did the logs I am waiting on get out", and `health()` answers "has anything
694
+ been lost at all" — **check both**, as the handler below does.
695
+
696
+ It never raises — a logging call must not be the reason your function fails. Passing
687
697
  `timeout=None` waits indefinitely, which is unsafe anywhere with an execution deadline.
688
698
 
689
699
  #### Serverless / short-lived processes
@@ -715,9 +725,17 @@ def handler(event, context):
715
725
  try:
716
726
  return do_work(event)
717
727
  finally:
718
- lf.flush() # in `finally`: the failed invocation is the one worth logging.
719
- # NEVER shutdown() here — the worker does not come back, and every
720
- # later invocation on this warm container would log nothing.
728
+ # In `finally`: the failed invocation is the one worth logging. NEVER shutdown() here —
729
+ # the worker does not come back, and every later invocation on this warm container
730
+ # would log nothing.
731
+ drained = lf.flush()
732
+ h = lf.health()
733
+ if not drained or h.failed_batches or h.dropped or h.stopped_reason:
734
+ # `drained` covers this invocation's tail; the counters cover anything the worker
735
+ # lost earlier — a batch its own interval trigger already gave up on, for instance.
736
+ # Emitting this through your platform's own logger keeps it outside the pipeline
737
+ # that just failed.
738
+ print(f"log-foundry: undelivered logs ({drained=}, {h=})")
721
739
  ```
722
740
 
723
741
  By default each invocation is its own trace, so N invocations produce N `trace_id`s. To join
@@ -645,9 +645,19 @@ lf.shutdown() # drain, close the sink, and stop for good; blocks until drained
645
645
  explicitly when you need to be certain the tail reached the sink before a fast exit, e.g. at the
646
646
  end of a short script. It is idempotent.
647
647
 
648
- `flush(timeout=5.0)` returns `True` when every event submitted before the call has been passed
649
- to the sink, and `False` if that did not happen within `timeout` (or the worker was already shut
650
- down). It never raises a logging call must not be the reason your function fails. Passing
648
+ `flush(timeout=5.0)` returns `True` when **nothing was lost while the call was outstanding** — the
649
+ drain it forces reached the sink, and so did anything else the worker emitted while it waited its
650
+ turn. It returns `False` on timeout, when the worker was already shut down or has died, and when
651
+ any batch was abandoned inside that window. A `True` is evidence of delivery, not merely that a
652
+ drain took place.
653
+
654
+ The window starts when you call it. A batch abandoned *before* that is deliberately not its
655
+ business: the loss is already counted in `health().failed_batches` and reported on stderr, and
656
+ folding it in would make every later `flush()` in the process report a failure it did not incur.
657
+ So `flush()` answers "did the logs I am waiting on get out", and `health()` answers "has anything
658
+ been lost at all" — **check both**, as the handler below does.
659
+
660
+ It never raises — a logging call must not be the reason your function fails. Passing
651
661
  `timeout=None` waits indefinitely, which is unsafe anywhere with an execution deadline.
652
662
 
653
663
  #### Serverless / short-lived processes
@@ -679,9 +689,17 @@ def handler(event, context):
679
689
  try:
680
690
  return do_work(event)
681
691
  finally:
682
- lf.flush() # in `finally`: the failed invocation is the one worth logging.
683
- # NEVER shutdown() here — the worker does not come back, and every
684
- # later invocation on this warm container would log nothing.
692
+ # In `finally`: the failed invocation is the one worth logging. NEVER shutdown() here —
693
+ # the worker does not come back, and every later invocation on this warm container
694
+ # would log nothing.
695
+ drained = lf.flush()
696
+ h = lf.health()
697
+ if not drained or h.failed_batches or h.dropped or h.stopped_reason:
698
+ # `drained` covers this invocation's tail; the counters cover anything the worker
699
+ # lost earlier — a batch its own interval trigger already gave up on, for instance.
700
+ # Emitting this through your platform's own logger keeps it outside the pipeline
701
+ # that just failed.
702
+ print(f"log-foundry: undelivered logs ({drained=}, {h=})")
685
703
  ```
686
704
 
687
705
  By default each invocation is its own trace, so N invocations produce N `trace_id`s. To join
@@ -20,7 +20,7 @@ dependencies = [
20
20
  ]
21
21
 
22
22
  # Optional features. Install with: pip install log-foundry[aws]
23
- version = "0.7.1.dev3"
23
+ version = "0.7.2.dev2"
24
24
 
25
25
  [project.optional-dependencies]
26
26
  aws = ["boto3>=1.34"] # SQSSink, SNSSink, KinesisSink, FirehoseSink
@@ -30,10 +30,11 @@ except PackageNotFoundError: # running from a source tree that isn't installed
30
30
  def flush(timeout: float | None = 5.0) -> bool:
31
31
  """Drain buffered events through the sink without closing it.
32
32
 
33
- Every event submitted before this call has been passed to ``sink.emit`` when it returns
34
- ``True``. (Events submitted concurrently by another thread may or may not be included —
35
- the caller cannot have meant those.) Unlike :func:`shutdown` the background worker stays
36
- alive and the sink stays open, so logging continues normally afterwards.
33
+ ``True`` means the events submitted before this call reached the sink; ``False`` means the
34
+ drain carrying them was abandoned, or never completed. (Events submitted concurrently by
35
+ another thread may or may not be included — the caller cannot have meant those.) Unlike
36
+ :func:`shutdown` the background worker stays alive and the sink stays open, so logging
37
+ continues normally afterwards.
37
38
 
38
39
  This is the drain for a process that is *frozen* rather than exited — an AWS Lambda
39
40
  handler must drain before it returns, but will be invoked again on the same warm
@@ -42,8 +43,11 @@ def flush(timeout: float | None = 5.0) -> bool:
42
43
  ``timeout=None`` waits indefinitely, which is unsafe in any environment with an execution
43
44
  deadline — it converts "some logs were lost" into "the invocation timed out".
44
45
 
45
- Returns ``False`` if the drain did not complete within ``timeout``, or if the worker has
46
- already been shut down. Never raises.
46
+ Returns ``False`` if the drain did not complete within ``timeout``, if the worker has
47
+ already been shut down or has died, or if a batch was abandoned while this call was
48
+ outstanding (SPEC-021 FR-001) — a ``True`` means the events were delivered, not merely that
49
+ a drain took place. A batch lost *before* the call is outside this call's window and belongs
50
+ to :func:`health`, whose ``failed_batches`` is the cumulative record. Never raises.
47
51
  """
48
52
  from log_foundry.decorator import _flush_worker
49
53
 
@@ -74,12 +74,41 @@ class _FlushMarker:
74
74
 
75
75
  Like ``_SHUTDOWN`` it is never emitted — but unlike ``_SHUTDOWN`` it carries state, so it is
76
76
  a class rather than a bare sentinel object.
77
+
78
+ ``delivered`` carries the drain's *outcome* back to the waiter, not merely the fact that the
79
+ marker was reached (SPEC-021 FR-001). It is written by the drain thread before ``event.set()``
80
+ and read by the waiter after ``event.wait()`` returns, so the ``Event`` supplies the ordering
81
+ and no further lock is needed.
82
+
83
+ The outcome is computed from ``seen_failures`` — ``Worker.failed_batches`` as it stood when
84
+ this marker was created, on the *caller's* thread — against the same counter when the marker is
85
+ answered. Equal means nothing was abandoned while this flush was outstanding, which covers both
86
+ the batch the marker forces and any batch another flush or a batching trigger emitted in the
87
+ meantime. So every flush *outstanding* when a batch is abandoned reports it, including the ones
88
+ whose own emit found nothing left to do because another marker's emit had just cleared and lost
89
+ it — that case was the original false success.
90
+
91
+ Two limits, both deliberate. A batch abandoned *before* the call is not in scope: that loss is
92
+ already in ``failed_batches`` and on stderr, and folding it in would make every later empty
93
+ flush report a failure it did not incur. It follows that two *concurrent* flushes need not
94
+ agree — one stamped before the abandonment and one after are the outstanding case and the
95
+ already-lost case respectively, and the race between them decides which is which. Reporting a
96
+ past loss and not letting a past loss stick are the same property read in opposite directions;
97
+ FR-001's "an empty drain is a successful one" chooses. ``health().failed_batches`` is what
98
+ reports a loss regardless of when anyone asked.
99
+
100
+ ``delivered`` starts ``False``: every path that answers a marker assigns it explicitly, so the
101
+ default is read only when the drain thread died without computing an answer, where "I could not
102
+ establish that this was delivered" is the honest reading. (The spec's Data Model sketched the
103
+ default as ``True``, before the concurrent-flush case moved the empty-drain answer off it.)
77
104
  """
78
105
 
79
- __slots__ = ("event",)
106
+ __slots__ = ("delivered", "event", "seen_failures")
80
107
 
81
- def __init__(self) -> None:
108
+ def __init__(self, seen_failures: int) -> None:
82
109
  self.event = threading.Event()
110
+ self.delivered = False
111
+ self.seen_failures = seen_failures
83
112
 
84
113
 
85
114
  class Worker:
@@ -162,10 +191,24 @@ class Worker:
162
191
  def flush(self, timeout: float | None = 5.0) -> bool:
163
192
  """Drain everything submitted before this call through the sink, without stopping.
164
193
 
165
- Returns ``True`` once the worker has emitted them, ``False`` on timeout or when the
166
- worker has already been shut down. Unlike :meth:`shutdown` the thread keeps running, the
167
- sink is **not** closed, and the once-only shutdown flag is untouched, so logging
168
- continues normally afterwards (SPEC-013 FR-002).
194
+ Returns ``True`` once the worker has *delivered* them ``False`` on timeout, on a worker
195
+ already shut down or dead, on a queue too full to accept the marker, and when the drain
196
+ carrying those events was abandoned after exhausting retries (SPEC-021 FR-001). That last
197
+ case used to return ``True``: the drain had run, so the marker was answered regardless of
198
+ what came of the emit. It is a false success exactly where ``flush()`` matters most — a
199
+ serverless handler draining before the environment freezes has the return value as its
200
+ only evidence the tail of the queue survived.
201
+
202
+ The precise claim is *nothing was abandoned while this call was outstanding*: the batch
203
+ this flush forces, and any batch another flush or a batching trigger emitted while its
204
+ marker waited its turn. A batch abandoned **before** the call is not in scope — that loss
205
+ is already in ``failed_batches`` and on stderr, and counting it here would make every
206
+ later empty flush report a failure it did not incur. ``health()`` is the cumulative
207
+ record; this is a verdict on one drain.
208
+
209
+ Unlike :meth:`shutdown` the thread keeps running, the sink is **not** closed, and the
210
+ once-only shutdown flag is untouched, so logging continues normally afterwards
211
+ (SPEC-013 FR-002).
169
212
  """
170
213
  with self._lock:
171
214
  if self._shutdown_done:
@@ -175,7 +218,12 @@ class Worker:
175
218
  return False
176
219
  if not self._thread.is_alive():
177
220
  return False
178
- marker = _FlushMarker()
221
+ with self._lock:
222
+ # Stamped here, on the caller's thread, *before* the marker joins the queue: it is the
223
+ # baseline the drain thread compares against, so anything abandoned from this moment
224
+ # on is attributed to this flush. Reading it after the put would race the very emit
225
+ # the flush is about to force.
226
+ marker = _FlushMarker(self.failed_batches)
179
227
  # One deadline shared by the put and the wait: a caller asked for a bound on the whole
180
228
  # call, not on each half of it, so the two cannot add up to 2 * timeout.
181
229
  deadline = None if timeout is None else time.monotonic() + timeout
@@ -187,7 +235,9 @@ class Worker:
187
235
  except queue.Full:
188
236
  return False
189
237
  remaining = None if deadline is None else max(0.0, deadline - time.monotonic())
190
- return marker.event.wait(remaining)
238
+ if not marker.event.wait(remaining):
239
+ return False # timed out: the drain never happened, so it delivered nothing.
240
+ return marker.delivered
191
241
 
192
242
  def shutdown(self) -> None:
193
243
  """Stop the thread, drain + emit everything queued, then ``close()`` the sink.
@@ -224,6 +274,31 @@ class Worker:
224
274
  self._drain(pending)
225
275
  except BaseException as exc: # deliberately broad — see the docstring
226
276
  self._terminal_failure(exc, len(pending))
277
+ self._release_waiters()
278
+
279
+ def _release_waiters(self) -> None:
280
+ """Answer every ``flush()`` marker still queued, so no caller waits out its timeout.
281
+
282
+ A ``BaseException`` from the main loop skips ``_final_drain`` entirely, which is where
283
+ queued markers are normally answered — leaving a waiter to sit for its full ``timeout``
284
+ on a thread that is never coming back. FR-001 requires the waiter to be released on every
285
+ path, and this is the last one.
286
+
287
+ The markers are *read* out of the queue rather than consumed: the queued event-lists are
288
+ the evidence ``health().queued`` and the terminal line report, and answering a waiter must
289
+ not erase it. Each keeps its pessimistic ``delivered``, which is the truth here.
290
+
291
+ Residual race, stated rather than papered over: a ``flush()`` that passed its liveness
292
+ check microseconds before the thread died can still enqueue a marker after this sweep, and
293
+ that one waits out its timeout — and then returns ``False``, which is correct either way.
294
+ """
295
+ try:
296
+ with self._queue.mutex: # a snapshot, so a marker cannot be missed mid-iteration
297
+ markers = [i for i in self._queue.queue if isinstance(i, _FlushMarker)]
298
+ for marker in markers:
299
+ marker.event.set()
300
+ except Exception: # this runs *after* the record and the stderr line; neither may be lost
301
+ pass
227
302
 
228
303
  def _terminal_failure(self, exc: BaseException, undrained: int) -> None:
229
304
  """Record the drain loop's terminal exit, then announce it (FR-001, FR-002).
@@ -265,12 +340,13 @@ class Worker:
265
340
  # marker must never fall through to the append below, where it would be treated
266
341
  # as a list of events and handed to sink.emit, killing this thread.
267
342
  try:
268
- if pending:
269
- self._emit(pending)
270
- pending.clear()
343
+ self._emit_pending(pending)
344
+ item.delivered = self._nothing_lost_since(item)
271
345
  finally:
272
346
  # Signal even if the emit died, so a waiter is released rather than left to
273
- # wait out its timeout on a thread that is no longer running.
347
+ # wait out its timeout on a thread that is no longer running. A failing flush
348
+ # therefore returns False promptly rather than at the caller's timeout, and
349
+ # an emit that died leaves the marker's pessimistic default standing.
274
350
  last_flush = time.monotonic()
275
351
  item.event.set()
276
352
  continue
@@ -278,15 +354,38 @@ class Worker:
278
354
  pending.append(cast("list[dict[str, object]]", item))
279
355
  now = time.monotonic()
280
356
  if len(pending) >= self.batch_size or now - last_flush >= self.flush_interval:
281
- if pending:
282
- self._emit(pending)
283
- pending.clear()
357
+ self._emit_pending(pending)
284
358
  # Advance the window even when idle (pending empty). Otherwise last_flush never
285
359
  # moves while the queue is empty, timeout collapses to 0.0, and get(timeout=0.0)
286
360
  # busy-spins a core. Resetting it lets the next get() block a full interval.
287
361
  last_flush = now
288
362
  self._final_drain(pending)
289
363
 
364
+ def _emit_pending(self, pending: list[list[dict[str, object]]]) -> None:
365
+ """Emit ``pending`` if there is any, then clear it. The two always go together."""
366
+ if pending:
367
+ self._emit(pending)
368
+ pending.clear()
369
+
370
+ def _nothing_lost_since(self, marker: _FlushMarker) -> bool:
371
+ """Whether any batch was abandoned while ``marker`` was outstanding (SPEC-021 FR-001).
372
+
373
+ ``failed_batches`` moves exactly once per abandoned batch, so comparing it against the
374
+ marker's stamp answers "was anything lost while this flush was in flight" — which is the
375
+ question ``flush()`` asks, and a stronger one than "did *my* emit succeed". A marker whose
376
+ own emit found nothing pending still reports the loss if another flush's emit, or a
377
+ batching trigger's, abandoned a batch while it waited its turn.
378
+
379
+ Deliberately *not* a running "has anything ever failed" flag. That would make every empty
380
+ flush after a single bad batch report a failure it did not incur, contradicting the rule
381
+ that an empty drain is a successful one.
382
+
383
+ Called on the drain thread; the lock is for the counter's other readers, and is held only
384
+ for the read (``shutdown()`` releases it before ``join()``, so it cannot deadlock).
385
+ """
386
+ with self._lock:
387
+ return self.failed_batches == marker.seen_failures
388
+
290
389
  def _final_drain(self, pending: list[list[dict[str, object]]]) -> None:
291
390
  """On stop, pull anything still queued and emit the tail as one final batch."""
292
391
  markers: list[_FlushMarker] = []
@@ -298,15 +397,21 @@ class Worker:
298
397
  if isinstance(item, _FlushMarker):
299
398
  # This guard is a second copy of _drain's and needs the same exclusion. Markers
300
399
  # are answered *after* the final emit below, so a flush() that raced shutdown()
301
- # still returns True and truthfully: its events really did reach the sink.
400
+ # is answered by the drain that carried its events with that drain's outcome.
302
401
  markers.append(item)
303
402
  continue
304
403
  if item is not None and item is not _SHUTDOWN:
305
404
  pending.append(cast("list[dict[str, object]]", item))
306
- if pending:
307
- self._emit(pending)
308
- for marker in markers:
309
- marker.event.set()
405
+ try:
406
+ self._emit_pending(pending)
407
+ for marker in markers:
408
+ marker.delivered = self._nothing_lost_since(marker)
409
+ finally:
410
+ # In a ``finally`` so a ``BaseException`` from the final emit cannot strand a waiter
411
+ # for its whole timeout — the markers are released carrying their pessimistic
412
+ # default, which is the truth about a batch that died with this thread.
413
+ for marker in markers:
414
+ marker.event.set()
310
415
 
311
416
  def _emit(self, event_lists: list[list[dict[str, object]]]) -> None:
312
417
  """Flatten queued per-span event-lists into one batch and emit, retrying with backoff.
@@ -314,23 +419,33 @@ class Worker:
314
419
  A failing ``sink.emit`` is retried up to ``max_retries`` times; past that the batch is
315
420
  abandoned with a counted warning and draining continues, so a broken sink never crashes
316
421
  the worker thread or the app (FR-002, FR-003).
422
+
423
+ The outcome is not returned: ``failed_batches`` already moves exactly once per abandoned
424
+ batch, and that counter is what a waiting ``flush()`` is compared against (SPEC-021
425
+ FR-001), so a second channel for the same fact could only disagree with it.
426
+
427
+ ``max_retries`` is floored at zero so the loop always makes at least one attempt. A
428
+ negative value otherwise skipped the emit entirely and discarded the batch with no
429
+ attempt, no counter and nothing on stderr — a silent loss of exactly the kind this arc of
430
+ specs exists to remove, reachable only by misconfiguration but reachable.
317
431
  """
318
432
  batch = [event for events in event_lists for event in events]
319
433
  if not batch:
320
434
  return
321
- for attempt in range(self.max_retries + 1):
435
+ retries = max(self.max_retries, 0)
436
+ for attempt in range(retries + 1):
322
437
  try:
323
438
  self.sink.emit(batch)
324
439
  return
325
440
  except Exception: # any sink failure must not kill the worker thread
326
- if attempt >= self.max_retries:
441
+ if attempt >= retries:
327
442
  # Under the lock so a concurrent health() sees a coherent snapshot rather
328
443
  # than a half-updated pair. No deadlock: shutdown() releases before join().
329
444
  with self._lock:
330
445
  self.failed_batches += 1
331
446
  sys.stderr.write(
332
447
  f"log-foundry: abandoned a batch of {len(batch)} event(s) after "
333
- f"{self.max_retries + 1} failed emit attempts\n"
448
+ f"{retries + 1} failed emit attempts\n"
334
449
  )
335
450
  return
336
451
  # Backoff between attempts; _stop.wait returns at once during shutdown, so a