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