Skip to main content

relay_metrics/
protocol.rs

1use std::hash::Hasher as _;
2
3use hash32::{FnvHasher, Hasher as _};
4use relay_protocol::FiniteF64;
5
6#[doc(inline)]
7pub use relay_base_schema::metrics::{
8    CustomUnit, DurationUnit, FractionUnit, InformationUnit, MetricName, MetricNamespace,
9    MetricResourceIdentifier, MetricType, MetricUnit, ParseMetricError, ParseMetricUnitError,
10};
11#[doc(inline)]
12pub use relay_common::time::UnixTimestamp;
13
14use crate::{Bucket, MetricTags};
15
16/// Type used for Counter metric
17pub type CounterType = FiniteF64;
18
19/// Type of distribution entries
20pub type DistributionType = FiniteF64;
21
22/// Type used for set elements in Set metric
23pub type SetType = u32;
24
25/// Type used for Gauge entries
26pub type GaugeType = FiniteF64;
27
28/// Error returned from [`normalize_bucket`].
29#[derive(Debug, thiserror::Error)]
30pub enum NormalizationError {
31    /// The metric name includes an invalid or unsupported metric namespace.
32    #[error("unsupported metric namespace")]
33    UnsupportedNamespace,
34    /// The metric name cannot be parsed and is invalid.
35    #[error("invalid metric name: {0:?}")]
36    InvalidMetricName(MetricName),
37}
38
39/// Normalizes a bucket.
40///
41/// The passed metric will have its name and tags normalized and tested for validity.
42/// Invalid characters in the metric name may be replaced,
43/// see [`relay_base_schema::metrics::try_normalize_metric_name`].
44///
45/// Invalid tags are removed and tag keys are normalized, for example control characters are
46/// removed from tag keys.
47pub fn normalize_bucket(bucket: &mut Bucket) -> Result<(), NormalizationError> {
48    normalize_metric_name(&mut bucket.name)?;
49    normalize_metric_tags(&mut bucket.tags);
50    Ok(())
51}
52
53/// Normalizes a metric name.
54///
55/// Normalization includes expanding valid metric names without a namespace to the default
56/// namespace.
57///
58/// Invalid metric names are rejected with [`NormalizationError`].
59fn normalize_metric_name(name: &mut MetricName) -> Result<(), NormalizationError> {
60    *name = match MetricResourceIdentifier::parse(name) {
61        Ok(mri) => {
62            if matches!(mri.namespace, MetricNamespace::Unsupported) {
63                return Err(NormalizationError::UnsupportedNamespace);
64            }
65
66            // We can improve this code part, by not always re-creating the name, if the name is
67            // already a valid MRI with namespace we can use the original name instead.
68            mri.to_string().into()
69        }
70        Err(_) => {
71            return Err(NormalizationError::InvalidMetricName(name.clone()));
72        }
73    };
74
75    Ok(())
76}
77
78/// Removes tags with invalid characters in the key, and validates tag values.
79///
80/// Tag values are validated with [`normalize_tag_value`].
81fn normalize_metric_tags(tags: &mut MetricTags) {
82    tags.retain(|tag_key, tag_value| {
83        if !is_valid_tag_key(tag_key) {
84            relay_log::debug!("invalid metric tag key {tag_key:?}");
85            return false;
86        }
87
88        normalize_tag_value(tag_value);
89
90        true
91    });
92}
93
94/// Validates a tag key.
95///
96/// Tag keys currently only need to not contain ASCII control characters. This might change.
97pub(crate) fn is_valid_tag_key(tag_key: &str) -> bool {
98    // iterating over bytes produces better asm, and we're only checking for ascii chars
99    for &byte in tag_key.as_bytes() {
100        if (byte as char).is_ascii_control() {
101            return false;
102        }
103    }
104    true
105}
106
107/// Normalizes a tag value.
108///
109/// Tag values are never entirely rejected, but invalid characters (control characters) are stripped
110/// out.
111pub(crate) fn normalize_tag_value(tag_value: &mut String) {
112    tag_value.retain(|c| !c.is_control());
113}
114
115/// Hashes the given set value.
116///
117/// Converts a string to the 32-bit FNV hash stored in a set bucket.
118pub(crate) fn hash_set_value(string: &str) -> u32 {
119    let mut hasher = FnvHasher::default();
120    hasher.write(string.as_bytes());
121    hasher.finish32()
122}
123
124#[cfg(test)]
125mod tests {
126    use insta::assert_json_snapshot;
127
128    use crate::BucketValue;
129
130    use super::*;
131
132    #[test]
133    fn test_normalize_invalid_name() {
134        let mut bucket = Bucket {
135            timestamp: UnixTimestamp::from_secs(5000),
136            width: 0,
137            name: "c:transactions/\0hergus.bergus@none".into(),
138            value: BucketValue::Counter(0.into()),
139            tags: Default::default(),
140            metadata: Default::default(),
141        };
142
143        assert!(matches!(
144            normalize_bucket(&mut bucket),
145            Err(NormalizationError::InvalidMetricName(_))
146        ));
147    }
148
149    #[test]
150    fn test_normalize_invalid_namespace() {
151        let mut bucket = Bucket {
152            timestamp: UnixTimestamp::from_secs(5000),
153            width: 0,
154            name: "c:lol/hergus.bergus@none".into(),
155            value: BucketValue::Counter(0.into()),
156            tags: Default::default(),
157            metadata: Default::default(),
158        };
159
160        assert!(matches!(
161            normalize_bucket(&mut bucket),
162            Err(NormalizationError::UnsupportedNamespace)
163        ));
164    }
165
166    #[test]
167    fn test_normalize_name() {
168        let mut bucket = Bucket {
169            timestamp: UnixTimestamp::from_secs(5000),
170            width: 0,
171            name: "c:spans/hergus\0\0bergus".into(),
172            value: BucketValue::Counter(0.into()),
173            tags: Default::default(),
174            metadata: Default::default(),
175        };
176
177        normalize_bucket(&mut bucket).unwrap();
178
179        assert_eq!(&bucket.name, "c:spans/hergus_bergus@none");
180    }
181
182    #[test]
183    fn test_normalize_tag_key_chars() {
184        let mut bucket = Bucket {
185            timestamp: UnixTimestamp::from_secs(5000),
186            width: 0,
187            name: "c:spans/hergus.bergus".into(),
188            value: BucketValue::Counter(0.into()),
189            tags: {
190                let mut tags = MetricTags::new();
191                // There are some SDKs which mess up content encodings, and interpret the raw bytes
192                // of an UTF-16 string as UTF-8. Leading to ASCII
193                // strings getting null-bytes interleaved.
194                //
195                // Somehow those values end up as release tag in sessions, while in error events we
196                // haven't observed this malformed encoding. We believe it's slightly better to
197                // strip out NUL-bytes instead of dropping the tag such that those values line up
198                // again across sessions and events. Should that cause too high cardinality we'll
199                // have to drop tags.
200                //
201                // Note that releases are validated separately against much stricter character set,
202                // but the above idea should still apply to other tags.
203                tags.insert(
204                    "is_it_garbage".to_owned(),
205                    "a\0b\0s\0o\0l\0u\0t\0e\0l\0y".to_owned(),
206                );
207                tags.insert("another\0garbage".to_owned(), "bye".to_owned());
208                tags
209            },
210            metadata: Default::default(),
211        };
212
213        normalize_bucket(&mut bucket).unwrap();
214
215        assert_json_snapshot!(bucket, @r###"
216        {
217          "timestamp": 5000,
218          "width": 0,
219          "name": "c:spans/hergus.bergus@none",
220          "type": "c",
221          "value": 0.0,
222          "tags": {
223            "is_it_garbage": "absolutely"
224          }
225        }
226        "###);
227    }
228}