log-foundry 0.6.1.dev4__tar.gz → 0.6.1.dev6__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 (53) hide show
  1. {log_foundry-0.6.1.dev4 → log_foundry-0.6.1.dev6}/PKG-INFO +20 -2
  2. {log_foundry-0.6.1.dev4 → log_foundry-0.6.1.dev6}/README.md +19 -1
  3. {log_foundry-0.6.1.dev4 → log_foundry-0.6.1.dev6}/pyproject.toml +1 -1
  4. {log_foundry-0.6.1.dev4 → log_foundry-0.6.1.dev6}/src/log_foundry/__init__.py +7 -5
  5. {log_foundry-0.6.1.dev4 → log_foundry-0.6.1.dev6}/src/log_foundry/decorator.py +3 -1
  6. {log_foundry-0.6.1.dev4 → log_foundry-0.6.1.dev6}/src/log_foundry/worker.py +60 -6
  7. {log_foundry-0.6.1.dev4 → log_foundry-0.6.1.dev6}/LICENSE +0 -0
  8. {log_foundry-0.6.1.dev4 → log_foundry-0.6.1.dev6}/src/log_foundry/api.py +0 -0
  9. {log_foundry-0.6.1.dev4 → log_foundry-0.6.1.dev6}/src/log_foundry/config.py +0 -0
  10. {log_foundry-0.6.1.dev4 → log_foundry-0.6.1.dev6}/src/log_foundry/console.py +0 -0
  11. {log_foundry-0.6.1.dev4 → log_foundry-0.6.1.dev6}/src/log_foundry/context.py +0 -0
  12. {log_foundry-0.6.1.dev4 → log_foundry-0.6.1.dev6}/src/log_foundry/ids.py +0 -0
  13. {log_foundry-0.6.1.dev4 → log_foundry-0.6.1.dev6}/src/log_foundry/model.py +0 -0
  14. {log_foundry-0.6.1.dev4 → log_foundry-0.6.1.dev6}/src/log_foundry/py.typed +0 -0
  15. {log_foundry-0.6.1.dev4 → log_foundry-0.6.1.dev6}/src/log_foundry/sanitize.py +0 -0
  16. {log_foundry-0.6.1.dev4 → log_foundry-0.6.1.dev6}/src/log_foundry/sinks/__init__.py +0 -0
  17. {log_foundry-0.6.1.dev4 → log_foundry-0.6.1.dev6}/src/log_foundry/sinks/_batch.py +0 -0
  18. {log_foundry-0.6.1.dev4 → log_foundry-0.6.1.dev6}/src/log_foundry/sinks/_chunk.py +0 -0
  19. {log_foundry-0.6.1.dev4 → log_foundry-0.6.1.dev6}/src/log_foundry/sinks/_socket.py +0 -0
  20. {log_foundry-0.6.1.dev4 → log_foundry-0.6.1.dev6}/src/log_foundry/sinks/_time.py +0 -0
  21. {log_foundry-0.6.1.dev4 → log_foundry-0.6.1.dev6}/src/log_foundry/sinks/base.py +0 -0
  22. {log_foundry-0.6.1.dev4 → log_foundry-0.6.1.dev6}/src/log_foundry/sinks/callback.py +0 -0
  23. {log_foundry-0.6.1.dev4 → log_foundry-0.6.1.dev6}/src/log_foundry/sinks/clickhouse.py +0 -0
  24. {log_foundry-0.6.1.dev4 → log_foundry-0.6.1.dev6}/src/log_foundry/sinks/datadog.py +0 -0
  25. {log_foundry-0.6.1.dev4 → log_foundry-0.6.1.dev6}/src/log_foundry/sinks/elasticsearch.py +0 -0
  26. {log_foundry-0.6.1.dev4 → log_foundry-0.6.1.dev6}/src/log_foundry/sinks/eventhubs.py +0 -0
  27. {log_foundry-0.6.1.dev4 → log_foundry-0.6.1.dev6}/src/log_foundry/sinks/file.py +0 -0
  28. {log_foundry-0.6.1.dev4 → log_foundry-0.6.1.dev6}/src/log_foundry/sinks/filtering.py +0 -0
  29. {log_foundry-0.6.1.dev4 → log_foundry-0.6.1.dev6}/src/log_foundry/sinks/firehose.py +0 -0
  30. {log_foundry-0.6.1.dev4 → log_foundry-0.6.1.dev6}/src/log_foundry/sinks/honeycomb.py +0 -0
  31. {log_foundry-0.6.1.dev4 → log_foundry-0.6.1.dev6}/src/log_foundry/sinks/http.py +0 -0
  32. {log_foundry-0.6.1.dev4 → log_foundry-0.6.1.dev6}/src/log_foundry/sinks/kafka.py +0 -0
  33. {log_foundry-0.6.1.dev4 → log_foundry-0.6.1.dev6}/src/log_foundry/sinks/kinesis.py +0 -0
  34. {log_foundry-0.6.1.dev4 → log_foundry-0.6.1.dev6}/src/log_foundry/sinks/logging_sink.py +0 -0
  35. {log_foundry-0.6.1.dev4 → log_foundry-0.6.1.dev6}/src/log_foundry/sinks/logstash.py +0 -0
  36. {log_foundry-0.6.1.dev4 → log_foundry-0.6.1.dev6}/src/log_foundry/sinks/loki.py +0 -0
  37. {log_foundry-0.6.1.dev4 → log_foundry-0.6.1.dev6}/src/log_foundry/sinks/mongodb.py +0 -0
  38. {log_foundry-0.6.1.dev4 → log_foundry-0.6.1.dev6}/src/log_foundry/sinks/multi.py +0 -0
  39. {log_foundry-0.6.1.dev4 → log_foundry-0.6.1.dev6}/src/log_foundry/sinks/nats.py +0 -0
  40. {log_foundry-0.6.1.dev4 → log_foundry-0.6.1.dev6}/src/log_foundry/sinks/newrelic.py +0 -0
  41. {log_foundry-0.6.1.dev4 → log_foundry-0.6.1.dev6}/src/log_foundry/sinks/postgres.py +0 -0
  42. {log_foundry-0.6.1.dev4 → log_foundry-0.6.1.dev6}/src/log_foundry/sinks/pubsub.py +0 -0
  43. {log_foundry-0.6.1.dev4 → log_foundry-0.6.1.dev6}/src/log_foundry/sinks/rabbitmq.py +0 -0
  44. {log_foundry-0.6.1.dev4 → log_foundry-0.6.1.dev6}/src/log_foundry/sinks/redis.py +0 -0
  45. {log_foundry-0.6.1.dev4 → log_foundry-0.6.1.dev6}/src/log_foundry/sinks/sentry.py +0 -0
  46. {log_foundry-0.6.1.dev4 → log_foundry-0.6.1.dev6}/src/log_foundry/sinks/sns.py +0 -0
  47. {log_foundry-0.6.1.dev4 → log_foundry-0.6.1.dev6}/src/log_foundry/sinks/splunk.py +0 -0
  48. {log_foundry-0.6.1.dev4 → log_foundry-0.6.1.dev6}/src/log_foundry/sinks/sqlite.py +0 -0
  49. {log_foundry-0.6.1.dev4 → log_foundry-0.6.1.dev6}/src/log_foundry/sinks/sqs.py +0 -0
  50. {log_foundry-0.6.1.dev4 → log_foundry-0.6.1.dev6}/src/log_foundry/sinks/stdout.py +0 -0
  51. {log_foundry-0.6.1.dev4 → log_foundry-0.6.1.dev6}/src/log_foundry/sinks/syslog.py +0 -0
  52. {log_foundry-0.6.1.dev4 → log_foundry-0.6.1.dev6}/src/log_foundry/sinks/transform.py +0 -0
  53. {log_foundry-0.6.1.dev4 → log_foundry-0.6.1.dev6}/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.6.1.dev4
3
+ Version: 0.6.1.dev6
4
4
  Summary: Generate logs for your console and JSON events for downstream consumption.
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -631,10 +631,28 @@ returns a snapshot of the worker's counters:
631
631
 
632
632
  ```python
633
633
  h = log_foundry.health()
634
- if h.dropped or h.failed_batches:
634
+ if h.dropped or h.failed_batches or h.stopped_reason:
635
635
  ... # logs were silently lost — worth an alert
636
636
  ```
637
637
 
638
+ The three tell you different things, and they want different responses:
639
+
640
+ | Field | Means | What to do |
641
+ |---|---|---|
642
+ | `dropped` | The queue filled — the destination is not keeping up. Delivery continues. | Tune `batch_size`/`flush_interval`, or scale the sink. |
643
+ | `failed_batches` | A sink stayed broken through the whole retry budget. Delivery continues. | Fix the destination. |
644
+ | `stopped_reason` | The background thread **died** on that exception type. Nothing further will be delivered, ever. | Restart the process; investigate the named exception. |
645
+
646
+ `stopped_reason` is a type name (e.g. `"SystemExit"`), never the exception's message — a sink's
647
+ error text can carry event data. It reads `None` for a healthy worker, for a process that has never
648
+ logged, and after a clean `shutdown()`, so a plain truthiness check is safe. Without it a dead
649
+ thread showed up only indirectly, as `dropped` climbing once the queue filled — the wrong signal,
650
+ pointing at the wrong fix.
651
+
652
+ Read a snapshot by attribute (`h.dropped`), as above. `Health` is a `NamedTuple` and gained a
653
+ fourth field in `v0.7.0`, so unpacking it whole — `queued, dropped, failed = health()` — raises
654
+ `ValueError` from that version on.
655
+
638
656
  `dropped` counts submissions discarded because the queue filled; `failed_batches` counts batches
639
657
  abandoned after the retry budget was spent. Overflow also warns on stderr — on the first drop and
640
658
  every thousandth after it, since overflow is a high-rate condition and a line per drop would be its
@@ -595,10 +595,28 @@ returns a snapshot of the worker's counters:
595
595
 
596
596
  ```python
597
597
  h = log_foundry.health()
598
- if h.dropped or h.failed_batches:
598
+ if h.dropped or h.failed_batches or h.stopped_reason:
599
599
  ... # logs were silently lost — worth an alert
600
600
  ```
601
601
 
602
+ The three tell you different things, and they want different responses:
603
+
604
+ | Field | Means | What to do |
605
+ |---|---|---|
606
+ | `dropped` | The queue filled — the destination is not keeping up. Delivery continues. | Tune `batch_size`/`flush_interval`, or scale the sink. |
607
+ | `failed_batches` | A sink stayed broken through the whole retry budget. Delivery continues. | Fix the destination. |
608
+ | `stopped_reason` | The background thread **died** on that exception type. Nothing further will be delivered, ever. | Restart the process; investigate the named exception. |
609
+
610
+ `stopped_reason` is a type name (e.g. `"SystemExit"`), never the exception's message — a sink's
611
+ error text can carry event data. It reads `None` for a healthy worker, for a process that has never
612
+ logged, and after a clean `shutdown()`, so a plain truthiness check is safe. Without it a dead
613
+ thread showed up only indirectly, as `dropped` climbing once the queue filled — the wrong signal,
614
+ pointing at the wrong fix.
615
+
616
+ Read a snapshot by attribute (`h.dropped`), as above. `Health` is a `NamedTuple` and gained a
617
+ fourth field in `v0.7.0`, so unpacking it whole — `queued, dropped, failed = health()` — raises
618
+ `ValueError` from that version on.
619
+
602
620
  `dropped` counts submissions discarded because the queue filled; `failed_batches` counts batches
603
621
  abandoned after the retry budget was spent. Overflow also warns on stderr — on the first drop and
604
622
  every thousandth after it, since overflow is a high-rate condition and a line per drop would be its
@@ -20,7 +20,7 @@ dependencies = [
20
20
  ]
21
21
 
22
22
  # Optional features. Install with: pip install log-foundry[aws]
23
- version = "0.6.1.dev4"
23
+ version = "0.6.1.dev6"
24
24
 
25
25
  [project.optional-dependencies]
26
26
  aws = ["boto3>=1.34"] # SQSSink, SNSSink, KinesisSink, FirehoseSink
@@ -53,13 +53,15 @@ def flush(timeout: float | None = 5.0) -> bool:
53
53
  def health() -> Health:
54
54
  """Snapshot the background worker's delivery counters (SPEC-017 FR-005). Never raises.
55
55
 
56
- Returns ``queued`` / ``dropped`` / ``failed_batches``. A non-zero ``dropped`` means the queue
57
- filled and submissions were discarded to keep your code non-blocking; a non-zero
58
- ``failed_batches`` means a sink stayed broken through the whole retry budget. Both are
59
- losses the library absorbs on purpose, and this is how you notice them::
56
+ Returns ``queued`` / ``dropped`` / ``failed_batches`` / ``stopped_reason``. A non-zero
57
+ ``dropped`` means the queue filled and submissions were discarded to keep your code
58
+ non-blocking; a non-zero ``failed_batches`` means a sink stayed broken through the whole
59
+ retry budget. Both are losses the library absorbs on purpose, and this is how you notice
60
+ them. A non-``None`` ``stopped_reason`` is worse than either: the background thread died
61
+ on that exception type, so nothing further will be delivered at all (SPEC-019)::
60
62
 
61
63
  h = log_foundry.health()
62
- if h.dropped or h.failed_batches:
64
+ if h.dropped or h.failed_batches or h.stopped_reason:
63
65
  ... # raise an alert; logs were silently lost
64
66
 
65
67
  A process that has never logged has no worker, and asking after its health does not create
@@ -240,7 +240,9 @@ def _worker_health() -> Health:
240
240
 
241
241
  Backs :func:`log_foundry.health` (SPEC-017 FR-005). Like :func:`_flush_worker` this
242
242
  deliberately does *not* call :func:`_get_worker`: starting a thread and registering an
243
- ``atexit`` drain in order to report three zeros would be pure cost.
243
+ ``atexit`` drain in order to report an empty snapshot would be pure cost. That snapshot
244
+ reads ``stopped_reason=None`` — a worker that was never created has not died, which is why
245
+ SPEC-019 reports the terminal failure as a reason rather than an ``alive`` flag.
244
246
  """
245
247
  worker = _worker
246
248
  if worker is None:
@@ -48,11 +48,19 @@ class Health(NamedTuple):
48
48
  alongside real submissions.
49
49
  dropped: Submissions discarded because the queue was full (backpressure).
50
50
  failed_batches: Batches abandoned after the retry budget was spent.
51
+ stopped_reason: The exception type name that terminated the drain thread, or ``None``
52
+ if it never died — which is also what a live worker and a process that never
53
+ logged report. Non-``None`` is categorically worse than the two counters above:
54
+ they measure loss the worker absorbed and kept running through, this one means
55
+ the worker is gone and nothing further will be delivered (SPEC-019 FR-003).
51
56
  """
52
57
 
53
58
  queued: int
54
59
  dropped: int
55
60
  failed_batches: int
61
+ # Defaulted so the zeroed snapshot in `decorator._worker_health` — and any third-party
62
+ # construction — keeps working unchanged.
63
+ stopped_reason: str | None = None
56
64
 
57
65
 
58
66
  class _FlushMarker:
@@ -92,6 +100,7 @@ class Worker:
92
100
  self.max_retries = max_retries
93
101
  self.dropped = 0 # submissions dropped because the queue was full (backpressure)
94
102
  self.failed_batches = 0 # batches abandoned after exhausting retries (worker-thread only)
103
+ self.stopped_reason: str | None = None # set once if the drain loop dies (SPEC-019)
95
104
  self._queue: queue.Queue[object] = queue.Queue(maxsize=max_queue)
96
105
  self._stop = threading.Event()
97
106
  self._shutdown_done = False
@@ -133,15 +142,21 @@ class Worker:
133
142
  pass
134
143
 
135
144
  def health(self) -> Health:
136
- """Snapshot the delivery counters (SPEC-017 FR-005). Never raises.
145
+ """Snapshot the delivery counters (SPEC-017 FR-005, SPEC-019 FR-003). Never raises.
137
146
 
138
147
  Valid after :meth:`shutdown` — the counters are plain integers that outlive the thread,
139
148
  and the final drain consumes the queue, so ``queued`` reads 0 rather than a stale marker.
149
+ The same applies to ``stopped_reason``: a terminal failure stays readable afterwards,
150
+ since a caller finding a dead worker will usually call ``shutdown()`` next.
140
151
  """
141
152
  with self._lock:
142
153
  dropped, failed_batches = self.dropped, self.failed_batches
154
+ stopped_reason = self.stopped_reason
143
155
  return Health(
144
- queued=self._queue.qsize(), dropped=dropped, failed_batches=failed_batches
156
+ queued=self._queue.qsize(),
157
+ dropped=dropped,
158
+ failed_batches=failed_batches,
159
+ stopped_reason=stopped_reason,
145
160
  )
146
161
 
147
162
  def flush(self, timeout: float | None = 5.0) -> bool:
@@ -195,8 +210,47 @@ class Worker:
195
210
  # -- worker thread ------------------------------------------------------------------
196
211
 
197
212
  def _run(self) -> None:
198
- """Drain loop: accumulate event-lists and emit a batch on the count/time trigger."""
213
+ """Drain loop: accumulate event-lists and emit a batch on the count/time trigger.
214
+
215
+ Guarded end to end (SPEC-019 FR-001). ``_emit`` already absorbs an ``Exception`` from
216
+ the sink, so anything reaching this handler has ended the only thread that delivers —
217
+ and CPython's thread bootstrap discards a ``SystemExit`` without even a traceback, which
218
+ is why the catch is ``BaseException`` rather than ``Exception``. It records and exits;
219
+ looping onward past a ``KeyboardInterrupt`` would be a worse failure than the one this
220
+ prevents.
221
+ """
199
222
  pending: list[list[dict[str, object]]] = []
223
+ try:
224
+ self._drain(pending)
225
+ except BaseException as exc: # deliberately broad — see the docstring
226
+ self._terminal_failure(exc, len(pending))
227
+
228
+ def _terminal_failure(self, exc: BaseException, undrained: int) -> None:
229
+ """Record the drain loop's terminal exit, then announce it (FR-001, FR-002).
230
+
231
+ Recording precedes announcing: stderr may be closed or wedged, and unlike the overflow
232
+ warning this line is written exactly once and cannot be re-emitted later, so the record
233
+ must not be able to ride on it. The exception's *type* is reported and its message is
234
+ not — a sink's exception text can carry event data, and arch §6 keeps caller data out of
235
+ places it was not asked for (the same rule behind ``sanitize``'s type-name placeholder).
236
+ """
237
+ name = type(exc).__name__
238
+ with self._lock:
239
+ self.stopped_reason = name
240
+ try:
241
+ sys.stderr.write(
242
+ f"log-foundry: worker thread stopped on {name}; {undrained} undrained "
243
+ f"event-list(s), nothing further will be delivered\n"
244
+ )
245
+ except Exception: # best-effort: the record above is what an operator reads.
246
+ pass
247
+
248
+ def _drain(self, pending: list[list[dict[str, object]]]) -> None:
249
+ """The drain loop proper. ``pending`` is owned by :meth:`_run`, which reports its size.
250
+
251
+ It is mutated in place rather than rebound so the terminal handler sees what was still
252
+ in hand; a local ``pending = []`` here would leave that count reading zero.
253
+ """
200
254
  last_flush = time.monotonic()
201
255
  while not self._stop.is_set():
202
256
  timeout = max(0.0, self.flush_interval - (time.monotonic() - last_flush))
@@ -213,7 +267,7 @@ class Worker:
213
267
  try:
214
268
  if pending:
215
269
  self._emit(pending)
216
- pending = []
270
+ pending.clear()
217
271
  finally:
218
272
  # Signal even if the emit died, so a waiter is released rather than left to
219
273
  # wait out its timeout on a thread that is no longer running.
@@ -226,7 +280,7 @@ class Worker:
226
280
  if len(pending) >= self.batch_size or now - last_flush >= self.flush_interval:
227
281
  if pending:
228
282
  self._emit(pending)
229
- pending = []
283
+ pending.clear()
230
284
  # Advance the window even when idle (pending empty). Otherwise last_flush never
231
285
  # moves while the queue is empty, timeout collapses to 0.0, and get(timeout=0.0)
232
286
  # busy-spins a core. Resetting it lets the next get() block a full interval.
@@ -242,7 +296,7 @@ class Worker:
242
296
  except queue.Empty:
243
297
  break
244
298
  if isinstance(item, _FlushMarker):
245
- # This guard is a second copy of _run's and needs the same exclusion. Markers
299
+ # This guard is a second copy of _drain's and needs the same exclusion. Markers
246
300
  # are answered *after* the final emit below, so a flush() that raced shutdown()
247
301
  # still returns True — and truthfully: its events really did reach the sink.
248
302
  markers.append(item)