Skip to main content

relay_dynamic_config/
global.rs

1use std::collections::HashMap;
2use std::fs::File;
3use std::io::BufReader;
4use std::path::Path;
5
6use relay_base_schema::metrics::MetricNamespace;
7use relay_event_normalization::{MeasurementsConfig, ModelMetadata, SpanOpDefaults};
8use relay_filter::GenericFiltersConfig;
9use relay_quotas::Quota;
10use serde::{Deserialize, Serialize, de};
11use serde_json::Value;
12
13use crate::{ErrorBoundary, MetricExtractionGroups};
14
15/// A dynamic configuration for all Relays passed down from Sentry.
16///
17/// Values shared across all projects may also be included here, to keep
18/// [`ProjectConfig`](crate::ProjectConfig)s small.
19#[derive(Default, Clone, Debug, Serialize, Deserialize)]
20#[serde(default, rename_all = "camelCase")]
21pub struct GlobalConfig {
22    /// Configuration for measurements normalization.
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub measurements: Option<MeasurementsConfig>,
25    /// Quotas that apply to all projects.
26    #[serde(skip_serializing_if = "Vec::is_empty")]
27    pub quotas: Vec<Quota>,
28    /// Configuration for global inbound filters.
29    ///
30    /// These filters are merged with generic filters in project configs before
31    /// applying.
32    #[serde(skip_serializing_if = "is_err_or_empty")]
33    pub filters: ErrorBoundary<GenericFiltersConfig>,
34    /// Sentry options passed down to Relay.
35    #[serde(
36        deserialize_with = "default_on_error",
37        skip_serializing_if = "is_default"
38    )]
39    pub options: Options,
40
41    /// Configuration for global metrics extraction rules.
42    ///
43    /// These are merged with rules in project configs before
44    /// applying.
45    #[serde(skip_serializing_if = "is_ok_and_empty")]
46    pub metric_extraction: ErrorBoundary<MetricExtractionGroups>,
47
48    /// Metadata for AI models including costs and context size.
49    #[serde(skip_serializing_if = "is_model_metadata_empty")]
50    pub ai_model_metadata: ErrorBoundary<ModelMetadata>,
51
52    /// Configuration to derive the `span.op` from other span fields.
53    #[serde(
54        deserialize_with = "default_on_error",
55        skip_serializing_if = "is_default"
56    )]
57    pub span_op_defaults: SpanOpDefaults,
58}
59
60impl GlobalConfig {
61    /// Loads the [`GlobalConfig`] from a file if it's provided.
62    ///
63    /// The folder_path argument should be the path to the folder where the Relay config and
64    /// credentials are stored.
65    pub fn load(folder_path: &Path) -> anyhow::Result<Option<Self>> {
66        let path = folder_path.join("global_config.json");
67
68        if path.exists() {
69            let file = BufReader::new(File::open(path)?);
70            Ok(Some(serde_json::from_reader(file)?))
71        } else {
72            Ok(None)
73        }
74    }
75
76    /// Returns the generic inbound filters.
77    pub fn filters(&self) -> Option<&GenericFiltersConfig> {
78        match &self.filters {
79            ErrorBoundary::Err(_) => None,
80            ErrorBoundary::Ok(f) => Some(f),
81        }
82    }
83
84    /// Returns the AI model metadata if configured and enabled.
85    pub fn ai_model_metadata(&self) -> Option<&ModelMetadata> {
86        self.ai_model_metadata
87            .as_ref()
88            .ok()
89            .filter(|m| m.is_enabled())
90    }
91}
92
93fn is_err_or_empty(filters_config: &ErrorBoundary<GenericFiltersConfig>) -> bool {
94    match filters_config {
95        ErrorBoundary::Err(_) => true,
96        ErrorBoundary::Ok(config) => config.version == 0 && config.filters.is_empty(),
97    }
98}
99
100/// All options passed down from Sentry to Relay.
101#[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq)]
102#[serde(default)]
103pub struct Options {
104    /// Metric bucket encoding configuration for sets by metric namespace.
105    #[serde(
106        rename = "relay.metric-bucket-set-encodings",
107        deserialize_with = "de_metric_bucket_encodings",
108        skip_serializing_if = "is_default"
109    )]
110    pub metric_bucket_set_encodings: BucketEncodings,
111    /// Metric bucket encoding configuration for distributions by metric namespace.
112    #[serde(
113        rename = "relay.metric-bucket-distribution-encodings",
114        deserialize_with = "de_metric_bucket_encodings",
115        skip_serializing_if = "is_default"
116    )]
117    pub metric_bucket_dist_encodings: BucketEncodings,
118
119    /// List of values on span description that are allowed to be sent to Sentry without being scrubbed.
120    ///
121    /// At this point, it doesn't accept IP addresses in CIDR format.. yet.
122    #[serde(
123        rename = "relay.span-normalization.allowed_hosts",
124        deserialize_with = "default_on_error",
125        skip_serializing_if = "Vec::is_empty"
126    )]
127    pub http_span_allowed_hosts: Vec<String>,
128
129    /// Instructs relay to store attachments in objectstore instead of sending chunks via kafka.
130    ///
131    /// Rate needs to be between `0.0` and `1.0`.
132    /// If set to `1.0` all attachments will be stored in objectstore.
133    #[serde(
134        rename = "relay.objectstore-attachments.sample-rate",
135        deserialize_with = "default_on_error",
136        skip_serializing_if = "is_default"
137    )]
138    pub objectstore_attachments_sample_rate: f32,
139
140    /// Rollout rate for the EAP (Event Analytics Platform) double-write for user sessions.
141    ///
142    /// When rolled out, session data is sent both through the legacy metrics pipeline
143    /// and directly to the `snuba-items` topic as `TRACE_ITEM_TYPE_USER_SESSION`.
144    ///
145    /// Rate needs to be between `0.0` and `1.0`.
146    #[serde(
147        rename = "relay.sessions-eap.rollout-rate",
148        deserialize_with = "default_on_error",
149        skip_serializing_if = "is_default"
150    )]
151    pub sessions_eap_rollout_rate: f32,
152
153    /// Rollout rate for accepted outcomes being emitted by EAP instead of Relay.
154    ///
155    /// Rate needs to be between `0.0` and `1.0`.
156    #[serde(
157        rename = "relay.eap-outcomes.rollout-rate",
158        deserialize_with = "default_on_error",
159        skip_serializing_if = "is_default"
160    )]
161    pub eap_outcomes_rollout_rate: f32,
162
163    /// Rollout rate for accepted outcomes for spans being emitted by EAP instead of Relay.
164    ///
165    /// Rate needs to be between `0.0` and `1.0`.
166    #[serde(
167        rename = "relay.eap-span-outcomes.rollout-rate",
168        deserialize_with = "default_on_error",
169        skip_serializing_if = "is_default"
170    )]
171    pub eap_span_outcomes_rollout_rate: f32,
172
173    /// Kill-switch for fetching project configs in endpoints.
174    #[serde(
175        rename = "relay.endpoint-fetch-config.enabled",
176        deserialize_with = "default_on_error",
177        skip_serializing_if = "is_default"
178    )]
179    pub endpoint_fetch_config_enabled: bool,
180
181    /// All other unknown options.
182    #[serde(flatten)]
183    other: HashMap<String, Value>,
184}
185
186/// Configuration container to control [`BucketEncoding`] per namespace.
187#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq)]
188#[serde(default)]
189pub struct BucketEncodings {
190    spans: BucketEncoding,
191    transactions: BucketEncoding,
192    profiles: BucketEncoding,
193    custom: BucketEncoding,
194}
195
196impl BucketEncodings {
197    /// Returns the configured encoding for a specific namespace.
198    pub fn for_namespace(&self, namespace: MetricNamespace) -> BucketEncoding {
199        match namespace {
200            MetricNamespace::Spans => self.spans,
201            MetricNamespace::Transactions => self.transactions,
202            MetricNamespace::Custom => self.custom,
203            // Always force the legacy encoding for sessions,
204            // sessions are not part of the generic metrics platform with different
205            // consumer which are not (yet) updated to support the new data.
206            MetricNamespace::Sessions => BucketEncoding::Legacy,
207            _ => BucketEncoding::Legacy,
208        }
209    }
210}
211
212/// Deserializes individual metric encodings or all from a string.
213///
214/// Returns a default when failing to deserialize.
215fn de_metric_bucket_encodings<'de, D>(deserializer: D) -> Result<BucketEncodings, D::Error>
216where
217    D: serde::de::Deserializer<'de>,
218{
219    struct Visitor;
220
221    impl<'de> de::Visitor<'de> for Visitor {
222        type Value = BucketEncodings;
223
224        fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
225            formatter.write_str("metric bucket encodings")
226        }
227
228        fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
229        where
230            E: de::Error,
231        {
232            let encoding = BucketEncoding::deserialize(de::value::StrDeserializer::new(v))?;
233            Ok(BucketEncodings {
234                spans: encoding,
235                transactions: encoding,
236                profiles: encoding,
237                custom: encoding,
238            })
239        }
240
241        fn visit_map<A>(self, map: A) -> Result<Self::Value, A::Error>
242        where
243            A: de::MapAccess<'de>,
244        {
245            BucketEncodings::deserialize(de::value::MapAccessDeserializer::new(map))
246        }
247    }
248
249    match deserializer.deserialize_any(Visitor) {
250        Ok(value) => Ok(value),
251        Err(error) => {
252            relay_log::error!(
253                error = %error,
254                "Error deserializing metric bucket encodings",
255            );
256            Ok(BucketEncodings::default())
257        }
258    }
259}
260
261/// All supported metric bucket encodings.
262#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq)]
263#[serde(rename_all = "lowercase")]
264pub enum BucketEncoding {
265    /// The default legacy encoding.
266    ///
267    /// A simple JSON array of numbers.
268    #[default]
269    Legacy,
270    /// The array encoding.
271    ///
272    /// Uses already the dynamic value format but still encodes
273    /// all values as a JSON number array.
274    Array,
275    /// Base64 encoding.
276    ///
277    /// Encodes all values as Base64.
278    Base64,
279    /// Zstd.
280    ///
281    /// Compresses all values with zstd.
282    Zstd,
283}
284
285/// Returns `true` if this value is equal to `Default::default()`.
286fn is_default<T: Default + PartialEq>(t: &T) -> bool {
287    t == &T::default()
288}
289
290fn default_on_error<'de, D, T>(deserializer: D) -> Result<T, D::Error>
291where
292    D: serde::de::Deserializer<'de>,
293    T: Default + serde::de::DeserializeOwned,
294{
295    match T::deserialize(deserializer) {
296        Ok(value) => Ok(value),
297        Err(error) => {
298            relay_log::error!(
299                error = %error,
300                "Error deserializing global config option: {}",
301                std::any::type_name::<T>(),
302            );
303            Ok(T::default())
304        }
305    }
306}
307
308fn is_ok_and_empty(value: &ErrorBoundary<MetricExtractionGroups>) -> bool {
309    matches!(
310        value,
311        &ErrorBoundary::Ok(MetricExtractionGroups { ref groups }) if groups.is_empty()
312    )
313}
314
315fn is_model_metadata_empty(value: &ErrorBoundary<ModelMetadata>) -> bool {
316    matches!(value, ErrorBoundary::Ok(metadata) if metadata.is_empty())
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322
323    #[test]
324    fn test_global_config_roundtrip() {
325        let json = r#"{
326  "measurements": {
327    "builtinMeasurements": [
328      {
329        "name": "foo",
330        "unit": "none"
331      },
332      {
333        "name": "bar",
334        "unit": "none"
335      },
336      {
337        "name": "baz",
338        "unit": "none"
339      }
340    ],
341    "maxCustomMeasurements": 5
342  },
343  "quotas": [
344    {
345      "id": "foo",
346      "categories": [
347        "metric_bucket"
348      ],
349      "scope": "organization",
350      "limit": 0,
351      "namespace": null
352    },
353    {
354      "id": "bar",
355      "categories": [
356        "metric_bucket"
357      ],
358      "scope": "organization",
359      "limit": 0,
360      "namespace": null
361    }
362  ],
363  "filters": {
364    "version": 1,
365    "filters": [
366      {
367        "id": "myError",
368        "isEnabled": true,
369        "condition": {
370          "op": "eq",
371          "name": "event.exceptions",
372          "value": "myError"
373        }
374      }
375    ]
376  }
377}"#;
378
379        let deserialized = serde_json::from_str::<GlobalConfig>(json).unwrap();
380        let serialized = serde_json::to_string_pretty(&deserialized).unwrap();
381        assert_eq!(json, serialized.as_str());
382    }
383
384    #[test]
385    fn test_minimal_serialization() {
386        let config = r#"{"options":{"foo":"bar"}}"#;
387        let deserialized: GlobalConfig = serde_json::from_str(config).unwrap();
388        let serialized = serde_json::to_string(&deserialized).unwrap();
389        assert_eq!(config, &serialized);
390    }
391
392    #[test]
393    fn test_metric_bucket_encodings_de_from_str() {
394        let o: Options = serde_json::from_str(
395            r#"{
396                "relay.metric-bucket-set-encodings": "legacy",
397                "relay.metric-bucket-distribution-encodings": "zstd"
398        }"#,
399        )
400        .unwrap();
401
402        assert_eq!(
403            o.metric_bucket_set_encodings,
404            BucketEncodings {
405                spans: BucketEncoding::Legacy,
406                transactions: BucketEncoding::Legacy,
407                profiles: BucketEncoding::Legacy,
408                custom: BucketEncoding::Legacy,
409            }
410        );
411        assert_eq!(
412            o.metric_bucket_dist_encodings,
413            BucketEncodings {
414                spans: BucketEncoding::Zstd,
415                transactions: BucketEncoding::Zstd,
416                profiles: BucketEncoding::Zstd,
417                custom: BucketEncoding::Zstd,
418            }
419        );
420    }
421
422    #[test]
423    fn test_metric_bucket_encodings_de_from_obj() {
424        let original = BucketEncodings {
425            spans: BucketEncoding::Zstd,
426            transactions: BucketEncoding::Zstd,
427            profiles: BucketEncoding::Base64,
428            custom: BucketEncoding::Zstd,
429        };
430        let s = serde_json::to_string(&original).unwrap();
431        let s = format!(
432            r#"{{
433            "relay.metric-bucket-set-encodings": {s},
434            "relay.metric-bucket-distribution-encodings": {s}
435        }}"#
436        );
437
438        let o: Options = serde_json::from_str(&s).unwrap();
439        assert_eq!(o.metric_bucket_set_encodings, original);
440        assert_eq!(o.metric_bucket_dist_encodings, original);
441    }
442}