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_missing")]
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. It applies on top of [`crate::Feature::ExtractCommonSpanMetricsFromEvent`],
165    /// so both feature flag and sample rate need to be enabled to get any spans extracted.
166    ///
167    /// `None` is the default and interpreted as a value of 1.0 (extract everything).
168    ///
169    /// Note: Any value below 1.0 will cause the product to break, so use with caution.
170    #[serde(
171        rename = "relay.span-extraction.sample-rate",
172        deserialize_with = "default_on_error",
173        skip_serializing_if = "is_default"
174    )]
175    pub span_extraction_sample_rate: Option<f32>,
176
177    /// Sample rate at which to ingest logs.
178    ///
179    /// This number represents the fraction of received logs that are processed. It only applies if
180    /// [`crate::Feature::OurLogsIngestion`] is enabled.
181    ///
182    /// `None` is the default and interpreted as a value of 1.0 (ingest everything).
183    ///
184    /// Note: Any value below 1.0 will cause the product to not show all the users data, so use with caution.
185    #[serde(
186        rename = "relay.ourlogs-ingestion.sample-rate",
187        deserialize_with = "default_on_error",
188        skip_serializing_if = "is_default"
189    )]
190    pub ourlogs_ingestion_sample_rate: Option<f32>,
191
192    /// List of values on span description that are allowed to be sent to Sentry without being scrubbed.
193    ///
194    /// At this point, it doesn't accept IP addresses in CIDR format.. yet.
195    #[serde(
196        rename = "relay.span-normalization.allowed_hosts",
197        deserialize_with = "default_on_error",
198        skip_serializing_if = "Vec::is_empty"
199    )]
200    pub http_span_allowed_hosts: Vec<String>,
201
202    /// Whether or not relay should drop attachments submitted with transactions.
203    #[serde(
204        rename = "relay.drop-transaction-attachments",
205        deserialize_with = "default_on_error",
206        skip_serializing_if = "is_default"
207    )]
208    pub drop_transaction_attachments: bool,
209
210    /// Deprecated, still forwarded for older downstream Relays.
211    #[doc(hidden)]
212    #[serde(
213        rename = "profiling.profile_metrics.unsampled_profiles.platforms",
214        deserialize_with = "default_on_error",
215        skip_serializing_if = "Vec::is_empty"
216    )]
217    pub deprecated1: Vec<String>,
218
219    /// Deprecated, still forwarded for older downstream Relays.
220    #[doc(hidden)]
221    #[serde(
222        rename = "profiling.profile_metrics.unsampled_profiles.sample_rate",
223        deserialize_with = "default_on_error",
224        skip_serializing_if = "is_default"
225    )]
226    pub deprecated2: f32,
227
228    /// Disable semantic partitioning of spans by trace ID. Use this in case there is partition
229    /// imbalance on the spans topic produced to by Relay (either snuba-spans or ingest-spans).
230    /// This will break the span buffer, and anything that depends on segments being assembled by
231    /// it (performance issue, etc). As of 2025-05-06, the span buffer is not yet rolled out to
232    /// most regions though.
233    #[serde(
234        rename = "relay.spans-ignore-trace-id-partitioning",
235        skip_serializing_if = "is_default"
236    )]
237    pub spans_ignore_trace_id_partitioning: bool,
238
239    /// All other unknown options.
240    #[serde(flatten)]
241    other: HashMap<String, Value>,
242}
243
244/// Kill switch for controlling the cardinality limiter.
245#[derive(Default, Clone, Copy, Debug, Serialize, Deserialize, PartialEq)]
246#[serde(rename_all = "lowercase")]
247pub enum CardinalityLimiterMode {
248    /// Cardinality limiter is enabled.
249    #[default]
250    // De-serialize from the empty string, because the option was added to
251    // Sentry incorrectly which makes Sentry send the empty string as a default.
252    #[serde(alias = "")]
253    Enabled,
254    /// Cardinality limiter is enabled but cardinality limits are not enforced.
255    Passive,
256    /// Cardinality limiter is disabled.
257    Disabled,
258}
259
260/// Configuration container to control [`BucketEncoding`] per namespace.
261#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq)]
262#[serde(default)]
263pub struct BucketEncodings {
264    transactions: BucketEncoding,
265    spans: BucketEncoding,
266    profiles: BucketEncoding,
267    custom: BucketEncoding,
268    metric_stats: BucketEncoding,
269}
270
271impl BucketEncodings {
272    /// Returns the configured encoding for a specific namespace.
273    pub fn for_namespace(&self, namespace: MetricNamespace) -> BucketEncoding {
274        match namespace {
275            MetricNamespace::Transactions => self.transactions,
276            MetricNamespace::Spans => self.spans,
277            MetricNamespace::Custom => self.custom,
278            MetricNamespace::Stats => self.metric_stats,
279            // Always force the legacy encoding for sessions,
280            // sessions are not part of the generic metrics platform with different
281            // consumer which are not (yet) updated to support the new data.
282            MetricNamespace::Sessions => BucketEncoding::Legacy,
283            _ => BucketEncoding::Legacy,
284        }
285    }
286}
287
288/// Deserializes individual metric encodings or all from a string.
289///
290/// Returns a default when failing to deserialize.
291fn de_metric_bucket_encodings<'de, D>(deserializer: D) -> Result<BucketEncodings, D::Error>
292where
293    D: serde::de::Deserializer<'de>,
294{
295    struct Visitor;
296
297    impl<'de> de::Visitor<'de> for Visitor {
298        type Value = BucketEncodings;
299
300        fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
301            formatter.write_str("metric bucket encodings")
302        }
303
304        fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
305        where
306            E: de::Error,
307        {
308            let encoding = BucketEncoding::deserialize(de::value::StrDeserializer::new(v))?;
309            Ok(BucketEncodings {
310                transactions: encoding,
311                spans: encoding,
312                profiles: encoding,
313                custom: encoding,
314                metric_stats: encoding,
315            })
316        }
317
318        fn visit_map<A>(self, map: A) -> Result<Self::Value, A::Error>
319        where
320            A: de::MapAccess<'de>,
321        {
322            BucketEncodings::deserialize(de::value::MapAccessDeserializer::new(map))
323        }
324    }
325
326    match deserializer.deserialize_any(Visitor) {
327        Ok(value) => Ok(value),
328        Err(error) => {
329            relay_log::error!(
330                error = %error,
331                "Error deserializing metric bucket encodings",
332            );
333            Ok(BucketEncodings::default())
334        }
335    }
336}
337
338/// All supported metric bucket encodings.
339#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq)]
340#[serde(rename_all = "lowercase")]
341pub enum BucketEncoding {
342    /// The default legacy encoding.
343    ///
344    /// A simple JSON array of numbers.
345    #[default]
346    Legacy,
347    /// The array encoding.
348    ///
349    /// Uses already the dynamic value format but still encodes
350    /// all values as a JSON number array.
351    Array,
352    /// Base64 encoding.
353    ///
354    /// Encodes all values as Base64.
355    Base64,
356    /// Zstd.
357    ///
358    /// Compresses all values with zstd.
359    Zstd,
360}
361
362/// Returns `true` if this value is equal to `Default::default()`.
363fn is_default<T: Default + PartialEq>(t: &T) -> bool {
364    t == &T::default()
365}
366
367fn default_on_error<'de, D, T>(deserializer: D) -> Result<T, D::Error>
368where
369    D: serde::de::Deserializer<'de>,
370    T: Default + serde::de::DeserializeOwned,
371{
372    match T::deserialize(deserializer) {
373        Ok(value) => Ok(value),
374        Err(error) => {
375            relay_log::error!(
376                error = %error,
377                "Error deserializing global config option: {}",
378                std::any::type_name::<T>(),
379            );
380            Ok(T::default())
381        }
382    }
383}
384
385fn is_ok_and_empty(value: &ErrorBoundary<MetricExtractionGroups>) -> bool {
386    matches!(
387        value,
388        &ErrorBoundary::Ok(MetricExtractionGroups { ref groups }) if groups.is_empty()
389    )
390}
391
392fn is_missing(value: &ErrorBoundary<ModelCosts>) -> bool {
393    matches!(
394        value,
395        &ErrorBoundary::Ok(ModelCosts{ version, ref costs }) if version == 0 && costs.is_empty()
396    )
397}
398
399#[cfg(test)]
400mod tests {
401    use super::*;
402
403    #[test]
404    fn test_global_config_roundtrip() {
405        let json = r#"{
406  "measurements": {
407    "builtinMeasurements": [
408      {
409        "name": "foo",
410        "unit": "none"
411      },
412      {
413        "name": "bar",
414        "unit": "none"
415      },
416      {
417        "name": "baz",
418        "unit": "none"
419      }
420    ],
421    "maxCustomMeasurements": 5
422  },
423  "quotas": [
424    {
425      "id": "foo",
426      "categories": [
427        "metric_bucket"
428      ],
429      "scope": "organization",
430      "limit": 0,
431      "namespace": null
432    },
433    {
434      "id": "bar",
435      "categories": [
436        "metric_bucket"
437      ],
438      "scope": "organization",
439      "limit": 0,
440      "namespace": null
441    }
442  ],
443  "filters": {
444    "version": 1,
445    "filters": [
446      {
447        "id": "myError",
448        "isEnabled": true,
449        "condition": {
450          "op": "eq",
451          "name": "event.exceptions",
452          "value": "myError"
453        }
454      }
455    ]
456  }
457}"#;
458
459        let deserialized = serde_json::from_str::<GlobalConfig>(json).unwrap();
460        let serialized = serde_json::to_string_pretty(&deserialized).unwrap();
461        assert_eq!(json, serialized.as_str());
462    }
463
464    #[test]
465    fn test_global_config_invalid_value_is_default() {
466        let options: Options = serde_json::from_str(
467            r#"{
468                "relay.cardinality-limiter.mode": "passive"
469            }"#,
470        )
471        .unwrap();
472
473        let expected = Options {
474            cardinality_limiter_mode: CardinalityLimiterMode::Passive,
475            ..Default::default()
476        };
477
478        assert_eq!(options, expected);
479    }
480
481    #[test]
482    fn test_cardinality_limiter_mode_de_serialize() {
483        let m: CardinalityLimiterMode = serde_json::from_str("\"\"").unwrap();
484        assert_eq!(m, CardinalityLimiterMode::Enabled);
485        let m: CardinalityLimiterMode = serde_json::from_str("\"enabled\"").unwrap();
486        assert_eq!(m, CardinalityLimiterMode::Enabled);
487        let m: CardinalityLimiterMode = serde_json::from_str("\"disabled\"").unwrap();
488        assert_eq!(m, CardinalityLimiterMode::Disabled);
489        let m: CardinalityLimiterMode = serde_json::from_str("\"passive\"").unwrap();
490        assert_eq!(m, CardinalityLimiterMode::Passive);
491
492        let m = serde_json::to_string(&CardinalityLimiterMode::Enabled).unwrap();
493        assert_eq!(m, "\"enabled\"");
494    }
495
496    #[test]
497    fn test_minimal_serialization() {
498        let config = r#"{"options":{"foo":"bar"}}"#;
499        let deserialized: GlobalConfig = serde_json::from_str(config).unwrap();
500        let serialized = serde_json::to_string(&deserialized).unwrap();
501        assert_eq!(config, &serialized);
502    }
503
504    #[test]
505    fn test_metric_bucket_encodings_de_from_str() {
506        let o: Options = serde_json::from_str(
507            r#"{
508                "relay.metric-bucket-set-encodings": "legacy",
509                "relay.metric-bucket-distribution-encodings": "zstd"
510        }"#,
511        )
512        .unwrap();
513
514        assert_eq!(
515            o.metric_bucket_set_encodings,
516            BucketEncodings {
517                transactions: BucketEncoding::Legacy,
518                spans: BucketEncoding::Legacy,
519                profiles: BucketEncoding::Legacy,
520                custom: BucketEncoding::Legacy,
521                metric_stats: BucketEncoding::Legacy,
522            }
523        );
524        assert_eq!(
525            o.metric_bucket_dist_encodings,
526            BucketEncodings {
527                transactions: BucketEncoding::Zstd,
528                spans: BucketEncoding::Zstd,
529                profiles: BucketEncoding::Zstd,
530                custom: BucketEncoding::Zstd,
531                metric_stats: BucketEncoding::Zstd,
532            }
533        );
534    }
535
536    #[test]
537    fn test_metric_bucket_encodings_de_from_obj() {
538        let original = BucketEncodings {
539            transactions: BucketEncoding::Base64,
540            spans: BucketEncoding::Zstd,
541            profiles: BucketEncoding::Base64,
542            custom: BucketEncoding::Zstd,
543            metric_stats: BucketEncoding::Base64,
544        };
545        let s = serde_json::to_string(&original).unwrap();
546        let s = format!(
547            r#"{{
548            "relay.metric-bucket-set-encodings": {s},
549            "relay.metric-bucket-distribution-encodings": {s}
550        }}"#
551        );
552
553        let o: Options = serde_json::from_str(&s).unwrap();
554        assert_eq!(o.metric_bucket_set_encodings, original);
555        assert_eq!(o.metric_bucket_dist_encodings, original);
556    }
557}