log-foundry 0.5.1.dev4__tar.gz → 0.5.1.dev6__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.dev4 → log_foundry-0.5.1.dev6}/PKG-INFO +1 -1
- {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/pyproject.toml +1 -1
- {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/config.py +37 -1
- log_foundry-0.5.1.dev6/src/log_foundry/sanitize.py +281 -0
- {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/LICENSE +0 -0
- {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/README.md +0 -0
- {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/__init__.py +0 -0
- {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/api.py +0 -0
- {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/console.py +0 -0
- {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/context.py +0 -0
- {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/decorator.py +0 -0
- {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/ids.py +0 -0
- {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/model.py +0 -0
- {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/py.typed +0 -0
- {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/__init__.py +0 -0
- {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/_chunk.py +0 -0
- {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/_socket.py +0 -0
- {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/_time.py +0 -0
- {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/base.py +0 -0
- {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/callback.py +0 -0
- {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/clickhouse.py +0 -0
- {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/datadog.py +0 -0
- {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/elasticsearch.py +0 -0
- {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/eventhubs.py +0 -0
- {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/file.py +0 -0
- {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/filtering.py +0 -0
- {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/firehose.py +0 -0
- {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/honeycomb.py +0 -0
- {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/http.py +0 -0
- {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/kafka.py +0 -0
- {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/kinesis.py +0 -0
- {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/logging_sink.py +0 -0
- {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/logstash.py +0 -0
- {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/loki.py +0 -0
- {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/mongodb.py +0 -0
- {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/multi.py +0 -0
- {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/nats.py +0 -0
- {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/newrelic.py +0 -0
- {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/postgres.py +0 -0
- {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/pubsub.py +0 -0
- {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/rabbitmq.py +0 -0
- {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/redis.py +0 -0
- {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/sentry.py +0 -0
- {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/sns.py +0 -0
- {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/splunk.py +0 -0
- {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/sqlite.py +0 -0
- {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/sqs.py +0 -0
- {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/stdout.py +0 -0
- {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/syslog.py +0 -0
- {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/transform.py +0 -0
- {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/util.py +0 -0
- {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/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
|
|
|
@@ -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
|
|
File without changes
|