Skip to main content

relay_dynamic_config/
global.rs

1use std::collections::HashMap;
2use std::fs::File;
3use std::io::BufReader;
4use std::path::Path;
5
6use relay_base_schema::metrics::MetricNamespace;
7use relay_event_normalization::{MeasurementsConfig, ModelMetadata, SpanOpDefaults};
8use relay_filter::GenericFiltersConfig;
9use relay_quotas::Quota;
10use serde::{Deserialize, Serialize, de};
11use serde_json::Value;
12
13use crate::{ErrorBoundary, MetricExtractionGroups};
14
15/// A dynamic configuration for all Relays passed down from Sentry.
16///
17/// Values shared across all projects may also be included here, to keep
18/// [`ProjectConfig`](crate::ProjectConfig)s small.
19#[derive(Default, Clone, Debug, Serialize, Deserialize)]
20#[serde(default, rename_all = "camelCase")]
21pub struct GlobalConfig {
22    /// Configuration for measurements normalization.
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub measurements: Option<MeasurementsConfig>,
25    /// Quotas that apply to all projects.
26    #[serde(skip_serializing_if = "Vec::is_empty")]
27    pub quotas: Vec<Quota>,
28    /// Configuration for global inbound filters.
29    ///
30    /// These filters are merged with generic filters in project configs before
31    /// applying.
32    #[serde(skip_serializing_if = "is_err_or_empty")]
33    pub filters: ErrorBoundary<GenericFiltersConfig>,
34    /// Sentry options passed down to Relay.
35    #[serde(
36        deserialize_with = "default_on_error",
37        skip_serializing_if = "is_default"
38    )]
39    pub options: Options,
40
41    /// Configuration for global metrics extraction rules.
42    ///
43    /// These are merged with rules in project configs before
44    /// applying.
45    #[serde(skip_serializing_if = "is_ok_and_empty")]
46    pub metric_extraction: ErrorBoundary<MetricExtractionGroups>,
47
48    /// Metadata for AI models including costs and context size.
49    #[serde(skip_serializing_if = "is_model_metadata_empty")]
50    pub ai_model_metadata: ErrorBoundary<ModelMetadata>,
51
52    /// Configuration to derive the `span.op` from other span fields.
53    #[serde(
54        deserialize_with = "default_on_error",
55        skip_serializing_if = "is_default"
56    )]
57    pub span_op_defaults: SpanOpDefaults,
58}
59
60impl GlobalConfig {
61    /// Loads the [`GlobalConfig`] from a file if it's provided.
62    ///
63    /// The folder_path argument should be the path to the folder where the Relay config and
64    /// credentials are stored.
65    pub fn load(folder_path: &Path) -> anyhow::Result<Option<Self>> {
66        let path = folder_path.join("global_config.json");
67
68        if path.exists() {
69            let file = BufReader::new(File::open(path)?);
70            Ok(Some(serde_json::from_reader(file)?))
71        } else {
72            Ok(None)
73        }
74    }
75
76    /// Returns the generic inbound filters.
77    pub fn filters(&self) -> Option<&GenericFiltersConfig> {
78        match &self.filters {
79            ErrorBoundary::Err(_) => None,
80            ErrorBoundary::Ok(f) => Some(f),
81        }
82    }
83
84    /// Returns the AI model metadata if configured and enabled.
85    pub fn ai_model_metadata(&self) -> Option<&ModelMetadata> {
86        self.ai_model_metadata
87            .as_ref()
88            .ok()
89            .filter(|m| m.is_enabled())
90    }
91}
92
93fn is_err_or_empty(filters_config: &ErrorBoundary<GenericFiltersConfig>) -> bool {
94    match filters_config {
95        ErrorBoundary::Err(_) => true,
96        ErrorBoundary::Ok(config) => config.version == 0 && config.filters.is_empty(),
97    }
98}
99
100// Temporary until we understand why we see false killswitch values sometimes appearing.
101fn default_killswitched() -> bool {
102    relay_log::info!("using default for endpoint fetch config");
103    bool::default()
104}
105
106/// All options passed down from Sentry to Relay.
107#[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq)]
108#[serde(default)]
109pub struct Options {
110    /// Metric bucket encoding configuration for sets by metric namespace.
111    #[serde(
112        rename = "relay.metric-bucket-set-encodings",
113        deserialize_with = "de_metric_bucket_encodings",
114        skip_serializing_if = "is_default"
115    )]
116    pub metric_bucket_set_encodings: BucketEncodings,
117    /// Metric bucket encoding configuration for distributions by metric namespace.
118    #[serde(
119        rename = "relay.metric-bucket-distribution-encodings",
120        deserialize_with = "de_metric_bucket_encodings",
121        skip_serializing_if = "is_default"
122    )]
123    pub metric_bucket_dist_encodings: BucketEncodings,
124
125    /// List of values on span description that are allowed to be sent to Sentry without being scrubbed.
126    ///
127    /// At this point, it doesn't accept IP addresses in CIDR format.. yet.
128    #[serde(
129        rename = "relay.span-normalization.allowed_hosts",
130        deserialize_with = "default_on_error",
131        skip_serializing_if = "Vec::is_empty"
132    )]
133    pub http_span_allowed_hosts: Vec<String>,
134
135    /// Instructs relay to store attachments in objectstore instead of sending chunks via kafka.
136    ///
137    /// Rate needs to be between `0.0` and `1.0`.
138    /// If set to `1.0` all attachments will be stored in objectstore.
139    #[serde(
140        rename = "relay.objectstore-attachments.sample-rate",
141        deserialize_with = "default_on_error",
142        skip_serializing_if = "is_default"
143    )]
144    pub objectstore_attachments_sample_rate: f32,
145
146    /// Rollout rate for the EAP (Event Analytics Platform) double-write for user sessions.
147    ///
148    /// When rolled out, session data is sent both through the legacy metrics pipeline
149    /// and directly to the `snuba-items` topic as `TRACE_ITEM_TYPE_USER_SESSION`.
150    ///
151    /// Rate needs to be between `0.0` and `1.0`.
152    #[serde(
153        rename = "relay.sessions-eap.rollout-rate",
154        deserialize_with = "default_on_error",
155        skip_serializing_if = "is_default"
156    )]
157    pub sessions_eap_rollout_rate: f32,
158
159    /// Rollout rate for accepted outcomes being emitted by EAP instead of Relay.
160    ///
161    /// Rate needs to be between `0.0` and `1.0`.
162    #[serde(
163        rename = "relay.eap-outcomes.rollout-rate",
164        deserialize_with = "default_on_error",
165        skip_serializing_if = "is_default"
166    )]
167    pub eap_outcomes_rollout_rate: f32,
168
169    /// Rollout rate for accepted outcomes for spans being emitted by EAP instead of Relay.
170    ///
171    /// Rate needs to be between `0.0` and `1.0`.
172    #[serde(
173        rename = "relay.eap-span-outcomes.rollout-rate",
174        deserialize_with = "default_on_error",
175        skip_serializing_if = "is_default"
176    )]
177    pub eap_span_outcomes_rollout_rate: f32,
178
179    /// Kill-switch for fetching project configs in endpoints.
180    #[serde(
181        default = "default_killswitched",
182        rename = "relay.endpoint-fetch-config.enabled",
183        deserialize_with = "default_on_error",
184        skip_serializing_if = "is_default"
185    )]
186    pub endpoint_fetch_config_enabled: bool,
187
188    /// All other unknown options.
189    #[serde(flatten)]
190    other: HashMap<String, Value>,
191}
192
193/// Configuration container to control [`BucketEncoding`] per namespace.
194#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq)]
195#[serde(default)]
196pub struct BucketEncodings {
197    spans: BucketEncoding,
198    transactions: BucketEncoding,
199    profiles: BucketEncoding,
200    custom: BucketEncoding,
201}
202
203impl BucketEncodings {
204    /// Returns the configured encoding for a specific namespace.
205    pub fn for_namespace(&self, namespace: MetricNamespace) -> BucketEncoding {
206        match namespace {
207            MetricNamespace::Spans => self.spans,
208            MetricNamespace::Transactions => self.transactions,
209            MetricNamespace::Custom => self.custom,
210            // Always force the legacy encoding for sessions,
211            // sessions are not part of the generic metrics platform with different
212            // consumer which are not (yet) updated to support the new data.
213            MetricNamespace::Sessions => BucketEncoding::Legacy,
214            _ => BucketEncoding::Legacy,
215        }
216    }
217}
218
219/// Deserializes individual metric encodings or all from a string.
220///
221/// Returns a default when failing to deserialize.
222fn de_metric_bucket_encodings<'de, D>(deserializer: D) -> Result<BucketEncodings, D::Error>
223where
224    D: serde::de::Deserializer<'de>,
225{
226    struct Visitor;
227
228    impl<'de> de::Visitor<'de> for Visitor {
229        type Value = BucketEncodings;
230
231        fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
232            formatter.write_str("metric bucket encodings")
233        }
234
235        fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
236        where
237            E: de::Error,
238        {
239            let encoding = BucketEncoding::deserialize(de::value::StrDeserializer::new(v))?;
240            Ok(BucketEncodings {
241                spans: encoding,
242                transactions: encoding,
243                profiles: encoding,
244                custom: encoding,
245            })
246        }
247
248        fn visit_map<A>(self, map: A) -> Result<Self::Value, A::Error>
249        where
250            A: de::MapAccess<'de>,
251        {
252            BucketEncodings::deserialize(de::value::MapAccessDeserializer::new(map))
253        }
254    }
255
256    match deserializer.deserialize_any(Visitor) {
257        Ok(value) => Ok(value),
258        Err(error) => {
259            relay_log::error!(
260                error = %error,
261                "Error deserializing metric bucket encodings",
262            );
263            Ok(BucketEncodings::default())
264        }
265    }
266}
267
268/// All supported metric bucket encodings.
269#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq)]
270#[serde(rename_all = "lowercase")]
271pub enum BucketEncoding {
272    /// The default legacy encoding.
273    ///
274    /// A simple JSON array of numbers.
275    #[default]
276    Legacy,
277    /// The array encoding.
278    ///
279    /// Uses already the dynamic value format but still encodes
280    /// all values as a JSON number array.
281    Array,
282    /// Base64 encoding.
283    ///
284    /// Encodes all values as Base64.
285    Base64,
286    /// Zstd.
287    ///
288    /// Compresses all values with zstd.
289    Zstd,
290}
291
292/// Returns `true` if this value is equal to `Default::default()`.
293fn is_default<T: Default + PartialEq>(t: &T) -> bool {
294    t == &T::default()
295}
296
297fn default_on_error<'de, D, T>(deserializer: D) -> Result<T, D::Error>
298where
299    D: serde::de::Deserializer<'de>,
300    T: Default + serde::de::DeserializeOwned,
301{
302    match T::deserialize(deserializer) {
303        Ok(value) => Ok(value),
304        Err(error) => {
305            relay_log::error!(
306                error = %error,
307                "Error deserializing global config option: {}",
308                std::any::type_name::<T>(),
309            );
310            Ok(T::default())
311        }
312    }
313}
314
315fn is_ok_and_empty(value: &ErrorBoundary<MetricExtractionGroups>) -> bool {
316    matches!(
317        value,
318        &ErrorBoundary::Ok(MetricExtractionGroups { ref groups }) if groups.is_empty()
319    )
320}
321
322fn is_model_metadata_empty(value: &ErrorBoundary<ModelMetadata>) -> bool {
323    matches!(value, ErrorBoundary::Ok(metadata) if metadata.is_empty())
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329
330    #[test]
331    fn test_global_config_roundtrip() {
332        let json = r#"{
333  "measurements": {
334    "builtinMeasurements": [
335      {
336        "name": "foo",
337        "unit": "none"
338      },
339      {
340        "name": "bar",
341        "unit": "none"
342      },
343      {
344        "name": "baz",
345        "unit": "none"
346      }
347    ],
348    "maxCustomMeasurements": 5
349  },
350  "quotas": [
351    {
352      "id": "foo",
353      "categories": [
354        "metric_bucket"
355      ],
356      "scope": "organization",
357      "limit": 0,
358      "namespace": null
359    },
360    {
361      "id": "bar",
362      "categories": [
363        "metric_bucket"
364      ],
365      "scope": "organization",
366      "limit": 0,
367      "namespace": null
368    }
369  ],
370  "filters": {
371    "version": 1,
372    "filters": [
373      {
374        "id": "myError",
375        "isEnabled": true,
376        "condition": {
377          "op": "eq",
378          "name": "event.exceptions",
379          "value": "myError"
380        }
381      }
382    ]
383  }
384}"#;
385
386        let deserialized = serde_json::from_str::<GlobalConfig>(json).unwrap();
387        let serialized = serde_json::to_string_pretty(&deserialized).unwrap();
388        assert_eq!(json, serialized.as_str());
389    }
390
391    #[test]
392    fn test_minimal_serialization() {
393        let config = r#"{"options":{"foo":"bar"}}"#;
394        let deserialized: GlobalConfig = serde_json::from_str(config).unwrap();
395        let serialized = serde_json::to_string(&deserialized).unwrap();
396        assert_eq!(config, &serialized);
397    }
398
399    #[test]
400    fn test_metric_bucket_encodings_de_from_str() {
401        let o: Options = serde_json::from_str(
402            r#"{
403                "relay.metric-bucket-set-encodings": "legacy",
404                "relay.metric-bucket-distribution-encodings": "zstd"
405        }"#,
406        )
407        .unwrap();
408
409        assert_eq!(
410            o.metric_bucket_set_encodings,
411            BucketEncodings {
412                spans: BucketEncoding::Legacy,
413                transactions: BucketEncoding::Legacy,
414                profiles: BucketEncoding::Legacy,
415                custom: BucketEncoding::Legacy,
416            }
417        );
418        assert_eq!(
419            o.metric_bucket_dist_encodings,
420            BucketEncodings {
421                spans: BucketEncoding::Zstd,
422                transactions: BucketEncoding::Zstd,
423                profiles: BucketEncoding::Zstd,
424                custom: BucketEncoding::Zstd,
425            }
426        );
427    }
428
429    #[test]
430    fn test_metric_bucket_encodings_de_from_obj() {
431        let original = BucketEncodings {
432            spans: BucketEncoding::Zstd,
433            transactions: BucketEncoding::Zstd,
434            profiles: BucketEncoding::Base64,
435            custom: BucketEncoding::Zstd,
436        };
437        let s = serde_json::to_string(&original).unwrap();
438        let s = format!(
439            r#"{{
440            "relay.metric-bucket-set-encodings": {s},
441            "relay.metric-bucket-distribution-encodings": {s}
442        }}"#
443        );
444
445        let o: Options = serde_json::from_str(&s).unwrap();
446        assert_eq!(o.metric_bucket_set_encodings, original);
447        assert_eq!(o.metric_bucket_dist_encodings, original);
448    }
449}