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.
Files changed (55) hide show
  1. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/PKG-INFO +10 -1
  2. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/README.md +9 -0
  3. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/pyproject.toml +1 -1
  4. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/__init__.py +12 -1
  5. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/api.py +25 -1
  6. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/console.py +13 -2
  7. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/decorator.py +140 -8
  8. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/model.py +8 -7
  9. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sanitize.py +8 -2
  10. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/_socket.py +35 -7
  11. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/file.py +21 -7
  12. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/logstash.py +12 -0
  13. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/stdout.py +7 -2
  14. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/syslog.py +12 -0
  15. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/util.py +8 -3
  16. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/worker.py +10 -1
  17. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/LICENSE +0 -0
  18. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/_diag.py +0 -0
  19. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/config.py +0 -0
  20. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/context.py +0 -0
  21. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/ids.py +0 -0
  22. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/py.typed +0 -0
  23. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/__init__.py +0 -0
  24. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/_batch.py +0 -0
  25. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/_chunk.py +0 -0
  26. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/_retry.py +0 -0
  27. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/_time.py +0 -0
  28. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/base.py +0 -0
  29. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/callback.py +0 -0
  30. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/clickhouse.py +0 -0
  31. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/datadog.py +0 -0
  32. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/elasticsearch.py +0 -0
  33. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/eventhubs.py +0 -0
  34. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/filtering.py +0 -0
  35. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/firehose.py +0 -0
  36. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/honeycomb.py +0 -0
  37. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/http.py +0 -0
  38. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/kafka.py +0 -0
  39. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/kinesis.py +0 -0
  40. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/logging_sink.py +0 -0
  41. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/loki.py +0 -0
  42. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/mongodb.py +0 -0
  43. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/multi.py +0 -0
  44. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/nats.py +0 -0
  45. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/newrelic.py +0 -0
  46. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/postgres.py +0 -0
  47. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/pubsub.py +0 -0
  48. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/rabbitmq.py +0 -0
  49. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/redis.py +0 -0
  50. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/sentry.py +0 -0
  51. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/sns.py +0 -0
  52. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/splunk.py +0 -0
  53. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/sqlite.py +0 -0
  54. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/sqs.py +0 -0
  55. {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.dev29
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
@@ -20,7 +20,7 @@ dependencies = [
20
20
  ]
21
21
 
22
22
  # Optional features. Install with: pip install log-foundry[aws]
23
- version = "0.10.2.dev29"
23
+ version = "0.10.2.dev31"
24
24
 
25
25
  [project.optional-dependencies]
26
26
  aws = ["boto3>=1.43.61"] # SQSSink, SNSSink, KinesisSink, FirehoseSink
@@ -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. Valid after :func:`shutdown`.
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().emit([event])
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, _atexit_registered
208
+ global _worker
206
209
  if _worker is None:
207
210
  with _worker_lock:
208
211
  if _worker is None:
209
- if not _atexit_registered:
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 if one was created, backing ``shutdown()``.
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
- return worker.health()
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 <= _int_digit_ceiling(self._cfg.max_value_bytes):
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
- None.
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
- return socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
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. No event is lost across a rotation, because the rotate happens before the
121
- pending event is written and the event lands in the fresh file.
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 wall-clock time of the next time-based rotation.
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 absolute time, or ``None`` when there is no time trigger.
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.time() + self._interval_seconds
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.time() >= self._next_rollover
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``. An explicit one, such
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 :meth:`Worker.shutdown` has been called. It describes an action the
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