log-foundry 0.10.2.dev44__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.dev44 → log_foundry-0.10.2.dev45}/PKG-INFO +53 -14
  2. {log_foundry-0.10.2.dev44 → log_foundry-0.10.2.dev45}/README.md +52 -13
  3. {log_foundry-0.10.2.dev44 → log_foundry-0.10.2.dev45}/pyproject.toml +1 -1
  4. {log_foundry-0.10.2.dev44 → log_foundry-0.10.2.dev45}/src/log_foundry/__init__.py +10 -5
  5. {log_foundry-0.10.2.dev44 → log_foundry-0.10.2.dev45}/src/log_foundry/decorator.py +36 -8
  6. log_foundry-0.10.2.dev45/src/log_foundry/results.py +65 -0
  7. {log_foundry-0.10.2.dev44 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/base.py +4 -2
  8. {log_foundry-0.10.2.dev44 → log_foundry-0.10.2.dev45}/src/log_foundry/worker.py +30 -14
  9. {log_foundry-0.10.2.dev44 → log_foundry-0.10.2.dev45}/LICENSE +0 -0
  10. {log_foundry-0.10.2.dev44 → log_foundry-0.10.2.dev45}/src/log_foundry/_diag.py +0 -0
  11. {log_foundry-0.10.2.dev44 → log_foundry-0.10.2.dev45}/src/log_foundry/_lifecycle.py +0 -0
  12. {log_foundry-0.10.2.dev44 → log_foundry-0.10.2.dev45}/src/log_foundry/api.py +0 -0
  13. {log_foundry-0.10.2.dev44 → log_foundry-0.10.2.dev45}/src/log_foundry/config.py +0 -0
  14. {log_foundry-0.10.2.dev44 → log_foundry-0.10.2.dev45}/src/log_foundry/console.py +0 -0
  15. {log_foundry-0.10.2.dev44 → log_foundry-0.10.2.dev45}/src/log_foundry/context.py +0 -0
  16. {log_foundry-0.10.2.dev44 → log_foundry-0.10.2.dev45}/src/log_foundry/ids.py +0 -0
  17. {log_foundry-0.10.2.dev44 → log_foundry-0.10.2.dev45}/src/log_foundry/model.py +0 -0
  18. {log_foundry-0.10.2.dev44 → log_foundry-0.10.2.dev45}/src/log_foundry/py.typed +0 -0
  19. {log_foundry-0.10.2.dev44 → log_foundry-0.10.2.dev45}/src/log_foundry/sanitize.py +0 -0
  20. {log_foundry-0.10.2.dev44 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/__init__.py +0 -0
  21. {log_foundry-0.10.2.dev44 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/_batch.py +0 -0
  22. {log_foundry-0.10.2.dev44 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/_chunk.py +0 -0
  23. {log_foundry-0.10.2.dev44 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/_retry.py +0 -0
  24. {log_foundry-0.10.2.dev44 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/_socket.py +0 -0
  25. {log_foundry-0.10.2.dev44 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/_time.py +0 -0
  26. {log_foundry-0.10.2.dev44 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/callback.py +0 -0
  27. {log_foundry-0.10.2.dev44 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/clickhouse.py +0 -0
  28. {log_foundry-0.10.2.dev44 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/datadog.py +0 -0
  29. {log_foundry-0.10.2.dev44 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/elasticsearch.py +0 -0
  30. {log_foundry-0.10.2.dev44 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/eventhubs.py +0 -0
  31. {log_foundry-0.10.2.dev44 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/file.py +0 -0
  32. {log_foundry-0.10.2.dev44 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/filtering.py +0 -0
  33. {log_foundry-0.10.2.dev44 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/firehose.py +0 -0
  34. {log_foundry-0.10.2.dev44 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/honeycomb.py +0 -0
  35. {log_foundry-0.10.2.dev44 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/http.py +0 -0
  36. {log_foundry-0.10.2.dev44 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/kafka.py +0 -0
  37. {log_foundry-0.10.2.dev44 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/kinesis.py +0 -0
  38. {log_foundry-0.10.2.dev44 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/logging_sink.py +0 -0
  39. {log_foundry-0.10.2.dev44 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/logstash.py +0 -0
  40. {log_foundry-0.10.2.dev44 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/loki.py +0 -0
  41. {log_foundry-0.10.2.dev44 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/mongodb.py +0 -0
  42. {log_foundry-0.10.2.dev44 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/multi.py +0 -0
  43. {log_foundry-0.10.2.dev44 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/nats.py +0 -0
  44. {log_foundry-0.10.2.dev44 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/newrelic.py +0 -0
  45. {log_foundry-0.10.2.dev44 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/postgres.py +0 -0
  46. {log_foundry-0.10.2.dev44 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/pubsub.py +0 -0
  47. {log_foundry-0.10.2.dev44 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/rabbitmq.py +0 -0
  48. {log_foundry-0.10.2.dev44 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/redis.py +0 -0
  49. {log_foundry-0.10.2.dev44 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/sentry.py +0 -0
  50. {log_foundry-0.10.2.dev44 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/sns.py +0 -0
  51. {log_foundry-0.10.2.dev44 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/splunk.py +0 -0
  52. {log_foundry-0.10.2.dev44 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/sqlite.py +0 -0
  53. {log_foundry-0.10.2.dev44 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/sqs.py +0 -0
  54. {log_foundry-0.10.2.dev44 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/stdout.py +0 -0
  55. {log_foundry-0.10.2.dev44 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/syslog.py +0 -0
  56. {log_foundry-0.10.2.dev44 → log_foundry-0.10.2.dev45}/src/log_foundry/sinks/transform.py +0 -0
  57. {log_foundry-0.10.2.dev44 → 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.dev44
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
@@ -405,7 +424,7 @@ def handler(event, context):
405
424
 
406
425
  | Call | Does |
407
426
  |---|---|
408
- | `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. |
409
428
  | `current_traceparent()` | This span as a W3C `traceparent` string, or `None` if no span is active. |
410
429
  | `current_trace_context()` | `(trace_id, span_id)`, for when moving two fields beats moving a string. |
411
430
  | `current_baggage_header()` | Current baggage in W3C `baggage` format (`""` when empty). |
@@ -937,11 +956,12 @@ by design: a later level call is *refused* at the closed sink and announced on s
937
956
  guards its own post-close state — rather than queued where nothing will drain it, and those are not
938
957
  the same claim. A stateless sink such as the default `StdoutSink` still accepts it.
939
958
 
940
- Read a snapshot by attribute (`h.dropped`), as above. `Health` is a `NamedTuple` and has gained
941
- fields over time a fourth (`stopped_reason`) in `v0.7.0`, and a fifth (`sink`) plus four more
942
- (`retired`, `submitted_after_shutdown`, `incomplete_swaps`, `closing_sinks`) not yet in a tagged
943
- release so unpacking it whole (`queued, dropped, failed = health()`) raises `ValueError`. Every
944
- 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.
945
965
 
946
966
  `dropped` counts submissions discarded because the queue filled; `failed_batches` counts batches
947
967
  abandoned after the retry budget was spent. Overflow also warns on stderr — on the first drop and
@@ -955,12 +975,12 @@ which one you want depends on whether the process is about to end:
955
975
  ```python
956
976
  import log_foundry as lf
957
977
 
958
- 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
959
979
  lf.shutdown() # drain, close the sink, and stop for good; blocks until drained (30s cap)
960
980
  ```
961
981
 
962
982
  Both are bounded, because both can be called somewhere with a deadline. `flush(timeout=5.0)`
963
- 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
964
984
  what it could, and reports `health().stopped_reason == "ShutdownTimeout"`. Passing `None` to
965
985
  either waits indefinitely, which is unsafe in any environment with an execution deadline.
966
986
 
@@ -985,11 +1005,30 @@ docstring states its own worst case.
985
1005
  explicitly when you need to be certain the tail reached the sink before a fast exit, e.g. at the
986
1006
  end of a short script. It is idempotent.
987
1007
 
988
- `flush(timeout=5.0)` returns `True` when **nothing was lost while the call was outstanding** — the
989
- drain it forces reached the sink, and so did anything else the worker emitted while it waited its
990
- turn. It returns `False` on timeout, when the worker was already shut down or has died, and when
991
- any batch was abandoned inside that window. A `True` is evidence of delivery, not merely that a
992
- 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.
993
1032
 
994
1033
  The window starts when you call it. A batch abandoned *before* that is deliberately not its
995
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
@@ -369,7 +388,7 @@ def handler(event, context):
369
388
 
370
389
  | Call | Does |
371
390
  |---|---|
372
- | `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. |
373
392
  | `current_traceparent()` | This span as a W3C `traceparent` string, or `None` if no span is active. |
374
393
  | `current_trace_context()` | `(trace_id, span_id)`, for when moving two fields beats moving a string. |
375
394
  | `current_baggage_header()` | Current baggage in W3C `baggage` format (`""` when empty). |
@@ -901,11 +920,12 @@ by design: a later level call is *refused* at the closed sink and announced on s
901
920
  guards its own post-close state — rather than queued where nothing will drain it, and those are not
902
921
  the same claim. A stateless sink such as the default `StdoutSink` still accepts it.
903
922
 
904
- Read a snapshot by attribute (`h.dropped`), as above. `Health` is a `NamedTuple` and has gained
905
- fields over time a fourth (`stopped_reason`) in `v0.7.0`, and a fifth (`sink`) plus four more
906
- (`retired`, `submitted_after_shutdown`, `incomplete_swaps`, `closing_sinks`) not yet in a tagged
907
- release so unpacking it whole (`queued, dropped, failed = health()`) raises `ValueError`. Every
908
- 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.
909
929
 
910
930
  `dropped` counts submissions discarded because the queue filled; `failed_batches` counts batches
911
931
  abandoned after the retry budget was spent. Overflow also warns on stderr — on the first drop and
@@ -919,12 +939,12 @@ which one you want depends on whether the process is about to end:
919
939
  ```python
920
940
  import log_foundry as lf
921
941
 
922
- 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
923
943
  lf.shutdown() # drain, close the sink, and stop for good; blocks until drained (30s cap)
924
944
  ```
925
945
 
926
946
  Both are bounded, because both can be called somewhere with a deadline. `flush(timeout=5.0)`
927
- 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
928
948
  what it could, and reports `health().stopped_reason == "ShutdownTimeout"`. Passing `None` to
929
949
  either waits indefinitely, which is unsafe in any environment with an execution deadline.
930
950
 
@@ -949,11 +969,30 @@ docstring states its own worst case.
949
969
  explicitly when you need to be certain the tail reached the sink before a fast exit, e.g. at the
950
970
  end of a short script. It is idempotent.
951
971
 
952
- `flush(timeout=5.0)` returns `True` when **nothing was lost while the call was outstanding** — the
953
- drain it forces reached the sink, and so did anything else the worker emitted while it waited its
954
- turn. It returns `False` on timeout, when the worker was already shut down or has died, and when
955
- any batch was abandoned inside that window. A `True` is evidence of delivery, not merely that a
956
- 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.
957
996
 
958
997
  The window starts when you call it. A batch abandoned *before* that is deliberately not its
959
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.dev44"
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",
@@ -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).