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