structguru 1.3.0__tar.gz → 1.4.0__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 (48) hide show
  1. {structguru-1.3.0 → structguru-1.4.0}/Cargo.lock +2 -2
  2. {structguru-1.3.0 → structguru-1.4.0}/Cargo.toml +1 -1
  3. {structguru-1.3.0 → structguru-1.4.0}/PKG-INFO +45 -16
  4. {structguru-1.3.0 → structguru-1.4.0}/README.md +44 -15
  5. structguru-1.4.0/crates/structguru-core/src/clock.rs +113 -0
  6. {structguru-1.3.0 → structguru-1.4.0}/crates/structguru-core/src/filter.rs +62 -33
  7. {structguru-1.3.0 → structguru-1.4.0}/crates/structguru-core/src/lib.rs +7 -5
  8. {structguru-1.3.0 → structguru-1.4.0}/crates/structguru-core/src/render.rs +513 -125
  9. {structguru-1.3.0 → structguru-1.4.0}/crates/structguru-core/src/value.rs +90 -1
  10. {structguru-1.3.0 → structguru-1.4.0}/crates/structguru-core/src/worker.rs +148 -96
  11. {structguru-1.3.0 → structguru-1.4.0}/crates/structguru-py/src/dispatch.rs +451 -107
  12. {structguru-1.3.0 → structguru-1.4.0}/crates/structguru-py/src/lib.rs +248 -69
  13. {structguru-1.3.0 → structguru-1.4.0}/pyproject.toml +1 -1
  14. {structguru-1.3.0 → structguru-1.4.0}/src/structguru/__init__.py +1 -1
  15. {structguru-1.3.0 → structguru-1.4.0}/src/structguru/_contextvars.py +19 -3
  16. {structguru-1.3.0 → structguru-1.4.0}/src/structguru/_native_dispatch.py +17 -3
  17. {structguru-1.3.0 → structguru-1.4.0}/src/structguru/_native_env.py +3 -0
  18. {structguru-1.3.0 → structguru-1.4.0}/src/structguru/_runtime.py +197 -64
  19. {structguru-1.3.0 → structguru-1.4.0}/src/structguru/core.py +393 -97
  20. structguru-1.4.0/src/structguru/exceptions.py +511 -0
  21. structguru-1.4.0/src/structguru/integrations/_util.py +345 -0
  22. {structguru-1.3.0 → structguru-1.4.0}/src/structguru/integrations/asgi.py +67 -27
  23. structguru-1.4.0/src/structguru/integrations/celery.py +244 -0
  24. {structguru-1.3.0 → structguru-1.4.0}/src/structguru/integrations/django.py +77 -11
  25. structguru-1.4.0/src/structguru/integrations/flask.py +220 -0
  26. {structguru-1.3.0 → structguru-1.4.0}/src/structguru/integrations/grpc.py +7 -4
  27. {structguru-1.3.0 → structguru-1.4.0}/src/structguru/integrations/requests.py +30 -3
  28. {structguru-1.3.0 → structguru-1.4.0}/src/structguru/integrations/sentry.py +3 -1
  29. {structguru-1.3.0 → structguru-1.4.0}/src/structguru/integrations/sqlalchemy.py +4 -2
  30. {structguru-1.3.0 → structguru-1.4.0}/src/structguru/integrations/stdlib.py +27 -3
  31. structguru-1.3.0/crates/structguru-core/src/clock.rs +0 -52
  32. structguru-1.3.0/src/structguru/exceptions.py +0 -167
  33. structguru-1.3.0/src/structguru/integrations/_util.py +0 -57
  34. structguru-1.3.0/src/structguru/integrations/celery.py +0 -120
  35. structguru-1.3.0/src/structguru/integrations/flask.py +0 -82
  36. {structguru-1.3.0 → structguru-1.4.0}/LICENSE +0 -0
  37. {structguru-1.3.0 → structguru-1.4.0}/crates/structguru-core/Cargo.toml +0 -0
  38. {structguru-1.3.0 → structguru-1.4.0}/crates/structguru-py/Cargo.toml +0 -0
  39. {structguru-1.3.0 → structguru-1.4.0}/src/structguru/_levels.py +0 -0
  40. {structguru-1.3.0 → structguru-1.4.0}/src/structguru/config.py +0 -0
  41. {structguru-1.3.0 → structguru-1.4.0}/src/structguru/integrations/__init__.py +0 -0
  42. {structguru-1.3.0 → structguru-1.4.0}/src/structguru/integrations/_stdlib_env.py +0 -0
  43. {structguru-1.3.0 → structguru-1.4.0}/src/structguru/integrations/httpx.py +0 -0
  44. {structguru-1.3.0 → structguru-1.4.0}/src/structguru/metrics.py +0 -0
  45. {structguru-1.3.0 → structguru-1.4.0}/src/structguru/otel.py +0 -0
  46. {structguru-1.3.0 → structguru-1.4.0}/src/structguru/py.typed +0 -0
  47. {structguru-1.3.0 → structguru-1.4.0}/src/structguru/redaction.py +0 -0
  48. {structguru-1.3.0 → structguru-1.4.0}/src/structguru/settings.py +0 -0
@@ -245,7 +245,7 @@ dependencies = [
245
245
 
246
246
  [[package]]
247
247
  name = "structguru-core"
248
- version = "1.3.0"
248
+ version = "1.4.0"
249
249
  dependencies = [
250
250
  "fancy-regex",
251
251
  "fastrand",
@@ -257,7 +257,7 @@ dependencies = [
257
257
 
258
258
  [[package]]
259
259
  name = "structguru-py"
260
- version = "1.3.0"
260
+ version = "1.4.0"
261
261
  dependencies = [
262
262
  "pyo3",
263
263
  "regex",
@@ -3,7 +3,7 @@ members = ["crates/structguru-core", "crates/structguru-py"]
3
3
  resolver = "2"
4
4
 
5
5
  [workspace.package]
6
- version = "1.3.0"
6
+ version = "1.4.0"
7
7
  edition = "2024"
8
8
  rust-version = "1.89"
9
9
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: structguru
3
- Version: 1.3.0
3
+ Version: 1.4.0
4
4
  Classifier: Development Status :: 5 - Production/Stable
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -157,6 +157,7 @@ thresholds. Unknown names, booleans and negative integers raise `ValueError`.
157
157
  | `STRUCTGURU_AUTOCONFIGURE` | enabled | inverse of `STRUCTGURU_LEGACY` |
158
158
 
159
159
  New names win over their fallbacks; old names remain supported without warnings.
160
+ Level variables accept a name or a non-negative integer threshold (`LOG_LEVEL=10`).
160
161
  Rate limits use `MAX` or `MAX/PERIOD`, with integer counts and seconds for the period.
161
162
  Autoconfiguration accepts `1/0`, `true/false`, `yes/no`, or `on/off` and controls import
162
163
  only. Set it to `0` before import to configure explicitly later. Invalid selected values
@@ -195,6 +196,17 @@ logger.info("User {user_id} logged in", user_id=42, ip="10.0.0.1")
195
196
  # user_id is consumed by formatting and not duplicated
196
197
  ```
197
198
 
199
+ The message is formatted whenever positional or keyword arguments are passed, as
200
+ `str.format` would (`"a }} b"` becomes `"a } b"`); `exc_info` and `stack_info` alone
201
+ do not count, so `logger.exception("{{x}}")` logs `{{x}}` like `logger.error`. Fields
202
+ named in a nested format spec (`"{x:{width}}"`) are consumed too. A placeholder that
203
+ names a sensitive key — as the root (`{token}`) or an attribute or index
204
+ (`{creds[password]}`) — is interpolated as `[REDACTED]`. A value interpolated whole
205
+ (`{creds}`) is covered by `sensitive_patterns` only. The message is positional-only,
206
+ so `message=` and `self=` are ordinary fields. A template that fails to format, or a
207
+ message whose `str()` raises, is logged raw or as a placeholder with a one-shot
208
+ `UserWarning`; logging calls do not raise for their arguments.
209
+
198
210
  ### Bound context
199
211
 
200
212
  ```python
@@ -240,6 +252,14 @@ logger.remove(handler_id)
240
252
  logger.remove()
241
253
  ```
242
254
 
255
+ Handler ids are process-wide: any `Logger` can remove a sink that another one added.
256
+ Removing an id that names no active sink raises `ValueError` (a non-integer raises
257
+ `TypeError`), as in loguru; `remove()` without an id removes the sinks added through
258
+ that logger and its `bind()`/`opt()` children. `level` accepts a name or a non-negative
259
+ integer and raises `ValueError` for anything else, like `configure(level=...)`.
260
+ A `logging.Handler` passed to `add()` keeps its own level and formatter: its formatter
261
+ wraps the rendered line, only `level` gates what it receives, and `remove()` closes it.
262
+
243
263
  On Unix, new files created by either `logger.add(path)` or the native rotating
244
264
  file sink are owner-only (`0600`). Existing files retain their permissions.
245
265
 
@@ -302,6 +322,11 @@ update(
302
322
  )
303
323
  ```
304
324
 
325
+ Patterns apply to the message, field names, and string values at any depth,
326
+ and to integers by their decimal digits (a matching integer is emitted as the
327
+ redacted string). If redaction makes two keys of one map equal, the record
328
+ keeps one key with the last value.
329
+
305
330
  Patterns run on Rust's linear-time regex engine (no ReDoS), which rejects
306
331
  look-around and backreferences at `update()` time. Most look-behinds rewrite
307
332
  as capture groups — `(?<=password=)\S+` becomes `(password=)\S+` with
@@ -406,10 +431,13 @@ traceback still ship:
406
431
  - `<cycle: dict>` for a container that refers back to itself.
407
432
  - `<max depth exceeded>` beyond 64 levels of nesting.
408
433
 
409
- Integers outside the 64-bit range are written as JSON numbers, non-string
434
+ Integers outside the 64-bit range are written as JSON numbers, `int` and
435
+ `float` subclasses (such as `numpy.float64`) are written by value, non-string
410
436
  mapping keys are rendered as strings (`{200: 3}` becomes `{"200": 3}`), and
411
- unpaired surrogates in text become U+FFFD. Markers are redacted like any other
412
- string. The policy applies to native `logger` fields and to `extra=` fields
437
+ each unpaired surrogate in text becomes one U+FFFD while a split surrogate pair
438
+ decodes to its character. Keys that render to the same text never produce
439
+ duplicate JSON keys: the later value wins at the earlier key's position.
440
+ Markers are redacted like any other string. The policy applies to native `logger` fields and to `extra=` fields
413
441
  bridged from the standard library alike.
414
442
 
415
443
  ```python
@@ -457,15 +485,15 @@ Public API:
457
485
  Behavior notes:
458
486
 
459
487
  - **Overflow**: the default `maxsize=8192` uses `overflow="block"` to wait for queue space. Use `overflow="drop"` for drop-newest behavior with metrics and rate-limited warnings. `maxsize=0` explicitly opts into an unbounded queue.
460
- - **Redaction, level filtering, exceptions, and OpenTelemetry** injection are supported natively; redaction covers the message and all structured string values before rendering or Sentry export. `sensitive_keys` overrides the default redaction keys. Rust's linear-time regex engine rejects backreferences and look-around with `ValueError` at configuration time.
488
+ - **Redaction, level filtering, exceptions, and OpenTelemetry** injection are supported natively; redaction covers the message, field names, string values, and integers at any depth before rendering or Sentry export, and message placeholders that name a sensitive key are interpolated as `[REDACTED]` (see the redaction API reference for the exact coverage). `sensitive_keys` overrides the default redaction keys. Rust's linear-time regex engine rejects backreferences and look-around with `ValueError` at configuration time.
461
489
  - **Sampling & rate limiting** (`sample_rate`, `rate_limit_max`, `rate_limit_period`) are applied as native pre-render filters — dropped records cost zero rendering. `sampled` and `rate_limited` counters are distinct from the transport `dropped` counter. `sample_max_level` restricts sampling to records at or below that level; more severe records always pass.
462
- - **Metric hooks** (`metric_processor=...`) invoke a structlog-style processor (e.g. `MetricProcessor`) for every *kept* record on the caller's thread, with `(None, method, {"event": message, **fields})`. Dropped records (level/sampling/rate-limit) never reach it; hook errors are swallowed.
463
- - **Fork/shutdown safe** — the writer is flushed on exit and respawned in forked children (gunicorn/celery prefork). Rotating-file writers sharing a path coordinate through an owner-only `.lock` sidecar; distributed hosts should still prefer stdout and an external collector.
464
- - **Structured exceptions** (`structured_exceptions=True`) render `type`, `message`, `module`, and frames as a dictionary, with optional redacted/truncated locals controlled by the `exception_*` options. Exception groups include nested members under `exceptions`, with the same frame limits and redaction. Traversal stops at ten nesting levels or 100 exception nodes; `exceptions_truncated` counts omitted direct children. Failed message conversions produce a marker instead of interrupting logging.
465
- - **`stack_info` is supported natively**: the stack is captured in Python and rendered in the same position as `StackInfoRenderer` (`stack` between `service` and `message`). Unlike the standard path, the stack ends at the *user's* calling frame (structguru-internal frames are skipped, the way structlog skips its own).
466
- - **Console mode** (`format="console"`): renders colored, human-readable lines instead of JSON — structguru's own stable dev format (`<timestamp> [<LEVEL>] <message> k=v`), with ANSI colors by default on a TTY. Override with `colors=True/False`.
490
+ - **Metric hooks** (`metric_processor=...`) invoke a structlog-style processor (e.g. `MetricProcessor`) for every *kept* record on the caller's thread, with `(None, method, {"event": message, **fields})`; the message stays in `"event"` even when a field has that name. Dropped records (level/sampling/rate-limit) never reach it; hook errors are swallowed.
491
+ - **Fork/shutdown safe** — the writer is flushed on exit and respawned in forked children (gunicorn/celery prefork). The exit drain is registered when structguru is imported, so `atexit` handlers registered afterwards can still log. Rotating-file writers sharing a path coordinate through an owner-only `.lock` sidecar; distributed hosts should still prefer stdout and an external collector.
492
+ - **Structured exceptions** (`structured_exceptions=True`) render `type`, `message`, `module`, and frames as a dictionary, with optional redacted/truncated locals controlled by the `exception_*` options. A local named like a sensitive key is redacted, and so is any value under a sensitive key inside a mapping, list, tuple, named tuple, set, dataclass with its generated `__repr__`, or `SimpleNamespace` (walked with bounded depth and size, cycle-safe); other objects' own `repr()` text is covered by `sensitive_patterns`, which see each local's full rendering before it is truncated to `exception_max_local_repr`. Exception groups include nested members under `exceptions`, with the same frame limits and redaction. Traversal stops at ten nesting levels or 100 exception nodes; `exceptions_truncated` counts omitted direct children. Failed message conversions produce a marker instead of interrupting logging.
493
+ - **`stack_info` is supported natively**: the stack is captured in Python and rendered in the same position as `StackInfoRenderer` (`stack` between `service` and `message`). Unlike the standard path, the stack ends at the *user's* calling frame (frames of the `structguru` package are skipped, the way structlog skips its own).
494
+ - **Console mode** (`format="console"`): renders colored, human-readable lines instead of JSON — structguru's own stable dev format (`<timestamp> [<LEVEL>] <message> k=v`), with ANSI colors by default on a TTY. Override with `colors=True/False`. String values are double-quoted with `"` and `\` escaped, keys containing spaces, `=`, quotes, or backslashes are quoted, and control characters, U+2028/U+2029, and bidirectional controls are escaped everywhere, so a field key or value cannot forge another field or line. Floats use the JSON renderer's text (`1.0`, `1e+300`); the stack and a formatted `exception` follow as indented blocks.
467
495
  - **File sinks** (`file_path=...`): write to a rotating file natively. Defaults mirror `RotatingFileHandler` (50 MB, 5 backups); configure via `file_max_bytes`/`file_backup_count`. Set `also_stdout=True` to mirror output to both file and stdout (e.g. container + persistent log).
468
- - **Callable sinks** (`callable_sinks=[fn, ...]`): use a bounded queue (`callable_queue_maxsize=1024`). `overflow="block"` provides lossless backpressure; `overflow="drop"` reports `callable_dropped` metrics. Flush and lifecycle operations drain queued calls.
496
+ - **Callable sinks** (`callable_sinks=[fn, ...]`): use a bounded queue (`callable_queue_maxsize=1024`). `overflow="block"` provides lossless backpressure; `overflow="drop"` reports `callable_dropped` metrics. One background thread at a time calls the sinks, in log order, also across reconfiguration; `logger.add()` never waits for the queue. Sink exceptions are counted in `callable_errors` with a rate-limited warning. Flush and lifecycle operations drain queued calls; a sink that makes no progress for 10 seconds is abandoned with a warning instead of blocking them.
469
497
  - **Sentry integration** (`sentry_processor=SentryProcessor(...)`): receives the already-redacted event and raw `exc_info` only for exception capture.
470
498
  - **Scope**: the native renderer covers JSON and console rendering, file/stdout/callable sinks, redaction, sampling/rate limiting, metrics, exceptions, and stack information. `logger.add()` sinks receive native and stdlib records.
471
499
 
@@ -473,9 +501,9 @@ Shutdown and reconfiguration drain records already accepted by the native queue.
473
501
  Calls still formatting or waiting for space may be rejected when their writer closes,
474
502
  even in block mode. `lifecycle_metrics()["rejected"]` counts these native deliveries
475
503
  without queue-full warnings and survives shutdown and configuration changes; forked
476
- children start at zero. Another destination, such as a synchronous stream, may already
477
- have received the event. Calls begun while logging is disabled are no-ops and are not
478
- counted. See the [lifecycle contract](docs/api/config.md#runtime-control) for details.
504
+ children start at zero. A rejected record reaches no other destination either: the
505
+ synchronous stream, callable sinks, and Sentry follow the native writer's outcome.
506
+ Calls begun while logging is disabled are no-ops and are not counted. See the [lifecycle contract](docs/api/config.md#runtime-control) for details.
479
507
 
480
508
  ## Framework integrations
481
509
 
@@ -501,7 +529,7 @@ logging dictionary, application and launcher examples, and access-log options.
501
529
  from structguru.integrations.celery import setup_celery_logging
502
530
 
503
531
  setup_celery_logging(propagate_context=True, context_keys=["request_id"])
504
- # Binds task_id/task_name to context, propagates selected keys via headers
532
+ # Binds task_id/task_name; propagates selected keys (redacted, JSON-safe) via headers
505
533
  ```
506
534
 
507
535
  ### Flask
@@ -622,7 +650,8 @@ bridge = install_stdlib_bridge_from_env()
622
650
  ## Requirements
623
651
 
624
652
  - Python 3.11+
625
- - The compiled Rust extension (shipped as abi3 wheels for Linux/macOS/Windows)
653
+ - The compiled Rust extension (shipped as abi3 wheels for Linux/macOS/Windows, plus
654
+ free-threaded CPython 3.14t wheels for Linux x86_64, macOS arm64, and Windows x64)
626
655
 
627
656
  ## Documentation & Examples
628
657
 
@@ -113,6 +113,7 @@ thresholds. Unknown names, booleans and negative integers raise `ValueError`.
113
113
  | `STRUCTGURU_AUTOCONFIGURE` | enabled | inverse of `STRUCTGURU_LEGACY` |
114
114
 
115
115
  New names win over their fallbacks; old names remain supported without warnings.
116
+ Level variables accept a name or a non-negative integer threshold (`LOG_LEVEL=10`).
116
117
  Rate limits use `MAX` or `MAX/PERIOD`, with integer counts and seconds for the period.
117
118
  Autoconfiguration accepts `1/0`, `true/false`, `yes/no`, or `on/off` and controls import
118
119
  only. Set it to `0` before import to configure explicitly later. Invalid selected values
@@ -151,6 +152,17 @@ logger.info("User {user_id} logged in", user_id=42, ip="10.0.0.1")
151
152
  # user_id is consumed by formatting and not duplicated
152
153
  ```
153
154
 
155
+ The message is formatted whenever positional or keyword arguments are passed, as
156
+ `str.format` would (`"a }} b"` becomes `"a } b"`); `exc_info` and `stack_info` alone
157
+ do not count, so `logger.exception("{{x}}")` logs `{{x}}` like `logger.error`. Fields
158
+ named in a nested format spec (`"{x:{width}}"`) are consumed too. A placeholder that
159
+ names a sensitive key — as the root (`{token}`) or an attribute or index
160
+ (`{creds[password]}`) — is interpolated as `[REDACTED]`. A value interpolated whole
161
+ (`{creds}`) is covered by `sensitive_patterns` only. The message is positional-only,
162
+ so `message=` and `self=` are ordinary fields. A template that fails to format, or a
163
+ message whose `str()` raises, is logged raw or as a placeholder with a one-shot
164
+ `UserWarning`; logging calls do not raise for their arguments.
165
+
154
166
  ### Bound context
155
167
 
156
168
  ```python
@@ -196,6 +208,14 @@ logger.remove(handler_id)
196
208
  logger.remove()
197
209
  ```
198
210
 
211
+ Handler ids are process-wide: any `Logger` can remove a sink that another one added.
212
+ Removing an id that names no active sink raises `ValueError` (a non-integer raises
213
+ `TypeError`), as in loguru; `remove()` without an id removes the sinks added through
214
+ that logger and its `bind()`/`opt()` children. `level` accepts a name or a non-negative
215
+ integer and raises `ValueError` for anything else, like `configure(level=...)`.
216
+ A `logging.Handler` passed to `add()` keeps its own level and formatter: its formatter
217
+ wraps the rendered line, only `level` gates what it receives, and `remove()` closes it.
218
+
199
219
  On Unix, new files created by either `logger.add(path)` or the native rotating
200
220
  file sink are owner-only (`0600`). Existing files retain their permissions.
201
221
 
@@ -258,6 +278,11 @@ update(
258
278
  )
259
279
  ```
260
280
 
281
+ Patterns apply to the message, field names, and string values at any depth,
282
+ and to integers by their decimal digits (a matching integer is emitted as the
283
+ redacted string). If redaction makes two keys of one map equal, the record
284
+ keeps one key with the last value.
285
+
261
286
  Patterns run on Rust's linear-time regex engine (no ReDoS), which rejects
262
287
  look-around and backreferences at `update()` time. Most look-behinds rewrite
263
288
  as capture groups — `(?<=password=)\S+` becomes `(password=)\S+` with
@@ -362,10 +387,13 @@ traceback still ship:
362
387
  - `<cycle: dict>` for a container that refers back to itself.
363
388
  - `<max depth exceeded>` beyond 64 levels of nesting.
364
389
 
365
- Integers outside the 64-bit range are written as JSON numbers, non-string
390
+ Integers outside the 64-bit range are written as JSON numbers, `int` and
391
+ `float` subclasses (such as `numpy.float64`) are written by value, non-string
366
392
  mapping keys are rendered as strings (`{200: 3}` becomes `{"200": 3}`), and
367
- unpaired surrogates in text become U+FFFD. Markers are redacted like any other
368
- string. The policy applies to native `logger` fields and to `extra=` fields
393
+ each unpaired surrogate in text becomes one U+FFFD while a split surrogate pair
394
+ decodes to its character. Keys that render to the same text never produce
395
+ duplicate JSON keys: the later value wins at the earlier key's position.
396
+ Markers are redacted like any other string. The policy applies to native `logger` fields and to `extra=` fields
369
397
  bridged from the standard library alike.
370
398
 
371
399
  ```python
@@ -413,15 +441,15 @@ Public API:
413
441
  Behavior notes:
414
442
 
415
443
  - **Overflow**: the default `maxsize=8192` uses `overflow="block"` to wait for queue space. Use `overflow="drop"` for drop-newest behavior with metrics and rate-limited warnings. `maxsize=0` explicitly opts into an unbounded queue.
416
- - **Redaction, level filtering, exceptions, and OpenTelemetry** injection are supported natively; redaction covers the message and all structured string values before rendering or Sentry export. `sensitive_keys` overrides the default redaction keys. Rust's linear-time regex engine rejects backreferences and look-around with `ValueError` at configuration time.
444
+ - **Redaction, level filtering, exceptions, and OpenTelemetry** injection are supported natively; redaction covers the message, field names, string values, and integers at any depth before rendering or Sentry export, and message placeholders that name a sensitive key are interpolated as `[REDACTED]` (see the redaction API reference for the exact coverage). `sensitive_keys` overrides the default redaction keys. Rust's linear-time regex engine rejects backreferences and look-around with `ValueError` at configuration time.
417
445
  - **Sampling & rate limiting** (`sample_rate`, `rate_limit_max`, `rate_limit_period`) are applied as native pre-render filters — dropped records cost zero rendering. `sampled` and `rate_limited` counters are distinct from the transport `dropped` counter. `sample_max_level` restricts sampling to records at or below that level; more severe records always pass.
418
- - **Metric hooks** (`metric_processor=...`) invoke a structlog-style processor (e.g. `MetricProcessor`) for every *kept* record on the caller's thread, with `(None, method, {"event": message, **fields})`. Dropped records (level/sampling/rate-limit) never reach it; hook errors are swallowed.
419
- - **Fork/shutdown safe** — the writer is flushed on exit and respawned in forked children (gunicorn/celery prefork). Rotating-file writers sharing a path coordinate through an owner-only `.lock` sidecar; distributed hosts should still prefer stdout and an external collector.
420
- - **Structured exceptions** (`structured_exceptions=True`) render `type`, `message`, `module`, and frames as a dictionary, with optional redacted/truncated locals controlled by the `exception_*` options. Exception groups include nested members under `exceptions`, with the same frame limits and redaction. Traversal stops at ten nesting levels or 100 exception nodes; `exceptions_truncated` counts omitted direct children. Failed message conversions produce a marker instead of interrupting logging.
421
- - **`stack_info` is supported natively**: the stack is captured in Python and rendered in the same position as `StackInfoRenderer` (`stack` between `service` and `message`). Unlike the standard path, the stack ends at the *user's* calling frame (structguru-internal frames are skipped, the way structlog skips its own).
422
- - **Console mode** (`format="console"`): renders colored, human-readable lines instead of JSON — structguru's own stable dev format (`<timestamp> [<LEVEL>] <message> k=v`), with ANSI colors by default on a TTY. Override with `colors=True/False`.
446
+ - **Metric hooks** (`metric_processor=...`) invoke a structlog-style processor (e.g. `MetricProcessor`) for every *kept* record on the caller's thread, with `(None, method, {"event": message, **fields})`; the message stays in `"event"` even when a field has that name. Dropped records (level/sampling/rate-limit) never reach it; hook errors are swallowed.
447
+ - **Fork/shutdown safe** — the writer is flushed on exit and respawned in forked children (gunicorn/celery prefork). The exit drain is registered when structguru is imported, so `atexit` handlers registered afterwards can still log. Rotating-file writers sharing a path coordinate through an owner-only `.lock` sidecar; distributed hosts should still prefer stdout and an external collector.
448
+ - **Structured exceptions** (`structured_exceptions=True`) render `type`, `message`, `module`, and frames as a dictionary, with optional redacted/truncated locals controlled by the `exception_*` options. A local named like a sensitive key is redacted, and so is any value under a sensitive key inside a mapping, list, tuple, named tuple, set, dataclass with its generated `__repr__`, or `SimpleNamespace` (walked with bounded depth and size, cycle-safe); other objects' own `repr()` text is covered by `sensitive_patterns`, which see each local's full rendering before it is truncated to `exception_max_local_repr`. Exception groups include nested members under `exceptions`, with the same frame limits and redaction. Traversal stops at ten nesting levels or 100 exception nodes; `exceptions_truncated` counts omitted direct children. Failed message conversions produce a marker instead of interrupting logging.
449
+ - **`stack_info` is supported natively**: the stack is captured in Python and rendered in the same position as `StackInfoRenderer` (`stack` between `service` and `message`). Unlike the standard path, the stack ends at the *user's* calling frame (frames of the `structguru` package are skipped, the way structlog skips its own).
450
+ - **Console mode** (`format="console"`): renders colored, human-readable lines instead of JSON — structguru's own stable dev format (`<timestamp> [<LEVEL>] <message> k=v`), with ANSI colors by default on a TTY. Override with `colors=True/False`. String values are double-quoted with `"` and `\` escaped, keys containing spaces, `=`, quotes, or backslashes are quoted, and control characters, U+2028/U+2029, and bidirectional controls are escaped everywhere, so a field key or value cannot forge another field or line. Floats use the JSON renderer's text (`1.0`, `1e+300`); the stack and a formatted `exception` follow as indented blocks.
423
451
  - **File sinks** (`file_path=...`): write to a rotating file natively. Defaults mirror `RotatingFileHandler` (50 MB, 5 backups); configure via `file_max_bytes`/`file_backup_count`. Set `also_stdout=True` to mirror output to both file and stdout (e.g. container + persistent log).
424
- - **Callable sinks** (`callable_sinks=[fn, ...]`): use a bounded queue (`callable_queue_maxsize=1024`). `overflow="block"` provides lossless backpressure; `overflow="drop"` reports `callable_dropped` metrics. Flush and lifecycle operations drain queued calls.
452
+ - **Callable sinks** (`callable_sinks=[fn, ...]`): use a bounded queue (`callable_queue_maxsize=1024`). `overflow="block"` provides lossless backpressure; `overflow="drop"` reports `callable_dropped` metrics. One background thread at a time calls the sinks, in log order, also across reconfiguration; `logger.add()` never waits for the queue. Sink exceptions are counted in `callable_errors` with a rate-limited warning. Flush and lifecycle operations drain queued calls; a sink that makes no progress for 10 seconds is abandoned with a warning instead of blocking them.
425
453
  - **Sentry integration** (`sentry_processor=SentryProcessor(...)`): receives the already-redacted event and raw `exc_info` only for exception capture.
426
454
  - **Scope**: the native renderer covers JSON and console rendering, file/stdout/callable sinks, redaction, sampling/rate limiting, metrics, exceptions, and stack information. `logger.add()` sinks receive native and stdlib records.
427
455
 
@@ -429,9 +457,9 @@ Shutdown and reconfiguration drain records already accepted by the native queue.
429
457
  Calls still formatting or waiting for space may be rejected when their writer closes,
430
458
  even in block mode. `lifecycle_metrics()["rejected"]` counts these native deliveries
431
459
  without queue-full warnings and survives shutdown and configuration changes; forked
432
- children start at zero. Another destination, such as a synchronous stream, may already
433
- have received the event. Calls begun while logging is disabled are no-ops and are not
434
- counted. See the [lifecycle contract](docs/api/config.md#runtime-control) for details.
460
+ children start at zero. A rejected record reaches no other destination either: the
461
+ synchronous stream, callable sinks, and Sentry follow the native writer's outcome.
462
+ Calls begun while logging is disabled are no-ops and are not counted. See the [lifecycle contract](docs/api/config.md#runtime-control) for details.
435
463
 
436
464
  ## Framework integrations
437
465
 
@@ -457,7 +485,7 @@ logging dictionary, application and launcher examples, and access-log options.
457
485
  from structguru.integrations.celery import setup_celery_logging
458
486
 
459
487
  setup_celery_logging(propagate_context=True, context_keys=["request_id"])
460
- # Binds task_id/task_name to context, propagates selected keys via headers
488
+ # Binds task_id/task_name; propagates selected keys (redacted, JSON-safe) via headers
461
489
  ```
462
490
 
463
491
  ### Flask
@@ -578,7 +606,8 @@ bridge = install_stdlib_bridge_from_env()
578
606
  ## Requirements
579
607
 
580
608
  - Python 3.11+
581
- - The compiled Rust extension (shipped as abi3 wheels for Linux/macOS/Windows)
609
+ - The compiled Rust extension (shipped as abi3 wheels for Linux/macOS/Windows, plus
610
+ free-threaded CPython 3.14t wheels for Linux x86_64, macOS arm64, and Windows x64)
582
611
 
583
612
  ## Documentation & Examples
584
613
 
@@ -0,0 +1,113 @@
1
+ //! Timestamp generation for the native render path.
2
+ //!
3
+ //! Produces an ISO-8601 UTC timestamp matching structlog's
4
+ //! `TimeStamper(fmt="iso", utc=True)` output, e.g. `2026-07-08T16:19:24.616660Z`.
5
+ //!
6
+ //! The whole-second prefix (`YYYY-MM-DDTHH:MM:SS`) is cached per thread and only
7
+ //! re-rendered when the second rolls over; the microsecond suffix is appended
8
+ //! cheaply on every call. This keeps the calendar breakdown off the hot path
9
+ //! (architecture §5/§14).
10
+
11
+ use std::cell::RefCell;
12
+
13
+ use time::OffsetDateTime;
14
+ use time::macros::format_description;
15
+
16
+ thread_local! {
17
+ // (unix_second, "YYYY-MM-DDTHH:MM:SS")
18
+ static PREFIX_CACHE: RefCell<(i64, String)> = const { RefCell::new((i64::MIN, String::new())) };
19
+ }
20
+
21
+ /// Current time as an ISO-8601 UTC string with microsecond precision + `Z`.
22
+ pub fn now_iso8601() -> String {
23
+ let now = OffsetDateTime::now_utc();
24
+ let secs = now.unix_timestamp();
25
+ let micros = now.microsecond(); // 0..1_000_000
26
+
27
+ PREFIX_CACHE.with(|cell| {
28
+ let mut cache = cell.borrow_mut();
29
+ if cache.0 != secs {
30
+ let fmt = format_description!("[year]-[month]-[day]T[hour]:[minute]:[second]");
31
+ cache.1 = now.format(&fmt).unwrap_or_default();
32
+ cache.0 = secs;
33
+ }
34
+ format!("{}.{:06}Z", cache.1, micros)
35
+ })
36
+ }
37
+
38
+ /// Unix seconds bounding years 1 through 9999, the range of Python's `datetime`.
39
+ const UNIX_SECONDS_RANGE: std::ops::Range<f64> = -62_135_596_800.0..253_402_300_800.0;
40
+
41
+ /// `seconds` since the Unix epoch, as `time.time()` and `LogRecord.created`
42
+ /// give them, in the [`now_iso8601`] format; `None` when the value is not
43
+ /// finite or falls outside years 1 through 9999.
44
+ ///
45
+ /// The fraction rounds to the nearest microsecond, ties to even, the way
46
+ /// `datetime.fromtimestamp` rounds it.
47
+ pub fn unix_to_iso8601(seconds: f64) -> Option<String> {
48
+ if !UNIX_SECONDS_RANGE.contains(&seconds) {
49
+ return None;
50
+ }
51
+ let whole = seconds.floor();
52
+ // `seconds - whole` is exact, so this rounds only once, like CPython.
53
+ let micros = ((seconds - whole) * 1_000_000.0).round_ties_even();
54
+ let (whole, micros) = if micros >= 1_000_000.0 {
55
+ (whole + 1.0, 0)
56
+ } else {
57
+ (whole, micros as u32)
58
+ };
59
+ let datetime = OffsetDateTime::from_unix_timestamp(whole as i64).ok()?;
60
+ let fmt = format_description!("[year]-[month]-[day]T[hour]:[minute]:[second]");
61
+ Some(format!("{}.{micros:06}Z", datetime.format(&fmt).ok()?))
62
+ }
63
+
64
+ #[cfg(test)]
65
+ mod tests {
66
+ use super::{now_iso8601, unix_to_iso8601};
67
+
68
+ #[test]
69
+ fn produces_iso8601_microsecond_utc() {
70
+ let ts = now_iso8601();
71
+ // e.g. 2026-07-08T16:19:24.616660Z
72
+ assert_eq!(ts.len(), 27, "unexpected timestamp: {ts}");
73
+ assert!(ts.ends_with('Z'));
74
+ assert_eq!(&ts[4..5], "-");
75
+ assert_eq!(&ts[10..11], "T");
76
+ assert_eq!(&ts[19..20], ".");
77
+ }
78
+
79
+ #[test]
80
+ fn formats_unix_seconds_like_datetime_fromtimestamp() {
81
+ let cases = [
82
+ (0.0, "1970-01-01T00:00:00.000000Z"),
83
+ (1_000_000_000.123456, "2001-09-09T01:46:40.123456Z"),
84
+ // Rounds to the nearest microsecond, carrying into the second.
85
+ (1_700_000_000.999_999_6, "2023-11-14T22:13:21.000000Z"),
86
+ // A tie rounds to even: 1/128 s is 7812.5 us.
87
+ (0.0078125, "1970-01-01T00:00:00.007812Z"),
88
+ (-1.5, "1969-12-31T23:59:58.500000Z"),
89
+ (-62_135_596_800.0, "0001-01-01T00:00:00.000000Z"),
90
+ ];
91
+ for (seconds, expected) in cases {
92
+ assert_eq!(
93
+ unix_to_iso8601(seconds).as_deref(),
94
+ Some(expected),
95
+ "{seconds}"
96
+ );
97
+ }
98
+ }
99
+
100
+ #[test]
101
+ fn rejects_unrepresentable_unix_seconds() {
102
+ for seconds in [
103
+ f64::NAN,
104
+ f64::INFINITY,
105
+ f64::NEG_INFINITY,
106
+ 1e20,
107
+ -62_135_596_801.0,
108
+ 253_402_300_800.0,
109
+ ] {
110
+ assert_eq!(unix_to_iso8601(seconds), None, "{seconds}");
111
+ }
112
+ }
113
+ }
@@ -18,23 +18,26 @@ pub enum Decision {
18
18
  /// A pre-render filter deciding whether a record should be rendered.
19
19
  pub trait RecordFilter: Send + Sync {
20
20
  /// Inspect `key` (the rate-limit grouping key, usually the formatted message)
21
- /// and `level` (the canonical method name) and return `Keep` or `Drop`.
22
- fn allow(&self, key: &str, level: &str) -> Decision;
21
+ /// and `level` (the record's numeric level, as in `logging`) and return
22
+ /// `Keep` or `Drop`.
23
+ fn allow(&self, key: &str, level: u32) -> Decision;
23
24
  }
24
25
 
25
26
  /// Numeric level for a method name; unknown names rank as `info`.
26
- fn method_level_num(method: &str) -> u8 {
27
- crate::level_number(method).unwrap_or(20)
27
+ pub fn method_level_number(method: &str) -> u32 {
28
+ u32::from(crate::level_number(method).unwrap_or(20))
28
29
  }
29
30
 
30
31
  /// Probabilistic sampler: keeps a record with probability `rate`.
31
32
  ///
32
33
  /// With `max_level_num` set, only records at or below that level are sampled;
33
34
  /// more severe records always pass — the native analog of wrapping
34
- /// `SamplingProcessor` in `ConditionalProcessor(max_level=...)`.
35
+ /// `SamplingProcessor` in `ConditionalProcessor(max_level=...)`. Levels
36
+ /// compare by number, so a custom stdlib level between two named ones ranks
37
+ /// by its own value.
35
38
  pub struct Sampler {
36
39
  rate: f64,
37
- max_level_num: Option<u8>,
40
+ max_level_num: Option<u32>,
38
41
  }
39
42
 
40
43
  impl Sampler {
@@ -43,15 +46,15 @@ impl Sampler {
43
46
  pub fn new(rate: f64, max_level: Option<&str>) -> Self {
44
47
  Self {
45
48
  rate: rate.clamp(0.0, 1.0),
46
- max_level_num: max_level.map(method_level_num),
49
+ max_level_num: max_level.map(method_level_number),
47
50
  }
48
51
  }
49
52
  }
50
53
 
51
54
  impl RecordFilter for Sampler {
52
- fn allow(&self, _key: &str, level: &str) -> Decision {
55
+ fn allow(&self, _key: &str, level: u32) -> Decision {
53
56
  if let Some(max) = self.max_level_num
54
- && method_level_num(level) > max
57
+ && level > max
55
58
  {
56
59
  return Decision::Keep;
57
60
  }
@@ -201,7 +204,7 @@ fn evict_least_recent(state: &mut RateLimiterState, keep: usize) {
201
204
  }
202
205
 
203
206
  impl RecordFilter for RateLimiter {
204
- fn allow(&self, key: &str, _level: &str) -> Decision {
207
+ fn allow(&self, key: &str, _level: u32) -> Decision {
205
208
  self.allow_key(key, Instant::now())
206
209
  }
207
210
  }
@@ -254,8 +257,9 @@ impl Pipeline {
254
257
  }
255
258
  }
256
259
 
257
- /// Returns `false` when any stage drops the record.
258
- pub fn allow(&self, key: &str, level: &str) -> bool {
260
+ /// Returns `false` when any stage drops the record. `level` is the
261
+ /// record's numeric level; see [`method_level_number`] for method names.
262
+ pub fn allow(&self, key: &str, level: u32) -> bool {
259
263
  for stage in &self.stages {
260
264
  let (decision, sampled, rate_limited) = match stage {
261
265
  Stage::Sampler(f) => {
@@ -300,7 +304,7 @@ mod tests {
300
304
  fn sampler_rate_zero_drops_all() {
301
305
  let s = Sampler::new(0.0, None);
302
306
  for _ in 0..100 {
303
- assert_eq!(s.allow("k", "info"), Decision::Drop);
307
+ assert_eq!(s.allow("k", 20), Decision::Drop);
304
308
  }
305
309
  }
306
310
 
@@ -308,7 +312,7 @@ mod tests {
308
312
  fn sampler_rate_one_keeps_all() {
309
313
  let s = Sampler::new(1.0, None);
310
314
  for _ in 0..100 {
311
- assert_eq!(s.allow("k", "info"), Decision::Keep);
315
+ assert_eq!(s.allow("k", 20), Decision::Keep);
312
316
  }
313
317
  }
314
318
 
@@ -318,7 +322,7 @@ mod tests {
318
322
  let s = Sampler::new(0.5, None);
319
323
  let mut kept = 0;
320
324
  for _ in 0..1000 {
321
- if s.allow("k", "info") == Decision::Keep {
325
+ if s.allow("k", 20) == Decision::Keep {
322
326
  kept += 1;
323
327
  }
324
328
  }
@@ -329,20 +333,45 @@ mod tests {
329
333
  fn sampler_max_level_gates_sampling_to_low_levels() {
330
334
  // rate=0 would drop everything, but only records <= INFO are sampled.
331
335
  let s = Sampler::new(0.0, Some("info"));
332
- assert_eq!(s.allow("k", "debug"), Decision::Drop);
333
- assert_eq!(s.allow("k", "info"), Decision::Drop);
334
- assert_eq!(s.allow("k", "success"), Decision::Drop);
335
- assert_eq!(s.allow("k", "warning"), Decision::Keep);
336
- assert_eq!(s.allow("k", "error"), Decision::Keep);
337
- assert_eq!(s.allow("k", "critical"), Decision::Keep);
336
+ for (method, expected) in [
337
+ ("debug", Decision::Drop),
338
+ ("info", Decision::Drop),
339
+ ("success", Decision::Drop),
340
+ ("warning", Decision::Keep),
341
+ ("error", Decision::Keep),
342
+ ("critical", Decision::Keep),
343
+ ] {
344
+ assert_eq!(
345
+ s.allow("k", method_level_number(method)),
346
+ expected,
347
+ "{method}"
348
+ );
349
+ }
350
+ }
351
+
352
+ #[test]
353
+ fn sampler_max_level_ranks_custom_levels_by_number() {
354
+ // A stdlib level between INFO (20) and WARNING (30) is more severe
355
+ // than INFO, so it is never sampled.
356
+ let s = Sampler::new(0.0, Some("info"));
357
+ assert_eq!(s.allow("k", 19), Decision::Drop);
358
+ assert_eq!(s.allow("k", 20), Decision::Drop);
359
+ assert_eq!(s.allow("k", 21), Decision::Keep);
360
+ assert_eq!(s.allow("k", 25), Decision::Keep);
361
+ }
362
+
363
+ #[test]
364
+ fn unknown_method_names_rank_as_info() {
365
+ assert_eq!(method_level_number("notice"), 20);
366
+ assert_eq!(method_level_number("WARN"), 30);
338
367
  }
339
368
 
340
369
  #[test]
341
370
  fn pipeline_level_gated_sampler_counts_only_gated_drops() {
342
371
  let p = Pipeline::new(0.0, Some("debug"), None, Duration::from_secs(60));
343
- assert!(!p.allow("k", "debug"));
344
- assert!(p.allow("k", "info"));
345
- assert!(p.allow("k", "error"));
372
+ assert!(!p.allow("k", 10));
373
+ assert!(p.allow("k", 20));
374
+ assert!(p.allow("k", 40));
346
375
  let stats = p.stats();
347
376
  assert_eq!(stats.sampled, 1);
348
377
  }
@@ -411,7 +440,7 @@ mod tests {
411
440
  let p = Pipeline::new(1.0, None, None, Duration::from_secs(60));
412
441
  assert!(p.is_empty());
413
442
  for _ in 0..10 {
414
- assert!(p.allow("k", "info"));
443
+ assert!(p.allow("k", 20));
415
444
  }
416
445
  let stats = p.stats();
417
446
  assert_eq!(stats.sampled, 0);
@@ -422,7 +451,7 @@ mod tests {
422
451
  fn pipeline_sampler_only_counts_sampled() {
423
452
  let p = Pipeline::new(0.0, None, None, Duration::from_secs(60));
424
453
  for _ in 0..5 {
425
- assert!(!p.allow("k", "info"));
454
+ assert!(!p.allow("k", 20));
426
455
  }
427
456
  let stats = p.stats();
428
457
  assert_eq!(stats.sampled, 5);
@@ -432,9 +461,9 @@ mod tests {
432
461
  #[test]
433
462
  fn pipeline_rate_limiter_only_counts_rate_limited() {
434
463
  let p = Pipeline::new(1.0, None, Some(2), Duration::from_secs(60));
435
- assert!(p.allow("k", "info"));
436
- assert!(p.allow("k", "info"));
437
- assert!(!p.allow("k", "info"));
464
+ assert!(p.allow("k", 20));
465
+ assert!(p.allow("k", 20));
466
+ assert!(!p.allow("k", 20));
438
467
  let stats = p.stats();
439
468
  assert_eq!(stats.sampled, 0);
440
469
  assert_eq!(stats.rate_limited, 1);
@@ -445,7 +474,7 @@ mod tests {
445
474
  // rate=0 drops everything; the rate limiter never runs, so its counter stays 0.
446
475
  let p = Pipeline::new(0.0, None, Some(2), Duration::from_secs(60));
447
476
  for _ in 0..10 {
448
- assert!(!p.allow("k", "info"));
477
+ assert!(!p.allow("k", 20));
449
478
  }
450
479
  let stats = p.stats();
451
480
  assert_eq!(stats.sampled, 10);
@@ -456,9 +485,9 @@ mod tests {
456
485
  fn pipeline_sampler_then_rate_limiter_both_count() {
457
486
  // rate=1 so the sampler always keeps; the rate limiter governs drops.
458
487
  let p = Pipeline::new(1.0, None, Some(1), Duration::from_secs(60));
459
- assert!(p.allow("alpha", "info"));
460
- assert!(!p.allow("alpha", "info"));
461
- assert!(p.allow("beta", "info"));
488
+ assert!(p.allow("alpha", 20));
489
+ assert!(!p.allow("alpha", 20));
490
+ assert!(p.allow("beta", 20));
462
491
  let stats = p.stats();
463
492
  assert_eq!(stats.sampled, 0);
464
493
  assert_eq!(stats.rate_limited, 1);
@@ -9,13 +9,15 @@ mod render;
9
9
  mod value;
10
10
  mod worker;
11
11
 
12
- pub use clock::now_iso8601;
13
- pub use filter::{Decision, FilterStats, Pipeline, RateLimiter, RecordFilter, Sampler};
12
+ pub use clock::{now_iso8601, unix_to_iso8601};
13
+ pub use filter::{
14
+ Decision, FilterStats, Pipeline, RateLimiter, RecordFilter, Sampler, method_level_number,
15
+ };
14
16
  pub use render::{DEFAULT_SENSITIVE_KEYS, RedactionPattern, render_line, render_line_console};
15
- pub use value::{Value, ValueStats};
17
+ pub use value::{Value, ValueStats, collapse_duplicate_keys};
16
18
  pub use worker::{
17
- EnqueueError, MemorySink, MemorySinkHandle, MultiSink, RotatingFileSink, SinkError, StringSink,
18
- StringWriter, WorkerMetrics, WriteSink,
19
+ EnqueueError, MemorySink, MemorySinkHandle, MultiSink, RotatingFileSink, SinkError, StdoutSink,
20
+ StringSink, StringWriter, WorkerMetrics, WriteSink,
19
21
  };
20
22
 
21
23
  /// Return the Rust core crate version.