log-foundry 0.10.2.dev41__tar.gz → 0.10.2.dev43__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 (56) hide show
  1. {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/PKG-INFO +62 -9
  2. {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/README.md +61 -8
  3. {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/pyproject.toml +1 -1
  4. {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/__init__.py +7 -2
  5. {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/_lifecycle.py +4 -3
  6. {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/api.py +1 -1
  7. {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/config.py +131 -27
  8. {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/context.py +33 -3
  9. {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/decorator.py +1 -1
  10. {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/model.py +4 -4
  11. {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/_socket.py +2 -2
  12. {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/base.py +36 -1
  13. {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/clickhouse.py +2 -2
  14. {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/eventhubs.py +2 -2
  15. {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/filtering.py +4 -4
  16. {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/firehose.py +2 -2
  17. {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/http.py +2 -2
  18. {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/kinesis.py +2 -2
  19. {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/logstash.py +5 -5
  20. {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/mongodb.py +2 -2
  21. {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/multi.py +4 -4
  22. {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/postgres.py +2 -2
  23. {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/rabbitmq.py +2 -2
  24. {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/redis.py +2 -2
  25. {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/sentry.py +10 -10
  26. {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/sns.py +2 -2
  27. {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/sqs.py +3 -3
  28. {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/syslog.py +4 -4
  29. {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/transform.py +4 -4
  30. {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/worker.py +1 -1
  31. {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/LICENSE +0 -0
  32. {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/_diag.py +0 -0
  33. {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/console.py +0 -0
  34. {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/ids.py +0 -0
  35. {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/py.typed +0 -0
  36. {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sanitize.py +0 -0
  37. {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/__init__.py +0 -0
  38. {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/_batch.py +0 -0
  39. {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/_chunk.py +0 -0
  40. {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/_retry.py +0 -0
  41. {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/_time.py +0 -0
  42. {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/callback.py +0 -0
  43. {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/datadog.py +0 -0
  44. {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/elasticsearch.py +0 -0
  45. {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/file.py +0 -0
  46. {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/honeycomb.py +0 -0
  47. {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/kafka.py +0 -0
  48. {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/logging_sink.py +0 -0
  49. {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/loki.py +0 -0
  50. {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/nats.py +0 -0
  51. {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/newrelic.py +0 -0
  52. {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/pubsub.py +0 -0
  53. {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/splunk.py +0 -0
  54. {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/sqlite.py +0 -0
  55. {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/stdout.py +0 -0
  56. {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/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.dev41
3
+ Version: 0.10.2.dev43
4
4
  Summary: Generate logs for your console and JSON events for downstream consumption.
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -396,6 +396,7 @@ def handler(event, context):
396
396
  | `current_traceparent()` | This span as a W3C `traceparent` string, or `None` if no span is active. |
397
397
  | `current_trace_context()` | `(trace_id, span_id)`, for when moving two fields beats moving a string. |
398
398
  | `current_baggage_header()` | Current baggage in W3C `baggage` format (`""` when empty). |
399
+ | `get_baggage()` | Current baggage as a `dict`. A shallow copy — rebinding a key does not reach the library, but a nested mutable value is shared. |
399
400
  | `reset_context()` | Clear baggage and any adopted context. `@trace` users do not need it. Never raises. |
400
401
 
401
402
  Details worth knowing:
@@ -465,14 +466,20 @@ undoing the erasure. It never raises.
465
466
  A **sink** is the swappable output transport — any object satisfying the `Sink` protocol. It
466
467
  receives already-built, batched event dicts and knows nothing about spans or context:
467
468
 
468
- ```python
469
- class Sink(Protocol):
470
- def emit(self, batch: list[dict[str, object]]) -> None: ...
471
- def close(self) -> None: ...
469
+ ```
470
+ emit(batch: list[dict[str, object]]) -> None
471
+ close() -> None
472
472
  ```
473
473
 
474
+ Satisfy it **structurally** — any object with those two methods is a sink, and every sink shipped
475
+ here is one. You do not need to inherit. If you prefer to, `from log_foundry import Sink` gives you
476
+ the protocol to annotate against or subclass; both methods are abstract, so a subclass that
477
+ misspells `emit` fails at construction rather than silently accepting every batch and delivering
478
+ nothing.
479
+
474
480
  Wire one up by passing an instance to `configure(sink=...)`; if you never do, the first decorated
475
- call falls back to `StdoutSink()`. Sinks are **not** re-exported at the top level import each
481
+ call falls back to `StdoutSink()`. The **protocol** is a top-level export, alongside
482
+ `SinkDeliveryError`, `SinkLosses` and `read_losses`; the **concrete sinks** are not, so import each
476
483
  from its own module, e.g. `from log_foundry.sinks.sqs import SQSSink`.
477
484
 
478
485
  A few conventions hold across every sink below:
@@ -728,11 +735,14 @@ loss-reporting apparatus is built on them:
728
735
  whole operation that assumes exclusivity. If it holds none, you need do nothing. The library
729
736
  cannot serialize this for you: it does not own the calling thread.
730
737
 
738
+ - **The batch is borrowed, not given.** The list and the dicts in it may go to other sinks after
739
+ you — `MultiSink` hands the same objects to every child in turn — so copy before you redact or
740
+ reshape. A child that cleared the list in place left the next child with nothing, and no error
741
+ anywhere.
731
742
  - **Raise when you delivered none of the batch**, after your own retries are spent. That is the
732
743
  signal the worker's bounded retry and `health().failed_batches` depend on, and the one case where
733
744
  a retry cannot duplicate anything: nothing landed downstream. Raise `SinkDeliveryError` (from
734
- `log_foundry.sinks.base`) or any exception of your own — the contract is that *something*
735
- propagates.
745
+ `log_foundry`) or any exception of your own — the contract is that *something* propagates.
736
746
  - **Do not raise when you delivered some of it.** The worker retries whole batches, so raising on a
737
747
  partial success re-delivers the records that already arrived, and duplicates downstream are worse
738
748
  than a counted loss.
@@ -756,7 +766,7 @@ separate from the transport one so a poll never waits on an in-flight send:
756
766
 
757
767
  ```python
758
768
  import threading
759
- from log_foundry.sinks.base import SinkDeliveryError, SinkLosses
769
+ from log_foundry import SinkDeliveryError, SinkLosses
760
770
 
761
771
  class MySink:
762
772
  def __init__(self) -> None:
@@ -764,6 +774,7 @@ class MySink:
764
774
  self._closed = False
765
775
  self._lock = threading.Lock() # transport state
766
776
  self._counter_lock = threading.Lock() # counters only, never held across I/O
777
+ self.log_foundry_stop_signal: threading.Event | None = None # optional; see below
767
778
 
768
779
  def emit(self, batch: list[dict[str, object]]) -> None:
769
780
  if not batch:
@@ -799,6 +810,48 @@ class MySink:
799
810
  simply contributes nothing to `health().sink`. `emit([])` must be a no-op: an empty batch has not
800
811
  failed to deliver.
801
812
 
813
+ `log_foundry_stop_signal` is optional in the same way, and is an attribute rather than a method.
814
+ Declare it as a plain `threading.Event | None` initialised to `None` and the library assigns the
815
+ worker's shutdown event to it; leave it out and you are simply never offered one. **Honour it in
816
+ your retry backoff** — pass it to `Event.wait(timeout)` instead of calling `time.sleep`:
817
+
818
+ ```python
819
+ if self.log_foundry_stop_signal is not None:
820
+ self.log_foundry_stop_signal.wait(delay) # returns early when shutdown starts
821
+ else:
822
+ time.sleep(delay)
823
+ ```
824
+
825
+ There is one drain thread, so your backoff pauses *all* log delivery, and it is held across
826
+ `shutdown()` — which joins that thread. A sink that sleeps through a 30-second backoff holds
827
+ process exit for 30 seconds. The name is prefixed because the library assigns this attribute onto
828
+ an object it does not own: a bare `stop_signal` would silently overwrite one you already had.
829
+
830
+ If you write a **wrapper** sink, forward it to whatever actually holds the retry loop — a plain
831
+ attribute on the wrapper is assigned, stops there, and the inner sink never sees it. Measured: a
832
+ wrapper built from the leaf template above left an inner sink's 4-second backoff uninterrupted,
833
+ `shutdown(timeout=30)` ran the full 30 seconds, `stopped_reason` read `"ShutdownTimeout"` and the
834
+ sink was left open — against 0.00 s for the same sink configured directly. Use a property:
835
+
836
+ ```python
837
+ class MyWrapper:
838
+ def __init__(self, inner) -> None:
839
+ self._inner = inner
840
+ self._stop_signal: threading.Event | None = None
841
+
842
+ @property
843
+ def log_foundry_stop_signal(self) -> threading.Event | None:
844
+ return self._stop_signal
845
+
846
+ @log_foundry_stop_signal.setter
847
+ def log_foundry_stop_signal(self, signal: threading.Event | None) -> None:
848
+ self._stop_signal = signal
849
+ self._inner.log_foundry_stop_signal = signal # the one line that matters
850
+ ```
851
+
852
+ Every wrapper shipped here — `MultiSink`, `FilteringSink`, `TransformSink`, `SyslogSink`,
853
+ `LogstashSink`, `SentrySink` — does exactly this.
854
+
802
855
  ### Flushing and shutdown
803
856
 
804
857
  Delivery is off the hot path. When a span ends, its events are handed to a per-process
@@ -360,6 +360,7 @@ def handler(event, context):
360
360
  | `current_traceparent()` | This span as a W3C `traceparent` string, or `None` if no span is active. |
361
361
  | `current_trace_context()` | `(trace_id, span_id)`, for when moving two fields beats moving a string. |
362
362
  | `current_baggage_header()` | Current baggage in W3C `baggage` format (`""` when empty). |
363
+ | `get_baggage()` | Current baggage as a `dict`. A shallow copy — rebinding a key does not reach the library, but a nested mutable value is shared. |
363
364
  | `reset_context()` | Clear baggage and any adopted context. `@trace` users do not need it. Never raises. |
364
365
 
365
366
  Details worth knowing:
@@ -429,14 +430,20 @@ undoing the erasure. It never raises.
429
430
  A **sink** is the swappable output transport — any object satisfying the `Sink` protocol. It
430
431
  receives already-built, batched event dicts and knows nothing about spans or context:
431
432
 
432
- ```python
433
- class Sink(Protocol):
434
- def emit(self, batch: list[dict[str, object]]) -> None: ...
435
- def close(self) -> None: ...
433
+ ```
434
+ emit(batch: list[dict[str, object]]) -> None
435
+ close() -> None
436
436
  ```
437
437
 
438
+ Satisfy it **structurally** — any object with those two methods is a sink, and every sink shipped
439
+ here is one. You do not need to inherit. If you prefer to, `from log_foundry import Sink` gives you
440
+ the protocol to annotate against or subclass; both methods are abstract, so a subclass that
441
+ misspells `emit` fails at construction rather than silently accepting every batch and delivering
442
+ nothing.
443
+
438
444
  Wire one up by passing an instance to `configure(sink=...)`; if you never do, the first decorated
439
- call falls back to `StdoutSink()`. Sinks are **not** re-exported at the top level import each
445
+ call falls back to `StdoutSink()`. The **protocol** is a top-level export, alongside
446
+ `SinkDeliveryError`, `SinkLosses` and `read_losses`; the **concrete sinks** are not, so import each
440
447
  from its own module, e.g. `from log_foundry.sinks.sqs import SQSSink`.
441
448
 
442
449
  A few conventions hold across every sink below:
@@ -692,11 +699,14 @@ loss-reporting apparatus is built on them:
692
699
  whole operation that assumes exclusivity. If it holds none, you need do nothing. The library
693
700
  cannot serialize this for you: it does not own the calling thread.
694
701
 
702
+ - **The batch is borrowed, not given.** The list and the dicts in it may go to other sinks after
703
+ you — `MultiSink` hands the same objects to every child in turn — so copy before you redact or
704
+ reshape. A child that cleared the list in place left the next child with nothing, and no error
705
+ anywhere.
695
706
  - **Raise when you delivered none of the batch**, after your own retries are spent. That is the
696
707
  signal the worker's bounded retry and `health().failed_batches` depend on, and the one case where
697
708
  a retry cannot duplicate anything: nothing landed downstream. Raise `SinkDeliveryError` (from
698
- `log_foundry.sinks.base`) or any exception of your own — the contract is that *something*
699
- propagates.
709
+ `log_foundry`) or any exception of your own — the contract is that *something* propagates.
700
710
  - **Do not raise when you delivered some of it.** The worker retries whole batches, so raising on a
701
711
  partial success re-delivers the records that already arrived, and duplicates downstream are worse
702
712
  than a counted loss.
@@ -720,7 +730,7 @@ separate from the transport one so a poll never waits on an in-flight send:
720
730
 
721
731
  ```python
722
732
  import threading
723
- from log_foundry.sinks.base import SinkDeliveryError, SinkLosses
733
+ from log_foundry import SinkDeliveryError, SinkLosses
724
734
 
725
735
  class MySink:
726
736
  def __init__(self) -> None:
@@ -728,6 +738,7 @@ class MySink:
728
738
  self._closed = False
729
739
  self._lock = threading.Lock() # transport state
730
740
  self._counter_lock = threading.Lock() # counters only, never held across I/O
741
+ self.log_foundry_stop_signal: threading.Event | None = None # optional; see below
731
742
 
732
743
  def emit(self, batch: list[dict[str, object]]) -> None:
733
744
  if not batch:
@@ -763,6 +774,48 @@ class MySink:
763
774
  simply contributes nothing to `health().sink`. `emit([])` must be a no-op: an empty batch has not
764
775
  failed to deliver.
765
776
 
777
+ `log_foundry_stop_signal` is optional in the same way, and is an attribute rather than a method.
778
+ Declare it as a plain `threading.Event | None` initialised to `None` and the library assigns the
779
+ worker's shutdown event to it; leave it out and you are simply never offered one. **Honour it in
780
+ your retry backoff** — pass it to `Event.wait(timeout)` instead of calling `time.sleep`:
781
+
782
+ ```python
783
+ if self.log_foundry_stop_signal is not None:
784
+ self.log_foundry_stop_signal.wait(delay) # returns early when shutdown starts
785
+ else:
786
+ time.sleep(delay)
787
+ ```
788
+
789
+ There is one drain thread, so your backoff pauses *all* log delivery, and it is held across
790
+ `shutdown()` — which joins that thread. A sink that sleeps through a 30-second backoff holds
791
+ process exit for 30 seconds. The name is prefixed because the library assigns this attribute onto
792
+ an object it does not own: a bare `stop_signal` would silently overwrite one you already had.
793
+
794
+ If you write a **wrapper** sink, forward it to whatever actually holds the retry loop — a plain
795
+ attribute on the wrapper is assigned, stops there, and the inner sink never sees it. Measured: a
796
+ wrapper built from the leaf template above left an inner sink's 4-second backoff uninterrupted,
797
+ `shutdown(timeout=30)` ran the full 30 seconds, `stopped_reason` read `"ShutdownTimeout"` and the
798
+ sink was left open — against 0.00 s for the same sink configured directly. Use a property:
799
+
800
+ ```python
801
+ class MyWrapper:
802
+ def __init__(self, inner) -> None:
803
+ self._inner = inner
804
+ self._stop_signal: threading.Event | None = None
805
+
806
+ @property
807
+ def log_foundry_stop_signal(self) -> threading.Event | None:
808
+ return self._stop_signal
809
+
810
+ @log_foundry_stop_signal.setter
811
+ def log_foundry_stop_signal(self, signal: threading.Event | None) -> None:
812
+ self._stop_signal = signal
813
+ self._inner.log_foundry_stop_signal = signal # the one line that matters
814
+ ```
815
+
816
+ Every wrapper shipped here — `MultiSink`, `FilteringSink`, `TransformSink`, `SyslogSink`,
817
+ `LogstashSink`, `SentrySink` — does exactly this.
818
+
766
819
  ### Flushing and shutdown
767
820
 
768
821
  Delivery is off the hot path. When a span ends, its events are handed to a per-process
@@ -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.dev41"
23
+ version = "0.10.2.dev43"
24
24
 
25
25
  [project.optional-dependencies]
26
26
  aws = ["boto3>=1.43.61"] # SQSSink, SNSSink, KinesisSink, FirehoseSink
@@ -4,15 +4,16 @@ from importlib.metadata import PackageNotFoundError
4
4
  from importlib.metadata import version as _dist_version
5
5
 
6
6
  from log_foundry.api import critical, debug, error, info, set_baggage, warning
7
- from log_foundry.config import configure, get_config
7
+ from log_foundry.config import Config, configure, get_config
8
8
  from log_foundry.context import (
9
9
  current_baggage_header,
10
10
  current_trace_context,
11
11
  current_traceparent,
12
+ get_baggage,
12
13
  reset_context,
13
14
  )
14
15
  from log_foundry.decorator import continue_trace, trace
15
- from log_foundry.sinks.base import SinkDeliveryError, SinkLosses
16
+ from log_foundry.sinks.base import Sink, SinkDeliveryError, SinkLosses, read_losses
16
17
  from log_foundry.worker import DEFAULT_SHUTDOWN_TIMEOUT, Health
17
18
 
18
19
  try:
@@ -156,7 +157,9 @@ def shutdown(timeout: float | None = DEFAULT_SHUTDOWN_TIMEOUT) -> None:
156
157
 
157
158
  __all__ = [
158
159
  "DEFAULT_SHUTDOWN_TIMEOUT",
160
+ "Config",
159
161
  "Health",
162
+ "Sink",
160
163
  "SinkDeliveryError",
161
164
  "SinkLosses",
162
165
  "__version__",
@@ -169,9 +172,11 @@ __all__ = [
169
172
  "debug",
170
173
  "error",
171
174
  "flush",
175
+ "get_baggage",
172
176
  "get_config",
173
177
  "health",
174
178
  "info",
179
+ "read_losses",
175
180
  "reset_context",
176
181
  "set_baggage",
177
182
  "shutdown",
@@ -165,11 +165,12 @@ def offer_stop_signal(sink: Sink, stop: threading.Event) -> None:
165
165
  None.
166
166
 
167
167
  Raises:
168
- None. A sink whose ``stop_signal`` is a read-only property, or whose ``__setattr__``
168
+ None. A sink whose ``log_foundry_stop_signal`` is a read-only property, or whose
169
+ ``__setattr__``
169
170
  objects, loses interruptibility rather than preventing the caller from proceeding.
170
171
  """
171
172
  try:
172
- if hasattr(sink, "stop_signal"):
173
- sink.stop_signal = stop
173
+ if hasattr(sink, "log_foundry_stop_signal"):
174
+ sink.log_foundry_stop_signal = stop
174
175
  except Exception as exc:
175
176
  _diag.absorbed("handing the sink its stop signal", exc, "its backoff stays uninterruptible")
@@ -72,7 +72,7 @@ def _log(level: str, message: str, echo: bool, fields: dict[str, object]) -> Non
72
72
  None. A logging call must never hand the application an exception from a destination
73
73
  it never chose to talk to; absorbed faults are reported through ``_diag``.
74
74
  """
75
- baggage = context.get_baggage()
75
+ baggage = context._live_baggage()
76
76
  span = context.current_span()
77
77
  event: dict[str, object] | None = None
78
78
  if span is not None:
@@ -2,14 +2,15 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from dataclasses import dataclass, field
5
+ import threading
6
+ from dataclasses import dataclass, field, replace
6
7
  from typing import TYPE_CHECKING
7
8
 
8
9
  if TYPE_CHECKING:
9
10
  from log_foundry.sinks.base import Sink
10
11
 
11
12
 
12
- @dataclass
13
+ @dataclass(frozen=True)
13
14
  class Config:
14
15
  """Process-wide settings stamped onto every event and consulted by the pipeline.
15
16
 
@@ -22,6 +23,13 @@ class Config:
22
23
  string is measured in UTF-8 bytes, an integer in the decimal length it renders as, sign
23
24
  included. An integer is also bounded by ``sys.get_int_max_str_digits()`` whenever that
24
25
  is lower, since a longer one cannot be rendered at all.
26
+
27
+ It is **frozen** (SPEC-034 FR-003). :func:`get_config` handed out this object, so assigning
28
+ to it retargeted what the config *reported* while every event continued to the sink the
29
+ worker had already captured — SPEC-030's defect reachable with no underscore in sight — and
30
+ assigning a ceiling bypassed :func:`_require_positive`, so ``max_value_bytes = 0`` was
31
+ accepted and emptied every event it touched. Both measured. :func:`configure` is the only
32
+ supported route to a change, and it rebinds the module global rather than mutating.
25
33
  """
26
34
 
27
35
  service: str = "unknown"
@@ -37,6 +45,25 @@ class Config:
37
45
 
38
46
  _config = Config()
39
47
 
48
+ _config_lock = threading.Lock()
49
+ """Serializes the read-modify-write that replacing a frozen config now is.
50
+
51
+ Freezing :class:`Config` turned each field assignment into a whole-object
52
+ ``replace()`` — a read of every field followed by a write of every field — and one of the two
53
+ call sites, :func:`_ensure_sink`, runs on the **orphan logging path**, on whatever application
54
+ thread called ``info()``. A stale snapshot there puts back the pre-``configure()`` ``service``,
55
+ ``version``, ``env``, ``defaults`` *and* ``sink``, permanently. Measured on the unlocked version:
56
+ 268 of 2000 trials shipped every later event with ``service="unknown"`` after one concurrent
57
+ ``info()``, against 0 before the freeze — a regression, and the SPEC-024 category of wrong data
58
+ rather than lost data.
59
+
60
+ It does **not** cover reads. :func:`_live_config` is one atomic global read and stays lock-free,
61
+ so the per-event path pays nothing (SPEC-034 FR-003 AC-6). Lock ordering is one-way and stays
62
+ that way: ``_ensure_sink`` is called with ``decorator._worker_lock`` held, and ``configure()``
63
+ releases this lock before ``_swap_live_sink`` takes that one, so nothing acquires
64
+ ``_worker_lock`` underneath this.
65
+ """
66
+
40
67
 
41
68
  def _require_positive(name: str, value: int | None) -> None:
42
69
  """Rejects a non-positive ceiling.
@@ -127,24 +154,22 @@ def configure(
127
154
  _require_positive("max_keys", max_keys)
128
155
  _require_positive("max_depth", max_depth)
129
156
 
130
- if service is not None:
131
- _config.service = service
132
- if version is not None:
133
- _config.version = version
134
- if env is not None:
135
- _config.env = env
136
- if sink is not None:
137
- _config.sink = sink
138
- if defaults is not None:
139
- _config.defaults = dict(defaults)
140
- if max_value_bytes is not None:
141
- _config.max_value_bytes = max_value_bytes
142
- if max_stack_bytes is not None:
143
- _config.max_stack_bytes = max_stack_bytes
144
- if max_keys is not None:
145
- _config.max_keys = max_keys
146
- if max_depth is not None:
147
- _config.max_depth = max_depth
157
+ changed: dict[str, object] = {
158
+ name: value
159
+ for name, value in (
160
+ ("service", service),
161
+ ("version", version),
162
+ ("env", env),
163
+ ("sink", sink),
164
+ ("defaults", None if defaults is None else dict(defaults)),
165
+ ("max_value_bytes", max_value_bytes),
166
+ ("max_stack_bytes", max_stack_bytes),
167
+ ("max_keys", max_keys),
168
+ ("max_depth", max_depth),
169
+ )
170
+ if value is not None
171
+ }
172
+ _rebind(**changed)
148
173
 
149
174
  _ensure_sink()
150
175
 
@@ -181,13 +206,75 @@ def _swap_live_sink(sink: Sink) -> None:
181
206
 
182
207
 
183
208
  def get_config() -> Config:
184
- """Returns the current global config singleton.
209
+ """Returns the current global config, for reading.
210
+
211
+ **Mutating what this returns raises** (SPEC-034 FR-003). It used to hand back the live
212
+ singleton, so assigning to it retargeted what the config *reported* while every event
213
+ continued to the sink the worker had already captured, and assigning a ceiling bypassed the
214
+ validation :func:`configure` performs — ``max_value_bytes = 0`` was accepted and emptied
215
+ every event it touched. Both measured. :func:`configure` is the only route to a change.
216
+
217
+ It is a **copy**, not the frozen original, and ``defaults`` is copied with it. A caller who
218
+ defeats the freeze — ``object.__setattr__`` reaches through any frozen dataclass — then
219
+ edits an object the library does not read, rather than the live config; and ``defaults`` is
220
+ a plain mutable ``dict``, so sharing it would leave the freeze cosmetic at the one field
221
+ that is not a scalar. ``dataclasses.replace`` alone does **not** do this: it shares the
222
+ dict, which was measured while building this.
223
+
224
+ Args:
225
+ None.
226
+
227
+ Returns:
228
+ A copy of the process-wide :class:`Config`, with its own ``defaults``.
229
+
230
+ Raises:
231
+ None.
232
+ """
233
+ return replace(_config, defaults=dict(_config.defaults))
234
+
235
+
236
+ def _rebind(**changed: object) -> None:
237
+ """Replaces the module-global config with a copy carrying the changed fields.
238
+
239
+ :class:`Config` is frozen (SPEC-034 FR-003), so a change is a new object and a rebinding of
240
+ the global rather than an assignment to a field. It is **one** replacement for the whole
241
+ call, not one per field: nine rebindings would allocate nine configs and, worse, would leave
242
+ a window in which another thread reads a half-applied config — a `service` from the new call
243
+ beside a `sink` from the old one, stamped onto real events.
244
+
245
+ Rebinding is safe only because no module imports ``_config`` by value; a
246
+ ``from log_foundry.config import _config`` anywhere would hold the pre-rebind object forever,
247
+ which is why a test asserts the absence rather than a comment claiming it.
248
+
249
+ Args:
250
+ **changed: Field names and their new values. Fields not named keep their current value.
251
+
252
+ Returns:
253
+ None.
254
+
255
+ Raises:
256
+ None.
257
+ """
258
+ global _config
259
+ with _config_lock:
260
+ _config = replace(_config, **changed) # type: ignore[arg-type]
261
+
262
+
263
+ def _live_config() -> Config:
264
+ """Returns the config object itself, for callers inside the package.
265
+
266
+ :func:`get_config` copies, because it is public and a caller must not be able to reach the
267
+ live object through it. ``model.build_event`` reads the config **one to three times per
268
+ event**, so routing that through the public accessor would allocate a ``Config`` and a
269
+ ``defaults`` dict per event. The freeze is a guarantee to the library's *users*, not one the
270
+ library needs against itself, so internal callers read the live object and treat it as
271
+ read-only — the same split :func:`context._live_baggage` makes for baggage.
185
272
 
186
273
  Args:
187
274
  None.
188
275
 
189
276
  Returns:
190
- The process-wide :class:`Config`.
277
+ The live process-wide :class:`Config`.
191
278
 
192
279
  Raises:
193
280
  None.
@@ -203,6 +290,14 @@ def _ensure_sink() -> Sink:
203
290
  user has not called ``configure()`` yet. The local import defers the ``sinks``
204
291
  dependency and avoids a top-level import cycle (arch §7).
205
292
 
293
+ It is called on the **orphan logging path**, on arbitrary application threads, so the
294
+ default is resolved under :data:`_config_lock` with a double check and the global is re-read
295
+ inside it (SPEC-034 FR-003). Returning a freshly built local instead handed two racing
296
+ threads two different ``StdoutSink`` objects — measured 996 of 3000 trials — one of which
297
+ then received events and was referenced by nothing, so nothing closed it (SPEC-031 FR-006).
298
+ The unlocked read above it is the fast path: once a sink is configured this is one atomic
299
+ global read and no lock at all.
300
+
206
301
  Args:
207
302
  None.
208
303
 
@@ -212,8 +307,17 @@ def _ensure_sink() -> Sink:
212
307
  Raises:
213
308
  None.
214
309
  """
215
- if _config.sink is None:
216
- from log_foundry.sinks.stdout import StdoutSink
217
-
218
- _config.sink = StdoutSink()
219
- return _config.sink
310
+ global _config
311
+ sink_now = _config.sink
312
+ if sink_now is not None:
313
+ return sink_now
314
+
315
+ from log_foundry.sinks.stdout import StdoutSink
316
+
317
+ with _config_lock:
318
+ if _config.sink is None:
319
+ _config = replace(_config, sink=StdoutSink())
320
+ resolved = _config.sink
321
+ if resolved is None: # pragma: no cover - unreachable; the branch above just set it
322
+ raise RuntimeError("the default sink could not be resolved")
323
+ return resolved
@@ -96,18 +96,48 @@ def pop_span(token: contextvars.Token[tuple[Span, ...]]) -> None:
96
96
 
97
97
 
98
98
  def get_baggage() -> dict[str, object]:
99
- """Returns the current trace's baggage, which must not be mutated in place.
99
+ """Returns a copy of the current trace's baggage.
100
100
 
101
101
  The trace's own keys are discarded when the enclosing root span closes, restoring the
102
102
  baggage in effect before it, so a later trace sees only a process-level default set
103
103
  before any span opened. With no span open at all nothing releases them and they
104
104
  accumulate for the life of the context; :func:`reset_context` is the release there.
105
105
 
106
+ It is a **shallow** copy (SPEC-034 FR-005). This became public at 1.0, and a public accessor
107
+ that hands out the live mapping documented as "do not mutate" is FR-003's ``get_config()``
108
+ defect under another name. Shallow rather than deep because :func:`set_baggage` accepts
109
+ arbitrary values: a deep copy would run user objects through ``copy.deepcopy`` inside a
110
+ getter that must never raise, which trades a narrow sharing bound for a wide new failure —
111
+ and the library never mutates a baggage value either, so the sharing is only reachable
112
+ through the caller's own object. The library's own hot path does not pay even for the
113
+ shallow copy: :func:`_live_baggage` is the internal read, for the reason FR-003 AC-6 gives.
114
+
115
+ Args:
116
+ None.
117
+
118
+ Returns:
119
+ A shallow copy of the baggage mapping. Rebinding a key does not affect the library; a
120
+ nested mutable value is shared, and mutating one *does* reach every later event.
121
+
122
+ Raises:
123
+ None.
124
+ """
125
+ return dict(_baggage.get())
126
+
127
+
128
+ def _live_baggage() -> dict[str, object]:
129
+ """Returns the baggage mapping itself, for callers inside the package.
130
+
131
+ :func:`api._log` reads baggage once per event, so a copy here would allocate per event —
132
+ the cost FR-003 AC-6 measures and refuses on the config's own hot path. Callers must treat
133
+ the result as read-only; the freeze is a guarantee to the library's *users*, not one the
134
+ library needs against itself.
135
+
106
136
  Args:
107
137
  None.
108
138
 
109
139
  Returns:
110
- The baggage mapping, to be treated as read-only.
140
+ The live baggage mapping.
111
141
 
112
142
  Raises:
113
143
  None.
@@ -300,7 +330,7 @@ def current_baggage_header() -> str:
300
330
  Raises:
301
331
  None.
302
332
  """
303
- return format_baggage_header(get_baggage())
333
+ return format_baggage_header(_live_baggage())
304
334
 
305
335
 
306
336
  def format_baggage_header(baggage: dict[str, object]) -> str:
@@ -853,7 +853,7 @@ def _close_span(span: Span, status: str, exc: BaseException | None) -> None:
853
853
  guard.
854
854
  """
855
855
  span.events.append(end_event(span, status, exc))
856
- backfill_baggage(span, context.get_baggage())
856
+ backfill_baggage(span, context._live_baggage())
857
857
  _flush(span)
858
858
 
859
859
 
@@ -8,7 +8,7 @@ from dataclasses import dataclass, field
8
8
  from datetime import UTC, datetime
9
9
  from typing import TYPE_CHECKING
10
10
 
11
- from log_foundry.config import get_config
11
+ from log_foundry.config import _live_config
12
12
  from log_foundry.ids import new_log_id
13
13
  from log_foundry.sanitize import sanitize_fields, truncate_str, truncate_tail
14
14
 
@@ -93,7 +93,7 @@ def build_event(
93
93
  Raises:
94
94
  None.
95
95
  """
96
- cfg = get_config()
96
+ cfg = _live_config()
97
97
  merged: dict[str, object] = {**cfg.defaults, **span.defaults, **baggage, **fields}
98
98
  safe, clipped = sanitize_fields(merged, cfg=cfg)
99
99
  bounded_message, message_clipped = truncate_str(message, cfg.max_value_bytes)
@@ -236,7 +236,7 @@ def end_event(
236
236
  event["duration_ms"] = (time.monotonic() - span.start_ts) * 1000.0
237
237
  event["status"] = status
238
238
  if exc is not None:
239
- error, clipped = _error_fields(exc, cfg=get_config())
239
+ error, clipped = _error_fields(exc, cfg=_live_config())
240
240
  event["error"] = error
241
241
  if clipped:
242
242
  event[_TRUNCATED] = True
@@ -269,7 +269,7 @@ def backfill_baggage(span: Span, baggage: dict[str, object]) -> None:
269
269
  """
270
270
  if not baggage:
271
271
  return
272
- safe, clipped = sanitize_fields(baggage, cfg=get_config())
272
+ safe, clipped = sanitize_fields(baggage, cfg=_live_config())
273
273
  for event in span.events:
274
274
  if event.get("message") in (_START_MESSAGE, _END_MESSAGE):
275
275
  fields = event.get("fields")
@@ -133,7 +133,7 @@ class SocketTransport:
133
133
  self._sock: socket.socket | None = None
134
134
  self.failed = 0
135
135
  self._counter_lock = threading.Lock()
136
- self.stop_signal: threading.Event | None = None
136
+ self.log_foundry_stop_signal: threading.Event | None = None
137
137
  self._lock = threading.Lock()
138
138
  self._closed = False
139
139
 
@@ -243,7 +243,7 @@ class SocketTransport:
243
243
  except OSError as err:
244
244
  self._reset()
245
245
  if attempt < self._max_retries:
246
- wait(_BACKOFF_BASE * (2**attempt), self.stop_signal)
246
+ wait(_BACKOFF_BASE * (2**attempt), self.log_foundry_stop_signal)
247
247
  continue
248
248
  with self._counter_lock:
249
249
  self.failed += 1