log-foundry 0.10.2.dev24__tar.gz → 0.10.2.dev26__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.dev24 → log_foundry-0.10.2.dev26}/PKG-INFO +53 -4
  2. {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/README.md +52 -3
  3. {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/pyproject.toml +1 -1
  4. {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/__init__.py +23 -5
  5. {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/config.py +52 -1
  6. {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/decorator.py +34 -1
  7. {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/_socket.py +16 -2
  8. {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/clickhouse.py +10 -5
  9. {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/eventhubs.py +36 -1
  10. {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/file.py +16 -2
  11. {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/firehose.py +4 -0
  12. {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/kafka.py +4 -0
  13. {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/kinesis.py +4 -0
  14. {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/mongodb.py +28 -7
  15. {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/nats.py +21 -7
  16. {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/postgres.py +5 -1
  17. {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/pubsub.py +21 -4
  18. {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/rabbitmq.py +33 -10
  19. {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/redis.py +7 -0
  20. {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/sentry.py +5 -0
  21. {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/sns.py +4 -0
  22. {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/sqlite.py +37 -4
  23. {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/sqs.py +7 -0
  24. {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/worker.py +241 -8
  25. {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/LICENSE +0 -0
  26. {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/_diag.py +0 -0
  27. {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/api.py +0 -0
  28. {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/console.py +0 -0
  29. {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/context.py +0 -0
  30. {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/ids.py +0 -0
  31. {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/model.py +0 -0
  32. {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/py.typed +0 -0
  33. {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sanitize.py +0 -0
  34. {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/__init__.py +0 -0
  35. {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/_batch.py +0 -0
  36. {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/_chunk.py +0 -0
  37. {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/_retry.py +0 -0
  38. {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/_time.py +0 -0
  39. {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/base.py +0 -0
  40. {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/callback.py +0 -0
  41. {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/datadog.py +0 -0
  42. {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/elasticsearch.py +0 -0
  43. {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/filtering.py +0 -0
  44. {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/honeycomb.py +0 -0
  45. {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/http.py +0 -0
  46. {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/logging_sink.py +0 -0
  47. {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/logstash.py +0 -0
  48. {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/loki.py +0 -0
  49. {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/multi.py +0 -0
  50. {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/newrelic.py +0 -0
  51. {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/splunk.py +0 -0
  52. {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/stdout.py +0 -0
  53. {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/syslog.py +0 -0
  54. {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/transform.py +0 -0
  55. {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/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.dev24
3
+ Version: 0.10.2.dev26
4
4
  Summary: Generate logs for your console and JSON events for downstream consumption.
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -234,6 +234,34 @@ lf.configure(
234
234
  If you never set a `sink`, the first decorated call falls back to `StdoutSink()`, so `@trace`
235
235
  works with zero configuration.
236
236
 
237
+ **Passing `sink=` after logging has started swaps the live destination.** The background worker
238
+ captures its sink when it is built, so a later `configure(sink=...)` has to do more than update
239
+ what `get_config()` reports — otherwise the config and the behaviour disagree silently, which is
240
+ what it used to do. The swap drains everything submitted so far to the **previous** sink, closes
241
+ it, and points the worker at the new one:
242
+
243
+ ```python
244
+ lf.configure(sink=StdoutSink())
245
+ do_some_work() # these events go to stdout
246
+
247
+ lf.configure(sink=SQSSink(queue_url=QUEUE_URL))
248
+ do_some_more_work() # these go to SQS; the earlier ones were drained to stdout
249
+ ```
250
+
251
+ The drain is bounded at 5 s. If it cannot be confirmed in that time the swap still takes effect —
252
+ you asked for that sink — but the previous sink is left **open** rather than closed, because the
253
+ drain thread may still be inside its `emit`, and `health().incomplete_swaps` records it. Passing
254
+ the sink that is already live is a no-op: no drain, no close. The previous sink **is** closed, so
255
+ do not hand it back to a later call.
256
+
257
+ The 5 s covers the drain, **not** the previous sink's `close()`, which has no timeout of its own —
258
+ a destination that blocks there blocks `configure()`. `KafkaSink.close()` flushes its producer, so
259
+ an unreachable broker is the case to watch. Configure the sink before the first log where you can;
260
+ that path has no worker to retarget and nothing to close.
261
+
262
+ `configure()` is still a startup call. It is not thread-safe, and a span finishing on another
263
+ thread mid-swap may land on either sink.
264
+
237
265
  ### `@trace`
238
266
 
239
267
  Decorate any **synchronous** function. Usable bare or with arguments:
@@ -746,7 +774,11 @@ returns a snapshot of the worker's counters:
746
774
 
747
775
  ```python
748
776
  h = log_foundry.health()
749
- if h.dropped or h.failed_batches or h.stopped_reason or (h.sink and (h.sink.dropped or h.sink.failed)):
777
+ if (
778
+ h.dropped or h.failed_batches or h.stopped_reason or h.incomplete_swaps
779
+ or (h.sink and (h.sink.dropped or h.sink.failed))
780
+ or (h.retired and h.submitted_after_shutdown)
781
+ ):
750
782
  ... # logs were silently lost — worth an alert
751
783
  ```
752
784
 
@@ -759,6 +791,8 @@ They tell you different things, and they want different responses:
759
791
  | `stopped_reason` | The background thread **died** on that exception type. Nothing further will be delivered, ever. | Restart the process; investigate the named exception. |
760
792
  | `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. |
761
793
  | `sink.failed` | The sink attempted delivery and could not confirm it — abandoned requests, partially-failed batches, responses it could not adjudicate. | Fix the destination. |
794
+ | `retired` + `submitted_after_shutdown` | `shutdown()` was called and the process **kept logging**. Those events are queued where nothing will drain them — total loss, for as long as the process runs. | Use `flush()`, not `shutdown()`, in a process that logs again. This is the serverless mistake below. |
795
+ | `incomplete_swaps` | A late `configure(sink=...)` could not confirm the previous sink was drained. The swap took effect; that sink was left open and some queued events may have gone to the new one. | Investigate the previous sink — it was hung or failing. Configure the sink before the first log where you can. |
762
796
 
763
797
  `h.sink` is a `SinkLosses(dropped, failed)` or `None` — `None` when no worker exists yet, or when
764
798
  the configured sink reports nothing (`losses()` is optional). Note the two `dropped` fields count
@@ -780,8 +814,14 @@ logged, and after a clean `shutdown()`, so a plain truthiness check is safe. Wit
780
814
  thread showed up only indirectly, as `dropped` climbing once the queue filled — the wrong signal,
781
815
  pointing at the wrong fix.
782
816
 
817
+ `retired` is deliberately **not** alerted on by itself. A process that shuts down and then stops
818
+ logging is doing the right thing; it is the *pair* — retired, and still being handed events — that
819
+ means every log line since the shutdown has gone nowhere. That state used to read as perfectly
820
+ healthy: `stopped_reason` is `None` after a clean shutdown, and the queue simply grows.
821
+
783
822
  Read a snapshot by attribute (`h.dropped`), as above. `Health` is a `NamedTuple` and has gained
784
- fields over time — a fourth (`stopped_reason`) in `v0.7.0` and a fifth (`sink`) not yet in a tagged
823
+ fields over time — a fourth (`stopped_reason`) in `v0.7.0`, and a fifth (`sink`) plus a sixth,
824
+ seventh and eighth (`retired`, `submitted_after_shutdown`, `incomplete_swaps`) not yet in a tagged
785
825
  release — so unpacking it whole (`queued, dropped, failed = health()`) raises `ValueError`. Every
786
826
  field keeps its position when a new one is appended, so attribute and index access stay stable.
787
827
 
@@ -859,6 +899,13 @@ getting them wrong is silent:
859
899
  the first invocation on a warm container would log and every later one would silently log
860
900
  nothing. That failure reads as "works locally, broken in production".
861
901
 
902
+ It is no longer silent. Logging after `shutdown()` is accepted and undeliverable, and
903
+ `health()` says so: **`retired` is `True` and `submitted_after_shutdown` is non-zero** — that
904
+ pair, and only that pair, is this mistake. The first such submission also writes one stderr
905
+ line naming `flush()` as the remedy, throttled to the first and every thousandth after it.
906
+ `stopped_reason` stays `None` throughout, because nothing failed; someone used the terminal
907
+ drain where the repeatable one belonged.
908
+
862
909
  ```python
863
910
  import log_foundry as lf
864
911
  from log_foundry.sinks.sqs import SQSSink
@@ -876,9 +923,11 @@ def handler(event, context):
876
923
  # would log nothing.
877
924
  drained = lf.flush()
878
925
  h = lf.health()
879
- if not drained or h.failed_batches or h.dropped or h.stopped_reason:
926
+ if not drained or h.failed_batches or h.dropped or h.stopped_reason or h.retired:
880
927
  # `drained` covers this invocation's tail; the counters cover anything the worker
881
928
  # lost earlier — a batch its own interval trigger already gave up on, for instance.
929
+ # `h.retired` catches the mistake above: inside a handler it can only mean something
930
+ # called shutdown(), and from here on this container logs nothing.
882
931
  # Emitting this through your platform's own logger keeps it outside the pipeline
883
932
  # that just failed.
884
933
  print(f"log-foundry: undelivered logs ({drained=}, {h=})")
@@ -198,6 +198,34 @@ lf.configure(
198
198
  If you never set a `sink`, the first decorated call falls back to `StdoutSink()`, so `@trace`
199
199
  works with zero configuration.
200
200
 
201
+ **Passing `sink=` after logging has started swaps the live destination.** The background worker
202
+ captures its sink when it is built, so a later `configure(sink=...)` has to do more than update
203
+ what `get_config()` reports — otherwise the config and the behaviour disagree silently, which is
204
+ what it used to do. The swap drains everything submitted so far to the **previous** sink, closes
205
+ it, and points the worker at the new one:
206
+
207
+ ```python
208
+ lf.configure(sink=StdoutSink())
209
+ do_some_work() # these events go to stdout
210
+
211
+ lf.configure(sink=SQSSink(queue_url=QUEUE_URL))
212
+ do_some_more_work() # these go to SQS; the earlier ones were drained to stdout
213
+ ```
214
+
215
+ The drain is bounded at 5 s. If it cannot be confirmed in that time the swap still takes effect —
216
+ you asked for that sink — but the previous sink is left **open** rather than closed, because the
217
+ drain thread may still be inside its `emit`, and `health().incomplete_swaps` records it. Passing
218
+ the sink that is already live is a no-op: no drain, no close. The previous sink **is** closed, so
219
+ do not hand it back to a later call.
220
+
221
+ The 5 s covers the drain, **not** the previous sink's `close()`, which has no timeout of its own —
222
+ a destination that blocks there blocks `configure()`. `KafkaSink.close()` flushes its producer, so
223
+ an unreachable broker is the case to watch. Configure the sink before the first log where you can;
224
+ that path has no worker to retarget and nothing to close.
225
+
226
+ `configure()` is still a startup call. It is not thread-safe, and a span finishing on another
227
+ thread mid-swap may land on either sink.
228
+
201
229
  ### `@trace`
202
230
 
203
231
  Decorate any **synchronous** function. Usable bare or with arguments:
@@ -710,7 +738,11 @@ returns a snapshot of the worker's counters:
710
738
 
711
739
  ```python
712
740
  h = log_foundry.health()
713
- if h.dropped or h.failed_batches or h.stopped_reason or (h.sink and (h.sink.dropped or h.sink.failed)):
741
+ if (
742
+ h.dropped or h.failed_batches or h.stopped_reason or h.incomplete_swaps
743
+ or (h.sink and (h.sink.dropped or h.sink.failed))
744
+ or (h.retired and h.submitted_after_shutdown)
745
+ ):
714
746
  ... # logs were silently lost — worth an alert
715
747
  ```
716
748
 
@@ -723,6 +755,8 @@ They tell you different things, and they want different responses:
723
755
  | `stopped_reason` | The background thread **died** on that exception type. Nothing further will be delivered, ever. | Restart the process; investigate the named exception. |
724
756
  | `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. |
725
757
  | `sink.failed` | The sink attempted delivery and could not confirm it — abandoned requests, partially-failed batches, responses it could not adjudicate. | Fix the destination. |
758
+ | `retired` + `submitted_after_shutdown` | `shutdown()` was called and the process **kept logging**. Those events are queued where nothing will drain them — total loss, for as long as the process runs. | Use `flush()`, not `shutdown()`, in a process that logs again. This is the serverless mistake below. |
759
+ | `incomplete_swaps` | A late `configure(sink=...)` could not confirm the previous sink was drained. The swap took effect; that sink was left open and some queued events may have gone to the new one. | Investigate the previous sink — it was hung or failing. Configure the sink before the first log where you can. |
726
760
 
727
761
  `h.sink` is a `SinkLosses(dropped, failed)` or `None` — `None` when no worker exists yet, or when
728
762
  the configured sink reports nothing (`losses()` is optional). Note the two `dropped` fields count
@@ -744,8 +778,14 @@ logged, and after a clean `shutdown()`, so a plain truthiness check is safe. Wit
744
778
  thread showed up only indirectly, as `dropped` climbing once the queue filled — the wrong signal,
745
779
  pointing at the wrong fix.
746
780
 
781
+ `retired` is deliberately **not** alerted on by itself. A process that shuts down and then stops
782
+ logging is doing the right thing; it is the *pair* — retired, and still being handed events — that
783
+ means every log line since the shutdown has gone nowhere. That state used to read as perfectly
784
+ healthy: `stopped_reason` is `None` after a clean shutdown, and the queue simply grows.
785
+
747
786
  Read a snapshot by attribute (`h.dropped`), as above. `Health` is a `NamedTuple` and has gained
748
- fields over time — a fourth (`stopped_reason`) in `v0.7.0` and a fifth (`sink`) not yet in a tagged
787
+ fields over time — a fourth (`stopped_reason`) in `v0.7.0`, and a fifth (`sink`) plus a sixth,
788
+ seventh and eighth (`retired`, `submitted_after_shutdown`, `incomplete_swaps`) not yet in a tagged
749
789
  release — so unpacking it whole (`queued, dropped, failed = health()`) raises `ValueError`. Every
750
790
  field keeps its position when a new one is appended, so attribute and index access stay stable.
751
791
 
@@ -823,6 +863,13 @@ getting them wrong is silent:
823
863
  the first invocation on a warm container would log and every later one would silently log
824
864
  nothing. That failure reads as "works locally, broken in production".
825
865
 
866
+ It is no longer silent. Logging after `shutdown()` is accepted and undeliverable, and
867
+ `health()` says so: **`retired` is `True` and `submitted_after_shutdown` is non-zero** — that
868
+ pair, and only that pair, is this mistake. The first such submission also writes one stderr
869
+ line naming `flush()` as the remedy, throttled to the first and every thousandth after it.
870
+ `stopped_reason` stays `None` throughout, because nothing failed; someone used the terminal
871
+ drain where the repeatable one belonged.
872
+
826
873
  ```python
827
874
  import log_foundry as lf
828
875
  from log_foundry.sinks.sqs import SQSSink
@@ -840,9 +887,11 @@ def handler(event, context):
840
887
  # would log nothing.
841
888
  drained = lf.flush()
842
889
  h = lf.health()
843
- if not drained or h.failed_batches or h.dropped or h.stopped_reason:
890
+ if not drained or h.failed_batches or h.dropped or h.stopped_reason or h.retired:
844
891
  # `drained` covers this invocation's tail; the counters cover anything the worker
845
892
  # lost earlier — a batch its own interval trigger already gave up on, for instance.
893
+ # `h.retired` catches the mistake above: inside a handler it can only mean something
894
+ # called shutdown(), and from here on this container logs nothing.
846
895
  # Emitting this through your platform's own logger keeps it outside the pipeline
847
896
  # that just failed.
848
897
  print(f"log-foundry: undelivered logs ({drained=}, {h=})")
@@ -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.dev24"
23
+ version = "0.10.2.dev26"
24
24
 
25
25
  [project.optional-dependencies]
26
26
  aws = ["boto3>=1.43.61"] # SQSSink, SNSSink, KinesisSink, FirehoseSink
@@ -58,22 +58,35 @@ def health() -> Health:
58
58
  code non-blocking, and a non-zero ``failed_batches`` means a sink stayed broken through the
59
59
  whole retry budget — both are losses the library absorbs on purpose, and this is how you
60
60
  notice them. A non-``None`` ``stopped_reason`` is worse than either: the background thread
61
- died on that exception type, so nothing further will be delivered at all (SPEC-019)::
61
+ died on that exception type, so nothing further will be delivered at all (SPEC-019). A
62
+ ``retired`` worker still being handed events is the same total loss arrived at from the
63
+ other direction — someone called :func:`shutdown` in a process that logs again (SPEC-030)::
62
64
 
63
65
  h = log_foundry.health()
64
- if h.dropped or h.failed_batches or h.stopped_reason or (
66
+ if h.dropped or h.failed_batches or h.stopped_reason or h.incomplete_swaps or (
65
67
  h.sink and (h.sink.dropped or h.sink.failed)
66
- ):
68
+ ) or (h.retired and h.submitted_after_shutdown):
67
69
  ... # raise an alert; logs were silently lost
68
70
 
71
+ ``retired`` alone is not a fault — a process that shuts down and then stops logging is
72
+ doing the right thing, which is why it is paired with the count rather than alerted on.
73
+
69
74
  Args:
70
75
  None.
71
76
 
72
77
  Returns:
73
- The snapshot: ``queued``, ``dropped``, ``failed_batches``, ``stopped_reason`` and
74
- ``sink``. The last is the configured sink's own
78
+ The snapshot: ``queued``, ``dropped``, ``failed_batches``, ``stopped_reason``, ``sink``,
79
+ ``retired``, ``submitted_after_shutdown`` and ``incomplete_swaps``. ``retired`` says
80
+ :func:`shutdown` was called, and ``submitted_after_shutdown`` counts events accepted
81
+ afterwards, which are queued where nothing will drain them — non-zero together, that is
82
+ the ``shutdown()``-per-invocation mistake, and the remedy is :func:`flush`.
83
+ ``incomplete_swaps`` counts late ``configure(sink=...)`` calls whose drain of the
84
+ previous sink could not be confirmed, leaving that sink open. ``sink`` is the configured
85
+ sink's own
75
86
  :class:`~log_foundry.sinks.base.SinkLosses` — loss the sink absorbed rather than the
76
87
  worker (SPEC-026) — and is ``None`` when no worker exists or the sink reports nothing.
88
+ Note ``sink`` describes whichever sink is live now, so a swap takes the previous sink's
89
+ absorbed losses out of the snapshot with it.
77
90
  Its ``dropped`` is not the worker's: the worker's is backpressure at the queue, the
78
91
  sink's is an event that never reached the wire, and the stderr line names which. Its
79
92
  ``failed`` is an upper bound on loss rather than a count of it, since a sink that raises
@@ -98,6 +111,11 @@ def shutdown(timeout: float | None = DEFAULT_SHUTDOWN_TIMEOUT) -> None:
98
111
  registered via ``atexit``, so call it explicitly only when you want to be certain the tail
99
112
  of the queue reached the sink before a fast process exit (SPEC-004 FR-005).
100
113
 
114
+ Logging afterwards is **accepted and undeliverable**: those events are queued and nothing
115
+ will drain them. It is not silent any more — :func:`health` then reports ``retired`` with a
116
+ non-zero ``submitted_after_shutdown``, and the first such submission writes one stderr line
117
+ (SPEC-030). That pair is the reading that catches the mistake above.
118
+
101
119
  Args:
102
120
  timeout: Seconds bounding the wait for the background thread (SPEC-027 FR-004). ``None``
103
121
  waits indefinitely, which is what this did unconditionally before and is still
@@ -76,11 +76,31 @@ def configure(
76
76
  the zero-dependency dev default (arch §8). Every ceiling is validated before anything is
77
77
  assigned, so a rejected call leaves the config exactly as it found it.
78
78
 
79
+ A ``sink=`` passed after logging has already started is the one argument that needs more
80
+ than an assignment, because the background worker captured its sink when it was built
81
+ (arch §7). It **swaps the live delivery target**: everything submitted so far is drained to
82
+ the previous sink, that sink is closed, and subsequent events go to the new one. The drains
83
+ are bounded, and a swap whose drain could not be confirmed leaves the previous sink open and
84
+ records ``health().incomplete_swaps`` (SPEC-030 FR-003) — so "repeated calls compose rather
85
+ than reset" holds for the sink too, at the cost of one bounded wait. Passing the sink that
86
+ is already live is a no-op: no drain, no close. The previous sink is closed and must not be
87
+ handed back to a later call.
88
+
89
+ The *closing* of the previous sink is not bounded, because ``Sink.close`` takes no timeout —
90
+ a destination that blocks in ``close()`` blocks this call. It is the same gap
91
+ ``architecture.md`` §13 already records for ``shutdown()``, and it has the same fix, which
92
+ is a change to the sink contract rather than to this function.
93
+
94
+ This is still a startup call. It is not thread-safe, and a span finishing on another thread
95
+ during a swap may land on either sink.
96
+
79
97
  Args:
80
98
  service: The service name stamped onto every event.
81
99
  version: The service version stamped onto every event.
82
100
  env: The deployment environment stamped onto every event.
83
- sink: The destination every event is delivered to.
101
+ sink: The destination every event is delivered to. Passed after the first log, it swaps
102
+ the live target as described above rather than only updating what ``get_config()``
103
+ reports.
84
104
  defaults: Fields merged into every event at the lowest precedence.
85
105
  max_value_bytes: Per-value ceiling, in UTF-8 bytes or rendered digits.
86
106
  max_stack_bytes: Ceiling for ``error.stack`` alone.
@@ -119,6 +139,37 @@ def configure(
119
139
 
120
140
  _ensure_sink()
121
141
 
142
+ if sink is not None:
143
+ _swap_live_sink(sink)
144
+
145
+
146
+ def _swap_live_sink(sink: Sink) -> None:
147
+ """Points an already-running worker at a newly configured sink (SPEC-030 FR-003).
148
+
149
+ The import is local for the reason ``_ensure_sink``'s is: ``decorator`` imports this module
150
+ at module scope, so reaching back the other way at import time would be a cycle. It also
151
+ keeps the dependency to the one call that needs it — ``configure()`` without a ``sink=``
152
+ never touches the worker at all.
153
+
154
+ The budget is passed explicitly rather than left to the parameter default, so that the
155
+ bound this call actually applies is resolved when it runs. A default argument is bound at
156
+ definition time, which would leave the end-to-end bound untestable without reaching past
157
+ the function under test.
158
+
159
+ Args:
160
+ sink: The sink just written to the config.
161
+
162
+ Returns:
163
+ None.
164
+
165
+ Raises:
166
+ None.
167
+ """
168
+ from log_foundry.decorator import _swap_sink
169
+ from log_foundry.worker import DEFAULT_SWAP_TIMEOUT
170
+
171
+ _swap_sink(sink, DEFAULT_SWAP_TIMEOUT)
172
+
122
173
 
123
174
  def get_config() -> Config:
124
175
  """Returns the current global config singleton.
@@ -20,11 +20,13 @@ from log_foundry.ids import (
20
20
  parse_traceparent,
21
21
  )
22
22
  from log_foundry.model import Span, backfill_baggage, end_event, start_event
23
- from log_foundry.worker import DEFAULT_SHUTDOWN_TIMEOUT, Health, Worker
23
+ from log_foundry.worker import DEFAULT_SHUTDOWN_TIMEOUT, DEFAULT_SWAP_TIMEOUT, Health, Worker
24
24
 
25
25
  if TYPE_CHECKING:
26
26
  import contextvars
27
27
 
28
+ from log_foundry.sinks.base import Sink
29
+
28
30
  __all__ = ["continue_trace", "trace"]
29
31
 
30
32
  _worker: Worker | None = None
@@ -231,6 +233,37 @@ def _shutdown_worker(timeout: float | None = DEFAULT_SHUTDOWN_TIMEOUT) -> None:
231
233
  _worker.shutdown(timeout)
232
234
 
233
235
 
236
+ def _swap_sink(new_sink: Sink, timeout: float | None = DEFAULT_SWAP_TIMEOUT) -> None:
237
+ """Retargets the process worker at a new sink, backing a late ``configure(sink=...)``.
238
+
239
+ Like :func:`_flush_worker` this deliberately does not call :func:`_get_worker`: a process
240
+ that has not logged has captured no sink, so there is nothing to swap and building a thread
241
+ to prove it would be pure cost — that is also the case where the old behaviour was already
242
+ correct (SPEC-030 FR-003).
243
+
244
+ Args:
245
+ new_sink: The sink already written to the config, to be made the live delivery target.
246
+ timeout: Seconds bounding the drain of the previous sink.
247
+
248
+ Returns:
249
+ None.
250
+
251
+ Raises:
252
+ None. This runs inside ``configure()``, which has never raised for anything but a
253
+ rejected ceiling, and a sink swap that fails must not become the reason an application
254
+ cannot start.
255
+ """
256
+ worker = _worker
257
+ if worker is None:
258
+ return
259
+ try:
260
+ worker.swap_sink(new_sink, timeout)
261
+ except Exception as exc:
262
+ _diag.absorbed(
263
+ "swapping the log sink", exc, "events may still be delivered to the previous sink"
264
+ )
265
+
266
+
234
267
  def _flush_worker(timeout: float | None = 5.0) -> bool:
235
268
  """Drains the process worker without retiring it, backing ``flush()`` (SPEC-013 FR-003).
236
269
 
@@ -111,6 +111,7 @@ class SocketTransport:
111
111
  self._counter_lock = threading.Lock()
112
112
  self.stop_signal: threading.Event | None = None
113
113
  self._lock = threading.Lock()
114
+ self._closed = False
114
115
 
115
116
  def send_all(self, messages: list[bytes]) -> None:
116
117
  """Sends each pre-framed message, reconnecting on error (FR-005, FR-006).
@@ -119,6 +120,11 @@ class SocketTransport:
119
120
  reconnect rebinds, so releasing between messages would let another thread send on a
120
121
  socket this one is about to reset (SPEC-028 FR-002).
121
122
 
123
+ A closed transport refuses rather than reconnecting. ``_socket`` opens a connection
124
+ whenever it holds none and ``close`` only drops the one it has, so without this a single
125
+ ``log_foundry.info()`` after ``shutdown()`` would open a TCP connection nothing will ever
126
+ reap — measured, and the same leak ``RabbitMQSink`` had (SPEC-028).
127
+
122
128
  Args:
123
129
  messages: The exact bytes to put on the wire, one call per message.
124
130
 
@@ -130,14 +136,21 @@ class SocketTransport:
130
136
  this transport propagate a dead destination to the worker instead of reporting
131
137
  success (SPEC-026 FR-001). A partial send does not raise, because the worker's retry
132
138
  would re-send the messages that already landed, and an empty call is a no-op rather
133
- than a total failure.
139
+ than a total failure. Also when the transport is already closed.
134
140
  """
141
+ if not messages:
142
+ return
135
143
  with self._lock:
144
+ if self._closed:
145
+ raise SinkDeliveryError(
146
+ f"SocketTransport delivered none of {len(messages)} message(s): "
147
+ f"the transport is closed"
148
+ )
136
149
  delivered = 0
137
150
  for message in messages:
138
151
  if self._send_one(message):
139
152
  delivered += 1
140
- if messages and delivered == 0:
153
+ if delivered == 0:
141
154
  raise SinkDeliveryError(
142
155
  f"SocketTransport delivered none of {len(messages)} message(s)"
143
156
  )
@@ -175,6 +188,7 @@ class SocketTransport:
175
188
  None.
176
189
  """
177
190
  with self._lock:
191
+ self._closed = True
178
192
  self._reset()
179
193
 
180
194
  def _send_one(self, message: bytes) -> bool:
@@ -41,10 +41,11 @@ class ClickHouseSink:
41
41
  sink is write-only, and the worst-case delay (SPEC-027 FR-005) is ``max_retries``
42
42
  interruptible waits per chunk, 0.7 s at the defaults.
43
43
 
44
- The driver requirement satisfied (SPEC-028 FR-002): a ``clickhouse-connect`` client holds
45
- per-session state across an insert and the project does not publish it as safe to share
46
- between threads, so this sink serializes its use rather than assuming otherwise. A lock is
47
- the conservative reading one client per thread would be the alternative, and that is the
44
+ The driver requirement satisfied (SPEC-028 FR-002): ``clickhouse-connect`` states that a
45
+ client is **not** thread-safe when using session ids, and that concurrent queries in one
46
+ session raise ``ProgrammingError``. This sink calls ``get_client(dsn=...)``, which takes the
47
+ default auto-generated session, so it is squarely in that case and the lock is required
48
+ rather than merely prudent. One client per thread would be the alternative, and that is the
48
49
  connection-pool design FR-002 puts out of scope.
49
50
  """
50
51
 
@@ -133,6 +134,10 @@ class ClickHouseSink:
133
134
  return
134
135
  chunks = inserted = 0
135
136
  with self._lock:
137
+ if self._closed:
138
+ raise SinkDeliveryError(
139
+ f"ClickHouseSink inserted none of {len(batch)} event(s): the sink is closed"
140
+ )
136
141
  for chunk in chunk_list(batch, self._chunk_size):
137
142
  chunks += 1
138
143
  inserted += self._insert([self._row(event) for event in chunk])
@@ -157,9 +162,9 @@ class ClickHouseSink:
157
162
  with self._lock:
158
163
  if self._closed:
159
164
  return
165
+ self._closed = True
160
166
  if self._owns_client:
161
167
  self.client.close()
162
- self._closed = True
163
168
 
164
169
  def _row(self, event: dict[str, object]) -> list[object]:
165
170
  """Builds one row: the extracted columns, then the whole event as JSON.
@@ -68,6 +68,8 @@ class AzureEventHubsSink:
68
68
  self.failed = 0
69
69
  self.dropped_oversized = 0
70
70
  self._counter_lock = threading.Lock()
71
+ self._lock = threading.Lock()
72
+ self._closed = False
71
73
 
72
74
  def losses(self) -> SinkLosses:
73
75
  """Reports oversized drops and events in a batch abandoned past the bound (FR-002).
@@ -106,6 +108,32 @@ class AzureEventHubsSink:
106
108
  """
107
109
  if not batch:
108
110
  return
111
+ with self._lock:
112
+ if self._closed:
113
+ raise SinkDeliveryError(
114
+ f"AzureEventHubsSink sent none of {len(batch)} event(s): the sink is closed"
115
+ )
116
+ self._send_batch(batch)
117
+
118
+ def _send_batch(self, batch: list[dict[str, object]]) -> None:
119
+ """Packs and sends the batch, with the emit lock already held.
120
+
121
+ Split out so the lock's extent is one line in :meth:`emit`. The driver requirement
122
+ satisfied (SPEC-028 FR-002): Microsoft affirmatively states that the
123
+ ``EventHubProducerClient`` is not thread-safe and recommends guarding it with a
124
+ ``threading.Lock``; the ``EventDataBatch`` this builds up across the loop is single-owner
125
+ state besides — two threads packing into batches from one producer interleave
126
+ ``create_batch``/``add``/``send`` on it.
127
+
128
+ Args:
129
+ batch: The events to send, known non-empty.
130
+
131
+ Returns:
132
+ None.
133
+
134
+ Raises:
135
+ SinkDeliveryError: When every attempted send failed.
136
+ """
109
137
  event_data_cls = _event_data_cls()
110
138
  current = self.producer.create_batch()
111
139
  attempted = delivered = 0
@@ -132,6 +160,9 @@ class AzureEventHubsSink:
132
160
  def close(self) -> None:
133
161
  """Closes the producer (FR-009).
134
162
 
163
+ Idempotent, and takes the emit lock so the producer is never closed out from under an
164
+ in-flight send (SPEC-028 FR-002).
165
+
135
166
  Args:
136
167
  None.
137
168
 
@@ -141,7 +172,11 @@ class AzureEventHubsSink:
141
172
  Raises:
142
173
  Exception: Whatever the producer raises on close.
143
174
  """
144
- self.producer.close()
175
+ with self._lock:
176
+ if self._closed:
177
+ return
178
+ self._closed = True
179
+ self.producer.close()
145
180
 
146
181
  def _send(self, event_batch: Any) -> int:
147
182
  """Sends one ``EventDataBatch``, retrying failures (FR-009, FR-011).
@@ -8,6 +8,8 @@ import threading
8
8
  import time
9
9
  from typing import TextIO
10
10
 
11
+ from log_foundry.sinks.base import SinkDeliveryError
12
+
11
13
  __all__ = ["FileSink", "RotatingFileSink"]
12
14
 
13
15
  _WHEN_SECONDS = {
@@ -71,7 +73,13 @@ class FileSink:
71
73
  Raises:
72
74
  OSError: If the write or flush fails (FR-001).
73
75
  """
76
+ if not batch:
77
+ return
74
78
  with self._lock:
79
+ if self._closed:
80
+ raise SinkDeliveryError(
81
+ f"FileSink wrote none of {len(batch)} event(s): the sink is closed"
82
+ )
75
83
  for event in batch:
76
84
  self._stream.write(json.dumps(event) + "\n")
77
85
  self._stream.flush()
@@ -94,9 +102,9 @@ class FileSink:
94
102
  with self._lock:
95
103
  if self._closed:
96
104
  return
105
+ self._closed = True
97
106
  self._stream.flush()
98
107
  self._stream.close()
99
- self._closed = True
100
108
 
101
109
 
102
110
  class RotatingFileSink:
@@ -176,7 +184,13 @@ class RotatingFileSink:
176
184
  Raises:
177
185
  OSError: If a write, flush or rotation fails.
178
186
  """
187
+ if not batch:
188
+ return
179
189
  with self._lock:
190
+ if self._closed:
191
+ raise SinkDeliveryError(
192
+ f"RotatingFileSink wrote none of {len(batch)} event(s): the sink is closed"
193
+ )
180
194
  for event in batch:
181
195
  line = json.dumps(event) + "\n"
182
196
  data = len(line.encode(self._encoding))
@@ -205,9 +219,9 @@ class RotatingFileSink:
205
219
  with self._lock:
206
220
  if self._closed:
207
221
  return
222
+ self._closed = True
208
223
  self._stream.flush()
209
224
  self._stream.close()
210
- self._closed = True
211
225
 
212
226
  @staticmethod
213
227
  def _rollover_seconds(when: str | None, interval: int) -> float | None:
@@ -32,6 +32,10 @@ class FirehoseSink:
32
32
  the chunk sent, so none could be paired to an outcome. A non-zero count means those
33
33
  records were abandoned without the stream ever confirming them — treat it as loss, and
34
34
  as a sign the client is not AWS-shaped.
35
+
36
+ The driver requirement satisfied (SPEC-028 FR-002): this sink takes **no** transport
37
+ lock. ``boto3`` clients are documented thread-safe, this one is built once in
38
+ ``__init__``, and the sink rebinds nothing after construction.
35
39
  """
36
40
 
37
41
  MAX_RECORDS = 500
@@ -24,6 +24,10 @@ class KafkaSink:
24
24
  failed: Messages whose delivery callback reported an error.
25
25
  rejected: Messages ``produce()`` itself refused — a full local queue, a serialization fault
26
26
  — which never reached the producer's batch at all.
27
+
28
+ The driver requirement satisfied (SPEC-028 FR-002): this sink takes **no** transport
29
+ lock. ``confluent-kafka`` documents its ``Producer`` as thread-safe, and this sink adds no state
30
+ of its own to guard — ``produce()`` is a local hand-off into the client's internal queue.
27
31
  """
28
32
 
29
33
  def __init__(
@@ -32,6 +32,10 @@ class KinesisSink:
32
32
  describe the chunk sent, so none could be paired to an outcome. A non-zero count means
33
33
  those records were abandoned without the stream ever confirming them — treat it as loss,
34
34
  and as a sign the client is not AWS-shaped.
35
+
36
+ The driver requirement satisfied (SPEC-028 FR-002): this sink takes **no** transport
37
+ lock. ``boto3`` clients are documented thread-safe, this one is built once in
38
+ ``__init__``, and the sink rebinds nothing after construction.
35
39
  """
36
40
 
37
41
  MAX_RECORDS = 500