log-foundry 0.5.1.dev6__tar.gz → 0.5.1.dev8__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.
- {log_foundry-0.5.1.dev6 → log_foundry-0.5.1.dev8}/PKG-INFO +1 -1
- {log_foundry-0.5.1.dev6 → log_foundry-0.5.1.dev8}/pyproject.toml +1 -1
- {log_foundry-0.5.1.dev6 → log_foundry-0.5.1.dev8}/src/log_foundry/model.py +95 -9
- {log_foundry-0.5.1.dev6 → log_foundry-0.5.1.dev8}/src/log_foundry/sinks/multi.py +26 -1
- {log_foundry-0.5.1.dev6 → log_foundry-0.5.1.dev8}/LICENSE +0 -0
- {log_foundry-0.5.1.dev6 → log_foundry-0.5.1.dev8}/README.md +0 -0
- {log_foundry-0.5.1.dev6 → log_foundry-0.5.1.dev8}/src/log_foundry/__init__.py +0 -0
- {log_foundry-0.5.1.dev6 → log_foundry-0.5.1.dev8}/src/log_foundry/api.py +0 -0
- {log_foundry-0.5.1.dev6 → log_foundry-0.5.1.dev8}/src/log_foundry/config.py +0 -0
- {log_foundry-0.5.1.dev6 → log_foundry-0.5.1.dev8}/src/log_foundry/console.py +0 -0
- {log_foundry-0.5.1.dev6 → log_foundry-0.5.1.dev8}/src/log_foundry/context.py +0 -0
- {log_foundry-0.5.1.dev6 → log_foundry-0.5.1.dev8}/src/log_foundry/decorator.py +0 -0
- {log_foundry-0.5.1.dev6 → log_foundry-0.5.1.dev8}/src/log_foundry/ids.py +0 -0
- {log_foundry-0.5.1.dev6 → log_foundry-0.5.1.dev8}/src/log_foundry/py.typed +0 -0
- {log_foundry-0.5.1.dev6 → log_foundry-0.5.1.dev8}/src/log_foundry/sanitize.py +0 -0
- {log_foundry-0.5.1.dev6 → log_foundry-0.5.1.dev8}/src/log_foundry/sinks/__init__.py +0 -0
- {log_foundry-0.5.1.dev6 → log_foundry-0.5.1.dev8}/src/log_foundry/sinks/_chunk.py +0 -0
- {log_foundry-0.5.1.dev6 → log_foundry-0.5.1.dev8}/src/log_foundry/sinks/_socket.py +0 -0
- {log_foundry-0.5.1.dev6 → log_foundry-0.5.1.dev8}/src/log_foundry/sinks/_time.py +0 -0
- {log_foundry-0.5.1.dev6 → log_foundry-0.5.1.dev8}/src/log_foundry/sinks/base.py +0 -0
- {log_foundry-0.5.1.dev6 → log_foundry-0.5.1.dev8}/src/log_foundry/sinks/callback.py +0 -0
- {log_foundry-0.5.1.dev6 → log_foundry-0.5.1.dev8}/src/log_foundry/sinks/clickhouse.py +0 -0
- {log_foundry-0.5.1.dev6 → log_foundry-0.5.1.dev8}/src/log_foundry/sinks/datadog.py +0 -0
- {log_foundry-0.5.1.dev6 → log_foundry-0.5.1.dev8}/src/log_foundry/sinks/elasticsearch.py +0 -0
- {log_foundry-0.5.1.dev6 → log_foundry-0.5.1.dev8}/src/log_foundry/sinks/eventhubs.py +0 -0
- {log_foundry-0.5.1.dev6 → log_foundry-0.5.1.dev8}/src/log_foundry/sinks/file.py +0 -0
- {log_foundry-0.5.1.dev6 → log_foundry-0.5.1.dev8}/src/log_foundry/sinks/filtering.py +0 -0
- {log_foundry-0.5.1.dev6 → log_foundry-0.5.1.dev8}/src/log_foundry/sinks/firehose.py +0 -0
- {log_foundry-0.5.1.dev6 → log_foundry-0.5.1.dev8}/src/log_foundry/sinks/honeycomb.py +0 -0
- {log_foundry-0.5.1.dev6 → log_foundry-0.5.1.dev8}/src/log_foundry/sinks/http.py +0 -0
- {log_foundry-0.5.1.dev6 → log_foundry-0.5.1.dev8}/src/log_foundry/sinks/kafka.py +0 -0
- {log_foundry-0.5.1.dev6 → log_foundry-0.5.1.dev8}/src/log_foundry/sinks/kinesis.py +0 -0
- {log_foundry-0.5.1.dev6 → log_foundry-0.5.1.dev8}/src/log_foundry/sinks/logging_sink.py +0 -0
- {log_foundry-0.5.1.dev6 → log_foundry-0.5.1.dev8}/src/log_foundry/sinks/logstash.py +0 -0
- {log_foundry-0.5.1.dev6 → log_foundry-0.5.1.dev8}/src/log_foundry/sinks/loki.py +0 -0
- {log_foundry-0.5.1.dev6 → log_foundry-0.5.1.dev8}/src/log_foundry/sinks/mongodb.py +0 -0
- {log_foundry-0.5.1.dev6 → log_foundry-0.5.1.dev8}/src/log_foundry/sinks/nats.py +0 -0
- {log_foundry-0.5.1.dev6 → log_foundry-0.5.1.dev8}/src/log_foundry/sinks/newrelic.py +0 -0
- {log_foundry-0.5.1.dev6 → log_foundry-0.5.1.dev8}/src/log_foundry/sinks/postgres.py +0 -0
- {log_foundry-0.5.1.dev6 → log_foundry-0.5.1.dev8}/src/log_foundry/sinks/pubsub.py +0 -0
- {log_foundry-0.5.1.dev6 → log_foundry-0.5.1.dev8}/src/log_foundry/sinks/rabbitmq.py +0 -0
- {log_foundry-0.5.1.dev6 → log_foundry-0.5.1.dev8}/src/log_foundry/sinks/redis.py +0 -0
- {log_foundry-0.5.1.dev6 → log_foundry-0.5.1.dev8}/src/log_foundry/sinks/sentry.py +0 -0
- {log_foundry-0.5.1.dev6 → log_foundry-0.5.1.dev8}/src/log_foundry/sinks/sns.py +0 -0
- {log_foundry-0.5.1.dev6 → log_foundry-0.5.1.dev8}/src/log_foundry/sinks/splunk.py +0 -0
- {log_foundry-0.5.1.dev6 → log_foundry-0.5.1.dev8}/src/log_foundry/sinks/sqlite.py +0 -0
- {log_foundry-0.5.1.dev6 → log_foundry-0.5.1.dev8}/src/log_foundry/sinks/sqs.py +0 -0
- {log_foundry-0.5.1.dev6 → log_foundry-0.5.1.dev8}/src/log_foundry/sinks/stdout.py +0 -0
- {log_foundry-0.5.1.dev6 → log_foundry-0.5.1.dev8}/src/log_foundry/sinks/syslog.py +0 -0
- {log_foundry-0.5.1.dev6 → log_foundry-0.5.1.dev8}/src/log_foundry/sinks/transform.py +0 -0
- {log_foundry-0.5.1.dev6 → log_foundry-0.5.1.dev8}/src/log_foundry/sinks/util.py +0 -0
- {log_foundry-0.5.1.dev6 → log_foundry-0.5.1.dev8}/src/log_foundry/worker.py +0 -0
|
@@ -12,6 +12,12 @@ import time
|
|
|
12
12
|
import traceback
|
|
13
13
|
from dataclasses import dataclass, field
|
|
14
14
|
from datetime import datetime, timezone
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
from log_foundry.sanitize import sanitize_fields, truncate_str, truncate_tail
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from log_foundry.config import Config
|
|
15
21
|
|
|
16
22
|
__all__ = ["Span", "build_event", "start_event", "end_event", "backfill_baggage"]
|
|
17
23
|
|
|
@@ -20,6 +26,11 @@ __all__ = ["Span", "build_event", "start_event", "end_event", "backfill_baggage"
|
|
|
20
26
|
_START_MESSAGE = "span.start"
|
|
21
27
|
_END_MESSAGE = "span.end"
|
|
22
28
|
|
|
29
|
+
# Marks an event that a ceiling clipped (SPEC-017 FR-002). Set **only ever to True**, never to
|
|
30
|
+
# False — that single invariant is what makes "OR with whatever an earlier stage set" and
|
|
31
|
+
# "absent, not false, on a clean event" both fall out with no read-modify-write anywhere.
|
|
32
|
+
_TRUNCATED = "truncated"
|
|
33
|
+
|
|
23
34
|
|
|
24
35
|
@dataclass
|
|
25
36
|
class Span:
|
|
@@ -57,16 +68,22 @@ def build_event(
|
|
|
57
68
|
|
|
58
69
|
Field precedence, lowest to highest: config ``defaults`` → ``span.defaults`` → ``baggage``
|
|
59
70
|
→ per-call ``fields`` (arch §5.1). Later sources win on a key conflict.
|
|
71
|
+
|
|
72
|
+
The merged mapping is coerced and size-bounded here (SPEC-017 FR-001/FR-002), which is what
|
|
73
|
+
makes every sink's bare ``json.dumps`` safe without any of them changing. ``message`` is
|
|
74
|
+
bounded too: it is a base field, but unlike the other eleven it is caller-supplied free text.
|
|
60
75
|
"""
|
|
61
76
|
from log_foundry.config import get_config
|
|
62
77
|
from log_foundry.ids import new_log_id
|
|
63
78
|
|
|
64
79
|
cfg = get_config()
|
|
65
80
|
merged: dict[str, object] = {**cfg.defaults, **span.defaults, **baggage, **fields}
|
|
66
|
-
|
|
81
|
+
safe, clipped = sanitize_fields(merged, cfg=cfg)
|
|
82
|
+
bounded_message, message_clipped = truncate_str(message, cfg.max_value_bytes)
|
|
83
|
+
event: dict[str, object] = {
|
|
67
84
|
"timestamp": _iso_now(),
|
|
68
85
|
"level": level,
|
|
69
|
-
"message":
|
|
86
|
+
"message": bounded_message,
|
|
70
87
|
"trace_id": span.trace_id,
|
|
71
88
|
"span_id": span.span_id,
|
|
72
89
|
"parent_span_id": span.parent_span_id,
|
|
@@ -75,8 +92,12 @@ def build_event(
|
|
|
75
92
|
"service": cfg.service,
|
|
76
93
|
"version": cfg.version,
|
|
77
94
|
"env": cfg.env,
|
|
78
|
-
"fields": merged,
|
|
79
95
|
}
|
|
96
|
+
# Before ``fields`` so it reads ahead of the payload blob in a rendered log line.
|
|
97
|
+
if clipped or message_clipped:
|
|
98
|
+
event[_TRUNCATED] = True
|
|
99
|
+
event["fields"] = safe
|
|
100
|
+
return event
|
|
80
101
|
|
|
81
102
|
|
|
82
103
|
def start_event(span: Span) -> dict[str, object]:
|
|
@@ -84,6 +105,57 @@ def start_event(span: Span) -> dict[str, object]:
|
|
|
84
105
|
return build_event(span, "INFO", _START_MESSAGE, fields={}, baggage={})
|
|
85
106
|
|
|
86
107
|
|
|
108
|
+
def _exception_message(exc: BaseException) -> str:
|
|
109
|
+
"""``str(exc)`` — ``""`` for an exception raised with no arguments (SPEC-017 FR-003).
|
|
110
|
+
|
|
111
|
+
Guarded because a user exception may define a ``__str__`` that itself raises. This runs
|
|
112
|
+
inside the decorator's ``except`` block, so an exception escaping here would *replace* the
|
|
113
|
+
one the user's code raised, demoting theirs to ``__context__`` — logging breaking the app,
|
|
114
|
+
which is the thing this spec exists to stop.
|
|
115
|
+
"""
|
|
116
|
+
try:
|
|
117
|
+
return str(exc)
|
|
118
|
+
except Exception: # noqa: BLE001 — see above; a hostile __str__ must not escape.
|
|
119
|
+
return "<unprintable message>"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _exception_stack(exc: BaseException) -> str:
|
|
123
|
+
"""The formatted traceback.
|
|
124
|
+
|
|
125
|
+
``traceback.format_exception`` already renders a failing ``__str__`` as
|
|
126
|
+
``<exception str() failed>`` rather than propagating, so this guard is for the rarer case of
|
|
127
|
+
a frame that cannot be rendered at all.
|
|
128
|
+
"""
|
|
129
|
+
try:
|
|
130
|
+
return "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
|
|
131
|
+
except Exception: # noqa: BLE001 — same reasoning as _exception_message.
|
|
132
|
+
return f"<unformattable traceback: {type(exc).__name__}>"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _error_fields(exc: BaseException, *, cfg: Config) -> tuple[dict[str, object], bool]:
|
|
136
|
+
"""Build the bounded ``error`` sub-document; report whether a ceiling fired.
|
|
137
|
+
|
|
138
|
+
``type`` keeps the bare class name consumers already index; ``module`` is added alongside it
|
|
139
|
+
so two same-named exception classes from different packages stay distinguishable, rather
|
|
140
|
+
than qualifying ``type`` in place and breaking every existing query (SPEC-017 FR-003).
|
|
141
|
+
|
|
142
|
+
``stack`` gets its own, larger ceiling: a traceback is legitimately long and is the most
|
|
143
|
+
valuable thing on the event. It is clipped from the *head*, because ``format_exception``
|
|
144
|
+
puts the exception and the innermost frames last.
|
|
145
|
+
"""
|
|
146
|
+
type_name, t1 = truncate_str(type(exc).__name__, cfg.max_value_bytes)
|
|
147
|
+
module, t2 = truncate_str(str(getattr(type(exc), "__module__", "")), cfg.max_value_bytes)
|
|
148
|
+
message, t3 = truncate_str(_exception_message(exc), cfg.max_value_bytes)
|
|
149
|
+
stack, t4 = truncate_tail(_exception_stack(exc), cfg.max_stack_bytes)
|
|
150
|
+
error: dict[str, object] = {
|
|
151
|
+
"type": type_name,
|
|
152
|
+
"module": module,
|
|
153
|
+
"message": message,
|
|
154
|
+
"stack": stack,
|
|
155
|
+
}
|
|
156
|
+
return error, (t1 or t2 or t3 or t4)
|
|
157
|
+
|
|
158
|
+
|
|
87
159
|
def end_event(
|
|
88
160
|
span: Span,
|
|
89
161
|
status: str,
|
|
@@ -92,17 +164,20 @@ def end_event(
|
|
|
92
164
|
"""Build the span-end boundary event.
|
|
93
165
|
|
|
94
166
|
Adds ``duration_ms`` (from a monotonic delta), ``status`` (``"ok"``/``"error"``), and on
|
|
95
|
-
failure a nested ``error`` with the exception type and formatted stack
|
|
167
|
+
failure a nested ``error`` with the exception type, module, message and formatted stack
|
|
168
|
+
(arch §6, SPEC-017 FR-003).
|
|
96
169
|
"""
|
|
170
|
+
from log_foundry.config import get_config
|
|
171
|
+
|
|
97
172
|
level = "INFO" if status == "ok" else "ERROR"
|
|
98
173
|
event = build_event(span, level, _END_MESSAGE, fields={}, baggage={})
|
|
99
174
|
event["duration_ms"] = (time.monotonic() - span.start_ts) * 1000.0
|
|
100
175
|
event["status"] = status
|
|
101
176
|
if exc is not None:
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
177
|
+
error, clipped = _error_fields(exc, cfg=get_config())
|
|
178
|
+
event["error"] = error
|
|
179
|
+
if clipped:
|
|
180
|
+
event[_TRUNCATED] = True # only ever True — this *is* the OR with build_event's
|
|
106
181
|
return event
|
|
107
182
|
|
|
108
183
|
|
|
@@ -123,13 +198,24 @@ def backfill_baggage(span: Span, baggage: dict[str, object]) -> None:
|
|
|
123
198
|
|
|
124
199
|
The baggage is a parameter rather than read from ``context``: this module does not know where
|
|
125
200
|
the current span lives (arch §6).
|
|
201
|
+
|
|
202
|
+
These values bypass :func:`build_event`'s pass entirely — ``set_baggage`` accepts arbitrary
|
|
203
|
+
objects — so they are coerced here, **once** above the loop rather than per event: the same
|
|
204
|
+
mapping is merged into every boundary event, and ``fields`` is already sanitized. The merge
|
|
205
|
+
can push a mapping past ``max_keys``; that is deliberate, because re-capping here would drop
|
|
206
|
+
the correlation keys SPEC-015 shipped to add (SPEC-017 FR-001).
|
|
126
207
|
"""
|
|
127
208
|
if not baggage:
|
|
128
209
|
return
|
|
210
|
+
from log_foundry.config import get_config
|
|
211
|
+
|
|
212
|
+
safe, clipped = sanitize_fields(baggage, cfg=get_config())
|
|
129
213
|
for event in span.events:
|
|
130
214
|
# Matched on the message constants, not a position — that ``span.start`` is index 0 is an
|
|
131
215
|
# implementation detail of when it happens to be appended.
|
|
132
216
|
if event.get("message") in (_START_MESSAGE, _END_MESSAGE):
|
|
133
217
|
fields = event.get("fields")
|
|
134
218
|
if isinstance(fields, dict):
|
|
135
|
-
event["fields"] = {**fields, **
|
|
219
|
+
event["fields"] = {**fields, **safe}
|
|
220
|
+
if clipped:
|
|
221
|
+
event[_TRUNCATED] = True
|
|
@@ -6,6 +6,12 @@ whose ``emit`` (or ``close``) raises is isolated — the failure is counted on `
|
|
|
6
6
|
logged to stderr, and its siblings still run — so one broken destination never fails the whole
|
|
7
7
|
fan-out or the worker's retry. This mirrors the worker's own survive-a-sink-failure isolation
|
|
8
8
|
boundary (arch §9; best-practices §7 sanctions the broad catch here).
|
|
9
|
+
|
|
10
|
+
The one exception is **total** failure: if every child raised, ``emit`` re-raises rather than
|
|
11
|
+
reporting success, so the worker's retry engages (SPEC-017 FR-004). Swallowing there meant a
|
|
12
|
+
``MultiSink`` whose destinations were all down reported healthy on every batch and the loss was
|
|
13
|
+
invisible. ``close()`` keeps the unconditional isolate-and-continue behaviour — a failed close
|
|
14
|
+
has nothing to retry.
|
|
9
15
|
"""
|
|
10
16
|
|
|
11
17
|
from __future__ import annotations
|
|
@@ -29,16 +35,35 @@ class MultiSink:
|
|
|
29
35
|
self.failed = 0
|
|
30
36
|
|
|
31
37
|
def emit(self, batch: list[dict[str, object]]) -> None:
|
|
32
|
-
"""Forward ``batch`` to every child in construction order, isolating failures (FR-002).
|
|
38
|
+
"""Forward ``batch`` to every child in construction order, isolating failures (FR-002).
|
|
39
|
+
|
|
40
|
+
When **every** child failed, re-raise the first child's exception so the worker's bounded
|
|
41
|
+
retry sees the total loss (SPEC-017 FR-004). Partial success stays isolated: a retry there
|
|
42
|
+
would re-deliver the batch to the children that already took it, and duplicates are worse
|
|
43
|
+
than the one failure already counted on ``failed`` and written to stderr. Total failure
|
|
44
|
+
delivered nothing, so it has no duplicates to create and is the one case worth retrying.
|
|
45
|
+
"""
|
|
46
|
+
first_error: Exception | None = None
|
|
47
|
+
delivered = 0
|
|
33
48
|
for sink in self._sinks:
|
|
34
49
|
try:
|
|
35
50
|
sink.emit(batch)
|
|
36
51
|
except Exception as err: # isolation boundary: one child must not fail the rest
|
|
37
52
|
self.failed += 1
|
|
53
|
+
if first_error is None:
|
|
54
|
+
first_error = err
|
|
38
55
|
sys.stderr.write(
|
|
39
56
|
f"log-foundry: MultiSink child {type(sink).__name__}.emit "
|
|
40
57
|
f"failed and was skipped: {err!r}\n"
|
|
41
58
|
)
|
|
59
|
+
else:
|
|
60
|
+
delivered += 1
|
|
61
|
+
if delivered == 0 and first_error is not None:
|
|
62
|
+
# Raised outside any ``except`` block, so ``__context__`` is untouched and the
|
|
63
|
+
# exception propagates by identity with its original traceback. The ``is not None``
|
|
64
|
+
# is also what keeps an empty ``MultiSink`` a no-op rather than a raise — otherwise a
|
|
65
|
+
# misconfigured fan-out with no children would retry every batch to exhaustion.
|
|
66
|
+
raise first_error
|
|
42
67
|
|
|
43
68
|
def close(self) -> None:
|
|
44
69
|
"""Close every child, isolating a failing child so the rest still close (FR-002)."""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|