log-foundry 0.7.1.dev3__tar.gz → 0.7.2.dev4__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.7.1.dev3 → log_foundry-0.7.2.dev4}/PKG-INFO +31 -7
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/README.md +30 -6
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/pyproject.toml +2 -1
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/__init__.py +10 -6
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/config.py +10 -1
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sanitize.py +23 -2
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/worker.py +155 -24
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/LICENSE +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/api.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/console.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/context.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/decorator.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/ids.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/model.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/py.typed +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/__init__.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/_batch.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/_chunk.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/_socket.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/_time.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/base.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/callback.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/clickhouse.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/datadog.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/elasticsearch.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/eventhubs.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/file.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/filtering.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/firehose.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/honeycomb.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/http.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/kafka.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/kinesis.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/logging_sink.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/logstash.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/loki.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/mongodb.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/multi.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/nats.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/newrelic.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/postgres.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/pubsub.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/rabbitmq.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/redis.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/sentry.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/sns.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/splunk.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/sqlite.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/sqs.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/stdout.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/syslog.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/src/log_foundry/sinks/transform.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev4}/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.7.
|
|
3
|
+
Version: 0.7.2.dev4
|
|
4
4
|
Summary: Generate logs for your console and JSON events for downstream consumption.
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -681,9 +681,19 @@ lf.shutdown() # drain, close the sink, and stop for good; blocks until drained
|
|
|
681
681
|
explicitly when you need to be certain the tail reached the sink before a fast exit, e.g. at the
|
|
682
682
|
end of a short script. It is idempotent.
|
|
683
683
|
|
|
684
|
-
`flush(timeout=5.0)` returns `True` when
|
|
685
|
-
|
|
686
|
-
|
|
684
|
+
`flush(timeout=5.0)` returns `True` when **nothing was lost while the call was outstanding** — the
|
|
685
|
+
drain it forces reached the sink, and so did anything else the worker emitted while it waited its
|
|
686
|
+
turn. It returns `False` on timeout, when the worker was already shut down or has died, and when
|
|
687
|
+
any batch was abandoned inside that window. A `True` is evidence of delivery, not merely that a
|
|
688
|
+
drain took place.
|
|
689
|
+
|
|
690
|
+
The window starts when you call it. A batch abandoned *before* that is deliberately not its
|
|
691
|
+
business: the loss is already counted in `health().failed_batches` and reported on stderr, and
|
|
692
|
+
folding it in would make every later `flush()` in the process report a failure it did not incur.
|
|
693
|
+
So `flush()` answers "did the logs I am waiting on get out", and `health()` answers "has anything
|
|
694
|
+
been lost at all" — **check both**, as the handler below does.
|
|
695
|
+
|
|
696
|
+
It never raises — a logging call must not be the reason your function fails. Passing
|
|
687
697
|
`timeout=None` waits indefinitely, which is unsafe anywhere with an execution deadline.
|
|
688
698
|
|
|
689
699
|
#### Serverless / short-lived processes
|
|
@@ -715,9 +725,17 @@ def handler(event, context):
|
|
|
715
725
|
try:
|
|
716
726
|
return do_work(event)
|
|
717
727
|
finally:
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
728
|
+
# In `finally`: the failed invocation is the one worth logging. NEVER shutdown() here —
|
|
729
|
+
# the worker does not come back, and every later invocation on this warm container
|
|
730
|
+
# would log nothing.
|
|
731
|
+
drained = lf.flush()
|
|
732
|
+
h = lf.health()
|
|
733
|
+
if not drained or h.failed_batches or h.dropped or h.stopped_reason:
|
|
734
|
+
# `drained` covers this invocation's tail; the counters cover anything the worker
|
|
735
|
+
# lost earlier — a batch its own interval trigger already gave up on, for instance.
|
|
736
|
+
# Emitting this through your platform's own logger keeps it outside the pipeline
|
|
737
|
+
# that just failed.
|
|
738
|
+
print(f"log-foundry: undelivered logs ({drained=}, {h=})")
|
|
721
739
|
```
|
|
722
740
|
|
|
723
741
|
By default each invocation is its own trace, so N invocations produce N `trace_id`s. To join
|
|
@@ -765,6 +783,12 @@ the default configuration the interpreter's limit — not `max_value_bytes` —
|
|
|
765
783
|
unlikely to meet it deliberately; `int.from_bytes(blob, "big")` over a couple of kilobytes gets
|
|
766
784
|
there. Any ceiling firing sets `truncated: true` on the event.
|
|
767
785
|
|
|
786
|
+
`max_value_bytes` therefore carries two units: **UTF-8 bytes** for a string, **rendered decimal
|
|
787
|
+
length** (sign included) for an integer. They coincide for ASCII digits, and one ceiling for "how
|
|
788
|
+
big may a single value get" was preferred to a second config key. Note that all four ceilings
|
|
789
|
+
bound each *value* — an event of many bounded values can still be large; see
|
|
790
|
+
[Known constraints](docs/architecture.md#known-constraints).
|
|
791
|
+
|
|
768
792
|
## Development
|
|
769
793
|
|
|
770
794
|
```bash
|
|
@@ -645,9 +645,19 @@ lf.shutdown() # drain, close the sink, and stop for good; blocks until drained
|
|
|
645
645
|
explicitly when you need to be certain the tail reached the sink before a fast exit, e.g. at the
|
|
646
646
|
end of a short script. It is idempotent.
|
|
647
647
|
|
|
648
|
-
`flush(timeout=5.0)` returns `True` when
|
|
649
|
-
|
|
650
|
-
|
|
648
|
+
`flush(timeout=5.0)` returns `True` when **nothing was lost while the call was outstanding** — the
|
|
649
|
+
drain it forces reached the sink, and so did anything else the worker emitted while it waited its
|
|
650
|
+
turn. It returns `False` on timeout, when the worker was already shut down or has died, and when
|
|
651
|
+
any batch was abandoned inside that window. A `True` is evidence of delivery, not merely that a
|
|
652
|
+
drain took place.
|
|
653
|
+
|
|
654
|
+
The window starts when you call it. A batch abandoned *before* that is deliberately not its
|
|
655
|
+
business: the loss is already counted in `health().failed_batches` and reported on stderr, and
|
|
656
|
+
folding it in would make every later `flush()` in the process report a failure it did not incur.
|
|
657
|
+
So `flush()` answers "did the logs I am waiting on get out", and `health()` answers "has anything
|
|
658
|
+
been lost at all" — **check both**, as the handler below does.
|
|
659
|
+
|
|
660
|
+
It never raises — a logging call must not be the reason your function fails. Passing
|
|
651
661
|
`timeout=None` waits indefinitely, which is unsafe anywhere with an execution deadline.
|
|
652
662
|
|
|
653
663
|
#### Serverless / short-lived processes
|
|
@@ -679,9 +689,17 @@ def handler(event, context):
|
|
|
679
689
|
try:
|
|
680
690
|
return do_work(event)
|
|
681
691
|
finally:
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
692
|
+
# In `finally`: the failed invocation is the one worth logging. NEVER shutdown() here —
|
|
693
|
+
# the worker does not come back, and every later invocation on this warm container
|
|
694
|
+
# would log nothing.
|
|
695
|
+
drained = lf.flush()
|
|
696
|
+
h = lf.health()
|
|
697
|
+
if not drained or h.failed_batches or h.dropped or h.stopped_reason:
|
|
698
|
+
# `drained` covers this invocation's tail; the counters cover anything the worker
|
|
699
|
+
# lost earlier — a batch its own interval trigger already gave up on, for instance.
|
|
700
|
+
# Emitting this through your platform's own logger keeps it outside the pipeline
|
|
701
|
+
# that just failed.
|
|
702
|
+
print(f"log-foundry: undelivered logs ({drained=}, {h=})")
|
|
685
703
|
```
|
|
686
704
|
|
|
687
705
|
By default each invocation is its own trace, so N invocations produce N `trace_id`s. To join
|
|
@@ -729,6 +747,12 @@ the default configuration the interpreter's limit — not `max_value_bytes` —
|
|
|
729
747
|
unlikely to meet it deliberately; `int.from_bytes(blob, "big")` over a couple of kilobytes gets
|
|
730
748
|
there. Any ceiling firing sets `truncated: true` on the event.
|
|
731
749
|
|
|
750
|
+
`max_value_bytes` therefore carries two units: **UTF-8 bytes** for a string, **rendered decimal
|
|
751
|
+
length** (sign included) for an integer. They coincide for ASCII digits, and one ceiling for "how
|
|
752
|
+
big may a single value get" was preferred to a second config key. Note that all four ceilings
|
|
753
|
+
bound each *value* — an event of many bounded values can still be large; see
|
|
754
|
+
[Known constraints](docs/architecture.md#known-constraints).
|
|
755
|
+
|
|
732
756
|
## Development
|
|
733
757
|
|
|
734
758
|
```bash
|
|
@@ -20,7 +20,7 @@ dependencies = [
|
|
|
20
20
|
]
|
|
21
21
|
|
|
22
22
|
# Optional features. Install with: pip install log-foundry[aws]
|
|
23
|
-
version = "0.7.
|
|
23
|
+
version = "0.7.2.dev4"
|
|
24
24
|
|
|
25
25
|
[project.optional-dependencies]
|
|
26
26
|
aws = ["boto3>=1.34"] # SQSSink, SNSSink, KinesisSink, FirehoseSink
|
|
@@ -58,6 +58,7 @@ exclude = [
|
|
|
58
58
|
|
|
59
59
|
# Development-only dependencies (not shipped to consumers).
|
|
60
60
|
# Install with: poetry install --with dev
|
|
61
|
+
|
|
61
62
|
[tool.poetry.group.dev.dependencies]
|
|
62
63
|
pytest = "^9.0"
|
|
63
64
|
pytest-asyncio = "^1.4.0" # for testing async @trace (guide Phase 8)
|
|
@@ -30,10 +30,11 @@ except PackageNotFoundError: # running from a source tree that isn't installed
|
|
|
30
30
|
def flush(timeout: float | None = 5.0) -> bool:
|
|
31
31
|
"""Drain buffered events through the sink without closing it.
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
the caller cannot have meant those.) Unlike
|
|
36
|
-
alive and the sink stays open, so logging
|
|
33
|
+
``True`` means the events submitted before this call reached the sink; ``False`` means the
|
|
34
|
+
drain carrying them was abandoned, or never completed. (Events submitted concurrently by
|
|
35
|
+
another thread may or may not be included — the caller cannot have meant those.) Unlike
|
|
36
|
+
:func:`shutdown` the background worker stays alive and the sink stays open, so logging
|
|
37
|
+
continues normally afterwards.
|
|
37
38
|
|
|
38
39
|
This is the drain for a process that is *frozen* rather than exited — an AWS Lambda
|
|
39
40
|
handler must drain before it returns, but will be invoked again on the same warm
|
|
@@ -42,8 +43,11 @@ def flush(timeout: float | None = 5.0) -> bool:
|
|
|
42
43
|
``timeout=None`` waits indefinitely, which is unsafe in any environment with an execution
|
|
43
44
|
deadline — it converts "some logs were lost" into "the invocation timed out".
|
|
44
45
|
|
|
45
|
-
Returns ``False`` if the drain did not complete within ``timeout``,
|
|
46
|
-
already been shut down
|
|
46
|
+
Returns ``False`` if the drain did not complete within ``timeout``, if the worker has
|
|
47
|
+
already been shut down or has died, or if a batch was abandoned while this call was
|
|
48
|
+
outstanding (SPEC-021 FR-001) — a ``True`` means the events were delivered, not merely that
|
|
49
|
+
a drain took place. A batch lost *before* the call is outside this call's window and belongs
|
|
50
|
+
to :func:`health`, whose ``failed_batches`` is the cumulative record. Never raises.
|
|
47
51
|
"""
|
|
48
52
|
from log_foundry.decorator import _flush_worker
|
|
49
53
|
|
|
@@ -24,6 +24,15 @@ class Config:
|
|
|
24
24
|
The four ``max_*`` ceilings bound every event payload (SPEC-017 FR-002). Defaults are set so
|
|
25
25
|
that the overwhelming majority of events are untouched; they exist to stop *one* pathological
|
|
26
26
|
value getting a whole event rejected by a sink's hard limit.
|
|
27
|
+
|
|
28
|
+
``max_value_bytes`` carries **two units**, deliberately (SPEC-020, recorded by SPEC-021): a
|
|
29
|
+
string is measured in UTF-8 bytes, an integer in the decimal length it renders as, sign
|
|
30
|
+
included. They coincide for ASCII digits, and one ceiling covering "how big may a single
|
|
31
|
+
value get" beat a second config key for a distinction almost no one configures. An integer is
|
|
32
|
+
also bounded by ``sys.get_int_max_str_digits()`` whenever that is lower, since a longer one
|
|
33
|
+
cannot be rendered at all.
|
|
34
|
+
|
|
35
|
+
They bound each *value*, not the event as a whole — see arch §13 Known Constraints.
|
|
27
36
|
"""
|
|
28
37
|
|
|
29
38
|
service: str = "unknown"
|
|
@@ -31,7 +40,7 @@ class Config:
|
|
|
31
40
|
env: str = "dev"
|
|
32
41
|
sink: Sink | None = None
|
|
33
42
|
defaults: dict[str, object] = field(default_factory=dict)
|
|
34
|
-
max_value_bytes: int = 8192 # per
|
|
43
|
+
max_value_bytes: int = 8192 # per value: UTF-8 bytes for a str, rendered digits for an int
|
|
35
44
|
max_stack_bytes: int = 32768 # error.stack only — legitimately long, and worth keeping
|
|
36
45
|
max_keys: int = 256 # per mapping / sequence
|
|
37
46
|
max_depth: int = 8 # nesting levels
|
|
@@ -55,6 +55,12 @@ _DEPTH_LIMIT = "<depth limit>"
|
|
|
55
55
|
_LOG10_2_NUM = 30103
|
|
56
56
|
_LOG10_2_DEN = 100000
|
|
57
57
|
|
|
58
|
+
# ``int.__lt__`` bound once, for the sign test in :meth:`_Coercer.integer` (SPEC-021 FR-003). The
|
|
59
|
+
# unbound slot cannot be diverted by an ``int`` subclass the way ``value < 0`` can, but reaching
|
|
60
|
+
# it through ``int.__lt__`` costs a global plus an attribute lookup on a per-value hot path;
|
|
61
|
+
# binding it here recovers about a third of that, the same idiom as the constants above.
|
|
62
|
+
_INT_LT = int.__lt__
|
|
63
|
+
|
|
58
64
|
# Exact-type membership, deliberately not ``isinstance``. ``IntEnum``/``StrEnum`` members *are*
|
|
59
65
|
# ``int``/``str`` instances, so an isinstance check would pass the enum member itself through and
|
|
60
66
|
# hand a sink an ``Enum`` where a plain value was promised. Exact typing lets them fall to the
|
|
@@ -74,6 +80,11 @@ def _int_digit_ceiling(max_value_bytes: int) -> int:
|
|
|
74
80
|
raises ``ValueError``, which ``json.dumps`` inherits. A configured ceiling above that cannot be
|
|
75
81
|
honoured — rendering such an integer is the very thing that raises — so the interpreter's limit
|
|
76
82
|
wins whenever it is lower. A limit of ``0`` means the interpreter imposes none.
|
|
83
|
+
|
|
84
|
+
The caller measures a negative value's rendered length, sign included, against this; the
|
|
85
|
+
interpreter's own limit counts digits only. A negative integer sitting exactly on the
|
|
86
|
+
interpreter bound is therefore replaced rather than rendered — one value at the far edge, in
|
|
87
|
+
the direction this module always errs (SPEC-021 FR-003).
|
|
77
88
|
"""
|
|
78
89
|
limit = sys.get_int_max_str_digits()
|
|
79
90
|
return max_value_bytes if limit <= 0 else min(max_value_bytes, limit)
|
|
@@ -278,7 +289,11 @@ class _Coercer:
|
|
|
278
289
|
The size test is ``bit_length()``, never ``len(str(value))``: converting an over-long
|
|
279
290
|
integer to a string raises the very ``ValueError`` this bound exists to prevent, so the
|
|
280
291
|
obvious check would move the crash rather than remove it (FR-002). ``bit_length()`` is
|
|
281
|
-
O(1), total, and ignores the sign
|
|
292
|
+
O(1), total, and ignores the sign — so the minus sign is added back explicitly, since the
|
|
293
|
+
ceiling measures what the value *renders* as and ``-10**9`` renders as eleven bytes
|
|
294
|
+
(SPEC-021 FR-003). The placeholder still names the digit count: a sign is not a digit.
|
|
295
|
+
The sign is read through an *unbound* ``int.__lt__``, which an ``int`` subclass cannot
|
|
296
|
+
divert; ``bit_length()`` stays the one trusted call, as below.
|
|
282
297
|
|
|
283
298
|
An over-long integer is *replaced*, not clipped. Dropping digits would silently change the
|
|
284
299
|
value, and a wrong number is worse than a visibly elided one — so this reuses the
|
|
@@ -291,7 +306,13 @@ class _Coercer:
|
|
|
291
306
|
``type()`` check to catch only a value engineered to lie about itself.
|
|
292
307
|
"""
|
|
293
308
|
digits = value.bit_length() * _LOG10_2_NUM // _LOG10_2_DEN + 1
|
|
294
|
-
|
|
309
|
+
# ``_INT_LT`` (unbound ``int.__lt__``), never ``value < 0``: on an ``int`` *subclass* the
|
|
310
|
+
# operator dispatches to user code, which can raise — and a raise here is caught up in
|
|
311
|
+
# :meth:`value`, replacing the whole enclosing mapping and taking every sibling key with
|
|
312
|
+
# it, which is precisely what :meth:`key` was written to prevent. The unbound call reads
|
|
313
|
+
# the underlying integer and cannot be overridden.
|
|
314
|
+
rendered = digits + 1 if _INT_LT(value, 0) else digits
|
|
315
|
+
if rendered <= _int_digit_ceiling(self._cfg.max_value_bytes):
|
|
295
316
|
return value
|
|
296
317
|
self.truncated = True
|
|
297
318
|
return f"<int: ~{digits} digits>"
|
|
@@ -74,12 +74,41 @@ class _FlushMarker:
|
|
|
74
74
|
|
|
75
75
|
Like ``_SHUTDOWN`` it is never emitted — but unlike ``_SHUTDOWN`` it carries state, so it is
|
|
76
76
|
a class rather than a bare sentinel object.
|
|
77
|
+
|
|
78
|
+
``delivered`` carries the drain's *outcome* back to the waiter, not merely the fact that the
|
|
79
|
+
marker was reached (SPEC-021 FR-001). It is written by the drain thread before ``event.set()``
|
|
80
|
+
and read by the waiter after ``event.wait()`` returns, so the ``Event`` supplies the ordering
|
|
81
|
+
and no further lock is needed.
|
|
82
|
+
|
|
83
|
+
The outcome is computed from ``seen_failures`` — ``Worker.failed_batches`` as it stood when
|
|
84
|
+
this marker was created, on the *caller's* thread — against the same counter when the marker is
|
|
85
|
+
answered. Equal means nothing was abandoned while this flush was outstanding, which covers both
|
|
86
|
+
the batch the marker forces and any batch another flush or a batching trigger emitted in the
|
|
87
|
+
meantime. So every flush *outstanding* when a batch is abandoned reports it, including the ones
|
|
88
|
+
whose own emit found nothing left to do because another marker's emit had just cleared and lost
|
|
89
|
+
it — that case was the original false success.
|
|
90
|
+
|
|
91
|
+
Two limits, both deliberate. A batch abandoned *before* the call is not in scope: that loss is
|
|
92
|
+
already in ``failed_batches`` and on stderr, and folding it in would make every later empty
|
|
93
|
+
flush report a failure it did not incur. It follows that two *concurrent* flushes need not
|
|
94
|
+
agree — one stamped before the abandonment and one after are the outstanding case and the
|
|
95
|
+
already-lost case respectively, and the race between them decides which is which. Reporting a
|
|
96
|
+
past loss and not letting a past loss stick are the same property read in opposite directions;
|
|
97
|
+
FR-001's "an empty drain is a successful one" chooses. ``health().failed_batches`` is what
|
|
98
|
+
reports a loss regardless of when anyone asked.
|
|
99
|
+
|
|
100
|
+
``delivered`` starts ``False``: every path that answers a marker assigns it explicitly, so the
|
|
101
|
+
default is read only when the drain thread died without computing an answer, where "I could not
|
|
102
|
+
establish that this was delivered" is the honest reading. (The spec's Data Model sketched the
|
|
103
|
+
default as ``True``, before the concurrent-flush case moved the empty-drain answer off it.)
|
|
77
104
|
"""
|
|
78
105
|
|
|
79
|
-
__slots__ = ("event",)
|
|
106
|
+
__slots__ = ("delivered", "event", "seen_failures")
|
|
80
107
|
|
|
81
|
-
def __init__(self) -> None:
|
|
108
|
+
def __init__(self, seen_failures: int) -> None:
|
|
82
109
|
self.event = threading.Event()
|
|
110
|
+
self.delivered = False
|
|
111
|
+
self.seen_failures = seen_failures
|
|
83
112
|
|
|
84
113
|
|
|
85
114
|
class Worker:
|
|
@@ -162,10 +191,24 @@ class Worker:
|
|
|
162
191
|
def flush(self, timeout: float | None = 5.0) -> bool:
|
|
163
192
|
"""Drain everything submitted before this call through the sink, without stopping.
|
|
164
193
|
|
|
165
|
-
Returns ``True`` once the worker has
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
194
|
+
Returns ``True`` once the worker has *delivered* them — ``False`` on timeout, on a worker
|
|
195
|
+
already shut down or dead, on a queue too full to accept the marker, and when the drain
|
|
196
|
+
carrying those events was abandoned after exhausting retries (SPEC-021 FR-001). That last
|
|
197
|
+
case used to return ``True``: the drain had run, so the marker was answered regardless of
|
|
198
|
+
what came of the emit. It is a false success exactly where ``flush()`` matters most — a
|
|
199
|
+
serverless handler draining before the environment freezes has the return value as its
|
|
200
|
+
only evidence the tail of the queue survived.
|
|
201
|
+
|
|
202
|
+
The precise claim is *nothing was abandoned while this call was outstanding*: the batch
|
|
203
|
+
this flush forces, and any batch another flush or a batching trigger emitted while its
|
|
204
|
+
marker waited its turn. A batch abandoned **before** the call is not in scope — that loss
|
|
205
|
+
is already in ``failed_batches`` and on stderr, and counting it here would make every
|
|
206
|
+
later empty flush report a failure it did not incur. ``health()`` is the cumulative
|
|
207
|
+
record; this is a verdict on one drain.
|
|
208
|
+
|
|
209
|
+
Unlike :meth:`shutdown` the thread keeps running, the sink is **not** closed, and the
|
|
210
|
+
once-only shutdown flag is untouched, so logging continues normally afterwards
|
|
211
|
+
(SPEC-013 FR-002).
|
|
169
212
|
"""
|
|
170
213
|
with self._lock:
|
|
171
214
|
if self._shutdown_done:
|
|
@@ -175,7 +218,12 @@ class Worker:
|
|
|
175
218
|
return False
|
|
176
219
|
if not self._thread.is_alive():
|
|
177
220
|
return False
|
|
178
|
-
|
|
221
|
+
with self._lock:
|
|
222
|
+
# Stamped here, on the caller's thread, *before* the marker joins the queue: it is the
|
|
223
|
+
# baseline the drain thread compares against, so anything abandoned from this moment
|
|
224
|
+
# on is attributed to this flush. Reading it after the put would race the very emit
|
|
225
|
+
# the flush is about to force.
|
|
226
|
+
marker = _FlushMarker(self.failed_batches)
|
|
179
227
|
# One deadline shared by the put and the wait: a caller asked for a bound on the whole
|
|
180
228
|
# call, not on each half of it, so the two cannot add up to 2 * timeout.
|
|
181
229
|
deadline = None if timeout is None else time.monotonic() + timeout
|
|
@@ -187,7 +235,9 @@ class Worker:
|
|
|
187
235
|
except queue.Full:
|
|
188
236
|
return False
|
|
189
237
|
remaining = None if deadline is None else max(0.0, deadline - time.monotonic())
|
|
190
|
-
|
|
238
|
+
if not marker.event.wait(remaining):
|
|
239
|
+
return False # timed out: the drain never happened, so it delivered nothing.
|
|
240
|
+
return marker.delivered
|
|
191
241
|
|
|
192
242
|
def shutdown(self) -> None:
|
|
193
243
|
"""Stop the thread, drain + emit everything queued, then ``close()`` the sink.
|
|
@@ -224,6 +274,31 @@ class Worker:
|
|
|
224
274
|
self._drain(pending)
|
|
225
275
|
except BaseException as exc: # deliberately broad — see the docstring
|
|
226
276
|
self._terminal_failure(exc, len(pending))
|
|
277
|
+
self._release_waiters()
|
|
278
|
+
|
|
279
|
+
def _release_waiters(self) -> None:
|
|
280
|
+
"""Answer every ``flush()`` marker still queued, so no caller waits out its timeout.
|
|
281
|
+
|
|
282
|
+
A ``BaseException`` from the main loop skips ``_final_drain`` entirely, which is where
|
|
283
|
+
queued markers are normally answered — leaving a waiter to sit for its full ``timeout``
|
|
284
|
+
on a thread that is never coming back. FR-001 requires the waiter to be released on every
|
|
285
|
+
path, and this is the last one.
|
|
286
|
+
|
|
287
|
+
The markers are *read* out of the queue rather than consumed: the queued event-lists are
|
|
288
|
+
the evidence ``health().queued`` and the terminal line report, and answering a waiter must
|
|
289
|
+
not erase it. Each keeps its pessimistic ``delivered``, which is the truth here.
|
|
290
|
+
|
|
291
|
+
Residual race, stated rather than papered over: a ``flush()`` that passed its liveness
|
|
292
|
+
check microseconds before the thread died can still enqueue a marker after this sweep, and
|
|
293
|
+
that one waits out its timeout — and then returns ``False``, which is correct either way.
|
|
294
|
+
"""
|
|
295
|
+
try:
|
|
296
|
+
with self._queue.mutex: # a snapshot, so a marker cannot be missed mid-iteration
|
|
297
|
+
markers = [i for i in self._queue.queue if isinstance(i, _FlushMarker)]
|
|
298
|
+
for marker in markers:
|
|
299
|
+
marker.event.set()
|
|
300
|
+
except Exception: # this runs *after* the record and the stderr line; neither may be lost
|
|
301
|
+
pass
|
|
227
302
|
|
|
228
303
|
def _terminal_failure(self, exc: BaseException, undrained: int) -> None:
|
|
229
304
|
"""Record the drain loop's terminal exit, then announce it (FR-001, FR-002).
|
|
@@ -233,14 +308,30 @@ class Worker:
|
|
|
233
308
|
must not be able to ride on it. The exception's *type* is reported and its message is
|
|
234
309
|
not — a sink's exception text can carry event data, and arch §6 keeps caller data out of
|
|
235
310
|
places it was not asked for (the same rule behind ``sanitize``'s type-name placeholder).
|
|
311
|
+
|
|
312
|
+
The count reports what was *in hand* and what was still *queued behind it* (SPEC-021
|
|
313
|
+
FR-002). Held alone under-reads the loss: nothing will drain the queue either, so an
|
|
314
|
+
operator reading "1 undrained event-list(s)" could conclude far less was lost than was.
|
|
315
|
+
|
|
316
|
+
The queued figure is "items", not "event-lists", and says so: like ``Health.queued`` it is
|
|
317
|
+
read without stopping the world, so it counts any internal flush/shutdown marker sitting
|
|
318
|
+
alongside real submissions, and a producer thread can add to the queue between the death
|
|
319
|
+
and the read. It is a floor on what was lost, which is the useful direction.
|
|
236
320
|
"""
|
|
237
321
|
name = type(exc).__name__
|
|
238
322
|
with self._lock:
|
|
239
323
|
self.stopped_reason = name
|
|
324
|
+
try:
|
|
325
|
+
# In its own guard, and after the record: ``qsize()`` is not guaranteed on every
|
|
326
|
+
# platform's queue, and a diagnostic must not be the reason the diagnosis is lost.
|
|
327
|
+
queued: object = self._queue.qsize()
|
|
328
|
+
except Exception:
|
|
329
|
+
queued = "?"
|
|
240
330
|
try:
|
|
241
331
|
sys.stderr.write(
|
|
242
332
|
f"log-foundry: worker thread stopped on {name}; {undrained} undrained "
|
|
243
|
-
f"event-list(s), nothing further
|
|
333
|
+
f"event-list(s) held and {queued} queued item(s) undelivered, nothing further "
|
|
334
|
+
f"will be delivered\n"
|
|
244
335
|
)
|
|
245
336
|
except Exception: # best-effort: the record above is what an operator reads.
|
|
246
337
|
pass
|
|
@@ -265,12 +356,13 @@ class Worker:
|
|
|
265
356
|
# marker must never fall through to the append below, where it would be treated
|
|
266
357
|
# as a list of events and handed to sink.emit, killing this thread.
|
|
267
358
|
try:
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
pending.clear()
|
|
359
|
+
self._emit_pending(pending)
|
|
360
|
+
item.delivered = self._nothing_lost_since(item)
|
|
271
361
|
finally:
|
|
272
362
|
# Signal even if the emit died, so a waiter is released rather than left to
|
|
273
|
-
# wait out its timeout on a thread that is no longer running.
|
|
363
|
+
# wait out its timeout on a thread that is no longer running. A failing flush
|
|
364
|
+
# therefore returns False promptly rather than at the caller's timeout, and
|
|
365
|
+
# an emit that died leaves the marker's pessimistic default standing.
|
|
274
366
|
last_flush = time.monotonic()
|
|
275
367
|
item.event.set()
|
|
276
368
|
continue
|
|
@@ -278,15 +370,38 @@ class Worker:
|
|
|
278
370
|
pending.append(cast("list[dict[str, object]]", item))
|
|
279
371
|
now = time.monotonic()
|
|
280
372
|
if len(pending) >= self.batch_size or now - last_flush >= self.flush_interval:
|
|
281
|
-
|
|
282
|
-
self._emit(pending)
|
|
283
|
-
pending.clear()
|
|
373
|
+
self._emit_pending(pending)
|
|
284
374
|
# Advance the window even when idle (pending empty). Otherwise last_flush never
|
|
285
375
|
# moves while the queue is empty, timeout collapses to 0.0, and get(timeout=0.0)
|
|
286
376
|
# busy-spins a core. Resetting it lets the next get() block a full interval.
|
|
287
377
|
last_flush = now
|
|
288
378
|
self._final_drain(pending)
|
|
289
379
|
|
|
380
|
+
def _emit_pending(self, pending: list[list[dict[str, object]]]) -> None:
|
|
381
|
+
"""Emit ``pending`` if there is any, then clear it. The two always go together."""
|
|
382
|
+
if pending:
|
|
383
|
+
self._emit(pending)
|
|
384
|
+
pending.clear()
|
|
385
|
+
|
|
386
|
+
def _nothing_lost_since(self, marker: _FlushMarker) -> bool:
|
|
387
|
+
"""Whether any batch was abandoned while ``marker`` was outstanding (SPEC-021 FR-001).
|
|
388
|
+
|
|
389
|
+
``failed_batches`` moves exactly once per abandoned batch, so comparing it against the
|
|
390
|
+
marker's stamp answers "was anything lost while this flush was in flight" — which is the
|
|
391
|
+
question ``flush()`` asks, and a stronger one than "did *my* emit succeed". A marker whose
|
|
392
|
+
own emit found nothing pending still reports the loss if another flush's emit, or a
|
|
393
|
+
batching trigger's, abandoned a batch while it waited its turn.
|
|
394
|
+
|
|
395
|
+
Deliberately *not* a running "has anything ever failed" flag. That would make every empty
|
|
396
|
+
flush after a single bad batch report a failure it did not incur, contradicting the rule
|
|
397
|
+
that an empty drain is a successful one.
|
|
398
|
+
|
|
399
|
+
Called on the drain thread; the lock is for the counter's other readers, and is held only
|
|
400
|
+
for the read (``shutdown()`` releases it before ``join()``, so it cannot deadlock).
|
|
401
|
+
"""
|
|
402
|
+
with self._lock:
|
|
403
|
+
return self.failed_batches == marker.seen_failures
|
|
404
|
+
|
|
290
405
|
def _final_drain(self, pending: list[list[dict[str, object]]]) -> None:
|
|
291
406
|
"""On stop, pull anything still queued and emit the tail as one final batch."""
|
|
292
407
|
markers: list[_FlushMarker] = []
|
|
@@ -298,15 +413,21 @@ class Worker:
|
|
|
298
413
|
if isinstance(item, _FlushMarker):
|
|
299
414
|
# This guard is a second copy of _drain's and needs the same exclusion. Markers
|
|
300
415
|
# are answered *after* the final emit below, so a flush() that raced shutdown()
|
|
301
|
-
#
|
|
416
|
+
# is answered by the drain that carried its events — with that drain's outcome.
|
|
302
417
|
markers.append(item)
|
|
303
418
|
continue
|
|
304
419
|
if item is not None and item is not _SHUTDOWN:
|
|
305
420
|
pending.append(cast("list[dict[str, object]]", item))
|
|
306
|
-
|
|
307
|
-
self.
|
|
308
|
-
|
|
309
|
-
|
|
421
|
+
try:
|
|
422
|
+
self._emit_pending(pending)
|
|
423
|
+
for marker in markers:
|
|
424
|
+
marker.delivered = self._nothing_lost_since(marker)
|
|
425
|
+
finally:
|
|
426
|
+
# In a ``finally`` so a ``BaseException`` from the final emit cannot strand a waiter
|
|
427
|
+
# for its whole timeout — the markers are released carrying their pessimistic
|
|
428
|
+
# default, which is the truth about a batch that died with this thread.
|
|
429
|
+
for marker in markers:
|
|
430
|
+
marker.event.set()
|
|
310
431
|
|
|
311
432
|
def _emit(self, event_lists: list[list[dict[str, object]]]) -> None:
|
|
312
433
|
"""Flatten queued per-span event-lists into one batch and emit, retrying with backoff.
|
|
@@ -314,23 +435,33 @@ class Worker:
|
|
|
314
435
|
A failing ``sink.emit`` is retried up to ``max_retries`` times; past that the batch is
|
|
315
436
|
abandoned with a counted warning and draining continues, so a broken sink never crashes
|
|
316
437
|
the worker thread or the app (FR-002, FR-003).
|
|
438
|
+
|
|
439
|
+
The outcome is not returned: ``failed_batches`` already moves exactly once per abandoned
|
|
440
|
+
batch, and that counter is what a waiting ``flush()`` is compared against (SPEC-021
|
|
441
|
+
FR-001), so a second channel for the same fact could only disagree with it.
|
|
442
|
+
|
|
443
|
+
``max_retries`` is floored at zero so the loop always makes at least one attempt. A
|
|
444
|
+
negative value otherwise skipped the emit entirely and discarded the batch with no
|
|
445
|
+
attempt, no counter and nothing on stderr — a silent loss of exactly the kind this arc of
|
|
446
|
+
specs exists to remove, reachable only by misconfiguration but reachable.
|
|
317
447
|
"""
|
|
318
448
|
batch = [event for events in event_lists for event in events]
|
|
319
449
|
if not batch:
|
|
320
450
|
return
|
|
321
|
-
|
|
451
|
+
retries = max(self.max_retries, 0)
|
|
452
|
+
for attempt in range(retries + 1):
|
|
322
453
|
try:
|
|
323
454
|
self.sink.emit(batch)
|
|
324
455
|
return
|
|
325
456
|
except Exception: # any sink failure must not kill the worker thread
|
|
326
|
-
if attempt >=
|
|
457
|
+
if attempt >= retries:
|
|
327
458
|
# Under the lock so a concurrent health() sees a coherent snapshot rather
|
|
328
459
|
# than a half-updated pair. No deadlock: shutdown() releases before join().
|
|
329
460
|
with self._lock:
|
|
330
461
|
self.failed_batches += 1
|
|
331
462
|
sys.stderr.write(
|
|
332
463
|
f"log-foundry: abandoned a batch of {len(batch)} event(s) after "
|
|
333
|
-
f"{
|
|
464
|
+
f"{retries + 1} failed emit attempts\n"
|
|
334
465
|
)
|
|
335
466
|
return
|
|
336
467
|
# Backoff between attempts; _stop.wait returns at once during shutdown, so a
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|