log-foundry 0.2.1.dev4__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 (54) hide show
  1. {log_foundry-0.2.1.dev4 → log_foundry-0.2.1.dev5}/PKG-INFO +73 -4
  2. {log_foundry-0.2.1.dev4 → log_foundry-0.2.1.dev5}/README.md +72 -3
  3. {log_foundry-0.2.1.dev4 → log_foundry-0.2.1.dev5}/pyproject.toml +1 -1
  4. {log_foundry-0.2.1.dev4 → log_foundry-0.2.1.dev5}/src/log_foundry/__init__.py +14 -3
  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.dev4/src/log_foundry/context.py +0 -62
  9. log_foundry-0.2.1.dev4/src/log_foundry/decorator.py +0 -176
  10. log_foundry-0.2.1.dev4/src/log_foundry/ids.py +0 -29
  11. {log_foundry-0.2.1.dev4 → log_foundry-0.2.1.dev5}/LICENSE +0 -0
  12. {log_foundry-0.2.1.dev4 → log_foundry-0.2.1.dev5}/src/log_foundry/api.py +0 -0
  13. {log_foundry-0.2.1.dev4 → log_foundry-0.2.1.dev5}/src/log_foundry/config.py +0 -0
  14. {log_foundry-0.2.1.dev4 → log_foundry-0.2.1.dev5}/src/log_foundry/console.py +0 -0
  15. {log_foundry-0.2.1.dev4 → log_foundry-0.2.1.dev5}/src/log_foundry/model.py +0 -0
  16. {log_foundry-0.2.1.dev4 → log_foundry-0.2.1.dev5}/src/log_foundry/py.typed +0 -0
  17. {log_foundry-0.2.1.dev4 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/__init__.py +0 -0
  18. {log_foundry-0.2.1.dev4 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/_chunk.py +0 -0
  19. {log_foundry-0.2.1.dev4 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/_socket.py +0 -0
  20. {log_foundry-0.2.1.dev4 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/_time.py +0 -0
  21. {log_foundry-0.2.1.dev4 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/base.py +0 -0
  22. {log_foundry-0.2.1.dev4 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/callback.py +0 -0
  23. {log_foundry-0.2.1.dev4 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/clickhouse.py +0 -0
  24. {log_foundry-0.2.1.dev4 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/datadog.py +0 -0
  25. {log_foundry-0.2.1.dev4 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/elasticsearch.py +0 -0
  26. {log_foundry-0.2.1.dev4 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/eventhubs.py +0 -0
  27. {log_foundry-0.2.1.dev4 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/file.py +0 -0
  28. {log_foundry-0.2.1.dev4 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/filtering.py +0 -0
  29. {log_foundry-0.2.1.dev4 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/firehose.py +0 -0
  30. {log_foundry-0.2.1.dev4 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/honeycomb.py +0 -0
  31. {log_foundry-0.2.1.dev4 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/http.py +0 -0
  32. {log_foundry-0.2.1.dev4 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/kafka.py +0 -0
  33. {log_foundry-0.2.1.dev4 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/kinesis.py +0 -0
  34. {log_foundry-0.2.1.dev4 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/logging_sink.py +0 -0
  35. {log_foundry-0.2.1.dev4 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/logstash.py +0 -0
  36. {log_foundry-0.2.1.dev4 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/loki.py +0 -0
  37. {log_foundry-0.2.1.dev4 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/mongodb.py +0 -0
  38. {log_foundry-0.2.1.dev4 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/multi.py +0 -0
  39. {log_foundry-0.2.1.dev4 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/nats.py +0 -0
  40. {log_foundry-0.2.1.dev4 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/newrelic.py +0 -0
  41. {log_foundry-0.2.1.dev4 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/postgres.py +0 -0
  42. {log_foundry-0.2.1.dev4 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/pubsub.py +0 -0
  43. {log_foundry-0.2.1.dev4 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/rabbitmq.py +0 -0
  44. {log_foundry-0.2.1.dev4 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/redis.py +0 -0
  45. {log_foundry-0.2.1.dev4 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/sentry.py +0 -0
  46. {log_foundry-0.2.1.dev4 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/sns.py +0 -0
  47. {log_foundry-0.2.1.dev4 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/splunk.py +0 -0
  48. {log_foundry-0.2.1.dev4 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/sqlite.py +0 -0
  49. {log_foundry-0.2.1.dev4 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/sqs.py +0 -0
  50. {log_foundry-0.2.1.dev4 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/stdout.py +0 -0
  51. {log_foundry-0.2.1.dev4 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/syslog.py +0 -0
  52. {log_foundry-0.2.1.dev4 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/transform.py +0 -0
  53. {log_foundry-0.2.1.dev4 → log_foundry-0.2.1.dev5}/src/log_foundry/sinks/util.py +0 -0
  54. {log_foundry-0.2.1.dev4 → log_foundry-0.2.1.dev5}/src/log_foundry/worker.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: log-foundry
3
- Version: 0.2.1.dev4
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
@@ -298,6 +298,75 @@ def process_payment(user_id: int) -> str:
298
298
  - **Orphan logs** — a level call made with no active span is not dropped: it emits a standalone
299
299
  one-event span with a fresh `trace_id`, flushed straight to the sink.
300
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
+
301
370
  ### Sinks
302
371
 
303
372
  A **sink** is the swappable output transport — any object satisfying the `Sink` protocol. It
@@ -561,9 +630,9 @@ def handler(event, context):
561
630
  # later invocation on this warm container would log nothing.
562
631
  ```
563
632
 
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.
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).
567
636
 
568
637
  ## Event schema
569
638
 
@@ -262,6 +262,75 @@ def process_payment(user_id: int) -> str:
262
262
  - **Orphan logs** — a level call made with no active span is not dropped: it emits a standalone
263
263
  one-event span with a fresh `trace_id`, flushed straight to the sink.
264
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
+
265
334
  ### Sinks
266
335
 
267
336
  A **sink** is the swappable output transport — any object satisfying the `Sink` protocol. It
@@ -525,9 +594,9 @@ def handler(event, context):
525
594
  # later invocation on this warm container would log nothing.
526
595
  ```
527
596
 
528
- Note that each invocation is its own trace: trace context does not cross a process boundary
529
- today, so N invocations produce N `trace_id`s. Correlate them with a shared
530
- [`set_baggage`](#logging-inside-a-span) field until cross-process continuation ships.
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).
531
600
 
532
601
  ## Event schema
533
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.dev4"
23
+ version = "0.2.1.dev5"
24
24
 
25
25
  [project.optional-dependencies]
26
26
  aws = ["boto3>=1.34"] # SQSSink, SNSSink, KinesisSink, FirehoseSink
@@ -1,8 +1,10 @@
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``, ``flush`` (drain and keep
5
- logging) and ``shutdown`` (drain, close the sink, and stop).
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).
6
8
  """
7
9
 
8
10
  from importlib.metadata import PackageNotFoundError
@@ -10,7 +12,12 @@ from importlib.metadata import version as _dist_version
10
12
 
11
13
  from log_foundry.api import critical, debug, error, info, set_baggage, warning
12
14
  from log_foundry.config import configure, get_config
13
- from log_foundry.decorator import trace
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
14
21
 
15
22
  try:
16
23
  # Distribution name ("log-foundry") differs from the import name ("log_foundry").
@@ -67,6 +74,10 @@ __all__ = [
67
74
  "error",
68
75
  "critical",
69
76
  "set_baggage",
77
+ "continue_trace",
78
+ "current_traceparent",
79
+ "current_trace_context",
80
+ "current_baggage_header",
70
81
  "flush",
71
82
  "shutdown",
72
83
  "__version__",
@@ -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
@@ -0,0 +1,312 @@
1
+ """The ``@trace`` decorator — synchronous and async (arch §4–5, guide Phases 6, 8).
2
+
3
+ Opens a span on enter and closes it on exit (success *or* exception), maintaining the
4
+ trace/parent hierarchy from the context stack, then flushes the finished span straight to
5
+ the configured sink. The decorator is **non-swallowing**: it records the failure and
6
+ re-raises the original exception unchanged (arch §4).
7
+
8
+ At decoration time :func:`asyncio.iscoroutinefunction` selects a sync or async wrapper. The
9
+ two are deliberate near-duplicates — the only difference is ``await fn(...)`` — because the
10
+ sync/async split is a hard boundary; ``contextvars`` already propagates the span stack and
11
+ baggage correctly across ``await`` points and concurrent tasks (arch §5), so the async span
12
+ opens when the coroutine actually runs and closes when it finishes, with no new machinery.
13
+
14
+ Flushing is synchronous here; the background worker (SPEC-004) later swaps only
15
+ :func:`_flush` for a non-blocking handoff — the lifecycle below is untouched.
16
+
17
+ :func:`continue_trace` (SPEC-014) lives here rather than in ``api`` because it is the other half
18
+ of :func:`_open_span`'s hierarchy rules: it decides which trace the *next* root span joins, and
19
+ re-parents an already-open one. The ``traceparent`` codec it validates with stays in ``ids``.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import asyncio
25
+ import atexit
26
+ import functools
27
+ import sys
28
+ import threading
29
+ from collections.abc import Callable
30
+ from time import monotonic
31
+ from typing import Any, TypeVar, cast, overload
32
+
33
+ from log_foundry import context
34
+ from log_foundry.config import _ensure_sink
35
+ from log_foundry.ids import (
36
+ is_valid_span_id,
37
+ is_valid_trace_id,
38
+ new_span_id,
39
+ new_trace_id,
40
+ parse_traceparent,
41
+ )
42
+ from log_foundry.model import Span, end_event, start_event
43
+ from log_foundry.worker import Worker
44
+
45
+ __all__ = ["trace", "continue_trace"]
46
+
47
+ # Bound on how much of a rejected inbound value is echoed into a stderr warning.
48
+ _MAX_REJECTED_ECHO = 64
49
+
50
+ # One background worker per process (SPEC-004), created lazily from the configured sink on the
51
+ # first flush. The double-checked lock makes concurrent first-flushes create exactly one.
52
+ _worker: Worker | None = None
53
+ _worker_lock = threading.Lock()
54
+ _atexit_registered = False # register the drain exactly once per process, not per worker
55
+
56
+ F = TypeVar("F", bound=Callable[..., Any])
57
+
58
+
59
+ def _open_span(name: str, defaults: dict[str, object] | None) -> Span:
60
+ """Mint a span, inheriting the trace/parent from the current context (arch §3).
61
+
62
+ An inbound context adopted via :func:`continue_trace` is consulted **only** when no span is
63
+ open (SPEC-014 FR-001): a nested call still inherits from its in-process parent, because
64
+ moving it to the inbound span would sever it from the parent it actually ran inside.
65
+ """
66
+ parent = context.current_span()
67
+ trace_id: str
68
+ parent_span_id: str | None
69
+ if parent is not None:
70
+ trace_id, parent_span_id = parent.trace_id, parent.span_id
71
+ else:
72
+ adopted = context.get_adopted_context()
73
+ trace_id, parent_span_id = adopted if adopted else (new_trace_id(), None)
74
+ span = Span(
75
+ trace_id=trace_id,
76
+ span_id=new_span_id(),
77
+ parent_span_id=parent_span_id,
78
+ name=name,
79
+ start_ts=monotonic(),
80
+ defaults=defaults or {},
81
+ )
82
+ span.events.append(start_event(span))
83
+ return span
84
+
85
+
86
+ def _warn_rejected(reason: str, value: object) -> None:
87
+ """Report a rejected inbound context on stderr, as ``worker`` and ``SQSSink`` do.
88
+
89
+ The offending value is echoed only as a **bounded ``repr``**. Unbounded is a log-injection
90
+ surface — the value is attacker-controllable, and `repr` additionally escapes newlines and
91
+ control characters so it cannot forge a second log line in an operator's console.
92
+ """
93
+ shown = repr(value)
94
+ if len(shown) > _MAX_REJECTED_ECHO:
95
+ shown = shown[:_MAX_REJECTED_ECHO] + "…"
96
+ sys.stderr.write(f"log-foundry: ignoring inbound trace context ({reason}): {shown}\n")
97
+
98
+
99
+ def continue_trace(
100
+ traceparent: str | None = None,
101
+ *,
102
+ trace_id: str | None = None,
103
+ parent_span_id: str | None = None,
104
+ baggage: str | None = None,
105
+ ) -> bool:
106
+ """Adopt an inbound trace context so this process's spans join the caller's trace.
107
+
108
+ Accepts a W3C ``traceparent`` string or the ids directly. **If a span is already open and
109
+ it is a root** (``parent_span_id is None`` — the ``@trace``-decorated entry point calling
110
+ this on its first line), that span is re-parented in place and its buffered events are
111
+ rewritten to match. A span that is *not* a root is left alone; a call with no span open
112
+ re-parents nothing. In every case the context applies to the next root span opened here.
113
+
114
+ Call it on the **first line** of the entry point: a child span that already finished has
115
+ been handed to the worker and can no longer be rewritten.
116
+
117
+ ``baggage`` is a W3C ``baggage`` header merged into the current context. It succeeds or
118
+ fails independently of the trace context.
119
+
120
+ Returns ``True`` when a context was adopted, ``False`` when nothing valid was supplied and
121
+ a fresh trace is in use. Never raises.
122
+
123
+ Adopting a context grants **nothing** — it selects a correlation id and confers no
124
+ authority. The validation below is about output integrity (never emitting a malformed id
125
+ into the event stream), not authorization; this is not a trust boundary.
126
+ """
127
+ adopted: tuple[str, str | None] | None = None
128
+ if traceparent is not None:
129
+ if trace_id is not None or parent_span_id is not None:
130
+ # A programming error, not bad input: the caller supplied the same thing twice.
131
+ _warn_rejected("both traceparent and explicit ids given; traceparent wins", traceparent)
132
+ parsed = parse_traceparent(traceparent)
133
+ if parsed is None:
134
+ _warn_rejected("unparseable traceparent", traceparent)
135
+ else:
136
+ adopted = parsed
137
+ elif trace_id is not None:
138
+ if not is_valid_trace_id(trace_id):
139
+ _warn_rejected("invalid trace_id", trace_id)
140
+ elif parent_span_id is not None and not is_valid_span_id(parent_span_id):
141
+ # Drop just the parent and join as another root rather than reject the whole
142
+ # context: being in the right trace without a parent beats being in a fresh one.
143
+ _warn_rejected("invalid parent_span_id; joining as a root", parent_span_id)
144
+ adopted = (trace_id, None)
145
+ else:
146
+ # parent_span_id may legitimately be omitted — a consumer that knows the trace but
147
+ # not the specific parent span is better off in the right trace than a fresh one.
148
+ adopted = (trace_id, parent_span_id)
149
+ # Nothing supplied at all is a silent no-op, not a rejection: `event.get("traceparent")`
150
+ # legitimately yields None whenever the caller did not propagate one, and warning on that
151
+ # would write a line on every uninstrumented invocation.
152
+
153
+ if adopted is not None:
154
+ context.set_adopted_context(*adopted)
155
+ _reparent_current_span(*adopted)
156
+
157
+ if baggage is not None:
158
+ parsed_baggage = context.parse_baggage_header(baggage)
159
+ if parsed_baggage is None:
160
+ # Deliberately independent of the trace context above: losing correlating fields is
161
+ # bad, and losing the trace join because one field was malformed is worse.
162
+ _warn_rejected("unusable baggage header", baggage)
163
+ else:
164
+ context.set_baggage(**parsed_baggage)
165
+
166
+ return adopted is not None
167
+
168
+
169
+ def _reparent_current_span(trace_id: str, parent_span_id: str | None) -> None:
170
+ """Move an already-open **root** span into the adopted trace, events included.
171
+
172
+ ``@trace`` opened the entry point's span before its body ran, so by the time
173
+ :func:`continue_trace` is called on the first line that span already exists *and* has its
174
+ ``span.start`` event buffered. :func:`~log_foundry.model.build_event` **snapshots** the ids
175
+ into each event dict, so re-parenting only the dataclass would leave the buffered start
176
+ event on the old trace — one span emitting its start on trace A and its end on trace B. A
177
+ split trace is worse than no continuation at all, because it looks like data rather than a
178
+ bug.
179
+ """
180
+ span = context.current_span()
181
+ if span is None or span.parent_span_id is not None:
182
+ # Not a root: it already belongs to an in-process trace, and moving it would sever it
183
+ # from its own parent. The adopted context still applies to the next root span.
184
+ return
185
+ span.trace_id = trace_id
186
+ span.parent_span_id = parent_span_id
187
+ for event in span.events:
188
+ event["trace_id"] = trace_id
189
+ # `span_id` is never overwritten — the adopting span keeps its own identity and takes
190
+ # the inbound span as its parent. Overwriting would give two processes the same span id
191
+ # and break parent/child reconstruction downstream.
192
+ if event.get("span_id") == span.span_id:
193
+ event["parent_span_id"] = parent_span_id
194
+
195
+
196
+ def _get_worker() -> Worker:
197
+ """Return the process worker, creating it lazily from the configured sink (FR-006).
198
+
199
+ Registers the graceful drain via ``atexit`` exactly once, on first creation, so a program
200
+ that logs and exits immediately still flushes its buffered events.
201
+ """
202
+ global _worker, _atexit_registered
203
+ if _worker is None:
204
+ with _worker_lock:
205
+ if _worker is None:
206
+ if not _atexit_registered:
207
+ atexit.register(_shutdown_worker)
208
+ _atexit_registered = True
209
+ _worker = Worker(_ensure_sink())
210
+ return _worker
211
+
212
+
213
+ def _shutdown_worker() -> None:
214
+ """Drain + close the process worker if one was created (idempotent). Backs ``shutdown()``."""
215
+ if _worker is not None:
216
+ _worker.shutdown()
217
+
218
+
219
+ def _flush_worker(timeout: float | None = 5.0) -> bool:
220
+ """Drain the process worker without retiring it. Backs ``flush()`` (SPEC-013 FR-003).
221
+
222
+ Returns ``True`` when no worker exists: a process that never logged has nothing to drain,
223
+ and building one here — with the thread and ``atexit`` registration :func:`_get_worker`
224
+ brings — in order to flush nothing would be pure cost. So this deliberately does *not* call
225
+ :func:`_get_worker`.
226
+ """
227
+ worker = _worker
228
+ if worker is None:
229
+ return True
230
+ try:
231
+ return worker.flush(timeout)
232
+ except Exception: # noqa: BLE001 — a flush is the call most likely to be made in a
233
+ # `finally`, so the library must never be the reason a caller's function fails. Any
234
+ # failure is reported by the return value instead (FR-003).
235
+ return False
236
+
237
+
238
+ def _flush(span: Span) -> None:
239
+ """Hand the finished span's events to the background worker — non-blocking (FR-001).
240
+
241
+ Resolves/creates the worker via :func:`_get_worker` (whose sink comes from ``_ensure_sink``,
242
+ so a zero-config ``@trace`` still falls back to ``StdoutSink`` rather than crashing).
243
+ """
244
+ _get_worker().submit(span.events)
245
+
246
+
247
+ def _close_span(span: Span, status: str, exc: BaseException | None) -> None:
248
+ """Append the end event (so the flushed queue is complete), then flush."""
249
+ span.events.append(end_event(span, status, exc))
250
+ _flush(span)
251
+
252
+
253
+ @overload
254
+ def trace(func: F) -> F: ...
255
+ @overload
256
+ def trace(
257
+ *, name: str | None = ..., defaults: dict[str, object] | None = ...
258
+ ) -> Callable[[F], F]: ...
259
+
260
+
261
+ def trace(
262
+ func: F | None = None,
263
+ *,
264
+ name: str | None = None,
265
+ defaults: dict[str, object] | None = None,
266
+ ) -> F | Callable[[F], F]:
267
+ """Trace a function call as a span. Usable bare (``@trace``) or with args.
268
+
269
+ ``@trace(name=..., defaults=...)`` overrides the span name (default:
270
+ ``func.__qualname__``) and adds per-decorator default fields.
271
+ """
272
+
273
+ def decorate(fn: F) -> F:
274
+ if asyncio.iscoroutinefunction(fn):
275
+
276
+ @functools.wraps(fn)
277
+ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
278
+ span = _open_span(name or fn.__qualname__, defaults)
279
+ token = context.push_span(span)
280
+ try:
281
+ result = await fn(*args, **kwargs)
282
+ _close_span(span, "ok", None)
283
+ return result
284
+ except BaseException as exc:
285
+ # Non-swallowing (arch §4): record the error, then re-raise unchanged.
286
+ # BaseException covers asyncio.CancelledError — a cancelled coroutine is
287
+ # recorded as an error end event, never left with an unclosed span.
288
+ _close_span(span, "error", exc)
289
+ raise
290
+ finally:
291
+ context.pop_span(token)
292
+
293
+ return cast(F, async_wrapper)
294
+
295
+ @functools.wraps(fn)
296
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
297
+ span = _open_span(name or fn.__qualname__, defaults)
298
+ token = context.push_span(span)
299
+ try:
300
+ result = fn(*args, **kwargs)
301
+ _close_span(span, "ok", None)
302
+ return result
303
+ except BaseException as exc:
304
+ # Non-swallowing (arch §4): record the error, then re-raise unchanged.
305
+ _close_span(span, "error", exc)
306
+ raise
307
+ finally:
308
+ context.pop_span(token)
309
+
310
+ return cast(F, wrapper)
311
+
312
+ return decorate(func) if func is not None else decorate
@@ -0,0 +1,109 @@
1
+ """W3C Trace Context-compatible id generation and ``traceparent`` codec (arch §3.1, §12).
2
+
3
+ ``trace_id`` and ``span_id`` use the W3C wire formats (not arbitrary UUIDs) so adopting an
4
+ inbound request's trace is just a header parse and the emitted records stay compatible with
5
+ standard distributed-tracing tooling. ``log_id`` is internal-only, so a UUID is fine.
6
+
7
+ SPEC-014 cashes that in: the ``traceparent`` parser and formatter live here, beside the
8
+ generators whose formats they mirror, so the definition of "a valid trace id" has exactly one
9
+ home. Everything here is **total** — the parser takes ``object`` and returns ``None`` rather than
10
+ raising, because its input arrives from outside the process and a logging call must never be the
11
+ reason a caller's function fails.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import uuid
18
+
19
+ __all__ = [
20
+ "new_trace_id",
21
+ "new_span_id",
22
+ "new_log_id",
23
+ "parse_traceparent",
24
+ "format_traceparent",
25
+ "is_valid_trace_id",
26
+ "is_valid_span_id",
27
+ ]
28
+
29
+ _HEX = frozenset("0123456789abcdef")
30
+
31
+ # W3C: the sampled bit. This library records every span, so "sampled" is always true. An
32
+ # *inbound* flags byte is deliberately never round-tripped — honouring another system's sampling
33
+ # decision would mean dropping spans, which this library does not do (arch §10).
34
+ _FLAGS_SAMPLED = "01"
35
+
36
+ _VERSION = "00"
37
+ # Version ff is reserved and invalid per the W3C spec — it is not a "higher version".
38
+ _INVALID_VERSION = "ff"
39
+
40
+
41
+ def new_trace_id() -> str:
42
+ """Return a fresh trace id: 32 lowercase hex chars (16 random bytes)."""
43
+ return os.urandom(16).hex()
44
+
45
+
46
+ def new_span_id() -> str:
47
+ """Return a fresh span id: 16 lowercase hex chars (8 random bytes)."""
48
+ return os.urandom(8).hex()
49
+
50
+
51
+ def new_log_id() -> str:
52
+ """Return a fresh log id: a UUID4 hex string (internal-only, format is our choice)."""
53
+ return uuid.uuid4().hex
54
+
55
+
56
+ def _is_hex(value: str, length: int) -> bool:
57
+ """True when ``value`` is exactly ``length`` *lowercase* hex characters.
58
+
59
+ Uppercase is rejected rather than normalized: the W3C formats are defined as lowercase, and
60
+ silently accepting ``4BF9...`` here would let a non-conforming id reach the event stream.
61
+ """
62
+ return len(value) == length and not (set(value) - _HEX)
63
+
64
+
65
+ def is_valid_trace_id(value: object) -> bool:
66
+ """True for a W3C-valid trace id: exactly 32 lowercase hex chars, not all zero."""
67
+ # All-zero is explicitly invalid per the W3C spec — it is the "unset" sentinel, not an id.
68
+ return isinstance(value, str) and _is_hex(value, 32) and value != "0" * 32
69
+
70
+
71
+ def is_valid_span_id(value: object) -> bool:
72
+ """True for a W3C-valid span id: exactly 16 lowercase hex chars, not all zero."""
73
+ return isinstance(value, str) and _is_hex(value, 16) and value != "0" * 16
74
+
75
+
76
+ def parse_traceparent(value: object) -> tuple[str, str] | None:
77
+ """Parse a W3C ``traceparent`` into ``(trace_id, span_id)``, or ``None`` if unusable.
78
+
79
+ The format is ``version-trace_id-span_id-flags``, e.g.
80
+ ``00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01``. Takes ``object`` and never
81
+ raises: the value arrives from outside the process and may be any type at all.
82
+
83
+ The returned ``span_id`` is the *caller's* span — the adopting span's parent, never its own
84
+ identity. ``flags`` is parsed for validity and then ignored (see ``_FLAGS_SAMPLED``).
85
+ """
86
+ if not isinstance(value, str):
87
+ return None
88
+ parts = value.strip().split("-")
89
+ if len(parts) < 4:
90
+ return None
91
+ version, trace_id, span_id, flags = parts[0], parts[1], parts[2], parts[3]
92
+ if not _is_hex(version, 2) or version == _INVALID_VERSION:
93
+ return None
94
+ if version == _VERSION:
95
+ # Version 00 is defined as exactly four fields; a trailing field is malformed, not a
96
+ # forward-compatible extension.
97
+ if len(parts) != 4 or not _is_hex(flags, 2):
98
+ return None
99
+ # A *higher* version is deliberately not rejected. The W3C forward-compatibility rule says to
100
+ # parse the first three fields and ignore any extra ones — "reject anything that isn't 00" is
101
+ # the intuitive-and-wrong reading, and would strand us the moment version 01 ships.
102
+ if not is_valid_trace_id(trace_id) or not is_valid_span_id(span_id):
103
+ return None
104
+ return trace_id, span_id
105
+
106
+
107
+ def format_traceparent(trace_id: str, span_id: str) -> str:
108
+ """Format ``(trace_id, span_id)`` as a version-``00`` W3C ``traceparent`` string."""
109
+ return f"{_VERSION}-{trace_id}-{span_id}-{_FLAGS_SAMPLED}"
@@ -1,62 +0,0 @@
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
-
20
- if TYPE_CHECKING:
21
- from log_foundry.model import Span
22
-
23
- __all__ = [
24
- "current_span",
25
- "push_span",
26
- "pop_span",
27
- "get_baggage",
28
- "set_baggage",
29
- ]
30
-
31
- _span_stack: contextvars.ContextVar[tuple[Span, ...]] = contextvars.ContextVar(
32
- "log_foundry_span_stack", default=()
33
- )
34
- _baggage: contextvars.ContextVar[dict[str, object]] = contextvars.ContextVar(
35
- "log_foundry_baggage", default={}
36
- )
37
-
38
-
39
- def current_span() -> Span | None:
40
- """Return the innermost active span, or ``None`` when no span is active."""
41
- stack = _span_stack.get()
42
- return stack[-1] if stack else None
43
-
44
-
45
- def push_span(span: Span) -> contextvars.Token[tuple[Span, ...]]:
46
- """Push ``span`` onto the stack; return a token to hand back to :func:`pop_span`."""
47
- return _span_stack.set((*_span_stack.get(), span))
48
-
49
-
50
- def pop_span(token: contextvars.Token[tuple[Span, ...]]) -> None:
51
- """Restore the span stack to its state before the matching :func:`push_span`."""
52
- _span_stack.reset(token)
53
-
54
-
55
- def get_baggage() -> dict[str, object]:
56
- """Return the current trace's baggage (do not mutate the returned dict in place)."""
57
- return _baggage.get()
58
-
59
-
60
- def set_baggage(**kv: object) -> None:
61
- """Merge key/values into the current trace's baggage (replaces with a new dict)."""
62
- _baggage.set({**_baggage.get(), **kv})
@@ -1,176 +0,0 @@
1
- """The ``@trace`` decorator — synchronous and async (arch §4–5, guide Phases 6, 8).
2
-
3
- Opens a span on enter and closes it on exit (success *or* exception), maintaining the
4
- trace/parent hierarchy from the context stack, then flushes the finished span straight to
5
- the configured sink. The decorator is **non-swallowing**: it records the failure and
6
- re-raises the original exception unchanged (arch §4).
7
-
8
- At decoration time :func:`asyncio.iscoroutinefunction` selects a sync or async wrapper. The
9
- two are deliberate near-duplicates — the only difference is ``await fn(...)`` — because the
10
- sync/async split is a hard boundary; ``contextvars`` already propagates the span stack and
11
- baggage correctly across ``await`` points and concurrent tasks (arch §5), so the async span
12
- opens when the coroutine actually runs and closes when it finishes, with no new machinery.
13
-
14
- Flushing is synchronous here; the background worker (SPEC-004) later swaps only
15
- :func:`_flush` for a non-blocking handoff — the lifecycle below is untouched.
16
- """
17
-
18
- from __future__ import annotations
19
-
20
- import asyncio
21
- import atexit
22
- import functools
23
- import threading
24
- from collections.abc import Callable
25
- from time import monotonic
26
- from typing import Any, TypeVar, cast, overload
27
-
28
- from log_foundry import context
29
- from log_foundry.config import _ensure_sink
30
- from log_foundry.ids import new_span_id, new_trace_id
31
- from log_foundry.model import Span, end_event, start_event
32
- from log_foundry.worker import Worker
33
-
34
- __all__ = ["trace"]
35
-
36
- # One background worker per process (SPEC-004), created lazily from the configured sink on the
37
- # first flush. The double-checked lock makes concurrent first-flushes create exactly one.
38
- _worker: Worker | None = None
39
- _worker_lock = threading.Lock()
40
- _atexit_registered = False # register the drain exactly once per process, not per worker
41
-
42
- F = TypeVar("F", bound=Callable[..., Any])
43
-
44
-
45
- def _open_span(name: str, defaults: dict[str, object] | None) -> Span:
46
- """Mint a span, inheriting the trace/parent from the current context (arch §3)."""
47
- parent = context.current_span()
48
- span = Span(
49
- trace_id=parent.trace_id if parent else new_trace_id(),
50
- span_id=new_span_id(),
51
- parent_span_id=parent.span_id if parent else None,
52
- name=name,
53
- start_ts=monotonic(),
54
- defaults=defaults or {},
55
- )
56
- span.events.append(start_event(span))
57
- return span
58
-
59
-
60
- def _get_worker() -> Worker:
61
- """Return the process worker, creating it lazily from the configured sink (FR-006).
62
-
63
- Registers the graceful drain via ``atexit`` exactly once, on first creation, so a program
64
- that logs and exits immediately still flushes its buffered events.
65
- """
66
- global _worker, _atexit_registered
67
- if _worker is None:
68
- with _worker_lock:
69
- if _worker is None:
70
- if not _atexit_registered:
71
- atexit.register(_shutdown_worker)
72
- _atexit_registered = True
73
- _worker = Worker(_ensure_sink())
74
- return _worker
75
-
76
-
77
- def _shutdown_worker() -> None:
78
- """Drain + close the process worker if one was created (idempotent). Backs ``shutdown()``."""
79
- if _worker is not None:
80
- _worker.shutdown()
81
-
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
-
102
- def _flush(span: Span) -> None:
103
- """Hand the finished span's events to the background worker — non-blocking (FR-001).
104
-
105
- Resolves/creates the worker via :func:`_get_worker` (whose sink comes from ``_ensure_sink``,
106
- so a zero-config ``@trace`` still falls back to ``StdoutSink`` rather than crashing).
107
- """
108
- _get_worker().submit(span.events)
109
-
110
-
111
- def _close_span(span: Span, status: str, exc: BaseException | None) -> None:
112
- """Append the end event (so the flushed queue is complete), then flush."""
113
- span.events.append(end_event(span, status, exc))
114
- _flush(span)
115
-
116
-
117
- @overload
118
- def trace(func: F) -> F: ...
119
- @overload
120
- def trace(
121
- *, name: str | None = ..., defaults: dict[str, object] | None = ...
122
- ) -> Callable[[F], F]: ...
123
-
124
-
125
- def trace(
126
- func: F | None = None,
127
- *,
128
- name: str | None = None,
129
- defaults: dict[str, object] | None = None,
130
- ) -> F | Callable[[F], F]:
131
- """Trace a function call as a span. Usable bare (``@trace``) or with args.
132
-
133
- ``@trace(name=..., defaults=...)`` overrides the span name (default:
134
- ``func.__qualname__``) and adds per-decorator default fields.
135
- """
136
-
137
- def decorate(fn: F) -> F:
138
- if asyncio.iscoroutinefunction(fn):
139
-
140
- @functools.wraps(fn)
141
- async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
142
- span = _open_span(name or fn.__qualname__, defaults)
143
- token = context.push_span(span)
144
- try:
145
- result = await fn(*args, **kwargs)
146
- _close_span(span, "ok", None)
147
- return result
148
- except BaseException as exc:
149
- # Non-swallowing (arch §4): record the error, then re-raise unchanged.
150
- # BaseException covers asyncio.CancelledError — a cancelled coroutine is
151
- # recorded as an error end event, never left with an unclosed span.
152
- _close_span(span, "error", exc)
153
- raise
154
- finally:
155
- context.pop_span(token)
156
-
157
- return cast(F, async_wrapper)
158
-
159
- @functools.wraps(fn)
160
- def wrapper(*args: Any, **kwargs: Any) -> Any:
161
- span = _open_span(name or fn.__qualname__, defaults)
162
- token = context.push_span(span)
163
- try:
164
- result = fn(*args, **kwargs)
165
- _close_span(span, "ok", None)
166
- return result
167
- except BaseException as exc:
168
- # Non-swallowing (arch §4): record the error, then re-raise unchanged.
169
- _close_span(span, "error", exc)
170
- raise
171
- finally:
172
- context.pop_span(token)
173
-
174
- return cast(F, wrapper)
175
-
176
- return decorate(func) if func is not None else decorate
@@ -1,29 +0,0 @@
1
- """W3C Trace Context-compatible id generation (arch §3.1, guide Phase 2).
2
-
3
- ``trace_id`` and ``span_id`` use the W3C wire formats (not arbitrary UUIDs) so a future
4
- "adopt the inbound request's trace" feature is just a header parse (arch §12) and the
5
- emitted records stay compatible with standard distributed-tracing tooling. ``log_id`` is
6
- internal-only, so a UUID is fine.
7
- """
8
-
9
- from __future__ import annotations
10
-
11
- import os
12
- import uuid
13
-
14
- __all__ = ["new_trace_id", "new_span_id", "new_log_id"]
15
-
16
-
17
- def new_trace_id() -> str:
18
- """Return a fresh trace id: 32 lowercase hex chars (16 random bytes)."""
19
- return os.urandom(16).hex()
20
-
21
-
22
- def new_span_id() -> str:
23
- """Return a fresh span id: 16 lowercase hex chars (8 random bytes)."""
24
- return os.urandom(8).hex()
25
-
26
-
27
- def new_log_id() -> str:
28
- """Return a fresh log id: a UUID4 hex string (internal-only, format is our choice)."""
29
- return uuid.uuid4().hex