log-foundry 0.5.1.dev5__tar.gz → 0.5.1.dev7__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.dev5 → log_foundry-0.5.1.dev7}/PKG-INFO +1 -1
- {log_foundry-0.5.1.dev5 → log_foundry-0.5.1.dev7}/pyproject.toml +1 -1
- {log_foundry-0.5.1.dev5 → log_foundry-0.5.1.dev7}/src/log_foundry/config.py +37 -1
- {log_foundry-0.5.1.dev5 → log_foundry-0.5.1.dev7}/src/log_foundry/model.py +95 -9
- log_foundry-0.5.1.dev7/src/log_foundry/sanitize.py +281 -0
- {log_foundry-0.5.1.dev5 → log_foundry-0.5.1.dev7}/LICENSE +0 -0
- {log_foundry-0.5.1.dev5 → log_foundry-0.5.1.dev7}/README.md +0 -0
- {log_foundry-0.5.1.dev5 → log_foundry-0.5.1.dev7}/src/log_foundry/__init__.py +0 -0
- {log_foundry-0.5.1.dev5 → log_foundry-0.5.1.dev7}/src/log_foundry/api.py +0 -0
- {log_foundry-0.5.1.dev5 → log_foundry-0.5.1.dev7}/src/log_foundry/console.py +0 -0
- {log_foundry-0.5.1.dev5 → log_foundry-0.5.1.dev7}/src/log_foundry/context.py +0 -0
- {log_foundry-0.5.1.dev5 → log_foundry-0.5.1.dev7}/src/log_foundry/decorator.py +0 -0
- {log_foundry-0.5.1.dev5 → log_foundry-0.5.1.dev7}/src/log_foundry/ids.py +0 -0
- {log_foundry-0.5.1.dev5 → log_foundry-0.5.1.dev7}/src/log_foundry/py.typed +0 -0
- {log_foundry-0.5.1.dev5 → log_foundry-0.5.1.dev7}/src/log_foundry/sinks/__init__.py +0 -0
- {log_foundry-0.5.1.dev5 → log_foundry-0.5.1.dev7}/src/log_foundry/sinks/_chunk.py +0 -0
- {log_foundry-0.5.1.dev5 → log_foundry-0.5.1.dev7}/src/log_foundry/sinks/_socket.py +0 -0
- {log_foundry-0.5.1.dev5 → log_foundry-0.5.1.dev7}/src/log_foundry/sinks/_time.py +0 -0
- {log_foundry-0.5.1.dev5 → log_foundry-0.5.1.dev7}/src/log_foundry/sinks/base.py +0 -0
- {log_foundry-0.5.1.dev5 → log_foundry-0.5.1.dev7}/src/log_foundry/sinks/callback.py +0 -0
- {log_foundry-0.5.1.dev5 → log_foundry-0.5.1.dev7}/src/log_foundry/sinks/clickhouse.py +0 -0
- {log_foundry-0.5.1.dev5 → log_foundry-0.5.1.dev7}/src/log_foundry/sinks/datadog.py +0 -0
- {log_foundry-0.5.1.dev5 → log_foundry-0.5.1.dev7}/src/log_foundry/sinks/elasticsearch.py +0 -0
- {log_foundry-0.5.1.dev5 → log_foundry-0.5.1.dev7}/src/log_foundry/sinks/eventhubs.py +0 -0
- {log_foundry-0.5.1.dev5 → log_foundry-0.5.1.dev7}/src/log_foundry/sinks/file.py +0 -0
- {log_foundry-0.5.1.dev5 → log_foundry-0.5.1.dev7}/src/log_foundry/sinks/filtering.py +0 -0
- {log_foundry-0.5.1.dev5 → log_foundry-0.5.1.dev7}/src/log_foundry/sinks/firehose.py +0 -0
- {log_foundry-0.5.1.dev5 → log_foundry-0.5.1.dev7}/src/log_foundry/sinks/honeycomb.py +0 -0
- {log_foundry-0.5.1.dev5 → log_foundry-0.5.1.dev7}/src/log_foundry/sinks/http.py +0 -0
- {log_foundry-0.5.1.dev5 → log_foundry-0.5.1.dev7}/src/log_foundry/sinks/kafka.py +0 -0
- {log_foundry-0.5.1.dev5 → log_foundry-0.5.1.dev7}/src/log_foundry/sinks/kinesis.py +0 -0
- {log_foundry-0.5.1.dev5 → log_foundry-0.5.1.dev7}/src/log_foundry/sinks/logging_sink.py +0 -0
- {log_foundry-0.5.1.dev5 → log_foundry-0.5.1.dev7}/src/log_foundry/sinks/logstash.py +0 -0
- {log_foundry-0.5.1.dev5 → log_foundry-0.5.1.dev7}/src/log_foundry/sinks/loki.py +0 -0
- {log_foundry-0.5.1.dev5 → log_foundry-0.5.1.dev7}/src/log_foundry/sinks/mongodb.py +0 -0
- {log_foundry-0.5.1.dev5 → log_foundry-0.5.1.dev7}/src/log_foundry/sinks/multi.py +0 -0
- {log_foundry-0.5.1.dev5 → log_foundry-0.5.1.dev7}/src/log_foundry/sinks/nats.py +0 -0
- {log_foundry-0.5.1.dev5 → log_foundry-0.5.1.dev7}/src/log_foundry/sinks/newrelic.py +0 -0
- {log_foundry-0.5.1.dev5 → log_foundry-0.5.1.dev7}/src/log_foundry/sinks/postgres.py +0 -0
- {log_foundry-0.5.1.dev5 → log_foundry-0.5.1.dev7}/src/log_foundry/sinks/pubsub.py +0 -0
- {log_foundry-0.5.1.dev5 → log_foundry-0.5.1.dev7}/src/log_foundry/sinks/rabbitmq.py +0 -0
- {log_foundry-0.5.1.dev5 → log_foundry-0.5.1.dev7}/src/log_foundry/sinks/redis.py +0 -0
- {log_foundry-0.5.1.dev5 → log_foundry-0.5.1.dev7}/src/log_foundry/sinks/sentry.py +0 -0
- {log_foundry-0.5.1.dev5 → log_foundry-0.5.1.dev7}/src/log_foundry/sinks/sns.py +0 -0
- {log_foundry-0.5.1.dev5 → log_foundry-0.5.1.dev7}/src/log_foundry/sinks/splunk.py +0 -0
- {log_foundry-0.5.1.dev5 → log_foundry-0.5.1.dev7}/src/log_foundry/sinks/sqlite.py +0 -0
- {log_foundry-0.5.1.dev5 → log_foundry-0.5.1.dev7}/src/log_foundry/sinks/sqs.py +0 -0
- {log_foundry-0.5.1.dev5 → log_foundry-0.5.1.dev7}/src/log_foundry/sinks/stdout.py +0 -0
- {log_foundry-0.5.1.dev5 → log_foundry-0.5.1.dev7}/src/log_foundry/sinks/syslog.py +0 -0
- {log_foundry-0.5.1.dev5 → log_foundry-0.5.1.dev7}/src/log_foundry/sinks/transform.py +0 -0
- {log_foundry-0.5.1.dev5 → log_foundry-0.5.1.dev7}/src/log_foundry/sinks/util.py +0 -0
- {log_foundry-0.5.1.dev5 → log_foundry-0.5.1.dev7}/src/log_foundry/worker.py +0 -0
|
@@ -19,18 +19,33 @@ if TYPE_CHECKING:
|
|
|
19
19
|
|
|
20
20
|
@dataclass
|
|
21
21
|
class Config:
|
|
22
|
-
"""Process-wide settings stamped onto every event / consulted by the pipeline.
|
|
22
|
+
"""Process-wide settings stamped onto every event / consulted by the pipeline.
|
|
23
|
+
|
|
24
|
+
The four ``max_*`` ceilings bound every event payload (SPEC-017 FR-002). Defaults are set so
|
|
25
|
+
that the overwhelming majority of events are untouched; they exist to stop *one* pathological
|
|
26
|
+
value getting a whole event rejected by a sink's hard limit.
|
|
27
|
+
"""
|
|
23
28
|
|
|
24
29
|
service: str = "unknown"
|
|
25
30
|
version: str = "0.0.0"
|
|
26
31
|
env: str = "dev"
|
|
27
32
|
sink: Sink | None = None
|
|
28
33
|
defaults: dict[str, object] = field(default_factory=dict)
|
|
34
|
+
max_value_bytes: int = 8192 # per str value, UTF-8 bytes
|
|
35
|
+
max_stack_bytes: int = 32768 # error.stack only — legitimately long, and worth keeping
|
|
36
|
+
max_keys: int = 256 # per mapping / sequence
|
|
37
|
+
max_depth: int = 8 # nesting levels
|
|
29
38
|
|
|
30
39
|
|
|
31
40
|
_config = Config() # module-level singleton; the whole library reads through get_config()
|
|
32
41
|
|
|
33
42
|
|
|
43
|
+
def _require_positive(name: str, value: int | None) -> None:
|
|
44
|
+
"""Reject a non-positive ceiling. A ceiling of zero would empty every event it touched."""
|
|
45
|
+
if value is not None and value < 1:
|
|
46
|
+
raise ValueError(f"{name} must be >= 1, got {value}")
|
|
47
|
+
|
|
48
|
+
|
|
34
49
|
def configure(
|
|
35
50
|
*,
|
|
36
51
|
service: str | None = None,
|
|
@@ -38,13 +53,26 @@ def configure(
|
|
|
38
53
|
env: str | None = None,
|
|
39
54
|
sink: Sink | None = None,
|
|
40
55
|
defaults: dict[str, object] | None = None,
|
|
56
|
+
max_value_bytes: int | None = None,
|
|
57
|
+
max_stack_bytes: int | None = None,
|
|
58
|
+
max_keys: int | None = None,
|
|
59
|
+
max_depth: int | None = None,
|
|
41
60
|
) -> None:
|
|
42
61
|
"""Patch the global config. Call once at startup.
|
|
43
62
|
|
|
44
63
|
Only the arguments you pass are applied, so repeated calls compose rather than reset.
|
|
45
64
|
If no sink has ever been set, defaults to :class:`~log_foundry.sinks.stdout.StdoutSink`
|
|
46
65
|
(the zero-dependency dev default, arch §8) once that phase lands.
|
|
66
|
+
|
|
67
|
+
The four ``max_*`` ceilings bound event payloads (SPEC-017 FR-006); each must be >= 1.
|
|
47
68
|
"""
|
|
69
|
+
# Validate every ceiling *before* assigning anything: a rejected call must leave the config
|
|
70
|
+
# exactly as it found it, not half-applied with `service` set and the ceiling rejected.
|
|
71
|
+
_require_positive("max_value_bytes", max_value_bytes)
|
|
72
|
+
_require_positive("max_stack_bytes", max_stack_bytes)
|
|
73
|
+
_require_positive("max_keys", max_keys)
|
|
74
|
+
_require_positive("max_depth", max_depth)
|
|
75
|
+
|
|
48
76
|
if service is not None:
|
|
49
77
|
_config.service = service
|
|
50
78
|
if version is not None:
|
|
@@ -55,6 +83,14 @@ def configure(
|
|
|
55
83
|
_config.sink = sink
|
|
56
84
|
if defaults is not None:
|
|
57
85
|
_config.defaults = dict(defaults)
|
|
86
|
+
if max_value_bytes is not None:
|
|
87
|
+
_config.max_value_bytes = max_value_bytes
|
|
88
|
+
if max_stack_bytes is not None:
|
|
89
|
+
_config.max_stack_bytes = max_stack_bytes
|
|
90
|
+
if max_keys is not None:
|
|
91
|
+
_config.max_keys = max_keys
|
|
92
|
+
if max_depth is not None:
|
|
93
|
+
_config.max_depth = max_depth
|
|
58
94
|
|
|
59
95
|
_ensure_sink()
|
|
60
96
|
|
|
@@ -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
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""Coercion and size-bounding for event values (SPEC-017 FR-001, FR-002).
|
|
2
|
+
|
|
3
|
+
An event dict must never contain a value ``json.dumps`` would reject, and no value may be
|
|
4
|
+
unbounded. Both guarantees are established **once**, where the event is assembled, rather than
|
|
5
|
+
in each sink: one pass per event instead of one per destination (which matters under
|
|
6
|
+
``MultiSink``), and the guarantee then holds for the non-JSON sinks — ``postgres``, ``mongo``,
|
|
7
|
+
``sqlite`` — for free. Every bare ``json.dumps`` in ``sinks/`` is correct by consequence.
|
|
8
|
+
|
|
9
|
+
Three rules worth knowing before changing anything here:
|
|
10
|
+
|
|
11
|
+
* **Total by contract.** Nothing in this module raises. It runs on the caller's own stack (a
|
|
12
|
+
level call with no active span emits synchronously, ``api.py``), so an exception escaping
|
|
13
|
+
here is precisely the failure SPEC-017 exists to remove.
|
|
14
|
+
* **The ``truncated`` marker means a *ceiling* fired** — ``max_value_bytes``, ``max_stack_bytes``,
|
|
15
|
+
``max_keys`` or ``max_depth``. It is *not* set by :data:`_CIRCULAR` or an unserializable
|
|
16
|
+
placeholder, which are coercion outcomes, not clipping.
|
|
17
|
+
* **``max_depth`` is what bounds the recursion**, not cycle detection. A cycle shallower than the
|
|
18
|
+
depth limit terminates there regardless; the ancestor tracking exists only so the value reads
|
|
19
|
+
``<circular>`` rather than ``<depth limit>``. Don't "harden" it into a safety mechanism.
|
|
20
|
+
|
|
21
|
+
The unserializable fallback is a type-name placeholder rather than ``repr(value)`` on purpose.
|
|
22
|
+
Architecture §6 refuses to auto-capture argument and return values so the library cannot leak
|
|
23
|
+
secrets or PII, and ``repr()`` of an arbitrary object routinely prints attribute values — a
|
|
24
|
+
credential held on a client object would land in the log. The placeholder identifies what was
|
|
25
|
+
dropped without disclosing it.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
31
|
+
from datetime import date, datetime, time
|
|
32
|
+
from decimal import Decimal
|
|
33
|
+
from enum import Enum
|
|
34
|
+
from typing import TYPE_CHECKING, Any
|
|
35
|
+
from uuid import UUID
|
|
36
|
+
|
|
37
|
+
if TYPE_CHECKING:
|
|
38
|
+
# Type-only import: this module then has *no* runtime dependency on any other package
|
|
39
|
+
# module, so it can never take part in an import cycle. Same idiom as ``config.Sink``.
|
|
40
|
+
from log_foundry.config import Config
|
|
41
|
+
|
|
42
|
+
__all__ = ["TRUNCATION_MARKER", "coerce", "sanitize_fields", "truncate_str", "truncate_tail"]
|
|
43
|
+
|
|
44
|
+
TRUNCATION_MARKER = "…[truncated]"
|
|
45
|
+
_MARKER_BYTES = len(TRUNCATION_MARKER.encode("utf-8"))
|
|
46
|
+
|
|
47
|
+
_CIRCULAR = "<circular>"
|
|
48
|
+
_DEPTH_LIMIT = "<depth limit>"
|
|
49
|
+
|
|
50
|
+
# Exact-type membership, deliberately not ``isinstance``. ``IntEnum``/``StrEnum`` members *are*
|
|
51
|
+
# ``int``/``str`` instances, so an isinstance check would pass the enum member itself through and
|
|
52
|
+
# hand a sink an ``Enum`` where a plain value was promised. Exact typing lets them fall to the
|
|
53
|
+
# ``Enum`` branch and degrade to ``.value``. It is also one hash lookup rather than an
|
|
54
|
+
# ``ABCMeta.__instancecheck__``.
|
|
55
|
+
_PLAIN_SCALARS: frozenset[type] = frozenset({int, float, bool})
|
|
56
|
+
|
|
57
|
+
# ``str``/``bytes``/``bytearray`` are Sequences, and so — less obviously — is ``memoryview``.
|
|
58
|
+
# Without this guard ``memoryview(b"x")`` would render as ``[120]`` instead of ``"x"``.
|
|
59
|
+
_TEXTLIKE: tuple[type, ...] = (str, bytes, bytearray, memoryview)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _measured(value: str) -> bytes:
|
|
63
|
+
"""UTF-8 bytes of ``value``, tolerating lone surrogates.
|
|
64
|
+
|
|
65
|
+
A ``str`` carrying an unpaired surrogate (anything that went through ``surrogateescape``,
|
|
66
|
+
e.g. ``os.fsdecode`` of an undecodable filename) raises ``UnicodeEncodeError`` on a bare
|
|
67
|
+
``.encode("utf-8")`` — inside a function contracted never to raise. ``errors="replace"``
|
|
68
|
+
is the same tolerance ``context`` applies when measuring an inbound baggage header.
|
|
69
|
+
"""
|
|
70
|
+
return value.encode("utf-8", errors="replace")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def truncate_str(value: str, max_bytes: int) -> tuple[str, bool]:
|
|
74
|
+
"""Clip ``value`` to ``max_bytes`` UTF-8 bytes, keeping the head.
|
|
75
|
+
|
|
76
|
+
The returned string — marker included — never exceeds ``max_bytes``, so a caller sizing
|
|
77
|
+
against a hard downstream limit can rely on the ceiling being a ceiling. Cuts on a character
|
|
78
|
+
boundary, so the result always decodes cleanly even when the budget falls mid-sequence.
|
|
79
|
+
|
|
80
|
+
Returns ``(value, was_truncated)``.
|
|
81
|
+
"""
|
|
82
|
+
raw = _measured(value)
|
|
83
|
+
if len(raw) <= max_bytes:
|
|
84
|
+
return value, False
|
|
85
|
+
budget = max_bytes - _MARKER_BYTES
|
|
86
|
+
if budget <= 0:
|
|
87
|
+
# No room for anything but the marker — and a marker alone is still the honest answer.
|
|
88
|
+
return TRUNCATION_MARKER, True
|
|
89
|
+
# ``errors="ignore"`` drops a partial trailing sequence rather than emitting U+FFFD, which
|
|
90
|
+
# is what makes the cut land on a character boundary.
|
|
91
|
+
return raw[:budget].decode("utf-8", errors="ignore") + TRUNCATION_MARKER, True
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def truncate_tail(value: str, max_bytes: int) -> tuple[str, bool]:
|
|
95
|
+
"""Clip ``value`` to ``max_bytes`` UTF-8 bytes, keeping the **tail**.
|
|
96
|
+
|
|
97
|
+
For ``error.stack``: ``traceback.format_exception`` puts the exception type, its message and
|
|
98
|
+
the innermost frames *last*, so the head of an over-long traceback is the least useful part
|
|
99
|
+
of it. The marker is prepended, and the total stays within ``max_bytes`` as above.
|
|
100
|
+
|
|
101
|
+
Returns ``(value, was_truncated)``.
|
|
102
|
+
"""
|
|
103
|
+
raw = _measured(value)
|
|
104
|
+
if len(raw) <= max_bytes:
|
|
105
|
+
return value, False
|
|
106
|
+
budget = max_bytes - _MARKER_BYTES
|
|
107
|
+
if budget <= 0:
|
|
108
|
+
return TRUNCATION_MARKER, True
|
|
109
|
+
# ``raw[-budget:]`` would return the *whole* string at ``budget == 0``, which is why the
|
|
110
|
+
# guard above is not merely defensive: ``max_stack_bytes`` may legally be smaller than the
|
|
111
|
+
# marker, and silently truncating nothing would be worse than truncating everything.
|
|
112
|
+
return TRUNCATION_MARKER + raw[-budget:].decode("utf-8", errors="ignore"), True
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class _Coercer:
|
|
116
|
+
"""One event's coercion pass: the ceilings, the truncation flag, and the ancestor chain.
|
|
117
|
+
|
|
118
|
+
A pass object rather than a pure function because the ceilings fire *deep* in the recursion
|
|
119
|
+
— a 300-key mapping eight levels down still has to set ``truncated`` on the top-level event —
|
|
120
|
+
and a recursive function returning a bare value has nowhere to report that. One instance per
|
|
121
|
+
event, ``__slots__``-ed; against the timestamp, UUID and two dicts ``build_event`` already
|
|
122
|
+
allocates per event, it is noise.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
__slots__ = ("_cfg", "_parents", "truncated")
|
|
126
|
+
|
|
127
|
+
def __init__(self, cfg: Config) -> None:
|
|
128
|
+
self._cfg = cfg
|
|
129
|
+
self.truncated = False
|
|
130
|
+
# ``id()``s of the containers currently being descended through. Ancestors only, never a
|
|
131
|
+
# global "visited" set: an ancestor is held alive by the recursion for the whole descent,
|
|
132
|
+
# so its ``id()`` cannot be recycled underneath us, and two siblings referencing the same
|
|
133
|
+
# object are not a cycle and must both render.
|
|
134
|
+
self._parents: list[int] = []
|
|
135
|
+
|
|
136
|
+
def value(self, value: object, depth: int) -> object:
|
|
137
|
+
"""Coerce one node. Total — never raises, whatever the value does."""
|
|
138
|
+
try:
|
|
139
|
+
return self._dispatch(value, depth)
|
|
140
|
+
except Exception: # noqa: BLE001 — a hostile __iter__/__str__/__eq__ must not reach the
|
|
141
|
+
return self._placeholder(value) # caller's stack; this is the whole point (FR-001).
|
|
142
|
+
|
|
143
|
+
def _dispatch(self, value: object, depth: int) -> object:
|
|
144
|
+
if depth >= self._cfg.max_depth:
|
|
145
|
+
self.truncated = True
|
|
146
|
+
return _DEPTH_LIMIT
|
|
147
|
+
if value is None:
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
# Exact types first: the common case, one hash lookup, and it is what lets IntEnum and
|
|
151
|
+
# StrEnum fall through to the Enum branch below instead of passing through as themselves.
|
|
152
|
+
kind = type(value)
|
|
153
|
+
if kind is str:
|
|
154
|
+
return self.text(value) # type: ignore[arg-type]
|
|
155
|
+
if kind in _PLAIN_SCALARS:
|
|
156
|
+
return value
|
|
157
|
+
if kind is dict:
|
|
158
|
+
return self.mapping(value, depth) # type: ignore[arg-type]
|
|
159
|
+
if kind is list:
|
|
160
|
+
return self.members(value, depth) # type: ignore[arg-type]
|
|
161
|
+
|
|
162
|
+
if isinstance(value, Enum):
|
|
163
|
+
member = value.value
|
|
164
|
+
if type(member) in _PLAIN_SCALARS or member is None:
|
|
165
|
+
return member
|
|
166
|
+
if isinstance(member, str):
|
|
167
|
+
return self.text(member)
|
|
168
|
+
# A structured ``.value`` (a tuple payload, say) has no plain form; ``str(value)``
|
|
169
|
+
# renders the member as ``Class.NAME``, which identifies it without a ``repr``.
|
|
170
|
+
return self.text(str(value))
|
|
171
|
+
if isinstance(value, bool): # bool before int: it is an int subclass.
|
|
172
|
+
return value
|
|
173
|
+
if isinstance(value, (int, float)): # int/float *subclasses* land here.
|
|
174
|
+
return value
|
|
175
|
+
if isinstance(value, (datetime, date, time)):
|
|
176
|
+
return self.text(value.isoformat())
|
|
177
|
+
if isinstance(value, UUID):
|
|
178
|
+
return self.text(str(value))
|
|
179
|
+
if isinstance(value, Decimal):
|
|
180
|
+
# A string, not a float: ``Decimal("0.1")`` must not become ``0.1000000000000000055``.
|
|
181
|
+
return self.text(str(value))
|
|
182
|
+
if isinstance(value, (bytes, bytearray, memoryview)):
|
|
183
|
+
return self.text(bytes(value).decode("utf-8", errors="replace"))
|
|
184
|
+
if isinstance(value, str): # ``str`` subclass.
|
|
185
|
+
return self.text(str(value))
|
|
186
|
+
if isinstance(value, Mapping):
|
|
187
|
+
return self.mapping(value, depth)
|
|
188
|
+
if isinstance(value, (set, frozenset)): # sets are not Sequences.
|
|
189
|
+
return self.members(value, depth)
|
|
190
|
+
if isinstance(value, Sequence) and not isinstance(value, _TEXTLIKE):
|
|
191
|
+
return self.members(value, depth)
|
|
192
|
+
return self._placeholder(value)
|
|
193
|
+
|
|
194
|
+
def mapping(self, value: Mapping[Any, object], depth: int) -> object:
|
|
195
|
+
"""Coerce a mapping, capping it at ``max_keys`` and guarding against a cycle.
|
|
196
|
+
|
|
197
|
+
The key type is ``Any`` rather than ``object`` because ``Mapping`` is invariant in its
|
|
198
|
+
key: a ``Mapping[str, object]`` (which is what ``build_event`` passes) is not a
|
|
199
|
+
``Mapping[object, object]``. :meth:`key` coerces whatever actually arrives.
|
|
200
|
+
"""
|
|
201
|
+
ident = id(value)
|
|
202
|
+
if ident in self._parents:
|
|
203
|
+
return _CIRCULAR
|
|
204
|
+
self._parents.append(ident)
|
|
205
|
+
try:
|
|
206
|
+
out: dict[str, object] = {}
|
|
207
|
+
for key, item in value.items():
|
|
208
|
+
if len(out) >= self._cfg.max_keys:
|
|
209
|
+
self.truncated = True
|
|
210
|
+
break
|
|
211
|
+
out[self.key(key)] = self.value(item, depth + 1)
|
|
212
|
+
return out
|
|
213
|
+
finally:
|
|
214
|
+
self._parents.pop()
|
|
215
|
+
|
|
216
|
+
def members(self, value: Iterable[object], depth: int) -> object:
|
|
217
|
+
"""Coerce an iterable's members into a list, capping length at ``max_keys``."""
|
|
218
|
+
ident = id(value)
|
|
219
|
+
if ident in self._parents:
|
|
220
|
+
return _CIRCULAR
|
|
221
|
+
self._parents.append(ident)
|
|
222
|
+
try:
|
|
223
|
+
out: list[object] = []
|
|
224
|
+
for item in value:
|
|
225
|
+
if len(out) >= self._cfg.max_keys:
|
|
226
|
+
self.truncated = True
|
|
227
|
+
break
|
|
228
|
+
out.append(self.value(item, depth + 1))
|
|
229
|
+
return out
|
|
230
|
+
finally:
|
|
231
|
+
self._parents.pop()
|
|
232
|
+
|
|
233
|
+
def key(self, key: object) -> str:
|
|
234
|
+
"""Coerce a mapping key to a bounded ``str`` — JSON object keys are always strings."""
|
|
235
|
+
text = key if isinstance(key, str) else str(key)
|
|
236
|
+
return self.text(text)
|
|
237
|
+
|
|
238
|
+
def text(self, value: str) -> str:
|
|
239
|
+
"""Apply ``max_value_bytes``, recording whether it fired."""
|
|
240
|
+
clipped, was_truncated = truncate_str(value, self._cfg.max_value_bytes)
|
|
241
|
+
if was_truncated:
|
|
242
|
+
self.truncated = True
|
|
243
|
+
return clipped
|
|
244
|
+
|
|
245
|
+
def _placeholder(self, value: object) -> str:
|
|
246
|
+
"""Name the type that could not be coerced, without disclosing the value."""
|
|
247
|
+
try:
|
|
248
|
+
name = type(value).__name__
|
|
249
|
+
except Exception: # noqa: BLE001 — a pathological metaclass is still not our problem.
|
|
250
|
+
name = "?"
|
|
251
|
+
return f"<unserializable: {name}>"
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def coerce(value: object, *, cfg: Config) -> object:
|
|
255
|
+
"""Return a JSON-serializable, size-bounded equivalent of ``value``. Never raises.
|
|
256
|
+
|
|
257
|
+
Use :func:`sanitize_fields` for a whole event mapping — it reports whether a ceiling fired,
|
|
258
|
+
which this cannot.
|
|
259
|
+
"""
|
|
260
|
+
return _Coercer(cfg).value(value, 0)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def sanitize_fields(
|
|
264
|
+
fields: Mapping[str, object], *, cfg: Config
|
|
265
|
+
) -> tuple[dict[str, object], bool]:
|
|
266
|
+
"""Coerce and bound a whole ``fields`` mapping.
|
|
267
|
+
|
|
268
|
+
Returns ``(fields, any_ceiling_was_applied)``. The flag drives the event's ``truncated``
|
|
269
|
+
marker, so it must reflect ceilings that fired arbitrarily deep in the structure — hence the
|
|
270
|
+
accumulator rather than a pure recursive function.
|
|
271
|
+
"""
|
|
272
|
+
coercer = _Coercer(cfg)
|
|
273
|
+
try:
|
|
274
|
+
result = coercer.mapping(fields, 0)
|
|
275
|
+
except Exception: # noqa: BLE001 — belt and braces: `_Coercer.value` is already total, but
|
|
276
|
+
return {}, True # a hostile top-level mapping must not reach the caller either.
|
|
277
|
+
if not isinstance(result, dict):
|
|
278
|
+
# ``fields`` was itself circular — impossible from ``build_event``, which always passes a
|
|
279
|
+
# freshly merged dict, but ``sanitize_fields`` is public and must stay total.
|
|
280
|
+
return {}, True
|
|
281
|
+
return result, coercer.truncated
|
|
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
|