log-foundry 0.10.2.dev43__tar.gz → 0.10.2.dev45__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 (57) hide show
  1. {log_foundry-0.10.2.dev43 → log_foundry-0.10.2.dev45}/PKG-INFO +69 -17
  2. {log_foundry-0.10.2.dev43 → log_foundry-0.10.2.dev45}/README.md +68 -16
  3. {log_foundry-0.10.2.dev43 → log_foundry-0.10.2.dev45}/pyproject.toml +1 -1
  4. {log_foundry-0.10.2.dev43 → log_foundry-0.10.2.dev45}/src/log_foundry/__init__.py +10 -5
  5. {log_foundry-0.10.2.dev43 → log_foundry-0.10.2.dev45}/src/log_foundry/api.py +113 -15
  6. {log_foundry-0.10.2.dev43 → log_foundry-0.10.2.dev45}/src/log_foundry/decorator.py +36 -8
  7. log_foundry-0.10.2.dev45/src/log_foundry/results.py +65 -0
  8. {log_foundry-0.10.2.dev43 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/base.py +4 -2
  9. {log_foundry-0.10.2.dev43 → log_foundry-0.10.2.dev45}/src/log_foundry/worker.py +30 -14
  10. {log_foundry-0.10.2.dev43 → log_foundry-0.10.2.dev45}/LICENSE +0 -0
  11. {log_foundry-0.10.2.dev43 → log_foundry-0.10.2.dev45}/src/log_foundry/_diag.py +0 -0
  12. {log_foundry-0.10.2.dev43 → log_foundry-0.10.2.dev45}/src/log_foundry/_lifecycle.py +0 -0
  13. {log_foundry-0.10.2.dev43 → log_foundry-0.10.2.dev45}/src/log_foundry/config.py +0 -0
  14. {log_foundry-0.10.2.dev43 → log_foundry-0.10.2.dev45}/src/log_foundry/console.py +0 -0
  15. {log_foundry-0.10.2.dev43 → log_foundry-0.10.2.dev45}/src/log_foundry/context.py +0 -0
  16. {log_foundry-0.10.2.dev43 → log_foundry-0.10.2.dev45}/src/log_foundry/ids.py +0 -0
  17. {log_foundry-0.10.2.dev43 → log_foundry-0.10.2.dev45}/src/log_foundry/model.py +0 -0
  18. {log_foundry-0.10.2.dev43 → log_foundry-0.10.2.dev45}/src/log_foundry/py.typed +0 -0
  19. {log_foundry-0.10.2.dev43 → log_foundry-0.10.2.dev45}/src/log_foundry/sanitize.py +0 -0
  20. {log_foundry-0.10.2.dev43 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/__init__.py +0 -0
  21. {log_foundry-0.10.2.dev43 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/_batch.py +0 -0
  22. {log_foundry-0.10.2.dev43 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/_chunk.py +0 -0
  23. {log_foundry-0.10.2.dev43 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/_retry.py +0 -0
  24. {log_foundry-0.10.2.dev43 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/_socket.py +0 -0
  25. {log_foundry-0.10.2.dev43 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/_time.py +0 -0
  26. {log_foundry-0.10.2.dev43 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/callback.py +0 -0
  27. {log_foundry-0.10.2.dev43 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/clickhouse.py +0 -0
  28. {log_foundry-0.10.2.dev43 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/datadog.py +0 -0
  29. {log_foundry-0.10.2.dev43 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/elasticsearch.py +0 -0
  30. {log_foundry-0.10.2.dev43 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/eventhubs.py +0 -0
  31. {log_foundry-0.10.2.dev43 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/file.py +0 -0
  32. {log_foundry-0.10.2.dev43 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/filtering.py +0 -0
  33. {log_foundry-0.10.2.dev43 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/firehose.py +0 -0
  34. {log_foundry-0.10.2.dev43 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/honeycomb.py +0 -0
  35. {log_foundry-0.10.2.dev43 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/http.py +0 -0
  36. {log_foundry-0.10.2.dev43 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/kafka.py +0 -0
  37. {log_foundry-0.10.2.dev43 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/kinesis.py +0 -0
  38. {log_foundry-0.10.2.dev43 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/logging_sink.py +0 -0
  39. {log_foundry-0.10.2.dev43 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/logstash.py +0 -0
  40. {log_foundry-0.10.2.dev43 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/loki.py +0 -0
  41. {log_foundry-0.10.2.dev43 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/mongodb.py +0 -0
  42. {log_foundry-0.10.2.dev43 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/multi.py +0 -0
  43. {log_foundry-0.10.2.dev43 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/nats.py +0 -0
  44. {log_foundry-0.10.2.dev43 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/newrelic.py +0 -0
  45. {log_foundry-0.10.2.dev43 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/postgres.py +0 -0
  46. {log_foundry-0.10.2.dev43 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/pubsub.py +0 -0
  47. {log_foundry-0.10.2.dev43 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/rabbitmq.py +0 -0
  48. {log_foundry-0.10.2.dev43 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/redis.py +0 -0
  49. {log_foundry-0.10.2.dev43 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/sentry.py +0 -0
  50. {log_foundry-0.10.2.dev43 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/sns.py +0 -0
  51. {log_foundry-0.10.2.dev43 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/splunk.py +0 -0
  52. {log_foundry-0.10.2.dev43 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/sqlite.py +0 -0
  53. {log_foundry-0.10.2.dev43 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/sqs.py +0 -0
  54. {log_foundry-0.10.2.dev43 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/stdout.py +0 -0
  55. {log_foundry-0.10.2.dev43 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/syslog.py +0 -0
  56. {log_foundry-0.10.2.dev43 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/transform.py +0 -0
  57. {log_foundry-0.10.2.dev43 → log_foundry-0.10.2.dev45}/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.dev43
3
+ Version: 0.10.2.dev45
4
4
  Summary: Generate logs for your console and JSON events for downstream consumption.
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -68,6 +68,25 @@ pip install log-foundry # core, zero dependencies
68
68
  pip install 'log-foundry[aws]' # + boto3 for the SQS/SNS/Kinesis/Firehose sinks
69
69
  ```
70
70
 
71
+ > **Breaking in `1.0.0`.** Three public shapes change once, before the API is frozen under
72
+ > semantic versioning, because none of them could be changed afterwards without a major version:
73
+ >
74
+ > - **`health()` and `sink.losses()` return frozen dataclasses**, not `NamedTuple`s. Attribute
75
+ > access (`h.dropped`, `losses.failed`) is unchanged and is the whole contract; `len(h)`,
76
+ > `h[0]` and `queued, dropped, failed = health()` now raise `TypeError`. `Health` has gained a
77
+ > field in six consecutive specs and gains more — with no positions, that stops being breaking.
78
+ > - **`flush()` returns a `FlushResult` and `continue_trace()` a `ContinueResult`**, each truthy
79
+ > or falsy with a `reason` naming *why*. `if lf.flush():` is unchanged; **`lf.flush() is True`
80
+ > is not** — the result is an object. A one-bit return could not grow a reason later without
81
+ > silently changing what `if flush():` means, which is why it moved now.
82
+ > - **`SQSSink`'s injected client is keyword-only** (`SQSSink(url, client=…)`), **`SentrySink`
83
+ > injects through `client=`** rather than the old `sdk` keyword, with no alias, and the sink attribute the
84
+ > library assigns for interruptible backoff is **`log_foundry_stop_signal`**, not
85
+ > `stop_signal` — a prefixed name cannot silently overwrite one your own sink already uses.
86
+ >
87
+ > `echo`, `message` and `fields` are reserved parameter names on the emitters; pass fields of
88
+ > those names through `fields={...}`, which also takes keys that are not Python identifiers.
89
+
71
90
  > **Renamed in 0.2.0: `log_forge` → `log_foundry`.** The import package now matches the
72
91
  > distribution name — `pip install log-foundry`, then `import log_foundry`. If you are on
73
92
  > `0.1.x`, update your imports; there is no compatibility shim. The project was originally
@@ -327,8 +346,8 @@ await fetch(4127) # one trace_id; load's parent_span_id == fetch's span_id
327
346
  Emit your own structured events from inside a decorated call with the level functions
328
347
  `debug` / `info` / `warning` / `error` / `critical`. Each appends one event to the current
329
348
  span, so the whole call's logs flush together and share its `trace_id` / `span_id`. Keyword
330
- arguments land in the event's `fields`; the function name is captured, but arguments and
331
- return values never are.
349
+ arguments land in the event's `fields` — except the three reserved names below; the function
350
+ name is captured, but arguments and return values never are.
332
351
 
333
352
  ```python
334
353
  @lf.trace
@@ -341,7 +360,8 @@ def process_payment(user_id: int) -> str:
341
360
 
342
361
  - **`set_baggage(**kv)`** — attach trace-scoped context that is merged into the `fields` of
343
362
  every subsequent event in the same execution flow. Precedence, lowest to highest: config
344
- `defaults` → span `defaults` → baggage → per-call `fields`. **Trace-scoped means it ends
363
+ `defaults` → span `defaults` → baggage → per-call fields (`fields=` first, then `**kwargs`
364
+ over it). **Trace-scoped means it ends
345
365
  with the trace:** when the outermost `@trace` call returns *or raises*, the baggage in effect
346
366
  before it is restored, so one request's keys do not reach the next request's events. Nested
347
367
  calls do not reset — baggage set three calls deep stays visible to its parent and to the
@@ -352,6 +372,18 @@ def process_payment(user_id: int) -> str:
352
372
  - **`echo=True`** — *additionally* write a human-readable `LEVEL message` line to the console
353
373
  (`sys.stderr` by default), synchronously, without waiting for the async flush. The event
354
374
  still rides the normal pipeline to the sink — echo never redirects.
375
+ - **`fields={...}`** — the escape hatch. `message`, `echo` and `fields` are **reserved**: they
376
+ are parameters, so `info("x", echo="the payload we echoed back")` would switch on the console
377
+ line instead of recording a field. Pass them — and any key that is not a Python identifier —
378
+ through `fields=`:
379
+
380
+ ```python
381
+ lf.info("proxied", fields={"echo": "the payload we echoed back", "content-type": "text/json"})
382
+ ```
383
+
384
+ It reaches its own name too (`fields={"fields": ...}`), so every reserved word has exactly one
385
+ route through. A key given both ways takes the keyword's value, since `**kwargs` is what you
386
+ wrote at the call site and `fields=` is usually a mapping built elsewhere.
355
387
  - **Orphan logs** — a level call made with no active span is not dropped: it emits a standalone
356
388
  one-event span with a fresh `trace_id`, flushed straight to the sink.
357
389
 
@@ -392,7 +424,7 @@ def handler(event, context):
392
424
 
393
425
  | Call | Does |
394
426
  |---|---|
395
- | `continue_trace(traceparent=None, *, trace_id=None, parent_span_id=None, baggage=None)` | Adopt an inbound context. `True` if adopted, `False` if nothing valid was supplied. Never raises. |
427
+ | `continue_trace(traceparent=None, *, trace_id=None, parent_span_id=None, baggage=None)` | Adopt an inbound context. Returns a `ContinueResult`: truthy if adopted, else falsy with `reason` of `"nothing-supplied"` or `"rejected"`. The verdict is about the **trace context** — `baggage=` is merged independently and does not make it truthy. Never raises. |
396
428
  | `current_traceparent()` | This span as a W3C `traceparent` string, or `None` if no span is active. |
397
429
  | `current_trace_context()` | `(trace_id, span_id)`, for when moving two fields beats moving a string. |
398
430
  | `current_baggage_header()` | Current baggage in W3C `baggage` format (`""` when empty). |
@@ -924,11 +956,12 @@ by design: a later level call is *refused* at the closed sink and announced on s
924
956
  guards its own post-close state — rather than queued where nothing will drain it, and those are not
925
957
  the same claim. A stateless sink such as the default `StdoutSink` still accepts it.
926
958
 
927
- Read a snapshot by attribute (`h.dropped`), as above. `Health` is a `NamedTuple` and has gained
928
- fields over time a fourth (`stopped_reason`) in `v0.7.0`, and a fifth (`sink`) plus four more
929
- (`retired`, `submitted_after_shutdown`, `incomplete_swaps`, `closing_sinks`) not yet in a tagged
930
- release so unpacking it whole (`queued, dropped, failed = health()`) raises `ValueError`. Every
931
- field keeps its position when a new one is appended, so attribute and index access stay stable.
959
+ Read a snapshot **by attribute** (`h.dropped`), as above that is the whole contract. `Health`
960
+ and `SinkLosses` are frozen dataclasses, so `len(h)`, `h[0]` and `queued, dropped, failed =
961
+ health()` all raise `TypeError`. They were `NamedTuple`s before `1.0.0` and the tuple shape is
962
+ deliberately gone: `Health` has gained a field in six consecutive specs and gains more, and every
963
+ one of those had to argue that the positions before it were undisturbed. There are no positions to
964
+ disturb now, and adding a field is not a breaking change.
932
965
 
933
966
  `dropped` counts submissions discarded because the queue filled; `failed_batches` counts batches
934
967
  abandoned after the retry budget was spent. Overflow also warns on stderr — on the first drop and
@@ -942,12 +975,12 @@ which one you want depends on whether the process is about to end:
942
975
  ```python
943
976
  import log_foundry as lf
944
977
 
945
- lf.flush() # drain to the sink and keep going; returns True when everything landed
978
+ lf.flush() # drain to the sink and keep going; truthy when everything landed
946
979
  lf.shutdown() # drain, close the sink, and stop for good; blocks until drained (30s cap)
947
980
  ```
948
981
 
949
982
  Both are bounded, because both can be called somewhere with a deadline. `flush(timeout=5.0)`
950
- returns `False` if the drain did not complete; `shutdown(timeout=30.0)` returns having stopped
983
+ is falsy if the drain did not complete; `shutdown(timeout=30.0)` returns having stopped
951
984
  what it could, and reports `health().stopped_reason == "ShutdownTimeout"`. Passing `None` to
952
985
  either waits indefinitely, which is unsafe in any environment with an execution deadline.
953
986
 
@@ -972,11 +1005,30 @@ docstring states its own worst case.
972
1005
  explicitly when you need to be certain the tail reached the sink before a fast exit, e.g. at the
973
1006
  end of a short script. It is idempotent.
974
1007
 
975
- `flush(timeout=5.0)` returns `True` when **nothing was lost while the call was outstanding** — the
976
- drain it forces reached the sink, and so did anything else the worker emitted while it waited its
977
- turn. It returns `False` on timeout, when the worker was already shut down or has died, and when
978
- any batch was abandoned inside that window. A `True` is evidence of delivery, not merely that a
979
- drain took place.
1008
+ `flush(timeout=5.0)` returns a **`FlushResult`**, truthy when **nothing was lost while the call
1009
+ was outstanding** — the drain it forces reached the sink, and so did anything else the worker
1010
+ emitted while it waited its turn. A truthy result is evidence of delivery, not merely that a drain
1011
+ took place.
1012
+
1013
+ Falsy carries a `reason` saying which of five things happened, because they need different fixes:
1014
+
1015
+ | `reason` | Means |
1016
+ |---|---|
1017
+ | `"timed-out"` | The drain did not finish inside your timeout — the destination is slow. |
1018
+ | `"retired"` | `shutdown()` was already called. Your lifecycle is wrong, not the sink. |
1019
+ | `"thread-died"` | The drain thread is gone; see `health().stopped_reason`. |
1020
+ | `"queue-full"` | Backpressure — the queue could not even accept the marker. |
1021
+ | `"abandoned"` | A batch was given up on after its retry budget. The destination is broken. |
1022
+
1023
+ ```python
1024
+ result = lf.flush(5.0)
1025
+ if not result:
1026
+ print(f"undelivered: {result.reason}")
1027
+ ```
1028
+
1029
+ `if lf.flush():` works exactly as it always did. `lf.flush() is True` does **not** — the result is
1030
+ an object, not a bool. New `reason` values may appear in any release, so treat an unrecognised one
1031
+ as "some other failure" rather than matching exhaustively.
980
1032
 
981
1033
  The window starts when you call it. A batch abandoned *before* that is deliberately not its
982
1034
  business: the loss is already counted in `health().failed_batches` and reported on stderr, and
@@ -32,6 +32,25 @@ pip install log-foundry # core, zero dependencies
32
32
  pip install 'log-foundry[aws]' # + boto3 for the SQS/SNS/Kinesis/Firehose sinks
33
33
  ```
34
34
 
35
+ > **Breaking in `1.0.0`.** Three public shapes change once, before the API is frozen under
36
+ > semantic versioning, because none of them could be changed afterwards without a major version:
37
+ >
38
+ > - **`health()` and `sink.losses()` return frozen dataclasses**, not `NamedTuple`s. Attribute
39
+ > access (`h.dropped`, `losses.failed`) is unchanged and is the whole contract; `len(h)`,
40
+ > `h[0]` and `queued, dropped, failed = health()` now raise `TypeError`. `Health` has gained a
41
+ > field in six consecutive specs and gains more — with no positions, that stops being breaking.
42
+ > - **`flush()` returns a `FlushResult` and `continue_trace()` a `ContinueResult`**, each truthy
43
+ > or falsy with a `reason` naming *why*. `if lf.flush():` is unchanged; **`lf.flush() is True`
44
+ > is not** — the result is an object. A one-bit return could not grow a reason later without
45
+ > silently changing what `if flush():` means, which is why it moved now.
46
+ > - **`SQSSink`'s injected client is keyword-only** (`SQSSink(url, client=…)`), **`SentrySink`
47
+ > injects through `client=`** rather than the old `sdk` keyword, with no alias, and the sink attribute the
48
+ > library assigns for interruptible backoff is **`log_foundry_stop_signal`**, not
49
+ > `stop_signal` — a prefixed name cannot silently overwrite one your own sink already uses.
50
+ >
51
+ > `echo`, `message` and `fields` are reserved parameter names on the emitters; pass fields of
52
+ > those names through `fields={...}`, which also takes keys that are not Python identifiers.
53
+
35
54
  > **Renamed in 0.2.0: `log_forge` → `log_foundry`.** The import package now matches the
36
55
  > distribution name — `pip install log-foundry`, then `import log_foundry`. If you are on
37
56
  > `0.1.x`, update your imports; there is no compatibility shim. The project was originally
@@ -291,8 +310,8 @@ await fetch(4127) # one trace_id; load's parent_span_id == fetch's span_id
291
310
  Emit your own structured events from inside a decorated call with the level functions
292
311
  `debug` / `info` / `warning` / `error` / `critical`. Each appends one event to the current
293
312
  span, so the whole call's logs flush together and share its `trace_id` / `span_id`. Keyword
294
- arguments land in the event's `fields`; the function name is captured, but arguments and
295
- return values never are.
313
+ arguments land in the event's `fields` — except the three reserved names below; the function
314
+ name is captured, but arguments and return values never are.
296
315
 
297
316
  ```python
298
317
  @lf.trace
@@ -305,7 +324,8 @@ def process_payment(user_id: int) -> str:
305
324
 
306
325
  - **`set_baggage(**kv)`** — attach trace-scoped context that is merged into the `fields` of
307
326
  every subsequent event in the same execution flow. Precedence, lowest to highest: config
308
- `defaults` → span `defaults` → baggage → per-call `fields`. **Trace-scoped means it ends
327
+ `defaults` → span `defaults` → baggage → per-call fields (`fields=` first, then `**kwargs`
328
+ over it). **Trace-scoped means it ends
309
329
  with the trace:** when the outermost `@trace` call returns *or raises*, the baggage in effect
310
330
  before it is restored, so one request's keys do not reach the next request's events. Nested
311
331
  calls do not reset — baggage set three calls deep stays visible to its parent and to the
@@ -316,6 +336,18 @@ def process_payment(user_id: int) -> str:
316
336
  - **`echo=True`** — *additionally* write a human-readable `LEVEL message` line to the console
317
337
  (`sys.stderr` by default), synchronously, without waiting for the async flush. The event
318
338
  still rides the normal pipeline to the sink — echo never redirects.
339
+ - **`fields={...}`** — the escape hatch. `message`, `echo` and `fields` are **reserved**: they
340
+ are parameters, so `info("x", echo="the payload we echoed back")` would switch on the console
341
+ line instead of recording a field. Pass them — and any key that is not a Python identifier —
342
+ through `fields=`:
343
+
344
+ ```python
345
+ lf.info("proxied", fields={"echo": "the payload we echoed back", "content-type": "text/json"})
346
+ ```
347
+
348
+ It reaches its own name too (`fields={"fields": ...}`), so every reserved word has exactly one
349
+ route through. A key given both ways takes the keyword's value, since `**kwargs` is what you
350
+ wrote at the call site and `fields=` is usually a mapping built elsewhere.
319
351
  - **Orphan logs** — a level call made with no active span is not dropped: it emits a standalone
320
352
  one-event span with a fresh `trace_id`, flushed straight to the sink.
321
353
 
@@ -356,7 +388,7 @@ def handler(event, context):
356
388
 
357
389
  | Call | Does |
358
390
  |---|---|
359
- | `continue_trace(traceparent=None, *, trace_id=None, parent_span_id=None, baggage=None)` | Adopt an inbound context. `True` if adopted, `False` if nothing valid was supplied. Never raises. |
391
+ | `continue_trace(traceparent=None, *, trace_id=None, parent_span_id=None, baggage=None)` | Adopt an inbound context. Returns a `ContinueResult`: truthy if adopted, else falsy with `reason` of `"nothing-supplied"` or `"rejected"`. The verdict is about the **trace context** — `baggage=` is merged independently and does not make it truthy. Never raises. |
360
392
  | `current_traceparent()` | This span as a W3C `traceparent` string, or `None` if no span is active. |
361
393
  | `current_trace_context()` | `(trace_id, span_id)`, for when moving two fields beats moving a string. |
362
394
  | `current_baggage_header()` | Current baggage in W3C `baggage` format (`""` when empty). |
@@ -888,11 +920,12 @@ by design: a later level call is *refused* at the closed sink and announced on s
888
920
  guards its own post-close state — rather than queued where nothing will drain it, and those are not
889
921
  the same claim. A stateless sink such as the default `StdoutSink` still accepts it.
890
922
 
891
- Read a snapshot by attribute (`h.dropped`), as above. `Health` is a `NamedTuple` and has gained
892
- fields over time a fourth (`stopped_reason`) in `v0.7.0`, and a fifth (`sink`) plus four more
893
- (`retired`, `submitted_after_shutdown`, `incomplete_swaps`, `closing_sinks`) not yet in a tagged
894
- release so unpacking it whole (`queued, dropped, failed = health()`) raises `ValueError`. Every
895
- field keeps its position when a new one is appended, so attribute and index access stay stable.
923
+ Read a snapshot **by attribute** (`h.dropped`), as above that is the whole contract. `Health`
924
+ and `SinkLosses` are frozen dataclasses, so `len(h)`, `h[0]` and `queued, dropped, failed =
925
+ health()` all raise `TypeError`. They were `NamedTuple`s before `1.0.0` and the tuple shape is
926
+ deliberately gone: `Health` has gained a field in six consecutive specs and gains more, and every
927
+ one of those had to argue that the positions before it were undisturbed. There are no positions to
928
+ disturb now, and adding a field is not a breaking change.
896
929
 
897
930
  `dropped` counts submissions discarded because the queue filled; `failed_batches` counts batches
898
931
  abandoned after the retry budget was spent. Overflow also warns on stderr — on the first drop and
@@ -906,12 +939,12 @@ which one you want depends on whether the process is about to end:
906
939
  ```python
907
940
  import log_foundry as lf
908
941
 
909
- lf.flush() # drain to the sink and keep going; returns True when everything landed
942
+ lf.flush() # drain to the sink and keep going; truthy when everything landed
910
943
  lf.shutdown() # drain, close the sink, and stop for good; blocks until drained (30s cap)
911
944
  ```
912
945
 
913
946
  Both are bounded, because both can be called somewhere with a deadline. `flush(timeout=5.0)`
914
- returns `False` if the drain did not complete; `shutdown(timeout=30.0)` returns having stopped
947
+ is falsy if the drain did not complete; `shutdown(timeout=30.0)` returns having stopped
915
948
  what it could, and reports `health().stopped_reason == "ShutdownTimeout"`. Passing `None` to
916
949
  either waits indefinitely, which is unsafe in any environment with an execution deadline.
917
950
 
@@ -936,11 +969,30 @@ docstring states its own worst case.
936
969
  explicitly when you need to be certain the tail reached the sink before a fast exit, e.g. at the
937
970
  end of a short script. It is idempotent.
938
971
 
939
- `flush(timeout=5.0)` returns `True` when **nothing was lost while the call was outstanding** — the
940
- drain it forces reached the sink, and so did anything else the worker emitted while it waited its
941
- turn. It returns `False` on timeout, when the worker was already shut down or has died, and when
942
- any batch was abandoned inside that window. A `True` is evidence of delivery, not merely that a
943
- drain took place.
972
+ `flush(timeout=5.0)` returns a **`FlushResult`**, truthy when **nothing was lost while the call
973
+ was outstanding** — the drain it forces reached the sink, and so did anything else the worker
974
+ emitted while it waited its turn. A truthy result is evidence of delivery, not merely that a drain
975
+ took place.
976
+
977
+ Falsy carries a `reason` saying which of five things happened, because they need different fixes:
978
+
979
+ | `reason` | Means |
980
+ |---|---|
981
+ | `"timed-out"` | The drain did not finish inside your timeout — the destination is slow. |
982
+ | `"retired"` | `shutdown()` was already called. Your lifecycle is wrong, not the sink. |
983
+ | `"thread-died"` | The drain thread is gone; see `health().stopped_reason`. |
984
+ | `"queue-full"` | Backpressure — the queue could not even accept the marker. |
985
+ | `"abandoned"` | A batch was given up on after its retry budget. The destination is broken. |
986
+
987
+ ```python
988
+ result = lf.flush(5.0)
989
+ if not result:
990
+ print(f"undelivered: {result.reason}")
991
+ ```
992
+
993
+ `if lf.flush():` works exactly as it always did. `lf.flush() is True` does **not** — the result is
994
+ an object, not a bool. New `reason` values may appear in any release, so treat an unrecognised one
995
+ as "some other failure" rather than matching exhaustively.
944
996
 
945
997
  The window starts when you call it. A batch abandoned *before* that is deliberately not its
946
998
  business: the loss is already counted in `health().failed_batches` and reported on stderr, and
@@ -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.dev43"
23
+ version = "0.10.2.dev45"
24
24
 
25
25
  [project.optional-dependencies]
26
26
  aws = ["boto3>=1.43.61"] # SQSSink, SNSSink, KinesisSink, FirehoseSink
@@ -13,6 +13,7 @@ from log_foundry.context import (
13
13
  reset_context,
14
14
  )
15
15
  from log_foundry.decorator import continue_trace, trace
16
+ from log_foundry.results import ContinueResult, FlushResult
16
17
  from log_foundry.sinks.base import Sink, SinkDeliveryError, SinkLosses, read_losses
17
18
  from log_foundry.worker import DEFAULT_SHUTDOWN_TIMEOUT, Health
18
19
 
@@ -22,7 +23,7 @@ except PackageNotFoundError:
22
23
  __version__ = "0.0.0"
23
24
 
24
25
 
25
- def flush(timeout: float | None = 5.0) -> bool:
26
+ def flush(timeout: float | None = 5.0) -> FlushResult:
26
27
  """Drains buffered events through the sink without closing it.
27
28
 
28
29
  This is the drain for a process that is frozen rather than exited — an AWS Lambda handler
@@ -37,10 +38,12 @@ def flush(timeout: float | None = 5.0) -> bool:
37
38
  "the invocation timed out".
38
39
 
39
40
  Returns:
40
- True when the events submitted before this call reached the sink. False if the drain did
41
- not complete within the timeout, if the worker has already been shut down or has died, or
42
- if a batch was abandoned while this call was outstanding (SPEC-021 FR-001) — so True means
43
- the events were delivered, not merely that a drain took place. Events submitted
41
+ A :class:`FlushResult`. Truthy when the events submitted before this call reached the
42
+ sink so a truthy result means they were delivered, not merely that a drain took place.
43
+ Falsy carries a ``reason`` naming which outcome occurred: ``"timed-out"``, ``"retired"``,
44
+ ``"thread-died"``, ``"queue-full"`` or ``"abandoned"``. It is falsy rather than ``False``:
45
+ ``if flush():`` is unchanged, but ``flush() is True`` can no longer hold, which is why the
46
+ type had to change before ``1.0.0`` rather than after (SPEC-034 FR-007). Events submitted
44
47
  concurrently by another thread may or may not be included, since the caller cannot have
45
48
  meant those, and a batch lost before the call belongs to :func:`health`.
46
49
 
@@ -158,6 +161,8 @@ def shutdown(timeout: float | None = DEFAULT_SHUTDOWN_TIMEOUT) -> None:
158
161
  __all__ = [
159
162
  "DEFAULT_SHUTDOWN_TIMEOUT",
160
163
  "Config",
164
+ "ContinueResult",
165
+ "FlushResult",
161
166
  "Health",
162
167
  "Sink",
163
168
  "SinkDeliveryError",
@@ -2,6 +2,8 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from typing import TYPE_CHECKING
6
+
5
7
  from log_foundry import _diag, context
6
8
  from log_foundry.config import _ensure_sink
7
9
  from log_foundry.console import ConsoleWriter
@@ -10,6 +12,9 @@ from log_foundry.decorator import _note_orphan_emit
10
12
  from log_foundry.ids import new_span_id, new_trace_id
11
13
  from log_foundry.model import Span, build_event
12
14
 
15
+ if TYPE_CHECKING:
16
+ from collections.abc import Mapping
17
+
13
18
  __all__ = [
14
19
  "critical",
15
20
  "debug",
@@ -100,13 +105,66 @@ def _log(level: str, message: str, echo: bool, fields: dict[str, object]) -> Non
100
105
  _diag.absorbed("echoing to the console", exc)
101
106
 
102
107
 
103
- def debug(message: str, *, echo: bool = False, **fields: object) -> None:
108
+ def _merge(fields: Mapping[str, object] | None, kv: dict[str, object]) -> dict[str, object]:
109
+ """Combines the explicit ``fields=`` mapping with the keyword form.
110
+
111
+ ``echo`` and ``message`` were reserved words stolen from the caller's field namespace:
112
+ ``info("x", echo="incoming payload echoed back")`` dropped a real field *and* turned on an
113
+ unwanted console line, and ``message=`` raised ``TypeError`` (SPEC-034 FR-004). ``fields=``
114
+ is the way round all three — including its own name, which is why the escape hatch can
115
+ express every key including the ones this signature reserves — and the only way to pass a
116
+ key that is not a Python identifier.
117
+
118
+ The keyword form wins a collision. ``fields=`` is the bulk route, usually a mapping built
119
+ somewhere else; ``**kv`` is what the caller wrote at this call site, and a literal
120
+ overriding a base is what ``{**base, **overrides}`` already means in the language.
121
+
122
+ A ``fields=`` that is not a mapping is **absorbed, not raised**. This helper runs in the
123
+ emitter, before :func:`_log`, so it sits outside that function's orphan guard entirely: an
124
+ unguarded ``{**fields, **kv}`` propagated a ``TypeError`` into the application on all four
125
+ entry paths, including the orphan one where SPEC-025's promise holds today, and inside a span
126
+ the decorator then recorded ``status=error`` with an ``error.type`` the caller's code never
127
+ raised. It was also asymmetric in a way neither reading defends — ``fields=[]`` was silently
128
+ ignored while ``fields=["x"]`` crashed. The library coerces rather than validates (SPEC-017),
129
+ so the tolerant half is the one that matches, and the fault is announced by type.
130
+
131
+ Args:
132
+ fields: The explicit mapping, or ``None``.
133
+ kv: The keyword-collected fields.
134
+
135
+ Returns:
136
+ One mapping, with ``kv`` taking precedence. The caller's ``fields`` is never mutated, and a
137
+ ``fields=`` that could not be merged is dropped rather than costing the event.
138
+
139
+ Raises:
140
+ None.
141
+ """
142
+ if not fields:
143
+ return kv
144
+ try:
145
+ return {**fields, **kv}
146
+ except Exception as exc:
147
+ _diag.absorbed("merging the fields= argument", exc, "the fields= argument was ignored")
148
+ return kv
149
+
150
+
151
+ def debug(
152
+ message: str,
153
+ *,
154
+ echo: bool = False,
155
+ fields: Mapping[str, object] | None = None,
156
+ **kv: object,
157
+ ) -> None:
104
158
  """Emits a ``DEBUG`` event on the current span, or a standalone orphan span.
105
159
 
106
160
  Args:
107
161
  message: The message text.
108
162
  echo: Whether to also write a human-readable console line.
109
- **fields: Per-call structured fields.
163
+ fields: Per-call structured fields, for names ``**kv`` cannot express — the three
164
+ reserved words ``message``, ``echo`` and ``fields`` itself, and any key that is not a
165
+ Python identifier at all. Merged **under** ``**kv``, so a name given both ways takes the
166
+ keyword's value.
167
+ **kv: Per-call structured fields.
110
168
 
111
169
  Returns:
112
170
  None.
@@ -114,16 +172,26 @@ def debug(message: str, *, echo: bool = False, **fields: object) -> None:
114
172
  Raises:
115
173
  None.
116
174
  """
117
- _log("DEBUG", message, echo, fields)
175
+ _log("DEBUG", message, echo, _merge(fields, kv))
118
176
 
119
177
 
120
- def info(message: str, *, echo: bool = False, **fields: object) -> None:
178
+ def info(
179
+ message: str,
180
+ *,
181
+ echo: bool = False,
182
+ fields: Mapping[str, object] | None = None,
183
+ **kv: object,
184
+ ) -> None:
121
185
  """Emits an ``INFO`` event on the current span, or a standalone orphan span.
122
186
 
123
187
  Args:
124
188
  message: The message text.
125
189
  echo: Whether to also write a human-readable console line.
126
- **fields: Per-call structured fields.
190
+ fields: Per-call structured fields, for names ``**kv`` cannot express — the three
191
+ reserved words ``message``, ``echo`` and ``fields`` itself, and any key that is not a
192
+ Python identifier at all. Merged **under** ``**kv``, so a name given both ways takes the
193
+ keyword's value.
194
+ **kv: Per-call structured fields.
127
195
 
128
196
  Returns:
129
197
  None.
@@ -131,16 +199,26 @@ def info(message: str, *, echo: bool = False, **fields: object) -> None:
131
199
  Raises:
132
200
  None.
133
201
  """
134
- _log("INFO", message, echo, fields)
202
+ _log("INFO", message, echo, _merge(fields, kv))
135
203
 
136
204
 
137
- def warning(message: str, *, echo: bool = False, **fields: object) -> None:
205
+ def warning(
206
+ message: str,
207
+ *,
208
+ echo: bool = False,
209
+ fields: Mapping[str, object] | None = None,
210
+ **kv: object,
211
+ ) -> None:
138
212
  """Emits a ``WARNING`` event on the current span, or a standalone orphan span.
139
213
 
140
214
  Args:
141
215
  message: The message text.
142
216
  echo: Whether to also write a human-readable console line.
143
- **fields: Per-call structured fields.
217
+ fields: Per-call structured fields, for names ``**kv`` cannot express — the three
218
+ reserved words ``message``, ``echo`` and ``fields`` itself, and any key that is not a
219
+ Python identifier at all. Merged **under** ``**kv``, so a name given both ways takes the
220
+ keyword's value.
221
+ **kv: Per-call structured fields.
144
222
 
145
223
  Returns:
146
224
  None.
@@ -148,16 +226,26 @@ def warning(message: str, *, echo: bool = False, **fields: object) -> None:
148
226
  Raises:
149
227
  None.
150
228
  """
151
- _log("WARNING", message, echo, fields)
229
+ _log("WARNING", message, echo, _merge(fields, kv))
152
230
 
153
231
 
154
- def error(message: str, *, echo: bool = False, **fields: object) -> None:
232
+ def error(
233
+ message: str,
234
+ *,
235
+ echo: bool = False,
236
+ fields: Mapping[str, object] | None = None,
237
+ **kv: object,
238
+ ) -> None:
155
239
  """Emits an ``ERROR`` event on the current span, or a standalone orphan span.
156
240
 
157
241
  Args:
158
242
  message: The message text.
159
243
  echo: Whether to also write a human-readable console line.
160
- **fields: Per-call structured fields.
244
+ fields: Per-call structured fields, for names ``**kv`` cannot express — the three
245
+ reserved words ``message``, ``echo`` and ``fields`` itself, and any key that is not a
246
+ Python identifier at all. Merged **under** ``**kv``, so a name given both ways takes the
247
+ keyword's value.
248
+ **kv: Per-call structured fields.
161
249
 
162
250
  Returns:
163
251
  None.
@@ -165,16 +253,26 @@ def error(message: str, *, echo: bool = False, **fields: object) -> None:
165
253
  Raises:
166
254
  None.
167
255
  """
168
- _log("ERROR", message, echo, fields)
256
+ _log("ERROR", message, echo, _merge(fields, kv))
169
257
 
170
258
 
171
- def critical(message: str, *, echo: bool = False, **fields: object) -> None:
259
+ def critical(
260
+ message: str,
261
+ *,
262
+ echo: bool = False,
263
+ fields: Mapping[str, object] | None = None,
264
+ **kv: object,
265
+ ) -> None:
172
266
  """Emits a ``CRITICAL`` event on the current span, or a standalone orphan span.
173
267
 
174
268
  Args:
175
269
  message: The message text.
176
270
  echo: Whether to also write a human-readable console line.
177
- **fields: Per-call structured fields.
271
+ fields: Per-call structured fields, for names ``**kv`` cannot express — the three
272
+ reserved words ``message``, ``echo`` and ``fields`` itself, and any key that is not a
273
+ Python identifier at all. Merged **under** ``**kv``, so a name given both ways takes the
274
+ keyword's value.
275
+ **kv: Per-call structured fields.
178
276
 
179
277
  Returns:
180
278
  None.
@@ -182,4 +280,4 @@ def critical(message: str, *, echo: bool = False, **fields: object) -> None:
182
280
  Raises:
183
281
  None.
184
282
  """
185
- _log("CRITICAL", message, echo, fields)
283
+ _log("CRITICAL", message, echo, _merge(fields, kv))
@@ -7,6 +7,7 @@ import atexit
7
7
  import functools
8
8
  import threading
9
9
  from collections.abc import Callable
10
+ from dataclasses import replace
10
11
  from time import monotonic
11
12
  from typing import TYPE_CHECKING, Any, TypeVar, cast, overload
12
13
 
@@ -20,6 +21,7 @@ from log_foundry.ids import (
20
21
  parse_traceparent,
21
22
  )
22
23
  from log_foundry.model import Span, backfill_baggage, end_event, start_event
24
+ from log_foundry.results import ContinueResult, FlushResult
23
25
  from log_foundry.worker import DEFAULT_SHUTDOWN_TIMEOUT, DEFAULT_SWAP_TIMEOUT, Health, Worker
24
26
 
25
27
  if TYPE_CHECKING:
@@ -104,7 +106,7 @@ def continue_trace(
104
106
  trace_id: str | None = None,
105
107
  parent_span_id: str | None = None,
106
108
  baggage: str | None = None,
107
- ) -> bool:
109
+ ) -> ContinueResult:
108
110
  """Adopts an inbound trace context so this process's spans join the caller's trace.
109
111
 
110
112
  Call it on the first line of the entry point: if a span is already open and it is a root,
@@ -135,31 +137,52 @@ def continue_trace(
135
137
  losing the trace join because one field was malformed is worse.
136
138
 
137
139
  Returns:
138
- True when a context was adopted, False when nothing valid was supplied and a fresh
140
+ A :class:`ContinueResult`. Truthy when a context was adopted; falsy with a ``reason`` of
141
+ ``"nothing-supplied"`` when no argument carried one, or ``"rejected"`` when something was
142
+ supplied and was malformed — two outcomes that read identically as ``False`` today, where
143
+ the second is a caller bug and the first is often a deliberate "continue if there is one".
144
+ Falsy also means a fresh
139
145
  trace is in use. Supplying nothing at all is a silent no-op rather than a rejection,
140
146
  since a caller who did not propagate a header would otherwise get a line per
141
147
  invocation.
142
148
 
149
+ **The verdict describes the trace context and nothing else.** ``baggage=`` is merged
150
+ independently of it (SPEC-014: losing correlating fields is bad, and losing the trace join
151
+ because one field was malformed is worse), so ``continue_trace(baggage=...)`` alone applies
152
+ the baggage and still reports falsy — there was no trace context to adopt. The reason then
153
+ distinguishes the two honestly, because ``"rejected"`` means **exactly** that a rejection
154
+ was announced through ``_diag``: a malformed ``baggage=`` is a rejection, a well-formed one
155
+ is not, and each reads back the way the stderr line does. A first version keyed the reason
156
+ on "was any argument supplied", which reported ``"nothing-supplied"`` for a malformed
157
+ baggage header *while writing the rejection line for it* — the discrimination FR-007 AC-3
158
+ exists to provide, stated backwards.
159
+
143
160
  Raises:
144
161
  None.
145
162
  """
146
163
  adopted: tuple[str, str | None] | None = None
164
+ announced = False
147
165
  if traceparent is not None:
148
166
  if trace_id is not None or parent_span_id is not None:
149
167
  _diag.rejected("both traceparent and explicit ids given; traceparent wins", traceparent)
150
168
  parsed = parse_traceparent(traceparent)
151
169
  if parsed is None:
152
170
  _diag.rejected("unparseable traceparent", traceparent)
171
+ announced = True
153
172
  else:
154
173
  adopted = parsed
155
174
  elif trace_id is not None:
156
175
  if not is_valid_trace_id(trace_id):
157
176
  _diag.rejected("invalid trace_id", trace_id)
177
+ announced = True
158
178
  elif parent_span_id is not None and not is_valid_span_id(parent_span_id):
159
179
  _diag.rejected("invalid parent_span_id; joining as a root", parent_span_id)
160
180
  adopted = (trace_id, None)
161
181
  else:
162
182
  adopted = (trace_id, parent_span_id)
183
+ elif parent_span_id is not None:
184
+ _diag.rejected("parent_span_id given with no trace_id to join", parent_span_id)
185
+ announced = True
163
186
 
164
187
  if adopted is not None:
165
188
  context.set_adopted_context(*adopted)
@@ -169,10 +192,13 @@ def continue_trace(
169
192
  parsed_baggage = context.parse_baggage_header(baggage)
170
193
  if parsed_baggage is None:
171
194
  _diag.rejected("unusable baggage header", baggage)
195
+ announced = True
172
196
  else:
173
197
  context.set_baggage(**parsed_baggage)
174
198
 
175
- return adopted is not None
199
+ if adopted is not None:
200
+ return ContinueResult(ok=True)
201
+ return ContinueResult(ok=False, reason="rejected" if announced else "nothing-supplied")
176
202
 
177
203
 
178
204
  def _reparent_current_span(trace_id: str, parent_span_id: str | None) -> None:
@@ -636,7 +662,7 @@ def _adopt_declined_swap(new_sink: Sink) -> None:
636
662
  _orphan_sink = new_sink
637
663
 
638
664
 
639
- def _flush_worker(timeout: float | None = 5.0) -> bool:
665
+ def _flush_worker(timeout: float | None = 5.0) -> FlushResult:
640
666
  """Drains the process worker without retiring it, backing ``flush()`` (SPEC-013 FR-003).
641
667
 
642
668
  This deliberately does not call :func:`_get_worker`: a process that never logged has
@@ -647,7 +673,9 @@ def _flush_worker(timeout: float | None = 5.0) -> bool:
647
673
  timeout: Seconds to wait for the drain, or ``None`` to wait indefinitely.
648
674
 
649
675
  Returns:
650
- Whether everything outstanding was delivered, and True when no worker exists.
676
+ A :class:`FlushResult`, truthy when everything outstanding was delivered and when no
677
+ worker exists — a process that never logged has nothing to drain, so it has lost
678
+ nothing.
651
679
 
652
680
  Raises:
653
681
  None. A flush is the call most likely to be made in a ``finally``, so the library must
@@ -656,11 +684,11 @@ def _flush_worker(timeout: float | None = 5.0) -> bool:
656
684
  """
657
685
  worker = _worker
658
686
  if worker is None:
659
- return True
687
+ return FlushResult(ok=True)
660
688
  try:
661
689
  return worker.flush(timeout)
662
690
  except Exception:
663
- return False
691
+ return FlushResult(ok=False, reason="thread-died")
664
692
 
665
693
 
666
694
  def _worker_health() -> Health:
@@ -710,7 +738,7 @@ def _worker_health() -> Health:
710
738
  )
711
739
  health = worker.health()
712
740
  if _orphan_retired and not health.retired:
713
- return health._replace(retired=True)
741
+ return replace(health, retired=True)
714
742
  return health
715
743
 
716
744
 
@@ -0,0 +1,65 @@
1
+ """Result types for the two public calls that answered five questions with one bit (SPEC-034)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ __all__ = ["ContinueResult", "FlushResult"]
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class _Result:
12
+ """A verdict that reads as a boolean and can say why (SPEC-034 FR-007).
13
+
14
+ ``flush()`` returned one bit for five distinct outcomes — timed out, worker retired, drain
15
+ thread died, queue too full for the marker, a batch abandoned — and a Lambda handler needs
16
+ "the worker is retired, my code is wrong" separated from "the sink is slow". A ``NamedTuple``
17
+ cannot be retrofitted here: a non-empty tuple is always truthy, so every ``if flush():``
18
+ would silently start passing.
19
+
20
+ The type exists now, before ``1.0.0``, precisely so that later reasons are additive. It grows
21
+ by new ``reason`` values and never by changing :meth:`__bool__`.
22
+
23
+ Attributes:
24
+ ok: Whether the operation succeeded, and what ``bool()`` reports.
25
+ reason: A short stable token naming *why* when it did not, or ``None`` when it did. New
26
+ tokens may appear in any release; code should branch on ``bool()`` and treat an unknown
27
+ reason as "some other failure" rather than matching exhaustively.
28
+ """
29
+
30
+ ok: bool
31
+ reason: str | None = None
32
+
33
+ def __bool__(self) -> bool:
34
+ """Reports the verdict, so every existing ``if flush():`` keeps its meaning.
35
+
36
+ Args:
37
+ None.
38
+
39
+ Returns:
40
+ Whether the operation succeeded.
41
+
42
+ Raises:
43
+ None.
44
+ """
45
+ return self.ok
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class FlushResult(_Result):
50
+ """What :func:`log_foundry.flush` returns.
51
+
52
+ ``reason`` is ``None`` on success. The tokens it can carry today are ``"timed-out"``,
53
+ ``"retired"``, ``"thread-died"``, ``"queue-full"`` and ``"abandoned"``.
54
+ """
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class ContinueResult(_Result):
59
+ """What :func:`log_foundry.continue_trace` returns.
60
+
61
+ ``reason`` is ``None`` on success. Today it distinguishes ``"nothing-supplied"`` — no
62
+ argument carried a context — from ``"rejected"``, which means something *was* supplied and
63
+ was malformed. Those two read identically as ``False``, and the second is a caller bug where
64
+ the first is often a deliberate "continue if there is one to continue".
65
+ """
@@ -3,7 +3,8 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from abc import abstractmethod
6
- from typing import NamedTuple, Protocol, runtime_checkable
6
+ from dataclasses import dataclass
7
+ from typing import Protocol, runtime_checkable
7
8
 
8
9
  __all__ = ["Sink", "SinkDeliveryError", "SinkLosses", "read_losses"]
9
10
 
@@ -19,7 +20,8 @@ class SinkDeliveryError(Exception):
19
20
  """
20
21
 
21
22
 
22
- class SinkLosses(NamedTuple):
23
+ @dataclass(frozen=True)
24
+ class SinkLosses:
23
25
  """What a sink discarded or could not confirm, cumulative for its lifetime (FR-002).
24
26
 
25
27
  ``failed`` is an upper bound on loss, not a count of it: a sink that also raises on total
@@ -5,9 +5,11 @@ from __future__ import annotations
5
5
  import queue
6
6
  import threading
7
7
  import time
8
- from typing import TYPE_CHECKING, NamedTuple, cast
8
+ from dataclasses import dataclass
9
+ from typing import TYPE_CHECKING, cast
9
10
 
10
11
  from log_foundry import _diag, _lifecycle
12
+ from log_foundry.results import FlushResult
11
13
 
12
14
  if TYPE_CHECKING:
13
15
  from log_foundry.sinks.base import Sink, SinkLosses
@@ -57,7 +59,8 @@ def _bounded_seconds(timeout: float | None) -> str:
57
59
  return "?"
58
60
 
59
61
 
60
- class Health(NamedTuple):
62
+ @dataclass(frozen=True)
63
+ class Health:
61
64
  """A point-in-time snapshot of the worker's delivery counters (SPEC-017 FR-005).
62
65
 
63
66
  ``stopped_reason``, ``sink``, and SPEC-030's three are defaulted and appended in that
@@ -112,7 +115,10 @@ class Health(NamedTuple):
112
115
  may have been misrouted, never merely that a close was slow — ``closing_sinks`` is the
113
116
  field for that.
114
117
  closing_sinks: Swapped-out sinks whose ``close()`` is running *at this instant* — a live
115
- gauge, not a counter, and the only field here that can fall as well as rise. A close is
118
+ gauge rather than a counter, so it falls as well as rises. ~~the only field here that
119
+ can fall~~ — struck (SPEC-034 AC-2c): ``queued`` falls on every drain. Those two are the
120
+ gauges and the other five integers are monotonic, which is the distinction an operator
121
+ alerting on "any non-zero" needs and which no name here encodes. A close is
116
122
  bounded only in how long ``configure()`` waits for it, so this is how a destination
117
123
  stuck in ``close()`` becomes visible at all. Reading it non-zero once means a swap just
118
124
  happened; reading it non-zero repeatedly means a close is not coming back, and that sink
@@ -460,7 +466,7 @@ class Worker:
460
466
 
461
467
  return read_losses(self.sink)
462
468
 
463
- def flush(self, timeout: float | None = 5.0) -> bool:
469
+ def flush(self, timeout: float | None = 5.0) -> FlushResult:
464
470
  """Drains everything submitted before this call through the sink, without stopping.
465
471
 
466
472
  The precise claim is that nothing was abandoned while this call was outstanding: the
@@ -498,32 +504,42 @@ class Worker:
498
504
  wait, so the two cannot add up to twice the timeout. ``None`` waits indefinitely.
499
505
 
500
506
  Returns:
501
- True once the worker has delivered them. False on timeout, on a worker already shut
502
- down or dead, on a queue too full to accept the marker, and when the drain carrying
503
- those events was abandoned after exhausting retries (SPEC-021 FR-001). That last
504
- case used to return True, a false success exactly where ``flush()`` matters most.
507
+ A :class:`FlushResult`, truthy once the worker has delivered them and otherwise
508
+ falsy with a ``reason``: ``"timed-out"``, ``"retired"``, ``"thread-died"``,
509
+ ``"queue-full"``, or ``"abandoned"`` when the drain carrying those events gave up
510
+ after exhausting retries (SPEC-021 FR-001) — that last case used to return True, a
511
+ false success exactly where ``flush()`` matters most. **The inner call carries the
512
+ type too, not only the public ``log_foundry.flush``** (SPEC-034 FR-007 AC-1b): the
513
+ five outcomes are distinguishable only here, so a public wrapper over a bare ``bool``
514
+ could name none of them without guessing.
505
515
 
506
516
  Raises:
507
517
  None.
508
518
  """
509
519
  with self._lock:
510
520
  if self._shutdown_done:
511
- return False
521
+ return FlushResult(ok=False, reason="retired")
512
522
  if not self._thread.is_alive():
513
- return False
523
+ return FlushResult(ok=False, reason="thread-died")
514
524
  with self._lock:
515
525
  marker = _FlushMarker(self.failed_batches)
516
526
  deadline = None if timeout is None else time.monotonic() + timeout
517
527
  try:
518
528
  self._queue.put(marker, timeout=timeout)
519
529
  except queue.Full:
520
- return False
530
+ return FlushResult(ok=False, reason="queue-full")
521
531
  if self._drain_finished.is_set() or not self._thread.is_alive():
522
- return marker.event.is_set() and marker.delivered
532
+ answered = marker.event.is_set()
533
+ delivered = answered and marker.delivered
534
+ if delivered:
535
+ return FlushResult(ok=True)
536
+ return FlushResult(ok=False, reason="abandoned" if answered else "thread-died")
523
537
  remaining = None if deadline is None else max(0.0, deadline - time.monotonic())
524
538
  if not marker.event.wait(remaining):
525
- return False
526
- return marker.delivered
539
+ return FlushResult(ok=False, reason="timed-out")
540
+ if not marker.delivered:
541
+ return FlushResult(ok=False, reason="abandoned")
542
+ return FlushResult(ok=True)
527
543
 
528
544
  def swap_sink(self, new_sink: Sink, timeout: float | None = DEFAULT_SWAP_TIMEOUT) -> bool:
529
545
  """Retargets delivery at a new sink, draining and closing the previous one (FR-003).