log-foundry 0.10.2.dev18__tar.gz → 0.10.2.dev20__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 (55) hide show
  1. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/PKG-INFO +99 -16
  2. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/README.md +98 -15
  3. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/pyproject.toml +1 -1
  4. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/__init__.py +29 -6
  5. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/decorator.py +9 -4
  6. log_foundry-0.10.2.dev20/src/log_foundry/sinks/_retry.py +103 -0
  7. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/_socket.py +13 -2
  8. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/base.py +6 -4
  9. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/clickhouse.py +13 -4
  10. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/eventhubs.py +13 -4
  11. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/filtering.py +26 -0
  12. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/firehose.py +17 -1
  13. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/http.py +37 -4
  14. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/kinesis.py +17 -1
  15. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/logstash.py +24 -0
  16. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/mongodb.py +12 -3
  17. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/multi.py +29 -0
  18. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/postgres.py +13 -4
  19. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/pubsub.py +6 -2
  20. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/rabbitmq.py +13 -4
  21. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/redis.py +13 -4
  22. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/sentry.py +22 -1
  23. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/sns.py +19 -2
  24. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/sqs.py +24 -2
  25. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/syslog.py +19 -0
  26. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/transform.py +26 -0
  27. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/worker.py +132 -8
  28. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/LICENSE +0 -0
  29. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/_diag.py +0 -0
  30. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/api.py +0 -0
  31. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/config.py +0 -0
  32. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/console.py +0 -0
  33. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/context.py +0 -0
  34. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/ids.py +0 -0
  35. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/model.py +0 -0
  36. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/py.typed +0 -0
  37. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sanitize.py +0 -0
  38. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/__init__.py +0 -0
  39. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/_batch.py +0 -0
  40. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/_chunk.py +0 -0
  41. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/_time.py +0 -0
  42. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/callback.py +0 -0
  43. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/datadog.py +0 -0
  44. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/elasticsearch.py +0 -0
  45. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/file.py +0 -0
  46. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/honeycomb.py +0 -0
  47. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/kafka.py +0 -0
  48. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/logging_sink.py +0 -0
  49. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/loki.py +0 -0
  50. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/nats.py +0 -0
  51. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/newrelic.py +0 -0
  52. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/splunk.py +0 -0
  53. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/sqlite.py +0 -0
  54. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/stdout.py +0 -0
  55. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/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.10.2.dev18
3
+ Version: 0.10.2.dev20
4
4
  Summary: Generate logs for your console and JSON events for downstream consumption.
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -438,12 +438,16 @@ A few conventions hold across every sink below:
438
438
  The tables show the destination-defining arguments only; sinks that retry also take `max_retries`.
439
439
  - **Ownership.** A resource the sink opens itself is closed on `shutdown()`; an injected one is left
440
440
  open for you to manage.
441
- - **Never crashes the app.** A failing sink is retried with backoff and then counted (`.failed`,
442
- `.dropped_oversized`, `.dropped_unadjudicated`, …) rather than raised a broken destination
443
- degrades logging, nothing more.
444
- The one deliberate exception is a `MultiSink` whose children *all* failed: it re-raises so the
445
- worker's retry engages, since nothing was delivered and there are no duplicates to risk. That
446
- still doesn't reach your code the worker is what catches it.
441
+ - **Never crashes the app.** A broken destination degrades logging and nothing more. A sink that
442
+ delivered *part* of a batch counts what it lost (`.failed`, `.dropped_oversized`,
443
+ `.dropped_unadjudicated`, …) and returns, since retrying would re-deliver what already landed.
444
+ A sink that delivered **none** of it raises instead, so the worker's bounded retry engages and
445
+ `health().failed_batches` records the loss there is nothing downstream to duplicate. Three
446
+ cases are excepted, each because a retry would be wrong rather than merely futile: an oversized
447
+ event (it can never fit), a response the sink could not adjudicate (it cannot prove nothing
448
+ landed), and an SQS sender fault (a byte-identical re-send can only fail again). Either way the
449
+ exception never reaches your code — inside a span the worker catches it, and on the orphan path
450
+ (`log_foundry.info(...)` outside any span, which emits synchronously) the emitter does.
447
451
 
448
452
  #### Built-in, zero-dependency
449
453
 
@@ -466,7 +470,7 @@ to a plain callable.
466
470
 
467
471
  | Sink | Import from | Configure |
468
472
  |---|---|---|
469
- | `MultiSink` | `log_foundry.sinks.multi` | `MultiSink(*sinks)` — forward each batch to every child; a failing child is isolated and counted on `.failed` |
473
+ | `MultiSink` | `log_foundry.sinks.multi` | `MultiSink(*sinks)` — forward each batch to every child; a failing child is isolated and counted on `.failed`, unless *every* child failed, which re-raises |
470
474
  | `FilteringSink` | `log_foundry.sinks.filtering` | `FilteringSink(inner, *, predicate=None, min_level=None)` — forward only events passing `predicate` and/or at/above `min_level` |
471
475
  | `TransformSink` | `log_foundry.sinks.transform` | `TransformSink(inner, fn)` — map each event through `fn` before forwarding; return `None` to drop one |
472
476
  | `CallbackSink` | `log_foundry.sinks.callback` | `CallbackSink(fn, *, on_close=None)` — hand each batch to any callable |
@@ -659,8 +663,56 @@ Write-only inserts (querying is the downstream tool's job); each needs its own e
659
663
  `PostgresSink` / `ClickHouseSink` default `create_table=False` (you own the schema and indexes); set
660
664
  it `True` for an idempotent `CREATE TABLE IF NOT EXISTS` convenience.
661
665
 
662
- Prefer a destination not listed here? Implement the two-method `Sink` protocol yourself, or wrap any
663
- callable in `CallbackSink`.
666
+ Prefer a destination not listed here? Implement the `Sink` protocol yourself, or wrap any callable
667
+ in `CallbackSink`.
668
+
669
+ #### Writing your own sink
670
+
671
+ `Sink` is two required methods, `emit(batch)` and `close()`, plus two rules about *how* `emit`
672
+ fails. They are not stylistic — the library's whole loss-reporting apparatus is built on them:
673
+
674
+ - **Raise when you delivered none of the batch**, after your own retries are spent. That is the
675
+ signal the worker's bounded retry and `health().failed_batches` depend on, and the one case where
676
+ a retry cannot duplicate anything: nothing landed downstream. Raise `SinkDeliveryError` (from
677
+ `log_foundry.sinks.base`) or any exception of your own — the contract is that *something*
678
+ propagates.
679
+ - **Do not raise when you delivered some of it.** The worker retries whole batches, so raising on a
680
+ partial success re-delivers the records that already arrived, and duplicates downstream are worse
681
+ than a counted loss.
682
+
683
+ A sink that absorbs a total failure and returns normally is a sink the worker believes: the retry
684
+ never engages, `failed_batches` stays at zero, and `flush()` returns `True` while every event is
685
+ lost.
686
+
687
+ Optionally add `losses()` to report what you absorbed. It must never raise and must be safe to call
688
+ while `emit` is running (`health()` is a poll):
689
+
690
+ ```python
691
+ from log_foundry.sinks.base import SinkDeliveryError, SinkLosses
692
+
693
+ class MySink:
694
+ def __init__(self) -> None:
695
+ self._dropped = self._failed = 0
696
+
697
+ def emit(self, batch: list[dict[str, object]]) -> None:
698
+ delivered = 0
699
+ for chunk in self._chunks(batch):
700
+ if self._send(chunk): # your own bounded retry
701
+ delivered += len(chunk)
702
+ else:
703
+ self._failed += len(chunk)
704
+ if batch and not delivered:
705
+ raise SinkDeliveryError(f"MySink delivered none of {len(batch)} event(s)")
706
+
707
+ def losses(self) -> SinkLosses:
708
+ return SinkLosses(dropped=self._dropped, failed=self._failed)
709
+
710
+ def close(self) -> None: ...
711
+ ```
712
+
713
+ `losses()` is optional and probed by name, so a sink written before it existed keeps working and
714
+ simply contributes nothing to `health().sink`. `emit([])` must be a no-op: an empty batch has not
715
+ failed to deliver.
664
716
 
665
717
  ### Flushing and shutdown
666
718
 
@@ -676,17 +728,33 @@ returns a snapshot of the worker's counters:
676
728
 
677
729
  ```python
678
730
  h = log_foundry.health()
679
- if h.dropped or h.failed_batches or h.stopped_reason:
731
+ if h.dropped or h.failed_batches or h.stopped_reason or (h.sink and (h.sink.dropped or h.sink.failed)):
680
732
  ... # logs were silently lost — worth an alert
681
733
  ```
682
734
 
683
- The three tell you different things, and they want different responses:
735
+ They tell you different things, and they want different responses:
684
736
 
685
737
  | Field | Means | What to do |
686
738
  |---|---|---|
687
739
  | `dropped` | The queue filled — the destination is not keeping up. Delivery continues. | Tune `batch_size`/`flush_interval`, or scale the sink. |
688
740
  | `failed_batches` | A sink stayed broken through the whole retry budget. Delivery continues. | Fix the destination. |
689
741
  | `stopped_reason` | The background thread **died** on that exception type. Nothing further will be delivered, ever. | Restart the process; investigate the named exception. |
742
+ | `sink.dropped` | The sink discarded events **before** attempting delivery — an oversized record, or one the client refused outright. | Read the stderr line: it names the cause. An oversized record means shrink what you log; a refused local produce/publish (Kafka, Pub/Sub) points at the client — a saturated buffer, a bad topic, a credential. |
743
+ | `sink.failed` | The sink attempted delivery and could not confirm it — abandoned requests, partially-failed batches, responses it could not adjudicate. | Fix the destination. |
744
+
745
+ `h.sink` is a `SinkLosses(dropped, failed)` or `None` — `None` when no worker exists yet, or when
746
+ the configured sink reports nothing (`losses()` is optional). Note the two `dropped` fields count
747
+ different things: the worker's is backpressure at *its* queue, the sink's is an event that never
748
+ reached the wire. They are separate because the remedies do not overlap — and `sink.dropped` is
749
+ itself two causes, which is why the diagnostic line matters. Most sinks drop only what can never
750
+ fit; `KafkaSink` and `GooglePubSubSink` also count what their client refused outright, which may
751
+ be backpressure one layer further out than the worker's, or may be a misconfiguration. The stderr
752
+ line carries the exception type that distinguishes them.
753
+
754
+ `sink.failed` is an **upper bound** on loss, not a count of it. A sink that raises on total failure
755
+ counts the attempt *and* hands the batch back to the worker, whose retry may then deliver it — so a
756
+ transient outage leaves it non-zero with nothing actually lost. `failed_batches` is the record of a
757
+ batch given up on for good.
690
758
 
691
759
  `stopped_reason` is a type name (e.g. `"SystemExit"`), never the exception's message — a sink's
692
760
  error text can carry event data. It reads `None` for a healthy worker, for a process that has never
@@ -694,9 +762,10 @@ logged, and after a clean `shutdown()`, so a plain truthiness check is safe. Wit
694
762
  thread showed up only indirectly, as `dropped` climbing once the queue filled — the wrong signal,
695
763
  pointing at the wrong fix.
696
764
 
697
- Read a snapshot by attribute (`h.dropped`), as above. `Health` is a `NamedTuple` and gained a
698
- fourth field in `v0.7.0`, so unpacking it whole — `queued, dropped, failed = health()` — raises
699
- `ValueError` from that version on.
765
+ Read a snapshot by attribute (`h.dropped`), as above. `Health` is a `NamedTuple` and has gained
766
+ fields over time — a fourth (`stopped_reason`) in `v0.7.0` and a fifth (`sink`) not yet in a tagged
767
+ release — so unpacking it whole (`queued, dropped, failed = health()`) raises `ValueError`. Every
768
+ field keeps its position when a new one is appended, so attribute and index access stay stable.
700
769
 
701
770
  `dropped` counts submissions discarded because the queue filled; `failed_batches` counts batches
702
771
  abandoned after the retry budget was spent. Overflow also warns on stderr — on the first drop and
@@ -711,9 +780,23 @@ which one you want depends on whether the process is about to end:
711
780
  import log_foundry as lf
712
781
 
713
782
  lf.flush() # drain to the sink and keep going; returns True when everything landed
714
- lf.shutdown() # drain, close the sink, and stop for good; blocks until drained
783
+ lf.shutdown() # drain, close the sink, and stop for good; blocks until drained (30s cap)
715
784
  ```
716
785
 
786
+ Both are bounded, because both can be called somewhere with a deadline. `flush(timeout=5.0)`
787
+ returns `False` if the drain did not complete; `shutdown(timeout=30.0)` returns having stopped
788
+ what it could, and reports `health().stopped_reason == "ShutdownTimeout"`. Passing `None` to
789
+ either waits indefinitely, which is unsafe in any environment with an execution deadline.
790
+
791
+ **What a broken destination can cost you.** There is one drain thread, so a sink's backoff pauses
792
+ *all* log delivery, and it spans `shutdown()`. At the defaults (`max_retries=3`) that is 0.7 s of
793
+ backoff per batch for most sinks (per *message* for the socket-backed ones — ~70 s for a
794
+ 100-message batch against a dead syslog host), and up to 90 s for an HTTP sink whose destination
795
+ is sending
796
+ `Retry-After` — clamped to `max_retry_after=30.0` per wait, which you can lower. Every wait is cut
797
+ short by a shutdown, and `shutdown()`'s own timeout bounds the total either way. Each sink's class
798
+ docstring states its own worst case.
799
+
717
800
  | | `flush()` | `shutdown()` |
718
801
  |---|---|---|
719
802
  | Drains buffered events | yes | yes |
@@ -402,12 +402,16 @@ A few conventions hold across every sink below:
402
402
  The tables show the destination-defining arguments only; sinks that retry also take `max_retries`.
403
403
  - **Ownership.** A resource the sink opens itself is closed on `shutdown()`; an injected one is left
404
404
  open for you to manage.
405
- - **Never crashes the app.** A failing sink is retried with backoff and then counted (`.failed`,
406
- `.dropped_oversized`, `.dropped_unadjudicated`, …) rather than raised a broken destination
407
- degrades logging, nothing more.
408
- The one deliberate exception is a `MultiSink` whose children *all* failed: it re-raises so the
409
- worker's retry engages, since nothing was delivered and there are no duplicates to risk. That
410
- still doesn't reach your code the worker is what catches it.
405
+ - **Never crashes the app.** A broken destination degrades logging and nothing more. A sink that
406
+ delivered *part* of a batch counts what it lost (`.failed`, `.dropped_oversized`,
407
+ `.dropped_unadjudicated`, …) and returns, since retrying would re-deliver what already landed.
408
+ A sink that delivered **none** of it raises instead, so the worker's bounded retry engages and
409
+ `health().failed_batches` records the loss there is nothing downstream to duplicate. Three
410
+ cases are excepted, each because a retry would be wrong rather than merely futile: an oversized
411
+ event (it can never fit), a response the sink could not adjudicate (it cannot prove nothing
412
+ landed), and an SQS sender fault (a byte-identical re-send can only fail again). Either way the
413
+ exception never reaches your code — inside a span the worker catches it, and on the orphan path
414
+ (`log_foundry.info(...)` outside any span, which emits synchronously) the emitter does.
411
415
 
412
416
  #### Built-in, zero-dependency
413
417
 
@@ -430,7 +434,7 @@ to a plain callable.
430
434
 
431
435
  | Sink | Import from | Configure |
432
436
  |---|---|---|
433
- | `MultiSink` | `log_foundry.sinks.multi` | `MultiSink(*sinks)` — forward each batch to every child; a failing child is isolated and counted on `.failed` |
437
+ | `MultiSink` | `log_foundry.sinks.multi` | `MultiSink(*sinks)` — forward each batch to every child; a failing child is isolated and counted on `.failed`, unless *every* child failed, which re-raises |
434
438
  | `FilteringSink` | `log_foundry.sinks.filtering` | `FilteringSink(inner, *, predicate=None, min_level=None)` — forward only events passing `predicate` and/or at/above `min_level` |
435
439
  | `TransformSink` | `log_foundry.sinks.transform` | `TransformSink(inner, fn)` — map each event through `fn` before forwarding; return `None` to drop one |
436
440
  | `CallbackSink` | `log_foundry.sinks.callback` | `CallbackSink(fn, *, on_close=None)` — hand each batch to any callable |
@@ -623,8 +627,56 @@ Write-only inserts (querying is the downstream tool's job); each needs its own e
623
627
  `PostgresSink` / `ClickHouseSink` default `create_table=False` (you own the schema and indexes); set
624
628
  it `True` for an idempotent `CREATE TABLE IF NOT EXISTS` convenience.
625
629
 
626
- Prefer a destination not listed here? Implement the two-method `Sink` protocol yourself, or wrap any
627
- callable in `CallbackSink`.
630
+ Prefer a destination not listed here? Implement the `Sink` protocol yourself, or wrap any callable
631
+ in `CallbackSink`.
632
+
633
+ #### Writing your own sink
634
+
635
+ `Sink` is two required methods, `emit(batch)` and `close()`, plus two rules about *how* `emit`
636
+ fails. They are not stylistic — the library's whole loss-reporting apparatus is built on them:
637
+
638
+ - **Raise when you delivered none of the batch**, after your own retries are spent. That is the
639
+ signal the worker's bounded retry and `health().failed_batches` depend on, and the one case where
640
+ a retry cannot duplicate anything: nothing landed downstream. Raise `SinkDeliveryError` (from
641
+ `log_foundry.sinks.base`) or any exception of your own — the contract is that *something*
642
+ propagates.
643
+ - **Do not raise when you delivered some of it.** The worker retries whole batches, so raising on a
644
+ partial success re-delivers the records that already arrived, and duplicates downstream are worse
645
+ than a counted loss.
646
+
647
+ A sink that absorbs a total failure and returns normally is a sink the worker believes: the retry
648
+ never engages, `failed_batches` stays at zero, and `flush()` returns `True` while every event is
649
+ lost.
650
+
651
+ Optionally add `losses()` to report what you absorbed. It must never raise and must be safe to call
652
+ while `emit` is running (`health()` is a poll):
653
+
654
+ ```python
655
+ from log_foundry.sinks.base import SinkDeliveryError, SinkLosses
656
+
657
+ class MySink:
658
+ def __init__(self) -> None:
659
+ self._dropped = self._failed = 0
660
+
661
+ def emit(self, batch: list[dict[str, object]]) -> None:
662
+ delivered = 0
663
+ for chunk in self._chunks(batch):
664
+ if self._send(chunk): # your own bounded retry
665
+ delivered += len(chunk)
666
+ else:
667
+ self._failed += len(chunk)
668
+ if batch and not delivered:
669
+ raise SinkDeliveryError(f"MySink delivered none of {len(batch)} event(s)")
670
+
671
+ def losses(self) -> SinkLosses:
672
+ return SinkLosses(dropped=self._dropped, failed=self._failed)
673
+
674
+ def close(self) -> None: ...
675
+ ```
676
+
677
+ `losses()` is optional and probed by name, so a sink written before it existed keeps working and
678
+ simply contributes nothing to `health().sink`. `emit([])` must be a no-op: an empty batch has not
679
+ failed to deliver.
628
680
 
629
681
  ### Flushing and shutdown
630
682
 
@@ -640,17 +692,33 @@ returns a snapshot of the worker's counters:
640
692
 
641
693
  ```python
642
694
  h = log_foundry.health()
643
- if h.dropped or h.failed_batches or h.stopped_reason:
695
+ if h.dropped or h.failed_batches or h.stopped_reason or (h.sink and (h.sink.dropped or h.sink.failed)):
644
696
  ... # logs were silently lost — worth an alert
645
697
  ```
646
698
 
647
- The three tell you different things, and they want different responses:
699
+ They tell you different things, and they want different responses:
648
700
 
649
701
  | Field | Means | What to do |
650
702
  |---|---|---|
651
703
  | `dropped` | The queue filled — the destination is not keeping up. Delivery continues. | Tune `batch_size`/`flush_interval`, or scale the sink. |
652
704
  | `failed_batches` | A sink stayed broken through the whole retry budget. Delivery continues. | Fix the destination. |
653
705
  | `stopped_reason` | The background thread **died** on that exception type. Nothing further will be delivered, ever. | Restart the process; investigate the named exception. |
706
+ | `sink.dropped` | The sink discarded events **before** attempting delivery — an oversized record, or one the client refused outright. | Read the stderr line: it names the cause. An oversized record means shrink what you log; a refused local produce/publish (Kafka, Pub/Sub) points at the client — a saturated buffer, a bad topic, a credential. |
707
+ | `sink.failed` | The sink attempted delivery and could not confirm it — abandoned requests, partially-failed batches, responses it could not adjudicate. | Fix the destination. |
708
+
709
+ `h.sink` is a `SinkLosses(dropped, failed)` or `None` — `None` when no worker exists yet, or when
710
+ the configured sink reports nothing (`losses()` is optional). Note the two `dropped` fields count
711
+ different things: the worker's is backpressure at *its* queue, the sink's is an event that never
712
+ reached the wire. They are separate because the remedies do not overlap — and `sink.dropped` is
713
+ itself two causes, which is why the diagnostic line matters. Most sinks drop only what can never
714
+ fit; `KafkaSink` and `GooglePubSubSink` also count what their client refused outright, which may
715
+ be backpressure one layer further out than the worker's, or may be a misconfiguration. The stderr
716
+ line carries the exception type that distinguishes them.
717
+
718
+ `sink.failed` is an **upper bound** on loss, not a count of it. A sink that raises on total failure
719
+ counts the attempt *and* hands the batch back to the worker, whose retry may then deliver it — so a
720
+ transient outage leaves it non-zero with nothing actually lost. `failed_batches` is the record of a
721
+ batch given up on for good.
654
722
 
655
723
  `stopped_reason` is a type name (e.g. `"SystemExit"`), never the exception's message — a sink's
656
724
  error text can carry event data. It reads `None` for a healthy worker, for a process that has never
@@ -658,9 +726,10 @@ logged, and after a clean `shutdown()`, so a plain truthiness check is safe. Wit
658
726
  thread showed up only indirectly, as `dropped` climbing once the queue filled — the wrong signal,
659
727
  pointing at the wrong fix.
660
728
 
661
- Read a snapshot by attribute (`h.dropped`), as above. `Health` is a `NamedTuple` and gained a
662
- fourth field in `v0.7.0`, so unpacking it whole — `queued, dropped, failed = health()` — raises
663
- `ValueError` from that version on.
729
+ Read a snapshot by attribute (`h.dropped`), as above. `Health` is a `NamedTuple` and has gained
730
+ fields over time — a fourth (`stopped_reason`) in `v0.7.0` and a fifth (`sink`) not yet in a tagged
731
+ release — so unpacking it whole (`queued, dropped, failed = health()`) raises `ValueError`. Every
732
+ field keeps its position when a new one is appended, so attribute and index access stay stable.
664
733
 
665
734
  `dropped` counts submissions discarded because the queue filled; `failed_batches` counts batches
666
735
  abandoned after the retry budget was spent. Overflow also warns on stderr — on the first drop and
@@ -675,9 +744,23 @@ which one you want depends on whether the process is about to end:
675
744
  import log_foundry as lf
676
745
 
677
746
  lf.flush() # drain to the sink and keep going; returns True when everything landed
678
- lf.shutdown() # drain, close the sink, and stop for good; blocks until drained
747
+ lf.shutdown() # drain, close the sink, and stop for good; blocks until drained (30s cap)
679
748
  ```
680
749
 
750
+ Both are bounded, because both can be called somewhere with a deadline. `flush(timeout=5.0)`
751
+ returns `False` if the drain did not complete; `shutdown(timeout=30.0)` returns having stopped
752
+ what it could, and reports `health().stopped_reason == "ShutdownTimeout"`. Passing `None` to
753
+ either waits indefinitely, which is unsafe in any environment with an execution deadline.
754
+
755
+ **What a broken destination can cost you.** There is one drain thread, so a sink's backoff pauses
756
+ *all* log delivery, and it spans `shutdown()`. At the defaults (`max_retries=3`) that is 0.7 s of
757
+ backoff per batch for most sinks (per *message* for the socket-backed ones — ~70 s for a
758
+ 100-message batch against a dead syslog host), and up to 90 s for an HTTP sink whose destination
759
+ is sending
760
+ `Retry-After` — clamped to `max_retry_after=30.0` per wait, which you can lower. Every wait is cut
761
+ short by a shutdown, and `shutdown()`'s own timeout bounds the total either way. Each sink's class
762
+ docstring states its own worst case.
763
+
681
764
  | | `flush()` | `shutdown()` |
682
765
  |---|---|---|
683
766
  | Drains buffered events | yes | yes |
@@ -20,7 +20,7 @@ dependencies = [
20
20
  ]
21
21
 
22
22
  # Optional features. Install with: pip install log-foundry[aws]
23
- version = "0.10.2.dev18"
23
+ version = "0.10.2.dev20"
24
24
 
25
25
  [project.optional-dependencies]
26
26
  aws = ["boto3>=1.43.61"] # SQSSink, SNSSink, KinesisSink, FirehoseSink
@@ -20,7 +20,8 @@ from log_foundry.context import (
20
20
  reset_context,
21
21
  )
22
22
  from log_foundry.decorator import continue_trace, trace
23
- from log_foundry.worker import Health
23
+ from log_foundry.sinks.base import SinkDeliveryError, SinkLosses
24
+ from log_foundry.worker import DEFAULT_SHUTDOWN_TIMEOUT, Health
24
25
 
25
26
  try:
26
27
  # Distribution name ("log-foundry") differs from the import name ("log_foundry").
@@ -59,17 +60,29 @@ def flush(timeout: float | None = 5.0) -> bool:
59
60
  def health() -> Health:
60
61
  """Snapshot the background worker's delivery counters (SPEC-017 FR-005). Never raises.
61
62
 
62
- Returns ``queued`` / ``dropped`` / ``failed_batches`` / ``stopped_reason``. A non-zero
63
- ``dropped`` means the queue filled and submissions were discarded to keep your code
63
+ Returns ``queued`` / ``dropped`` / ``failed_batches`` / ``stopped_reason`` / ``sink``. A
64
+ non-zero ``dropped`` means the queue filled and submissions were discarded to keep your code
64
65
  non-blocking; a non-zero ``failed_batches`` means a sink stayed broken through the whole
65
66
  retry budget. Both are losses the library absorbs on purpose, and this is how you notice
66
67
  them. A non-``None`` ``stopped_reason`` is worse than either: the background thread died
67
68
  on that exception type, so nothing further will be delivered at all (SPEC-019)::
68
69
 
69
70
  h = log_foundry.health()
70
- if h.dropped or h.failed_batches or h.stopped_reason:
71
+ if h.dropped or h.failed_batches or h.stopped_reason or (
72
+ h.sink and (h.sink.dropped or h.sink.failed)
73
+ ):
71
74
  ... # raise an alert; logs were silently lost
72
75
 
76
+ ``sink`` is the configured sink's own :class:`~log_foundry.sinks.base.SinkLosses` — loss the
77
+ *sink* absorbed rather than the worker, which the worker's counters cannot see (SPEC-026).
78
+ It is ``None`` when no worker exists and when the sink reports nothing, since ``losses()`` is
79
+ optional. Its ``dropped`` is not the worker's: the worker's is backpressure at the queue, the
80
+ sink's is an event that never reached the wire — usually one too large to ever fit, and for the
81
+ sinks whose client owns a local buffer (Kafka, Pub/Sub) also what that buffer refused. The
82
+ stderr line names which. Its ``failed`` is an upper bound on loss, not a count of it: a sink
83
+ that raises on total failure counts the attempt *and* hands the batch back, and the worker's
84
+ retry may then deliver it.
85
+
73
86
  A process that has never logged has no worker, and asking after its health does not create
74
87
  one — the snapshot is simply zeroed. Valid after :func:`shutdown`, which leaves the final
75
88
  counters readable.
@@ -79,7 +92,7 @@ def health() -> Health:
79
92
  return _worker_health()
80
93
 
81
94
 
82
- def shutdown() -> None:
95
+ def shutdown(timeout: float | None = DEFAULT_SHUTDOWN_TIMEOUT) -> None:
83
96
  """Flush buffered events and close the sink, blocking until drained. Idempotent.
84
97
 
85
98
  Also registered via ``atexit``; call it explicitly before a fast process exit when you
@@ -88,14 +101,24 @@ def shutdown() -> None:
88
101
  This is **terminal** — the worker does not come back. Do not call it per-invocation in a
89
102
  serverless handler: the first invocation on a warm container would log and every later one
90
103
  would silently log nothing. Use :func:`flush` there, which drains and keeps the worker.
104
+
105
+ ``timeout`` bounds the wait for the background thread (SPEC-027 FR-004). ``None`` waits
106
+ indefinitely, which is what this did unconditionally before and is still available on
107
+ request — but it is unsafe anywhere with an execution deadline, and ``atexit`` is one such
108
+ place: a sink blocked in a network call would hold the process open. An expired shutdown
109
+ reports ``health().stopped_reason == "ShutdownTimeout"`` and leaves the sink **open**, since
110
+ the drain thread may still be inside ``emit``. Never raises.
91
111
  """
92
112
  from log_foundry.decorator import _shutdown_worker
93
113
 
94
- _shutdown_worker()
114
+ _shutdown_worker(timeout)
95
115
 
96
116
 
97
117
  __all__ = [
118
+ "DEFAULT_SHUTDOWN_TIMEOUT",
98
119
  "Health",
120
+ "SinkDeliveryError",
121
+ "SinkLosses",
99
122
  "__version__",
100
123
  "configure",
101
124
  "continue_trace",
@@ -39,7 +39,7 @@ from log_foundry.ids import (
39
39
  parse_traceparent,
40
40
  )
41
41
  from log_foundry.model import Span, backfill_baggage, end_event, start_event
42
- from log_foundry.worker import Health, Worker
42
+ from log_foundry.worker import DEFAULT_SHUTDOWN_TIMEOUT, Health, Worker
43
43
 
44
44
  if TYPE_CHECKING:
45
45
  import contextvars
@@ -209,10 +209,15 @@ def _get_worker() -> Worker:
209
209
  return _worker
210
210
 
211
211
 
212
- def _shutdown_worker() -> None:
213
- """Drain + close the process worker if one was created (idempotent). Backs ``shutdown()``."""
212
+ def _shutdown_worker(timeout: float | None = DEFAULT_SHUTDOWN_TIMEOUT) -> None:
213
+ """Drain + close the process worker if one was created (idempotent). Backs ``shutdown()``.
214
+
215
+ The ``atexit`` registration binds this function, so the exit path gets the bounded form and
216
+ its default (SPEC-027 FR-004) — an unbounded join in an ``atexit`` handler is a process that
217
+ will not exit.
218
+ """
214
219
  if _worker is not None:
215
- _worker.shutdown()
220
+ _worker.shutdown(timeout)
216
221
 
217
222
 
218
223
  def _flush_worker(timeout: float | None = 5.0) -> bool:
@@ -0,0 +1,103 @@
1
+ """Shared retry waiting for the sinks (SPEC-027).
2
+
3
+ Every sink that retries does so by sleeping the one thread that delivers anything. The worker
4
+ owns a single drain thread by design (arch §9), so a sink's backoff is not a local decision — it
5
+ is a global pause on log delivery, and it is held across ``shutdown()``, which joins that thread.
6
+
7
+ Two rules follow, and this module is where both are applied once rather than at fifteen call
8
+ sites:
9
+
10
+ * **A wait is interruptible.** Sinks wait on an ``Event`` rather than calling ``time.sleep``, so a
11
+ shutdown cuts an in-progress backoff short instead of holding the drain thread for its full
12
+ delay. The worker's own backoff already did this (``worker.py``'s ``_stop.wait``); the sinks had
13
+ no access to the signal.
14
+ * **A server-supplied delay is advice, not an instruction.** ``Retry-After`` arrives from the
15
+ destination and went straight to ``time.sleep`` with no ceiling and no sign check: a measured
16
+ ``Retry-After: 8`` with the default ``max_retries=3`` blocked ``shutdown()`` for 22 seconds, a
17
+ header of ``86400`` would stall logging for a day, and a negative one makes ``time.sleep``
18
+ raise — absorbed inside a span, but reaching the caller on the orphan path.
19
+
20
+ This module imports nothing from its own package (``_diag``'s rule, for the same reason: it sits
21
+ below everything that might want it).
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import math
27
+ import time
28
+ from typing import TYPE_CHECKING
29
+
30
+ if TYPE_CHECKING:
31
+ import threading
32
+
33
+ __all__ = ["MAX_WAIT", "clamp_server_delay", "wait"]
34
+
35
+ MAX_WAIT = 86_400.0
36
+ """Hard ceiling on any single wait, in seconds — a day.
37
+
38
+ Not a policy, a totality guard. ``time.sleep`` and ``Event.wait`` both raise ``OverflowError``
39
+ past the platform's ``time_t``, so ``wait(1e18)`` — reachable from a large ``max_retry_after``, or
40
+ from ``0.1 * 2**63`` on an absurd ``max_retries`` — would raise on the drain thread, which is the
41
+ one thing this module exists to prevent. Nothing legitimate waits longer than this, and a caller
42
+ who genuinely wants to is better served by not logging.
43
+ """
44
+
45
+
46
+ def wait(delay: float, stop: threading.Event | None = None) -> None:
47
+ """Wait ``delay`` seconds, returning early if ``stop`` is set (SPEC-027 FR-002).
48
+
49
+ Total. A non-positive or non-finite delay returns immediately rather than raising, so a
50
+ caller that computed one from arithmetic — or from a destination's header — cannot turn a
51
+ backoff into an exception on the drain thread.
52
+
53
+ ``stop`` is the worker's shutdown event when the sink was given one, and ``None`` for a sink
54
+ used standalone, which then waits exactly as it did before this spec. ``Event.wait`` is used
55
+ rather than ``time.sleep`` in both cases: with no event there is nothing to wait on, so the
56
+ ``None`` path falls back to ``time.sleep``.
57
+
58
+ A finite delay larger than :data:`MAX_WAIT` is capped rather than passed through: both
59
+ ``time.sleep`` and ``Event.wait`` raise ``OverflowError`` past the platform's ``time_t``, and
60
+ "total" has to mean total.
61
+
62
+ It does **not** abort an in-flight network call — only the pause between attempts. Cancelling
63
+ a socket mid-write is not something this library attempts.
64
+ """
65
+ if not (delay > 0) or math.isinf(delay):
66
+ return
67
+ delay = min(delay, MAX_WAIT)
68
+ if stop is None:
69
+ time.sleep(delay)
70
+ return
71
+ stop.wait(delay)
72
+
73
+
74
+ def clamp_server_delay(value: float | None, ceiling: float) -> float | None:
75
+ """Bound a server-supplied delay; ``None`` means "fall back to exponential backoff".
76
+
77
+ ``None`` in and ``None`` out is the ordinary case: no ``Retry-After``, or one in HTTP-date
78
+ form that the parser declined. Everything else is validated against what a *delay* can be —
79
+ finite, positive, and no longer than the caller's ceiling.
80
+
81
+ Zero is rejected rather than honoured as "retry now". A destination that is rate-limiting has
82
+ asked us to slow down, and a header saying "wait zero seconds" is far more likely a broken or
83
+ truncated value than a real instruction to hammer it; the exponential backoff is the safer
84
+ reading. ``NaN`` falls out of the same comparison that rejects a negative: every comparison
85
+ against ``NaN`` is ``False``, which is why the test is written ``not (value > 0)`` rather than
86
+ ``value <= 0`` — the latter reads ``False`` for ``NaN``, letting it through to ``min()``,
87
+ which returns it.
88
+
89
+ The ceiling is the caller's, not a constant: a platform that legitimately asks for a
90
+ two-minute pause should be allowed one, and a caller with an execution deadline should be able
91
+ to lower it below the default.
92
+ """
93
+ if value is None or not (value > 0) or math.isinf(value):
94
+ return None
95
+ if not (ceiling > 0):
96
+ # An unusable ceiling is a misconfiguration, not an instruction to stop waiting. Left
97
+ # unchecked, ``min(value, 0)`` returns ``0.0`` — which is *not* ``None``, so the caller
98
+ # reads it as an honoured delay and retries with no backoff at all, and ``min(value,
99
+ # nan)`` returns ``value``, so the ceiling silently vanishes and the header is honoured
100
+ # in full. Both defeat this function in the direction it exists to prevent. Falling back
101
+ # to the sink's own backoff is the same answer a missing header gets.
102
+ return None
103
+ return min(value, ceiling)
@@ -12,9 +12,13 @@ substitute a fake socket without any network access.
12
12
  from __future__ import annotations
13
13
 
14
14
  import socket
15
- import time
15
+ from typing import TYPE_CHECKING
16
+
17
+ if TYPE_CHECKING:
18
+ import threading
16
19
 
17
20
  from log_foundry import _diag
21
+ from log_foundry.sinks._retry import wait
18
22
  from log_foundry.sinks.base import SinkDeliveryError, SinkLosses
19
23
 
20
24
  __all__ = ["SocketTransport"]
@@ -35,6 +39,11 @@ def _make_udp() -> socket.socket:
35
39
  class SocketTransport:
36
40
  """Send pre-framed messages over a TCP or UDP socket, reconnecting on error within a bound.
37
41
 
42
+ **Worst-case delay** (SPEC-027 FR-005): ``max_retries`` waits of ``0.1 * 2**n`` *per
43
+ message* — 0.7 s per message at the default 3, so a 100-message batch against a dead
44
+ destination is ~70 s of backoff on the single drain thread. The wait is interruptible, so
45
+ ``shutdown()`` cuts it short.
46
+
38
47
  Attributes:
39
48
  failed: Messages abandoned past the reconnect-retry bound.
40
49
  """
@@ -59,6 +68,8 @@ class SocketTransport:
59
68
  self._max_retries = max(max_retries, 0)
60
69
  self._sock: socket.socket | None = None
61
70
  self.failed = 0
71
+ # Set by the worker through the owning sink (SPEC-027 FR-002).
72
+ self.stop_signal: threading.Event | None = None
62
73
 
63
74
  def send_all(self, messages: list[bytes]) -> None:
64
75
  """Send each pre-framed message, reconnecting on error (FR-005, FR-006).
@@ -102,7 +113,7 @@ class SocketTransport:
102
113
  except OSError as err:
103
114
  self._reset() # force a fresh connection on the next attempt
104
115
  if attempt < self._max_retries:
105
- time.sleep(_BACKOFF_BASE * (2**attempt))
116
+ wait(_BACKOFF_BASE * (2**attempt), self.stop_signal)
106
117
  continue
107
118
  self.failed += 1
108
119
  # Guarded now (SPEC-029 FR-003): this runs on the worker thread, and the bare
@@ -41,10 +41,12 @@ class SinkDeliveryError(Exception):
41
41
  class SinkLosses(NamedTuple):
42
42
  """What a sink discarded or could not confirm, cumulative for its lifetime (SPEC-026 FR-002).
43
43
 
44
- Two fields rather than one because the remedies differ: ``dropped`` means the destination
45
- could never have accepted the event as built (an oversized record), so the fix is upstream in
46
- what the application logs; ``failed`` means delivery was attempted and the destination did not
47
- confirm it, so the fix is the destination or the network.
44
+ Two fields rather than one because the remedies differ. ``dropped`` is an event the sink
45
+ discarded *before* attempting delivery usually one the destination could never have accepted
46
+ as built (an oversized record), so the fix is upstream in what the application logs; for a sink
47
+ whose client owns a local buffer it also covers what that buffer refused, which is
48
+ backpressure. The stderr line names which. ``failed`` means delivery was attempted and the
49
+ destination did not confirm it, so the fix is the destination or the network.
48
50
 
49
51
  ``failed`` is an **upper bound** on loss, not a count of it. A sink that also raises on total
50
52
  failure counts the attempt here *and* hands the batch back to the worker, whose retry may