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    /// List of values on span description that are allowed to be sent to Sentry without being scrubbed.
151    ///
152    /// At this point, it doesn't accept IP addresses in CIDR format.. yet.
153    #[serde(
154        rename = "relay.span-normalization.allowed_hosts",
155        deserialize_with = "default_on_error",
156        skip_serializing_if = "Vec::is_empty"
157    )]
158    pub http_span_allowed_hosts: Vec<String>,
159
160    /// Instructs relay to store attachments in objectstore instead of sending chunks via kafka.
161    ///
162    /// Rate needs to be between `0.0` and `1.0`.
163    /// If set to `1.0` all attachments will be stored in objectstore.
164    #[serde(
165        rename = "relay.objectstore-attachments.sample-rate",
166        deserialize_with = "default_on_error",
167        skip_serializing_if = "is_default"
168    )]
169    pub objectstore_attachments_sample_rate: f32,
170
171    /// Rollout rate for the EAP (Event Analytics Platform) double-write for user sessions.
172    ///
173    /// When rolled out, session data is sent both through the legacy metrics pipeline
174    /// and directly to the `snuba-items` topic as `TRACE_ITEM_TYPE_USER_SESSION`.
175    ///
176    /// Rate needs to be between `0.0` and `1.0`.
177    #[serde(
178        rename = "relay.sessions-eap.rollout-rate",
179        deserialize_with = "default_on_error",
180        skip_serializing_if = "is_default"
181    )]
182    pub sessions_eap_rollout_rate: f32,
183
184    /// All other unknown options.
185    #[serde(flatten)]
186    other: HashMap<String, Value>,
187}
188
189/// Kill switch for controlling the cardinality limiter.
190#[derive(Default, Clone, Copy, Debug, Serialize, Deserialize, PartialEq)]
191#[serde(rename_all = "lowercase")]
192pub enum CardinalityLimiterMode {
193    /// Cardinality limiter is enabled.
194    #[default]
195    // De-serialize from the empty string, because the option was added to
196    // Sentry incorrectly which makes Sentry send the empty string as a default.
197    #[serde(alias = "")]
198    Enabled,
199    /// Cardinality limiter is enabled but cardinality limits are not enforced.
200    Passive,
201    /// Cardinality limiter is disabled.
202    Disabled,
203}
204
205/// Configuration container to control [`BucketEncoding`] per namespace.
206#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq)]
207#[serde(default)]
208pub struct BucketEncodings {
209    transactions: BucketEncoding,
210    spans: BucketEncoding,
211    profiles: BucketEncoding,
212    custom: BucketEncoding,
213}
214
215impl BucketEncodings {
216    /// Returns the configured encoding for a specific namespace.
217    pub fn for_namespace(&self, namespace: MetricNamespace) -> BucketEncoding {
218        match namespace {
219            MetricNamespace::Transactions => self.transactions,
220            MetricNamespace::Spans => self.spans,
221            MetricNamespace::Custom => self.custom,
222            // Always force the legacy encoding for sessions,
223            // sessions are not part of the generic metrics platform with different
224            // consumer which are not (yet) updated to support the new data.
225            MetricNamespace::Sessions => BucketEncoding::Legacy,
226            _ => BucketEncoding::Legacy,
227        }
228    }
229}
230
231/// Deserializes individual metric encodings or all from a string.
232///
233/// Returns a default when failing to deserialize.
234fn de_metric_bucket_encodings<'de, D>(deserializer: D) -> Result<BucketEncodings, D::Error>
235where
236    D: serde::de::Deserializer<'de>,
237{
238    struct Visitor;
239
240    impl<'de> de::Visitor<'de> for Visitor {
241        type Value = BucketEncodings;
242
243        fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
244            formatter.write_str("metric bucket encodings")
245        }
246
247        fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
248        where
249            E: de::Error,
250        {
251            let encoding = BucketEncoding::deserialize(de::value::StrDeserializer::new(v))?;
252            Ok(BucketEncodings {
253                transactions: encoding,
254                spans: encoding,
255                profiles: encoding,
256                custom: encoding,
257            })
258        }
259
260        fn visit_map<A>(self, map: A) -> Result<Self::Value, A::Error>
261        where
262            A: de::MapAccess<'de>,
263        {
264            BucketEncodings::deserialize(de::value::MapAccessDeserializer::new(map))
265        }
266    }
267
268    match deserializer.deserialize_any(Visitor) {
269        Ok(value) => Ok(value),
270        Err(error) => {
271            relay_log::error!(
272                error = %error,
273                "Error deserializing metric bucket encodings",
274            );
275            Ok(BucketEncodings::default())
276        }
277    }
278}
279
280/// All supported metric bucket encodings.
281#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq)]
282#[serde(rename_all = "lowercase")]
283pub enum BucketEncoding {
284    /// The default legacy encoding.
285    ///
286    /// A simple JSON array of numbers.
287    #[default]
288    Legacy,
289    /// The array encoding.
290    ///
291    /// Uses already the dynamic value format but still encodes
292    /// all values as a JSON number array.
293    Array,
294    /// Base64 encoding.
295    ///
296    /// Encodes all values as Base64.
297    Base64,
298    /// Zstd.
299    ///
300    /// Compresses all values with zstd.
301    Zstd,
302}
303
304/// Returns `true` if this value is equal to `Default::default()`.
305fn is_default<T: Default + PartialEq>(t: &T) -> bool {
306    t == &T::default()
307}
308
309fn default_on_error<'de, D, T>(deserializer: D) -> Result<T, D::Error>
310where
311    D: serde::de::Deserializer<'de>,
312    T: Default + serde::de::DeserializeOwned,
313{
314    match T::deserialize(deserializer) {
315        Ok(value) => Ok(value),
316        Err(error) => {
317            relay_log::error!(
318                error = %error,
319                "Error deserializing global config option: {}",
320                std::any::type_name::<T>(),
321            );
322            Ok(T::default())
323        }
324    }
325}
326
327fn is_ok_and_empty(value: &ErrorBoundary<MetricExtractionGroups>) -> bool {
328    matches!(
329        value,
330        &ErrorBoundary::Ok(MetricExtractionGroups { ref groups }) if groups.is_empty()
331    )
332}
333
334fn is_model_costs_empty(value: &ErrorBoundary<ModelCosts>) -> bool {
335    matches!(value, ErrorBoundary::Ok(model_costs) if model_costs.is_empty())
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341
342    #[test]
343    fn test_global_config_roundtrip() {
344        let json = r#"{
345  "measurements": {
346    "builtinMeasurements": [
347      {
348        "name": "foo",
349        "unit": "none"
350      },
351      {
352        "name": "bar",
353        "unit": "none"
354      },
355      {
356        "name": "baz",
357        "unit": "none"
358      }
359    ],
360    "maxCustomMeasurements": 5
361  },
362  "quotas": [
363    {
364      "id": "foo",
365      "categories": [
366        "metric_bucket"
367      ],
368      "scope": "organization",
369      "limit": 0,
370      "namespace": null
371    },
372    {
373      "id": "bar",
374      "categories": [
375        "metric_bucket"
376      ],
377      "scope": "organization",
378      "limit": 0,
379      "namespace": null
380    }
381  ],
382  "filters": {
383    "version": 1,
384    "filters": [
385      {
386        "id": "myError",
387        "isEnabled": true,
388        "condition": {
389          "op": "eq",
390          "name": "event.exceptions",
391          "value": "myError"
392        }
393      }
394    ]
395  }
396}"#;
397
398        let deserialized = serde_json::from_str::<GlobalConfig>(json).unwrap();
399        let serialized = serde_json::to_string_pretty(&deserialized).unwrap();
400        assert_eq!(json, serialized.as_str());
401    }
402
403    #[test]
404    fn test_global_config_invalid_value_is_default() {
405        let options: Options = serde_json::from_str(
406            r#"{
407                "relay.cardinality-limiter.mode": "passive"
408            }"#,
409        )
410        .unwrap();
411
412        let expected = Options {
413            cardinality_limiter_mode: CardinalityLimiterMode::Passive,
414            ..Default::default()
415        };
416
417        assert_eq!(options, expected);
418    }
419
420    #[test]
421    fn test_cardinality_limiter_mode_de_serialize() {
422        let m: CardinalityLimiterMode = serde_json::from_str("\"\"").unwrap();
423        assert_eq!(m, CardinalityLimiterMode::Enabled);
424        let m: CardinalityLimiterMode = serde_json::from_str("\"enabled\"").unwrap();
425        assert_eq!(m, CardinalityLimiterMode::Enabled);
426        let m: CardinalityLimiterMode = serde_json::from_str("\"disabled\"").unwrap();
427        assert_eq!(m, CardinalityLimiterMode::Disabled);
428        let m: CardinalityLimiterMode = serde_json::from_str("\"passive\"").unwrap();
429        assert_eq!(m, CardinalityLimiterMode::Passive);
430
431        let m = serde_json::to_string(&CardinalityLimiterMode::Enabled).unwrap();
432        assert_eq!(m, "\"enabled\"");
433    }
434
435    #[test]
436    fn test_minimal_serialization() {
437        let config = r#"{"options":{"foo":"bar"}}"#;
438        let deserialized: GlobalConfig = serde_json::from_str(config).unwrap();
439        let serialized = serde_json::to_string(&deserialized).unwrap();
440        assert_eq!(config, &serialized);
441    }
442
443    #[test]
444    fn test_metric_bucket_encodings_de_from_str() {
445        let o: Options = serde_json::from_str(
446            r#"{
447                "relay.metric-bucket-set-encodings": "legacy",
448                "relay.metric-bucket-distribution-encodings": "zstd"
449        }"#,
450        )
451        .unwrap();
452
453        assert_eq!(
454            o.metric_bucket_set_encodings,
455            BucketEncodings {
456                transactions: BucketEncoding::Legacy,
457                spans: BucketEncoding::Legacy,
458                profiles: BucketEncoding::Legacy,
459                custom: BucketEncoding::Legacy,
460            }
461        );
462        assert_eq!(
463            o.metric_bucket_dist_encodings,
464            BucketEncodings {
465                transactions: BucketEncoding::Zstd,
466                spans: BucketEncoding::Zstd,
467                profiles: BucketEncoding::Zstd,
468                custom: BucketEncoding::Zstd,
469            }
470        );
471    }
472
473    #[test]
474    fn test_metric_bucket_encodings_de_from_obj() {
475        let original = BucketEncodings {
476            transactions: BucketEncoding::Base64,
477            spans: BucketEncoding::Zstd,
478            profiles: BucketEncoding::Base64,
479            custom: BucketEncoding::Zstd,
480        };
481        let s = serde_json::to_string(&original).unwrap();
482        let s = format!(
483            r#"{{
484            "relay.metric-bucket-set-encodings": {s},
485            "relay.metric-bucket-distribution-encodings": {s}
486        }}"#
487        );
488
489        let o: Options = serde_json::from_str(&s).unwrap();
490        assert_eq!(o.metric_bucket_set_encodings, original);
491        assert_eq!(o.metric_bucket_dist_encodings, original);
492    }
493}