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.
- {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/PKG-INFO +62 -9
- {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/README.md +61 -8
- {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/pyproject.toml +1 -1
- {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/__init__.py +7 -2
- {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/_lifecycle.py +4 -3
- {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/api.py +1 -1
- {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/config.py +131 -27
- {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/context.py +33 -3
- {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/decorator.py +1 -1
- {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/model.py +4 -4
- {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/_socket.py +2 -2
- {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/base.py +36 -1
- {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/clickhouse.py +2 -2
- {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/eventhubs.py +2 -2
- {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/filtering.py +4 -4
- {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/firehose.py +2 -2
- {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/http.py +2 -2
- {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/kinesis.py +2 -2
- {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/logstash.py +5 -5
- {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/mongodb.py +2 -2
- {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/multi.py +4 -4
- {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/postgres.py +2 -2
- {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/rabbitmq.py +2 -2
- {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/redis.py +2 -2
- {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/sentry.py +10 -10
- {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/sns.py +2 -2
- {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/sqs.py +3 -3
- {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/syslog.py +4 -4
- {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/transform.py +4 -4
- {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/worker.py +1 -1
- {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/LICENSE +0 -0
- {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/_diag.py +0 -0
- {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/console.py +0 -0
- {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/ids.py +0 -0
- {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/py.typed +0 -0
- {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sanitize.py +0 -0
- {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/__init__.py +0 -0
- {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/_batch.py +0 -0
- {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/_chunk.py +0 -0
- {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/_retry.py +0 -0
- {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/_time.py +0 -0
- {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/callback.py +0 -0
- {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/datadog.py +0 -0
- {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/elasticsearch.py +0 -0
- {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/file.py +0 -0
- {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/honeycomb.py +0 -0
- {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/kafka.py +0 -0
- {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/logging_sink.py +0 -0
- {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/loki.py +0 -0
- {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/nats.py +0 -0
- {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/newrelic.py +0 -0
- {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/pubsub.py +0 -0
- {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/splunk.py +0 -0
- {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/sqlite.py +0 -0
- {log_foundry-0.10.2.dev41 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/stdout.py +0 -0
- {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.
|
|
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
|
-
```
|
|
469
|
-
|
|
470
|
-
|
|
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()`.
|
|
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
|
|
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
|
|
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
|
-
```
|
|
433
|
-
|
|
434
|
-
|
|
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()`.
|
|
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
|
|
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
|
|
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
|
|
@@ -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 ``
|
|
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, "
|
|
173
|
-
sink.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
|
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
|
|
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(
|
|
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.
|
|
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
|
|
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 =
|
|
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=
|
|
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=
|
|
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.
|
|
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.
|
|
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
|