Skip to main content

sentry_options/
lib.rs

1//! Options client for reading validated configuration values.
2
3pub mod features;
4
5pub use features::{FeatureChecker, FeatureContext, features};
6
7use std::path::{Path, PathBuf};
8use std::sync::{Arc, OnceLock};
9
10pub use sentry_options_validation::PropagationCallback;
11use sentry_options_validation::{
12    SchemaRegistry, ValidationError, ValuesStore, resolve_options_dir,
13};
14use serde_json::Value;
15use thiserror::Error;
16
17pub mod testing;
18
19static GLOBAL_OPTIONS: OnceLock<Options> = OnceLock::new();
20
21#[derive(Debug, Error)]
22pub enum OptionsError {
23    #[error("Options not initialized - call init() first")]
24    NotInitialized,
25
26    #[error("Options already initialized")]
27    AlreadyInitialized,
28
29    #[error("Unknown namespace: {0}")]
30    UnknownNamespace(String),
31
32    #[error("Unknown option '{key}' in namespace '{namespace}'")]
33    UnknownOption { namespace: String, key: String },
34
35    #[error("Schema error: {0}")]
36    Schema(#[from] ValidationError),
37}
38
39pub type Result<T> = std::result::Result<T, OptionsError>;
40
41/// Options store for reading configuration values.
42pub struct Options {
43    store: ValuesStore,
44}
45
46impl Options {
47    /// Returns a builder for constructing an [`Options`] instance or
48    /// initializing the global store.
49    ///
50    /// Configure optional parameters with the `with_*` methods, then finalize
51    /// with [`InitBuilder::build`] for a standalone instance or
52    /// [`InitBuilder::init`] for the global store.
53    pub fn builder<'a>() -> InitBuilder<'a> {
54        InitBuilder::new()
55    }
56
57    /// Load options using fallback chain: `SENTRY_OPTIONS_DIR` env var, then `/etc/sentry-options`
58    /// if it exists, otherwise `sentry-options/`.
59    /// Expects `{dir}/schemas/` and `{dir}/values/` subdirectories.
60    pub fn new() -> Result<Self> {
61        Self::from_directory(&resolve_options_dir())
62    }
63
64    /// Like [`new`], but with a propagation callback that fires `(namespace, delay_secs)`
65    /// whenever values are refreshed with a new `generated_at` timestamp.
66    pub fn new_with_propagation_callback(callback: PropagationCallback) -> Result<Self> {
67        let dir = resolve_options_dir();
68        let registry = SchemaRegistry::from_directory(&dir.join("schemas"))?;
69        Self::with_registry_and_values(registry, &dir.join("values"), Some(callback))
70    }
71
72    /// Load options from a specific directory (useful for testing).
73    /// Expects `{base_dir}/schemas/` and `{base_dir}/values/` subdirectories.
74    pub fn from_directory(base_dir: &Path) -> Result<Self> {
75        let registry = SchemaRegistry::from_directory(&base_dir.join("schemas"))?;
76        Self::with_registry_and_values(registry, &base_dir.join("values"), None)
77    }
78
79    /// Load options with schemas provided as in-memory JSON strings.
80    /// Values are loaded from disk using the standard fallback chain.
81    pub fn from_schemas(schemas: &[(&str, &str)]) -> Result<Self> {
82        let registry = SchemaRegistry::from_schemas(schemas)?;
83        Self::with_registry_and_values(registry, &resolve_options_dir().join("values"), None)
84    }
85
86    fn with_registry_and_values(
87        registry: SchemaRegistry,
88        values_dir: &Path,
89        callback: Option<PropagationCallback>,
90    ) -> Result<Self> {
91        let registry = Arc::new(registry);
92        let store = match callback {
93            Some(cb) => ValuesStore::with_propagation_callback(registry, values_dir, cb)?,
94            None => ValuesStore::new(registry, values_dir)?,
95        };
96        Ok(Self { store })
97    }
98
99    /// Get an option value, returning the schema default if not set.
100    pub fn get(&self, namespace: &str, key: &str) -> Result<Value> {
101        self.get_inner(namespace, key, false)
102    }
103
104    /// Like [`get`](Self::get), but always refreshes. Refresh incurs a cost
105    /// so this should only be used in testing.
106    pub fn get_forced(&self, namespace: &str, key: &str) -> Result<Value> {
107        self.get_inner(namespace, key, true)
108    }
109
110    fn get_inner(&self, namespace: &str, key: &str, force_reload: bool) -> Result<Value> {
111        if let Some(value) = testing::get_override(namespace, key) {
112            return Ok(value);
113        }
114
115        let schema = self
116            .store
117            .registry()
118            .get(namespace)
119            .ok_or_else(|| OptionsError::UnknownNamespace(namespace.to_string()))?;
120
121        let values_guard = if force_reload {
122            self.store.force_load()
123        } else {
124            self.store.load()
125        };
126
127        if let Some(ns_values) = values_guard.get(namespace)
128            && let Some(value) = ns_values.get(key)
129        {
130            return Ok(value.clone());
131        }
132
133        let default = schema
134            .get_default(key)
135            .ok_or_else(|| OptionsError::UnknownOption {
136                namespace: namespace.to_string(),
137                key: key.to_string(),
138            })?;
139
140        Ok(default.clone())
141    }
142
143    /// Validate that a key exists in the schema and the value matches the expected type.
144    pub fn validate_override(&self, namespace: &str, key: &str, value: &Value) -> Result<()> {
145        let schema = self
146            .store
147            .registry()
148            .get(namespace)
149            .ok_or_else(|| OptionsError::UnknownNamespace(namespace.to_string()))?;
150
151        schema.validate_option(key, value)?;
152
153        Ok(())
154    }
155
156    /// Check if an option has a value.
157    ///
158    /// Returns true if the option is defined and has a value, will return
159    /// false if the option is defined and does not have a value.
160    ///
161    /// If the namespace or option are not defined, an Err will be returned.
162    pub fn isset(&self, namespace: &str, key: &str) -> Result<bool> {
163        let schema = self
164            .store
165            .registry()
166            .get(namespace)
167            .ok_or_else(|| OptionsError::UnknownNamespace(namespace.to_string()))?;
168
169        if !schema.is_known_key(key) {
170            return Err(OptionsError::UnknownOption {
171                namespace: namespace.into(),
172                key: key.into(),
173            });
174        }
175
176        let values_guard = self.store.load();
177        if let Some(ns_values) = values_guard.get(namespace) {
178            Ok(ns_values.contains_key(key))
179        } else {
180            Ok(false)
181        }
182    }
183}
184
185/// Builder for initializing the global options store.
186/// Define optional parameters with `with_*` methods, then finalize with `init()`
187///
188/// All settings are optional and independent:
189/// - `with_directory` overrides the base directory
190/// - `with_schemas` supplies schemas in memory instead of reading `{dir}/schemas/`
191/// - `with_callback` registers a callback that fires on every value refresh.
192///
193#[derive(Default)]
194pub struct InitBuilder<'a> {
195    directory: Option<PathBuf>,
196    schemas: Option<&'a [(&'a str, &'a str)]>,
197    callback: Option<PropagationCallback>,
198}
199
200impl<'a> InitBuilder<'a> {
201    pub fn new() -> Self {
202        InitBuilder::default()
203    }
204
205    /// Override the base directory. Expects `{directory}/schemas/` and `{directory}/values/` subdirectories.
206    /// Otherwise, defaults to the fallback chain: `SENTRY_OPTIONS_DIR` env var, then `/etc/sentry-options`
207    pub fn with_directory(mut self, directory: impl Into<PathBuf>) -> Self {
208        self.directory = Some(directory.into());
209        self
210    }
211
212    /// Provide schemas as in-memory `(namespace, json)` pairs instead of reading
213    /// them from `{dir}/schemas/`, intended to be used with `include_str!`
214    pub fn with_schemas(mut self, schemas: &'a [(&'a str, &'a str)]) -> Self {
215        self.schemas = Some(schemas);
216        self
217    }
218
219    /// Register a callback that fires `(namespace, delay_secs)` whenever values
220    /// are refreshed with a new `generated_at` timestamp.
221    pub fn with_callback(mut self, callback: impl Fn(&str, f64) + Send + Sync + 'static) -> Self {
222        self.callback = Some(Box::new(callback));
223        self
224    }
225
226    /// Initialize the global options store from the inputs.
227    ///
228    /// Returns [`OptionsError::AlreadyInitialized`] if the store is already
229    /// initialized, leaving the existing configuration untouched. Callers that
230    /// want a no-op in that case can ignore the error with `.ok()`.
231    pub fn init(self) -> Result<()> {
232        if GLOBAL_OPTIONS.get().is_some() {
233            return Err(OptionsError::AlreadyInitialized);
234        }
235        GLOBAL_OPTIONS
236            .set(self.build()?)
237            .map_err(|_| OptionsError::AlreadyInitialized)
238    }
239
240    /// Builds an [`Options`] instance from the inputs, without initializing
241    /// the global store.
242    ///
243    /// Use this when you want to manage the lifetime of the options store yourself, or
244    /// when you want to have multiple independent options stores in the same process.
245    ///
246    /// Use [`init`] if you want to initialize the global store.
247    ///
248    /// Returns an error if the schemas are invalid or the values cannot be loaded.
249    pub fn build(self) -> Result<Options> {
250        let dir = self.directory.unwrap_or_else(resolve_options_dir);
251
252        let registry = match self.schemas {
253            Some(s) => SchemaRegistry::from_schemas(s)?,
254            None => SchemaRegistry::from_directory(&dir.join("schemas"))?,
255        };
256
257        Options::with_registry_and_values(registry, &dir.join("values"), self.callback)
258    }
259}
260
261/// Initialize global options using the fallback chain: `SENTRY_OPTIONS_DIR` env
262/// var, then `/etc/sentry-options` if it exists, otherwise `sentry-options/`.
263///
264/// Shorthand for `Options::builder().init()`. Idempotent: if already
265/// initialized, returns `Ok(())` without re-loading. For directory, schema, or
266/// callback overrides, use [`Options::builder`].
267pub fn init() -> Result<()> {
268    ignore_already_initialized(InitBuilder::new().init())
269}
270
271/// Like [`init`], but with a callback that fires whenever values are refreshed
272/// from disk with a new `generated_at` timestamp. The callback receives
273/// `(namespace, delay_secs)`.
274#[deprecated(
275    since = "1.3.0",
276    note = "use `Options::builder().with_callback(cb).init()`"
277)]
278pub fn init_with_propagation_callback(callback: PropagationCallback) -> Result<()> {
279    ignore_already_initialized(InitBuilder::new().with_callback(callback).init())
280}
281
282/// Initialize global options with schemas provided as in-memory JSON strings.
283/// Values are loaded from disk using the standard fallback chain.
284///
285/// Idempotent: if already initialized (by `init()` or a prior `init_with_schemas()`),
286/// returns `Ok(())` without updating schemas.
287///
288/// Use this when schemas are embedded in the binary via `include_str!`:
289/// ```rust,ignore
290/// init_with_schemas(&[
291///     ("snuba", include_str!("sentry-options/schemas/snuba/schema.json")),
292/// ])?;
293/// ```
294#[deprecated(
295    since = "1.3.0",
296    note = "use `Options::builder().with_schemas(s).init()`"
297)]
298pub fn init_with_schemas(schemas: &[(&str, &str)]) -> Result<()> {
299    ignore_already_initialized(InitBuilder::new().with_schemas(schemas).init())
300}
301
302/// legacy, deprecated system was idempotent, this preserves the behaviour
303fn ignore_already_initialized(result: Result<()>) -> Result<()> {
304    match result {
305        Err(OptionsError::AlreadyInitialized) => Ok(()),
306        other => other,
307    }
308}
309
310/// Get a namespace handle for accessing options.
311///
312/// Returns an error if `init()` has not been called.
313pub fn options(namespace: &str) -> Result<NamespaceOptions> {
314    let opts = GLOBAL_OPTIONS.get().ok_or(OptionsError::NotInitialized)?;
315    Ok(NamespaceOptions {
316        namespace: namespace.to_string(),
317        options: opts,
318    })
319}
320
321/// Handle for accessing options within a specific namespace.
322pub struct NamespaceOptions {
323    namespace: String,
324    options: &'static Options,
325}
326
327impl NamespaceOptions {
328    /// Get an option value, returning the schema default if not set.
329    pub fn get(&self, key: &str) -> Result<Value> {
330        self.options.get(&self.namespace, key)
331    }
332
333    /// Like [`get`](Self::get), but always refreshes. Refresh incurs a cost
334    /// so this should only be used in testing.
335    pub fn get_forced(&self, key: &str) -> Result<Value> {
336        self.options.get_forced(&self.namespace, key)
337    }
338
339    /// Check if an option has a key defined, or if the default is being used.
340    pub fn isset(&self, key: &str) -> Result<bool> {
341        self.options.isset(&self.namespace, key)
342    }
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348    use serde_json::json;
349    use std::fs;
350    use tempfile::TempDir;
351
352    fn create_schema(dir: &Path, namespace: &str, schema: &str) {
353        let schema_dir = dir.join(namespace);
354        fs::create_dir_all(&schema_dir).unwrap();
355        fs::write(schema_dir.join("schema.json"), schema).unwrap();
356    }
357
358    fn create_values(dir: &Path, namespace: &str, values: &str) {
359        let ns_dir = dir.join(namespace);
360        fs::create_dir_all(&ns_dir).unwrap();
361        fs::write(ns_dir.join("values.json"), values).unwrap();
362    }
363
364    #[test]
365    fn test_get_value() {
366        let temp = TempDir::new().unwrap();
367        let schemas = temp.path().join("schemas");
368        let values = temp.path().join("values");
369        fs::create_dir_all(&schemas).unwrap();
370
371        create_schema(
372            &schemas,
373            "test",
374            r#"{
375                "version": "1.0",
376                "type": "object",
377                "properties": {
378                    "enabled": {
379                        "type": "boolean",
380                        "default": false,
381                        "description": "Enable feature"
382                    }
383                }
384            }"#,
385        );
386        create_values(&values, "test", r#"{"options": {"enabled": true}}"#);
387
388        let options = Options::from_directory(temp.path()).unwrap();
389        assert_eq!(options.get("test", "enabled").unwrap(), json!(true));
390    }
391
392    #[test]
393    fn test_get_default() {
394        let temp = TempDir::new().unwrap();
395        let schemas = temp.path().join("schemas");
396        let values = temp.path().join("values");
397        fs::create_dir_all(&schemas).unwrap();
398        fs::create_dir_all(&values).unwrap();
399
400        create_schema(
401            &schemas,
402            "test",
403            r#"{
404                "version": "1.0",
405                "type": "object",
406                "properties": {
407                    "timeout": {
408                        "type": "integer",
409                        "default": 30,
410                        "description": "Timeout"
411                    }
412                }
413            }"#,
414        );
415
416        let options = Options::from_directory(temp.path()).unwrap();
417        assert_eq!(options.get("test", "timeout").unwrap(), json!(30));
418    }
419
420    #[test]
421    fn test_unknown_namespace() {
422        let temp = TempDir::new().unwrap();
423        let schemas = temp.path().join("schemas");
424        let values = temp.path().join("values");
425        fs::create_dir_all(&schemas).unwrap();
426        fs::create_dir_all(&values).unwrap();
427
428        create_schema(
429            &schemas,
430            "test",
431            r#"{"version": "1.0", "type": "object", "properties": {}}"#,
432        );
433
434        let options = Options::from_directory(temp.path()).unwrap();
435        assert!(matches!(
436            options.get("unknown", "key"),
437            Err(OptionsError::UnknownNamespace(_))
438        ));
439    }
440
441    #[test]
442    fn test_unknown_option() {
443        let temp = TempDir::new().unwrap();
444        let schemas = temp.path().join("schemas");
445        let values = temp.path().join("values");
446        fs::create_dir_all(&schemas).unwrap();
447        fs::create_dir_all(&values).unwrap();
448
449        create_schema(
450            &schemas,
451            "test",
452            r#"{
453                "version": "1.0",
454                "type": "object",
455                "properties": {
456                    "known": {"type": "string", "default": "x", "description": "Known"}
457                }
458            }"#,
459        );
460
461        let options = Options::from_directory(temp.path()).unwrap();
462        assert!(matches!(
463            options.get("test", "unknown"),
464            Err(OptionsError::UnknownOption { .. })
465        ));
466    }
467
468    #[test]
469    fn test_missing_values_dir() {
470        let temp = TempDir::new().unwrap();
471        let schemas = temp.path().join("schemas");
472        fs::create_dir_all(&schemas).unwrap();
473
474        create_schema(
475            &schemas,
476            "test",
477            r#"{
478                "version": "1.0",
479                "type": "object",
480                "properties": {
481                    "opt": {"type": "string", "default": "default_val", "description": "Opt"}
482                }
483            }"#,
484        );
485
486        let options = Options::from_directory(temp.path()).unwrap();
487        assert_eq!(options.get("test", "opt").unwrap(), json!("default_val"));
488    }
489
490    #[test]
491    fn isset_with_defined_and_undefined_keys() {
492        let temp = TempDir::new().unwrap();
493        let schemas = temp.path().join("schemas");
494        fs::create_dir_all(&schemas).unwrap();
495
496        let values = temp.path().join("values");
497        create_values(&values, "test", r#"{"options": {"has-value": "yes"}}"#);
498
499        create_schema(
500            &schemas,
501            "test",
502            r##"{
503                "version": "1.0",
504                "type": "object",
505                "properties": {
506                    "has-value": {"type": "string", "default": "", "description": ""},
507                    "defined-with-default": {"type": "string", "default": "default_val", "description": "Opt"},
508                    "feature.organizations:my-flag": {"$ref": "#/definitions/Feature"}
509                }
510            }"##,
511        );
512
513        let options = Options::from_directory(temp.path()).unwrap();
514        assert!(options.isset("test", "not-defined").is_err());
515        assert!(!options.isset("test", "defined-with-default").unwrap());
516        assert!(options.isset("test", "has-value").unwrap());
517        // Feature keys are known (no UnknownOption error) but unset -> false.
518        assert!(
519            !options
520                .isset("test", "feature.organizations:my-flag")
521                .unwrap()
522        );
523    }
524
525    #[test]
526    fn test_from_schemas_get_default() {
527        let schema = r#"{
528            "version": "1.0",
529            "type": "object",
530            "properties": {
531                "enabled": {
532                    "type": "boolean",
533                    "default": false,
534                    "description": "Enable feature"
535                }
536            }
537        }"#;
538
539        let registry = SchemaRegistry::from_schemas(&[("test", schema)]).unwrap();
540        let default = registry
541            .get("test")
542            .unwrap()
543            .get_default("enabled")
544            .unwrap();
545        assert_eq!(*default, json!(false));
546    }
547
548    #[test]
549    fn test_from_schemas_with_values() {
550        let temp = TempDir::new().unwrap();
551        let values_dir = temp.path().join("values");
552        create_values(&values_dir, "test", r#"{"options": {"enabled": true}}"#);
553
554        let schema = r#"{
555            "version": "1.0",
556            "type": "object",
557            "properties": {
558                "enabled": {
559                    "type": "boolean",
560                    "default": false,
561                    "description": "Enable feature"
562                }
563            }
564        }"#;
565
566        let registry = Arc::new(SchemaRegistry::from_schemas(&[("test", schema)]).unwrap());
567        let (loaded_values, _) = registry.load_values_json(&values_dir).unwrap();
568        assert_eq!(loaded_values["test"]["enabled"], json!(true));
569    }
570
571    #[test]
572    fn test_from_schemas_invalid_json() {
573        let result = SchemaRegistry::from_schemas(&[("test", "not valid json")]);
574        assert!(result.is_err());
575    }
576
577    const BOOL_SCHEMA: &str = r#"{
578        "version": "1.0",
579        "type": "object",
580        "properties": {
581            "enabled": {"type": "boolean", "default": false, "description": "x"}
582        }
583    }"#;
584
585    #[test]
586    fn builder_with_directory() {
587        let temp = TempDir::new().unwrap();
588        let schemas = temp.path().join("schemas");
589        let values = temp.path().join("values");
590        fs::create_dir_all(&schemas).unwrap();
591        create_schema(&schemas, "test", BOOL_SCHEMA);
592        create_values(&values, "test", r#"{"options": {"enabled": true}}"#);
593
594        let options = Options::builder()
595            // without this, schemas wouldn't be found and this test would fail
596            .with_directory(temp.path())
597            .build()
598            .unwrap();
599        assert_eq!(options.get("test", "enabled").unwrap(), json!(true));
600    }
601
602    #[test]
603    fn builder_with_schemas() {
604        let options = Options::builder()
605            // without this, init would fail as schemas are never saved on disk
606            .with_schemas(&[("test", BOOL_SCHEMA)])
607            .build()
608            .unwrap();
609        assert_eq!(options.get("test", "enabled").unwrap(), json!(false));
610    }
611
612    #[test]
613    fn get_forced_sees_change() {
614        let temp = TempDir::new().unwrap();
615        let schemas = temp.path().join("schemas");
616        let values = temp.path().join("values");
617        fs::create_dir_all(&schemas).unwrap();
618        create_schema(&schemas, "test", BOOL_SCHEMA);
619        create_values(&values, "test", r#"{"options": {"enabled": true}}"#);
620
621        let options = Options::from_directory(temp.path()).unwrap();
622        assert_eq!(options.get("test", "enabled").unwrap(), json!(true));
623
624        create_values(&values, "test", r#"{"options": {"enabled": false}}"#);
625
626        // Cached read still returns the old value
627        assert_eq!(options.get("test", "enabled").unwrap(), json!(true));
628        // Forced read picks up the change
629        assert_eq!(options.get_forced("test", "enabled").unwrap(), json!(false));
630    }
631}