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.
Files changed (52) hide show
  1. {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/PKG-INFO +1 -1
  2. {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/pyproject.toml +1 -1
  3. {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/config.py +37 -1
  4. log_foundry-0.5.1.dev6/src/log_foundry/sanitize.py +281 -0
  5. {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/LICENSE +0 -0
  6. {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/README.md +0 -0
  7. {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/__init__.py +0 -0
  8. {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/api.py +0 -0
  9. {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/console.py +0 -0
  10. {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/context.py +0 -0
  11. {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/decorator.py +0 -0
  12. {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/ids.py +0 -0
  13. {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/model.py +0 -0
  14. {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/py.typed +0 -0
  15. {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/__init__.py +0 -0
  16. {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/_chunk.py +0 -0
  17. {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/_socket.py +0 -0
  18. {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/_time.py +0 -0
  19. {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/base.py +0 -0
  20. {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/callback.py +0 -0
  21. {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/clickhouse.py +0 -0
  22. {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/datadog.py +0 -0
  23. {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/elasticsearch.py +0 -0
  24. {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/eventhubs.py +0 -0
  25. {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/file.py +0 -0
  26. {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/filtering.py +0 -0
  27. {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/firehose.py +0 -0
  28. {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/honeycomb.py +0 -0
  29. {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/http.py +0 -0
  30. {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/kafka.py +0 -0
  31. {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/kinesis.py +0 -0
  32. {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/logging_sink.py +0 -0
  33. {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/logstash.py +0 -0
  34. {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/loki.py +0 -0
  35. {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/mongodb.py +0 -0
  36. {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/multi.py +0 -0
  37. {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/nats.py +0 -0
  38. {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/newrelic.py +0 -0
  39. {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/postgres.py +0 -0
  40. {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/pubsub.py +0 -0
  41. {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/rabbitmq.py +0 -0
  42. {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/redis.py +0 -0
  43. {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/sentry.py +0 -0
  44. {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/sns.py +0 -0
  45. {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/splunk.py +0 -0
  46. {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/sqlite.py +0 -0
  47. {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/sqs.py +0 -0
  48. {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/stdout.py +0 -0
  49. {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/syslog.py +0 -0
  50. {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/transform.py +0 -0
  51. {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/sinks/util.py +0 -0
  52. {log_foundry-0.5.1.dev4 → log_foundry-0.5.1.dev6}/src/log_foundry/worker.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: log-foundry
3
- Version: 0.5.1.dev4
3
+ Version: 0.5.1.dev6
4
4
  Summary: Generate logs for your console and JSON events for downstream consumption.
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -20,7 +20,7 @@ dependencies = [
20
20
  ]
21
21
 
22
22
  # Optional features. Install with: pip install log-foundry[aws]
23
- version = "0.5.1.dev4"
23
+ version = "0.5.1.dev6"
24
24
 
25
25
  [project.optional-dependencies]
26
26
  aws = ["boto3>=1.34"] # SQSSink, SNSSink, KinesisSink, FirehoseSink
@@ -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