Skip to main content

opentelemetry_sdk/metrics/
instrument.rs

1use std::{borrow::Cow, collections::HashSet, error::Error, sync::Arc};
2
3#[cfg(feature = "experimental_metrics_bound_instruments")]
4use opentelemetry::metrics::BoundSyncInstrument;
5use opentelemetry::{
6    metrics::{AsyncInstrument, SyncInstrument},
7    InstrumentationScope, Key, KeyValue,
8};
9
10#[cfg(feature = "experimental_metrics_bound_instruments")]
11use crate::metrics::internal::BoundMeasure;
12use crate::metrics::{aggregation::Aggregation, internal::Measure};
13
14use super::meter::{INSTRUMENT_UNIT_INVALID_CHAR, INSTRUMENT_UNIT_LENGTH};
15
16use super::Temporality;
17
18/// The identifier of a group of instruments that all perform the same function.
19#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
20pub enum InstrumentKind {
21    /// Identifies a group of instruments that record increasing values synchronously
22    /// with the code path they are measuring.
23    Counter,
24    /// A group of instruments that record increasing and decreasing values
25    /// synchronously with the code path they are measuring.
26    UpDownCounter,
27    /// A group of instruments that record a distribution of values synchronously with
28    /// the code path they are measuring.
29    Histogram,
30    /// A group of instruments that record increasing values in an asynchronous
31    /// callback.
32    ObservableCounter,
33    /// A group of instruments that record increasing and decreasing values in an
34    /// asynchronous callback.
35    ObservableUpDownCounter,
36
37    /// a group of instruments that record current value synchronously with
38    /// the code path they are measuring.
39    Gauge,
40    ///
41    /// a group of instruments that record current values in an asynchronous callback.
42    ObservableGauge,
43}
44
45impl InstrumentKind {
46    /// Select the [Temporality] preference based on [InstrumentKind]
47    ///
48    /// [exporter-docs]: https://github.com/open-telemetry/opentelemetry-specification/blob/a1c13d59bb7d0fb086df2b3e1eaec9df9efef6cc/specification/metrics/sdk_exporters/otlp.md#additional-configuration
49    pub(crate) fn temporality_preference(&self, temporality: Temporality) -> Temporality {
50        match temporality {
51            Temporality::Cumulative => Temporality::Cumulative,
52            Temporality::Delta => match self {
53                Self::Counter
54                | Self::Histogram
55                | Self::ObservableCounter
56                | Self::Gauge
57                | Self::ObservableGauge => Temporality::Delta,
58                Self::UpDownCounter | InstrumentKind::ObservableUpDownCounter => {
59                    Temporality::Cumulative
60                }
61            },
62            Temporality::LowMemory => match self {
63                Self::Counter | InstrumentKind::Histogram => Temporality::Delta,
64                Self::ObservableCounter
65                | Self::Gauge
66                | Self::ObservableGauge
67                | Self::UpDownCounter
68                | Self::ObservableUpDownCounter => Temporality::Cumulative,
69            },
70        }
71    }
72}
73/// Describes the properties of an instrument at creation, used for filtering in
74/// views. This is utilized in the `with_view` methods on `MeterProviderBuilder`
75/// to customize metric output.
76///
77/// Users can use a reference to `Instrument` to select which instrument(s) a
78/// [Stream] should be applied to.
79///
80/// # Example
81///
82/// ```rust
83/// use opentelemetry_sdk::metrics::{Instrument, Stream};
84///
85/// let my_view_change_cardinality = |i: &Instrument| {
86///     if i.name() == "my_second_histogram" {
87///         // Note: If Stream is invalid, `build()` will return an error. By
88///         // calling `.ok()`, any such error is ignored and treated as if the
89///         // view does not match the instrument. If this is not the desired
90///         // behavior, consider handling the error explicitly.
91///         Stream::builder().with_cardinality_limit(2).build().ok()
92///     } else {
93///         None
94///     }
95/// };
96/// ```
97#[derive(Clone, Debug, PartialEq)]
98pub struct Instrument {
99    /// The human-readable identifier of the instrument.
100    pub(crate) name: Cow<'static, str>,
101    /// describes the purpose of the instrument.
102    pub(crate) description: Cow<'static, str>,
103    /// The functional group of the instrument.
104    pub(crate) kind: InstrumentKind,
105    /// Unit is the unit of measurement recorded by the instrument.
106    pub(crate) unit: Cow<'static, str>,
107    /// The instrumentation that created the instrument.
108    pub(crate) scope: InstrumentationScope,
109}
110
111impl Instrument {
112    /// Instrument name.
113    pub fn name(&self) -> &str {
114        self.name.as_ref()
115    }
116
117    /// Instrument kind.
118    pub fn kind(&self) -> InstrumentKind {
119        self.kind
120    }
121
122    /// Instrument unit.
123    pub fn unit(&self) -> &str {
124        self.unit.as_ref()
125    }
126
127    /// Instrument scope.
128    pub fn scope(&self) -> &InstrumentationScope {
129        &self.scope
130    }
131}
132
133/// A builder for creating Stream objects.
134///
135/// # Example
136///
137/// ```rust
138/// use opentelemetry_sdk::metrics::Stream;
139///
140/// let stream = Stream::builder()
141///     .with_name("my_stream")
142///     .with_description("A custom stream")
143///     .with_unit("ms")
144///     .with_cardinality_limit(100)
145///     .build()
146///     .unwrap();
147/// ```
148#[derive(Default, Debug)]
149pub struct StreamBuilder {
150    name: Option<Cow<'static, str>>,
151    description: Option<Cow<'static, str>>,
152    unit: Option<Cow<'static, str>>,
153    aggregation: Option<Aggregation>,
154    allowed_attribute_keys: Option<Arc<HashSet<Key>>>,
155    cardinality_limit: Option<usize>,
156}
157
158impl StreamBuilder {
159    /// Create a new stream builder with default values.
160    pub(crate) fn new() -> Self {
161        StreamBuilder::default()
162    }
163
164    /// Set the stream name. If this is not set, name provide while creating the instrument will be used.
165    pub fn with_name(mut self, name: impl Into<Cow<'static, str>>) -> Self {
166        self.name = Some(name.into());
167        self
168    }
169
170    /// Set the stream description. If this is not set, description provided while creating the instrument will be used.
171    pub fn with_description(mut self, description: impl Into<Cow<'static, str>>) -> Self {
172        self.description = Some(description.into());
173        self
174    }
175
176    /// Set the stream unit. If this is not set, unit provided while creating the instrument will be used.
177    pub fn with_unit(mut self, unit: impl Into<Cow<'static, str>>) -> Self {
178        self.unit = Some(unit.into());
179        self
180    }
181
182    /// Set the stream aggregation. This is used to customize the aggregation.
183    /// If not set, the default aggregation based on the instrument kind will be used.
184    pub fn with_aggregation(mut self, aggregation: Aggregation) -> Self {
185        self.aggregation = Some(aggregation);
186        self
187    }
188
189    #[cfg(feature = "spec_unstable_metrics_views")]
190    /// Set the stream allowed attribute keys.
191    ///
192    /// Any attribute recorded for the stream with a key not in this set will be
193    /// dropped. If the set is empty, all attributes will be dropped.
194    /// If this method is not used, all attributes will be kept.
195    pub fn with_allowed_attribute_keys(
196        mut self,
197        attribute_keys: impl IntoIterator<Item = Key>,
198    ) -> Self {
199        self.allowed_attribute_keys = Some(Arc::new(attribute_keys.into_iter().collect()));
200        self
201    }
202
203    /// Set the stream cardinality limit. If this is not set, the default limit of 2000 will be used.
204    pub fn with_cardinality_limit(mut self, limit: usize) -> Self {
205        self.cardinality_limit = Some(limit);
206        self
207    }
208
209    /// Build a new Stream instance using the configuration in this builder.
210    ///
211    /// # Returns
212    ///
213    /// A Result containing the new Stream instance or an error if the build failed.
214    pub fn build(self) -> Result<Stream, Box<dyn Error>> {
215        // Note: Per the OpenTelemetry specification, the View-provided stream
216        // `name` is NOT subject to the instrument name syntax, and the SDK
217        // MUST NOT validate it against that syntax. This allows Views to be
218        // used to fix instrumentation mistakes (e.g. renaming an instrument
219        // whose original name does not satisfy the syntax) and to support
220        // export targets that have different naming requirements.
221        // See: https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/metrics/sdk.md#view
222
223        // Validate unit if provided
224        if let Some(unit) = &self.unit {
225            if unit.len() > super::meter::INSTRUMENT_UNIT_NAME_MAX_LENGTH {
226                return Err(INSTRUMENT_UNIT_LENGTH.into());
227            }
228
229            if unit.contains(|c: char| !c.is_ascii()) {
230                return Err(INSTRUMENT_UNIT_INVALID_CHAR.into());
231            }
232        }
233
234        // Validate cardinality limit
235        if let Some(limit) = self.cardinality_limit {
236            if limit == 0 {
237                return Err("Cardinality limit must be greater than 0".into());
238            }
239            // Reject usize::MAX because the SDK's internal HashMap capacity
240            // is sized as `1 + cardinality_limit`, which would overflow.
241            if limit == usize::MAX {
242                return Err("Cardinality limit must be less than usize::MAX".into());
243            }
244        }
245
246        // Validate bucket boundaries if using ExplicitBucketHistogram
247        if let Some(Aggregation::ExplicitBucketHistogram { boundaries, .. }) = &self.aggregation {
248            validate_bucket_boundaries(boundaries)?;
249        }
250
251        // Validate parameters if using Base2ExponentialHistogram
252        if let Some(Aggregation::Base2ExponentialHistogram {
253            max_size,
254            max_scale,
255            ..
256        }) = &self.aggregation
257        {
258            if *max_size == 0 {
259                return Err("max_size must be greater than 0".into());
260            }
261            if *max_scale < super::internal::EXPO_MIN_SCALE
262                || *max_scale > super::internal::EXPO_MAX_SCALE
263            {
264                return Err(format!(
265                    "max_scale must be between {} and {}",
266                    super::internal::EXPO_MIN_SCALE,
267                    super::internal::EXPO_MAX_SCALE
268                )
269                .into());
270            }
271        }
272
273        Ok(Stream {
274            name: self.name,
275            description: self.description,
276            unit: self.unit,
277            aggregation: self.aggregation,
278            allowed_attribute_keys: self.allowed_attribute_keys,
279            cardinality_limit: self.cardinality_limit,
280        })
281    }
282}
283
284fn validate_bucket_boundaries(boundaries: &[f64]) -> Result<(), String> {
285    // Validate boundaries do not contain f64::NAN, f64::INFINITY, or f64::NEG_INFINITY
286    for boundary in boundaries {
287        if boundary.is_nan() || boundary.is_infinite() {
288            return Err(
289                "Bucket boundaries must not contain NaN, Infinity, or -Infinity".to_string(),
290            );
291        }
292    }
293
294    // validate that buckets are sorted and non-duplicate
295    for i in 1..boundaries.len() {
296        if boundaries[i] <= boundaries[i - 1] {
297            return Err(
298                "Bucket boundaries must be sorted and not contain any duplicates".to_string(),
299            );
300        }
301    }
302
303    Ok(())
304}
305
306/// Describes the stream of data an instrument produces. Used in `with_view`
307/// methods on `MeterProviderBuilder` to customize the metric output.
308#[derive(Default, Debug)]
309pub struct Stream {
310    /// The human-readable identifier of the stream.
311    pub(crate) name: Option<Cow<'static, str>>,
312    /// Describes the purpose of the data.
313    pub(crate) description: Option<Cow<'static, str>>,
314    /// the unit of measurement recorded.
315    pub(crate) unit: Option<Cow<'static, str>>,
316    /// Aggregation the stream uses for an instrument.
317    pub(crate) aggregation: Option<Aggregation>,
318    /// An allow-list of attribute keys that will be preserved for the stream.
319    ///
320    /// Any attribute recorded for the stream with a key not in this set will be
321    /// dropped. If the set is empty, all attributes will be dropped, if `None` all
322    /// attributes will be kept.
323    pub(crate) allowed_attribute_keys: Option<Arc<HashSet<Key>>>,
324
325    /// Cardinality limit for the stream.
326    pub(crate) cardinality_limit: Option<usize>,
327}
328
329impl Stream {
330    /// Create a new stream builder with default values.
331    pub fn builder() -> StreamBuilder {
332        StreamBuilder::new()
333    }
334}
335
336/// The identifying properties of an instrument.
337#[derive(Debug, PartialEq, Eq, Hash)]
338pub(crate) struct InstrumentId {
339    /// The human-readable identifier of the instrument.
340    pub(crate) name: Cow<'static, str>,
341    /// Describes the purpose of the data.
342    pub(crate) description: Cow<'static, str>,
343    /// Defines the functional group of the instrument.
344    pub(crate) kind: InstrumentKind,
345    /// The unit of measurement recorded.
346    pub(crate) unit: Cow<'static, str>,
347    /// Number is the underlying data type of the instrument.
348    pub(crate) number: Cow<'static, str>,
349}
350
351impl InstrumentId {
352    /// Instrument names are considered case-insensitive ASCII.
353    ///
354    /// Standardize the instrument name to always be lowercase so it can be compared
355    /// via hash.
356    ///
357    /// See [naming syntax] for full requirements.
358    ///
359    /// [naming syntax]: https://github.com/open-telemetry/opentelemetry-specification/blob/v1.21.0/specification/metrics/api.md#instrument-name-syntax
360    pub(crate) fn normalize(&mut self) {
361        if self.name.chars().any(|c| c.is_ascii_uppercase()) {
362            self.name = self.name.to_ascii_lowercase().into();
363        }
364    }
365}
366
367pub(crate) struct ResolvedMeasures<T> {
368    pub(crate) measures: Vec<Arc<dyn Measure<T>>>,
369}
370
371impl<T: Copy + 'static> SyncInstrument<T> for ResolvedMeasures<T> {
372    fn measure(&self, val: T, attrs: &[KeyValue]) {
373        for measure in &self.measures {
374            measure.call(val, attrs)
375        }
376    }
377
378    #[cfg(feature = "experimental_metrics_bound_instruments")]
379    fn bind(&self, attrs: &[KeyValue]) -> Box<dyn BoundSyncInstrument<T> + Send + Sync> {
380        let bound_measures: Vec<Box<dyn BoundMeasure<T>>> =
381            self.measures.iter().map(|m| m.bind(attrs)).collect();
382        Box::new(ResolvedBoundMeasures {
383            measures: bound_measures,
384        })
385    }
386}
387
388#[cfg(feature = "experimental_metrics_bound_instruments")]
389pub(crate) struct ResolvedBoundMeasures<T> {
390    measures: Vec<Box<dyn BoundMeasure<T>>>,
391}
392
393#[cfg(feature = "experimental_metrics_bound_instruments")]
394impl<T: Copy + 'static> BoundSyncInstrument<T> for ResolvedBoundMeasures<T> {
395    fn measure(&self, val: T) {
396        for measure in &self.measures {
397            measure.call(val);
398        }
399    }
400}
401
402#[derive(Clone)]
403pub(crate) struct Observable<T> {
404    measures: Vec<Arc<dyn Measure<T>>>,
405}
406
407impl<T> Observable<T> {
408    pub(crate) fn new(measures: Vec<Arc<dyn Measure<T>>>) -> Self {
409        Self { measures }
410    }
411}
412
413impl<T: Copy + Send + Sync + 'static> AsyncInstrument<T> for Observable<T> {
414    fn observe(&self, measurement: T, attrs: &[KeyValue]) {
415        for measure in &self.measures {
416            measure.call(measurement, attrs)
417        }
418    }
419}
420
421#[cfg(test)]
422mod tests {
423    use super::StreamBuilder;
424    use crate::metrics::meter::{INSTRUMENT_UNIT_INVALID_CHAR, INSTRUMENT_UNIT_LENGTH};
425
426    #[test]
427    fn stream_name_no_validation() {
428        // Per the OpenTelemetry specification, View-provided stream names are
429        // NOT subject to the instrument name syntax. The SDK MUST NOT validate
430        // the View-provided name against that syntax. All names below — which
431        // would be rejected for direct instrument creation — MUST be accepted
432        // when supplied via a View/Stream.
433        let stream_names_all_accepted = vec![
434            "validateName",
435            "_startWithNoneAlphabet",
436            "utf8char锈",
437            "a".repeat(255).leak(),
438            "a".repeat(256).leak(),
439            "invalid name",
440            "allow/slash",
441            "allow_under_score",
442            "allow.dots.ok",
443            "",
444            "\\allow\\slash /sec",
445            "\\allow\\$$slash /sec",
446            "Total $ Count",
447            "\\test\\UsagePercent(Total) > 80%",
448            "/not / allowed",
449        ];
450
451        for name in stream_names_all_accepted {
452            let result = StreamBuilder::new().with_name(name).build();
453            assert!(
454                result.is_ok(),
455                "Expected View-provided stream name '{}' to be accepted without validation, but got error: {:?}",
456                name,
457                result.err()
458            );
459        }
460    }
461
462    #[test]
463    fn stream_unit_validation() {
464        // (unit, expected error)
465        let stream_unit_test_cases = vec![
466            (
467                "0123456789012345678901234567890123456789012345678901234567890123",
468                INSTRUMENT_UNIT_LENGTH,
469            ),
470            ("utf8char锈", INSTRUMENT_UNIT_INVALID_CHAR),
471            ("kb", ""),
472            ("Kb/sec", ""),
473            ("%", ""),
474            ("", ""),
475        ];
476
477        for (unit, expected_error) in stream_unit_test_cases {
478            // Use a valid name to isolate unit validation
479            let builder = StreamBuilder::new().with_name("valid_name").with_unit(unit);
480
481            let result = builder.build();
482
483            if expected_error.is_empty() {
484                assert!(
485                    result.is_ok(),
486                    "Expected successful build for unit '{}', but got error: {:?}",
487                    unit,
488                    result.err()
489                );
490            } else {
491                let err = result.err().unwrap();
492                let err_str = err.to_string();
493                assert!(
494                    err_str == expected_error,
495                    "For unit '{unit}', expected error '{expected_error}', but got '{err_str}'"
496                );
497            }
498        }
499    }
500
501    #[test]
502    fn stream_cardinality_limit_validation() {
503        // Test zero cardinality limit (invalid)
504        let builder = StreamBuilder::new()
505            .with_name("valid_name")
506            .with_cardinality_limit(0);
507
508        let result = builder.build();
509        assert!(result.is_err(), "Expected error for zero cardinality limit");
510        assert_eq!(
511            result.err().unwrap().to_string(),
512            "Cardinality limit must be greater than 0",
513            "Expected cardinality limit validation error message"
514        );
515
516        // Test usize::MAX (invalid — would overflow internal `1 + limit` capacity)
517        let builder = StreamBuilder::new()
518            .with_name("valid_name")
519            .with_cardinality_limit(usize::MAX);
520
521        let result = builder.build();
522        assert!(
523            result.is_err(),
524            "Expected error for usize::MAX cardinality limit"
525        );
526        assert_eq!(
527            result.err().unwrap().to_string(),
528            "Cardinality limit must be less than usize::MAX",
529            "Expected cardinality limit usize::MAX error message"
530        );
531
532        // Test valid cardinality limits
533        let valid_limits = vec![1, 10, 100, 1000, usize::MAX - 1];
534        for limit in valid_limits {
535            let builder = StreamBuilder::new()
536                .with_name("valid_name")
537                .with_cardinality_limit(limit);
538
539            let result = builder.build();
540            assert!(
541                result.is_ok(),
542                "Expected successful build for cardinality limit {}, but got error: {:?}",
543                limit,
544                result.err()
545            );
546        }
547    }
548
549    #[test]
550    fn stream_valid_build() {
551        // Test with valid configuration
552        let stream = StreamBuilder::new()
553            .with_name("valid_name")
554            .with_description("Valid description")
555            .with_unit("ms")
556            .with_cardinality_limit(100)
557            .build();
558
559        assert!(
560            stream.is_ok(),
561            "Expected valid Stream to be built successfully"
562        );
563    }
564
565    #[test]
566    fn stream_histogram_bucket_validation() {
567        use super::Aggregation;
568
569        // Test with valid bucket boundaries
570        let valid_boundaries = vec![1.0, 2.0, 5.0, 10.0, 20.0, 50.0, 100.0];
571        let builder = StreamBuilder::new()
572            .with_name("valid_histogram")
573            .with_aggregation(Aggregation::ExplicitBucketHistogram {
574                boundaries: valid_boundaries.clone(),
575                record_min_max: true,
576            });
577
578        let result = builder.build();
579        assert!(
580            result.is_ok(),
581            "Expected successful build with valid bucket boundaries"
582        );
583
584        // Test with invalid bucket boundaries (NaN and Infinity)
585
586        // Test with NaN
587        let invalid_nan_boundaries = vec![1.0, 2.0, f64::NAN, 10.0];
588
589        let builder = StreamBuilder::new()
590            .with_name("invalid_histogram_nan")
591            .with_aggregation(Aggregation::ExplicitBucketHistogram {
592                boundaries: invalid_nan_boundaries,
593                record_min_max: true,
594            });
595
596        let result = builder.build();
597        assert!(
598            result.is_err(),
599            "Expected error for NaN in bucket boundaries"
600        );
601        assert_eq!(
602            result.err().unwrap().to_string(),
603            "Bucket boundaries must not contain NaN, Infinity, or -Infinity",
604            "Expected correct validation error for NaN"
605        );
606
607        // Test with infinity
608        let invalid_inf_boundaries = vec![1.0, 5.0, f64::INFINITY, 100.0];
609
610        let builder = StreamBuilder::new()
611            .with_name("invalid_histogram_inf")
612            .with_aggregation(Aggregation::ExplicitBucketHistogram {
613                boundaries: invalid_inf_boundaries,
614                record_min_max: true,
615            });
616
617        let result = builder.build();
618        assert!(
619            result.is_err(),
620            "Expected error for Infinity in bucket boundaries"
621        );
622        assert_eq!(
623            result.err().unwrap().to_string(),
624            "Bucket boundaries must not contain NaN, Infinity, or -Infinity",
625            "Expected correct validation error for Infinity"
626        );
627
628        // Test with negative infinity
629        let invalid_neg_inf_boundaries = vec![f64::NEG_INFINITY, 5.0, 10.0, 100.0];
630
631        let builder = StreamBuilder::new()
632            .with_name("invalid_histogram_neg_inf")
633            .with_aggregation(Aggregation::ExplicitBucketHistogram {
634                boundaries: invalid_neg_inf_boundaries,
635                record_min_max: true,
636            });
637
638        let result = builder.build();
639        assert!(
640            result.is_err(),
641            "Expected error for negative Infinity in bucket boundaries"
642        );
643        assert_eq!(
644            result.err().unwrap().to_string(),
645            "Bucket boundaries must not contain NaN, Infinity, or -Infinity",
646            "Expected correct validation error for negative Infinity"
647        );
648
649        // Test with unsorted bucket boundaries
650        let unsorted_boundaries = vec![1.0, 5.0, 2.0, 10.0]; // 2.0 comes after 5.0, which is incorrect
651
652        let builder = StreamBuilder::new()
653            .with_name("unsorted_histogram")
654            .with_aggregation(Aggregation::ExplicitBucketHistogram {
655                boundaries: unsorted_boundaries,
656                record_min_max: true,
657            });
658
659        let result = builder.build();
660        assert!(
661            result.is_err(),
662            "Expected error for unsorted bucket boundaries"
663        );
664        assert_eq!(
665            result.err().unwrap().to_string(),
666            "Bucket boundaries must be sorted and not contain any duplicates",
667            "Expected correct validation error for unsorted boundaries"
668        );
669
670        // Test with duplicate bucket boundaries
671        let duplicate_boundaries = vec![1.0, 2.0, 5.0, 5.0, 10.0]; // 5.0 appears twice
672
673        let builder = StreamBuilder::new()
674            .with_name("duplicate_histogram")
675            .with_aggregation(Aggregation::ExplicitBucketHistogram {
676                boundaries: duplicate_boundaries,
677                record_min_max: true,
678            });
679
680        let result = builder.build();
681        assert!(
682            result.is_err(),
683            "Expected error for duplicate bucket boundaries"
684        );
685        assert_eq!(
686            result.err().unwrap().to_string(),
687            "Bucket boundaries must be sorted and not contain any duplicates",
688            "Expected correct validation error for duplicate boundaries"
689        );
690    }
691
692    #[test]
693    fn stream_exponential_histogram_validation() {
694        use super::Aggregation;
695        use crate::metrics::internal::{EXPO_MAX_SCALE, EXPO_MIN_SCALE};
696
697        // Test with valid parameters
698        let builder = StreamBuilder::new()
699            .with_name("valid_expo_histogram")
700            .with_aggregation(Aggregation::Base2ExponentialHistogram {
701                max_size: 160,
702                max_scale: 10,
703                record_min_max: true,
704            });
705
706        let result = builder.build();
707        assert!(
708            result.is_ok(),
709            "Expected successful build with valid exponential histogram parameters"
710        );
711
712        // Test with max_size = 0 (invalid)
713        let builder = StreamBuilder::new()
714            .with_name("invalid_expo_histogram_size")
715            .with_aggregation(Aggregation::Base2ExponentialHistogram {
716                max_size: 0,
717                max_scale: 10,
718                record_min_max: true,
719            });
720
721        let result = builder.build();
722        assert!(result.is_err(), "Expected error for max_size = 0");
723        assert_eq!(
724            result.err().unwrap().to_string(),
725            "max_size must be greater than 0",
726            "Expected correct validation error for max_size = 0"
727        );
728
729        // Test with max_scale too high (invalid)
730        let builder = StreamBuilder::new()
731            .with_name("invalid_expo_histogram_scale_high")
732            .with_aggregation(Aggregation::Base2ExponentialHistogram {
733                max_size: 160,
734                max_scale: EXPO_MAX_SCALE + 1,
735                record_min_max: true,
736            });
737
738        let result = builder.build();
739        assert!(
740            result.is_err(),
741            "Expected error for max_scale > EXPO_MAX_SCALE"
742        );
743        assert_eq!(
744            result.err().unwrap().to_string(),
745            format!(
746                "max_scale must be between {} and {}",
747                EXPO_MIN_SCALE, EXPO_MAX_SCALE
748            ),
749            "Expected correct validation error for max_scale too high"
750        );
751
752        // Test with max_scale too low (invalid)
753        let builder = StreamBuilder::new()
754            .with_name("invalid_expo_histogram_scale_low")
755            .with_aggregation(Aggregation::Base2ExponentialHistogram {
756                max_size: 160,
757                max_scale: EXPO_MIN_SCALE - 1,
758                record_min_max: true,
759            });
760
761        let result = builder.build();
762        assert!(
763            result.is_err(),
764            "Expected error for max_scale < EXPO_MIN_SCALE"
765        );
766        assert_eq!(
767            result.err().unwrap().to_string(),
768            format!(
769                "max_scale must be between {} and {}",
770                EXPO_MIN_SCALE, EXPO_MAX_SCALE
771            ),
772            "Expected correct validation error for max_scale too low"
773        );
774
775        // Test with boundary values of max_scale (valid)
776        let builder = StreamBuilder::new()
777            .with_name("valid_expo_histogram_min_scale")
778            .with_aggregation(Aggregation::Base2ExponentialHistogram {
779                max_size: 160,
780                max_scale: EXPO_MIN_SCALE,
781                record_min_max: true,
782            });
783
784        let result = builder.build();
785        assert!(
786            result.is_ok(),
787            "Expected successful build with max_scale = EXPO_MIN_SCALE"
788        );
789
790        let builder = StreamBuilder::new()
791            .with_name("valid_expo_histogram_max_scale")
792            .with_aggregation(Aggregation::Base2ExponentialHistogram {
793                max_size: 160,
794                max_scale: EXPO_MAX_SCALE,
795                record_min_max: true,
796            });
797
798        let result = builder.build();
799        assert!(
800            result.is_ok(),
801            "Expected successful build with max_scale = EXPO_MAX_SCALE"
802        );
803    }
804}