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