log-foundry 0.10.2.dev42__tar.gz → 0.10.2.dev43__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 (56) hide show
  1. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev43}/PKG-INFO +1 -1
  2. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev43}/pyproject.toml +1 -1
  3. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev43}/src/log_foundry/config.py +129 -32
  4. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev43}/src/log_foundry/model.py +4 -4
  5. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev43}/LICENSE +0 -0
  6. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev43}/README.md +0 -0
  7. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev43}/src/log_foundry/__init__.py +0 -0
  8. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev43}/src/log_foundry/_diag.py +0 -0
  9. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev43}/src/log_foundry/_lifecycle.py +0 -0
  10. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev43}/src/log_foundry/api.py +0 -0
  11. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev43}/src/log_foundry/console.py +0 -0
  12. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev43}/src/log_foundry/context.py +0 -0
  13. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev43}/src/log_foundry/decorator.py +0 -0
  14. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev43}/src/log_foundry/ids.py +0 -0
  15. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev43}/src/log_foundry/py.typed +0 -0
  16. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev43}/src/log_foundry/sanitize.py +0 -0
  17. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/__init__.py +0 -0
  18. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/_batch.py +0 -0
  19. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/_chunk.py +0 -0
  20. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/_retry.py +0 -0
  21. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/_socket.py +0 -0
  22. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/_time.py +0 -0
  23. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/base.py +0 -0
  24. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/callback.py +0 -0
  25. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/clickhouse.py +0 -0
  26. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/datadog.py +0 -0
  27. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/elasticsearch.py +0 -0
  28. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/eventhubs.py +0 -0
  29. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/file.py +0 -0
  30. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/filtering.py +0 -0
  31. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/firehose.py +0 -0
  32. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/honeycomb.py +0 -0
  33. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/http.py +0 -0
  34. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/kafka.py +0 -0
  35. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/kinesis.py +0 -0
  36. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/logging_sink.py +0 -0
  37. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/logstash.py +0 -0
  38. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/loki.py +0 -0
  39. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/mongodb.py +0 -0
  40. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/multi.py +0 -0
  41. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/nats.py +0 -0
  42. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/newrelic.py +0 -0
  43. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/postgres.py +0 -0
  44. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/pubsub.py +0 -0
  45. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/rabbitmq.py +0 -0
  46. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/redis.py +0 -0
  47. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/sentry.py +0 -0
  48. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/sns.py +0 -0
  49. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/splunk.py +0 -0
  50. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/sqlite.py +0 -0
  51. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/sqs.py +0 -0
  52. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/stdout.py +0 -0
  53. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/syslog.py +0 -0
  54. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/transform.py +0 -0
  55. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev43}/src/log_foundry/sinks/util.py +0 -0
  56. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev43}/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.10.2.dev42
3
+ Version: 0.10.2.dev43
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.10.2.dev42"
23
+ version = "0.10.2.dev43"
24
24
 
25
25
  [project.optional-dependencies]
26
26
  aws = ["boto3>=1.43.61"] # SQSSink, SNSSink, KinesisSink, FirehoseSink
@@ -2,14 +2,15 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from dataclasses import dataclass, field
5
+ import threading
6
+ from dataclasses import dataclass, field, replace
6
7
  from typing import TYPE_CHECKING
7
8
 
8
9
  if TYPE_CHECKING:
9
10
  from log_foundry.sinks.base import Sink
10
11
 
11
12
 
12
- @dataclass
13
+ @dataclass(frozen=True)
13
14
  class Config:
14
15
  """Process-wide settings stamped onto every event and consulted by the pipeline.
15
16
 
@@ -22,6 +23,13 @@ class Config:
22
23
  string is measured in UTF-8 bytes, an integer in the decimal length it renders as, sign
23
24
  included. An integer is also bounded by ``sys.get_int_max_str_digits()`` whenever that
24
25
  is lower, since a longer one cannot be rendered at all.
26
+
27
+ It is **frozen** (SPEC-034 FR-003). :func:`get_config` handed out this object, so assigning
28
+ to it retargeted what the config *reported* while every event continued to the sink the
29
+ worker had already captured — SPEC-030's defect reachable with no underscore in sight — and
30
+ assigning a ceiling bypassed :func:`_require_positive`, so ``max_value_bytes = 0`` was
31
+ accepted and emptied every event it touched. Both measured. :func:`configure` is the only
32
+ supported route to a change, and it rebinds the module global rather than mutating.
25
33
  """
26
34
 
27
35
  service: str = "unknown"
@@ -37,6 +45,25 @@ class Config:
37
45
 
38
46
  _config = Config()
39
47
 
48
+ _config_lock = threading.Lock()
49
+ """Serializes the read-modify-write that replacing a frozen config now is.
50
+
51
+ Freezing :class:`Config` turned each field assignment into a whole-object
52
+ ``replace()`` — a read of every field followed by a write of every field — and one of the two
53
+ call sites, :func:`_ensure_sink`, runs on the **orphan logging path**, on whatever application
54
+ thread called ``info()``. A stale snapshot there puts back the pre-``configure()`` ``service``,
55
+ ``version``, ``env``, ``defaults`` *and* ``sink``, permanently. Measured on the unlocked version:
56
+ 268 of 2000 trials shipped every later event with ``service="unknown"`` after one concurrent
57
+ ``info()``, against 0 before the freeze — a regression, and the SPEC-024 category of wrong data
58
+ rather than lost data.
59
+
60
+ It does **not** cover reads. :func:`_live_config` is one atomic global read and stays lock-free,
61
+ so the per-event path pays nothing (SPEC-034 FR-003 AC-6). Lock ordering is one-way and stays
62
+ that way: ``_ensure_sink`` is called with ``decorator._worker_lock`` held, and ``configure()``
63
+ releases this lock before ``_swap_live_sink`` takes that one, so nothing acquires
64
+ ``_worker_lock`` underneath this.
65
+ """
66
+
40
67
 
41
68
  def _require_positive(name: str, value: int | None) -> None:
42
69
  """Rejects a non-positive ceiling.
@@ -127,24 +154,22 @@ def configure(
127
154
  _require_positive("max_keys", max_keys)
128
155
  _require_positive("max_depth", max_depth)
129
156
 
130
- if service is not None:
131
- _config.service = service
132
- if version is not None:
133
- _config.version = version
134
- if env is not None:
135
- _config.env = env
136
- if sink is not None:
137
- _config.sink = sink
138
- if defaults is not None:
139
- _config.defaults = dict(defaults)
140
- if max_value_bytes is not None:
141
- _config.max_value_bytes = max_value_bytes
142
- if max_stack_bytes is not None:
143
- _config.max_stack_bytes = max_stack_bytes
144
- if max_keys is not None:
145
- _config.max_keys = max_keys
146
- if max_depth is not None:
147
- _config.max_depth = max_depth
157
+ changed: dict[str, object] = {
158
+ name: value
159
+ for name, value in (
160
+ ("service", service),
161
+ ("version", version),
162
+ ("env", env),
163
+ ("sink", sink),
164
+ ("defaults", None if defaults is None else dict(defaults)),
165
+ ("max_value_bytes", max_value_bytes),
166
+ ("max_stack_bytes", max_stack_bytes),
167
+ ("max_keys", max_keys),
168
+ ("max_depth", max_depth),
169
+ )
170
+ if value is not None
171
+ }
172
+ _rebind(**changed)
148
173
 
149
174
  _ensure_sink()
150
175
 
@@ -183,18 +208,73 @@ def _swap_live_sink(sink: Sink) -> None:
183
208
  def get_config() -> Config:
184
209
  """Returns the current global config, for reading.
185
210
 
186
- **Mutating what this returns is unsupported and will raise from 1.0** (SPEC-034 FR-003).
187
- Today it hands back the live singleton, so assigning to it retargets what the config
188
- *reports* while every event continues to the sink the worker already captured, and assigning
189
- a ceiling bypasses the validation :func:`configure` performs — ``max_value_bytes = 0`` is
190
- accepted and empties every event it touches. Both measured. :func:`configure` is the only
191
- supported route to a change.
211
+ **Mutating what this returns raises** (SPEC-034 FR-003). It used to hand back the live
212
+ singleton, so assigning to it retargeted what the config *reported* while every event
213
+ continued to the sink the worker had already captured, and assigning a ceiling bypassed the
214
+ validation :func:`configure` performs — ``max_value_bytes = 0`` was accepted and emptied
215
+ every event it touched. Both measured. :func:`configure` is the only route to a change.
216
+
217
+ It is a **copy**, not the frozen original, and ``defaults`` is copied with it. A caller who
218
+ defeats the freeze — ``object.__setattr__`` reaches through any frozen dataclass — then
219
+ edits an object the library does not read, rather than the live config; and ``defaults`` is
220
+ a plain mutable ``dict``, so sharing it would leave the freeze cosmetic at the one field
221
+ that is not a scalar. ``dataclasses.replace`` alone does **not** do this: it shares the
222
+ dict, which was measured while building this.
192
223
 
193
224
  Args:
194
225
  None.
195
226
 
196
227
  Returns:
197
- The process-wide :class:`Config`, to be treated as read-only.
228
+ A copy of the process-wide :class:`Config`, with its own ``defaults``.
229
+
230
+ Raises:
231
+ None.
232
+ """
233
+ return replace(_config, defaults=dict(_config.defaults))
234
+
235
+
236
+ def _rebind(**changed: object) -> None:
237
+ """Replaces the module-global config with a copy carrying the changed fields.
238
+
239
+ :class:`Config` is frozen (SPEC-034 FR-003), so a change is a new object and a rebinding of
240
+ the global rather than an assignment to a field. It is **one** replacement for the whole
241
+ call, not one per field: nine rebindings would allocate nine configs and, worse, would leave
242
+ a window in which another thread reads a half-applied config — a `service` from the new call
243
+ beside a `sink` from the old one, stamped onto real events.
244
+
245
+ Rebinding is safe only because no module imports ``_config`` by value; a
246
+ ``from log_foundry.config import _config`` anywhere would hold the pre-rebind object forever,
247
+ which is why a test asserts the absence rather than a comment claiming it.
248
+
249
+ Args:
250
+ **changed: Field names and their new values. Fields not named keep their current value.
251
+
252
+ Returns:
253
+ None.
254
+
255
+ Raises:
256
+ None.
257
+ """
258
+ global _config
259
+ with _config_lock:
260
+ _config = replace(_config, **changed) # type: ignore[arg-type]
261
+
262
+
263
+ def _live_config() -> Config:
264
+ """Returns the config object itself, for callers inside the package.
265
+
266
+ :func:`get_config` copies, because it is public and a caller must not be able to reach the
267
+ live object through it. ``model.build_event`` reads the config **one to three times per
268
+ event**, so routing that through the public accessor would allocate a ``Config`` and a
269
+ ``defaults`` dict per event. The freeze is a guarantee to the library's *users*, not one the
270
+ library needs against itself, so internal callers read the live object and treat it as
271
+ read-only — the same split :func:`context._live_baggage` makes for baggage.
272
+
273
+ Args:
274
+ None.
275
+
276
+ Returns:
277
+ The live process-wide :class:`Config`.
198
278
 
199
279
  Raises:
200
280
  None.
@@ -210,6 +290,14 @@ def _ensure_sink() -> Sink:
210
290
  user has not called ``configure()`` yet. The local import defers the ``sinks``
211
291
  dependency and avoids a top-level import cycle (arch §7).
212
292
 
293
+ It is called on the **orphan logging path**, on arbitrary application threads, so the
294
+ default is resolved under :data:`_config_lock` with a double check and the global is re-read
295
+ inside it (SPEC-034 FR-003). Returning a freshly built local instead handed two racing
296
+ threads two different ``StdoutSink`` objects — measured 996 of 3000 trials — one of which
297
+ then received events and was referenced by nothing, so nothing closed it (SPEC-031 FR-006).
298
+ The unlocked read above it is the fast path: once a sink is configured this is one atomic
299
+ global read and no lock at all.
300
+
213
301
  Args:
214
302
  None.
215
303
 
@@ -219,8 +307,17 @@ def _ensure_sink() -> Sink:
219
307
  Raises:
220
308
  None.
221
309
  """
222
- if _config.sink is None:
223
- from log_foundry.sinks.stdout import StdoutSink
224
-
225
- _config.sink = StdoutSink()
226
- return _config.sink
310
+ global _config
311
+ sink_now = _config.sink
312
+ if sink_now is not None:
313
+ return sink_now
314
+
315
+ from log_foundry.sinks.stdout import StdoutSink
316
+
317
+ with _config_lock:
318
+ if _config.sink is None:
319
+ _config = replace(_config, sink=StdoutSink())
320
+ resolved = _config.sink
321
+ if resolved is None: # pragma: no cover - unreachable; the branch above just set it
322
+ raise RuntimeError("the default sink could not be resolved")
323
+ return resolved
@@ -8,7 +8,7 @@ from dataclasses import dataclass, field
8
8
  from datetime import UTC, datetime
9
9
  from typing import TYPE_CHECKING
10
10
 
11
- from log_foundry.config import get_config
11
+ from log_foundry.config import _live_config
12
12
  from log_foundry.ids import new_log_id
13
13
  from log_foundry.sanitize import sanitize_fields, truncate_str, truncate_tail
14
14
 
@@ -93,7 +93,7 @@ def build_event(
93
93
  Raises:
94
94
  None.
95
95
  """
96
- cfg = get_config()
96
+ cfg = _live_config()
97
97
  merged: dict[str, object] = {**cfg.defaults, **span.defaults, **baggage, **fields}
98
98
  safe, clipped = sanitize_fields(merged, cfg=cfg)
99
99
  bounded_message, message_clipped = truncate_str(message, cfg.max_value_bytes)
@@ -236,7 +236,7 @@ def end_event(
236
236
  event["duration_ms"] = (time.monotonic() - span.start_ts) * 1000.0
237
237
  event["status"] = status
238
238
  if exc is not None:
239
- error, clipped = _error_fields(exc, cfg=get_config())
239
+ error, clipped = _error_fields(exc, cfg=_live_config())
240
240
  event["error"] = error
241
241
  if clipped:
242
242
  event[_TRUNCATED] = True
@@ -269,7 +269,7 @@ def backfill_baggage(span: Span, baggage: dict[str, object]) -> None:
269
269
  """
270
270
  if not baggage:
271
271
  return
272
- safe, clipped = sanitize_fields(baggage, cfg=get_config())
272
+ safe, clipped = sanitize_fields(baggage, cfg=_live_config())
273
273
  for event in span.events:
274
274
  if event.get("message") in (_START_MESSAGE, _END_MESSAGE):
275
275
  fields = event.get("fields")