log-foundry 0.10.2.dev42__tar.gz → 0.10.2.dev44__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.dev44}/PKG-INFO +17 -4
  2. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev44}/README.md +16 -3
  3. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev44}/pyproject.toml +1 -1
  4. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev44}/src/log_foundry/api.py +113 -15
  5. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev44}/src/log_foundry/config.py +129 -32
  6. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev44}/src/log_foundry/model.py +4 -4
  7. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev44}/LICENSE +0 -0
  8. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev44}/src/log_foundry/__init__.py +0 -0
  9. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev44}/src/log_foundry/_diag.py +0 -0
  10. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev44}/src/log_foundry/_lifecycle.py +0 -0
  11. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev44}/src/log_foundry/console.py +0 -0
  12. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev44}/src/log_foundry/context.py +0 -0
  13. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev44}/src/log_foundry/decorator.py +0 -0
  14. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev44}/src/log_foundry/ids.py +0 -0
  15. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev44}/src/log_foundry/py.typed +0 -0
  16. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev44}/src/log_foundry/sanitize.py +0 -0
  17. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev44}/src/log_foundry/sinks/__init__.py +0 -0
  18. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev44}/src/log_foundry/sinks/_batch.py +0 -0
  19. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev44}/src/log_foundry/sinks/_chunk.py +0 -0
  20. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev44}/src/log_foundry/sinks/_retry.py +0 -0
  21. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev44}/src/log_foundry/sinks/_socket.py +0 -0
  22. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev44}/src/log_foundry/sinks/_time.py +0 -0
  23. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev44}/src/log_foundry/sinks/base.py +0 -0
  24. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev44}/src/log_foundry/sinks/callback.py +0 -0
  25. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev44}/src/log_foundry/sinks/clickhouse.py +0 -0
  26. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev44}/src/log_foundry/sinks/datadog.py +0 -0
  27. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev44}/src/log_foundry/sinks/elasticsearch.py +0 -0
  28. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev44}/src/log_foundry/sinks/eventhubs.py +0 -0
  29. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev44}/src/log_foundry/sinks/file.py +0 -0
  30. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev44}/src/log_foundry/sinks/filtering.py +0 -0
  31. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev44}/src/log_foundry/sinks/firehose.py +0 -0
  32. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev44}/src/log_foundry/sinks/honeycomb.py +0 -0
  33. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev44}/src/log_foundry/sinks/http.py +0 -0
  34. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev44}/src/log_foundry/sinks/kafka.py +0 -0
  35. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev44}/src/log_foundry/sinks/kinesis.py +0 -0
  36. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev44}/src/log_foundry/sinks/logging_sink.py +0 -0
  37. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev44}/src/log_foundry/sinks/logstash.py +0 -0
  38. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev44}/src/log_foundry/sinks/loki.py +0 -0
  39. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev44}/src/log_foundry/sinks/mongodb.py +0 -0
  40. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev44}/src/log_foundry/sinks/multi.py +0 -0
  41. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev44}/src/log_foundry/sinks/nats.py +0 -0
  42. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev44}/src/log_foundry/sinks/newrelic.py +0 -0
  43. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev44}/src/log_foundry/sinks/postgres.py +0 -0
  44. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev44}/src/log_foundry/sinks/pubsub.py +0 -0
  45. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev44}/src/log_foundry/sinks/rabbitmq.py +0 -0
  46. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev44}/src/log_foundry/sinks/redis.py +0 -0
  47. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev44}/src/log_foundry/sinks/sentry.py +0 -0
  48. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev44}/src/log_foundry/sinks/sns.py +0 -0
  49. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev44}/src/log_foundry/sinks/splunk.py +0 -0
  50. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev44}/src/log_foundry/sinks/sqlite.py +0 -0
  51. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev44}/src/log_foundry/sinks/sqs.py +0 -0
  52. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev44}/src/log_foundry/sinks/stdout.py +0 -0
  53. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev44}/src/log_foundry/sinks/syslog.py +0 -0
  54. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev44}/src/log_foundry/sinks/transform.py +0 -0
  55. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev44}/src/log_foundry/sinks/util.py +0 -0
  56. {log_foundry-0.10.2.dev42 → log_foundry-0.10.2.dev44}/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.dev44
4
4
  Summary: Generate logs for your console and JSON events for downstream consumption.
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -327,8 +327,8 @@ await fetch(4127) # one trace_id; load's parent_span_id == fetch's span_id
327
327
  Emit your own structured events from inside a decorated call with the level functions
328
328
  `debug` / `info` / `warning` / `error` / `critical`. Each appends one event to the current
329
329
  span, so the whole call's logs flush together and share its `trace_id` / `span_id`. Keyword
330
- arguments land in the event's `fields`; the function name is captured, but arguments and
331
- return values never are.
330
+ arguments land in the event's `fields` — except the three reserved names below; the function
331
+ name is captured, but arguments and return values never are.
332
332
 
333
333
  ```python
334
334
  @lf.trace
@@ -341,7 +341,8 @@ def process_payment(user_id: int) -> str:
341
341
 
342
342
  - **`set_baggage(**kv)`** — attach trace-scoped context that is merged into the `fields` of
343
343
  every subsequent event in the same execution flow. Precedence, lowest to highest: config
344
- `defaults` → span `defaults` → baggage → per-call `fields`. **Trace-scoped means it ends
344
+ `defaults` → span `defaults` → baggage → per-call fields (`fields=` first, then `**kwargs`
345
+ over it). **Trace-scoped means it ends
345
346
  with the trace:** when the outermost `@trace` call returns *or raises*, the baggage in effect
346
347
  before it is restored, so one request's keys do not reach the next request's events. Nested
347
348
  calls do not reset — baggage set three calls deep stays visible to its parent and to the
@@ -352,6 +353,18 @@ def process_payment(user_id: int) -> str:
352
353
  - **`echo=True`** — *additionally* write a human-readable `LEVEL message` line to the console
353
354
  (`sys.stderr` by default), synchronously, without waiting for the async flush. The event
354
355
  still rides the normal pipeline to the sink — echo never redirects.
356
+ - **`fields={...}`** — the escape hatch. `message`, `echo` and `fields` are **reserved**: they
357
+ are parameters, so `info("x", echo="the payload we echoed back")` would switch on the console
358
+ line instead of recording a field. Pass them — and any key that is not a Python identifier —
359
+ through `fields=`:
360
+
361
+ ```python
362
+ lf.info("proxied", fields={"echo": "the payload we echoed back", "content-type": "text/json"})
363
+ ```
364
+
365
+ It reaches its own name too (`fields={"fields": ...}`), so every reserved word has exactly one
366
+ route through. A key given both ways takes the keyword's value, since `**kwargs` is what you
367
+ wrote at the call site and `fields=` is usually a mapping built elsewhere.
355
368
  - **Orphan logs** — a level call made with no active span is not dropped: it emits a standalone
356
369
  one-event span with a fresh `trace_id`, flushed straight to the sink.
357
370
 
@@ -291,8 +291,8 @@ await fetch(4127) # one trace_id; load's parent_span_id == fetch's span_id
291
291
  Emit your own structured events from inside a decorated call with the level functions
292
292
  `debug` / `info` / `warning` / `error` / `critical`. Each appends one event to the current
293
293
  span, so the whole call's logs flush together and share its `trace_id` / `span_id`. Keyword
294
- arguments land in the event's `fields`; the function name is captured, but arguments and
295
- return values never are.
294
+ arguments land in the event's `fields` — except the three reserved names below; the function
295
+ name is captured, but arguments and return values never are.
296
296
 
297
297
  ```python
298
298
  @lf.trace
@@ -305,7 +305,8 @@ def process_payment(user_id: int) -> str:
305
305
 
306
306
  - **`set_baggage(**kv)`** — attach trace-scoped context that is merged into the `fields` of
307
307
  every subsequent event in the same execution flow. Precedence, lowest to highest: config
308
- `defaults` → span `defaults` → baggage → per-call `fields`. **Trace-scoped means it ends
308
+ `defaults` → span `defaults` → baggage → per-call fields (`fields=` first, then `**kwargs`
309
+ over it). **Trace-scoped means it ends
309
310
  with the trace:** when the outermost `@trace` call returns *or raises*, the baggage in effect
310
311
  before it is restored, so one request's keys do not reach the next request's events. Nested
311
312
  calls do not reset — baggage set three calls deep stays visible to its parent and to the
@@ -316,6 +317,18 @@ def process_payment(user_id: int) -> str:
316
317
  - **`echo=True`** — *additionally* write a human-readable `LEVEL message` line to the console
317
318
  (`sys.stderr` by default), synchronously, without waiting for the async flush. The event
318
319
  still rides the normal pipeline to the sink — echo never redirects.
320
+ - **`fields={...}`** — the escape hatch. `message`, `echo` and `fields` are **reserved**: they
321
+ are parameters, so `info("x", echo="the payload we echoed back")` would switch on the console
322
+ line instead of recording a field. Pass them — and any key that is not a Python identifier —
323
+ through `fields=`:
324
+
325
+ ```python
326
+ lf.info("proxied", fields={"echo": "the payload we echoed back", "content-type": "text/json"})
327
+ ```
328
+
329
+ It reaches its own name too (`fields={"fields": ...}`), so every reserved word has exactly one
330
+ route through. A key given both ways takes the keyword's value, since `**kwargs` is what you
331
+ wrote at the call site and `fields=` is usually a mapping built elsewhere.
319
332
  - **Orphan logs** — a level call made with no active span is not dropped: it emits a standalone
320
333
  one-event span with a fresh `trace_id`, flushed straight to the sink.
321
334
 
@@ -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.dev44"
24
24
 
25
25
  [project.optional-dependencies]
26
26
  aws = ["boto3>=1.43.61"] # SQSSink, SNSSink, KinesisSink, FirehoseSink
@@ -2,6 +2,8 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from typing import TYPE_CHECKING
6
+
5
7
  from log_foundry import _diag, context
6
8
  from log_foundry.config import _ensure_sink
7
9
  from log_foundry.console import ConsoleWriter
@@ -10,6 +12,9 @@ from log_foundry.decorator import _note_orphan_emit
10
12
  from log_foundry.ids import new_span_id, new_trace_id
11
13
  from log_foundry.model import Span, build_event
12
14
 
15
+ if TYPE_CHECKING:
16
+ from collections.abc import Mapping
17
+
13
18
  __all__ = [
14
19
  "critical",
15
20
  "debug",
@@ -100,13 +105,66 @@ def _log(level: str, message: str, echo: bool, fields: dict[str, object]) -> Non
100
105
  _diag.absorbed("echoing to the console", exc)
101
106
 
102
107
 
103
- def debug(message: str, *, echo: bool = False, **fields: object) -> None:
108
+ def _merge(fields: Mapping[str, object] | None, kv: dict[str, object]) -> dict[str, object]:
109
+ """Combines the explicit ``fields=`` mapping with the keyword form.
110
+
111
+ ``echo`` and ``message`` were reserved words stolen from the caller's field namespace:
112
+ ``info("x", echo="incoming payload echoed back")`` dropped a real field *and* turned on an
113
+ unwanted console line, and ``message=`` raised ``TypeError`` (SPEC-034 FR-004). ``fields=``
114
+ is the way round all three — including its own name, which is why the escape hatch can
115
+ express every key including the ones this signature reserves — and the only way to pass a
116
+ key that is not a Python identifier.
117
+
118
+ The keyword form wins a collision. ``fields=`` is the bulk route, usually a mapping built
119
+ somewhere else; ``**kv`` is what the caller wrote at this call site, and a literal
120
+ overriding a base is what ``{**base, **overrides}`` already means in the language.
121
+
122
+ A ``fields=`` that is not a mapping is **absorbed, not raised**. This helper runs in the
123
+ emitter, before :func:`_log`, so it sits outside that function's orphan guard entirely: an
124
+ unguarded ``{**fields, **kv}`` propagated a ``TypeError`` into the application on all four
125
+ entry paths, including the orphan one where SPEC-025's promise holds today, and inside a span
126
+ the decorator then recorded ``status=error`` with an ``error.type`` the caller's code never
127
+ raised. It was also asymmetric in a way neither reading defends — ``fields=[]`` was silently
128
+ ignored while ``fields=["x"]`` crashed. The library coerces rather than validates (SPEC-017),
129
+ so the tolerant half is the one that matches, and the fault is announced by type.
130
+
131
+ Args:
132
+ fields: The explicit mapping, or ``None``.
133
+ kv: The keyword-collected fields.
134
+
135
+ Returns:
136
+ One mapping, with ``kv`` taking precedence. The caller's ``fields`` is never mutated, and a
137
+ ``fields=`` that could not be merged is dropped rather than costing the event.
138
+
139
+ Raises:
140
+ None.
141
+ """
142
+ if not fields:
143
+ return kv
144
+ try:
145
+ return {**fields, **kv}
146
+ except Exception as exc:
147
+ _diag.absorbed("merging the fields= argument", exc, "the fields= argument was ignored")
148
+ return kv
149
+
150
+
151
+ def debug(
152
+ message: str,
153
+ *,
154
+ echo: bool = False,
155
+ fields: Mapping[str, object] | None = None,
156
+ **kv: object,
157
+ ) -> None:
104
158
  """Emits a ``DEBUG`` event on the current span, or a standalone orphan span.
105
159
 
106
160
  Args:
107
161
  message: The message text.
108
162
  echo: Whether to also write a human-readable console line.
109
- **fields: Per-call structured fields.
163
+ fields: Per-call structured fields, for names ``**kv`` cannot express — the three
164
+ reserved words ``message``, ``echo`` and ``fields`` itself, and any key that is not a
165
+ Python identifier at all. Merged **under** ``**kv``, so a name given both ways takes the
166
+ keyword's value.
167
+ **kv: Per-call structured fields.
110
168
 
111
169
  Returns:
112
170
  None.
@@ -114,16 +172,26 @@ def debug(message: str, *, echo: bool = False, **fields: object) -> None:
114
172
  Raises:
115
173
  None.
116
174
  """
117
- _log("DEBUG", message, echo, fields)
175
+ _log("DEBUG", message, echo, _merge(fields, kv))
118
176
 
119
177
 
120
- def info(message: str, *, echo: bool = False, **fields: object) -> None:
178
+ def info(
179
+ message: str,
180
+ *,
181
+ echo: bool = False,
182
+ fields: Mapping[str, object] | None = None,
183
+ **kv: object,
184
+ ) -> None:
121
185
  """Emits an ``INFO`` event on the current span, or a standalone orphan span.
122
186
 
123
187
  Args:
124
188
  message: The message text.
125
189
  echo: Whether to also write a human-readable console line.
126
- **fields: Per-call structured fields.
190
+ fields: Per-call structured fields, for names ``**kv`` cannot express — the three
191
+ reserved words ``message``, ``echo`` and ``fields`` itself, and any key that is not a
192
+ Python identifier at all. Merged **under** ``**kv``, so a name given both ways takes the
193
+ keyword's value.
194
+ **kv: Per-call structured fields.
127
195
 
128
196
  Returns:
129
197
  None.
@@ -131,16 +199,26 @@ def info(message: str, *, echo: bool = False, **fields: object) -> None:
131
199
  Raises:
132
200
  None.
133
201
  """
134
- _log("INFO", message, echo, fields)
202
+ _log("INFO", message, echo, _merge(fields, kv))
135
203
 
136
204
 
137
- def warning(message: str, *, echo: bool = False, **fields: object) -> None:
205
+ def warning(
206
+ message: str,
207
+ *,
208
+ echo: bool = False,
209
+ fields: Mapping[str, object] | None = None,
210
+ **kv: object,
211
+ ) -> None:
138
212
  """Emits a ``WARNING`` event on the current span, or a standalone orphan span.
139
213
 
140
214
  Args:
141
215
  message: The message text.
142
216
  echo: Whether to also write a human-readable console line.
143
- **fields: Per-call structured fields.
217
+ fields: Per-call structured fields, for names ``**kv`` cannot express — the three
218
+ reserved words ``message``, ``echo`` and ``fields`` itself, and any key that is not a
219
+ Python identifier at all. Merged **under** ``**kv``, so a name given both ways takes the
220
+ keyword's value.
221
+ **kv: Per-call structured fields.
144
222
 
145
223
  Returns:
146
224
  None.
@@ -148,16 +226,26 @@ def warning(message: str, *, echo: bool = False, **fields: object) -> None:
148
226
  Raises:
149
227
  None.
150
228
  """
151
- _log("WARNING", message, echo, fields)
229
+ _log("WARNING", message, echo, _merge(fields, kv))
152
230
 
153
231
 
154
- def error(message: str, *, echo: bool = False, **fields: object) -> None:
232
+ def error(
233
+ message: str,
234
+ *,
235
+ echo: bool = False,
236
+ fields: Mapping[str, object] | None = None,
237
+ **kv: object,
238
+ ) -> None:
155
239
  """Emits an ``ERROR`` event on the current span, or a standalone orphan span.
156
240
 
157
241
  Args:
158
242
  message: The message text.
159
243
  echo: Whether to also write a human-readable console line.
160
- **fields: Per-call structured fields.
244
+ fields: Per-call structured fields, for names ``**kv`` cannot express — the three
245
+ reserved words ``message``, ``echo`` and ``fields`` itself, and any key that is not a
246
+ Python identifier at all. Merged **under** ``**kv``, so a name given both ways takes the
247
+ keyword's value.
248
+ **kv: Per-call structured fields.
161
249
 
162
250
  Returns:
163
251
  None.
@@ -165,16 +253,26 @@ def error(message: str, *, echo: bool = False, **fields: object) -> None:
165
253
  Raises:
166
254
  None.
167
255
  """
168
- _log("ERROR", message, echo, fields)
256
+ _log("ERROR", message, echo, _merge(fields, kv))
169
257
 
170
258
 
171
- def critical(message: str, *, echo: bool = False, **fields: object) -> None:
259
+ def critical(
260
+ message: str,
261
+ *,
262
+ echo: bool = False,
263
+ fields: Mapping[str, object] | None = None,
264
+ **kv: object,
265
+ ) -> None:
172
266
  """Emits a ``CRITICAL`` event on the current span, or a standalone orphan span.
173
267
 
174
268
  Args:
175
269
  message: The message text.
176
270
  echo: Whether to also write a human-readable console line.
177
- **fields: Per-call structured fields.
271
+ fields: Per-call structured fields, for names ``**kv`` cannot express — the three
272
+ reserved words ``message``, ``echo`` and ``fields`` itself, and any key that is not a
273
+ Python identifier at all. Merged **under** ``**kv``, so a name given both ways takes the
274
+ keyword's value.
275
+ **kv: Per-call structured fields.
178
276
 
179
277
  Returns:
180
278
  None.
@@ -182,4 +280,4 @@ def critical(message: str, *, echo: bool = False, **fields: object) -> None:
182
280
  Raises:
183
281
  None.
184
282
  """
185
- _log("CRITICAL", message, echo, fields)
283
+ _log("CRITICAL", message, echo, _merge(fields, kv))
@@ -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")