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.
- {structguru-1.2.0 → structguru-1.2.2}/Cargo.lock +6 -61
- {structguru-1.2.0 → structguru-1.2.2}/Cargo.toml +6 -9
- {structguru-1.2.0 → structguru-1.2.2}/PKG-INFO +7 -6
- {structguru-1.2.0 → structguru-1.2.2}/README.md +6 -5
- {structguru-1.2.0 → structguru-1.2.2}/crates/structguru-core/Cargo.toml +1 -1
- {structguru-1.2.0 → structguru-1.2.2}/crates/structguru-core/src/filter.rs +1 -3
- {structguru-1.2.0 → structguru-1.2.2}/crates/structguru-core/src/render.rs +70 -12
- {structguru-1.2.0 → structguru-1.2.2}/pyproject.toml +16 -16
- {structguru-1.2.0 → structguru-1.2.2}/src/structguru/__init__.py +1 -1
- {structguru-1.2.0 → structguru-1.2.2}/src/structguru/_native_dispatch.py +79 -70
- {structguru-1.2.0 → structguru-1.2.2}/src/structguru/integrations/celery.py +7 -5
- {structguru-1.2.0 → structguru-1.2.2}/src/structguru/integrations/sentry.py +9 -9
- {structguru-1.2.0 → structguru-1.2.2}/LICENSE +0 -0
- {structguru-1.2.0 → structguru-1.2.2}/crates/structguru-core/src/clock.rs +0 -0
- {structguru-1.2.0 → structguru-1.2.2}/crates/structguru-core/src/lib.rs +0 -0
- {structguru-1.2.0 → structguru-1.2.2}/crates/structguru-core/src/value.rs +0 -0
- {structguru-1.2.0 → structguru-1.2.2}/crates/structguru-core/src/worker.rs +0 -0
- {structguru-1.2.0 → structguru-1.2.2}/crates/structguru-py/Cargo.toml +0 -0
- {structguru-1.2.0 → structguru-1.2.2}/crates/structguru-py/src/lib.rs +0 -0
- {structguru-1.2.0 → structguru-1.2.2}/src/structguru/_contextvars.py +0 -0
- {structguru-1.2.0 → structguru-1.2.2}/src/structguru/_native_env.py +0 -0
- {structguru-1.2.0 → structguru-1.2.2}/src/structguru/_runtime.py +0 -0
- {structguru-1.2.0 → structguru-1.2.2}/src/structguru/config.py +0 -0
- {structguru-1.2.0 → structguru-1.2.2}/src/structguru/core.py +0 -0
- {structguru-1.2.0 → structguru-1.2.2}/src/structguru/exceptions.py +0 -0
- {structguru-1.2.0 → structguru-1.2.2}/src/structguru/integrations/__init__.py +0 -0
- {structguru-1.2.0 → structguru-1.2.2}/src/structguru/integrations/_stdlib_env.py +0 -0
- {structguru-1.2.0 → structguru-1.2.2}/src/structguru/integrations/_util.py +0 -0
- {structguru-1.2.0 → structguru-1.2.2}/src/structguru/integrations/asgi.py +0 -0
- {structguru-1.2.0 → structguru-1.2.2}/src/structguru/integrations/django.py +0 -0
- {structguru-1.2.0 → structguru-1.2.2}/src/structguru/integrations/flask.py +0 -0
- {structguru-1.2.0 → structguru-1.2.2}/src/structguru/integrations/grpc.py +0 -0
- {structguru-1.2.0 → structguru-1.2.2}/src/structguru/integrations/httpx.py +0 -0
- {structguru-1.2.0 → structguru-1.2.2}/src/structguru/integrations/requests.py +0 -0
- {structguru-1.2.0 → structguru-1.2.2}/src/structguru/integrations/sqlalchemy.py +0 -0
- {structguru-1.2.0 → structguru-1.2.2}/src/structguru/integrations/stdlib.py +0 -0
- {structguru-1.2.0 → structguru-1.2.2}/src/structguru/metrics.py +0 -0
- {structguru-1.2.0 → structguru-1.2.2}/src/structguru/otel.py +0 -0
- {structguru-1.2.0 → structguru-1.2.2}/src/structguru/py.typed +0 -0
- {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 = "
|
|
74
|
-
version = "
|
|
47
|
+
name = "fastrand"
|
|
48
|
+
version = "2.5.0"
|
|
75
49
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
76
|
-
checksum = "
|
|
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.
|
|
248
|
+
version = "1.2.2"
|
|
304
249
|
dependencies = [
|
|
305
250
|
"fancy-regex",
|
|
306
|
-
"
|
|
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.
|
|
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.
|
|
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.
|
|
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 `
|
|
39
|
-
#
|
|
40
|
-
|
|
41
|
-
|
|
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.
|
|
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")
|
|
145
|
-
log.info("Request complete")
|
|
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")
|
|
153
|
-
do_work()
|
|
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()
|
|
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")
|
|
101
|
-
log.info("Request complete")
|
|
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")
|
|
109
|
-
do_work()
|
|
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()
|
|
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
|
```
|
|
@@ -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
|
|
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
|
|
187
|
-
|
|
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
|
|
193
|
-
Some(custom) => is_sensitive(
|
|
194
|
-
None => is_sensitive(
|
|
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
|
|
197
|
+
if key_is_sensitive {
|
|
197
198
|
return REDACTED.to_owned();
|
|
198
199
|
}
|
|
199
200
|
|
|
200
|
-
let mut value = Value::String(
|
|
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!("
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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.
|
|
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>=
|
|
58
|
-
"pytest-asyncio>=0
|
|
59
|
-
"pytest-cov>=7.
|
|
60
|
-
"pytest-httpserver>=1.
|
|
61
|
-
"responses>=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>=
|
|
65
|
-
"orjson>=3.
|
|
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.
|
|
68
|
+
"fastapi>=0.141.1",
|
|
69
69
|
]
|
|
70
70
|
lint = [
|
|
71
|
-
"mypy>=1
|
|
72
|
-
"ruff>=0.
|
|
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.
|
|
77
|
-
"mkdocstrings[python]>=1.0.
|
|
76
|
+
"mkdocs-material>=9.7.7",
|
|
77
|
+
"mkdocstrings[python]>=1.0.6",
|
|
78
78
|
]
|
|
79
79
|
bench = [
|
|
80
|
-
"pytest-benchmark>=5.
|
|
80
|
+
"pytest-benchmark>=5.3.0",
|
|
81
81
|
"loguru>=0.7.3",
|
|
82
82
|
]
|
|
83
83
|
free-threaded = [
|
|
84
|
-
"pytest>=
|
|
85
|
-
"pytest-benchmark>=5.
|
|
84
|
+
"pytest>=9.1.1",
|
|
85
|
+
"pytest-benchmark>=5.3.0",
|
|
86
86
|
]
|
|
87
87
|
|
|
88
88
|
[tool.maturin]
|
|
@@ -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
|
|
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
|
|
73
|
-
"""Reject new producers
|
|
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.
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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.
|
|
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.
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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.
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
)
|
|
171
|
-
|
|
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.
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
208
|
-
|
|
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.
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
|
126
|
-
scope.set_tag(key, str(
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|