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