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