sentry-options 1.2.2__tar.gz → 1.2.3__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 (33) hide show
  1. {sentry_options-1.2.2 → sentry_options-1.2.3}/Cargo.lock +82 -7
  2. {sentry_options-1.2.2 → sentry_options-1.2.3}/Cargo.toml +3 -2
  3. {sentry_options-1.2.2 → sentry_options-1.2.3}/PKG-INFO +1 -1
  4. {sentry_options-1.2.2 → sentry_options-1.2.3}/clients/python/uv.lock +1 -1
  5. {sentry_options-1.2.2 → sentry_options-1.2.3}/clients/rust/Cargo.toml +1 -0
  6. {sentry_options-1.2.2 → sentry_options-1.2.3}/clients/rust/src/features.rs +298 -125
  7. {sentry_options-1.2.2 → sentry_options-1.2.3}/pyproject.toml +1 -1
  8. {sentry_options-1.2.2 → sentry_options-1.2.3}/sentry-options-validation/Cargo.toml +1 -3
  9. {sentry_options-1.2.2 → sentry_options-1.2.3}/sentry-options-validation/src/lib.rs +31 -11
  10. {sentry_options-1.2.2 → sentry_options-1.2.3}/clients/python/Cargo.toml +0 -0
  11. {sentry_options-1.2.2 → sentry_options-1.2.3}/clients/python/benches/bench_get.py +0 -0
  12. {sentry_options-1.2.2 → sentry_options-1.2.3}/clients/python/benches/bench_has.py +0 -0
  13. {sentry_options-1.2.2 → sentry_options-1.2.3}/clients/python/benches/bench_isset.py +0 -0
  14. {sentry_options-1.2.2 → sentry_options-1.2.3}/clients/python/src/lib.rs +0 -0
  15. {sentry_options-1.2.2 → sentry_options-1.2.3}/clients/python/tests/conftest.py +0 -0
  16. {sentry_options-1.2.2 → sentry_options-1.2.3}/clients/python/tests/features_test.py +0 -0
  17. {sentry_options-1.2.2 → sentry_options-1.2.3}/clients/python/tests/options_override_test.py +0 -0
  18. {sentry_options-1.2.2 → sentry_options-1.2.3}/clients/python/tests/options_test.py +0 -0
  19. {sentry_options-1.2.2 → sentry_options-1.2.3}/clients/python/tests/propagation_test.py +0 -0
  20. {sentry_options-1.2.2 → sentry_options-1.2.3}/clients/python/tests/refresh_test.py +0 -0
  21. {sentry_options-1.2.2 → sentry_options-1.2.3}/clients/rust/benches/features_bench.rs +0 -0
  22. {sentry_options-1.2.2 → sentry_options-1.2.3}/clients/rust/benches/isset_bench.rs +0 -0
  23. {sentry_options-1.2.2 → sentry_options-1.2.3}/clients/rust/benches/options_bench.rs +0 -0
  24. {sentry_options-1.2.2 → sentry_options-1.2.3}/clients/rust/benches/reload_trace.rs +0 -0
  25. {sentry_options-1.2.2 → sentry_options-1.2.3}/clients/rust/src/lib.rs +0 -0
  26. {sentry_options-1.2.2 → sentry_options-1.2.3}/clients/rust/src/testing.rs +0 -0
  27. {sentry_options-1.2.2 → sentry_options-1.2.3}/clients/rust/tests/override_validation.rs +0 -0
  28. {sentry_options-1.2.2 → sentry_options-1.2.3}/python/sentry_options/__init__.py +0 -0
  29. {sentry_options-1.2.2 → sentry_options-1.2.3}/python/sentry_options/_core.pyi +0 -0
  30. {sentry_options-1.2.2 → sentry_options-1.2.3}/python/sentry_options/py.typed +0 -0
  31. {sentry_options-1.2.2 → sentry_options-1.2.3}/python/sentry_options/testing.py +0 -0
  32. {sentry_options-1.2.2 → sentry_options-1.2.3}/sentry-options-validation/src/feature-schema-defs.json +0 -0
  33. {sentry_options-1.2.2 → sentry_options-1.2.3}/sentry-options-validation/src/namespace-schema.json +0 -0
@@ -435,7 +435,7 @@ dependencies = [
435
435
 
436
436
  [[package]]
437
437
  name = "example"
438
- version = "1.2.2"
438
+ version = "1.2.3"
439
439
  dependencies = [
440
440
  "anyhow",
441
441
  "sentry-options",
@@ -996,6 +996,15 @@ dependencies = [
996
996
  "windows-sys 0.61.2",
997
997
  ]
998
998
 
999
+ [[package]]
1000
+ name = "nu-ansi-term"
1001
+ version = "0.50.3"
1002
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1003
+ checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
1004
+ dependencies = [
1005
+ "windows-sys 0.61.2",
1006
+ ]
1007
+
999
1008
  [[package]]
1000
1009
  name = "num"
1001
1010
  version = "0.4.3"
@@ -1437,7 +1446,7 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
1437
1446
 
1438
1447
  [[package]]
1439
1448
  name = "sentry-options"
1440
- version = "1.2.2"
1449
+ version = "1.2.3"
1441
1450
  dependencies = [
1442
1451
  "arc-swap",
1443
1452
  "criterion",
@@ -1447,11 +1456,12 @@ dependencies = [
1447
1456
  "sha1",
1448
1457
  "tempfile",
1449
1458
  "thiserror",
1459
+ "tracing",
1450
1460
  ]
1451
1461
 
1452
1462
  [[package]]
1453
1463
  name = "sentry-options-cli"
1454
- version = "1.2.2"
1464
+ version = "1.2.3"
1455
1465
  dependencies = [
1456
1466
  "chrono",
1457
1467
  "clap",
@@ -1461,12 +1471,14 @@ dependencies = [
1461
1471
  "serde_yaml",
1462
1472
  "tempfile",
1463
1473
  "thiserror",
1474
+ "tracing",
1475
+ "tracing-subscriber",
1464
1476
  "walkdir",
1465
1477
  ]
1466
1478
 
1467
1479
  [[package]]
1468
1480
  name = "sentry-options-python"
1469
- version = "1.2.2"
1481
+ version = "1.2.3"
1470
1482
  dependencies = [
1471
1483
  "pyo3",
1472
1484
  "sentry-options",
@@ -1475,7 +1487,7 @@ dependencies = [
1475
1487
 
1476
1488
  [[package]]
1477
1489
  name = "sentry-options-validation"
1478
- version = "1.2.2"
1490
+ version = "1.2.3"
1479
1491
  dependencies = [
1480
1492
  "anyhow",
1481
1493
  "arc-swap",
@@ -1485,6 +1497,7 @@ dependencies = [
1485
1497
  "serde_json",
1486
1498
  "tempfile",
1487
1499
  "thiserror",
1500
+ "tracing",
1488
1501
  ]
1489
1502
 
1490
1503
  [[package]]
@@ -1566,6 +1579,15 @@ dependencies = [
1566
1579
  "digest",
1567
1580
  ]
1568
1581
 
1582
+ [[package]]
1583
+ name = "sharded-slab"
1584
+ version = "0.1.7"
1585
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1586
+ checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
1587
+ dependencies = [
1588
+ "lazy_static",
1589
+ ]
1590
+
1569
1591
  [[package]]
1570
1592
  name = "shlex"
1571
1593
  version = "1.3.0"
@@ -1676,6 +1698,15 @@ dependencies = [
1676
1698
  "syn",
1677
1699
  ]
1678
1700
 
1701
+ [[package]]
1702
+ name = "thread_local"
1703
+ version = "1.1.10"
1704
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1705
+ checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070"
1706
+ dependencies = [
1707
+ "cfg-if",
1708
+ ]
1709
+
1679
1710
  [[package]]
1680
1711
  name = "tinystr"
1681
1712
  version = "0.8.2"
@@ -1756,14 +1787,26 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
1756
1787
 
1757
1788
  [[package]]
1758
1789
  name = "tracing"
1759
- version = "0.1.41"
1790
+ version = "0.1.44"
1760
1791
  source = "registry+https://github.com/rust-lang/crates.io-index"
1761
- checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
1792
+ checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
1762
1793
  dependencies = [
1763
1794
  "pin-project-lite",
1795
+ "tracing-attributes",
1764
1796
  "tracing-core",
1765
1797
  ]
1766
1798
 
1799
+ [[package]]
1800
+ name = "tracing-attributes"
1801
+ version = "0.1.31"
1802
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1803
+ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
1804
+ dependencies = [
1805
+ "proc-macro2",
1806
+ "quote",
1807
+ "syn",
1808
+ ]
1809
+
1767
1810
  [[package]]
1768
1811
  name = "tracing-core"
1769
1812
  version = "0.1.36"
@@ -1771,6 +1814,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1771
1814
  checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
1772
1815
  dependencies = [
1773
1816
  "once_cell",
1817
+ "valuable",
1818
+ ]
1819
+
1820
+ [[package]]
1821
+ name = "tracing-log"
1822
+ version = "0.2.0"
1823
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1824
+ checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
1825
+ dependencies = [
1826
+ "log",
1827
+ "once_cell",
1828
+ "tracing-core",
1829
+ ]
1830
+
1831
+ [[package]]
1832
+ name = "tracing-subscriber"
1833
+ version = "0.3.23"
1834
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1835
+ checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
1836
+ dependencies = [
1837
+ "nu-ansi-term",
1838
+ "sharded-slab",
1839
+ "smallvec",
1840
+ "thread_local",
1841
+ "tracing-core",
1842
+ "tracing-log",
1774
1843
  ]
1775
1844
 
1776
1845
  [[package]]
@@ -1843,6 +1912,12 @@ dependencies = [
1843
1912
  "vsimd",
1844
1913
  ]
1845
1914
 
1915
+ [[package]]
1916
+ name = "valuable"
1917
+ version = "0.1.1"
1918
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1919
+ checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
1920
+
1846
1921
  [[package]]
1847
1922
  name = "version_check"
1848
1923
  version = "0.9.5"
@@ -3,13 +3,13 @@ resolver = "3"
3
3
  members = ["sentry-options-validation", "clients/rust", "clients/python"]
4
4
 
5
5
  [workspace.package]
6
- version = "1.2.2"
6
+ version = "1.2.3"
7
7
  edition = "2024"
8
8
  repository = "https://github.com/getsentry/sentry-options"
9
9
  license = "Apache-2.0"
10
10
 
11
11
  [workspace.dependencies]
12
- sentry-options-validation = { path = "sentry-options-validation", version = "1.2.2" }
12
+ sentry-options-validation = { path = "sentry-options-validation", version = "1.2.3" }
13
13
  serde = { version = "1.0", features = ["derive"] }
14
14
  serde_json = "1.0.145"
15
15
  anyhow = "1.0.100"
@@ -19,3 +19,4 @@ jsonschema = "0.36.0"
19
19
  sha1 = "0.10"
20
20
  arc-swap = "1.9.1"
21
21
  chrono = { version = "0.4.45", default-features = false, features = ["std", "clock"] }
22
+ tracing = "0.1.44"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sentry_options
3
- Version: 1.2.2
3
+ Version: 1.2.3
4
4
  Classifier: Programming Language :: Python :: 3
5
5
  Classifier: Programming Language :: Python :: 3 :: Only
6
6
  Classifier: Programming Language :: Python :: Implementation :: CPython
@@ -126,7 +126,7 @@ wheels = [
126
126
 
127
127
  [[package]]
128
128
  name = "sentry-options"
129
- version = "1.2.2"
129
+ version = "1.2.3"
130
130
  source = { editable = "." }
131
131
 
132
132
  [package.optional-dependencies]
@@ -13,6 +13,7 @@ thiserror.workspace = true
13
13
  sha1.workspace = true
14
14
  arc-swap.workspace = true
15
15
  num = "0.4.3"
16
+ tracing.workspace = true
16
17
 
17
18
  [dev-dependencies]
18
19
  tempfile.workspace = true
@@ -6,7 +6,6 @@
6
6
  use num::bigint::{BigInt, Sign};
7
7
  use std::cell::Cell;
8
8
  use std::collections::HashMap;
9
- use std::sync::OnceLock;
10
9
 
11
10
  use serde_json::Value;
12
11
  use sha1::{Digest, Sha1};
@@ -88,7 +87,7 @@ impl FeatureContext {
88
87
  /// The result is cached and reset when identity fields or context data change.
89
88
  /// When no identity fields are set, all context keys are used (non-deterministic
90
89
  /// rollout across contexts with different keys).
91
- /// The id value is mostly used to id % 100 < rollout.
90
+ /// The id value is mostly used to id % 100 <= rollout.
92
91
  pub fn id(&self) -> u64 {
93
92
  if let Some(id) = self.cached_id.get() {
94
93
  return id;
@@ -229,7 +228,7 @@ impl Segment {
229
228
  if self.rollout >= 100 {
230
229
  return true;
231
230
  }
232
- context.id() % 100 < self.rollout
231
+ context.id() % 100 <= self.rollout
233
232
  }
234
233
  }
235
234
 
@@ -256,96 +255,113 @@ impl Condition {
256
255
  }
257
256
 
258
257
  fn matches(&self, context: &FeatureContext) -> bool {
259
- let Some(ctx_val) = context.get(&self.property) else {
260
- return false;
261
- };
262
- match &self.operator {
258
+ let ctx_val = context.get(&self.property);
259
+ let result = match &self.operator {
263
260
  OperatorKind::In => eval_in(ctx_val, &self.value),
264
- OperatorKind::NotIn => !eval_in(ctx_val, &self.value),
261
+ OperatorKind::NotIn => eval_in(ctx_val, &self.value).map(|m| !m),
265
262
  OperatorKind::Contains => eval_contains(ctx_val, &self.value),
266
- OperatorKind::NotContains => !eval_contains(ctx_val, &self.value),
263
+ OperatorKind::NotContains => eval_contains(ctx_val, &self.value).map(|m| !m),
267
264
  OperatorKind::Equals => eval_equals(ctx_val, &self.value),
268
- OperatorKind::NotEquals => !eval_equals(ctx_val, &self.value),
265
+ OperatorKind::NotEquals => eval_equals(ctx_val, &self.value).map(|m| !m),
269
266
  OperatorKind::Matches => eval_matches(ctx_val, &self.value),
270
- OperatorKind::NotMatches => !eval_matches(ctx_val, &self.value),
271
- }
267
+ OperatorKind::NotMatches => eval_matches(ctx_val, &self.value).map(|m| !m),
268
+ };
269
+ // A comparison that cannot be made is a non-match even when negated:
270
+ // flagpole raises for these, and the exception is caught above the
271
+ // evaluator rather than inverted.
272
+ result.unwrap_or(false)
272
273
  }
273
274
  }
274
275
 
275
- /// Check if a scalar context value is contained in a condition array.
276
+ /// For scalar ctx_val, check if it's contained in the condition array.
277
+ /// For array ctx_val, check if there is any intersection with the condition array.
276
278
  /// String comparison is case-insensitive.
277
- fn eval_in(ctx_val: &Value, condition_val: &Value) -> bool {
278
- let Some(arr) = condition_val.as_array() else {
279
- return false;
280
- };
279
+ ///
280
+ /// `None` means the comparison could not be made.
281
+ fn eval_in(ctx_val: Option<&Value>, condition_val: &Value) -> Option<bool> {
282
+ let arr = condition_val.as_array()?;
283
+ match ctx_val {
284
+ None => Some(false),
285
+ Some(Value::Object(_)) => None,
286
+ Some(Value::Array(ctx_arr)) => Some(ctx_arr.iter().any(|v| scalar_in(v, arr))),
287
+ Some(scalar) => Some(scalar_in(scalar, arr)),
288
+ }
289
+ }
290
+
291
+ fn scalar_in(ctx_val: &Value, arr: &[Value]) -> bool {
281
292
  match ctx_val {
282
293
  Value::String(s) => {
283
294
  let s_lower = s.to_lowercase();
284
295
  arr.iter()
285
296
  .any(|v| v.as_str().is_some_and(|cv| cv.to_lowercase() == s_lower))
286
297
  }
287
- Value::Number(n) => {
288
- if let Some(i) = n.as_i64() {
289
- arr.iter().any(|v| v.as_i64().is_some_and(|cv| cv == i))
290
- } else if let Some(f) = n.as_f64() {
291
- arr.iter().any(|v| v.as_f64().is_some_and(|cv| cv == f))
292
- } else {
293
- false
294
- }
295
- }
298
+ Value::Number(n) => arr
299
+ .iter()
300
+ .any(|v| v.as_number().is_some_and(|cv| numbers_equal(n, cv))),
296
301
  Value::Bool(b) => arr.iter().any(|v| v.as_bool().is_some_and(|cv| cv == *b)),
297
302
  _ => false,
298
303
  }
299
304
  }
300
305
 
306
+ /// Compare two JSON numbers regardless of their type (int vs float)
307
+ /// 1 == 1, 1.0 == 1, and 1.0 == 1.0
308
+ fn numbers_equal(a: &serde_json::Number, b: &serde_json::Number) -> bool {
309
+ // !f64 as opposed to (i64 || u64)
310
+ if !a.is_f64() && !b.is_f64() {
311
+ return a == b;
312
+ }
313
+ // mismatched types are cast to float
314
+ matches!((a.as_f64(), b.as_f64()), (Some(x), Some(y)) if x == y)
315
+ }
316
+
301
317
  /// Check if a context array contains a condition scalar value.
302
318
  /// String comparison is case-insensitive.
303
- fn eval_contains(ctx_val: &Value, condition_val: &Value) -> bool {
304
- let Some(ctx_arr) = ctx_val.as_array() else {
305
- return false;
306
- };
319
+ ///
320
+ /// A property that is missing or is not a list cannot be searched at all, so
321
+ /// `not_contains` does not grant the feature for it either.
322
+ fn eval_contains(ctx_val: Option<&Value>, condition_val: &Value) -> Option<bool> {
323
+ let ctx_arr = ctx_val?.as_array()?;
307
324
  match condition_val {
308
325
  Value::String(s) => {
309
326
  let s_lower = s.to_lowercase();
327
+ Some(
328
+ ctx_arr
329
+ .iter()
330
+ .any(|v| v.as_str().is_some_and(|cv| cv.to_lowercase() == s_lower)),
331
+ )
332
+ }
333
+ Value::Number(n) => Some(
310
334
  ctx_arr
311
335
  .iter()
312
- .any(|v| v.as_str().is_some_and(|cv| cv.to_lowercase() == s_lower))
313
- }
314
- Value::Number(n) => {
315
- if let Some(i) = n.as_i64() {
316
- ctx_arr.iter().any(|v| v.as_i64().is_some_and(|cv| cv == i))
317
- } else if let Some(f) = n.as_f64() {
318
- ctx_arr.iter().any(|v| v.as_f64().is_some_and(|cv| cv == f))
319
- } else {
320
- false
321
- }
322
- }
323
- Value::Bool(b) => ctx_arr
324
- .iter()
325
- .any(|v| v.as_bool().is_some_and(|cv| cv == *b)),
326
- _ => false,
336
+ .any(|v| v.as_number().is_some_and(|cv| numbers_equal(n, cv))),
337
+ ),
338
+ Value::Bool(b) => Some(
339
+ ctx_arr
340
+ .iter()
341
+ .any(|v| v.as_bool().is_some_and(|cv| cv == *b)),
342
+ ),
343
+ _ => None,
327
344
  }
328
345
  }
329
346
 
330
347
  /// Check if a context value equals a condition value.
348
+ /// A missing property equals nothing, and is not treated as unusable.
349
+ fn eval_equals(ctx_val: Option<&Value>, condition_val: &Value) -> Option<bool> {
350
+ Some(match ctx_val {
351
+ Some(ctx_val) => values_equal(ctx_val, condition_val),
352
+ None => false,
353
+ })
354
+ }
355
+
331
356
  /// Scalars are compared directly (strings case-insensitively).
332
357
  /// Arrays are compared element-wise with matching length.
333
- fn eval_equals(ctx_val: &Value, condition_val: &Value) -> bool {
358
+ fn values_equal(ctx_val: &Value, condition_val: &Value) -> bool {
334
359
  match (ctx_val, condition_val) {
335
360
  (Value::String(a), Value::String(b)) => a.to_lowercase() == b.to_lowercase(),
336
- (Value::Number(a), Value::Number(b)) => {
337
- // Compare as i64 first, fall back to f64
338
- if let (Some(ai), Some(bi)) = (a.as_i64(), b.as_i64()) {
339
- ai == bi
340
- } else if let (Some(af), Some(bf)) = (a.as_f64(), b.as_f64()) {
341
- af == bf
342
- } else {
343
- false
344
- }
345
- }
361
+ (Value::Number(a), Value::Number(b)) => numbers_equal(a, b),
346
362
  (Value::Bool(a), Value::Bool(b)) => a == b,
347
363
  (Value::Array(a), Value::Array(b)) => {
348
- a.len() == b.len() && a.iter().zip(b.iter()).all(|(av, bv)| eval_equals(av, bv))
364
+ a.len() == b.len() && a.iter().zip(b.iter()).all(|(av, bv)| values_equal(av, bv))
349
365
  }
350
366
  _ => false,
351
367
  }
@@ -399,71 +415,21 @@ fn glob_star_match(pattern: &str, value: &str) -> bool {
399
415
  /// Check if a string context value matches any pattern in the condition array.
400
416
  /// Patterns use star-only glob semantics; comparison is case-insensitive.
401
417
  /// Returns false for non-string context values.
402
- fn eval_matches(ctx_val: &Value, condition_val: &Value) -> bool {
403
- let Some(s) = ctx_val.as_str() else {
404
- return false;
405
- };
406
- let Some(arr) = condition_val.as_array() else {
407
- return false;
408
- };
409
- arr.iter().any(|v| {
410
- v.as_str()
411
- .is_some_and(|pattern| glob_star_match(pattern, s))
412
- })
413
- }
414
-
415
- #[derive(Debug, PartialEq)]
416
- enum DebugLogLevel {
417
- None,
418
- Parse,
419
- Match,
420
- All,
421
- }
422
-
423
- static DEBUG_LOG_LEVEL: OnceLock<DebugLogLevel> = OnceLock::new();
424
- static DEBUG_MATCH_SAMPLE_RATE: OnceLock<u64> = OnceLock::new();
425
-
426
- fn debug_log_level() -> &'static DebugLogLevel {
427
- DEBUG_LOG_LEVEL.get_or_init(|| {
428
- match std::env::var("SENTRY_OPTIONS_FEATURE_DEBUG_LOG")
429
- .as_deref()
430
- .unwrap_or("")
431
- {
432
- "all" => DebugLogLevel::All,
433
- "parse" => DebugLogLevel::Parse,
434
- "match" => DebugLogLevel::Match,
435
- _ => DebugLogLevel::None,
436
- }
437
- })
438
- }
439
-
440
- fn debug_match_sample_rate() -> u64 {
441
- *DEBUG_MATCH_SAMPLE_RATE.get_or_init(|| {
442
- std::env::var("SENTRY_OPTIONS_FEATURE_DEBUG_LOG_SAMPLE_RATE")
443
- .ok()
444
- .and_then(|v| v.parse::<f64>().ok())
445
- .map(|r| (r.clamp(0.0, 1.0) * 1000.0) as u64)
446
- .unwrap_or(1000)
447
- })
448
- }
449
-
450
- fn debug_log_parse(msg: &str) {
451
- match debug_log_level() {
452
- DebugLogLevel::Parse | DebugLogLevel::All => eprintln!("[sentry-options/parse] {msg}"),
453
- _ => {}
454
- }
455
- }
456
-
457
- fn debug_log_match(feature: &str, result: bool, context_id: u64) {
458
- match debug_log_level() {
459
- DebugLogLevel::Match | DebugLogLevel::All
460
- if context_id % 1000 < debug_match_sample_rate() =>
461
- {
462
- eprintln!(
463
- "[sentry-options/match] feature='{feature}' result={result} context_id={context_id}"
464
- );
418
+ fn eval_matches(ctx_val: Option<&Value>, condition_val: &Value) -> Option<bool> {
419
+ let arr = condition_val.as_array()?;
420
+ match ctx_val {
421
+ // A container cannot be glob matched against.
422
+ Some(Value::Array(_) | Value::Object(_)) => None,
423
+ None => Some(false),
424
+ Some(scalar) => {
425
+ let Some(s) = scalar.as_str() else {
426
+ return Some(false);
427
+ };
428
+ Some(arr.iter().any(|v| {
429
+ v.as_str()
430
+ .is_some_and(|pattern| glob_star_match(pattern, s))
431
+ }))
465
432
  }
466
- _ => {}
467
433
  }
468
434
  }
469
435
 
@@ -494,24 +460,29 @@ impl FeatureChecker {
494
460
  let feature_val = match opts.get(&self.namespace, &key) {
495
461
  Ok(v) => v,
496
462
  Err(e) => {
497
- debug_log_parse(&format!("Failed to get feature '{key}': {e}"));
463
+ tracing::debug!(key = %key, error = %e, "Failed to get feature");
498
464
  return false;
499
465
  }
500
466
  };
501
467
 
502
468
  let feature = match Feature::from_json(&feature_val) {
503
469
  Some(f) => {
504
- debug_log_parse(&format!("Parsed feature '{key}'"));
470
+ tracing::debug!(key = %key, "Parsed feature");
505
471
  f
506
472
  }
507
473
  None => {
508
- debug_log_parse(&format!("Failed to parse feature '{key}'"));
474
+ tracing::debug!(key = %key, "Failed to parse feature");
509
475
  return false;
510
476
  }
511
477
  };
512
478
 
513
479
  let result = feature.matches(context);
514
- debug_log_match(feature_name, result, context.id());
480
+ tracing::debug!(
481
+ feature = feature_name,
482
+ result,
483
+ context_id = context.id(),
484
+ "Feature match result"
485
+ );
515
486
  result
516
487
  }
517
488
  }
@@ -823,6 +794,97 @@ mod tests {
823
794
  assert!(!check(&opts, "organizations:test-feature", &ctx2));
824
795
  }
825
796
 
797
+ #[test]
798
+ fn test_condition_in_list_property_matches_on_overlap() {
799
+ let cond = r#"{"property": "plan_family", "operator": "in", "value": ["business"]}"#;
800
+ let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
801
+
802
+ let mut ctx = FeatureContext::new();
803
+ ctx.insert("plan_family", json!(["enterprise business", "Business"]));
804
+ assert!(check(&opts, "organizations:test-feature", &ctx));
805
+
806
+ let mut ctx2 = FeatureContext::new();
807
+ ctx2.insert("plan_family", json!(["team"]));
808
+ assert!(!check(&opts, "organizations:test-feature", &ctx2));
809
+
810
+ let mut ctx3 = FeatureContext::new();
811
+ ctx3.insert("plan_family", json!([]));
812
+ assert!(!check(&opts, "organizations:test-feature", &ctx3));
813
+ }
814
+
815
+ #[test]
816
+ fn test_condition_not_in_list_property_requires_no_overlap() {
817
+ let cond = r#"{"property": "plan_family", "operator": "not_in", "value": ["business"]}"#;
818
+ let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
819
+
820
+ let mut ctx = FeatureContext::new();
821
+ ctx.insert("plan_family", json!(["enterprise business", "business"]));
822
+ assert!(!check(&opts, "organizations:test-feature", &ctx));
823
+
824
+ let mut ctx2 = FeatureContext::new();
825
+ ctx2.insert("plan_family", json!(["team"]));
826
+ assert!(check(&opts, "organizations:test-feature", &ctx2));
827
+ }
828
+
829
+ #[test]
830
+ fn test_condition_in_list_property_no_type_coercion() {
831
+ let cond = r#"{"property": "org_id", "operator": "in", "value": ["123"]}"#;
832
+ let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
833
+
834
+ let mut ctx = FeatureContext::new();
835
+ ctx.insert("org_id", json!([123]));
836
+ assert!(!check(&opts, "organizations:test-feature", &ctx));
837
+
838
+ let int_cond = r#"{"property": "org_id", "operator": "in", "value": [123]}"#;
839
+ let (int_opts, _t2) = setup_feature_options(&feature_json(true, 100, int_cond));
840
+ assert!(check(&int_opts, "organizations:test-feature", &ctx));
841
+ }
842
+
843
+ #[test]
844
+ fn test_condition_in_matches_across_int_and_float() {
845
+ let cond = r#"{"property": "org_id", "operator": "in", "value": [123.0]}"#;
846
+ let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
847
+
848
+ let mut ctx = FeatureContext::new();
849
+ ctx.insert("org_id", json!(123));
850
+ assert!(check(&opts, "organizations:test-feature", &ctx));
851
+
852
+ let mut list_ctx = FeatureContext::new();
853
+ list_ctx.insert("org_id", json!([123]));
854
+ assert!(check(&opts, "organizations:test-feature", &list_ctx));
855
+
856
+ let mut ctx2 = FeatureContext::new();
857
+ ctx2.insert("org_id", json!(124));
858
+ assert!(!check(&opts, "organizations:test-feature", &ctx2));
859
+ }
860
+
861
+ #[test]
862
+ fn test_condition_in_large_ints_compare_exactly() {
863
+ // 2^53 + 1 and 2^53 collide as f64, so ints must not round-trip through it.
864
+ let cond = r#"{"property": "org_id", "operator": "in", "value": [9007199254740993]}"#;
865
+ let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
866
+
867
+ let mut ctx = FeatureContext::new();
868
+ ctx.insert("org_id", json!(9007199254740992i64));
869
+ assert!(!check(&opts, "organizations:test-feature", &ctx));
870
+
871
+ let mut ctx2 = FeatureContext::new();
872
+ ctx2.insert("org_id", json!(9007199254740993i64));
873
+ assert!(check(&opts, "organizations:test-feature", &ctx2));
874
+
875
+ // Above i64::MAX, where as_i64() gives up but the values are still exact.
876
+ let big = r#"{"property": "org_id", "operator": "in", "value": [18446744073709551615]}"#;
877
+ let (big_opts, _t2) = setup_feature_options(&feature_json(true, 100, big));
878
+
879
+ let mut ctx3 = FeatureContext::new();
880
+ ctx3.insert("org_id", json!(18446744073709551614u64));
881
+ assert!(!check(&big_opts, "organizations:test-feature", &ctx3));
882
+
883
+ let mut ctx4 = FeatureContext::new();
884
+ ctx4.insert("org_id", json!(18446744073709551615u64));
885
+ assert!(check(&big_opts, "organizations:test-feature", &ctx4));
886
+ }
887
+
826
888
  #[test]
827
889
  fn test_condition_contains() {
828
890
  let cond = r#"{"property": "tags", "operator": "contains", "value": "beta"}"#;
@@ -837,6 +899,30 @@ mod tests {
837
899
  assert!(!check(&opts, "organizations:test-feature", &ctx2));
838
900
  }
839
901
 
902
+ #[test]
903
+ fn test_condition_contains_matches_across_int_and_float() {
904
+ let int_cond = r#"{"property": "ids", "operator": "contains", "value": 123}"#;
905
+ let (int_opts, _t) = setup_feature_options(&feature_json(true, 100, int_cond));
906
+
907
+ let float_cond = r#"{"property": "ids", "operator": "contains", "value": 123.0}"#;
908
+ let (float_opts, _t2) = setup_feature_options(&feature_json(true, 100, float_cond));
909
+
910
+ let mut int_ctx = FeatureContext::new();
911
+ int_ctx.insert("ids", json!([123]));
912
+ let mut float_ctx = FeatureContext::new();
913
+ float_ctx.insert("ids", json!([123.0]));
914
+
915
+ // Matches regardless of which side holds the float.
916
+ assert!(check(&int_opts, "organizations:test-feature", &int_ctx));
917
+ assert!(check(&int_opts, "organizations:test-feature", &float_ctx));
918
+ assert!(check(&float_opts, "organizations:test-feature", &int_ctx));
919
+ assert!(check(&float_opts, "organizations:test-feature", &float_ctx));
920
+
921
+ let mut other = FeatureContext::new();
922
+ other.insert("ids", json!([124]));
923
+ assert!(!check(&int_opts, "organizations:test-feature", &other));
924
+ }
925
+
840
926
  #[test]
841
927
  fn test_condition_equals() {
842
928
  let cond = r#"{"property": "plan", "operator": "equals", "value": "enterprise"}"#;
@@ -851,6 +937,21 @@ mod tests {
851
937
  assert!(!check(&opts, "organizations:test-feature", &ctx2));
852
938
  }
853
939
 
940
+ #[test]
941
+ fn test_condition_equals_large_ints_compare_exactly() {
942
+ // Above i64::MAX these used to collapse into f64 and compare equal.
943
+ let cond = r#"{"property": "org_id", "operator": "equals", "value": 18446744073709551615}"#;
944
+ let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
945
+
946
+ let mut ctx = FeatureContext::new();
947
+ ctx.insert("org_id", json!(18446744073709551614u64));
948
+ assert!(!check(&opts, "organizations:test-feature", &ctx));
949
+
950
+ let mut ctx2 = FeatureContext::new();
951
+ ctx2.insert("org_id", json!(18446744073709551615u64));
952
+ assert!(check(&opts, "organizations:test-feature", &ctx2));
953
+ }
954
+
854
955
  #[test]
855
956
  fn test_condition_equals_bool() {
856
957
  let cond = r#"{"property": "is_free", "operator": "equals", "value": true}"#;
@@ -1404,4 +1505,76 @@ mod tests {
1404
1505
  ctx.insert("org_id", json!(123));
1405
1506
  assert!(check(&opts, "organizations:test-feature", &ctx));
1406
1507
  }
1508
+
1509
+ #[test]
1510
+ fn test_id_string_is_stable() {
1511
+ for (value, expected) in [
1512
+ (json!("sentry"), "sentry"),
1513
+ (json!(123), "123"),
1514
+ (json!(-7), "-7"),
1515
+ (json!(true), "True"),
1516
+ (json!(false), "False"),
1517
+ (json!(null), "None"),
1518
+ (json!(1.0), "1.0"),
1519
+ (json!(1.5), "1.5"),
1520
+ (json!(["a", "b"]), "[a, b]"),
1521
+ (json!([1, 2]), "[1, 2]"),
1522
+ (json!([]), "[]"),
1523
+ ] {
1524
+ assert_eq!(value_to_id_string(&value), expected, "for {value}");
1525
+ }
1526
+ }
1527
+
1528
+ fn condition_matches(operator: &str, value: &str, ctx: &FeatureContext) -> bool {
1529
+ let json = format!(r#"{{"property": "prop", "operator": "{operator}", "value": {value}}}"#);
1530
+ Condition::from_json(&serde_json::from_str(&json).unwrap())
1531
+ .expect("condition should parse")
1532
+ .matches(ctx)
1533
+ }
1534
+
1535
+ #[test]
1536
+ fn test_missing_property_matches_only_negated_conditions() {
1537
+ let ctx = FeatureContext::new();
1538
+ for (operator, value, expected) in [
1539
+ ("in", "[1]", false),
1540
+ ("not_in", "[1]", true),
1541
+ ("equals", "1", false),
1542
+ ("not_equals", "1", true),
1543
+ ("matches", r#"["a"]"#, false),
1544
+ ("not_matches", r#"["a"]"#, true),
1545
+ // `contains` has no list to search, so neither form matches.
1546
+ ("contains", r#""a""#, false),
1547
+ ("not_contains", r#""a""#, false),
1548
+ ] {
1549
+ assert_eq!(
1550
+ condition_matches(operator, value, &ctx),
1551
+ expected,
1552
+ "{operator}"
1553
+ );
1554
+ }
1555
+ }
1556
+
1557
+ #[test]
1558
+ fn test_unusable_property_type_matches_neither_form() {
1559
+ let mut map = FeatureContext::new();
1560
+ map.insert("prop", json!({"k": "v"}));
1561
+ let mut scalar = FeatureContext::new();
1562
+ scalar.insert("prop", json!("a"));
1563
+ let mut list = FeatureContext::new();
1564
+ list.insert("prop", json!(["b"]));
1565
+
1566
+ for (operator, value, ctx) in [
1567
+ ("in", "[1]", &map),
1568
+ ("not_in", "[1]", &map),
1569
+ ("contains", r#""a""#, &scalar),
1570
+ ("not_contains", r#""a""#, &scalar),
1571
+ ("matches", r#"["b"]"#, &list),
1572
+ ("not_matches", r#"["b"]"#, &list),
1573
+ ] {
1574
+ assert!(!condition_matches(operator, value, ctx), "{operator}");
1575
+ }
1576
+
1577
+ // A usable list that lacks the value still matches when negated.
1578
+ assert!(condition_matches("not_contains", r#""a""#, &list));
1579
+ }
1407
1580
  }
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "sentry_options"
7
- version = "1.2.2"
7
+ version = "1.2.3"
8
8
  description = "Python client for sentry-options using Rust validation"
9
9
  license = "Apache-2.0"
10
10
  requires-python = ">=3.11"
@@ -15,9 +15,7 @@ jsonschema.workspace = true
15
15
  tempfile.workspace = true
16
16
  arc-swap.workspace = true
17
17
  chrono.workspace = true
18
+ tracing.workspace = true
18
19
 
19
20
  [dev-dependencies]
20
21
  tempfile.workspace = true
21
-
22
- [features]
23
- default = []
@@ -611,10 +611,11 @@ impl SchemaRegistry {
611
611
  }
612
612
 
613
613
  for key in &unknown_keys {
614
- eprintln!(
615
- "sentry-options: Ignoring unknown option '{}' in namespace '{}'. \
616
- This is expected during deployments when values are updated before schemas.",
617
- key, namespace
614
+ tracing::warn!(
615
+ key = %key,
616
+ namespace = %namespace,
617
+ "Ignoring unknown option. This is expected during deployments when \
618
+ values are updated before schemas.",
618
619
  );
619
620
  }
620
621
 
@@ -693,7 +694,7 @@ impl ValuesStoreBuilder {
693
694
  pub fn build(self) -> ValidationResult<ValuesStore> {
694
695
  let values_dir = &self.values_dir;
695
696
  if !should_suppress_missing_dir_errors() && fs::metadata(values_dir).is_err() {
696
- eprintln!("Values directory does not exist: {}", values_dir.display());
697
+ tracing::warn!(path = %values_dir.display(), "Values directory does not exist");
697
698
  }
698
699
 
699
700
  let baseline = Instant::now();
@@ -826,10 +827,10 @@ impl ValuesStore {
826
827
  }
827
828
 
828
829
  fn log_refresh_error(&self, error: &ValidationError) {
829
- eprintln!(
830
- "Failed to reload values from {}: {}",
831
- self.values_dir.display(),
832
- error
830
+ tracing::error!(
831
+ path = %self.values_dir.display(),
832
+ error = %error,
833
+ "Failed to reload values",
833
834
  );
834
835
  }
835
836
 
@@ -845,6 +846,7 @@ impl ValuesStore {
845
846
  return Ok(false);
846
847
  }
847
848
 
849
+ let reload_start = Instant::now();
848
850
  let result = self.registry.load_values_json(&self.values_dir);
849
851
 
850
852
  // Publish the new snapshot before bumping the timestamp. The CAS below
@@ -856,6 +858,16 @@ impl ValuesStore {
856
858
  // architectures.
857
859
  let new_generated_at = match result {
858
860
  Ok((new_values, generated_at)) => {
861
+ let elapsed_ms = reload_start.elapsed().as_millis();
862
+ for namespace in new_values.keys() {
863
+ tracing::info!(
864
+ path = %self.values_dir.display(),
865
+ namespace = %namespace,
866
+ elapsed_ms,
867
+ "Reloaded values",
868
+ );
869
+ }
870
+
859
871
  self.values.store(Arc::new(new_values));
860
872
  self.last_mtimes.store(Arc::new(current_mtimes));
861
873
  Ok(generated_at)
@@ -894,10 +906,18 @@ impl ValuesStore {
894
906
  callback(ns, delay)
895
907
  }))
896
908
  {
897
- eprintln!("Propagation callback panicked for {ns}: {:?}", e);
909
+ tracing::error!(
910
+ namespace = %ns,
911
+ error = ?e,
912
+ "Propagation callback panicked",
913
+ );
898
914
  }
899
915
  }
900
- None => eprintln!("Bad generated_at for {ns}: {ts}"),
916
+ None => tracing::warn!(
917
+ namespace = %ns,
918
+ generated_at = %ts,
919
+ "Bad generated_at timestamp",
920
+ ),
901
921
  }
902
922
  }
903
923
  }