relay_dynamic_config/
global.rs

1use std::collections::HashMap;
2use std::collections::btree_map::Entry;
3use std::fs::File;
4use std::io::BufReader;
5use std::path::Path;
6
7use relay_base_schema::metrics::MetricNamespace;
8use relay_event_normalization::{MeasurementsConfig, ModelCosts, SpanOpDefaults};
9use relay_filter::GenericFiltersConfig;
10use relay_quotas::Quota;
11use serde::{Deserialize, Serialize, de};
12use serde_json::Value;
13
14use crate::{ErrorBoundary, MetricExtractionGroup, MetricExtractionGroups, defaults};
15
16/// A dynamic configuration for all Relays passed down from Sentry.
17///
18/// Values shared across all projects may also be included here, to keep
19/// [`ProjectConfig`](crate::ProjectConfig)s small.
20#[derive(Default, Clone, Debug, Serialize, Deserialize)]
21#[serde(default, rename_all = "camelCase")]
22pub struct GlobalConfig {
23    /// Configuration for measurements normalization.
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub measurements: Option<MeasurementsConfig>,
26    /// Quotas that apply to all projects.
27    #[serde(skip_serializing_if = "Vec::is_empty")]
28    pub quotas: Vec<Quota>,
29    /// Configuration for global inbound filters.
30    ///
31    /// These filters are merged with generic filters in project configs before
32    /// applying.
33    #[serde(skip_serializing_if = "is_err_or_empty")]
34    pub filters: ErrorBoundary<GenericFiltersConfig>,
35    /// Sentry options passed down to Relay.
36    #[serde(
37        deserialize_with = "default_on_error",
38        skip_serializing_if = "is_default"
39    )]
40    pub options: Options,
41
42    /// Configuration for global metrics extraction rules.
43    ///
44    /// These are merged with rules in project configs before
45    /// applying.
46    #[serde(skip_serializing_if = "is_ok_and_empty")]
47    pub metric_extraction: ErrorBoundary<MetricExtractionGroups>,
48
49    /// Configuration for AI span measurements.
50    #[serde(skip_serializing_if = "is_model_costs_empty")]
51    pub ai_model_costs: ErrorBoundary<ModelCosts>,
52
53    /// Configuration to derive the `span.op` from other span fields.
54    #[serde(
55        deserialize_with = "default_on_error",
56        skip_serializing_if = "is_default"
57    )]
58    pub span_op_defaults: SpanOpDefaults,
59}
60
61impl GlobalConfig {
62    /// Loads the [`GlobalConfig`] from a file if it's provided.
63    ///
64    /// The folder_path argument should be the path to the folder where the Relay config and
65    /// credentials are stored.
66    pub fn load(folder_path: &Path) -> anyhow::Result<Option<Self>> {
67        let path = folder_path.join("global_config.json");
68
69        if path.exists() {
70            let file = BufReader::new(File::open(path)?);
71            Ok(Some(serde_json::from_reader(file)?))
72        } else {
73            Ok(None)
74        }
75    }
76
77    /// Returns the generic inbound filters.
78    pub fn filters(&self) -> Option<&GenericFiltersConfig> {
79        match &self.filters {
80            ErrorBoundary::Err(_) => None,
81            ErrorBoundary::Ok(f) => Some(f),
82        }
83    }
84
85    /// Modifies the global config after deserialization.
86    ///
87    /// - Adds hard-coded groups to metrics extraction configs.
88    pub fn normalize(&mut self) {
89        if let ErrorBoundary::Ok(config) = &mut self.metric_extraction {
90            for (group_name, metrics, tags) in defaults::hardcoded_span_metrics() {
91                // We only define these groups if they haven't been defined by the upstream yet.
92                // This ensures that the innermost Relay always defines the metrics.
93                if let Entry::Vacant(entry) = config.groups.entry(group_name) {
94                    entry.insert(MetricExtractionGroup {
95                        is_enabled: false, // must be enabled via project config
96                        metrics,
97                        tags,
98                    });
99                }
100            }
101        }
102    }
103}
104
105fn is_err_or_empty(filters_config: &ErrorBoundary<GenericFiltersConfig>) -> bool {
106    match filters_config {
107        ErrorBoundary::Err(_) => true,
108        ErrorBoundary::Ok(config) => config.version == 0 && config.filters.is_empty(),
109    }
110}
111
112/// All options passed down from Sentry to Relay.
113#[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq)]
114#[serde(default)]
115pub struct Options {
116    /// Kill switch for controlling the cardinality limiter.
117    #[serde(
118        rename = "relay.cardinality-limiter.mode",
119        deserialize_with = "default_on_error",
120        skip_serializing_if = "is_default"
121    )]
122    pub cardinality_limiter_mode: CardinalityLimiterMode,
123
124    /// Sample rate for Cardinality Limiter Sentry errors.
125    ///
126    /// Rate needs to be between `0.0` and `1.0`.
127    /// If set to `1.0` all cardinality limiter rejections will be logged as a Sentry error.
128    #[serde(
129        rename = "relay.cardinality-limiter.error-sample-rate",
130        deserialize_with = "default_on_error",
131        skip_serializing_if = "is_default"
132    )]
133    pub cardinality_limiter_error_sample_rate: f32,
134
135    /// Metric bucket encoding configuration for sets by metric namespace.
136    #[serde(
137        rename = "relay.metric-bucket-set-encodings",
138        deserialize_with = "de_metric_bucket_encodings",
139        skip_serializing_if = "is_default"
140    )]
141    pub metric_bucket_set_encodings: BucketEncodings,
142    /// Metric bucket encoding configuration for distributions by metric namespace.
143    #[serde(
144        rename = "relay.metric-bucket-distribution-encodings",
145        deserialize_with = "de_metric_bucket_encodings",
146        skip_serializing_if = "is_default"
147    )]
148    pub metric_bucket_dist_encodings: BucketEncodings,
149
150    /// Rollout rate for metric stats.
151    ///
152    /// Rate needs to be between `0.0` and `1.0`.
153    /// If set to `1.0` all organizations will have metric stats enabled.
154    #[serde(
155        rename = "relay.metric-stats.rollout-rate",
156        deserialize_with = "default_on_error",
157        skip_serializing_if = "is_default"
158    )]
159    pub metric_stats_rollout_rate: f32,
160
161    /// Overall sampling of span extraction.
162    ///
163    /// This number represents the fraction of transactions for which
164    /// spans are extracted.
165    ///
166    /// `None` is the default and interpreted as a value of 1.0 (extract everything).
167    ///
168    /// Note: Any value below 1.0 will cause the product to break, so use with caution.
169    #[serde(
170        rename = "relay.span-extraction.sample-rate",
171        deserialize_with = "default_on_error",
172        skip_serializing_if = "is_default"
173    )]
174    pub span_extraction_sample_rate: Option<f32>,
175
176    /// List of values on span description that are allowed to be sent to Sentry without being scrubbed.
177    ///
178    /// At this point, it doesn't accept IP addresses in CIDR format.. yet.
179    #[serde(
180        rename = "relay.span-normalization.allowed_hosts",
181        deserialize_with = "default_on_error",
182        skip_serializing_if = "Vec::is_empty"
183    )]
184    pub http_span_allowed_hosts: Vec<String>,
185
186    /// Whether or not relay should drop attachments submitted with transactions.
187    #[serde(
188        rename = "relay.drop-transaction-attachments",
189        deserialize_with = "default_on_error",
190        skip_serializing_if = "is_default"
191    )]
192    pub drop_transaction_attachments: bool,
193
194    /// All other unknown options.
195    #[serde(flatten)]
196    other: HashMap<String, Value>,
197}
198
199/// Kill switch for controlling the cardinality limiter.
200#[derive(Default, Clone, Copy, Debug, Serialize, Deserialize, PartialEq)]
201#[serde(rename_all = "lowercase")]
202pub enum CardinalityLimiterMode {
203    /// Cardinality limiter is enabled.
204    #[default]
205    // De-serialize from the empty string, because the option was added to
206    // Sentry incorrectly which makes Sentry send the empty string as a default.
207    #[serde(alias = "")]
208    Enabled,
209    /// Cardinality limiter is enabled but cardinality limits are not enforced.
210    Passive,
211    /// Cardinality limiter is disabled.
212    Disabled,
213}
214
215/// Configuration container to control [`BucketEncoding`] per namespace.
216#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq)]
217#[serde(default)]
218pub struct BucketEncodings {
219    transactions: BucketEncoding,
220    spans: BucketEncoding,
221    profiles: BucketEncoding,
222    custom: BucketEncoding,
223    metric_stats: BucketEncoding,
224}
225
226impl BucketEncodings {
227    /// Returns the configured encoding for a specific namespace.
228    pub fn for_namespace(&self, namespace: MetricNamespace) -> BucketEncoding {
229        match namespace {
230            MetricNamespace::Transactions => self.transactions,
231            MetricNamespace::Spans => self.spans,
232            MetricNamespace::Custom => self.custom,
233            MetricNamespace::Stats => self.metric_stats,
234            // Always force the legacy encoding for sessions,
235            // sessions are not part of the generic metrics platform with different
236            // consumer which are not (yet) updated to support the new data.
237            MetricNamespace::Sessions => BucketEncoding::Legacy,
238            _ => BucketEncoding::Legacy,
239        }
240    }
241}
242
243/// Deserializes individual metric encodings or all from a string.
244///
245/// Returns a default when failing to deserialize.
246fn de_metric_bucket_encodings<'de, D>(deserializer: D) -> Result<BucketEncodings, D::Error>
247where
248    D: serde::de::Deserializer<'de>,
249{
250    struct Visitor;
251
252    impl<'de> de::Visitor<'de> for Visitor {
253        type Value = BucketEncodings;
254
255        fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
256            formatter.write_str("metric bucket encodings")
257        }
258
259        fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
260        where
261            E: de::Error,
262        {
263            let encoding = BucketEncoding::deserialize(de::value::StrDeserializer::new(v))?;
264            Ok(BucketEncodings {
265                transactions: encoding,
266                spans: encoding,
267                profiles: encoding,
268                custom: encoding,
269                metric_stats: encoding,
270            })
271        }
272
273        fn visit_map<A>(self, map: A) -> Result<Self::Value, A::Error>
274        where
275            A: de::MapAccess<'de>,
276        {
277            BucketEncodings::deserialize(de::value::MapAccessDeserializer::new(map))
278        }
279    }
280
281    match deserializer.deserialize_any(Visitor) {
282        Ok(value) => Ok(value),
283        Err(error) => {
284            relay_log::error!(
285                error = %error,
286                "Error deserializing metric bucket encodings",
287            );
288            Ok(BucketEncodings::default())
289        }
290    }
291}
292
293/// All supported metric bucket encodings.
294#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq)]
295#[serde(rename_all = "lowercase")]
296pub enum BucketEncoding {
297    /// The default legacy encoding.
298    ///
299    /// A simple JSON array of numbers.
300    #[default]
301    Legacy,
302    /// The array encoding.
303    ///
304    /// Uses already the dynamic value format but still encodes
305    /// all values as a JSON number array.
306    Array,
307    /// Base64 encoding.
308    ///
309    /// Encodes all values as Base64.
310    Base64,
311    /// Zstd.
312    ///
313    /// Compresses all values with zstd.
314    Zstd,
315}
316
317/// Returns `true` if this value is equal to `Default::default()`.
318fn is_default<T: Default + PartialEq>(t: &T) -> bool {
319    t == &T::default()
320}
321
322fn default_on_error<'de, D, T>(deserializer: D) -> Result<T, D::Error>
323where
324    D: serde::de::Deserializer<'de>,
325    T: Default + serde::de::DeserializeOwned,
326{
327    match T::deserialize(deserializer) {
328        Ok(value) => Ok(value),
329        Err(error) => {
330            relay_log::error!(
331                error = %error,
332                "Error deserializing global config option: {}",
333                std::any::type_name::<T>(),
334            );
335            Ok(T::default())
336        }
337    }
338}
339
340fn is_ok_and_empty(value: &ErrorBoundary<MetricExtractionGroups>) -> bool {
341    matches!(
342        value,
343        &ErrorBoundary::Ok(MetricExtractionGroups { ref groups }) if groups.is_empty()
344    )
345}
346
347fn is_model_costs_empty(value: &ErrorBoundary<ModelCosts>) -> bool {
348    matches!(value, ErrorBoundary::Ok(model_costs) if model_costs.is_empty())
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354
355    #[test]
356    fn test_global_config_roundtrip() {
357        let json = r#"{
358  "measurements": {
359    "builtinMeasurements": [
360      {
361        "name": "foo",
362        "unit": "none"
363      },
364      {
365        "name": "bar",
366        "unit": "none"
367      },
368      {
369        "name": "baz",
370        "unit": "none"
371      }
372    ],
373    "maxCustomMeasurements": 5
374  },
375  "quotas": [
376    {
377      "id": "foo",
378      "categories": [
379        "metric_bucket"
380      ],
381      "scope": "organization",
382      "limit": 0,
383      "namespace": null
384    },
385    {
386      "id": "bar",
387      "categories": [
388        "metric_bucket"
389      ],
390      "scope": "organization",
391      "limit": 0,
392      "namespace": null
393    }
394  ],
395  "filters": {
396    "version": 1,
397    "filters": [
398      {
399        "id": "myError",
400        "isEnabled": true,
401        "condition": {
402          "op": "eq",
403          "name": "event.exceptions",
404          "value": "myError"
405        }
406      }
407    ]
408  }
409}"#;
410
411        let deserialized = serde_json::from_str::<GlobalConfig>(json).unwrap();
412        let serialized = serde_json::to_string_pretty(&deserialized).unwrap();
413        assert_eq!(json, serialized.as_str());
414    }
415
416    #[test]
417    fn test_global_config_invalid_value_is_default() {
418        let options: Options = serde_json::from_str(
419            r#"{
420                "relay.cardinality-limiter.mode": "passive"
421            }"#,
422        )
423        .unwrap();
424
425        let expected = Options {
426            cardinality_limiter_mode: CardinalityLimiterMode::Passive,
427            ..Default::default()
428        };
429
430        assert_eq!(options, expected);
431    }
432
433    #[test]
434    fn test_cardinality_limiter_mode_de_serialize() {
435        let m: CardinalityLimiterMode = serde_json::from_str("\"\"").unwrap();
436        assert_eq!(m, CardinalityLimiterMode::Enabled);
437        let m: CardinalityLimiterMode = serde_json::from_str("\"enabled\"").unwrap();
438        assert_eq!(m, CardinalityLimiterMode::Enabled);
439        let m: CardinalityLimiterMode = serde_json::from_str("\"disabled\"").unwrap();
440        assert_eq!(m, CardinalityLimiterMode::Disabled);
441        let m: CardinalityLimiterMode = serde_json::from_str("\"passive\"").unwrap();
442        assert_eq!(m, CardinalityLimiterMode::Passive);
443
444        let m = serde_json::to_string(&CardinalityLimiterMode::Enabled).unwrap();
445        assert_eq!(m, "\"enabled\"");
446    }
447
448    #[test]
449    fn test_minimal_serialization() {
450        let config = r#"{"options":{"foo":"bar"}}"#;
451        let deserialized: GlobalConfig = serde_json::from_str(config).unwrap();
452        let serialized = serde_json::to_string(&deserialized).unwrap();
453        assert_eq!(config, &serialized);
454    }
455
456    #[test]
457    fn test_metric_bucket_encodings_de_from_str() {
458        let o: Options = serde_json::from_str(
459            r#"{
460                "relay.metric-bucket-set-encodings": "legacy",
461                "relay.metric-bucket-distribution-encodings": "zstd"
462        }"#,
463        )
464        .unwrap();
465
466        assert_eq!(
467            o.metric_bucket_set_encodings,
468            BucketEncodings {
469                transactions: BucketEncoding::Legacy,
470                spans: BucketEncoding::Legacy,
471                profiles: BucketEncoding::Legacy,
472                custom: BucketEncoding::Legacy,
473                metric_stats: BucketEncoding::Legacy,
474            }
475        );
476        assert_eq!(
477            o.metric_bucket_dist_encodings,
478            BucketEncodings {
479                transactions: BucketEncoding::Zstd,
480                spans: BucketEncoding::Zstd,
481                profiles: BucketEncoding::Zstd,
482                custom: BucketEncoding::Zstd,
483                metric_stats: BucketEncoding::Zstd,
484            }
485        );
486    }
487
488    #[test]
489    fn test_metric_bucket_encodings_de_from_obj() {
490        let original = BucketEncodings {
491            transactions: BucketEncoding::Base64,
492            spans: BucketEncoding::Zstd,
493            profiles: BucketEncoding::Base64,
494            custom: BucketEncoding::Zstd,
495            metric_stats: BucketEncoding::Base64,
496        };
497        let s = serde_json::to_string(&original).unwrap();
498        let s = format!(
499            r#"{{
500            "relay.metric-bucket-set-encodings": {s},
501            "relay.metric-bucket-distribution-encodings": {s}
502        }}"#
503        );
504
505        let o: Options = serde_json::from_str(&s).unwrap();
506        assert_eq!(o.metric_bucket_set_encodings, original);
507        assert_eq!(o.metric_bucket_dist_encodings, original);
508    }
509}