log-foundry 0.10.2.dev29__tar.gz → 0.10.2.dev31__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.dev29 → log_foundry-0.10.2.dev31}/PKG-INFO +10 -1
- {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/README.md +9 -0
- {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/pyproject.toml +1 -1
- {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/__init__.py +12 -1
- {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/api.py +25 -1
- {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/console.py +13 -2
- {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/decorator.py +140 -8
- {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/model.py +8 -7
- {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sanitize.py +8 -2
- {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/_socket.py +35 -7
- {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/file.py +21 -7
- {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/logstash.py +12 -0
- {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/stdout.py +7 -2
- {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/syslog.py +12 -0
- {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/util.py +8 -3
- {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/worker.py +10 -1
- {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/LICENSE +0 -0
- {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/_diag.py +0 -0
- {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/config.py +0 -0
- {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/context.py +0 -0
- {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/ids.py +0 -0
- {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/py.typed +0 -0
- {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/__init__.py +0 -0
- {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/_batch.py +0 -0
- {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/_chunk.py +0 -0
- {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/_retry.py +0 -0
- {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/_time.py +0 -0
- {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/base.py +0 -0
- {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/callback.py +0 -0
- {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/clickhouse.py +0 -0
- {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/datadog.py +0 -0
- {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/elasticsearch.py +0 -0
- {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/eventhubs.py +0 -0
- {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/filtering.py +0 -0
- {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/firehose.py +0 -0
- {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/honeycomb.py +0 -0
- {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/http.py +0 -0
- {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/kafka.py +0 -0
- {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/kinesis.py +0 -0
- {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/logging_sink.py +0 -0
- {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/loki.py +0 -0
- {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/mongodb.py +0 -0
- {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/multi.py +0 -0
- {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/nats.py +0 -0
- {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/newrelic.py +0 -0
- {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/postgres.py +0 -0
- {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/pubsub.py +0 -0
- {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/rabbitmq.py +0 -0
- {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/redis.py +0 -0
- {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/sentry.py +0 -0
- {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/sns.py +0 -0
- {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/splunk.py +0 -0
- {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/sqlite.py +0 -0
- {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/sqs.py +0 -0
- {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/transform.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.dev31
|
|
4
4
|
Summary: Generate logs for your console and JSON events for downstream consumption.
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -862,6 +862,15 @@ logging is doing the right thing; it is the *pair* — retired, and still being
|
|
|
862
862
|
means every log line since the shutdown has gone nowhere. That state used to read as perfectly
|
|
863
863
|
healthy: `stopped_reason` is `None` after a clean shutdown, and the queue simply grows.
|
|
864
864
|
|
|
865
|
+
`retired` is also the one field reported for a process that has **no worker at all**. A program
|
|
866
|
+
that only ever calls `info()`/`error()` outside a span emits synchronously and builds no background
|
|
867
|
+
worker, so every other field describes something that does not exist and reads zero. Its
|
|
868
|
+
`shutdown()` still closes the sink, exactly once and without starting a thread, and `retired` reads
|
|
869
|
+
`True` afterwards rather than staying vacuously `False`. `submitted_after_shutdown` stays `0` there
|
|
870
|
+
by design: a later level call is *refused* at the closed sink and announced on stderr — if the sink
|
|
871
|
+
guards its own post-close state — rather than queued where nothing will drain it, and those are not
|
|
872
|
+
the same claim. A stateless sink such as the default `StdoutSink` still accepts it.
|
|
873
|
+
|
|
865
874
|
Read a snapshot by attribute (`h.dropped`), as above. `Health` is a `NamedTuple` and has gained
|
|
866
875
|
fields over time — a fourth (`stopped_reason`) in `v0.7.0`, and a fifth (`sink`) plus four more
|
|
867
876
|
(`retired`, `submitted_after_shutdown`, `incomplete_swaps`, `closing_sinks`) not yet in a tagged
|
|
@@ -826,6 +826,15 @@ logging is doing the right thing; it is the *pair* — retired, and still being
|
|
|
826
826
|
means every log line since the shutdown has gone nowhere. That state used to read as perfectly
|
|
827
827
|
healthy: `stopped_reason` is `None` after a clean shutdown, and the queue simply grows.
|
|
828
828
|
|
|
829
|
+
`retired` is also the one field reported for a process that has **no worker at all**. A program
|
|
830
|
+
that only ever calls `info()`/`error()` outside a span emits synchronously and builds no background
|
|
831
|
+
worker, so every other field describes something that does not exist and reads zero. Its
|
|
832
|
+
`shutdown()` still closes the sink, exactly once and without starting a thread, and `retired` reads
|
|
833
|
+
`True` afterwards rather than staying vacuously `False`. `submitted_after_shutdown` stays `0` there
|
|
834
|
+
by design: a later level call is *refused* at the closed sink and announced on stderr — if the sink
|
|
835
|
+
guards its own post-close state — rather than queued where nothing will drain it, and those are not
|
|
836
|
+
the same claim. A stateless sink such as the default `StdoutSink` still accepts it.
|
|
837
|
+
|
|
829
838
|
Read a snapshot by attribute (`h.dropped`), as above. `Health` is a `NamedTuple` and has gained
|
|
830
839
|
fields over time — a fourth (`stopped_reason`) in `v0.7.0`, and a fifth (`sink`) plus four more
|
|
831
840
|
(`retired`, `submitted_after_shutdown`, `incomplete_swaps`, `closing_sinks`) not yet in a tagged
|
|
@@ -98,7 +98,9 @@ def health() -> Health:
|
|
|
98
98
|
``failed`` is an upper bound on loss rather than a count of it, since a sink that raises
|
|
99
99
|
on total failure counts the attempt and hands the batch back for the worker to retry. A
|
|
100
100
|
process that has never logged has no worker, and asking does not create one — the
|
|
101
|
-
snapshot is simply zeroed
|
|
101
|
+
snapshot is simply zeroed, except for ``retired``, which stays truthful even for a
|
|
102
|
+
process that only ever logged outside a span and so built no worker at all (SPEC-031
|
|
103
|
+
FR-006). Valid after :func:`shutdown`.
|
|
102
104
|
|
|
103
105
|
Raises:
|
|
104
106
|
None.
|
|
@@ -122,6 +124,15 @@ def shutdown(timeout: float | None = DEFAULT_SHUTDOWN_TIMEOUT) -> None:
|
|
|
122
124
|
non-zero ``submitted_after_shutdown``, and the first such submission writes one stderr line
|
|
123
125
|
(SPEC-030). That pair is the reading that catches the mistake above.
|
|
124
126
|
|
|
127
|
+
A process that only ever logged **outside** a span built no worker, and this used to be a
|
|
128
|
+
no-op there, leaving the sink open forever: every event lost on a sink whose ``close()`` is
|
|
129
|
+
what delivers them, the flush and the resource on a synchronous one. It now closes that
|
|
130
|
+
sink — exactly once, and without creating a worker to do it — and ``health().retired``
|
|
131
|
+
reads ``True`` afterwards rather than staying vacuously ``False`` (SPEC-031 FR-006). A
|
|
132
|
+
later level call reaches a closed sink, so a sink that guards its own post-close state
|
|
133
|
+
refuses it and one stderr line is written; a stateless one such as ``StdoutSink`` still
|
|
134
|
+
accepts it (SPEC-032).
|
|
135
|
+
|
|
125
136
|
Args:
|
|
126
137
|
timeout: Seconds bounding the wait for the background thread and, carved from the same
|
|
127
138
|
budget, a short grace for any sink still closing after a late ``configure(sink=...)``
|
|
@@ -6,6 +6,7 @@ from log_foundry import _diag, context
|
|
|
6
6
|
from log_foundry.config import _ensure_sink
|
|
7
7
|
from log_foundry.console import ConsoleWriter
|
|
8
8
|
from log_foundry.context import set_baggage
|
|
9
|
+
from log_foundry.decorator import _note_orphan_emit
|
|
9
10
|
from log_foundry.ids import new_span_id, new_trace_id
|
|
10
11
|
from log_foundry.model import Span, build_event
|
|
11
12
|
|
|
@@ -30,6 +31,27 @@ def _log(level: str, message: str, echo: bool, fields: dict[str, object]) -> Non
|
|
|
30
31
|
the sink resolved through ``_ensure_sink`` so a zero-config orphan log falls back to
|
|
31
32
|
``StdoutSink`` rather than crashing.
|
|
32
33
|
|
|
34
|
+
That direct handoff is **settled, not pending**: SPEC-004's worker took over the traced
|
|
35
|
+
path and this branch was deliberately left synchronous, so a level call outside a span is
|
|
36
|
+
never silently dropped for want of a worker (``architecture.md`` §12 Resolved, "Orphan
|
|
37
|
+
logs"). It carried a comment saying the worker "will later" own it long after the
|
|
38
|
+
decision was made (SPEC-031 FR-003).
|
|
39
|
+
|
|
40
|
+
Because no worker is built here, nothing else in the library knows the sink was ever
|
|
41
|
+
written to — so this branch records it (SPEC-031 FR-006). That is what arms the exit-time
|
|
42
|
+
close for a process which only ever logs this way, and keying it on a sink this call is
|
|
43
|
+
about to write to, rather than on a *configured* sink, is deliberate: ``configure()``
|
|
44
|
+
materializes a ``StdoutSink`` whether or not anything is logged through it, and closing one
|
|
45
|
+
nothing was ever written to is cost with no benefit.
|
|
46
|
+
|
|
47
|
+
It is armed **before** the emit rather than after it, which matters for the sinks most
|
|
48
|
+
likely to need the close. SPEC-026 FR-001 makes a total failure raise, so an orphan-only
|
|
49
|
+
process against a dead syslog or HTTP destination raises on every call — and arming
|
|
50
|
+
afterwards would leave the socket that failure came from open forever, which is the leak
|
|
51
|
+
this FR exists to stop, in exactly the case that is leaking. A sink that raised is still a
|
|
52
|
+
sink that was written to. ``_ensure_sink`` is resolved first, so a sink that fails to
|
|
53
|
+
*construct* arms nothing: there is nothing to close.
|
|
54
|
+
|
|
33
55
|
The orphan branch is the one that reaches the sink on the caller's own thread, with no
|
|
34
56
|
worker between them to absorb a failure, so the whole branch is guarded (SPEC-025
|
|
35
57
|
FR-003) — ``_ensure_sink`` constructs the sink on first use, so a sink that fails to
|
|
@@ -66,7 +88,9 @@ def _log(level: str, message: str, echo: bool, fields: dict[str, object]) -> Non
|
|
|
66
88
|
start_ts=0.0,
|
|
67
89
|
)
|
|
68
90
|
event = build_event(orphan, level, message, fields=fields, baggage=baggage)
|
|
69
|
-
_ensure_sink()
|
|
91
|
+
sink = _ensure_sink()
|
|
92
|
+
_note_orphan_emit()
|
|
93
|
+
sink.emit([event])
|
|
70
94
|
except Exception as exc:
|
|
71
95
|
_diag.absorbed("emitting an orphan log", exc, "the event was lost")
|
|
72
96
|
if echo and event is not None:
|
|
@@ -16,13 +16,24 @@ class ConsoleWriter:
|
|
|
16
16
|
operator sees it without waiting for the async flush. It is deliberately dumb, rendering
|
|
17
17
|
an already-built event dict and knowing nothing about spans, and echo is additive — an
|
|
18
18
|
echoed event still rides the normal pipeline to the sink.
|
|
19
|
+
|
|
20
|
+
The default stream is **stderr**, not stdout (SPEC-031 FR-003, which corrected two
|
|
21
|
+
documents that said otherwise). It is the twelve-factor convention ``StderrSink`` already
|
|
22
|
+
cites — logs on stderr, the application's own output on stdout — so an echo cannot corrupt
|
|
23
|
+
a program whose stdout is a data stream someone pipes.
|
|
19
24
|
"""
|
|
20
25
|
|
|
21
26
|
def __init__(self, stream: TextIO | None = None) -> None:
|
|
22
|
-
"""Binds the writer to an output stream.
|
|
27
|
+
"""Binds the writer to an output stream, once, at construction.
|
|
28
|
+
|
|
29
|
+
The binding is deliberate and permanent for the life of the writer: a later
|
|
30
|
+
``contextlib.redirect_stderr`` or a test's capture of ``sys.stderr`` is not honoured,
|
|
31
|
+
because the attribute was resolved here. ``api._console`` is built at import, so in
|
|
32
|
+
practice a process's echo stream is fixed before any test runs. Passing ``stream=``
|
|
33
|
+
explicitly is how a caller — a test above all — captures the output (SPEC-031 FR-003).
|
|
23
34
|
|
|
24
35
|
Args:
|
|
25
|
-
stream: The stream to write to, defaulting to ``sys.stderr
|
|
36
|
+
stream: The stream to write to, defaulting to ``sys.stderr`` as resolved now.
|
|
26
37
|
|
|
27
38
|
Returns:
|
|
28
39
|
None.
|
|
@@ -32,6 +32,9 @@ __all__ = ["continue_trace", "trace"]
|
|
|
32
32
|
_worker: Worker | None = None
|
|
33
33
|
_worker_lock = threading.Lock()
|
|
34
34
|
_atexit_registered = False
|
|
35
|
+
_orphan_close_owed = False
|
|
36
|
+
_orphan_sink_closed = False
|
|
37
|
+
_orphan_retired = False
|
|
35
38
|
|
|
36
39
|
F = TypeVar("F", bound=Callable[..., Any])
|
|
37
40
|
|
|
@@ -202,23 +205,127 @@ def _get_worker() -> Worker:
|
|
|
202
205
|
Raises:
|
|
203
206
|
Exception: Whatever constructing the sink or worker raises.
|
|
204
207
|
"""
|
|
205
|
-
global _worker
|
|
208
|
+
global _worker
|
|
206
209
|
if _worker is None:
|
|
207
210
|
with _worker_lock:
|
|
208
211
|
if _worker is None:
|
|
209
|
-
|
|
210
|
-
atexit.register(_shutdown_worker)
|
|
211
|
-
_atexit_registered = True
|
|
212
|
+
_register_exit_handler()
|
|
212
213
|
_worker = Worker(_ensure_sink())
|
|
213
214
|
return _worker
|
|
214
215
|
|
|
215
216
|
|
|
217
|
+
def _register_exit_handler() -> None:
|
|
218
|
+
"""Registers the one ``atexit`` handler that covers both delivery paths (SPEC-031 FR-006).
|
|
219
|
+
|
|
220
|
+
One registration, not two, and one flag guarding it. :func:`_shutdown_worker` handles the
|
|
221
|
+
worker path *and* the orphan path, so an orphan log arming this does not cost a later
|
|
222
|
+
``@trace`` its exit drain — which reusing a worker-only registration flag would. Two
|
|
223
|
+
handlers would be worse still: ``atexit`` runs LIFO, so the second would close a sink the
|
|
224
|
+
first had already closed. What is made once-only is the *close*, not the registration.
|
|
225
|
+
|
|
226
|
+
Callers hold ``_worker_lock``.
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
None.
|
|
230
|
+
|
|
231
|
+
Returns:
|
|
232
|
+
None.
|
|
233
|
+
|
|
234
|
+
Raises:
|
|
235
|
+
None.
|
|
236
|
+
"""
|
|
237
|
+
global _atexit_registered
|
|
238
|
+
if not _atexit_registered:
|
|
239
|
+
atexit.register(_shutdown_worker)
|
|
240
|
+
_atexit_registered = True
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _note_orphan_emit() -> None:
|
|
244
|
+
"""Records that a level call with no span reached the sink (SPEC-031 FR-006).
|
|
245
|
+
|
|
246
|
+
This is what arms the exit-time close, and it is deliberately keyed on an event having
|
|
247
|
+
*landed* rather than on a sink existing: ``configure()`` runs ``_ensure_sink()``
|
|
248
|
+
unconditionally, so a bare ``configure(service=…)`` has already built a ``StdoutSink``,
|
|
249
|
+
and keying on that would close a sink nothing was ever written to.
|
|
250
|
+
|
|
251
|
+
The unlocked read is the fast path on a per-call route — the flag is written once and
|
|
252
|
+
never cleared, so a racing reader either sees it set or takes the lock and finds it set
|
|
253
|
+
there.
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
None.
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
None.
|
|
260
|
+
|
|
261
|
+
Raises:
|
|
262
|
+
None.
|
|
263
|
+
"""
|
|
264
|
+
global _orphan_close_owed
|
|
265
|
+
if _orphan_close_owed:
|
|
266
|
+
return
|
|
267
|
+
with _worker_lock:
|
|
268
|
+
if not _orphan_close_owed:
|
|
269
|
+
_register_exit_handler()
|
|
270
|
+
_orphan_close_owed = True
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _close_orphan_sink() -> None:
|
|
274
|
+
"""Closes a sink only the orphan path ever wrote to, once (SPEC-031 FR-006).
|
|
275
|
+
|
|
276
|
+
A process that never opens a span builds no worker, so nothing owned the sink's close and
|
|
277
|
+
nothing performed it: on a locally-buffering sink every event died in the client's batch,
|
|
278
|
+
on a synchronous one the flush and the resource were lost, and ``health()`` read all-clear
|
|
279
|
+
because every field it carries describes a worker that does not exist.
|
|
280
|
+
|
|
281
|
+
A live worker owns the close instead, and this returns — that is what makes a mixed
|
|
282
|
+
process exactly one ``close()`` in either order. It also inherits the worker's reasons for
|
|
283
|
+
*not* closing: an expired :meth:`Worker.shutdown` leaves the sink open because the drain
|
|
284
|
+
thread may still be inside ``emit``.
|
|
285
|
+
|
|
286
|
+
That check is read **under** ``_worker_lock``, not ahead of it, because :func:`_get_worker`
|
|
287
|
+
assigns ``_worker`` while holding that same lock. Unlocked, a ``shutdown()`` racing a first
|
|
288
|
+
``@trace`` could read ``None``, block behind the worker's construction, and then close the
|
|
289
|
+
sink underneath the worker that had just captured it — reproduced with an injected
|
|
290
|
+
preemption point, the way SPEC-028 demonstrates the races that need one.
|
|
291
|
+
|
|
292
|
+
The once-only flag is set ahead of the close, as ``Worker.shutdown``'s is: a second
|
|
293
|
+
``close()`` on a sink that partially released its resources is worse than an unclosed one.
|
|
294
|
+
|
|
295
|
+
Args:
|
|
296
|
+
None.
|
|
297
|
+
|
|
298
|
+
Returns:
|
|
299
|
+
None.
|
|
300
|
+
|
|
301
|
+
Raises:
|
|
302
|
+
None. This runs from ``atexit``, where an escaping exception makes CPython print a
|
|
303
|
+
traceback carrying the message arch §6 keeps out of anything the library says about
|
|
304
|
+
itself. ``Exception``, never ``BaseException`` (SPEC-025 FR-004).
|
|
305
|
+
"""
|
|
306
|
+
global _orphan_sink_closed
|
|
307
|
+
with _worker_lock:
|
|
308
|
+
if _worker is not None or not _orphan_close_owed or _orphan_sink_closed:
|
|
309
|
+
return
|
|
310
|
+
_orphan_sink_closed = True
|
|
311
|
+
try:
|
|
312
|
+
_ensure_sink().close()
|
|
313
|
+
except Exception as exc:
|
|
314
|
+
_diag.absorbed("closing the sink", exc, "it may still hold its resources")
|
|
315
|
+
|
|
316
|
+
|
|
216
317
|
def _shutdown_worker(timeout: float | None = DEFAULT_SHUTDOWN_TIMEOUT) -> None:
|
|
217
|
-
"""Drains and closes the process worker
|
|
318
|
+
"""Drains and closes the process worker, or closes an orphan-only sink, backing ``shutdown()``.
|
|
218
319
|
|
|
219
320
|
The ``atexit`` registration binds this function, so the exit path gets the bounded form
|
|
220
321
|
and its default (SPEC-027 FR-004) — an unbounded join in an ``atexit`` handler is a
|
|
221
|
-
process that will not exit. Idempotent.
|
|
322
|
+
process that will not exit. Idempotent on both paths.
|
|
323
|
+
|
|
324
|
+
``_orphan_retired`` is set unconditionally and read only when there is no worker, which is
|
|
325
|
+
what makes ``health().retired`` truthful for a process that shut down without ever
|
|
326
|
+
building one (SPEC-031 FR-006). No worker is created here to answer it: standing up a
|
|
327
|
+
thread at exit to prove there is nothing to drain is pure cost, the same refusal
|
|
328
|
+
:func:`_swap_sink` and :func:`_flush_worker` already make.
|
|
222
329
|
|
|
223
330
|
Args:
|
|
224
331
|
timeout: Seconds to wait for the drain, or ``None`` to wait indefinitely.
|
|
@@ -229,8 +336,12 @@ def _shutdown_worker(timeout: float | None = DEFAULT_SHUTDOWN_TIMEOUT) -> None:
|
|
|
229
336
|
Raises:
|
|
230
337
|
None.
|
|
231
338
|
"""
|
|
339
|
+
global _orphan_retired
|
|
340
|
+
_orphan_retired = True
|
|
232
341
|
if _worker is not None:
|
|
233
342
|
_worker.shutdown(timeout)
|
|
343
|
+
return
|
|
344
|
+
_close_orphan_sink()
|
|
234
345
|
|
|
235
346
|
|
|
236
347
|
def _swap_sink(new_sink: Sink, timeout: float | None = DEFAULT_SWAP_TIMEOUT) -> None:
|
|
@@ -301,6 +412,24 @@ def _worker_health() -> Health:
|
|
|
301
412
|
created has not died, which is why SPEC-019 reports the terminal failure as a reason
|
|
302
413
|
rather than an ``alive`` flag.
|
|
303
414
|
|
|
415
|
+
``retired`` is the one field synthesized rather than zeroed (SPEC-031 FR-006). It records
|
|
416
|
+
an action the caller took, not a state of the worker, so it stays true in a process that
|
|
417
|
+
called ``shutdown()`` without ever building one — where it was previously vacuous, and the
|
|
418
|
+
whole snapshot read all-clear over a sink that had just been closed.
|
|
419
|
+
``submitted_after_shutdown`` is deliberately **not** synthesized alongside it: SPEC-030
|
|
420
|
+
defines that count as submissions queued where nothing will drain them, and a later orphan
|
|
421
|
+
log is refused at the closed sink and announced instead. The two are not the same claim.
|
|
422
|
+
|
|
423
|
+
The synthesis also survives a worker built *after* that shutdown, which is why it is an
|
|
424
|
+
``or`` rather than a fallback. An orphan-only ``shutdown()`` leaves ``_worker`` unset, so a
|
|
425
|
+
later ``@trace`` constructs a fresh worker whose own ``retired`` is ``False`` — and reading
|
|
426
|
+
that alone would say the process was never shut down, contradicting this function's own
|
|
427
|
+
guarantee one call earlier. The events that worker carries are not lost silently: against a
|
|
428
|
+
sink that guards its post-close state they raise and land in ``failed_batches`` (measured),
|
|
429
|
+
and against one that releases nothing on ``close()`` they genuinely still deliver. So the
|
|
430
|
+
detection is ``failed_batches`` there rather than SPEC-030's ``retired`` +
|
|
431
|
+
``submitted_after_shutdown`` pair, which stays the signal for the path it was built for.
|
|
432
|
+
|
|
304
433
|
Args:
|
|
305
434
|
None.
|
|
306
435
|
|
|
@@ -312,8 +441,11 @@ def _worker_health() -> Health:
|
|
|
312
441
|
"""
|
|
313
442
|
worker = _worker
|
|
314
443
|
if worker is None:
|
|
315
|
-
return Health(queued=0, dropped=0, failed_batches=0)
|
|
316
|
-
|
|
444
|
+
return Health(queued=0, dropped=0, failed_batches=0, retired=_orphan_retired)
|
|
445
|
+
health = worker.health()
|
|
446
|
+
if _orphan_retired and not health.retired:
|
|
447
|
+
return health._replace(retired=True)
|
|
448
|
+
return health
|
|
317
449
|
|
|
318
450
|
|
|
319
451
|
def _flush(span: Span) -> None:
|
|
@@ -8,6 +8,8 @@ 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
|
|
12
|
+
from log_foundry.ids import new_log_id
|
|
11
13
|
from log_foundry.sanitize import sanitize_fields, truncate_str, truncate_tail
|
|
12
14
|
|
|
13
15
|
if TYPE_CHECKING:
|
|
@@ -72,6 +74,12 @@ def build_event(
|
|
|
72
74
|
bounded too, since both are caller-supplied text and leaving either out would keep
|
|
73
75
|
``info(huge_string)`` unbounded.
|
|
74
76
|
|
|
77
|
+
``get_config`` and ``new_log_id`` are imported at module scope (SPEC-031 FR-004). They were
|
|
78
|
+
function-local to avoid a cycle, but there is none to avoid: neither ``config`` nor ``ids``
|
|
79
|
+
imports this module, and ``config``'s own back-references to ``decorator`` stay local for
|
|
80
|
+
the reason its docstrings give. This is the hottest path in the library, so resolving them
|
|
81
|
+
once at import is what ``sanitize`` already does with its one-time bindings.
|
|
82
|
+
|
|
75
83
|
Args:
|
|
76
84
|
span: The span the event belongs to, supplying identity and defaults.
|
|
77
85
|
level: The severity label, such as ``"INFO"``.
|
|
@@ -85,9 +93,6 @@ def build_event(
|
|
|
85
93
|
Raises:
|
|
86
94
|
None.
|
|
87
95
|
"""
|
|
88
|
-
from log_foundry.config import get_config
|
|
89
|
-
from log_foundry.ids import new_log_id
|
|
90
|
-
|
|
91
96
|
cfg = get_config()
|
|
92
97
|
merged: dict[str, object] = {**cfg.defaults, **span.defaults, **baggage, **fields}
|
|
93
98
|
safe, clipped = sanitize_fields(merged, cfg=cfg)
|
|
@@ -226,8 +231,6 @@ def end_event(
|
|
|
226
231
|
Raises:
|
|
227
232
|
None.
|
|
228
233
|
"""
|
|
229
|
-
from log_foundry.config import get_config
|
|
230
|
-
|
|
231
234
|
level = "INFO" if status == "ok" else "ERROR"
|
|
232
235
|
event = build_event(span, level, _END_MESSAGE, fields={}, baggage={})
|
|
233
236
|
event["duration_ms"] = (time.monotonic() - span.start_ts) * 1000.0
|
|
@@ -266,8 +269,6 @@ def backfill_baggage(span: Span, baggage: dict[str, object]) -> None:
|
|
|
266
269
|
"""
|
|
267
270
|
if not baggage:
|
|
268
271
|
return
|
|
269
|
-
from log_foundry.config import get_config
|
|
270
|
-
|
|
271
272
|
safe, clipped = sanitize_fields(baggage, cfg=get_config())
|
|
272
273
|
for event in span.events:
|
|
273
274
|
if event.get("message") in (_START_MESSAGE, _END_MESSAGE):
|
|
@@ -142,7 +142,7 @@ class _Coercer:
|
|
|
142
142
|
``build_event`` already allocates per event, it is noise.
|
|
143
143
|
"""
|
|
144
144
|
|
|
145
|
-
__slots__ = ("_cfg", "_parents", "truncated")
|
|
145
|
+
__slots__ = ("_cfg", "_int_ceiling", "_parents", "truncated")
|
|
146
146
|
|
|
147
147
|
def __init__(self, cfg: Config) -> None:
|
|
148
148
|
"""Starts a pass with the configured ceilings and an empty ancestor chain.
|
|
@@ -152,6 +152,11 @@ class _Coercer:
|
|
|
152
152
|
recursion for the whole descent, so its ``id()`` cannot be recycled underneath us, and
|
|
153
153
|
two siblings referencing the same object are not a cycle and must both render.
|
|
154
154
|
|
|
155
|
+
The integer ceiling is resolved here, once per pass rather than once per integer
|
|
156
|
+
(SPEC-031 FR-004): it reads ``sys.get_int_max_str_digits()``, which cannot change
|
|
157
|
+
during a coercion pass, and the alternative sat four lines below an ``int.__lt__``
|
|
158
|
+
binding justified by this being a per-value hot path.
|
|
159
|
+
|
|
155
160
|
Args:
|
|
156
161
|
cfg: The config supplying ``max_value_bytes``, ``max_keys`` and ``max_depth``.
|
|
157
162
|
|
|
@@ -162,6 +167,7 @@ class _Coercer:
|
|
|
162
167
|
None.
|
|
163
168
|
"""
|
|
164
169
|
self._cfg = cfg
|
|
170
|
+
self._int_ceiling = _int_digit_ceiling(cfg.max_value_bytes)
|
|
165
171
|
self.truncated = False
|
|
166
172
|
self._parents: list[int] = []
|
|
167
173
|
|
|
@@ -368,7 +374,7 @@ class _Coercer:
|
|
|
368
374
|
"""
|
|
369
375
|
digits = value.bit_length() * _LOG10_2_NUM // _LOG10_2_DEN + 1
|
|
370
376
|
rendered = digits + 1 if _INT_LT(value, 0) else digits
|
|
371
|
-
if rendered <=
|
|
377
|
+
if rendered <= self._int_ceiling:
|
|
372
378
|
return value
|
|
373
379
|
self.truncated = True
|
|
374
380
|
return f"<int: ~{digits} digits>"
|
|
@@ -33,21 +33,45 @@ def _make_tcp(host: str, port: int, timeout: float) -> socket.socket:
|
|
|
33
33
|
return socket.create_connection((host, port), timeout=timeout)
|
|
34
34
|
|
|
35
35
|
|
|
36
|
-
def _make_udp() -> socket.socket:
|
|
37
|
-
"""Opens an unconnected UDP socket.
|
|
36
|
+
def _make_udp(host: str) -> socket.socket:
|
|
37
|
+
"""Opens an unconnected UDP socket in an address family the host resolves to (SPEC-031).
|
|
38
|
+
|
|
39
|
+
The family is resolved rather than assumed: a hardcoded ``AF_INET`` made every ``sendto``
|
|
40
|
+
to an IPv6 destination fail, silently, until the retry bound abandoned the message. TCP
|
|
41
|
+
never had the defect because ``socket.create_connection`` resolves for itself.
|
|
42
|
+
|
|
43
|
+
**IPv4 wins when the host offers it**, and taking the first result instead was measured
|
|
44
|
+
losing logs. ``getaddrinfo`` sorts by RFC 6724, which puts AAAA first, so a dual-stack
|
|
45
|
+
name like ``localhost`` would move from IPv4 — where every deployment of this library has
|
|
46
|
+
sent — to IPv6, and a collector bound to ``0.0.0.0:514`` would never see the datagram. UDP
|
|
47
|
+
is unconnected, so that failure is *silent*: ``sendto`` succeeds locally, ``emit`` returns,
|
|
48
|
+
and no counter moves. FR-002 AC-2 requires delivery to a hostname to be unchanged, and
|
|
49
|
+
this is what makes it so while AC-1 still holds. It is a fixed preference, not
|
|
50
|
+
happy-eyeballs, address caching, or a setting — none of which this FR builds.
|
|
38
51
|
|
|
39
52
|
This is a module-level seam so tests can substitute a fake socket without network access.
|
|
40
53
|
|
|
41
54
|
Args:
|
|
42
|
-
|
|
55
|
+
host: The destination host, resolved to choose the family.
|
|
43
56
|
|
|
44
57
|
Returns:
|
|
45
58
|
The socket.
|
|
46
59
|
|
|
47
60
|
Raises:
|
|
48
|
-
OSError: If the socket cannot be created.
|
|
61
|
+
OSError: If the host resolves to nothing, or the socket cannot be created. Both reach
|
|
62
|
+
``_send_one``'s handler, which counts and announces rather than raising — a
|
|
63
|
+
``gaierror`` is an ``OSError``, so an unresolvable host fails exactly as an
|
|
64
|
+
unreachable one already did. The empty-result case is raised as one explicitly: CPython
|
|
65
|
+
raises rather than returning ``[]``, but indexing it would produce an ``IndexError``,
|
|
66
|
+
which is *not* an ``OSError`` and would escape that handler into the caller — the one
|
|
67
|
+
thing SPEC-025 says this library may never do.
|
|
49
68
|
"""
|
|
50
|
-
|
|
69
|
+
families = [entry[0] for entry in socket.getaddrinfo(host, None, type=socket.SOCK_DGRAM)]
|
|
70
|
+
if not families:
|
|
71
|
+
raise socket.gaierror(socket.EAI_NONAME, "resolution returned no address family")
|
|
72
|
+
return socket.socket(
|
|
73
|
+
socket.AF_INET if socket.AF_INET in families else families[0], socket.SOCK_DGRAM
|
|
74
|
+
)
|
|
51
75
|
|
|
52
76
|
|
|
53
77
|
class SocketTransport:
|
|
@@ -235,6 +259,10 @@ class SocketTransport:
|
|
|
235
259
|
def _socket(self) -> socket.socket:
|
|
236
260
|
"""Returns the held socket, opening one if none is held.
|
|
237
261
|
|
|
262
|
+
The UDP address family is resolved here rather than per message, because this is the
|
|
263
|
+
only place a socket is created and the socket outlives every send made through it
|
|
264
|
+
(SPEC-031 FR-002).
|
|
265
|
+
|
|
238
266
|
Args:
|
|
239
267
|
None.
|
|
240
268
|
|
|
@@ -242,11 +270,11 @@ class SocketTransport:
|
|
|
242
270
|
The socket.
|
|
243
271
|
|
|
244
272
|
Raises:
|
|
245
|
-
OSError: If the socket cannot be created or connected.
|
|
273
|
+
OSError: If the socket cannot be created, resolved or connected.
|
|
246
274
|
"""
|
|
247
275
|
if self._sock is None:
|
|
248
276
|
self._sock = (
|
|
249
|
-
_make_udp() if self._transport == "udp" else _make_tcp(
|
|
277
|
+
_make_udp(self._host) if self._transport == "udp" else _make_tcp(
|
|
250
278
|
self._host, self._port, self._timeout
|
|
251
279
|
)
|
|
252
280
|
)
|
|
@@ -113,12 +113,16 @@ class RotatingFileSink:
|
|
|
113
113
|
Two independent triggers may be enabled, either or both. With a positive ``max_bytes`` it
|
|
114
114
|
rotates before the write that would push the active file past that size, so the file never
|
|
115
115
|
grows unbounded; with a ``when`` unit code and an interval it rotates on the first emit after
|
|
116
|
-
that period has elapsed since the last rotation.
|
|
116
|
+
that period has elapsed since the last rotation. That period is measured on the monotonic
|
|
117
|
+
clock (SPEC-031 FR-001), so a wall-clock step in either direction neither defers a rotation
|
|
118
|
+
nor forces an early one.
|
|
117
119
|
|
|
118
120
|
Rotation renames the active file through numbered backups, prunes any beyond the backup
|
|
119
121
|
count, and opens a fresh active file — a backup count of zero keeps none, simply replacing
|
|
120
|
-
the active file.
|
|
121
|
-
|
|
122
|
+
the active file. Backups are numbered rather than timestamped, so no filename derives from
|
|
123
|
+
a clock at all and the monotonic deadline has no naming consequence. No event is lost across
|
|
124
|
+
a rotation, because the rotate happens before the pending event is written and the event
|
|
125
|
+
lands in the fresh file.
|
|
122
126
|
|
|
123
127
|
A rotation rebinds the active stream, so it is the sink where concurrent writers did real
|
|
124
128
|
damage: a second thread mid-``emit`` could write to the handle rotation had just closed, or
|
|
@@ -249,24 +253,34 @@ class RotatingFileSink:
|
|
|
249
253
|
return unit * interval
|
|
250
254
|
|
|
251
255
|
def _schedule_next(self) -> float | None:
|
|
252
|
-
"""Returns the
|
|
256
|
+
"""Returns the monotonic-clock deadline for the next time-based rotation (SPEC-031).
|
|
257
|
+
|
|
258
|
+
Monotonic rather than wall-clock for the reason ``Span.start_ts`` is: a backward step —
|
|
259
|
+
an NTP correction, a container clock sync — larger than the interval would otherwise
|
|
260
|
+
defer every time-based rotation until wall-clock caught up, silently defeating this
|
|
261
|
+
class's promise to bound on-disk growth. Nothing here is a timestamp anyone reads; it is
|
|
262
|
+
only ever compared against another reading of the same clock.
|
|
253
263
|
|
|
254
264
|
Args:
|
|
255
265
|
None.
|
|
256
266
|
|
|
257
267
|
Returns:
|
|
258
|
-
The
|
|
268
|
+
The deadline as a ``time.monotonic()`` reading, or ``None`` when there is no time
|
|
269
|
+
trigger.
|
|
259
270
|
|
|
260
271
|
Raises:
|
|
261
272
|
None.
|
|
262
273
|
"""
|
|
263
274
|
if self._interval_seconds is None:
|
|
264
275
|
return None
|
|
265
|
-
return time.
|
|
276
|
+
return time.monotonic() + self._interval_seconds
|
|
266
277
|
|
|
267
278
|
def _should_rotate(self, incoming: int) -> bool:
|
|
268
279
|
"""Decides whether to rotate before writing the next event.
|
|
269
280
|
|
|
281
|
+
The time trigger reads ``time.monotonic()``, the same clock :meth:`_schedule_next`
|
|
282
|
+
wrote the deadline on; comparing the two clocks is what the SPEC-031 fix removes.
|
|
283
|
+
|
|
270
284
|
Args:
|
|
271
285
|
incoming: The byte cost of the event about to be written.
|
|
272
286
|
|
|
@@ -282,7 +296,7 @@ class RotatingFileSink:
|
|
|
282
296
|
and self._size + incoming > self._max_bytes
|
|
283
297
|
):
|
|
284
298
|
return True
|
|
285
|
-
return self._next_rollover is not None and time.
|
|
299
|
+
return self._next_rollover is not None and time.monotonic() >= self._next_rollover
|
|
286
300
|
|
|
287
301
|
def _rotate(self) -> None:
|
|
288
302
|
"""Closes the active file, shifts and prunes backups, then opens a fresh active file.
|
|
@@ -24,6 +24,18 @@ class LogstashSink:
|
|
|
24
24
|
:class:`~log_foundry.sinks._socket.SocketTransport`. Either backend handles its own bounded
|
|
25
25
|
retry and raises on total failure of its own accord, so ``emit`` needs no rule of its own.
|
|
26
26
|
|
|
27
|
+
In socket mode both IPv4 and IPv6 destinations are supported, over either transport
|
|
28
|
+
(SPEC-031 FR-002): the UDP socket's address family is resolved from ``host`` rather than
|
|
29
|
+
assumed, which is what an unconditional ``AF_INET`` used to make impossible.
|
|
30
|
+
|
|
31
|
+
**A hostname that resolves to both families goes to IPv4**, because that is where every
|
|
32
|
+
release before this one sent and moving it silently would strand a collector bound to
|
|
33
|
+
``0.0.0.0``. The consequence is the mirror case, stated because nothing else states it: a
|
|
34
|
+
dual-stack *name* whose collector listens on IPv6 only will not be reached over UDP, and
|
|
35
|
+
since UDP is unconnected that failure is silent — no exception, no counter. Give the IPv6
|
|
36
|
+
literal, or a name with no ``A`` record, to select IPv6 for such a destination. TCP is
|
|
37
|
+
unaffected either way; ``create_connection`` tries each candidate in turn.
|
|
38
|
+
|
|
27
39
|
It takes **no** transport lock (SPEC-028 FR-002) of its own: whichever backend it built owns
|
|
28
40
|
that decision — ``SocketTransport`` locks its sends, ``HTTPSink`` holds no transport to
|
|
29
41
|
guard. The post-close rule follows the same split (SPEC-032 FR-004), and the two modes
|
|
@@ -29,10 +29,15 @@ class StdoutSink:
|
|
|
29
29
|
"""
|
|
30
30
|
|
|
31
31
|
def __init__(self, stream: TextIO | None = None) -> None:
|
|
32
|
-
"""Binds the sink to an output stream.
|
|
32
|
+
"""Binds the sink to an output stream, once, at construction.
|
|
33
|
+
|
|
34
|
+
The binding is deliberate and permanent for the life of the sink: a later
|
|
35
|
+
``contextlib.redirect_stdout`` or a test's capture of ``sys.stdout`` is not honoured,
|
|
36
|
+
because the attribute was resolved here. Passing ``stream=`` explicitly is how a caller
|
|
37
|
+
— a test above all — captures the output (SPEC-031 FR-003).
|
|
33
38
|
|
|
34
39
|
Args:
|
|
35
|
-
stream: The stream to write to, defaulting to ``sys.stdout
|
|
40
|
+
stream: The stream to write to, defaulting to ``sys.stdout`` as resolved now.
|
|
36
41
|
|
|
37
42
|
Returns:
|
|
38
43
|
None.
|
|
@@ -34,6 +34,18 @@ class SyslogSink:
|
|
|
34
34
|
and a severity mapped from the event's level. UDP sends one datagram per event, TCP uses
|
|
35
35
|
octet-counted framing (RFC 6587), and the whole sink is dependency-free.
|
|
36
36
|
|
|
37
|
+
Both IPv4 and IPv6 destinations are supported, on either transport (SPEC-031 FR-002): the
|
|
38
|
+
UDP socket's address family is resolved from ``host`` rather than assumed, which is what an
|
|
39
|
+
unconditional ``AF_INET`` used to make impossible.
|
|
40
|
+
|
|
41
|
+
**A hostname that resolves to both families goes to IPv4**, because that is where every
|
|
42
|
+
release before this one sent and moving it silently would strand a collector bound to
|
|
43
|
+
``0.0.0.0``. The consequence is the mirror case, stated because nothing else states it: a
|
|
44
|
+
dual-stack *name* whose collector listens on IPv6 only will not be reached over UDP, and
|
|
45
|
+
since UDP is unconnected that failure is silent — no exception, no counter. Give the IPv6
|
|
46
|
+
literal, or a name with no ``A`` record, to select IPv6 for such a destination. TCP is
|
|
47
|
+
unaffected either way; ``create_connection`` tries each candidate in turn.
|
|
48
|
+
|
|
37
49
|
It takes **no** transport lock (SPEC-028 FR-002) of its own: the socket it holds is a
|
|
38
50
|
:class:`~log_foundry.sinks._socket.SocketTransport`, which locks its own sends. Its
|
|
39
51
|
post-close refusal comes from there too (SPEC-032 FR-004) — a batch emitted after
|
|
@@ -20,11 +20,16 @@ class StderrSink(StdoutSink):
|
|
|
20
20
|
"""
|
|
21
21
|
|
|
22
22
|
def __init__(self, stream: TextIO | None = None) -> None:
|
|
23
|
-
"""Binds the sink to an output stream.
|
|
23
|
+
"""Binds the sink to an output stream, once, at construction.
|
|
24
|
+
|
|
25
|
+
The binding is resolved here and not re-read per write, so a later
|
|
26
|
+
``contextlib.redirect_stderr`` is not honoured — the same property
|
|
27
|
+
:class:`~log_foundry.sinks.stdout.StdoutSink` documents (SPEC-031 FR-003), restated
|
|
28
|
+
because this override means none of that docstring is inherited.
|
|
24
29
|
|
|
25
30
|
Args:
|
|
26
|
-
stream: The stream to write to, defaulting to ``sys.stderr
|
|
27
|
-
as a ``StringIO``, can be injected for capture.
|
|
31
|
+
stream: The stream to write to, defaulting to ``sys.stderr`` as resolved now. An
|
|
32
|
+
explicit one, such as a ``StringIO``, can be injected for capture.
|
|
28
33
|
|
|
29
34
|
Returns:
|
|
30
35
|
None.
|
|
@@ -90,12 +90,15 @@ class Health(NamedTuple):
|
|
|
90
90
|
the sink reports nothing (SPEC-026 FR-003). Nested rather than folded into the
|
|
91
91
|
integers above because they count different things: ``dropped`` here is backpressure
|
|
92
92
|
at this queue, ``dropped`` on the sink is an event that never reached the wire.
|
|
93
|
-
retired: Whether
|
|
93
|
+
retired: Whether ``shutdown()`` has been called. It describes an action the
|
|
94
94
|
caller took, not a failure the library detected, which is why it is a boolean where
|
|
95
95
|
``stopped_reason`` is a string — SPEC-019 rejected an ``alive`` flag because it would
|
|
96
96
|
read ``False`` for a process that never logged, and that objection does not apply to a
|
|
97
97
|
field which is simply ``False`` until someone calls ``shutdown()`` (SPEC-030 FR-001).
|
|
98
98
|
On its own it is not a fault: a process that shuts down and stops logging is correct.
|
|
99
|
+
It is the one field ``decorator._worker_health`` synthesizes rather than zeroing, so
|
|
100
|
+
that a process which only ever logged outside a span — and therefore has no worker at
|
|
101
|
+
all — still reports its own shutdown truthfully (SPEC-031 FR-006).
|
|
99
102
|
submitted_after_shutdown: Submissions accepted after ``shutdown()`` and queued where
|
|
100
103
|
nothing will drain them. Non-zero alongside ``retired`` is the signature of the
|
|
101
104
|
serverless mistake — ``shutdown()`` called per invocation on a warm container, so the
|
|
@@ -854,6 +857,12 @@ class Worker:
|
|
|
854
857
|
this sweep, and that one waits out its timeout — then returns False, which is correct
|
|
855
858
|
either way.
|
|
856
859
|
|
|
860
|
+
The reliance on ``queue.Queue``'s private ``mutex`` and ``queue`` is deliberate and is
|
|
861
|
+
recorded in ``architecture.md`` §13 Known Constraints (SPEC-031 FR-005): there is no
|
|
862
|
+
public "inspect without consuming", and the draining alternative would destroy the
|
|
863
|
+
evidence the terminal-failure line reports. A CPython change would surface as the test
|
|
864
|
+
that exercises this against a mixed queue, rather than as waiters silently timing out.
|
|
865
|
+
|
|
857
866
|
Args:
|
|
858
867
|
None.
|
|
859
868
|
|
|
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
|
{log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/elasticsearch.py
RENAMED
|
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
|