log-foundry 0.7.1.dev1__tar.gz → 0.7.1.dev2__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 (53) hide show
  1. {log_foundry-0.7.1.dev1 → log_foundry-0.7.1.dev2}/PKG-INFO +19 -1
  2. {log_foundry-0.7.1.dev1 → log_foundry-0.7.1.dev2}/README.md +18 -0
  3. {log_foundry-0.7.1.dev1 → log_foundry-0.7.1.dev2}/pyproject.toml +1 -1
  4. {log_foundry-0.7.1.dev1 → log_foundry-0.7.1.dev2}/src/log_foundry/sanitize.py +66 -5
  5. {log_foundry-0.7.1.dev1 → log_foundry-0.7.1.dev2}/LICENSE +0 -0
  6. {log_foundry-0.7.1.dev1 → log_foundry-0.7.1.dev2}/src/log_foundry/__init__.py +0 -0
  7. {log_foundry-0.7.1.dev1 → log_foundry-0.7.1.dev2}/src/log_foundry/api.py +0 -0
  8. {log_foundry-0.7.1.dev1 → log_foundry-0.7.1.dev2}/src/log_foundry/config.py +0 -0
  9. {log_foundry-0.7.1.dev1 → log_foundry-0.7.1.dev2}/src/log_foundry/console.py +0 -0
  10. {log_foundry-0.7.1.dev1 → log_foundry-0.7.1.dev2}/src/log_foundry/context.py +0 -0
  11. {log_foundry-0.7.1.dev1 → log_foundry-0.7.1.dev2}/src/log_foundry/decorator.py +0 -0
  12. {log_foundry-0.7.1.dev1 → log_foundry-0.7.1.dev2}/src/log_foundry/ids.py +0 -0
  13. {log_foundry-0.7.1.dev1 → log_foundry-0.7.1.dev2}/src/log_foundry/model.py +0 -0
  14. {log_foundry-0.7.1.dev1 → log_foundry-0.7.1.dev2}/src/log_foundry/py.typed +0 -0
  15. {log_foundry-0.7.1.dev1 → log_foundry-0.7.1.dev2}/src/log_foundry/sinks/__init__.py +0 -0
  16. {log_foundry-0.7.1.dev1 → log_foundry-0.7.1.dev2}/src/log_foundry/sinks/_batch.py +0 -0
  17. {log_foundry-0.7.1.dev1 → log_foundry-0.7.1.dev2}/src/log_foundry/sinks/_chunk.py +0 -0
  18. {log_foundry-0.7.1.dev1 → log_foundry-0.7.1.dev2}/src/log_foundry/sinks/_socket.py +0 -0
  19. {log_foundry-0.7.1.dev1 → log_foundry-0.7.1.dev2}/src/log_foundry/sinks/_time.py +0 -0
  20. {log_foundry-0.7.1.dev1 → log_foundry-0.7.1.dev2}/src/log_foundry/sinks/base.py +0 -0
  21. {log_foundry-0.7.1.dev1 → log_foundry-0.7.1.dev2}/src/log_foundry/sinks/callback.py +0 -0
  22. {log_foundry-0.7.1.dev1 → log_foundry-0.7.1.dev2}/src/log_foundry/sinks/clickhouse.py +0 -0
  23. {log_foundry-0.7.1.dev1 → log_foundry-0.7.1.dev2}/src/log_foundry/sinks/datadog.py +0 -0
  24. {log_foundry-0.7.1.dev1 → log_foundry-0.7.1.dev2}/src/log_foundry/sinks/elasticsearch.py +0 -0
  25. {log_foundry-0.7.1.dev1 → log_foundry-0.7.1.dev2}/src/log_foundry/sinks/eventhubs.py +0 -0
  26. {log_foundry-0.7.1.dev1 → log_foundry-0.7.1.dev2}/src/log_foundry/sinks/file.py +0 -0
  27. {log_foundry-0.7.1.dev1 → log_foundry-0.7.1.dev2}/src/log_foundry/sinks/filtering.py +0 -0
  28. {log_foundry-0.7.1.dev1 → log_foundry-0.7.1.dev2}/src/log_foundry/sinks/firehose.py +0 -0
  29. {log_foundry-0.7.1.dev1 → log_foundry-0.7.1.dev2}/src/log_foundry/sinks/honeycomb.py +0 -0
  30. {log_foundry-0.7.1.dev1 → log_foundry-0.7.1.dev2}/src/log_foundry/sinks/http.py +0 -0
  31. {log_foundry-0.7.1.dev1 → log_foundry-0.7.1.dev2}/src/log_foundry/sinks/kafka.py +0 -0
  32. {log_foundry-0.7.1.dev1 → log_foundry-0.7.1.dev2}/src/log_foundry/sinks/kinesis.py +0 -0
  33. {log_foundry-0.7.1.dev1 → log_foundry-0.7.1.dev2}/src/log_foundry/sinks/logging_sink.py +0 -0
  34. {log_foundry-0.7.1.dev1 → log_foundry-0.7.1.dev2}/src/log_foundry/sinks/logstash.py +0 -0
  35. {log_foundry-0.7.1.dev1 → log_foundry-0.7.1.dev2}/src/log_foundry/sinks/loki.py +0 -0
  36. {log_foundry-0.7.1.dev1 → log_foundry-0.7.1.dev2}/src/log_foundry/sinks/mongodb.py +0 -0
  37. {log_foundry-0.7.1.dev1 → log_foundry-0.7.1.dev2}/src/log_foundry/sinks/multi.py +0 -0
  38. {log_foundry-0.7.1.dev1 → log_foundry-0.7.1.dev2}/src/log_foundry/sinks/nats.py +0 -0
  39. {log_foundry-0.7.1.dev1 → log_foundry-0.7.1.dev2}/src/log_foundry/sinks/newrelic.py +0 -0
  40. {log_foundry-0.7.1.dev1 → log_foundry-0.7.1.dev2}/src/log_foundry/sinks/postgres.py +0 -0
  41. {log_foundry-0.7.1.dev1 → log_foundry-0.7.1.dev2}/src/log_foundry/sinks/pubsub.py +0 -0
  42. {log_foundry-0.7.1.dev1 → log_foundry-0.7.1.dev2}/src/log_foundry/sinks/rabbitmq.py +0 -0
  43. {log_foundry-0.7.1.dev1 → log_foundry-0.7.1.dev2}/src/log_foundry/sinks/redis.py +0 -0
  44. {log_foundry-0.7.1.dev1 → log_foundry-0.7.1.dev2}/src/log_foundry/sinks/sentry.py +0 -0
  45. {log_foundry-0.7.1.dev1 → log_foundry-0.7.1.dev2}/src/log_foundry/sinks/sns.py +0 -0
  46. {log_foundry-0.7.1.dev1 → log_foundry-0.7.1.dev2}/src/log_foundry/sinks/splunk.py +0 -0
  47. {log_foundry-0.7.1.dev1 → log_foundry-0.7.1.dev2}/src/log_foundry/sinks/sqlite.py +0 -0
  48. {log_foundry-0.7.1.dev1 → log_foundry-0.7.1.dev2}/src/log_foundry/sinks/sqs.py +0 -0
  49. {log_foundry-0.7.1.dev1 → log_foundry-0.7.1.dev2}/src/log_foundry/sinks/stdout.py +0 -0
  50. {log_foundry-0.7.1.dev1 → log_foundry-0.7.1.dev2}/src/log_foundry/sinks/syslog.py +0 -0
  51. {log_foundry-0.7.1.dev1 → log_foundry-0.7.1.dev2}/src/log_foundry/sinks/transform.py +0 -0
  52. {log_foundry-0.7.1.dev1 → log_foundry-0.7.1.dev2}/src/log_foundry/sinks/util.py +0 -0
  53. {log_foundry-0.7.1.dev1 → log_foundry-0.7.1.dev2}/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.7.1.dev1
3
+ Version: 0.7.1.dev2
4
4
  Summary: Generate logs for your console and JSON events for downstream consumption.
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -743,10 +743,28 @@ Every event is the same shape (arch §6). Boundary events add a few fields:
743
743
  | `duration_ms` | span.end | wall time from a monotonic delta |
744
744
  | `status` | span.end | `"ok"` or `"error"` |
745
745
  | `error` | on failure | `{"type": ..., "stack": ...}` |
746
+ | `truncated` | when a ceiling fired | `true`; absent otherwise, never `false` |
746
747
 
747
748
  IDs are [W3C Trace Context](https://www.w3.org/TR/trace-context/)-compatible by design, so the
748
749
  logs can later correlate with distributed traces cheaply.
749
750
 
751
+ ### Field values are coerced and bounded
752
+
753
+ Every value you pass is made JSON-safe and given a size ceiling once, when the event is assembled
754
+ — so no sink can be handed a payload JSON refuses, and no single field can grow without limit.
755
+ Strings are clipped to `max_value_bytes` (8192 by default), mappings and sequences to `max_keys`
756
+ entries and `max_depth` levels. `datetime`, `UUID`, `Decimal`, `bytes` and friends render as
757
+ strings; anything with no JSON form becomes `<unserializable: TypeName>` — the type name only,
758
+ never a `repr`, so coercion can never leak a value the library was careful not to capture.
759
+
760
+ Integers are the one case worth knowing about. They are passed through unchanged — an ID or an
761
+ amount stays a number, at full precision — but an integer too long to *render* is replaced by
762
+ `<int: ~N digits>`. CPython refuses to convert an integer past `sys.get_int_max_str_digits()`
763
+ decimal digits (**4300** by default) and raises, and `json.dumps` inherits that refusal, so with
764
+ the default configuration the interpreter's limit — not `max_value_bytes` — is what binds. You are
765
+ unlikely to meet it deliberately; `int.from_bytes(blob, "big")` over a couple of kilobytes gets
766
+ there. Any ceiling firing sets `truncated: true` on the event.
767
+
750
768
  ## Development
751
769
 
752
770
  ```bash
@@ -707,10 +707,28 @@ Every event is the same shape (arch §6). Boundary events add a few fields:
707
707
  | `duration_ms` | span.end | wall time from a monotonic delta |
708
708
  | `status` | span.end | `"ok"` or `"error"` |
709
709
  | `error` | on failure | `{"type": ..., "stack": ...}` |
710
+ | `truncated` | when a ceiling fired | `true`; absent otherwise, never `false` |
710
711
 
711
712
  IDs are [W3C Trace Context](https://www.w3.org/TR/trace-context/)-compatible by design, so the
712
713
  logs can later correlate with distributed traces cheaply.
713
714
 
715
+ ### Field values are coerced and bounded
716
+
717
+ Every value you pass is made JSON-safe and given a size ceiling once, when the event is assembled
718
+ — so no sink can be handed a payload JSON refuses, and no single field can grow without limit.
719
+ Strings are clipped to `max_value_bytes` (8192 by default), mappings and sequences to `max_keys`
720
+ entries and `max_depth` levels. `datetime`, `UUID`, `Decimal`, `bytes` and friends render as
721
+ strings; anything with no JSON form becomes `<unserializable: TypeName>` — the type name only,
722
+ never a `repr`, so coercion can never leak a value the library was careful not to capture.
723
+
724
+ Integers are the one case worth knowing about. They are passed through unchanged — an ID or an
725
+ amount stays a number, at full precision — but an integer too long to *render* is replaced by
726
+ `<int: ~N digits>`. CPython refuses to convert an integer past `sys.get_int_max_str_digits()`
727
+ decimal digits (**4300** by default) and raises, and `json.dumps` inherits that refusal, so with
728
+ the default configuration the interpreter's limit — not `max_value_bytes` — is what binds. You are
729
+ unlikely to meet it deliberately; `int.from_bytes(blob, "big")` over a couple of kilobytes gets
730
+ there. Any ceiling firing sets `truncated: true` on the event.
731
+
714
732
  ## Development
715
733
 
716
734
  ```bash
@@ -20,7 +20,7 @@ dependencies = [
20
20
  ]
21
21
 
22
22
  # Optional features. Install with: pip install log-foundry[aws]
23
- version = "0.7.1.dev1"
23
+ version = "0.7.1.dev2"
24
24
 
25
25
  [project.optional-dependencies]
26
26
  aws = ["boto3>=1.34"] # SQSSink, SNSSink, KinesisSink, FirehoseSink
@@ -27,6 +27,7 @@ dropped without disclosing it.
27
27
 
28
28
  from __future__ import annotations
29
29
 
30
+ import sys
30
31
  from collections.abc import Iterable, Mapping, Sequence
31
32
  from datetime import date, datetime, time
32
33
  from decimal import Decimal
@@ -47,6 +48,13 @@ _MARKER_BYTES = len(TRUNCATION_MARKER.encode("utf-8"))
47
48
  _CIRCULAR = "<circular>"
48
49
  _DEPTH_LIMIT = "<depth limit>"
49
50
 
51
+ # ``log10(2)`` as an integer ratio, rounded *up*: ``|n| < 2**b``, so ``n`` has at most
52
+ # ``b * _LOG10_2_NUM // _LOG10_2_DEN + 1`` decimal digits. Rounding the ratio up makes that an
53
+ # over-estimate, which is the safe direction — it can replace an integer marginally short of the
54
+ # ceiling, but never admit one past it (SPEC-020 FR-002).
55
+ _LOG10_2_NUM = 30103
56
+ _LOG10_2_DEN = 100000
57
+
50
58
  # Exact-type membership, deliberately not ``isinstance``. ``IntEnum``/``StrEnum`` members *are*
51
59
  # ``int``/``str`` instances, so an isinstance check would pass the enum member itself through and
52
60
  # hand a sink an ``Enum`` where a plain value was promised. Exact typing lets them fall to the
@@ -59,6 +67,18 @@ _PLAIN_SCALARS: frozenset[type] = frozenset({int, float, bool})
59
67
  _TEXTLIKE: tuple[type, ...] = (str, bytes, bytearray, memoryview)
60
68
 
61
69
 
70
+ def _int_digit_ceiling(max_value_bytes: int) -> int:
71
+ """The largest decimal length an integer may have and still be rendered (SPEC-020 FR-001).
72
+
73
+ CPython 3.11+ refuses to convert an integer past ``sys.get_int_max_str_digits()`` digits and
74
+ raises ``ValueError``, which ``json.dumps`` inherits. A configured ceiling above that cannot be
75
+ honoured — rendering such an integer is the very thing that raises — so the interpreter's limit
76
+ wins whenever it is lower. A limit of ``0`` means the interpreter imposes none.
77
+ """
78
+ limit = sys.get_int_max_str_digits()
79
+ return max_value_bytes if limit <= 0 else min(max_value_bytes, limit)
80
+
81
+
62
82
  def _measured(value: str) -> bytes:
63
83
  """UTF-8 bytes of ``value``, tolerating lone surrogates.
64
84
 
@@ -152,7 +172,9 @@ class _Coercer:
152
172
  kind = type(value)
153
173
  if kind is str:
154
174
  return self.text(value) # type: ignore[arg-type]
155
- if kind in _PLAIN_SCALARS:
175
+ if kind is int: # before _PLAIN_SCALARS: `int` is the one scalar with no natural ceiling.
176
+ return self.integer(value) # type: ignore[arg-type]
177
+ if kind in _PLAIN_SCALARS: # float (IEEE-754-bounded) and bool
156
178
  return value
157
179
  if kind is dict:
158
180
  return self.mapping(value, depth) # type: ignore[arg-type]
@@ -161,6 +183,8 @@ class _Coercer:
161
183
 
162
184
  if isinstance(value, Enum):
163
185
  member = value.value
186
+ if type(member) is int: # an IntEnum member is as unbounded as a bare int.
187
+ return self.integer(member)
164
188
  if type(member) in _PLAIN_SCALARS or member is None:
165
189
  return member
166
190
  if isinstance(member, str):
@@ -170,7 +194,9 @@ class _Coercer:
170
194
  return self.text(str(value))
171
195
  if isinstance(value, bool): # bool before int: it is an int subclass.
172
196
  return value
173
- if isinstance(value, (int, float)): # int/float *subclasses* land here.
197
+ if isinstance(value, int): # int *subclasses* bounded like the exact type above.
198
+ return self.integer(value)
199
+ if isinstance(value, float): # float *subclasses*.
174
200
  return value
175
201
  if isinstance(value, (datetime, date, time)):
176
202
  return self.text(value.isoformat())
@@ -231,9 +257,44 @@ class _Coercer:
231
257
  self._parents.pop()
232
258
 
233
259
  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)
260
+ """Coerce a mapping key to a bounded ``str`` — JSON object keys are always strings.
261
+
262
+ An integer key goes through :meth:`integer` first. A bare ``str()`` here would raise on an
263
+ over-long one — the very ``ValueError`` this module exists to keep away from a sink — and
264
+ the failure would be caught up in :meth:`value`, replacing the *whole mapping* with a
265
+ placeholder. One hostile key would take every sibling key with it, unmarked. ``bool`` is
266
+ excluded because ``True`` must render as the key ``"True"``, not ``"1"``.
267
+ """
268
+ if isinstance(key, str):
269
+ return self.text(key)
270
+ if isinstance(key, int) and not isinstance(key, bool):
271
+ rendered = self.integer(key)
272
+ return self.text(rendered if isinstance(rendered, str) else str(rendered))
273
+ return self.text(str(key))
274
+
275
+ def integer(self, value: int) -> object:
276
+ """Return ``value`` unchanged, or a placeholder when it is too long to render (FR-001).
277
+
278
+ The size test is ``bit_length()``, never ``len(str(value))``: converting an over-long
279
+ integer to a string raises the very ``ValueError`` this bound exists to prevent, so the
280
+ obvious check would move the crash rather than remove it (FR-002). ``bit_length()`` is
281
+ O(1), total, and ignores the sign, so ``n`` and ``-n`` are bounded identically.
282
+
283
+ An over-long integer is *replaced*, not clipped. Dropping digits would silently change the
284
+ value, and a wrong number is worse than a visibly elided one — so this reuses the
285
+ type-naming shape of :meth:`_placeholder` rather than inventing a second elision style.
286
+
287
+ The bound trusts ``bit_length()``. An ``int`` subclass that overrides it to understate its
288
+ own magnitude defeats this, and nothing here can tell — the call does not raise, so the
289
+ totality guard in :meth:`value` never engages either. That is the same trust every coercion
290
+ rule extends to a subclass's dunders, and narrowing it would cost the common path a
291
+ ``type()`` check to catch only a value engineered to lie about itself.
292
+ """
293
+ digits = value.bit_length() * _LOG10_2_NUM // _LOG10_2_DEN + 1
294
+ if digits <= _int_digit_ceiling(self._cfg.max_value_bytes):
295
+ return value
296
+ self.truncated = True
297
+ return f"<int: ~{digits} digits>"
237
298
 
238
299
  def text(self, value: str) -> str:
239
300
  """Apply ``max_value_bytes``, recording whether it fired."""