log-foundry 0.2.1.dev3__tar.gz → 0.2.1.dev5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. {log_foundry-0.2.1.dev3 → log_foundry-0.2.1.dev5}/PKG-INFO +133 -5
  2. {log_foundry-0.2.1.dev3 → log_foundry-0.2.1.dev5}/README.md +132 -4
  3. {log_foundry-0.2.1.dev3 → log_foundry-0.2.1.dev5}/pyproject.toml +1 -1
  4. log_foundry-0.2.1.dev5/src/log_foundry/__init__.py +84 -0
  5. log_foundry-0.2.1.dev5/src/log_foundry/context.py +160 -0
  6. log_foundry-0.2.1.dev5/src/log_foundry/decorator.py +312 -0
  7. log_foundry-0.2.1.dev5/src/log_foundry/ids.py +109 -0
  8. {log_foundry-0.2.1.dev3 → log_foundry-0.2.1.dev5}/src/log_foundry/worker.py +79 -0
  9. log_foundry-0.2.1.dev3/src/log_foundry/__init__.py +0 -45
  10. log_foundry-0.2.1.dev3/src/log_foundry/context.py +0 -62
  11. log_foundry-0.2.1.dev3/src/log_foundry/decorator.py +0 -157
  12. log_foundry-0.2.1.dev3/src/log_foundry/ids.py +0 -29
  13. {log_foundry-0.2.1.dev3 → log_foundry-0.2.1.dev5}/LICENSE +0 -0
  14. {log_foundry-0.2.1.dev3 → log_foundry-0.2.1.dev5}/src/log_foundry/api.py +0 -0
  15. {log_foundry-0.2.1.dev3 → log_foundry-0.2.1.dev5}/src/log_foundry/config.py +0 -0
  16. {log_foundry-0.2.1.dev3 → log_foundry-0.2.1.dev5}/src/log_foundry/console.py +0 -0
  17. {log_foundry-0.2.1.dev3 → log_foundry-0.2.1.dev5}/src/log_foundry/model.py +0 -0
  18. {log_foundry-0.2.1.dev3 → log_foundry-0.2.1.dev5}/src/log_foundry/py.typed +0 -0
  19. {log_foundry-0.2.1.dev3 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/__init__.py +0 -0
  20. {log_foundry-0.2.1.dev3 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/_chunk.py +0 -0
  21. {log_foundry-0.2.1.dev3 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/_socket.py +0 -0
  22. {log_foundry-0.2.1.dev3 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/_time.py +0 -0
  23. {log_foundry-0.2.1.dev3 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/base.py +0 -0
  24. {log_foundry-0.2.1.dev3 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/callback.py +0 -0
  25. {log_foundry-0.2.1.dev3 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/clickhouse.py +0 -0
  26. {log_foundry-0.2.1.dev3 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/datadog.py +0 -0
  27. {log_foundry-0.2.1.dev3 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/elasticsearch.py +0 -0
  28. {log_foundry-0.2.1.dev3 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/eventhubs.py +0 -0
  29. {log_foundry-0.2.1.dev3 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/file.py +0 -0
  30. {log_foundry-0.2.1.dev3 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/filtering.py +0 -0
  31. {log_foundry-0.2.1.dev3 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/firehose.py +0 -0
  32. {log_foundry-0.2.1.dev3 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/honeycomb.py +0 -0
  33. {log_foundry-0.2.1.dev3 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/http.py +0 -0
  34. {log_foundry-0.2.1.dev3 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/kafka.py +0 -0
  35. {log_foundry-0.2.1.dev3 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/kinesis.py +0 -0
  36. {log_foundry-0.2.1.dev3 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/logging_sink.py +0 -0
  37. {log_foundry-0.2.1.dev3 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/logstash.py +0 -0
  38. {log_foundry-0.2.1.dev3 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/loki.py +0 -0
  39. {log_foundry-0.2.1.dev3 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/mongodb.py +0 -0
  40. {log_foundry-0.2.1.dev3 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/multi.py +0 -0
  41. {log_foundry-0.2.1.dev3 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/nats.py +0 -0
  42. {log_foundry-0.2.1.dev3 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/newrelic.py +0 -0
  43. {log_foundry-0.2.1.dev3 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/postgres.py +0 -0
  44. {log_foundry-0.2.1.dev3 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/pubsub.py +0 -0
  45. {log_foundry-0.2.1.dev3 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/rabbitmq.py +0 -0
  46. {log_foundry-0.2.1.dev3 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/redis.py +0 -0
  47. {log_foundry-0.2.1.dev3 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/sentry.py +0 -0
  48. {log_foundry-0.2.1.dev3 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/sns.py +0 -0
  49. {log_foundry-0.2.1.dev3 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/splunk.py +0 -0
  50. {log_foundry-0.2.1.dev3 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/sqlite.py +0 -0
  51. {log_foundry-0.2.1.dev3 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/sqs.py +0 -0
  52. {log_foundry-0.2.1.dev3 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/stdout.py +0 -0
  53. {log_foundry-0.2.1.dev3 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/syslog.py +0 -0
  54. {log_foundry-0.2.1.dev3 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/transform.py +0 -0
  55. {log_foundry-0.2.1.dev3 → log_foundry-0.2.1.dev5}/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.2.1.dev3
3
+ Version: 0.2.1.dev5
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(...)`
@@ -292,6 +298,75 @@ def process_payment(user_id: int) -> str:
292
298
  - **Orphan logs** — a level call made with no active span is not dropped: it emits a standalone
293
299
  one-event span with a fresh `trace_id`, flushed straight to the sink.
294
300
 
301
+ ### Continuing a trace across processes
302
+
303
+ A trace stops at the process boundary: `@trace` mints a fresh `trace_id` whenever no span is
304
+ open, so two processes cooperating on one logical operation produce two unrelated traces. Pass
305
+ the context across and they join up. Nothing here is serverless-specific — the same two calls
306
+ join an HTTP client to its server, or a Celery caller to its worker.
307
+
308
+ **The producer publishes where it is:**
309
+
310
+ ```python
311
+ @lf.trace
312
+ def enqueue_check(location: str) -> None:
313
+ sqs.send_message(
314
+ QueueUrl=QUEUE_URL,
315
+ MessageBody=json.dumps({
316
+ "location": location,
317
+ "traceparent": lf.current_traceparent(), # "00-<trace_id>-<span_id>-01"
318
+ "baggage": lf.current_baggage_header(), # "request_id=req-123,tenant=acme"
319
+ }),
320
+ )
321
+ ```
322
+
323
+ **The consumer adopts it — one line, and make it the first line:**
324
+
325
+ ```python
326
+ @lf.trace
327
+ def handler(event, context):
328
+ lf.continue_trace(event.get("traceparent"), baggage=event.get("baggage"))
329
+ lf.info("inspecting") # same trace_id as the producer; parent is its span
330
+ try:
331
+ return inspect(event)
332
+ finally:
333
+ lf.flush()
334
+ ```
335
+
336
+ | Call | Does |
337
+ |---|---|
338
+ | `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. |
339
+ | `current_traceparent()` | This span as a W3C `traceparent` string, or `None` if no span is active. |
340
+ | `current_trace_context()` | `(trace_id, span_id)`, for when moving two fields beats moving a string. |
341
+ | `current_baggage_header()` | Current baggage in W3C `baggage` format (`""` when empty). |
342
+
343
+ Details worth knowing:
344
+
345
+ - **Call `continue_trace()` on the first line.** `@trace` opens the handler's span *before* the
346
+ body runs, so the call re-parents that span in place and rewrites the events it has already
347
+ buffered. A child span that already finished has been handed to the worker and can no longer
348
+ be moved.
349
+ - **Only a root span is re-parented.** A nested span already belongs to an in-process trace, and
350
+ moving it would sever it from its own parent. The adopted context still applies to the next
351
+ root span opened in that context.
352
+ - **Your `span_id` is never overwritten.** The adopting span keeps its own identity and takes the
353
+ inbound span as its `parent_span_id` — otherwise two processes would share a span id.
354
+ - **`parent_span_id` may be omitted.** With only `trace_id` you join the trace as another root,
355
+ which beats being in a fresh trace when you know the trace but not the specific parent.
356
+ - **Inbound context is untrusted and validated strictly** — 32/16 lowercase hex, all-zero ids
357
+ rejected, higher `traceparent` versions accepted per the W3C forward-compatibility rule.
358
+ Anything unusable is ignored with a single bounded warning on stderr and a fresh trace is
359
+ minted; a malformed id never reaches the event stream. Adopting a context grants **nothing**
360
+ — it selects a correlation id and confers no authority.
361
+ - **Baggage fails independently of the trace.** A malformed `baggage` header is skipped with a
362
+ warning while the trace is still adopted: losing correlating fields is bad, losing the trace
363
+ join because one field was malformed is worse. Headers over 8192 bytes are rejected. Values
364
+ are percent-encoded, so `,` `=` and non-ASCII round-trip; non-string values are serialized
365
+ with `str()`, so a dict arrives as its repr.
366
+ - **Sampling is not honoured.** `traceparent`'s flags byte is parsed and ignored, and outbound
367
+ is always `01`: this library records every span, so respecting another system's sampling
368
+ decision would mean dropping them.
369
+
295
370
  ### Sinks
296
371
 
297
372
  A **sink** is the swappable output transport — any object satisfying the `Sink` protocol. It
@@ -494,17 +569,70 @@ a failing sink with backoff, and applies backpressure so a slow or down sink can
494
569
  back-pressure the app: when its bounded queue is full it drops the newest submissions and counts
495
570
  them (`worker.dropped`) rather than stalling.
496
571
 
497
- Because delivery is asynchronous, drain before the process exits:
572
+ Because delivery is asynchronous, drain before the process exits. There are two drains, and
573
+ which one you want depends on whether the process is about to end:
498
574
 
499
575
  ```python
500
576
  import log_foundry as lf
501
577
 
502
- lf.shutdown() # flush buffered events and close the sink; blocks until drained
578
+ lf.flush() # drain to the sink and keep going; returns True when everything landed
579
+ lf.shutdown() # drain, close the sink, and stop for good; blocks until drained
503
580
  ```
504
581
 
582
+ | | `flush()` | `shutdown()` |
583
+ |---|---|---|
584
+ | Drains buffered events | yes | yes |
585
+ | Closes the sink | no | yes |
586
+ | Worker survives | yes | **no** — it never comes back |
587
+ | Repeatable | yes | idempotent, but only the first call does anything |
588
+ | Use it | before returning from a handler, or at a checkpoint | once, as the process exits |
589
+
505
590
  `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.
591
+ explicitly when you need to be certain the tail reached the sink before a fast exit, e.g. at the
592
+ end of a short script. It is idempotent.
593
+
594
+ `flush(timeout=5.0)` returns `True` when every event submitted before the call has been passed
595
+ to the sink, and `False` if that did not happen within `timeout` (or the worker was already shut
596
+ down). It never raises — a logging call must not be the reason your function fails. Passing
597
+ `timeout=None` waits indefinitely, which is unsafe anywhere with an execution deadline.
598
+
599
+ #### Serverless / short-lived processes
600
+
601
+ In AWS Lambda (and anything else that freezes rather than exits) the rules are different, and
602
+ getting them wrong is silent:
603
+
604
+ - **Flush before the handler returns.** Lambda freezes the execution environment the instant
605
+ your handler returns, so the worker's interval-based flush stops mid-interval and whatever is
606
+ still queued is lost when the container is eventually reaped. `atexit` does not save you —
607
+ a frozen environment is killed without running exit handlers, so `flush()` is the *only*
608
+ guaranteed drain there.
609
+ - **Put it in a `finally`.** A flush written as the last line of the handler body is precisely
610
+ the line that does not run when the handler raises, and the invocation whose logs are most
611
+ worth having is the one that failed.
612
+ - **Never call `shutdown()` per invocation.** It is terminal: the worker does not come back, so
613
+ the first invocation on a warm container would log and every later one would silently log
614
+ nothing. That failure reads as "works locally, broken in production".
615
+
616
+ ```python
617
+ import log_foundry as lf
618
+ from log_foundry.sinks.sqs import SQSSink
619
+
620
+ lf.configure(service="billing-api", env="prod", sink=SQSSink(queue_url=QUEUE_URL))
621
+
622
+ @lf.trace
623
+ def handler(event, context):
624
+ lf.info("received", records=len(event["Records"]))
625
+ try:
626
+ return do_work(event)
627
+ finally:
628
+ lf.flush() # in `finally`: the failed invocation is the one worth logging.
629
+ # NEVER shutdown() here — the worker does not come back, and every
630
+ # later invocation on this warm container would log nothing.
631
+ ```
632
+
633
+ By default each invocation is its own trace, so N invocations produce N `trace_id`s. To join
634
+ them into one — a step function, a producer and its consumer — pass the context across with
635
+ [`continue_trace()`](#continuing-a-trace-across-processes).
508
636
 
509
637
  ## Event schema
510
638
 
@@ -170,6 +170,12 @@ never crashes the worker or the app. If the bounded queue fills completely, new
170
170
  dropped (newest-first) and counted rather than blocking your code. On `shutdown()` the worker
171
171
  stops, sweeps anything still queued into one final batch, emits it, and closes the sink.
172
172
 
173
+ Both triggers can be pre-empted: `flush()` puts a marker in the queue and the worker emits the
174
+ pending pile the moment it reaches it, ignoring the count and time triggers. Because the queue
175
+ is FIFO, everything submitted before the call is necessarily ahead of that marker — which is
176
+ exactly why the guarantee is "events submitted before this call", and why concurrent
177
+ submissions from other threads may or may not be included.
178
+
173
179
  ## Usage
174
180
 
175
181
  ### `configure(...)`
@@ -256,6 +262,75 @@ def process_payment(user_id: int) -> str:
256
262
  - **Orphan logs** — a level call made with no active span is not dropped: it emits a standalone
257
263
  one-event span with a fresh `trace_id`, flushed straight to the sink.
258
264
 
265
+ ### Continuing a trace across processes
266
+
267
+ A trace stops at the process boundary: `@trace` mints a fresh `trace_id` whenever no span is
268
+ open, so two processes cooperating on one logical operation produce two unrelated traces. Pass
269
+ the context across and they join up. Nothing here is serverless-specific — the same two calls
270
+ join an HTTP client to its server, or a Celery caller to its worker.
271
+
272
+ **The producer publishes where it is:**
273
+
274
+ ```python
275
+ @lf.trace
276
+ def enqueue_check(location: str) -> None:
277
+ sqs.send_message(
278
+ QueueUrl=QUEUE_URL,
279
+ MessageBody=json.dumps({
280
+ "location": location,
281
+ "traceparent": lf.current_traceparent(), # "00-<trace_id>-<span_id>-01"
282
+ "baggage": lf.current_baggage_header(), # "request_id=req-123,tenant=acme"
283
+ }),
284
+ )
285
+ ```
286
+
287
+ **The consumer adopts it — one line, and make it the first line:**
288
+
289
+ ```python
290
+ @lf.trace
291
+ def handler(event, context):
292
+ lf.continue_trace(event.get("traceparent"), baggage=event.get("baggage"))
293
+ lf.info("inspecting") # same trace_id as the producer; parent is its span
294
+ try:
295
+ return inspect(event)
296
+ finally:
297
+ lf.flush()
298
+ ```
299
+
300
+ | Call | Does |
301
+ |---|---|
302
+ | `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. |
303
+ | `current_traceparent()` | This span as a W3C `traceparent` string, or `None` if no span is active. |
304
+ | `current_trace_context()` | `(trace_id, span_id)`, for when moving two fields beats moving a string. |
305
+ | `current_baggage_header()` | Current baggage in W3C `baggage` format (`""` when empty). |
306
+
307
+ Details worth knowing:
308
+
309
+ - **Call `continue_trace()` on the first line.** `@trace` opens the handler's span *before* the
310
+ body runs, so the call re-parents that span in place and rewrites the events it has already
311
+ buffered. A child span that already finished has been handed to the worker and can no longer
312
+ be moved.
313
+ - **Only a root span is re-parented.** A nested span already belongs to an in-process trace, and
314
+ moving it would sever it from its own parent. The adopted context still applies to the next
315
+ root span opened in that context.
316
+ - **Your `span_id` is never overwritten.** The adopting span keeps its own identity and takes the
317
+ inbound span as its `parent_span_id` — otherwise two processes would share a span id.
318
+ - **`parent_span_id` may be omitted.** With only `trace_id` you join the trace as another root,
319
+ which beats being in a fresh trace when you know the trace but not the specific parent.
320
+ - **Inbound context is untrusted and validated strictly** — 32/16 lowercase hex, all-zero ids
321
+ rejected, higher `traceparent` versions accepted per the W3C forward-compatibility rule.
322
+ Anything unusable is ignored with a single bounded warning on stderr and a fresh trace is
323
+ minted; a malformed id never reaches the event stream. Adopting a context grants **nothing**
324
+ — it selects a correlation id and confers no authority.
325
+ - **Baggage fails independently of the trace.** A malformed `baggage` header is skipped with a
326
+ warning while the trace is still adopted: losing correlating fields is bad, losing the trace
327
+ join because one field was malformed is worse. Headers over 8192 bytes are rejected. Values
328
+ are percent-encoded, so `,` `=` and non-ASCII round-trip; non-string values are serialized
329
+ with `str()`, so a dict arrives as its repr.
330
+ - **Sampling is not honoured.** `traceparent`'s flags byte is parsed and ignored, and outbound
331
+ is always `01`: this library records every span, so respecting another system's sampling
332
+ decision would mean dropping them.
333
+
259
334
  ### Sinks
260
335
 
261
336
  A **sink** is the swappable output transport — any object satisfying the `Sink` protocol. It
@@ -458,17 +533,70 @@ a failing sink with backoff, and applies backpressure so a slow or down sink can
458
533
  back-pressure the app: when its bounded queue is full it drops the newest submissions and counts
459
534
  them (`worker.dropped`) rather than stalling.
460
535
 
461
- Because delivery is asynchronous, drain before the process exits:
536
+ Because delivery is asynchronous, drain before the process exits. There are two drains, and
537
+ which one you want depends on whether the process is about to end:
462
538
 
463
539
  ```python
464
540
  import log_foundry as lf
465
541
 
466
- lf.shutdown() # flush buffered events and close the sink; blocks until drained
542
+ lf.flush() # drain to the sink and keep going; returns True when everything landed
543
+ lf.shutdown() # drain, close the sink, and stop for good; blocks until drained
467
544
  ```
468
545
 
546
+ | | `flush()` | `shutdown()` |
547
+ |---|---|---|
548
+ | Drains buffered events | yes | yes |
549
+ | Closes the sink | no | yes |
550
+ | Worker survives | yes | **no** — it never comes back |
551
+ | Repeatable | yes | idempotent, but only the first call does anything |
552
+ | Use it | before returning from a handler, or at a checkpoint | once, as the process exits |
553
+
469
554
  `shutdown()` is also registered via `atexit`, so a normal exit flushes automatically — call it
470
- explicitly when you need to be certain the tail reached the sink before a fast exit (e.g. at the
471
- end of a short script or an AWS Lambda handler). It is idempotent.
555
+ explicitly when you need to be certain the tail reached the sink before a fast exit, e.g. at the
556
+ end of a short script. It is idempotent.
557
+
558
+ `flush(timeout=5.0)` returns `True` when every event submitted before the call has been passed
559
+ to the sink, and `False` if that did not happen within `timeout` (or the worker was already shut
560
+ down). It never raises — a logging call must not be the reason your function fails. Passing
561
+ `timeout=None` waits indefinitely, which is unsafe anywhere with an execution deadline.
562
+
563
+ #### Serverless / short-lived processes
564
+
565
+ In AWS Lambda (and anything else that freezes rather than exits) the rules are different, and
566
+ getting them wrong is silent:
567
+
568
+ - **Flush before the handler returns.** Lambda freezes the execution environment the instant
569
+ your handler returns, so the worker's interval-based flush stops mid-interval and whatever is
570
+ still queued is lost when the container is eventually reaped. `atexit` does not save you —
571
+ a frozen environment is killed without running exit handlers, so `flush()` is the *only*
572
+ guaranteed drain there.
573
+ - **Put it in a `finally`.** A flush written as the last line of the handler body is precisely
574
+ the line that does not run when the handler raises, and the invocation whose logs are most
575
+ worth having is the one that failed.
576
+ - **Never call `shutdown()` per invocation.** It is terminal: the worker does not come back, so
577
+ the first invocation on a warm container would log and every later one would silently log
578
+ nothing. That failure reads as "works locally, broken in production".
579
+
580
+ ```python
581
+ import log_foundry as lf
582
+ from log_foundry.sinks.sqs import SQSSink
583
+
584
+ lf.configure(service="billing-api", env="prod", sink=SQSSink(queue_url=QUEUE_URL))
585
+
586
+ @lf.trace
587
+ def handler(event, context):
588
+ lf.info("received", records=len(event["Records"]))
589
+ try:
590
+ return do_work(event)
591
+ finally:
592
+ lf.flush() # in `finally`: the failed invocation is the one worth logging.
593
+ # NEVER shutdown() here — the worker does not come back, and every
594
+ # later invocation on this warm container would log nothing.
595
+ ```
596
+
597
+ By default each invocation is its own trace, so N invocations produce N `trace_id`s. To join
598
+ them into one — a step function, a producer and its consumer — pass the context across with
599
+ [`continue_trace()`](#continuing-a-trace-across-processes).
472
600
 
473
601
  ## Event schema
474
602
 
@@ -20,7 +20,7 @@ dependencies = [
20
20
  ]
21
21
 
22
22
  # Optional features. Install with: pip install log-foundry[aws]
23
- version = "0.2.1.dev3"
23
+ version = "0.2.1.dev5"
24
24
 
25
25
  [project.optional-dependencies]
26
26
  aws = ["boto3>=1.34"] # SQSSink, SNSSink, KinesisSink, FirehoseSink
@@ -0,0 +1,84 @@
1
+ """log_foundry — consistent, structured logs per decorated function call.
2
+
3
+ Public façade (module-function shape). Exposes configuration, the ``@trace`` decorator, the
4
+ ``debug/info/warning/error/critical`` emitters, ``set_baggage``, the cross-process propagation
5
+ pair (``continue_trace`` to adopt an inbound context; ``current_traceparent`` /
6
+ ``current_trace_context`` / ``current_baggage_header`` to publish this one), ``flush`` (drain and
7
+ keep logging) and ``shutdown`` (drain, close the sink, and stop).
8
+ """
9
+
10
+ from importlib.metadata import PackageNotFoundError
11
+ from importlib.metadata import version as _dist_version
12
+
13
+ from log_foundry.api import critical, debug, error, info, set_baggage, warning
14
+ from log_foundry.config import configure, get_config
15
+ from log_foundry.context import (
16
+ current_baggage_header,
17
+ current_trace_context,
18
+ current_traceparent,
19
+ )
20
+ from log_foundry.decorator import continue_trace, trace
21
+
22
+ try:
23
+ # Distribution name ("log-foundry") differs from the import name ("log_foundry").
24
+ __version__ = _dist_version("log-foundry")
25
+ except PackageNotFoundError: # running from a source tree that isn't installed
26
+ __version__ = "0.0.0"
27
+
28
+
29
+ def flush(timeout: float | None = 5.0) -> bool:
30
+ """Drain buffered events through the sink without closing it.
31
+
32
+ Every event submitted before this call has been passed to ``sink.emit`` when it returns
33
+ ``True``. (Events submitted concurrently by another thread may or may not be included —
34
+ the caller cannot have meant those.) Unlike :func:`shutdown` the background worker stays
35
+ alive and the sink stays open, so logging continues normally afterwards.
36
+
37
+ This is the drain for a process that is *frozen* rather than exited — an AWS Lambda
38
+ handler must drain before it returns, but will be invoked again on the same warm
39
+ container. Call it in a ``finally``: the invocation worth logging is the one that failed.
40
+
41
+ ``timeout=None`` waits indefinitely, which is unsafe in any environment with an execution
42
+ deadline — it converts "some logs were lost" into "the invocation timed out".
43
+
44
+ Returns ``False`` if the drain did not complete within ``timeout``, or if the worker has
45
+ already been shut down. Never raises.
46
+ """
47
+ from log_foundry.decorator import _flush_worker
48
+
49
+ return _flush_worker(timeout)
50
+
51
+
52
+ def shutdown() -> None:
53
+ """Flush buffered events and close the sink, blocking until drained. Idempotent.
54
+
55
+ Also registered via ``atexit``; call it explicitly before a fast process exit when you
56
+ want to be certain the tail of the queue reached the sink (SPEC-004 FR-005).
57
+
58
+ This is **terminal** — the worker does not come back. Do not call it per-invocation in a
59
+ serverless handler: the first invocation on a warm container would log and every later one
60
+ would silently log nothing. Use :func:`flush` there, which drains and keeps the worker.
61
+ """
62
+ from log_foundry.decorator import _shutdown_worker
63
+
64
+ _shutdown_worker()
65
+
66
+
67
+ __all__ = [
68
+ "configure",
69
+ "get_config",
70
+ "trace",
71
+ "debug",
72
+ "info",
73
+ "warning",
74
+ "error",
75
+ "critical",
76
+ "set_baggage",
77
+ "continue_trace",
78
+ "current_traceparent",
79
+ "current_trace_context",
80
+ "current_baggage_header",
81
+ "flush",
82
+ "shutdown",
83
+ "__version__",
84
+ ]
@@ -0,0 +1,160 @@
1
+ """Context propagation: span stack + baggage via contextvars (arch §5, guide Phase 4).
2
+
3
+ ``contextvars`` (not thread-locals) is correct under both threads and asyncio — each task
4
+ inherits its own copy — so ``log_foundry.info(...)`` can find "the span I'm inside" with no
5
+ manual passing. This module holds a *stack* of active spans (the top is the current span and
6
+ the parent of the next nested call) plus trace-scoped baggage.
7
+
8
+ Two footguns, both avoided below:
9
+ * Never mutate a ContextVar's default mutable value — the ``()`` / ``{}`` defaults are shared
10
+ across all contexts. Always ``.set()`` a new tuple/dict.
11
+ * Use the token/``reset`` pattern, not a manual pop — ``reset(token)`` restores the exact
12
+ prior state even when tasks branch.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import contextvars
18
+ from typing import TYPE_CHECKING
19
+ from urllib.parse import quote, unquote
20
+
21
+ from log_foundry.ids import format_traceparent
22
+
23
+ if TYPE_CHECKING:
24
+ from log_foundry.model import Span
25
+
26
+ __all__ = [
27
+ "current_span",
28
+ "push_span",
29
+ "pop_span",
30
+ "get_baggage",
31
+ "set_baggage",
32
+ "current_traceparent",
33
+ "current_trace_context",
34
+ "current_baggage_header",
35
+ ]
36
+
37
+ _span_stack: contextvars.ContextVar[tuple[Span, ...]] = contextvars.ContextVar(
38
+ "log_foundry_span_stack", default=()
39
+ )
40
+ _baggage: contextvars.ContextVar[dict[str, object]] = contextvars.ContextVar(
41
+ "log_foundry_baggage", default={}
42
+ )
43
+ # An inbound trace context adopted via ``continue_trace`` (SPEC-014), applied by ``_open_span``
44
+ # to the next *root* span. A ContextVar for the same reason as the two above: it is then correct
45
+ # under threads and asyncio for free, and a task that adopts a context cannot leak it to siblings.
46
+ _adopted: contextvars.ContextVar[tuple[str, str | None] | None] = contextvars.ContextVar(
47
+ "log_foundry_adopted_context", default=None
48
+ )
49
+
50
+ # W3C suggests 8192 bytes for the whole `baggage` header. Bounded on the way in so a hostile or
51
+ # runaway header cannot inflate every subsequent event emitted by this process.
52
+ BAGGAGE_MAX_BYTES = 8192
53
+
54
+
55
+ def current_span() -> Span | None:
56
+ """Return the innermost active span, or ``None`` when no span is active."""
57
+ stack = _span_stack.get()
58
+ return stack[-1] if stack else None
59
+
60
+
61
+ def push_span(span: Span) -> contextvars.Token[tuple[Span, ...]]:
62
+ """Push ``span`` onto the stack; return a token to hand back to :func:`pop_span`."""
63
+ return _span_stack.set((*_span_stack.get(), span))
64
+
65
+
66
+ def pop_span(token: contextvars.Token[tuple[Span, ...]]) -> None:
67
+ """Restore the span stack to its state before the matching :func:`push_span`."""
68
+ _span_stack.reset(token)
69
+
70
+
71
+ def get_baggage() -> dict[str, object]:
72
+ """Return the current trace's baggage (do not mutate the returned dict in place)."""
73
+ return _baggage.get()
74
+
75
+
76
+ def set_baggage(**kv: object) -> None:
77
+ """Merge key/values into the current trace's baggage (replaces with a new dict)."""
78
+ _baggage.set({**_baggage.get(), **kv})
79
+
80
+
81
+ # -- adopted inbound context (SPEC-014) --------------------------------------------------
82
+
83
+
84
+ def get_adopted_context() -> tuple[str, str | None] | None:
85
+ """Return the adopted ``(trace_id, parent_span_id)``, or ``None`` if none was adopted."""
86
+ return _adopted.get()
87
+
88
+
89
+ def set_adopted_context(trace_id: str, parent_span_id: str | None) -> None:
90
+ """Record an inbound trace context for the next root span opened in this context."""
91
+ _adopted.set((trace_id, parent_span_id))
92
+
93
+
94
+ # -- the producer side: publish where this process is ------------------------------------
95
+
96
+
97
+ def current_traceparent() -> str | None:
98
+ """Return the current span as a W3C ``traceparent`` string, or ``None`` if none is active."""
99
+ span = current_span()
100
+ return format_traceparent(span.trace_id, span.span_id) if span else None
101
+
102
+
103
+ def current_trace_context() -> tuple[str, str] | None:
104
+ """Return ``(trace_id, span_id)`` for the current span, or ``None`` if none is active.
105
+
106
+ For callers who would rather move two fields than a formatted string — a Step Functions
107
+ payload, a queue message attribute. It exists so nobody is pushed into
108
+ :func:`current_span`, which is internal and hands back a mutable :class:`~.model.Span`.
109
+ """
110
+ span = current_span()
111
+ return (span.trace_id, span.span_id) if span else None
112
+
113
+
114
+ # -- the W3C `baggage` header codec ------------------------------------------------------
115
+
116
+
117
+ def current_baggage_header() -> str:
118
+ """Return the current baggage in W3C ``baggage`` header format (``""`` when empty).
119
+
120
+ The baggage store is ``dict[str, object]`` but the wire format is text, so **non-string
121
+ values are serialized with ``str()``** — put a dict in baggage and it arrives at the next
122
+ process as its repr, not as a dict. Keys and values are percent-encoded, so a value
123
+ containing ``,``, ``=`` or non-ASCII round-trips through :func:`parse_baggage_header`.
124
+ """
125
+ return format_baggage_header(get_baggage())
126
+
127
+
128
+ def format_baggage_header(baggage: dict[str, object]) -> str:
129
+ """Serialize a baggage mapping to the W3C ``key1=value1,key2=value2`` format."""
130
+ # safe="" so the separators themselves (`,` `=` `;`) are encoded rather than corrupting the
131
+ # framing — the whole reason a value containing a comma can survive the hop.
132
+ return ",".join(f"{quote(k, safe='')}={quote(str(v), safe='')}" for k, v in baggage.items())
133
+
134
+
135
+ def parse_baggage_header(header: object) -> dict[str, object] | None:
136
+ """Parse a W3C ``baggage`` header into a mapping, or ``None`` if it is unusable.
137
+
138
+ Total, like the ``traceparent`` parser: the header arrives from outside the process. Returns
139
+ ``None`` for a non-string, an empty header, one over :data:`BAGGAGE_MAX_BYTES`, or a
140
+ malformed member — never a partial parse, which would silently drop correlating fields.
141
+ """
142
+ if not isinstance(header, str) or not header.strip():
143
+ return None
144
+ if len(header.encode("utf-8", "replace")) > BAGGAGE_MAX_BYTES:
145
+ return None
146
+ parsed: dict[str, object] = {}
147
+ for member in header.split(","):
148
+ # W3C allows per-member properties after a `;` (e.g. `k=v;metadata`). Nothing here
149
+ # consumes them, so they are dropped rather than treated as part of the value.
150
+ entry = member.split(";", 1)[0].strip()
151
+ if not entry:
152
+ continue
153
+ key, sep, value = entry.partition("=")
154
+ if not sep:
155
+ return None
156
+ key = unquote(key.strip())
157
+ if not key:
158
+ return None
159
+ parsed[key] = unquote(value.strip())
160
+ return parsed or None