structguru 1.2.0__tar.gz → 1.2.2__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 (40) hide show
  1. {structguru-1.2.0 → structguru-1.2.2}/Cargo.lock +6 -61
  2. {structguru-1.2.0 → structguru-1.2.2}/Cargo.toml +6 -9
  3. {structguru-1.2.0 → structguru-1.2.2}/PKG-INFO +7 -6
  4. {structguru-1.2.0 → structguru-1.2.2}/README.md +6 -5
  5. {structguru-1.2.0 → structguru-1.2.2}/crates/structguru-core/Cargo.toml +1 -1
  6. {structguru-1.2.0 → structguru-1.2.2}/crates/structguru-core/src/filter.rs +1 -3
  7. {structguru-1.2.0 → structguru-1.2.2}/crates/structguru-core/src/render.rs +70 -12
  8. {structguru-1.2.0 → structguru-1.2.2}/pyproject.toml +16 -16
  9. {structguru-1.2.0 → structguru-1.2.2}/src/structguru/__init__.py +1 -1
  10. {structguru-1.2.0 → structguru-1.2.2}/src/structguru/_native_dispatch.py +79 -70
  11. {structguru-1.2.0 → structguru-1.2.2}/src/structguru/integrations/celery.py +7 -5
  12. {structguru-1.2.0 → structguru-1.2.2}/src/structguru/integrations/sentry.py +9 -9
  13. {structguru-1.2.0 → structguru-1.2.2}/LICENSE +0 -0
  14. {structguru-1.2.0 → structguru-1.2.2}/crates/structguru-core/src/clock.rs +0 -0
  15. {structguru-1.2.0 → structguru-1.2.2}/crates/structguru-core/src/lib.rs +0 -0
  16. {structguru-1.2.0 → structguru-1.2.2}/crates/structguru-core/src/value.rs +0 -0
  17. {structguru-1.2.0 → structguru-1.2.2}/crates/structguru-core/src/worker.rs +0 -0
  18. {structguru-1.2.0 → structguru-1.2.2}/crates/structguru-py/Cargo.toml +0 -0
  19. {structguru-1.2.0 → structguru-1.2.2}/crates/structguru-py/src/lib.rs +0 -0
  20. {structguru-1.2.0 → structguru-1.2.2}/src/structguru/_contextvars.py +0 -0
  21. {structguru-1.2.0 → structguru-1.2.2}/src/structguru/_native_env.py +0 -0
  22. {structguru-1.2.0 → structguru-1.2.2}/src/structguru/_runtime.py +0 -0
  23. {structguru-1.2.0 → structguru-1.2.2}/src/structguru/config.py +0 -0
  24. {structguru-1.2.0 → structguru-1.2.2}/src/structguru/core.py +0 -0
  25. {structguru-1.2.0 → structguru-1.2.2}/src/structguru/exceptions.py +0 -0
  26. {structguru-1.2.0 → structguru-1.2.2}/src/structguru/integrations/__init__.py +0 -0
  27. {structguru-1.2.0 → structguru-1.2.2}/src/structguru/integrations/_stdlib_env.py +0 -0
  28. {structguru-1.2.0 → structguru-1.2.2}/src/structguru/integrations/_util.py +0 -0
  29. {structguru-1.2.0 → structguru-1.2.2}/src/structguru/integrations/asgi.py +0 -0
  30. {structguru-1.2.0 → structguru-1.2.2}/src/structguru/integrations/django.py +0 -0
  31. {structguru-1.2.0 → structguru-1.2.2}/src/structguru/integrations/flask.py +0 -0
  32. {structguru-1.2.0 → structguru-1.2.2}/src/structguru/integrations/grpc.py +0 -0
  33. {structguru-1.2.0 → structguru-1.2.2}/src/structguru/integrations/httpx.py +0 -0
  34. {structguru-1.2.0 → structguru-1.2.2}/src/structguru/integrations/requests.py +0 -0
  35. {structguru-1.2.0 → structguru-1.2.2}/src/structguru/integrations/sqlalchemy.py +0 -0
  36. {structguru-1.2.0 → structguru-1.2.2}/src/structguru/integrations/stdlib.py +0 -0
  37. {structguru-1.2.0 → structguru-1.2.2}/src/structguru/metrics.py +0 -0
  38. {structguru-1.2.0 → structguru-1.2.2}/src/structguru/otel.py +0 -0
  39. {structguru-1.2.0 → structguru-1.2.2}/src/structguru/py.typed +0 -0
  40. {structguru-1.2.0 → structguru-1.2.2}/src/structguru/redaction.py +0 -0
@@ -26,32 +26,6 @@ version = "0.8.0"
26
26
  source = "registry+https://github.com/rust-lang/crates.io-index"
27
27
  checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
28
28
 
29
- [[package]]
30
- name = "cfg-if"
31
- version = "1.0.4"
32
- source = "registry+https://github.com/rust-lang/crates.io-index"
33
- checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
34
-
35
- [[package]]
36
- name = "chacha20"
37
- version = "0.10.1"
38
- source = "registry+https://github.com/rust-lang/crates.io-index"
39
- checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
40
- dependencies = [
41
- "cfg-if",
42
- "cpufeatures",
43
- "rand_core",
44
- ]
45
-
46
- [[package]]
47
- name = "cpufeatures"
48
- version = "0.3.0"
49
- source = "registry+https://github.com/rust-lang/crates.io-index"
50
- checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
51
- dependencies = [
52
- "libc",
53
- ]
54
-
55
29
  [[package]]
56
30
  name = "deranged"
57
31
  version = "0.5.8"
@@ -70,16 +44,10 @@ dependencies = [
70
44
  ]
71
45
 
72
46
  [[package]]
73
- name = "getrandom"
74
- version = "0.4.3"
47
+ name = "fastrand"
48
+ version = "2.5.0"
75
49
  source = "registry+https://github.com/rust-lang/crates.io-index"
76
- checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
77
- dependencies = [
78
- "cfg-if",
79
- "libc",
80
- "r-efi",
81
- "rand_core",
82
- ]
50
+ checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
83
51
 
84
52
  [[package]]
85
53
  name = "heck"
@@ -204,29 +172,6 @@ dependencies = [
204
172
  "proc-macro2",
205
173
  ]
206
174
 
207
- [[package]]
208
- name = "r-efi"
209
- version = "6.0.0"
210
- source = "registry+https://github.com/rust-lang/crates.io-index"
211
- checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
212
-
213
- [[package]]
214
- name = "rand"
215
- version = "0.10.2"
216
- source = "registry+https://github.com/rust-lang/crates.io-index"
217
- checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
218
- dependencies = [
219
- "chacha20",
220
- "getrandom",
221
- "rand_core",
222
- ]
223
-
224
- [[package]]
225
- name = "rand_core"
226
- version = "0.10.1"
227
- source = "registry+https://github.com/rust-lang/crates.io-index"
228
- checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
229
-
230
175
  [[package]]
231
176
  name = "regex"
232
177
  version = "1.13.1"
@@ -300,10 +245,10 @@ dependencies = [
300
245
 
301
246
  [[package]]
302
247
  name = "structguru-core"
303
- version = "1.2.0"
248
+ version = "1.2.2"
304
249
  dependencies = [
305
250
  "fancy-regex",
306
- "rand",
251
+ "fastrand",
307
252
  "regex",
308
253
  "serde",
309
254
  "serde_json",
@@ -312,7 +257,7 @@ dependencies = [
312
257
 
313
258
  [[package]]
314
259
  name = "structguru-py"
315
- version = "1.2.0"
260
+ version = "1.2.2"
316
261
  dependencies = [
317
262
  "pyo3",
318
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.2.0"
6
+ version = "1.2.2"
7
7
  edition = "2024"
8
8
  rust-version = "1.89"
9
9
  license = "MIT"
@@ -25,7 +25,7 @@ codegen-units = 1
25
25
  strip = true
26
26
 
27
27
  [workspace.dependencies]
28
- pyo3 = { version = "0.29.0" }
28
+ pyo3 = { version = "0.29.2" }
29
29
  serde = { version = "1.0" }
30
30
  serde_json = { version = "1.0", features = ["raw_value"] }
31
31
  time = { version = "0.3", features = ["macros", "formatting"] }
@@ -35,11 +35,8 @@ regex = { version = "1", default-features = false, features = ["std", "unicode"]
35
35
  # Backtracking engine for opt-in look-around/backreference redaction patterns
36
36
  # (`allow_backtracking_patterns=True`); never used unless explicitly requested.
37
37
  fancy-regex = { version = "0.19", default-features = false, features = ["std", "unicode"] }
38
- # Only `rand::random` is used (sampling filter); `thread_rng` pulls in the
39
- # std/sys_rng machinery it needs.
40
- rand = { version = "0.10", default-features = false, features = [
41
- "std",
42
- "std_rng",
43
- "thread_rng",
44
- ] }
38
+ # Only a per-thread `f64` draw is needed (sampling filter). fastrand's WyRand
39
+ # generator has no transitive dependencies on the wheel targets, unlike rand's
40
+ # ChaCha-based thread RNG.
41
+ fastrand = { version = "2.5" }
45
42
  structguru-core = { path = "crates/structguru-core" }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: structguru
3
- Version: 1.2.0
3
+ Version: 1.2.2
4
4
  Classifier: Development Status :: 5 - Production/Stable
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -141,16 +141,16 @@ logger.info("User {user_id} logged in", user_id=42, ip="10.0.0.1")
141
141
 
142
142
  ```python
143
143
  log = logger.bind(request_id="abc-123", user="alice")
144
- log.info("Processing request") # includes request_id and user
145
- log.info("Request complete") # same context carried through
144
+ log.info("Processing request") # includes request_id and user
145
+ log.info("Request complete") # same context carried through
146
146
  ```
147
147
 
148
148
  ### Request-scoped context
149
149
 
150
150
  ```python
151
151
  with logger.contextualize(request_id="abc-123"):
152
- logger.info("Handling request") # includes request_id
153
- do_work() # any logging inside also gets request_id
152
+ logger.info("Handling request") # includes request_id
153
+ do_work() # any logging inside also gets request_id
154
154
  # request_id removed automatically
155
155
  ```
156
156
 
@@ -200,7 +200,7 @@ been written:
200
200
  import structguru
201
201
 
202
202
  logger.info("checkpoint")
203
- structguru.flush() # returns once the line has reached its sink
203
+ structguru.flush() # returns once the line has reached its sink
204
204
  ```
205
205
 
206
206
  ### Console vs JSON output
@@ -454,6 +454,7 @@ bridge = install_stdlib_bridge(
454
454
  )
455
455
 
456
456
  import logging
457
+
457
458
  logging.getLogger("sqlalchemy.engine").info("SELECT 1")
458
459
  # → {"logger":"sqlalchemy.engine","level":"INFO",...,"message":"SELECT 1"}
459
460
  ```
@@ -97,16 +97,16 @@ logger.info("User {user_id} logged in", user_id=42, ip="10.0.0.1")
97
97
 
98
98
  ```python
99
99
  log = logger.bind(request_id="abc-123", user="alice")
100
- log.info("Processing request") # includes request_id and user
101
- log.info("Request complete") # same context carried through
100
+ log.info("Processing request") # includes request_id and user
101
+ log.info("Request complete") # same context carried through
102
102
  ```
103
103
 
104
104
  ### Request-scoped context
105
105
 
106
106
  ```python
107
107
  with logger.contextualize(request_id="abc-123"):
108
- logger.info("Handling request") # includes request_id
109
- do_work() # any logging inside also gets request_id
108
+ logger.info("Handling request") # includes request_id
109
+ do_work() # any logging inside also gets request_id
110
110
  # request_id removed automatically
111
111
  ```
112
112
 
@@ -156,7 +156,7 @@ been written:
156
156
  import structguru
157
157
 
158
158
  logger.info("checkpoint")
159
- structguru.flush() # returns once the line has reached its sink
159
+ structguru.flush() # returns once the line has reached its sink
160
160
  ```
161
161
 
162
162
  ### Console vs JSON output
@@ -410,6 +410,7 @@ bridge = install_stdlib_bridge(
410
410
  )
411
411
 
412
412
  import logging
413
+
413
414
  logging.getLogger("sqlalchemy.engine").info("SELECT 1")
414
415
  # → {"logger":"sqlalchemy.engine","level":"INFO",...,"message":"SELECT 1"}
415
416
  ```
@@ -19,4 +19,4 @@ serde_json.workspace = true
19
19
  time.workspace = true
20
20
  regex.workspace = true
21
21
  fancy-regex.workspace = true
22
- rand.workspace = true
22
+ fastrand.workspace = true
@@ -8,8 +8,6 @@ use std::collections::{HashMap, VecDeque};
8
8
  use std::sync::Mutex;
9
9
  use std::time::{Duration, Instant};
10
10
 
11
- use rand::random;
12
-
13
11
  /// Outcome of a pre-render filter check.
14
12
  #[derive(Debug, Clone, Copy, PartialEq, Eq)]
15
13
  pub enum Decision {
@@ -71,7 +69,7 @@ impl RecordFilter for Sampler {
71
69
  if self.rate <= 0.0 {
72
70
  return Decision::Drop;
73
71
  }
74
- if random::<f64>() < self.rate {
72
+ if fastrand::f64() < self.rate {
75
73
  Decision::Keep
76
74
  } else {
77
75
  Decision::Drop
@@ -183,21 +183,22 @@ fn redact_patterns(value: &mut Value, patterns: &[RedactionPattern], replacement
183
183
  }
184
184
  }
185
185
 
186
- fn redact_message(
187
- message: &str,
186
+ fn redact_text(
187
+ key: &str,
188
+ text: &str,
188
189
  sensitive_keys: Option<&Vec<String>>,
189
190
  sensitive_patterns: Option<&[RedactionPattern]>,
190
191
  pattern_replacement: Option<&str>,
191
192
  ) -> String {
192
- let message_is_sensitive = match sensitive_keys {
193
- Some(custom) => is_sensitive("message", custom),
194
- None => is_sensitive("message", DEFAULT_SENSITIVE_KEYS),
193
+ let key_is_sensitive = match sensitive_keys {
194
+ Some(custom) => is_sensitive(key, custom),
195
+ None => is_sensitive(key, DEFAULT_SENSITIVE_KEYS),
195
196
  };
196
- if message_is_sensitive {
197
+ if key_is_sensitive {
197
198
  return REDACTED.to_owned();
198
199
  }
199
200
 
200
- let mut value = Value::String(message.to_owned());
201
+ let mut value = Value::String(text.to_owned());
201
202
  if let Some(patterns) = sensitive_patterns {
202
203
  redact_patterns(
203
204
  &mut value,
@@ -206,7 +207,7 @@ fn redact_message(
206
207
  );
207
208
  }
208
209
  let Value::String(redacted) = value else {
209
- unreachable!("message redaction preserves the string value variant");
210
+ unreachable!("text redaction preserves the string value variant");
210
211
  };
211
212
  redacted
212
213
  }
@@ -238,7 +239,8 @@ pub fn render_line(
238
239
  sensitive_patterns: Option<&[RedactionPattern]>,
239
240
  pattern_replacement: Option<&str>,
240
241
  ) -> Result<String, serde_json::Error> {
241
- let redacted_message = redact_message(
242
+ let redacted_message = redact_text(
243
+ "message",
242
244
  message,
243
245
  sensitive_keys.as_ref(),
244
246
  sensitive_patterns,
@@ -285,7 +287,14 @@ pub fn render_line(
285
287
  // "stack" sits between "service" and "message": StackInfoRenderer runs
286
288
  // after add_service and before EventRenamer in the shared chain.
287
289
  if let Some(stack) = stack {
288
- upsert(&mut entries, "stack", Value::String(stack.to_owned()));
290
+ let redacted_stack = redact_text(
291
+ "stack",
292
+ stack,
293
+ sensitive_keys.as_ref(),
294
+ sensitive_patterns,
295
+ pattern_replacement,
296
+ );
297
+ upsert(&mut entries, "stack", Value::String(redacted_stack));
289
298
  }
290
299
  upsert(&mut entries, "message", Value::String(redacted_message));
291
300
 
@@ -380,7 +389,8 @@ pub fn render_line_console(
380
389
  pattern_replacement: Option<&str>,
381
390
  stack: Option<&str>,
382
391
  ) -> String {
383
- let redacted_message = redact_message(
392
+ let redacted_message = redact_text(
393
+ "message",
384
394
  message,
385
395
  sensitive_keys.as_ref(),
386
396
  sensitive_patterns,
@@ -440,8 +450,15 @@ pub fn render_line_console(
440
450
  let _ = (logger, service);
441
451
 
442
452
  if let Some(stack) = stack {
453
+ let redacted_stack = redact_text(
454
+ "stack",
455
+ stack,
456
+ sensitive_keys.as_ref(),
457
+ sensitive_patterns,
458
+ pattern_replacement,
459
+ );
443
460
  out.push('\n');
444
- out.push_str(stack);
461
+ out.push_str(&redacted_stack);
445
462
  }
446
463
  out
447
464
  }
@@ -519,6 +536,47 @@ mod tests {
519
536
  );
520
537
  }
521
538
 
539
+ #[test]
540
+ fn stack_redaction_applies_patterns_and_custom_keys_in_both_formats() {
541
+ let patterns = vec![RedactionPattern::linear(r"(token=)\w+").unwrap()];
542
+ for keys in [None, Some(vec!["STACK".to_owned()])] {
543
+ let expected = if keys.is_some() {
544
+ "[REDACTED]"
545
+ } else {
546
+ "frame\ntoken=[MASKED]"
547
+ };
548
+ let json = render_line(
549
+ vec![],
550
+ "l",
551
+ "info",
552
+ "svc",
553
+ "trace",
554
+ "TS",
555
+ Some("frame\ntoken=REVIEW_SENTINEL"),
556
+ keys.clone(),
557
+ Some(&patterns),
558
+ Some("$1[MASKED]"),
559
+ )
560
+ .unwrap();
561
+ let record: serde_json::Value = serde_json::from_str(&json).unwrap();
562
+ assert_eq!(record["stack"], expected);
563
+ let console = render_line_console(
564
+ vec![],
565
+ "l",
566
+ "info",
567
+ "svc",
568
+ "trace",
569
+ false,
570
+ "TS",
571
+ keys,
572
+ Some(&patterns),
573
+ Some("$1[MASKED]"),
574
+ Some("frame\ntoken=REVIEW_SENTINEL"),
575
+ );
576
+ assert_eq!(console, format!("TS [INFO ] trace\n{expected}"));
577
+ }
578
+ }
579
+
522
580
  #[test]
523
581
  fn redacts_sensitive_keys_including_nested_and_case_insensitive() {
524
582
  let fields = vec![
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "structguru"
7
- version = "1.2.0"
7
+ version = "1.2.2"
8
8
  description = "Native structured logging with a loguru-style Python API"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -54,35 +54,35 @@ dev = [
54
54
  { include-group = "bench" },
55
55
  ]
56
56
  test = [
57
- "pytest>=8.0",
58
- "pytest-asyncio>=0.23",
59
- "pytest-cov>=7.0.0",
60
- "pytest-httpserver>=1.0.0",
61
- "responses>=0.25.0",
57
+ "pytest>=9.1.1",
58
+ "pytest-asyncio>=1.4.0",
59
+ "pytest-cov>=7.1.0",
60
+ "pytest-httpserver>=1.1.5",
61
+ "responses>=0.26.3",
62
62
  # structlog + orjson are kept as test-only deps for the golden parity suite
63
63
  # (which compares native output against the standard structlog/orjson path).
64
- "structlog>=24.1.0",
65
- "orjson>=3.9.0",
64
+ "structlog>=26.1.0",
65
+ "orjson>=3.12.0",
66
66
  # fastapi drives the full-stack example smoke test (examples/full_stack_fastapi);
67
67
  # celery/sqlalchemy/httpx it also needs come from the integration extras.
68
- "fastapi>=0.110",
68
+ "fastapi>=0.141.1",
69
69
  ]
70
70
  lint = [
71
- "mypy>=1.8",
72
- "ruff>=0.4",
71
+ "mypy>=2.3.1",
72
+ "ruff>=0.16.6",
73
73
  ]
74
74
  docs = [
75
75
  "mkdocs>=1.6.1",
76
- "mkdocs-material>=9.7.6",
77
- "mkdocstrings[python]>=1.0.4",
76
+ "mkdocs-material>=9.7.7",
77
+ "mkdocstrings[python]>=1.0.6",
78
78
  ]
79
79
  bench = [
80
- "pytest-benchmark>=5.2.3",
80
+ "pytest-benchmark>=5.3.0",
81
81
  "loguru>=0.7.3",
82
82
  ]
83
83
  free-threaded = [
84
- "pytest>=8.0",
85
- "pytest-benchmark>=5.2.3",
84
+ "pytest>=9.1.1",
85
+ "pytest-benchmark>=5.3.0",
86
86
  ]
87
87
 
88
88
  [tool.maturin]
@@ -19,7 +19,7 @@ from structguru.metrics import MetricProcessor
19
19
  from structguru.otel import add_otel_context
20
20
  from structguru.redaction import DEFAULT_SENSITIVE_KEYS
21
21
 
22
- __version__ = "1.2.0"
22
+ __version__ = "1.2.2"
23
23
 
24
24
  __all__ = [
25
25
  "DEFAULT_SENSITIVE_KEYS",
@@ -26,13 +26,11 @@ class _DispatchChannel:
26
26
  """One queue generation with producer-aware shutdown semantics."""
27
27
 
28
28
  def __init__(self, maxsize: int) -> None:
29
- self.queue: queue.Queue[_Record | object] = queue.Queue(maxsize=maxsize)
29
+ self.queue: queue.Queue[_Record] = queue.Queue(maxsize=maxsize)
30
30
  self.maxsize = maxsize
31
31
  self._condition = threading.Condition()
32
32
  self._accepting = True
33
33
  self._producers = 0
34
- self._close_from_worker = False
35
- self._stop_token = object()
36
34
  self.thread = threading.Thread(target=self._loop, daemon=True)
37
35
  self.thread.start()
38
36
 
@@ -68,34 +66,40 @@ class _DispatchChannel:
68
66
  while self._producers:
69
67
  self._condition.wait()
70
68
  self.queue.join()
69
+ with self._condition:
70
+ retired = not self._accepting
71
+ if retired:
72
+ self.thread.join()
71
73
 
72
- def close(self, *, drain: bool) -> None:
73
- """Reject new producers and stop after every accepted producer finishes."""
74
- is_worker = threading.current_thread() is self.thread
74
+ def retire(self) -> None:
75
+ """Reject new producers without waiting for callbacks or queue space."""
75
76
  with self._condition:
76
77
  self._accepting = False
77
78
  self._condition.notify_all()
78
- if is_worker:
79
- # The callback currently running on the worker may itself disable
80
- # logging. Let the loop drain accepted producers and exit naturally;
81
- # blocking here would deadlock a producer waiting for queue space.
82
- self._close_from_worker = True
83
- return
84
- while self._producers:
85
- self._condition.wait()
86
79
 
80
+ def close(self, *, drain: bool) -> None:
81
+ """Reject new producers and stop after every accepted producer finishes."""
82
+ self.retire()
83
+ if threading.current_thread() is self.thread:
84
+ return
87
85
  if drain:
88
- self.queue.join()
89
- self.queue.put(self._stop_token)
86
+ self.flush()
90
87
  self.thread.join()
91
88
 
92
89
  def _loop(self) -> None:
93
90
  while True:
94
- item = self.queue.get()
95
- if item is self._stop_token:
96
- self.queue.task_done()
97
- return
98
- assert isinstance(item, _Record)
91
+ with self._condition:
92
+ # Producers notify after insertion, including when a reserved
93
+ # producer finishes after retirement. No stop token can be left
94
+ # behind if several callers close the same generation.
95
+ self._condition.wait_for(
96
+ lambda: (
97
+ not self.queue.empty() or (not self._accepting and self._producers == 0)
98
+ )
99
+ )
100
+ if self.queue.empty():
101
+ return
102
+ item = self.queue.get_nowait()
99
103
  for sink in item.sinks:
100
104
  try:
101
105
  sink.callback(item.line)
@@ -103,50 +107,40 @@ class _DispatchChannel:
103
107
  pass
104
108
  self.queue.task_done()
105
109
 
106
- with self._condition:
107
- should_exit = (
108
- self._close_from_worker and self._producers == 0 and self.queue.empty()
109
- )
110
- if should_exit:
111
- return
112
-
113
110
 
114
111
  class CallableSinkDispatcher:
115
112
  """Own callable-sink registration, queueing, lifecycle, and metrics."""
116
113
 
117
114
  def __init__(self) -> None:
118
115
  self._lock = threading.Lock()
119
- self._transition_lock = threading.RLock()
120
116
  self._configured: list[_Sink] = []
121
117
  self._runtime: dict[int, _Sink] = {}
122
118
  self._tokens = itertools.count(1)
123
119
  self._maxsize = 1024
124
120
  self._channel: _DispatchChannel | None = None
121
+ # Retired generations remain visible until drained, so concurrent
122
+ # remove/flush/shutdown cannot overlook callbacks still using a sink.
123
+ self._channels: set[_DispatchChannel] = set()
125
124
  self._dropped = 0
126
125
 
127
126
  def add(self, callback: Callable[[str], None], min_level: int, *, enabled: bool) -> int:
128
127
  """Register a runtime sink, starting dispatch when logging is enabled."""
129
128
  token = next(self._tokens)
130
- with self._transition_lock, self._lock:
129
+ with self._lock:
131
130
  self._runtime[token] = _Sink(token, callback, min_level)
132
131
  if enabled and self._channel is None:
133
132
  self._channel = _DispatchChannel(self._maxsize)
133
+ self._channels.add(self._channel)
134
134
  return token
135
135
 
136
136
  def remove(self, token: int) -> bool:
137
137
  """Remove one sink, then drain every record that captured it."""
138
- with self._transition_lock:
139
- with self._lock:
140
- removed = self._runtime.pop(token, None) is not None
141
- channel = self._channel
142
- should_stop = not (self._configured or self._runtime)
143
- if should_stop:
144
- self._channel = None
145
- if channel is not None:
146
- if should_stop:
147
- channel.close(drain=True)
148
- else:
149
- channel.flush()
138
+ with self._lock:
139
+ removed = self._runtime.pop(token, None) is not None
140
+ if not (self._configured or self._runtime):
141
+ self._retire_active()
142
+ channels = tuple(self._channels)
143
+ self._drain(channels)
150
144
  return removed
151
145
 
152
146
  def configure(
@@ -159,27 +153,44 @@ class CallableSinkDispatcher:
159
153
  new_configured = [
160
154
  _Sink(-index, callback, 0) for index, callback in enumerate(callbacks, 1)
161
155
  ]
162
- with self._transition_lock:
163
- with self._lock:
164
- old_channel = self._channel
165
- self._configured = new_configured
166
- self._maxsize = maxsize
167
- self._dropped = 0
168
- self._channel = (
169
- _DispatchChannel(maxsize) if self._configured or self._runtime else None
170
- )
171
- if old_channel is not None:
172
- old_channel.close(drain=True)
156
+ with self._lock:
157
+ self._retire_active()
158
+ channels = tuple(self._channels)
159
+ self._configured = new_configured
160
+ self._maxsize = maxsize
161
+ self._dropped = 0
162
+ if self._configured or self._runtime:
163
+ self._channel = _DispatchChannel(maxsize)
164
+ self._channels.add(self._channel)
165
+ self._drain(channels)
173
166
 
174
167
  def disable(self) -> None:
175
168
  """Stop dispatch and remove configured sinks while preserving runtime registrations."""
176
- with self._transition_lock:
177
- with self._lock:
178
- channel = self._channel
179
- self._channel = None
180
- self._configured = []
181
- if channel is not None:
182
- channel.close(drain=True)
169
+ with self._lock:
170
+ self._retire_active()
171
+ self._configured = []
172
+ channels = tuple(self._channels)
173
+ self._drain(channels)
174
+
175
+ def _retire_active(self) -> None:
176
+ """Retire the active generation; caller must hold ``_lock``."""
177
+ if self._channel is not None:
178
+ self._channel.retire()
179
+ self._channel = None
180
+
181
+ def _drain(self, channels: tuple[_DispatchChannel, ...]) -> None:
182
+ """Wait outside state locks, except when invoked by a sink callback."""
183
+ current = threading.current_thread()
184
+ # A callback cannot wait for itself, or for another generation whose
185
+ # callback may be waiting for this one. An external lifecycle call will
186
+ # still find and drain every retired generation in _channels.
187
+ if not any(current is channel.thread for channel in channels):
188
+ for channel in channels:
189
+ channel.flush()
190
+ with self._lock:
191
+ self._channels.difference_update(
192
+ channel for channel in channels if not channel.thread.is_alive()
193
+ )
183
194
 
184
195
  def enqueue(self, line: str, level: int, *, overflow: str) -> bool:
185
196
  """Queue one rendered record; return false when delivery is rejected."""
@@ -204,28 +215,26 @@ class CallableSinkDispatcher:
204
215
  def flush(self) -> None:
205
216
  """Block until all queued deliveries have completed."""
206
217
  with self._lock:
207
- channel = self._channel
208
- if channel is not None:
209
- channel.flush()
218
+ channels = tuple(self._channels)
219
+ self._drain(channels)
210
220
 
211
221
  def stop(self, *, drain: bool) -> None:
212
222
  """Stop the active dispatch queue while preserving registrations."""
213
- with self._transition_lock:
214
- with self._lock:
215
- channel = self._channel
216
- self._channel = None
217
- if channel is not None:
218
- channel.close(drain=drain)
223
+ with self._lock:
224
+ self._retire_active()
225
+ channels = tuple(self._channels)
226
+ # Even drain=False previously joined behind all accepted queue entries.
227
+ self._drain(channels)
219
228
 
220
229
  def after_fork(self, *, enabled: bool) -> None:
221
230
  """Replace inherited synchronization state and restart in a forked child."""
222
231
  self._lock = threading.Lock()
223
- self._transition_lock = threading.RLock()
224
232
  self._channel = (
225
233
  _DispatchChannel(self._maxsize)
226
234
  if enabled and (self._configured or self._runtime)
227
235
  else None
228
236
  )
237
+ self._channels = {self._channel} if self._channel is not None else set()
229
238
 
230
239
  def metrics(self) -> dict[str, int]:
231
240
  """Return callable dispatch queue and drop metrics."""
@@ -67,11 +67,6 @@ def setup_celery_logging(
67
67
  ) -> None:
68
68
  clear_contextvars()
69
69
 
70
- if task_id:
71
- bind_contextvars(task_id=task_id)
72
- if task:
73
- bind_contextvars(task_name=task.name)
74
-
75
70
  if propagate_context and task:
76
71
  request = task.request
77
72
  ctx: dict[str, Any] | None = None
@@ -82,6 +77,13 @@ def setup_celery_logging(
82
77
  if isinstance(ctx, dict):
83
78
  bind_contextvars(**ctx)
84
79
 
80
+ # A producer may itself be a task. Its propagated identity must never
81
+ # replace the consumer's identity; correlation fields still propagate.
82
+ if task_id:
83
+ bind_contextvars(task_id=task_id)
84
+ if task:
85
+ bind_contextvars(task_name=task.name)
86
+
85
87
  @task_postrun.connect(weak=False) # type: ignore[untyped-decorator]
86
88
  def _clear_context(**_kw: Any) -> None:
87
89
  clear_contextvars()
@@ -104,17 +104,18 @@ class SentryProcessor:
104
104
  return event_dict
105
105
 
106
106
  level = _METHOD_TO_LEVEL.get(method_name.lower(), logging.INFO)
107
+ # Raw exceptions are reserved for capture_exception. The SDK serializes
108
+ # arbitrary objects in breadcrumbs/extras, which would bypass redaction.
109
+ payload = {
110
+ k: v for k, v in event_dict.items() if k not in ("exc_info", REDACTED_MARKER_KEY)
111
+ }
107
112
 
108
113
  if level >= self._breadcrumb_level:
109
114
  _sentry_sdk.add_breadcrumb(
110
115
  message=str(event_dict.get("event", "")),
111
116
  category="structguru",
112
117
  level=method_name,
113
- data={
114
- k: v
115
- for k, v in event_dict.items()
116
- if k != "event" and k != REDACTED_MARKER_KEY
117
- },
118
+ data={k: v for k, v in payload.items() if k != "event"},
118
119
  )
119
120
 
120
121
  if level < self._event_level:
@@ -122,12 +123,11 @@ class SentryProcessor:
122
123
 
123
124
  with _sentry_sdk.new_scope() as scope:
124
125
  for key in self._tag_keys:
125
- if key in event_dict:
126
- scope.set_tag(key, str(event_dict[key]))
126
+ if key in payload:
127
+ scope.set_tag(key, str(payload[key]))
127
128
 
128
129
  if not self._require_redaction or event_dict.get(REDACTED_MARKER_KEY):
129
- extras = {k: v for k, v in event_dict.items() if k != REDACTED_MARKER_KEY}
130
- scope.set_extra("structlog_event", extras)
130
+ scope.set_extra("structlog_event", payload)
131
131
 
132
132
  exc_info = event_dict.get("exc_info")
133
133
  if exc_info:
File without changes