objectstore_options/lib.rs
1//! Runtime options for Objectstore, backed by [`sentry-options`].
2//!
3//! See the [`Options`] struct for details and usage instructions.
4//!
5//! [`sentry-options`]: https://crates.io/crates/sentry-options
6
7use std::collections::BTreeMap;
8
9use objectstore_typed_options::SentryOptions;
10use objectstore_types::usecase::Usecase;
11use serde::{Deserialize, Serialize};
12
13pub use objectstore_typed_options::Error;
14
15/// Initializes options and spawns a background task that refreshes them periodically.
16///
17/// The refresh interval is 4 seconds, chosen to stay under the 5-second staleness
18/// threshold built into `sentry-options`.
19pub fn init() -> Result<(), Error> {
20 Options::init()?;
21
22 tokio::spawn(async {
23 let mut interval = tokio::time::interval(std::time::Duration::from_secs(4));
24 loop {
25 interval.tick().await;
26
27 // `spawn_blocking` propagates panics as a JoinError. No need to log them, since the
28 // global panic hook does that already.
29 let result = tokio::task::spawn_blocking(Options::refresh).await;
30 if let Ok(Err(ref err)) = result {
31 objectstore_log::error!(!!err, "failed to refresh options");
32 }
33 }
34 });
35
36 Ok(())
37}
38
39/// Runtime options for Objectstore, loaded from sentry-options.
40///
41/// Obtain a snapshot of the current options via [`Options::get`]. Before calling `get`,
42/// the global instance must be initialized with [`Options::init`].
43#[derive(Debug, SentryOptions)]
44#[sentry_options(namespace = "objectstore", path = "../../sentry-options")]
45pub struct Options {
46 /// Active killswitches that may disable access to specific object contexts.
47 killswitches: Vec<Killswitch>,
48}
49
50impl Options {
51 /// Returns the list of active killswitches.
52 pub fn killswitches(&self) -> &[Killswitch] {
53 &self.killswitches
54 }
55}
56
57/// A killswitch that may disable access to certain object contexts.
58///
59/// Note that at least one of the fields should be set, or else the killswitch will match all
60/// contexts and discard all requests.
61#[derive(Debug, Deserialize, Serialize, PartialEq)]
62pub struct Killswitch {
63 /// Optional validated usecase name to match exactly.
64 ///
65 /// If `None`, matches any usecase.
66 #[serde(default)]
67 pub usecase: Option<Usecase>,
68
69 /// Scopes to match.
70 ///
71 /// If empty, matches any scopes. Additional scopes in the context are ignored, so a killswitch
72 /// matches if all of the specified scopes are present in the request with matching values.
73 #[serde(default)]
74 pub scopes: BTreeMap<String, String>,
75
76 /// Optional service glob pattern to match.
77 ///
78 /// If `None`, matches any service (or absence of service header).
79 /// If specified, the request must have a matching `x-downstream-service` header. The header
80 /// value is normalized before matching: any trailing Kubernetes ReplicaSet hash and pod
81 /// suffix are stripped, so patterns should match the base service name (e.g. `relay*`, not
82 /// `relay-7d8f9c5b6d-*`).
83 #[serde(default)]
84 pub service: Option<String>,
85}
86
87#[cfg(test)]
88mod tests {
89 use super::*;
90
91 // Required for schema validation.
92 #[cfg(not(feature = "testing"))]
93 compile_error!("tests require the `testing` feature: run with `--features testing`");
94
95 #[test]
96 fn schema_is_valid() {
97 let _ = Options::get();
98 }
99
100 #[test]
101 fn killswitch_deserialization_validates_usecase() {
102 let switch: Killswitch = serde_json::from_str(r#"{"usecase":"Debug_Files"}"#).unwrap();
103 assert_eq!(switch.usecase.as_ref().unwrap().as_str(), "Debug_Files");
104 let serialized = serde_json::to_value(&switch).unwrap();
105 assert_eq!(serialized["usecase"], "Debug_Files");
106
107 let wildcard: Killswitch = serde_json::from_str("{}").unwrap();
108 assert!(wildcard.usecase.is_none());
109 assert!(serde_json::from_str::<Killswitch>(r#"{"usecase":"test/name"}"#).is_err());
110 }
111}