log-foundry 0.2.1.dev3__py3-none-any.whl → 0.2.1.dev5__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 +42 -3
- log_foundry/context.py +98 -0
- log_foundry/decorator.py +160 -5
- log_foundry/ids.py +86 -6
- log_foundry/worker.py +79 -0
- {log_foundry-0.2.1.dev3.dist-info → log_foundry-0.2.1.dev5.dist-info}/METADATA +133 -5
- {log_foundry-0.2.1.dev3.dist-info → log_foundry-0.2.1.dev5.dist-info}/RECORD +9 -9
- {log_foundry-0.2.1.dev3.dist-info → log_foundry-0.2.1.dev5.dist-info}/WHEEL +0 -0
- {log_foundry-0.2.1.dev3.dist-info → log_foundry-0.2.1.dev5.dist-info}/licenses/LICENSE +0 -0
log_foundry/__init__.py
CHANGED
|
@@ -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``,
|
|
5
|
-
|
|
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.
|
|
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").
|
|
@@ -19,11 +26,38 @@ except PackageNotFoundError: # running from a source tree that isn't installed
|
|
|
19
26
|
__version__ = "0.0.0"
|
|
20
27
|
|
|
21
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
|
+
|
|
22
52
|
def shutdown() -> None:
|
|
23
53
|
"""Flush buffered events and close the sink, blocking until drained. Idempotent.
|
|
24
54
|
|
|
25
55
|
Also registered via ``atexit``; call it explicitly before a fast process exit when you
|
|
26
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.
|
|
27
61
|
"""
|
|
28
62
|
from log_foundry.decorator import _shutdown_worker
|
|
29
63
|
|
|
@@ -40,6 +74,11 @@ __all__ = [
|
|
|
40
74
|
"error",
|
|
41
75
|
"critical",
|
|
42
76
|
"set_baggage",
|
|
77
|
+
"continue_trace",
|
|
78
|
+
"current_traceparent",
|
|
79
|
+
"current_trace_context",
|
|
80
|
+
"current_baggage_header",
|
|
81
|
+
"flush",
|
|
43
82
|
"shutdown",
|
|
44
83
|
"__version__",
|
|
45
84
|
]
|
log_foundry/context.py
CHANGED
|
@@ -16,6 +16,9 @@ from __future__ import annotations
|
|
|
16
16
|
|
|
17
17
|
import contextvars
|
|
18
18
|
from typing import TYPE_CHECKING
|
|
19
|
+
from urllib.parse import quote, unquote
|
|
20
|
+
|
|
21
|
+
from log_foundry.ids import format_traceparent
|
|
19
22
|
|
|
20
23
|
if TYPE_CHECKING:
|
|
21
24
|
from log_foundry.model import Span
|
|
@@ -26,6 +29,9 @@ __all__ = [
|
|
|
26
29
|
"pop_span",
|
|
27
30
|
"get_baggage",
|
|
28
31
|
"set_baggage",
|
|
32
|
+
"current_traceparent",
|
|
33
|
+
"current_trace_context",
|
|
34
|
+
"current_baggage_header",
|
|
29
35
|
]
|
|
30
36
|
|
|
31
37
|
_span_stack: contextvars.ContextVar[tuple[Span, ...]] = contextvars.ContextVar(
|
|
@@ -34,6 +40,16 @@ _span_stack: contextvars.ContextVar[tuple[Span, ...]] = contextvars.ContextVar(
|
|
|
34
40
|
_baggage: contextvars.ContextVar[dict[str, object]] = contextvars.ContextVar(
|
|
35
41
|
"log_foundry_baggage", default={}
|
|
36
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
|
|
37
53
|
|
|
38
54
|
|
|
39
55
|
def current_span() -> Span | None:
|
|
@@ -60,3 +76,85 @@ def get_baggage() -> dict[str, object]:
|
|
|
60
76
|
def set_baggage(**kv: object) -> None:
|
|
61
77
|
"""Merge key/values into the current trace's baggage (replaces with a new dict)."""
|
|
62
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
|
log_foundry/decorator.py
CHANGED
|
@@ -13,6 +13,10 @@ opens when the coroutine actually runs and closes when it finishes, with no new
|
|
|
13
13
|
|
|
14
14
|
Flushing is synchronous here; the background worker (SPEC-004) later swaps only
|
|
15
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``.
|
|
16
20
|
"""
|
|
17
21
|
|
|
18
22
|
from __future__ import annotations
|
|
@@ -20,6 +24,7 @@ from __future__ import annotations
|
|
|
20
24
|
import asyncio
|
|
21
25
|
import atexit
|
|
22
26
|
import functools
|
|
27
|
+
import sys
|
|
23
28
|
import threading
|
|
24
29
|
from collections.abc import Callable
|
|
25
30
|
from time import monotonic
|
|
@@ -27,11 +32,20 @@ from typing import Any, TypeVar, cast, overload
|
|
|
27
32
|
|
|
28
33
|
from log_foundry import context
|
|
29
34
|
from log_foundry.config import _ensure_sink
|
|
30
|
-
from log_foundry.ids import
|
|
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
|
+
)
|
|
31
42
|
from log_foundry.model import Span, end_event, start_event
|
|
32
43
|
from log_foundry.worker import Worker
|
|
33
44
|
|
|
34
|
-
__all__ = ["trace"]
|
|
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
|
|
35
49
|
|
|
36
50
|
# One background worker per process (SPEC-004), created lazily from the configured sink on the
|
|
37
51
|
# first flush. The double-checked lock makes concurrent first-flushes create exactly one.
|
|
@@ -43,12 +57,24 @@ F = TypeVar("F", bound=Callable[..., Any])
|
|
|
43
57
|
|
|
44
58
|
|
|
45
59
|
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).
|
|
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
|
+
"""
|
|
47
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)
|
|
48
74
|
span = Span(
|
|
49
|
-
trace_id=
|
|
75
|
+
trace_id=trace_id,
|
|
50
76
|
span_id=new_span_id(),
|
|
51
|
-
parent_span_id=
|
|
77
|
+
parent_span_id=parent_span_id,
|
|
52
78
|
name=name,
|
|
53
79
|
start_ts=monotonic(),
|
|
54
80
|
defaults=defaults or {},
|
|
@@ -57,6 +83,116 @@ def _open_span(name: str, defaults: dict[str, object] | None) -> Span:
|
|
|
57
83
|
return span
|
|
58
84
|
|
|
59
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
|
+
|
|
60
196
|
def _get_worker() -> Worker:
|
|
61
197
|
"""Return the process worker, creating it lazily from the configured sink (FR-006).
|
|
62
198
|
|
|
@@ -80,6 +216,25 @@ def _shutdown_worker() -> None:
|
|
|
80
216
|
_worker.shutdown()
|
|
81
217
|
|
|
82
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
|
+
|
|
83
238
|
def _flush(span: Span) -> None:
|
|
84
239
|
"""Hand the finished span's events to the background worker — non-blocking (FR-001).
|
|
85
240
|
|
log_foundry/ids.py
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
|
-
"""W3C Trace Context-compatible id generation (arch §3.1,
|
|
1
|
+
"""W3C Trace Context-compatible id generation and ``traceparent`` codec (arch §3.1, §12).
|
|
2
2
|
|
|
3
|
-
``trace_id`` and ``span_id`` use the W3C wire formats (not arbitrary UUIDs) so
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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.
|
|
7
12
|
"""
|
|
8
13
|
|
|
9
14
|
from __future__ import annotations
|
|
@@ -11,7 +16,26 @@ from __future__ import annotations
|
|
|
11
16
|
import os
|
|
12
17
|
import uuid
|
|
13
18
|
|
|
14
|
-
__all__ = [
|
|
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"
|
|
15
39
|
|
|
16
40
|
|
|
17
41
|
def new_trace_id() -> str:
|
|
@@ -27,3 +51,59 @@ def new_span_id() -> str:
|
|
|
27
51
|
def new_log_id() -> str:
|
|
28
52
|
"""Return a fresh log id: a UUID4 hex string (internal-only, format is our choice)."""
|
|
29
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}"
|
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.
|
|
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.
|
|
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
|
|
507
|
-
end of a short script
|
|
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
|
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
log_foundry/__init__.py,sha256=
|
|
1
|
+
log_foundry/__init__.py,sha256=EKW6sdftIsRGPFYgFQBtStObf5aT5Fhpcup10D4l-o4,3287
|
|
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
|
-
log_foundry/context.py,sha256=
|
|
6
|
-
log_foundry/decorator.py,sha256=
|
|
7
|
-
log_foundry/ids.py,sha256=
|
|
5
|
+
log_foundry/context.py,sha256=Pq1H5eL8ZMWSw1PgrVi-NHERjBbstLcDr9ANMRPWsxM,6565
|
|
6
|
+
log_foundry/decorator.py,sha256=5UVsol6P2im0qOqrA6zeEe8Re1DLK-NuesOTfmL-Mzo,13566
|
|
7
|
+
log_foundry/ids.py,sha256=68hwM9NwliY4pvSMjMpJeTkQybEjDhqnfD7ko5WFMBo,4541
|
|
8
8
|
log_foundry/model.py,sha256=yL8RRBkHcPvTut5XfVNnm4vztN2YkRIAfFaC6i-U8qY,3591
|
|
9
9
|
log_foundry/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
10
|
log_foundry/sinks/__init__.py,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=
|
|
48
|
-
log_foundry-0.2.1.
|
|
49
|
-
log_foundry-0.2.1.
|
|
50
|
-
log_foundry-0.2.1.
|
|
51
|
-
log_foundry-0.2.1.
|
|
47
|
+
log_foundry/worker.py,sha256=bbBwMwecVOBu1k6rbSLPwM0zzxN-ebGdy5X2Mo5Q14s,11014
|
|
48
|
+
log_foundry-0.2.1.dev5.dist-info/METADATA,sha256=DVktJPh6HCxVxd-_0Bbh8afWkI_B91pTjdHUKxg8rjs,38154
|
|
49
|
+
log_foundry-0.2.1.dev5.dist-info/WHEEL,sha256=eY7nduwzv-ldUxpzbRlxwvC693Hg6PX8bWDjEHjZ_dk,88
|
|
50
|
+
log_foundry-0.2.1.dev5.dist-info/licenses/LICENSE,sha256=HaCckyCX8P6TR8gY5xyEsFi050Hs2kl-JqaO9Vi0Sw0,1072
|
|
51
|
+
log_foundry-0.2.1.dev5.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|