log-foundry 0.2.1.dev3__py3-none-any.whl → 0.2.1.dev4__py3-none-any.whl

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.
log_foundry/__init__.py CHANGED
@@ -1,8 +1,8 @@
1
1
  """log_foundry — consistent, structured logs per decorated function call.
2
2
 
3
3
  Public façade (module-function shape). Exposes configuration, the ``@trace`` decorator, the
4
- ``debug/info/warning/error/critical`` emitters, ``set_baggage``, and ``shutdown`` (graceful
5
- drain of the background worker).
4
+ ``debug/info/warning/error/critical`` emitters, ``set_baggage``, ``flush`` (drain and keep
5
+ logging) and ``shutdown`` (drain, close the sink, and stop).
6
6
  """
7
7
 
8
8
  from importlib.metadata import PackageNotFoundError
@@ -19,11 +19,38 @@ except PackageNotFoundError: # running from a source tree that isn't installed
19
19
  __version__ = "0.0.0"
20
20
 
21
21
 
22
+ def flush(timeout: float | None = 5.0) -> bool:
23
+ """Drain buffered events through the sink without closing it.
24
+
25
+ Every event submitted before this call has been passed to ``sink.emit`` when it returns
26
+ ``True``. (Events submitted concurrently by another thread may or may not be included —
27
+ the caller cannot have meant those.) Unlike :func:`shutdown` the background worker stays
28
+ alive and the sink stays open, so logging continues normally afterwards.
29
+
30
+ This is the drain for a process that is *frozen* rather than exited — an AWS Lambda
31
+ handler must drain before it returns, but will be invoked again on the same warm
32
+ container. Call it in a ``finally``: the invocation worth logging is the one that failed.
33
+
34
+ ``timeout=None`` waits indefinitely, which is unsafe in any environment with an execution
35
+ deadline — it converts "some logs were lost" into "the invocation timed out".
36
+
37
+ Returns ``False`` if the drain did not complete within ``timeout``, or if the worker has
38
+ already been shut down. Never raises.
39
+ """
40
+ from log_foundry.decorator import _flush_worker
41
+
42
+ return _flush_worker(timeout)
43
+
44
+
22
45
  def shutdown() -> None:
23
46
  """Flush buffered events and close the sink, blocking until drained. Idempotent.
24
47
 
25
48
  Also registered via ``atexit``; call it explicitly before a fast process exit when you
26
49
  want to be certain the tail of the queue reached the sink (SPEC-004 FR-005).
50
+
51
+ This is **terminal** — the worker does not come back. Do not call it per-invocation in a
52
+ serverless handler: the first invocation on a warm container would log and every later one
53
+ would silently log nothing. Use :func:`flush` there, which drains and keeps the worker.
27
54
  """
28
55
  from log_foundry.decorator import _shutdown_worker
29
56
 
@@ -40,6 +67,7 @@ __all__ = [
40
67
  "error",
41
68
  "critical",
42
69
  "set_baggage",
70
+ "flush",
43
71
  "shutdown",
44
72
  "__version__",
45
73
  ]
log_foundry/decorator.py CHANGED
@@ -80,6 +80,25 @@ def _shutdown_worker() -> None:
80
80
  _worker.shutdown()
81
81
 
82
82
 
83
+ def _flush_worker(timeout: float | None = 5.0) -> bool:
84
+ """Drain the process worker without retiring it. Backs ``flush()`` (SPEC-013 FR-003).
85
+
86
+ Returns ``True`` when no worker exists: a process that never logged has nothing to drain,
87
+ and building one here — with the thread and ``atexit`` registration :func:`_get_worker`
88
+ brings — in order to flush nothing would be pure cost. So this deliberately does *not* call
89
+ :func:`_get_worker`.
90
+ """
91
+ worker = _worker
92
+ if worker is None:
93
+ return True
94
+ try:
95
+ return worker.flush(timeout)
96
+ except Exception: # noqa: BLE001 — a flush is the call most likely to be made in a
97
+ # `finally`, so the library must never be the reason a caller's function fails. Any
98
+ # failure is reported by the return value instead (FR-003).
99
+ return False
100
+
101
+
83
102
  def _flush(span: Span) -> None:
84
103
  """Hand the finished span's events to the background worker — non-blocking (FR-001).
85
104
 
log_foundry/worker.py CHANGED
@@ -8,6 +8,11 @@ bounded and overflow is dropped-newest with a counter (arch §9). Emit failures
8
8
  with backoff; a graceful :meth:`shutdown` drains the queue, emits the tail, and closes the
9
9
  sink so buffered events survive process exit.
10
10
 
11
+ Two drains, deliberately distinct (SPEC-013): :meth:`shutdown` is terminal — it stops the thread
12
+ and closes the sink, and the worker never comes back — while :meth:`flush` drains on demand and
13
+ leaves everything running. A process that is frozen rather than exited (a serverless handler
14
+ between invocations) needs the second, because it will be asked to log again.
15
+
11
16
  This module owns *delivery mechanics only*; it receives already-built event dicts and knows
12
17
  nothing about spans or context (the same dumbness that makes sinks swappable).
13
18
  """
@@ -29,6 +34,25 @@ __all__ = ["Worker"]
29
34
  _SHUTDOWN = object()
30
35
 
31
36
 
37
+ class _FlushMarker:
38
+ """A drain request travelling the queue in FIFO order (SPEC-013 FR-002).
39
+
40
+ The mechanism follows from the queue being FIFO: everything submitted *before* ``flush()``
41
+ was called is necessarily ahead of the marker, so by the time the worker dequeues it those
42
+ events are either already emitted or sitting in ``pending``. The worker emits ``pending``,
43
+ then sets ``event``. No lock, no inspection of queue internals, and no coordination with the
44
+ batching triggers.
45
+
46
+ Like ``_SHUTDOWN`` it is never emitted — but unlike ``_SHUTDOWN`` it carries state, so it is
47
+ a class rather than a bare sentinel object.
48
+ """
49
+
50
+ __slots__ = ("event",)
51
+
52
+ def __init__(self) -> None:
53
+ self.event = threading.Event()
54
+
55
+
32
56
  class Worker:
33
57
  """Owns a bounded queue + daemon thread that batches and flushes events to ``sink``."""
34
58
 
@@ -71,6 +95,36 @@ class Worker:
71
95
  with self._lock:
72
96
  self.dropped += 1
73
97
 
98
+ def flush(self, timeout: float | None = 5.0) -> bool:
99
+ """Drain everything submitted before this call through the sink, without stopping.
100
+
101
+ Returns ``True`` once the worker has emitted them, ``False`` on timeout or when the
102
+ worker has already been shut down. Unlike :meth:`shutdown` the thread keeps running, the
103
+ sink is **not** closed, and the once-only shutdown flag is untouched, so logging
104
+ continues normally afterwards (SPEC-013 FR-002).
105
+ """
106
+ with self._lock:
107
+ if self._shutdown_done:
108
+ # Nothing will ever consume a marker now, so report the failure immediately
109
+ # rather than make the caller wait out `timeout` for a drain that cannot happen.
110
+ # A caller with an execution deadline would pay that wait for nothing (FR-003).
111
+ return False
112
+ if not self._thread.is_alive():
113
+ return False
114
+ marker = _FlushMarker()
115
+ # One deadline shared by the put and the wait: a caller asked for a bound on the whole
116
+ # call, not on each half of it, so the two cannot add up to 2 * timeout.
117
+ deadline = None if timeout is None else time.monotonic() + timeout
118
+ try:
119
+ # A *blocking* put, never put_nowait: on a full queue put_nowait would skip the
120
+ # flush and return as though it had succeeded, which is the one outcome a flush must
121
+ # never produce silently. A put that times out is reported as False.
122
+ self._queue.put(marker, timeout=timeout)
123
+ except queue.Full:
124
+ return False
125
+ remaining = None if deadline is None else max(0.0, deadline - time.monotonic())
126
+ return marker.event.wait(remaining)
127
+
74
128
  def shutdown(self) -> None:
75
129
  """Stop the thread, drain + emit everything queued, then ``close()`` the sink.
76
130
 
@@ -101,6 +155,22 @@ class Worker:
101
155
  item = self._queue.get(timeout=timeout)
102
156
  except queue.Empty:
103
157
  item = None
158
+ if isinstance(item, _FlushMarker):
159
+ # Drain on demand: emit immediately, ignoring both the batch_size and the
160
+ # flush_interval trigger — a caller who asked for a flush is not interested in
161
+ # the batching policy. Note this branch *returns to the top of the loop*: the
162
+ # marker must never fall through to the append below, where it would be treated
163
+ # as a list of events and handed to sink.emit, killing this thread.
164
+ try:
165
+ if pending:
166
+ self._emit(pending)
167
+ pending = []
168
+ finally:
169
+ # Signal even if the emit died, so a waiter is released rather than left to
170
+ # wait out its timeout on a thread that is no longer running.
171
+ last_flush = time.monotonic()
172
+ item.event.set()
173
+ continue
104
174
  if item is not None and item is not _SHUTDOWN:
105
175
  pending.append(cast("list[dict[str, object]]", item))
106
176
  now = time.monotonic()
@@ -116,15 +186,24 @@ class Worker:
116
186
 
117
187
  def _final_drain(self, pending: list[list[dict[str, object]]]) -> None:
118
188
  """On stop, pull anything still queued and emit the tail as one final batch."""
189
+ markers: list[_FlushMarker] = []
119
190
  while True:
120
191
  try:
121
192
  item = self._queue.get_nowait()
122
193
  except queue.Empty:
123
194
  break
195
+ if isinstance(item, _FlushMarker):
196
+ # This guard is a second copy of _run's and needs the same exclusion. Markers
197
+ # are answered *after* the final emit below, so a flush() that raced shutdown()
198
+ # still returns True — and truthfully: its events really did reach the sink.
199
+ markers.append(item)
200
+ continue
124
201
  if item is not None and item is not _SHUTDOWN:
125
202
  pending.append(cast("list[dict[str, object]]", item))
126
203
  if pending:
127
204
  self._emit(pending)
205
+ for marker in markers:
206
+ marker.event.set()
128
207
 
129
208
  def _emit(self, event_lists: list[list[dict[str, object]]]) -> None:
130
209
  """Flatten queued per-span event-lists into one batch and emit, retrying with backoff.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: log-foundry
3
- Version: 0.2.1.dev3
3
+ Version: 0.2.1.dev4
4
4
  Summary: Generate logs for your console and JSON events for downstream consumption.
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -206,6 +206,12 @@ never crashes the worker or the app. If the bounded queue fills completely, new
206
206
  dropped (newest-first) and counted rather than blocking your code. On `shutdown()` the worker
207
207
  stops, sweeps anything still queued into one final batch, emits it, and closes the sink.
208
208
 
209
+ Both triggers can be pre-empted: `flush()` puts a marker in the queue and the worker emits the
210
+ pending pile the moment it reaches it, ignoring the count and time triggers. Because the queue
211
+ is FIFO, everything submitted before the call is necessarily ahead of that marker — which is
212
+ exactly why the guarantee is "events submitted before this call", and why concurrent
213
+ submissions from other threads may or may not be included.
214
+
209
215
  ## Usage
210
216
 
211
217
  ### `configure(...)`
@@ -494,17 +500,70 @@ a failing sink with backoff, and applies backpressure so a slow or down sink can
494
500
  back-pressure the app: when its bounded queue is full it drops the newest submissions and counts
495
501
  them (`worker.dropped`) rather than stalling.
496
502
 
497
- Because delivery is asynchronous, drain before the process exits:
503
+ Because delivery is asynchronous, drain before the process exits. There are two drains, and
504
+ which one you want depends on whether the process is about to end:
498
505
 
499
506
  ```python
500
507
  import log_foundry as lf
501
508
 
502
- lf.shutdown() # flush buffered events and close the sink; blocks until drained
509
+ lf.flush() # drain to the sink and keep going; returns True when everything landed
510
+ lf.shutdown() # drain, close the sink, and stop for good; blocks until drained
503
511
  ```
504
512
 
513
+ | | `flush()` | `shutdown()` |
514
+ |---|---|---|
515
+ | Drains buffered events | yes | yes |
516
+ | Closes the sink | no | yes |
517
+ | Worker survives | yes | **no** — it never comes back |
518
+ | Repeatable | yes | idempotent, but only the first call does anything |
519
+ | Use it | before returning from a handler, or at a checkpoint | once, as the process exits |
520
+
505
521
  `shutdown()` is also registered via `atexit`, so a normal exit flushes automatically — call it
506
- explicitly when you need to be certain the tail reached the sink before a fast exit (e.g. at the
507
- end of a short script or an AWS Lambda handler). It is idempotent.
522
+ explicitly when you need to be certain the tail reached the sink before a fast exit, e.g. at the
523
+ end of a short script. It is idempotent.
524
+
525
+ `flush(timeout=5.0)` returns `True` when every event submitted before the call has been passed
526
+ to the sink, and `False` if that did not happen within `timeout` (or the worker was already shut
527
+ down). It never raises — a logging call must not be the reason your function fails. Passing
528
+ `timeout=None` waits indefinitely, which is unsafe anywhere with an execution deadline.
529
+
530
+ #### Serverless / short-lived processes
531
+
532
+ In AWS Lambda (and anything else that freezes rather than exits) the rules are different, and
533
+ getting them wrong is silent:
534
+
535
+ - **Flush before the handler returns.** Lambda freezes the execution environment the instant
536
+ your handler returns, so the worker's interval-based flush stops mid-interval and whatever is
537
+ still queued is lost when the container is eventually reaped. `atexit` does not save you —
538
+ a frozen environment is killed without running exit handlers, so `flush()` is the *only*
539
+ guaranteed drain there.
540
+ - **Put it in a `finally`.** A flush written as the last line of the handler body is precisely
541
+ the line that does not run when the handler raises, and the invocation whose logs are most
542
+ worth having is the one that failed.
543
+ - **Never call `shutdown()` per invocation.** It is terminal: the worker does not come back, so
544
+ the first invocation on a warm container would log and every later one would silently log
545
+ nothing. That failure reads as "works locally, broken in production".
546
+
547
+ ```python
548
+ import log_foundry as lf
549
+ from log_foundry.sinks.sqs import SQSSink
550
+
551
+ lf.configure(service="billing-api", env="prod", sink=SQSSink(queue_url=QUEUE_URL))
552
+
553
+ @lf.trace
554
+ def handler(event, context):
555
+ lf.info("received", records=len(event["Records"]))
556
+ try:
557
+ return do_work(event)
558
+ finally:
559
+ lf.flush() # in `finally`: the failed invocation is the one worth logging.
560
+ # NEVER shutdown() here — the worker does not come back, and every
561
+ # later invocation on this warm container would log nothing.
562
+ ```
563
+
564
+ Note that each invocation is its own trace: trace context does not cross a process boundary
565
+ today, so N invocations produce N `trace_id`s. Correlate them with a shared
566
+ [`set_baggage`](#logging-inside-a-span) field until cross-process continuation ships.
508
567
 
509
568
  ## Event schema
510
569
 
@@ -1,9 +1,9 @@
1
- log_foundry/__init__.py,sha256=omYgsLnsjBuqEuD9J3gzSCUuiY9md_eh79333klUus0,1384
1
+ log_foundry/__init__.py,sha256=bNIgcgWwtQc2BpmxdObbkRcyMOYOmTA6y5hlzKW5JCc,2860
2
2
  log_foundry/api.py,sha256=X4q1EnJCfqtfUglCoZkpUN4uHRs3lXCLBRB6hU8WI8k,3813
3
3
  log_foundry/config.py,sha256=vv0WkDffEmbSw3PjOiHBH0OHmyaqWcCUHXyRJsMBSiU,2700
4
4
  log_foundry/console.py,sha256=etACHsfxwx_lUF3bej-Hzj63VU_5hxa9g2_Ecpc-0ZI,1199
5
5
  log_foundry/context.py,sha256=MnqBFngMB0joOU3ybpXwmK1TwWqsHwAG5Tdye5B2768,2168
6
- log_foundry/decorator.py,sha256=0a_O7Cwsv1a8IxB_QXrWoCr_ZiDjoQ_i4E6q52SUSDk,5983
6
+ log_foundry/decorator.py,sha256=Fe-p6BGEVARTjqYNi8JwTvs7StnYr2bQRGiV4O3HrIM,6832
7
7
  log_foundry/ids.py,sha256=yKJPdCEepG9ZgVP3c-CQyvraHyKRzRUBhUp2-1A8-8g,912
8
8
  log_foundry/model.py,sha256=yL8RRBkHcPvTut5XfVNnm4vztN2YkRIAfFaC6i-U8qY,3591
9
9
  log_foundry/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -44,8 +44,8 @@ log_foundry/sinks/stdout.py,sha256=X_hVTUAAsppx1k8znuBveOZZwj3FqcTwLH185_JWJUU,1
44
44
  log_foundry/sinks/syslog.py,sha256=CAQ957jBIlAzrLyrOmtzspqcnAFBfYHGrIN8I35DGMw,3381
45
45
  log_foundry/sinks/transform.py,sha256=eeZpaMxc7urrZ07Vc4GQ-sIq2mTYhoA_dI4Ur4Mmb4s,1613
46
46
  log_foundry/sinks/util.py,sha256=SCYFxW9Jm-bIVsyOKir5JWWbwP7u5VFnhNmncMIkwBM,2759
47
- log_foundry/worker.py,sha256=9sX6-Zqq4bChlAAM26nDcV3CZGPkgaKHzolIvTeWRjk,6689
48
- log_foundry-0.2.1.dev3.dist-info/METADATA,sha256=0tkkYzwkQ4xQ6bEQ5R0iBtkxDAqdq-NMcAeMcbDSSX8,31265
49
- log_foundry-0.2.1.dev3.dist-info/WHEEL,sha256=eY7nduwzv-ldUxpzbRlxwvC693Hg6PX8bWDjEHjZ_dk,88
50
- log_foundry-0.2.1.dev3.dist-info/licenses/LICENSE,sha256=HaCckyCX8P6TR8gY5xyEsFi050Hs2kl-JqaO9Vi0Sw0,1072
51
- log_foundry-0.2.1.dev3.dist-info/RECORD,,
47
+ log_foundry/worker.py,sha256=bbBwMwecVOBu1k6rbSLPwM0zzxN-ebGdy5X2Mo5Q14s,11014
48
+ log_foundry-0.2.1.dev4.dist-info/METADATA,sha256=BUNLTnO5yKWnzM3KI1tNZyfUMVYHng9kdcazs8Gz7w4,34456
49
+ log_foundry-0.2.1.dev4.dist-info/WHEEL,sha256=eY7nduwzv-ldUxpzbRlxwvC693Hg6PX8bWDjEHjZ_dk,88
50
+ log_foundry-0.2.1.dev4.dist-info/licenses/LICENSE,sha256=HaCckyCX8P6TR8gY5xyEsFi050Hs2kl-JqaO9Vi0Sw0,1072
51
+ log_foundry-0.2.1.dev4.dist-info/RECORD,,