log-foundry 0.7.1.dev3__tar.gz → 0.7.2.dev4__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.dev4}/PKG-INFO +31 -7
  2. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/README.md +30 -6
  3. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/pyproject.toml +2 -1
  4. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/__init__.py +10 -6
  5. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/config.py +10 -1
  6. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sanitize.py +23 -2
  7. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/worker.py +155 -24
  8. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/LICENSE +0 -0
  9. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/api.py +0 -0
  10. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/console.py +0 -0
  11. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/context.py +0 -0
  12. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/decorator.py +0 -0
  13. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/ids.py +0 -0
  14. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/model.py +0 -0
  15. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/py.typed +0 -0
  16. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/__init__.py +0 -0
  17. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/_batch.py +0 -0
  18. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/_chunk.py +0 -0
  19. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/_socket.py +0 -0
  20. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/_time.py +0 -0
  21. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/base.py +0 -0
  22. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/callback.py +0 -0
  23. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/clickhouse.py +0 -0
  24. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/datadog.py +0 -0
  25. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/elasticsearch.py +0 -0
  26. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/eventhubs.py +0 -0
  27. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/file.py +0 -0
  28. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/filtering.py +0 -0
  29. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/firehose.py +0 -0
  30. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/honeycomb.py +0 -0
  31. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/http.py +0 -0
  32. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/kafka.py +0 -0
  33. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/kinesis.py +0 -0
  34. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/logging_sink.py +0 -0
  35. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/logstash.py +0 -0
  36. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/loki.py +0 -0
  37. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/mongodb.py +0 -0
  38. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/multi.py +0 -0
  39. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/nats.py +0 -0
  40. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/newrelic.py +0 -0
  41. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/postgres.py +0 -0
  42. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/pubsub.py +0 -0
  43. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/rabbitmq.py +0 -0
  44. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/redis.py +0 -0
  45. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/sentry.py +0 -0
  46. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/sns.py +0 -0
  47. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/splunk.py +0 -0
  48. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/sqlite.py +0 -0
  49. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/sqs.py +0 -0
  50. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/stdout.py +0 -0
  51. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/syslog.py +0 -0
  52. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/transform.py +0 -0
  53. {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/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.dev4
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
@@ -765,6 +783,12 @@ the default configuration the interpreter's limit — not `max_value_bytes` —
765
783
  unlikely to meet it deliberately; `int.from_bytes(blob, "big")` over a couple of kilobytes gets
766
784
  there. Any ceiling firing sets `truncated: true` on the event.
767
785
 
786
+ `max_value_bytes` therefore carries two units: **UTF-8 bytes** for a string, **rendered decimal
787
+ length** (sign included) for an integer. They coincide for ASCII digits, and one ceiling for "how
788
+ big may a single value get" was preferred to a second config key. Note that all four ceilings
789
+ bound each *value* — an event of many bounded values can still be large; see
790
+ [Known constraints](docs/architecture.md#known-constraints).
791
+
768
792
  ## Development
769
793
 
770
794
  ```bash
@@ -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
@@ -729,6 +747,12 @@ the default configuration the interpreter's limit — not `max_value_bytes` —
729
747
  unlikely to meet it deliberately; `int.from_bytes(blob, "big")` over a couple of kilobytes gets
730
748
  there. Any ceiling firing sets `truncated: true` on the event.
731
749
 
750
+ `max_value_bytes` therefore carries two units: **UTF-8 bytes** for a string, **rendered decimal
751
+ length** (sign included) for an integer. They coincide for ASCII digits, and one ceiling for "how
752
+ big may a single value get" was preferred to a second config key. Note that all four ceilings
753
+ bound each *value* — an event of many bounded values can still be large; see
754
+ [Known constraints](docs/architecture.md#known-constraints).
755
+
732
756
  ## Development
733
757
 
734
758
  ```bash
@@ -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.dev4"
24
24
 
25
25
  [project.optional-dependencies]
26
26
  aws = ["boto3>=1.34"] # SQSSink, SNSSink, KinesisSink, FirehoseSink
@@ -58,6 +58,7 @@ exclude = [
58
58
 
59
59
  # Development-only dependencies (not shipped to consumers).
60
60
  # Install with: poetry install --with dev
61
+
61
62
  [tool.poetry.group.dev.dependencies]
62
63
  pytest = "^9.0"
63
64
  pytest-asyncio = "^1.4.0" # for testing async @trace (guide Phase 8)
@@ -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
 
@@ -24,6 +24,15 @@ class Config:
24
24
  The four ``max_*`` ceilings bound every event payload (SPEC-017 FR-002). Defaults are set so
25
25
  that the overwhelming majority of events are untouched; they exist to stop *one* pathological
26
26
  value getting a whole event rejected by a sink's hard limit.
27
+
28
+ ``max_value_bytes`` carries **two units**, deliberately (SPEC-020, recorded by SPEC-021): a
29
+ string is measured in UTF-8 bytes, an integer in the decimal length it renders as, sign
30
+ included. They coincide for ASCII digits, and one ceiling covering "how big may a single
31
+ value get" beat a second config key for a distinction almost no one configures. An integer is
32
+ also bounded by ``sys.get_int_max_str_digits()`` whenever that is lower, since a longer one
33
+ cannot be rendered at all.
34
+
35
+ They bound each *value*, not the event as a whole — see arch §13 Known Constraints.
27
36
  """
28
37
 
29
38
  service: str = "unknown"
@@ -31,7 +40,7 @@ class Config:
31
40
  env: str = "dev"
32
41
  sink: Sink | None = None
33
42
  defaults: dict[str, object] = field(default_factory=dict)
34
- max_value_bytes: int = 8192 # per str value, UTF-8 bytes
43
+ max_value_bytes: int = 8192 # per value: UTF-8 bytes for a str, rendered digits for an int
35
44
  max_stack_bytes: int = 32768 # error.stack only — legitimately long, and worth keeping
36
45
  max_keys: int = 256 # per mapping / sequence
37
46
  max_depth: int = 8 # nesting levels
@@ -55,6 +55,12 @@ _DEPTH_LIMIT = "<depth limit>"
55
55
  _LOG10_2_NUM = 30103
56
56
  _LOG10_2_DEN = 100000
57
57
 
58
+ # ``int.__lt__`` bound once, for the sign test in :meth:`_Coercer.integer` (SPEC-021 FR-003). The
59
+ # unbound slot cannot be diverted by an ``int`` subclass the way ``value < 0`` can, but reaching
60
+ # it through ``int.__lt__`` costs a global plus an attribute lookup on a per-value hot path;
61
+ # binding it here recovers about a third of that, the same idiom as the constants above.
62
+ _INT_LT = int.__lt__
63
+
58
64
  # Exact-type membership, deliberately not ``isinstance``. ``IntEnum``/``StrEnum`` members *are*
59
65
  # ``int``/``str`` instances, so an isinstance check would pass the enum member itself through and
60
66
  # hand a sink an ``Enum`` where a plain value was promised. Exact typing lets them fall to the
@@ -74,6 +80,11 @@ def _int_digit_ceiling(max_value_bytes: int) -> int:
74
80
  raises ``ValueError``, which ``json.dumps`` inherits. A configured ceiling above that cannot be
75
81
  honoured — rendering such an integer is the very thing that raises — so the interpreter's limit
76
82
  wins whenever it is lower. A limit of ``0`` means the interpreter imposes none.
83
+
84
+ The caller measures a negative value's rendered length, sign included, against this; the
85
+ interpreter's own limit counts digits only. A negative integer sitting exactly on the
86
+ interpreter bound is therefore replaced rather than rendered — one value at the far edge, in
87
+ the direction this module always errs (SPEC-021 FR-003).
77
88
  """
78
89
  limit = sys.get_int_max_str_digits()
79
90
  return max_value_bytes if limit <= 0 else min(max_value_bytes, limit)
@@ -278,7 +289,11 @@ class _Coercer:
278
289
  The size test is ``bit_length()``, never ``len(str(value))``: converting an over-long
279
290
  integer to a string raises the very ``ValueError`` this bound exists to prevent, so the
280
291
  obvious check would move the crash rather than remove it (FR-002). ``bit_length()`` is
281
- O(1), total, and ignores the sign, so ``n`` and ``-n`` are bounded identically.
292
+ O(1), total, and ignores the sign so the minus sign is added back explicitly, since the
293
+ ceiling measures what the value *renders* as and ``-10**9`` renders as eleven bytes
294
+ (SPEC-021 FR-003). The placeholder still names the digit count: a sign is not a digit.
295
+ The sign is read through an *unbound* ``int.__lt__``, which an ``int`` subclass cannot
296
+ divert; ``bit_length()`` stays the one trusted call, as below.
282
297
 
283
298
  An over-long integer is *replaced*, not clipped. Dropping digits would silently change the
284
299
  value, and a wrong number is worse than a visibly elided one — so this reuses the
@@ -291,7 +306,13 @@ class _Coercer:
291
306
  ``type()`` check to catch only a value engineered to lie about itself.
292
307
  """
293
308
  digits = value.bit_length() * _LOG10_2_NUM // _LOG10_2_DEN + 1
294
- if digits <= _int_digit_ceiling(self._cfg.max_value_bytes):
309
+ # ``_INT_LT`` (unbound ``int.__lt__``), never ``value < 0``: on an ``int`` *subclass* the
310
+ # operator dispatches to user code, which can raise — and a raise here is caught up in
311
+ # :meth:`value`, replacing the whole enclosing mapping and taking every sibling key with
312
+ # it, which is precisely what :meth:`key` was written to prevent. The unbound call reads
313
+ # the underlying integer and cannot be overridden.
314
+ rendered = digits + 1 if _INT_LT(value, 0) else digits
315
+ if rendered <= _int_digit_ceiling(self._cfg.max_value_bytes):
295
316
  return value
296
317
  self.truncated = True
297
318
  return f"<int: ~{digits} digits>"
@@ -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).
@@ -233,14 +308,30 @@ class Worker:
233
308
  must not be able to ride on it. The exception's *type* is reported and its message is
234
309
  not — a sink's exception text can carry event data, and arch §6 keeps caller data out of
235
310
  places it was not asked for (the same rule behind ``sanitize``'s type-name placeholder).
311
+
312
+ The count reports what was *in hand* and what was still *queued behind it* (SPEC-021
313
+ FR-002). Held alone under-reads the loss: nothing will drain the queue either, so an
314
+ operator reading "1 undrained event-list(s)" could conclude far less was lost than was.
315
+
316
+ The queued figure is "items", not "event-lists", and says so: like ``Health.queued`` it is
317
+ read without stopping the world, so it counts any internal flush/shutdown marker sitting
318
+ alongside real submissions, and a producer thread can add to the queue between the death
319
+ and the read. It is a floor on what was lost, which is the useful direction.
236
320
  """
237
321
  name = type(exc).__name__
238
322
  with self._lock:
239
323
  self.stopped_reason = name
324
+ try:
325
+ # In its own guard, and after the record: ``qsize()`` is not guaranteed on every
326
+ # platform's queue, and a diagnostic must not be the reason the diagnosis is lost.
327
+ queued: object = self._queue.qsize()
328
+ except Exception:
329
+ queued = "?"
240
330
  try:
241
331
  sys.stderr.write(
242
332
  f"log-foundry: worker thread stopped on {name}; {undrained} undrained "
243
- f"event-list(s), nothing further will be delivered\n"
333
+ f"event-list(s) held and {queued} queued item(s) undelivered, nothing further "
334
+ f"will be delivered\n"
244
335
  )
245
336
  except Exception: # best-effort: the record above is what an operator reads.
246
337
  pass
@@ -265,12 +356,13 @@ class Worker:
265
356
  # marker must never fall through to the append below, where it would be treated
266
357
  # as a list of events and handed to sink.emit, killing this thread.
267
358
  try:
268
- if pending:
269
- self._emit(pending)
270
- pending.clear()
359
+ self._emit_pending(pending)
360
+ item.delivered = self._nothing_lost_since(item)
271
361
  finally:
272
362
  # 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.
363
+ # wait out its timeout on a thread that is no longer running. A failing flush
364
+ # therefore returns False promptly rather than at the caller's timeout, and
365
+ # an emit that died leaves the marker's pessimistic default standing.
274
366
  last_flush = time.monotonic()
275
367
  item.event.set()
276
368
  continue
@@ -278,15 +370,38 @@ class Worker:
278
370
  pending.append(cast("list[dict[str, object]]", item))
279
371
  now = time.monotonic()
280
372
  if len(pending) >= self.batch_size or now - last_flush >= self.flush_interval:
281
- if pending:
282
- self._emit(pending)
283
- pending.clear()
373
+ self._emit_pending(pending)
284
374
  # Advance the window even when idle (pending empty). Otherwise last_flush never
285
375
  # moves while the queue is empty, timeout collapses to 0.0, and get(timeout=0.0)
286
376
  # busy-spins a core. Resetting it lets the next get() block a full interval.
287
377
  last_flush = now
288
378
  self._final_drain(pending)
289
379
 
380
+ def _emit_pending(self, pending: list[list[dict[str, object]]]) -> None:
381
+ """Emit ``pending`` if there is any, then clear it. The two always go together."""
382
+ if pending:
383
+ self._emit(pending)
384
+ pending.clear()
385
+
386
+ def _nothing_lost_since(self, marker: _FlushMarker) -> bool:
387
+ """Whether any batch was abandoned while ``marker`` was outstanding (SPEC-021 FR-001).
388
+
389
+ ``failed_batches`` moves exactly once per abandoned batch, so comparing it against the
390
+ marker's stamp answers "was anything lost while this flush was in flight" — which is the
391
+ question ``flush()`` asks, and a stronger one than "did *my* emit succeed". A marker whose
392
+ own emit found nothing pending still reports the loss if another flush's emit, or a
393
+ batching trigger's, abandoned a batch while it waited its turn.
394
+
395
+ Deliberately *not* a running "has anything ever failed" flag. That would make every empty
396
+ flush after a single bad batch report a failure it did not incur, contradicting the rule
397
+ that an empty drain is a successful one.
398
+
399
+ Called on the drain thread; the lock is for the counter's other readers, and is held only
400
+ for the read (``shutdown()`` releases it before ``join()``, so it cannot deadlock).
401
+ """
402
+ with self._lock:
403
+ return self.failed_batches == marker.seen_failures
404
+
290
405
  def _final_drain(self, pending: list[list[dict[str, object]]]) -> None:
291
406
  """On stop, pull anything still queued and emit the tail as one final batch."""
292
407
  markers: list[_FlushMarker] = []
@@ -298,15 +413,21 @@ class Worker:
298
413
  if isinstance(item, _FlushMarker):
299
414
  # This guard is a second copy of _drain's and needs the same exclusion. Markers
300
415
  # 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.
416
+ # is answered by the drain that carried its events with that drain's outcome.
302
417
  markers.append(item)
303
418
  continue
304
419
  if item is not None and item is not _SHUTDOWN:
305
420
  pending.append(cast("list[dict[str, object]]", item))
306
- if pending:
307
- self._emit(pending)
308
- for marker in markers:
309
- marker.event.set()
421
+ try:
422
+ self._emit_pending(pending)
423
+ for marker in markers:
424
+ marker.delivered = self._nothing_lost_since(marker)
425
+ finally:
426
+ # In a ``finally`` so a ``BaseException`` from the final emit cannot strand a waiter
427
+ # for its whole timeout — the markers are released carrying their pessimistic
428
+ # default, which is the truth about a batch that died with this thread.
429
+ for marker in markers:
430
+ marker.event.set()
310
431
 
311
432
  def _emit(self, event_lists: list[list[dict[str, object]]]) -> None:
312
433
  """Flatten queued per-span event-lists into one batch and emit, retrying with backoff.
@@ -314,23 +435,33 @@ class Worker:
314
435
  A failing ``sink.emit`` is retried up to ``max_retries`` times; past that the batch is
315
436
  abandoned with a counted warning and draining continues, so a broken sink never crashes
316
437
  the worker thread or the app (FR-002, FR-003).
438
+
439
+ The outcome is not returned: ``failed_batches`` already moves exactly once per abandoned
440
+ batch, and that counter is what a waiting ``flush()`` is compared against (SPEC-021
441
+ FR-001), so a second channel for the same fact could only disagree with it.
442
+
443
+ ``max_retries`` is floored at zero so the loop always makes at least one attempt. A
444
+ negative value otherwise skipped the emit entirely and discarded the batch with no
445
+ attempt, no counter and nothing on stderr — a silent loss of exactly the kind this arc of
446
+ specs exists to remove, reachable only by misconfiguration but reachable.
317
447
  """
318
448
  batch = [event for events in event_lists for event in events]
319
449
  if not batch:
320
450
  return
321
- for attempt in range(self.max_retries + 1):
451
+ retries = max(self.max_retries, 0)
452
+ for attempt in range(retries + 1):
322
453
  try:
323
454
  self.sink.emit(batch)
324
455
  return
325
456
  except Exception: # any sink failure must not kill the worker thread
326
- if attempt >= self.max_retries:
457
+ if attempt >= retries:
327
458
  # Under the lock so a concurrent health() sees a coherent snapshot rather
328
459
  # than a half-updated pair. No deadlock: shutdown() releases before join().
329
460
  with self._lock:
330
461
  self.failed_batches += 1
331
462
  sys.stderr.write(
332
463
  f"log-foundry: abandoned a batch of {len(batch)} event(s) after "
333
- f"{self.max_retries + 1} failed emit attempts\n"
464
+ f"{retries + 1} failed emit attempts\n"
334
465
  )
335
466
  return
336
467
  # Backoff between attempts; _stop.wait returns at once during shutdown, so a