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 arc_swap::ArcSwap;
8use std::collections::HashMap;
9use std::path::Path;
10use std::sync::{Arc, OnceLock};
11
12use sentry_options_validation::{
13    SchemaRegistry, ValidationError, ValuesWatcher, resolve_options_dir,
14};
15use serde_json::Value;
16use thiserror::Error;
17
18pub mod testing;
19
20static GLOBAL_OPTIONS: OnceLock<Options> = OnceLock::new();
21
22#[derive(Debug, Error)]
23pub enum OptionsError {
24    #[error("Options not initialized - call init() first")]
25    NotInitialized,
26
27    #[error("Unknown namespace: {0}")]
28    UnknownNamespace(String),
29
30    #[error("Unknown option '{key}' in namespace '{namespace}'")]
31    UnknownOption { namespace: String, key: String },
32
33    #[error("Schema error: {0}")]
34    Schema(#[from] ValidationError),
35}
36
37pub type Result<T> = std::result::Result<T, OptionsError>;
38
39/// Options store for reading configuration values.
40pub struct Options {
41    registry: Arc<SchemaRegistry>,
42    values: Arc<ArcSwap<HashMap<String, HashMap<String, Value>>>>,
43    _watcher: ValuesWatcher,
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    /// Load options from a specific directory (useful for testing).
55    /// Expects `{base_dir}/schemas/` and `{base_dir}/values/` subdirectories.
56    pub fn from_directory(base_dir: &Path) -> Result<Self> {
57        let registry = SchemaRegistry::from_directory(&base_dir.join("schemas"))?;
58        Self::with_registry_and_values(registry, &base_dir.join("values"))
59    }
60
61    /// Load options with schemas provided as in-memory JSON strings.
62    /// Values are loaded from disk using the standard fallback chain.
63    pub fn from_schemas(schemas: &[(&str, &str)]) -> Result<Self> {
64        let registry = SchemaRegistry::from_schemas(schemas)?;
65        Self::with_registry_and_values(registry, &resolve_options_dir().join("values"))
66    }
67
68    fn with_registry_and_values(registry: SchemaRegistry, values_dir: &Path) -> Result<Self> {
69        let registry = Arc::new(registry);
70        let (loaded_values, _) = registry.load_values_json(values_dir)?;
71        let values = Arc::new(ArcSwap::from_pointee(loaded_values));
72        let watcher = ValuesWatcher::new(values_dir, Arc::clone(&registry), Arc::clone(&values))?;
73        Ok(Self {
74            registry,
75            values,
76            _watcher: watcher,
77        })
78    }
79
80    /// Get an option value, returning the schema default if not set.
81    pub fn get(&self, namespace: &str, key: &str) -> Result<Value> {
82        if let Some(value) = testing::get_override(namespace, key) {
83            return Ok(value);
84        }
85
86        let schema = self
87            .registry
88            .get(namespace)
89            .ok_or_else(|| OptionsError::UnknownNamespace(namespace.to_string()))?;
90
91        let values_guard = self.values.load();
92        if let Some(ns_values) = values_guard.get(namespace)
93            && let Some(value) = ns_values.get(key)
94        {
95            return Ok(value.clone());
96        }
97
98        let default = schema
99            .get_default(key)
100            .ok_or_else(|| OptionsError::UnknownOption {
101                namespace: namespace.to_string(),
102                key: key.to_string(),
103            })?;
104
105        Ok(default.clone())
106    }
107
108    /// Validate that a key exists in the schema and the value matches the expected type.
109    pub fn validate_override(&self, namespace: &str, key: &str, value: &Value) -> Result<()> {
110        let schema = self
111            .registry
112            .get(namespace)
113            .ok_or_else(|| OptionsError::UnknownNamespace(namespace.to_string()))?;
114
115        schema.validate_option(key, value)?;
116
117        Ok(())
118    }
119    /// Check if an option has a value.
120    ///
121    /// Returns true if the option is defined and has a value, will return
122    /// false if the option is defined and does not have a value.
123    ///
124    /// If the namespace or option are not defined, an Err will be returned.
125    pub fn isset(&self, namespace: &str, key: &str) -> Result<bool> {
126        let schema = self
127            .registry
128            .get(namespace)
129            .ok_or_else(|| OptionsError::UnknownNamespace(namespace.to_string()))?;
130
131        if !schema.options.contains_key(key) {
132            return Err(OptionsError::UnknownOption {
133                namespace: namespace.into(),
134                key: key.into(),
135            });
136        }
137
138        let values_guard = self.values.load();
139        if let Some(ns_values) = values_guard.get(namespace) {
140            Ok(ns_values.contains_key(key))
141        } else {
142            Ok(false)
143        }
144    }
145}
146
147/// Initialize global options using fallback chain: `SENTRY_OPTIONS_DIR` env var,
148/// then `/etc/sentry-options` if it exists, otherwise `sentry-options/`.
149///
150/// Idempotent: if already initialized, returns `Ok(())` without re-loading.
151pub fn init() -> Result<()> {
152    if GLOBAL_OPTIONS.get().is_some() {
153        return Ok(());
154    }
155    let opts = Options::new()?;
156    let _ = GLOBAL_OPTIONS.set(opts);
157    Ok(())
158}
159
160/// Initialize global options with schemas provided as in-memory JSON strings.
161/// Values are loaded from disk using the standard fallback chain.
162///
163/// Idempotent: if already initialized (by `init()` or a prior `init_with_schemas()`),
164/// returns `Ok(())` without updating schemas.
165///
166/// Use this when schemas are embedded in the binary via `include_str!`:
167/// ```rust,ignore
168/// init_with_schemas(&[
169///     ("snuba", include_str!("sentry-options/schemas/snuba/schema.json")),
170/// ])?;
171/// ```
172pub fn init_with_schemas(schemas: &[(&str, &str)]) -> Result<()> {
173    if GLOBAL_OPTIONS.get().is_some() {
174        return Ok(());
175    }
176    let opts = Options::from_schemas(schemas)?;
177    let _ = GLOBAL_OPTIONS.set(opts);
178    Ok(())
179}
180
181/// Get a namespace handle for accessing options.
182///
183/// Returns an error if `init()` has not been called.
184pub fn options(namespace: &str) -> Result<NamespaceOptions> {
185    let opts = GLOBAL_OPTIONS.get().ok_or(OptionsError::NotInitialized)?;
186    Ok(NamespaceOptions {
187        namespace: namespace.to_string(),
188        options: opts,
189    })
190}
191
192/// Handle for accessing options within a specific namespace.
193pub struct NamespaceOptions {
194    namespace: String,
195    options: &'static Options,
196}
197
198impl NamespaceOptions {
199    /// Get an option value, returning the schema default if not set.
200    pub fn get(&self, key: &str) -> Result<Value> {
201        self.options.get(&self.namespace, key)
202    }
203
204    /// Check if an option has a key defined, or if the default is being used.
205    pub fn isset(&self, key: &str) -> Result<bool> {
206        self.options.isset(&self.namespace, key)
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213    use serde_json::json;
214    use std::fs;
215    use tempfile::TempDir;
216
217    fn create_schema(dir: &Path, namespace: &str, schema: &str) {
218        let schema_dir = dir.join(namespace);
219        fs::create_dir_all(&schema_dir).unwrap();
220        fs::write(schema_dir.join("schema.json"), schema).unwrap();
221    }
222
223    fn create_values(dir: &Path, namespace: &str, values: &str) {
224        let ns_dir = dir.join(namespace);
225        fs::create_dir_all(&ns_dir).unwrap();
226        fs::write(ns_dir.join("values.json"), values).unwrap();
227    }
228
229    #[test]
230    fn test_get_value() {
231        let temp = TempDir::new().unwrap();
232        let schemas = temp.path().join("schemas");
233        let values = temp.path().join("values");
234        fs::create_dir_all(&schemas).unwrap();
235
236        create_schema(
237            &schemas,
238            "test",
239            r#"{
240                "version": "1.0",
241                "type": "object",
242                "properties": {
243                    "enabled": {
244                        "type": "boolean",
245                        "default": false,
246                        "description": "Enable feature"
247                    }
248                }
249            }"#,
250        );
251        create_values(&values, "test", r#"{"options": {"enabled": true}}"#);
252
253        let options = Options::from_directory(temp.path()).unwrap();
254        assert_eq!(options.get("test", "enabled").unwrap(), json!(true));
255    }
256
257    #[test]
258    fn test_get_default() {
259        let temp = TempDir::new().unwrap();
260        let schemas = temp.path().join("schemas");
261        let values = temp.path().join("values");
262        fs::create_dir_all(&schemas).unwrap();
263        fs::create_dir_all(&values).unwrap();
264
265        create_schema(
266            &schemas,
267            "test",
268            r#"{
269                "version": "1.0",
270                "type": "object",
271                "properties": {
272                    "timeout": {
273                        "type": "integer",
274                        "default": 30,
275                        "description": "Timeout"
276                    }
277                }
278            }"#,
279        );
280
281        let options = Options::from_directory(temp.path()).unwrap();
282        assert_eq!(options.get("test", "timeout").unwrap(), json!(30));
283    }
284
285    #[test]
286    fn test_unknown_namespace() {
287        let temp = TempDir::new().unwrap();
288        let schemas = temp.path().join("schemas");
289        let values = temp.path().join("values");
290        fs::create_dir_all(&schemas).unwrap();
291        fs::create_dir_all(&values).unwrap();
292
293        create_schema(
294            &schemas,
295            "test",
296            r#"{"version": "1.0", "type": "object", "properties": {}}"#,
297        );
298
299        let options = Options::from_directory(temp.path()).unwrap();
300        assert!(matches!(
301            options.get("unknown", "key"),
302            Err(OptionsError::UnknownNamespace(_))
303        ));
304    }
305
306    #[test]
307    fn test_unknown_option() {
308        let temp = TempDir::new().unwrap();
309        let schemas = temp.path().join("schemas");
310        let values = temp.path().join("values");
311        fs::create_dir_all(&schemas).unwrap();
312        fs::create_dir_all(&values).unwrap();
313
314        create_schema(
315            &schemas,
316            "test",
317            r#"{
318                "version": "1.0",
319                "type": "object",
320                "properties": {
321                    "known": {"type": "string", "default": "x", "description": "Known"}
322                }
323            }"#,
324        );
325
326        let options = Options::from_directory(temp.path()).unwrap();
327        assert!(matches!(
328            options.get("test", "unknown"),
329            Err(OptionsError::UnknownOption { .. })
330        ));
331    }
332
333    #[test]
334    fn test_missing_values_dir() {
335        let temp = TempDir::new().unwrap();
336        let schemas = temp.path().join("schemas");
337        fs::create_dir_all(&schemas).unwrap();
338
339        create_schema(
340            &schemas,
341            "test",
342            r#"{
343                "version": "1.0",
344                "type": "object",
345                "properties": {
346                    "opt": {"type": "string", "default": "default_val", "description": "Opt"}
347                }
348            }"#,
349        );
350
351        let options = Options::from_directory(temp.path()).unwrap();
352        assert_eq!(options.get("test", "opt").unwrap(), json!("default_val"));
353    }
354
355    #[test]
356    fn isset_with_defined_and_undefined_keys() {
357        let temp = TempDir::new().unwrap();
358        let schemas = temp.path().join("schemas");
359        fs::create_dir_all(&schemas).unwrap();
360
361        let values = temp.path().join("values");
362        create_values(&values, "test", r#"{"options": {"has-value": "yes"}}"#);
363
364        create_schema(
365            &schemas,
366            "test",
367            r#"{
368                "version": "1.0",
369                "type": "object",
370                "properties": {
371                    "has-value": {"type": "string", "default": "", "description": ""},
372                    "defined-with-default": {"type": "string", "default": "default_val", "description": "Opt"}
373                }
374            }"#,
375        );
376
377        let options = Options::from_directory(temp.path()).unwrap();
378        assert!(options.isset("test", "not-defined").is_err());
379        assert!(!options.isset("test", "defined-with-default").unwrap());
380        assert!(options.isset("test", "has-value").unwrap());
381    }
382
383    #[test]
384    fn test_from_schemas_get_default() {
385        let schema = r#"{
386            "version": "1.0",
387            "type": "object",
388            "properties": {
389                "enabled": {
390                    "type": "boolean",
391                    "default": false,
392                    "description": "Enable feature"
393                }
394            }
395        }"#;
396
397        let registry = SchemaRegistry::from_schemas(&[("test", schema)]).unwrap();
398        let default = registry
399            .get("test")
400            .unwrap()
401            .get_default("enabled")
402            .unwrap();
403        assert_eq!(*default, json!(false));
404    }
405
406    #[test]
407    fn test_from_schemas_with_values() {
408        let temp = TempDir::new().unwrap();
409        let values_dir = temp.path().join("values");
410        create_values(&values_dir, "test", r#"{"options": {"enabled": true}}"#);
411
412        let schema = r#"{
413            "version": "1.0",
414            "type": "object",
415            "properties": {
416                "enabled": {
417                    "type": "boolean",
418                    "default": false,
419                    "description": "Enable feature"
420                }
421            }
422        }"#;
423
424        let registry = Arc::new(SchemaRegistry::from_schemas(&[("test", schema)]).unwrap());
425        let (loaded_values, _) = registry.load_values_json(&values_dir).unwrap();
426        assert_eq!(loaded_values["test"]["enabled"], json!(true));
427    }
428
429    #[test]
430    fn test_from_schemas_invalid_json() {
431        let result = SchemaRegistry::from_schemas(&[("test", "not valid json")]);
432        assert!(result.is_err());
433    }
434}