log-foundry 0.10.2.dev30__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.dev30 → log_foundry-0.10.2.dev31}/PKG-INFO +10 -1
  2. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev31}/README.md +9 -0
  3. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev31}/pyproject.toml +1 -1
  4. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev31}/src/log_foundry/__init__.py +12 -1
  5. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev31}/src/log_foundry/api.py +19 -1
  6. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev31}/src/log_foundry/decorator.py +140 -8
  7. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev31}/src/log_foundry/worker.py +4 -1
  8. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev31}/LICENSE +0 -0
  9. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev31}/src/log_foundry/_diag.py +0 -0
  10. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev31}/src/log_foundry/config.py +0 -0
  11. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev31}/src/log_foundry/console.py +0 -0
  12. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev31}/src/log_foundry/context.py +0 -0
  13. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev31}/src/log_foundry/ids.py +0 -0
  14. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev31}/src/log_foundry/model.py +0 -0
  15. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev31}/src/log_foundry/py.typed +0 -0
  16. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev31}/src/log_foundry/sanitize.py +0 -0
  17. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/__init__.py +0 -0
  18. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/_batch.py +0 -0
  19. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/_chunk.py +0 -0
  20. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/_retry.py +0 -0
  21. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/_socket.py +0 -0
  22. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/_time.py +0 -0
  23. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/base.py +0 -0
  24. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/callback.py +0 -0
  25. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/clickhouse.py +0 -0
  26. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/datadog.py +0 -0
  27. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/elasticsearch.py +0 -0
  28. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/eventhubs.py +0 -0
  29. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/file.py +0 -0
  30. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/filtering.py +0 -0
  31. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/firehose.py +0 -0
  32. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/honeycomb.py +0 -0
  33. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/http.py +0 -0
  34. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/kafka.py +0 -0
  35. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/kinesis.py +0 -0
  36. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/logging_sink.py +0 -0
  37. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/logstash.py +0 -0
  38. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/loki.py +0 -0
  39. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/mongodb.py +0 -0
  40. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/multi.py +0 -0
  41. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/nats.py +0 -0
  42. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/newrelic.py +0 -0
  43. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/postgres.py +0 -0
  44. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/pubsub.py +0 -0
  45. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/rabbitmq.py +0 -0
  46. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/redis.py +0 -0
  47. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/sentry.py +0 -0
  48. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/sns.py +0 -0
  49. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/splunk.py +0 -0
  50. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/sqlite.py +0 -0
  51. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/sqs.py +0 -0
  52. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/stdout.py +0 -0
  53. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/syslog.py +0 -0
  54. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/transform.py +0 -0
  55. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev31}/src/log_foundry/sinks/util.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: log-foundry
3
- Version: 0.10.2.dev30
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.dev30"
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
 
@@ -36,6 +37,21 @@ def _log(level: str, message: str, echo: bool, fields: dict[str, object]) -> Non
36
37
  logs"). It carried a comment saying the worker "will later" own it long after the
37
38
  decision was made (SPEC-031 FR-003).
38
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
+
39
55
  The orphan branch is the one that reaches the sink on the caller's own thread, with no
40
56
  worker between them to absorb a failure, so the whole branch is guarded (SPEC-025
41
57
  FR-003) — ``_ensure_sink`` constructs the sink on first use, so a sink that fails to
@@ -72,7 +88,9 @@ def _log(level: str, message: str, echo: bool, fields: dict[str, object]) -> Non
72
88
  start_ts=0.0,
73
89
  )
74
90
  event = build_event(orphan, level, message, fields=fields, baggage=baggage)
75
- _ensure_sink().emit([event])
91
+ sink = _ensure_sink()
92
+ _note_orphan_emit()
93
+ sink.emit([event])
76
94
  except Exception as exc:
77
95
  _diag.absorbed("emitting an orphan log", exc, "the event was lost")
78
96
  if echo and event is not 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:
@@ -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