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