Skip to main content

lodestone_types/
metric.rs

1//! Mapping-owned metric records and their canonical ordinary-source projection.
2
3use std::error::Error;
4use std::fmt::{self, Display, Formatter};
5
6use serde::{Deserialize, Serialize};
7
8pub const METRIC_RECORD_MODEL_FORMAT_VERSION: u16 = 1;
9pub const METRIC_POINT_FORMAT_VERSION: u16 = 1;
10pub const METRIC_SOURCE_FORMAT_VERSION: u16 = 1;
11
12pub const MAX_METRIC_RESOURCE_ATTRIBUTES: usize = 64;
13pub const MAX_METRIC_SCOPE_ATTRIBUTES: usize = 32;
14pub const MAX_METRIC_SERIES_ATTRIBUTES: usize = 128;
15pub const MAX_METRIC_GROUPING_ATTRIBUTES: usize = 16;
16pub const MAX_METRIC_EXEMPLAR_ATTRIBUTES: usize = 16;
17pub const MAX_METRIC_ATTRIBUTE_KEY_BYTES: usize = 256;
18pub const MAX_METRIC_ATTRIBUTE_STRING_BYTES: usize = 4_096;
19pub const MAX_METRIC_ATTRIBUTE_BYTES: usize = 64 * 1_024;
20pub const MAX_METRIC_EXEMPLARS: usize = 8;
21pub const MAX_METRIC_EXPLICIT_BOUNDS: usize = 4_096;
22pub const MAX_METRIC_EXPONENTIAL_BUCKETS: usize = 4_096;
23pub const MIN_METRIC_EXPONENTIAL_SCALE: i32 = -10;
24pub const MAX_METRIC_EXPONENTIAL_SCALE: i32 = 20;
25pub const MAX_METRIC_POINT_BYTES: usize = 1_048_576;
26
27const RESOURCE_GROUPING_PREFIX: &str = "resource.";
28const SCOPE_GROUPING_PREFIX: &str = "scope.";
29const SERIES_GROUPING_PREFIX: &str = "series.";
30
31#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
32#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
33pub enum IndexRecordModel {
34    #[default]
35    Document,
36    MetricV1 {
37        format_version: u16,
38        grouping_attributes: Vec<String>,
39    },
40    RelationshipV1 {
41        format_version: u16,
42    },
43    ProfileV1 {
44        format_version: u16,
45        privacy: crate::ProfilePrivacyPolicyV1,
46    },
47}
48
49impl IndexRecordModel {
50    pub fn validate(&self, mode: crate::IndexMode) -> Result<(), MetricRecordError> {
51        match self {
52            Self::Document => Ok(()),
53            Self::MetricV1 {
54                format_version,
55                grouping_attributes,
56            } => {
57                if *format_version != METRIC_RECORD_MODEL_FORMAT_VERSION {
58                    return Err(MetricRecordError::UnsupportedRecordModelVersion(*format_version));
59                }
60                if mode != crate::IndexMode::AppendLog {
61                    return Err(MetricRecordError::MetricModelRequiresAppendLog);
62                }
63                validate_grouping_selectors(
64                    grouping_attributes,
65                    MAX_METRIC_GROUPING_ATTRIBUTES,
66                    "grouping attributes",
67                )
68            }
69            Self::RelationshipV1 { format_version } => {
70                crate::validate_relationship_record_model_v1(*format_version, mode)
71            }
72            Self::ProfileV1 {
73                format_version,
74                privacy,
75            } => crate::validate_profile_record_model_v1(*format_version, privacy, mode),
76        }
77    }
78
79    #[must_use]
80    pub fn metric_grouping_attributes(&self) -> Option<&[String]> {
81        match self {
82            Self::Document => None,
83            Self::MetricV1 {
84                grouping_attributes, ..
85            } => Some(grouping_attributes),
86            Self::RelationshipV1 { .. } => None,
87            Self::ProfileV1 { .. } => None,
88        }
89    }
90
91    #[must_use]
92    pub fn semantic_digest_v1(&self) -> [u8; 32] {
93        let mut hasher = blake3::Hasher::new();
94        hasher.update(b"lodestone/index-record-model/v1\0");
95        match self {
96            Self::Document => {
97                hasher.update(b"document\0");
98            }
99            Self::MetricV1 {
100                format_version,
101                grouping_attributes,
102            } => {
103                hasher.update(b"metric_v1\0");
104                hasher.update(&format_version.to_be_bytes());
105                hasher.update(&(grouping_attributes.len() as u64).to_be_bytes());
106                for key in grouping_attributes {
107                    hasher.update(&(key.len() as u64).to_be_bytes());
108                    hasher.update(key.as_bytes());
109                }
110            }
111            Self::RelationshipV1 { format_version } => {
112                hasher.update(b"relationship_v1\0");
113                hasher.update(&format_version.to_be_bytes());
114            }
115            Self::ProfileV1 {
116                format_version,
117                privacy,
118            } => {
119                hasher.update(b"profile_v1\0");
120                hasher.update(&format_version.to_be_bytes());
121                hasher.update(&privacy.semantic_digest_v1());
122            }
123        }
124        *hasher.finalize().as_bytes()
125    }
126}
127
128/// Resolves one mapping-admitted, namespace-qualified grouping selector.
129///
130/// The returned key remains qualified so equal attribute keys from distinct
131/// metric namespaces cannot collapse into the same result group.
132pub fn metric_grouping_attribute_v1(
133    point: &MetricPointV1,
134    selector: &str,
135) -> Result<Option<MetricAttributeV1>, MetricRecordError> {
136    let (attributes, key) = if let Some(key) = selector.strip_prefix(RESOURCE_GROUPING_PREFIX) {
137        (&point.resource_attributes, key)
138    } else if let Some(key) = selector.strip_prefix(SCOPE_GROUPING_PREFIX) {
139        (&point.instrumentation_scope.attributes, key)
140    } else if let Some(key) = selector.strip_prefix(SERIES_GROUPING_PREFIX) {
141        (&point.series_attributes, key)
142    } else {
143        return Err(MetricRecordError::InvalidGroupingSelector);
144    };
145    validate_key(key)?;
146    Ok(attributes
147        .binary_search_by(|attribute| attribute.key.as_str().cmp(key))
148        .ok()
149        .and_then(|position| attributes.get(position))
150        .map(|attribute| MetricAttributeV1 {
151            key: selector.to_owned(),
152            value: attribute.value.clone(),
153        }))
154}
155
156#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
157#[serde(deny_unknown_fields)]
158pub struct MetricAttributeV1 {
159    pub key: String,
160    pub value: MetricAttributeValueV1,
161}
162
163#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
164#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
165pub enum MetricAttributeValueV1 {
166    String { value: String },
167    Boolean { value: bool },
168    I64 { value: i64 },
169    U64 { value: u64 },
170    F64Bits { value: u64 },
171}
172
173impl MetricAttributeValueV1 {
174    fn validate(&self) -> Result<(), MetricRecordError> {
175        match self {
176            Self::String { value } if value.len() > MAX_METRIC_ATTRIBUTE_STRING_BYTES => {
177                Err(MetricRecordError::AttributeStringTooLong)
178            }
179            Self::F64Bits { value } if !f64::from_bits(*value).is_finite() => Err(MetricRecordError::NonFiniteNumber),
180            Self::String { .. } | Self::Boolean { .. } | Self::I64 { .. } | Self::U64 { .. } | Self::F64Bits { .. } => {
181                Ok(())
182            }
183        }
184    }
185}
186
187#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
188#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
189pub enum MetricNumberV1 {
190    I64 { value: i64 },
191    F64Bits { value: u64 },
192}
193
194impl MetricNumberV1 {
195    pub fn validate(self) -> Result<(), MetricRecordError> {
196        match self {
197            Self::F64Bits { value } if !f64::from_bits(value).is_finite() => Err(MetricRecordError::NonFiniteNumber),
198            Self::I64 { .. } | Self::F64Bits { .. } => Ok(()),
199        }
200    }
201
202    #[must_use]
203    pub const fn kind(self) -> MetricNumberKindV1 {
204        match self {
205            Self::I64 { .. } => MetricNumberKindV1::I64,
206            Self::F64Bits { .. } => MetricNumberKindV1::F64,
207        }
208    }
209
210    #[must_use]
211    pub const fn as_i64(self) -> Option<i64> {
212        match self {
213            Self::I64 { value } => Some(value),
214            Self::F64Bits { .. } => None,
215        }
216    }
217
218    #[must_use]
219    pub fn as_f64(self) -> f64 {
220        match self {
221            Self::I64 { value } => value as f64,
222            Self::F64Bits { value } => f64::from_bits(value),
223        }
224    }
225}
226
227#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
228pub enum MetricNumberKindV1 {
229    I64,
230    F64,
231}
232
233#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
234#[serde(rename_all = "snake_case")]
235pub enum MetricTemporalityV1 {
236    Cumulative,
237    Delta,
238}
239
240#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
241#[serde(deny_unknown_fields)]
242pub struct MetricExponentialBucketsV1 {
243    pub offset: i32,
244    pub counts: Vec<u64>,
245}
246
247#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
248#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
249pub enum MetricDataV1 {
250    Gauge {
251        value: MetricNumberV1,
252    },
253    Sum {
254        value: MetricNumberV1,
255        temporality: MetricTemporalityV1,
256        monotonic: bool,
257    },
258    ExplicitHistogram {
259        temporality: MetricTemporalityV1,
260        count: u64,
261        sum: Option<MetricNumberV1>,
262        bounds_f64_bits: Vec<u64>,
263        bucket_counts: Vec<u64>,
264    },
265    ExponentialHistogram {
266        temporality: MetricTemporalityV1,
267        count: u64,
268        sum: Option<MetricNumberV1>,
269        scale: i32,
270        zero_count: u64,
271        positive: MetricExponentialBucketsV1,
272        negative: MetricExponentialBucketsV1,
273    },
274}
275
276impl MetricDataV1 {
277    #[must_use]
278    pub const fn kind(&self) -> MetricPointKindV1 {
279        match self {
280            Self::Gauge { .. } => MetricPointKindV1::Gauge,
281            Self::Sum { .. } => MetricPointKindV1::Sum,
282            Self::ExplicitHistogram { .. } => MetricPointKindV1::ExplicitHistogram,
283            Self::ExponentialHistogram { .. } => MetricPointKindV1::ExponentialHistogram,
284        }
285    }
286
287    #[must_use]
288    pub const fn temporality(&self) -> Option<MetricTemporalityV1> {
289        match self {
290            Self::Gauge { .. } => None,
291            Self::Sum { temporality, .. }
292            | Self::ExplicitHistogram { temporality, .. }
293            | Self::ExponentialHistogram { temporality, .. } => Some(*temporality),
294        }
295    }
296
297    #[must_use]
298    pub const fn monotonic(&self) -> Option<bool> {
299        match self {
300            Self::Sum { monotonic, .. } => Some(*monotonic),
301            Self::Gauge { .. } | Self::ExplicitHistogram { .. } | Self::ExponentialHistogram { .. } => None,
302        }
303    }
304
305    fn validate(&self) -> Result<(), MetricRecordError> {
306        match self {
307            Self::Gauge { value } => value.validate(),
308            Self::Sum {
309                value, monotonic: true, ..
310            } => {
311                value.validate()?;
312                if value.as_f64() < 0.0 {
313                    return Err(MetricRecordError::NegativeMonotonicValue);
314                }
315                Ok(())
316            }
317            Self::Sum {
318                value,
319                monotonic: false,
320                ..
321            } => value.validate(),
322            Self::ExplicitHistogram {
323                count,
324                sum,
325                bounds_f64_bits,
326                bucket_counts,
327                ..
328            } => {
329                validate_optional_number(*sum)?;
330                if bounds_f64_bits.len() > MAX_METRIC_EXPLICIT_BOUNDS
331                    || bucket_counts.len() > MAX_METRIC_EXPLICIT_BOUNDS + 1
332                {
333                    return Err(MetricRecordError::HistogramBucketBudgetExceeded);
334                }
335                if bucket_counts.len() != bounds_f64_bits.len() + 1 {
336                    return Err(MetricRecordError::InvalidExplicitHistogramLayout);
337                }
338                let mut previous = None;
339                for bits in bounds_f64_bits {
340                    let bound = f64::from_bits(*bits);
341                    if !bound.is_finite() || previous.is_some_and(|prior: f64| bound <= prior) {
342                        return Err(MetricRecordError::InvalidExplicitHistogramBounds);
343                    }
344                    previous = Some(bound);
345                }
346                let observed = checked_count(bucket_counts.iter().copied())?;
347                if observed != *count {
348                    return Err(MetricRecordError::HistogramCountMismatch);
349                }
350                Ok(())
351            }
352            Self::ExponentialHistogram {
353                count,
354                sum,
355                scale,
356                zero_count,
357                positive,
358                negative,
359                ..
360            } => {
361                validate_optional_number(*sum)?;
362                if positive.counts.len() > MAX_METRIC_EXPONENTIAL_BUCKETS
363                    || negative.counts.len() > MAX_METRIC_EXPONENTIAL_BUCKETS
364                {
365                    return Err(MetricRecordError::HistogramBucketBudgetExceeded);
366                }
367                if !(MIN_METRIC_EXPONENTIAL_SCALE..=MAX_METRIC_EXPONENTIAL_SCALE).contains(scale) {
368                    return Err(MetricRecordError::InvalidExponentialHistogramLayout);
369                }
370                let observed = checked_count(
371                    positive
372                        .counts
373                        .iter()
374                        .copied()
375                        .chain(negative.counts.iter().copied())
376                        .chain(std::iter::once(*zero_count)),
377                )?;
378                if observed != *count {
379                    return Err(MetricRecordError::HistogramCountMismatch);
380                }
381                Ok(())
382            }
383        }
384    }
385}
386
387fn validate_optional_number(value: Option<MetricNumberV1>) -> Result<(), MetricRecordError> {
388    match value {
389        Some(value) => value.validate(),
390        None => Ok(()),
391    }
392}
393
394fn checked_count(values: impl IntoIterator<Item = u64>) -> Result<u64, MetricRecordError> {
395    values.into_iter().try_fold(0_u64, |total, value| {
396        total.checked_add(value).ok_or(MetricRecordError::CountOverflow)
397    })
398}
399
400#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
401#[serde(rename_all = "snake_case")]
402pub enum MetricPointKindV1 {
403    Gauge,
404    Sum,
405    ExplicitHistogram,
406    ExponentialHistogram,
407}
408
409#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
410#[serde(deny_unknown_fields)]
411pub struct MetricInstrumentationScopeV1 {
412    pub name: String,
413    pub version: Option<String>,
414    pub schema_url: Option<String>,
415    pub attributes: Vec<MetricAttributeV1>,
416}
417
418#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
419#[serde(deny_unknown_fields)]
420pub struct MetricExemplarV1 {
421    pub timestamp_unix_nanos: u64,
422    pub value: MetricNumberV1,
423    pub trace_id: Option<String>,
424    pub span_id: Option<String>,
425    pub filtered_attributes: Vec<MetricAttributeV1>,
426}
427
428#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
429#[serde(deny_unknown_fields)]
430pub struct MetricPointV1 {
431    pub format_version: u16,
432    pub resource_attributes: Vec<MetricAttributeV1>,
433    pub instrumentation_scope: MetricInstrumentationScopeV1,
434    pub metric_name: String,
435    pub description: Option<String>,
436    pub unit: String,
437    pub series_attributes: Vec<MetricAttributeV1>,
438    pub timestamp_unix_nanos: u64,
439    pub start_timestamp_unix_nanos: Option<u64>,
440    pub data: MetricDataV1,
441    pub exemplars: Vec<MetricExemplarV1>,
442}
443
444impl MetricPointV1 {
445    pub fn validate(&self, model: &IndexRecordModel) -> Result<(), MetricRecordError> {
446        if self.format_version != METRIC_POINT_FORMAT_VERSION {
447            return Err(MetricRecordError::UnsupportedPointVersion(self.format_version));
448        }
449        if model.metric_grouping_attributes().is_none() {
450            return Err(MetricRecordError::RecordModelMismatch);
451        }
452        model.validate(crate::IndexMode::AppendLog)?;
453        validate_name(&self.metric_name, "metric name")?;
454        validate_bounded_string(&self.unit)?;
455        validate_optional_string(self.description.as_deref())?;
456        validate_name(&self.instrumentation_scope.name, "instrumentation scope name")?;
457        validate_optional_string(self.instrumentation_scope.version.as_deref())?;
458        validate_optional_string(self.instrumentation_scope.schema_url.as_deref())?;
459        validate_attributes(
460            &self.resource_attributes,
461            MAX_METRIC_RESOURCE_ATTRIBUTES,
462            "resource attributes",
463        )?;
464        validate_attributes(
465            &self.instrumentation_scope.attributes,
466            MAX_METRIC_SCOPE_ATTRIBUTES,
467            "scope attributes",
468        )?;
469        validate_attributes(
470            &self.series_attributes,
471            MAX_METRIC_SERIES_ATTRIBUTES,
472            "series attributes",
473        )?;
474        if self
475            .start_timestamp_unix_nanos
476            .is_some_and(|start| start > self.timestamp_unix_nanos)
477        {
478            return Err(MetricRecordError::StartAfterObservation);
479        }
480        self.data.validate()?;
481        if self.exemplars.len() > MAX_METRIC_EXEMPLARS {
482            return Err(MetricRecordError::TooManyExemplars);
483        }
484        for exemplar in &self.exemplars {
485            exemplar.value.validate()?;
486            validate_hex_id(exemplar.trace_id.as_deref(), 32)?;
487            validate_hex_id(exemplar.span_id.as_deref(), 16)?;
488            validate_attributes(
489                &exemplar.filtered_attributes,
490                MAX_METRIC_EXEMPLAR_ATTRIBUTES,
491                "exemplar attributes",
492            )?;
493        }
494        let attribute_bytes = serde_json::to_vec(&(
495            &self.resource_attributes,
496            &self.instrumentation_scope.attributes,
497            &self.series_attributes,
498            self.exemplars
499                .iter()
500                .map(|exemplar| &exemplar.filtered_attributes)
501                .collect::<Vec<_>>(),
502        ))
503        .map_err(|error| MetricRecordError::Codec(error.to_string()))?
504        .len();
505        if attribute_bytes > MAX_METRIC_ATTRIBUTE_BYTES {
506            return Err(MetricRecordError::AttributeBudgetExceeded(attribute_bytes));
507        }
508        let encoded = serde_json::to_vec(self).map_err(|error| MetricRecordError::Codec(error.to_string()))?;
509        if encoded.len() > MAX_METRIC_POINT_BYTES {
510            return Err(MetricRecordError::PointBudgetExceeded(encoded.len()));
511        }
512        Ok(())
513    }
514
515    pub fn series_id_v1(&self, model: &IndexRecordModel) -> Result<[u8; 32], MetricRecordError> {
516        self.validate(model)?;
517        let identity = MetricSeriesIdentityV1 {
518            format_version: METRIC_POINT_FORMAT_VERSION,
519            resource_attributes: &self.resource_attributes,
520            instrumentation_scope: &self.instrumentation_scope,
521            metric_name: &self.metric_name,
522            unit: &self.unit,
523            point_kind: self.data.kind(),
524            temporality: self.data.temporality(),
525            monotonic: self.data.monotonic(),
526            series_attributes: &self.series_attributes,
527        };
528        let encoded = serde_json::to_vec(&identity).map_err(|error| MetricRecordError::Codec(error.to_string()))?;
529        let mut hasher = blake3::Hasher::new();
530        hasher.update(b"lodestone/metric-series/v1\0");
531        hasher.update(&(encoded.len() as u64).to_be_bytes());
532        hasher.update(&encoded);
533        Ok(*hasher.finalize().as_bytes())
534    }
535}
536
537#[derive(Serialize)]
538struct MetricSeriesIdentityV1<'a> {
539    format_version: u16,
540    resource_attributes: &'a [MetricAttributeV1],
541    instrumentation_scope: &'a MetricInstrumentationScopeV1,
542    metric_name: &'a str,
543    unit: &'a str,
544    point_kind: MetricPointKindV1,
545    temporality: Option<MetricTemporalityV1>,
546    monotonic: Option<bool>,
547    series_attributes: &'a [MetricAttributeV1],
548}
549
550#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
551#[serde(deny_unknown_fields)]
552pub struct CanonicalMetricSourceV1 {
553    pub format_version: u16,
554    pub series_id: String,
555    pub point: MetricPointV1,
556}
557
558impl CanonicalMetricSourceV1 {
559    pub fn new(point: MetricPointV1, model: &IndexRecordModel) -> Result<Self, MetricRecordError> {
560        let series_id = encode_lower_hex(point.series_id_v1(model)?);
561        Ok(Self {
562            format_version: METRIC_SOURCE_FORMAT_VERSION,
563            series_id,
564            point,
565        })
566    }
567
568    pub fn validate(&self, model: &IndexRecordModel) -> Result<[u8; 32], MetricRecordError> {
569        if self.format_version != METRIC_SOURCE_FORMAT_VERSION {
570            return Err(MetricRecordError::UnsupportedSourceVersion(self.format_version));
571        }
572        let series_id = self.point.series_id_v1(model)?;
573        if self.series_id != encode_lower_hex(series_id) {
574            return Err(MetricRecordError::SeriesIdentityMismatch);
575        }
576        Ok(series_id)
577    }
578}
579
580pub fn canonical_metric_source_v1(
581    point: MetricPointV1,
582    model: &IndexRecordModel,
583) -> Result<(Vec<u8>, [u8; 32]), MetricRecordError> {
584    let source = CanonicalMetricSourceV1::new(point, model)?;
585    let series_id = source.validate(model)?;
586    let encoded = serde_json::to_vec(&source).map_err(|error| MetricRecordError::Codec(error.to_string()))?;
587    if encoded.len() > MAX_METRIC_POINT_BYTES {
588        return Err(MetricRecordError::PointBudgetExceeded(encoded.len()));
589    }
590    Ok((encoded, series_id))
591}
592
593pub fn decode_canonical_metric_source_v1(
594    bytes: &[u8],
595    model: &IndexRecordModel,
596) -> Result<(CanonicalMetricSourceV1, [u8; 32]), MetricRecordError> {
597    if bytes.len() > MAX_METRIC_POINT_BYTES {
598        return Err(MetricRecordError::PointBudgetExceeded(bytes.len()));
599    }
600    let source: CanonicalMetricSourceV1 =
601        serde_json::from_slice(bytes).map_err(|error| MetricRecordError::Codec(error.to_string()))?;
602    let series_id = source.validate(model)?;
603    let canonical = serde_json::to_vec(&source).map_err(|error| MetricRecordError::Codec(error.to_string()))?;
604    if canonical != bytes {
605        return Err(MetricRecordError::NonCanonicalSource);
606    }
607    Ok((source, series_id))
608}
609
610#[must_use]
611pub fn encode_lower_hex(bytes: [u8; 32]) -> String {
612    const HEX: &[u8; 16] = b"0123456789abcdef";
613    let mut output = String::with_capacity(64);
614    for byte in bytes {
615        output.push(char::from(HEX[usize::from(byte >> 4)]));
616        output.push(char::from(HEX[usize::from(byte & 0x0f)]));
617    }
618    output
619}
620
621fn validate_attributes(
622    values: &[MetricAttributeV1],
623    maximum: usize,
624    section: &'static str,
625) -> Result<(), MetricRecordError> {
626    if values.len() > maximum {
627        return Err(MetricRecordError::TooManyAttributes(section));
628    }
629    let mut previous = None;
630    for attribute in values {
631        validate_key(&attribute.key)?;
632        if previous.is_some_and(|value: &str| value >= attribute.key.as_str()) {
633            return Err(MetricRecordError::AttributesNotSorted(section));
634        }
635        attribute.value.validate()?;
636        previous = Some(attribute.key.as_str());
637    }
638    Ok(())
639}
640
641fn validate_grouping_selectors(
642    values: &[String],
643    maximum: usize,
644    section: &'static str,
645) -> Result<(), MetricRecordError> {
646    if values.len() > maximum {
647        return Err(MetricRecordError::TooManyAttributes(section));
648    }
649    let mut previous = None;
650    for selector in values {
651        if previous.is_some_and(|prior: &str| prior >= selector.as_str()) {
652            return Err(MetricRecordError::AttributesNotSorted(section));
653        }
654        let key = selector
655            .strip_prefix(RESOURCE_GROUPING_PREFIX)
656            .or_else(|| selector.strip_prefix(SCOPE_GROUPING_PREFIX))
657            .or_else(|| selector.strip_prefix(SERIES_GROUPING_PREFIX))
658            .ok_or(MetricRecordError::InvalidGroupingSelector)?;
659        validate_key(key)?;
660        previous = Some(selector.as_str());
661    }
662    Ok(())
663}
664
665fn validate_key(value: &str) -> Result<(), MetricRecordError> {
666    if value.is_empty()
667        || value.len() > MAX_METRIC_ATTRIBUTE_KEY_BYTES
668        || value.bytes().any(|byte| byte <= 0x1f || byte == 0x7f)
669    {
670        return Err(MetricRecordError::InvalidAttributeKey);
671    }
672    Ok(())
673}
674
675fn validate_name(value: &str, section: &'static str) -> Result<(), MetricRecordError> {
676    if value.is_empty() {
677        return Err(MetricRecordError::MissingName(section));
678    }
679    validate_bounded_string(value)
680}
681
682fn validate_optional_string(value: Option<&str>) -> Result<(), MetricRecordError> {
683    match value {
684        Some(value) => validate_bounded_string(value),
685        None => Ok(()),
686    }
687}
688
689fn validate_bounded_string(value: &str) -> Result<(), MetricRecordError> {
690    if value.len() > MAX_METRIC_ATTRIBUTE_STRING_BYTES || value.bytes().any(|byte| byte == 0) {
691        return Err(MetricRecordError::AttributeStringTooLong);
692    }
693    Ok(())
694}
695
696fn validate_hex_id(value: Option<&str>, length: usize) -> Result<(), MetricRecordError> {
697    if value.is_some_and(|value| {
698        value.len() != length
699            || value
700                .bytes()
701                .any(|byte| !byte.is_ascii_digit() && !(b'a'..=b'f').contains(&byte))
702    }) {
703        return Err(MetricRecordError::InvalidCorrelationIdentity);
704    }
705    Ok(())
706}
707
708#[derive(Clone, Debug, Eq, PartialEq)]
709pub enum MetricRecordError {
710    UnsupportedRecordModelVersion(u16),
711    UnsupportedPointVersion(u16),
712    UnsupportedSourceVersion(u16),
713    MetricModelRequiresAppendLog,
714    RelationshipModelRequiresMutableOrAppendLog,
715    ProfileModelRequiresAppendLog,
716    InvalidProfilePrivacyPolicy,
717    RecordModelMismatch,
718    InvalidGroupingSelector,
719    TooManyAttributes(&'static str),
720    AttributesNotSorted(&'static str),
721    InvalidAttributeKey,
722    AttributeStringTooLong,
723    AttributeBudgetExceeded(usize),
724    MissingName(&'static str),
725    StartAfterObservation,
726    NonFiniteNumber,
727    NegativeMonotonicValue,
728    TooManyExemplars,
729    HistogramBucketBudgetExceeded,
730    InvalidCorrelationIdentity,
731    InvalidExplicitHistogramLayout,
732    InvalidExplicitHistogramBounds,
733    InvalidExponentialHistogramLayout,
734    HistogramCountMismatch,
735    CountOverflow,
736    PointBudgetExceeded(usize),
737    SeriesIdentityMismatch,
738    NonCanonicalSource,
739    Codec(String),
740}
741
742impl Display for MetricRecordError {
743    fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
744        write!(formatter, "metric record: {self:?}")
745    }
746}
747
748impl Error for MetricRecordError {}
749
750#[cfg(test)]
751mod tests {
752    use std::error::Error;
753
754    use super::*;
755
756    fn model() -> IndexRecordModel {
757        IndexRecordModel::MetricV1 {
758            format_version: METRIC_RECORD_MODEL_FORMAT_VERSION,
759            grouping_attributes: vec!["series.host".to_owned()],
760        }
761    }
762
763    fn point() -> MetricPointV1 {
764        MetricPointV1 {
765            format_version: METRIC_POINT_FORMAT_VERSION,
766            resource_attributes: vec![MetricAttributeV1 {
767                key: "service.name".to_owned(),
768                value: MetricAttributeValueV1::String {
769                    value: "checkout".to_owned(),
770                },
771            }],
772            instrumentation_scope: MetricInstrumentationScopeV1 {
773                name: "http".to_owned(),
774                version: Some("1".to_owned()),
775                schema_url: None,
776                attributes: Vec::new(),
777            },
778            metric_name: "requests".to_owned(),
779            description: Some("request count".to_owned()),
780            unit: "{request}".to_owned(),
781            series_attributes: vec![MetricAttributeV1 {
782                key: "host".to_owned(),
783                value: MetricAttributeValueV1::String { value: "a".to_owned() },
784            }],
785            timestamp_unix_nanos: 20,
786            start_timestamp_unix_nanos: Some(10),
787            data: MetricDataV1::Sum {
788                value: MetricNumberV1::I64 { value: 4 },
789                temporality: MetricTemporalityV1::Cumulative,
790                monotonic: true,
791            },
792            exemplars: Vec::new(),
793        }
794    }
795
796    #[test]
797    fn metric_source_is_canonical_and_series_identity_ignores_description() -> Result<(), Box<dyn Error>> {
798        let model = model();
799        let (encoded, series_id) = canonical_metric_source_v1(point(), &model)?;
800        let (decoded, decoded_id) = decode_canonical_metric_source_v1(&encoded, &model)?;
801        assert_eq!(decoded_id, series_id);
802        let mut changed = decoded.point;
803        changed.description = Some("changed".to_owned());
804        assert_eq!(changed.series_id_v1(&model)?, series_id);
805        changed.unit = "ms".to_owned();
806        assert_ne!(changed.series_id_v1(&model)?, series_id);
807        Ok(())
808    }
809
810    #[test]
811    fn metric_validation_rejects_unsorted_attributes_and_bad_histograms() {
812        let model = model();
813        let mut value = point();
814        value.series_attributes = vec![
815            MetricAttributeV1 {
816                key: "z".to_owned(),
817                value: MetricAttributeValueV1::Boolean { value: true },
818            },
819            MetricAttributeV1 {
820                key: "a".to_owned(),
821                value: MetricAttributeValueV1::Boolean { value: true },
822            },
823        ];
824        assert_eq!(
825            value.validate(&model),
826            Err(MetricRecordError::AttributesNotSorted("series attributes"))
827        );
828
829        let mut value = point();
830        value.data = MetricDataV1::ExplicitHistogram {
831            temporality: MetricTemporalityV1::Delta,
832            count: 2,
833            sum: None,
834            bounds_f64_bits: vec![1.0_f64.to_bits()],
835            bucket_counts: vec![1],
836        };
837        assert_eq!(
838            value.validate(&model),
839            Err(MetricRecordError::InvalidExplicitHistogramLayout)
840        );
841    }
842
843    #[test]
844    fn record_model_is_mode_bound_immutable_and_digest_sensitive() -> Result<(), Box<dyn Error>> {
845        let first = model();
846        assert_eq!(first.validate(crate::IndexMode::AppendLog), Ok(()));
847        assert_eq!(
848            first.validate(crate::IndexMode::Mutable),
849            Err(MetricRecordError::MetricModelRequiresAppendLog)
850        );
851        let second = IndexRecordModel::MetricV1 {
852            format_version: METRIC_RECORD_MODEL_FORMAT_VERSION,
853            grouping_attributes: vec!["series.zone".to_owned()],
854        };
855        assert_ne!(first.semantic_digest_v1(), second.semantic_digest_v1());
856        assert_ne!(
857            first.semantic_digest_v1(),
858            IndexRecordModel::Document.semantic_digest_v1()
859        );
860        let invalid = IndexRecordModel::MetricV1 {
861            format_version: METRIC_RECORD_MODEL_FORMAT_VERSION,
862            grouping_attributes: vec!["host".to_owned()],
863        };
864        assert_eq!(
865            invalid.validate(crate::IndexMode::AppendLog),
866            Err(MetricRecordError::InvalidGroupingSelector)
867        );
868        let newer = IndexRecordModel::MetricV1 {
869            format_version: METRIC_RECORD_MODEL_FORMAT_VERSION + 1,
870            grouping_attributes: vec!["series.host".to_owned()],
871        };
872        assert_eq!(
873            point().validate(&newer),
874            Err(MetricRecordError::UnsupportedRecordModelVersion(
875                METRIC_RECORD_MODEL_FORMAT_VERSION + 1
876            ))
877        );
878        assert_eq!(
879            metric_grouping_attribute_v1(&point(), "resource.service.name")?,
880            Some(MetricAttributeV1 {
881                key: "resource.service.name".to_owned(),
882                value: MetricAttributeValueV1::String {
883                    value: "checkout".to_owned(),
884                },
885            })
886        );
887        Ok(())
888    }
889
890    #[test]
891    fn malformed_numbers_counts_correlation_and_noncanonical_sources_refuse() -> Result<(), Box<dyn Error>> {
892        let model = model();
893        let mut value = point();
894        value.data = MetricDataV1::Gauge {
895            value: MetricNumberV1::F64Bits {
896                value: f64::NAN.to_bits(),
897            },
898        };
899        assert_eq!(value.validate(&model), Err(MetricRecordError::NonFiniteNumber));
900
901        let mut value = point();
902        value.data = MetricDataV1::Sum {
903            value: MetricNumberV1::I64 { value: -1 },
904            temporality: MetricTemporalityV1::Delta,
905            monotonic: true,
906        };
907        assert_eq!(value.validate(&model), Err(MetricRecordError::NegativeMonotonicValue));
908
909        let mut value = point();
910        value.data = MetricDataV1::ExponentialHistogram {
911            temporality: MetricTemporalityV1::Delta,
912            count: 2,
913            sum: None,
914            scale: 0,
915            zero_count: 0,
916            positive: MetricExponentialBucketsV1 {
917                offset: 0,
918                counts: vec![1],
919            },
920            negative: MetricExponentialBucketsV1 {
921                offset: 0,
922                counts: Vec::new(),
923            },
924        };
925        assert_eq!(value.validate(&model), Err(MetricRecordError::HistogramCountMismatch));
926
927        let (canonical, _) = canonical_metric_source_v1(point(), &model)?;
928        let mut padded = canonical.clone();
929        padded.push(b' ');
930        assert_eq!(
931            decode_canonical_metric_source_v1(&padded, &model),
932            Err(MetricRecordError::NonCanonicalSource)
933        );
934        Ok(())
935    }
936}