log-foundry 0.10.2.dev18__tar.gz → 0.10.2.dev19__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 (54) hide show
  1. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev19}/PKG-INFO +84 -15
  2. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev19}/README.md +83 -14
  3. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev19}/pyproject.toml +1 -1
  4. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev19}/src/log_foundry/__init__.py +18 -3
  5. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev19}/src/log_foundry/sinks/base.py +6 -4
  6. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev19}/src/log_foundry/sinks/pubsub.py +6 -2
  7. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev19}/src/log_foundry/worker.py +3 -3
  8. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev19}/LICENSE +0 -0
  9. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev19}/src/log_foundry/_diag.py +0 -0
  10. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev19}/src/log_foundry/api.py +0 -0
  11. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev19}/src/log_foundry/config.py +0 -0
  12. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev19}/src/log_foundry/console.py +0 -0
  13. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev19}/src/log_foundry/context.py +0 -0
  14. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev19}/src/log_foundry/decorator.py +0 -0
  15. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev19}/src/log_foundry/ids.py +0 -0
  16. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev19}/src/log_foundry/model.py +0 -0
  17. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev19}/src/log_foundry/py.typed +0 -0
  18. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev19}/src/log_foundry/sanitize.py +0 -0
  19. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev19}/src/log_foundry/sinks/__init__.py +0 -0
  20. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev19}/src/log_foundry/sinks/_batch.py +0 -0
  21. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev19}/src/log_foundry/sinks/_chunk.py +0 -0
  22. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev19}/src/log_foundry/sinks/_socket.py +0 -0
  23. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev19}/src/log_foundry/sinks/_time.py +0 -0
  24. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev19}/src/log_foundry/sinks/callback.py +0 -0
  25. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev19}/src/log_foundry/sinks/clickhouse.py +0 -0
  26. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev19}/src/log_foundry/sinks/datadog.py +0 -0
  27. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev19}/src/log_foundry/sinks/elasticsearch.py +0 -0
  28. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev19}/src/log_foundry/sinks/eventhubs.py +0 -0
  29. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev19}/src/log_foundry/sinks/file.py +0 -0
  30. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev19}/src/log_foundry/sinks/filtering.py +0 -0
  31. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev19}/src/log_foundry/sinks/firehose.py +0 -0
  32. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev19}/src/log_foundry/sinks/honeycomb.py +0 -0
  33. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev19}/src/log_foundry/sinks/http.py +0 -0
  34. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev19}/src/log_foundry/sinks/kafka.py +0 -0
  35. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev19}/src/log_foundry/sinks/kinesis.py +0 -0
  36. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev19}/src/log_foundry/sinks/logging_sink.py +0 -0
  37. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev19}/src/log_foundry/sinks/logstash.py +0 -0
  38. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev19}/src/log_foundry/sinks/loki.py +0 -0
  39. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev19}/src/log_foundry/sinks/mongodb.py +0 -0
  40. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev19}/src/log_foundry/sinks/multi.py +0 -0
  41. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev19}/src/log_foundry/sinks/nats.py +0 -0
  42. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev19}/src/log_foundry/sinks/newrelic.py +0 -0
  43. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev19}/src/log_foundry/sinks/postgres.py +0 -0
  44. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev19}/src/log_foundry/sinks/rabbitmq.py +0 -0
  45. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev19}/src/log_foundry/sinks/redis.py +0 -0
  46. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev19}/src/log_foundry/sinks/sentry.py +0 -0
  47. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev19}/src/log_foundry/sinks/sns.py +0 -0
  48. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev19}/src/log_foundry/sinks/splunk.py +0 -0
  49. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev19}/src/log_foundry/sinks/sqlite.py +0 -0
  50. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev19}/src/log_foundry/sinks/sqs.py +0 -0
  51. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev19}/src/log_foundry/sinks/stdout.py +0 -0
  52. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev19}/src/log_foundry/sinks/syslog.py +0 -0
  53. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev19}/src/log_foundry/sinks/transform.py +0 -0
  54. {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev19}/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.dev19
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
@@ -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
@@ -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.dev19"
24
24
 
25
25
  [project.optional-dependencies]
26
26
  aws = ["boto3>=1.43.61"] # SQSSink, SNSSink, KinesisSink, FirehoseSink
@@ -20,6 +20,7 @@ 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.sinks.base import SinkDeliveryError, SinkLosses
23
24
  from log_foundry.worker import Health
24
25
 
25
26
  try:
@@ -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.
@@ -96,6 +109,8 @@ def shutdown() -> None:
96
109
 
97
110
  __all__ = [
98
111
  "Health",
112
+ "SinkDeliveryError",
113
+ "SinkLosses",
99
114
  "__version__",
100
115
  "configure",
101
116
  "continue_trace",
@@ -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
@@ -55,7 +55,9 @@ class GooglePubSubSink:
55
55
  future = self.client.publish(self.topic, data=json.dumps(event).encode("utf-8"))
56
56
  except Exception as err: # isolation boundary: one event must not fail the batch
57
57
  self.rejected += 1
58
- _diag.lost("event", 1, f"GooglePubSubSink publish, {type(err).__name__}")
58
+ # "refused" vs "unconfirmed" below: the two counters mean different things and
59
+ # the line is the only place an operator sees which one moved.
60
+ _diag.lost("event", 1, f"GooglePubSubSink refused the publish, {type(err).__name__}")
59
61
  continue
60
62
  self._futures.append(future)
61
63
  published += 1
@@ -71,5 +73,7 @@ class GooglePubSubSink:
71
73
  future.result()
72
74
  except Exception as err: # isolation boundary: never crash the worker (FR-011)
73
75
  self.failed += 1
74
- _diag.lost("event", 1, f"GooglePubSubSink publish, {type(err).__name__}")
76
+ _diag.lost(
77
+ "event", 1, f"GooglePubSubSink publish unconfirmed, {type(err).__name__}"
78
+ )
75
79
  self._futures.clear()
@@ -56,9 +56,9 @@ class Health(NamedTuple):
56
56
  the worker is gone and nothing further will be delivered (SPEC-019 FR-003).
57
57
  sink: The configured sink's own loss counters, or ``None`` when there is no worker or
58
58
  the sink reports nothing (SPEC-026 FR-003). Nested rather than folded into the two
59
- integers above because they mean different things: ``dropped`` here is backpressure
60
- at the queue, ``dropped`` on the sink is an event the destination could never
61
- accept, and one number would make the remedies indistinguishable.
59
+ integers above because they count different things: ``dropped`` here is backpressure
60
+ at *this* queue, ``dropped`` on the sink is an event that never reached the wire,
61
+ and one number would make the remedies indistinguishable.
62
62
  """
63
63
 
64
64
  queued: int