Skip to main content

aster_forge_metrics/
provider.rs

1//! Explicit instance ownership, atomic schema registration and bounded collection.
2
3use crate::{MetricDescriptor, MetricError, MetricHandle, MetricKind, MetricResult};
4use opentelemetry::metrics::{Meter, MeterProvider};
5use opentelemetry_sdk::{Resource, metrics::SdkMeterProvider};
6use parking_lot::Mutex;
7use std::{
8    collections::{BTreeMap, BTreeSet},
9    sync::{
10        Arc,
11        atomic::{AtomicBool, AtomicUsize, Ordering},
12    },
13    time::Duration,
14};
15use tokio::sync::Semaphore;
16
17/// Limits apply to each instance and cannot be overridden by SDK environment variables.
18#[derive(Clone, Debug)]
19pub struct MetricsLimits {
20    pub max_instruments: usize,
21    pub max_series_per_instrument: usize,
22    pub max_label_bytes: usize,
23    pub max_total_series: usize,
24    pub max_exposition_bytes: usize,
25}
26
27impl Default for MetricsLimits {
28    fn default() -> Self {
29        Self {
30            max_instruments: 512,
31            max_series_per_instrument: 4096,
32            max_label_bytes: 256,
33            max_total_series: 16384,
34            max_exposition_bytes: 16 * 1024 * 1024,
35        }
36    }
37}
38
39/// Bounded deployment identity, separate from instrument labels and product IDs.
40#[derive(Clone, Debug, Default)]
41pub struct MetricsResource {
42    pub service_name: String,
43    pub service_version: String,
44    pub environment: String,
45    pub role: String,
46    pub instance_id: String,
47}
48
49/// Builder with independent pull and push capabilities. No global SDK provider is installed.
50pub struct MetricsProviderBuilder {
51    pub(crate) sdk: opentelemetry_sdk::metrics::MeterProviderBuilder,
52    limits: MetricsLimits,
53    pub(crate) enabled: bool,
54    #[cfg(feature = "backend-prometheus")]
55    registry: Option<prometheus::Registry>,
56    #[cfg(feature = "otlp")]
57    pub(crate) otlp_diagnostics: Option<crate::OtlpExportDiagnostics>,
58}
59
60impl MetricsProviderBuilder {
61    #[must_use]
62    pub fn with_limits(mut self, limits: MetricsLimits) -> Self {
63        self.limits = limits;
64        self
65    }
66
67    /// Explicit resource attributes only; SDK environment resource detectors are excluded.
68    /// # Errors
69    /// Returns a redacted error for an empty service name or oversized/control-character attributes.
70    pub fn with_resource(mut self, resource: MetricsResource) -> MetricResult<Self> {
71        if resource.service_name.is_empty() {
72            return Err(MetricError::InvalidConfiguration);
73        }
74        let mut attributes = Vec::new();
75        for (key, value) in [
76            ("service.name", resource.service_name),
77            ("service.version", resource.service_version),
78            ("deployment.environment.name", resource.environment),
79            ("aster.role", resource.role),
80            ("service.instance.id", resource.instance_id),
81        ] {
82            if value.len() > 128 || value.chars().any(char::is_control) {
83                return Err(MetricError::InvalidConfiguration);
84            }
85            if !value.is_empty() {
86                attributes.push(opentelemetry::KeyValue::new(key, value));
87            }
88        }
89        self.sdk = self.sdk.with_resource(
90            Resource::builder_empty()
91                .with_attributes(attributes)
92                .build(),
93        );
94        Ok(self)
95    }
96
97    #[cfg(feature = "backend-prometheus")]
98    /// # Errors
99    /// Returns an error if pull export is already configured or its reader cannot be created.
100    pub fn with_prometheus(mut self) -> MetricResult<Self> {
101        if self.registry.is_some() {
102            return Err(MetricError::InvalidConfiguration);
103        }
104        let registry = prometheus::Registry::new();
105        let reader = opentelemetry_prometheus::exporter()
106            .with_registry(registry.clone())
107            .without_units()
108            .without_counter_suffixes()
109            .without_target_info()
110            .with_resource_selector(opentelemetry_prometheus::ResourceSelector::All)
111            .scope_info_enabled(false)
112            .build()
113            .map_err(|_| MetricError::Initialization)?;
114        self.sdk = self.sdk.with_reader(reader);
115        self.registry = Some(registry);
116        self.enabled = true;
117        Ok(self)
118    }
119
120    /// Reader injection also permits deterministic tests and custom exporters.
121    #[must_use]
122    pub fn with_reader<R: opentelemetry_sdk::metrics::reader::MetricReader>(
123        mut self,
124        reader: R,
125    ) -> Self {
126        self.sdk = self.sdk.with_reader(reader);
127        self.enabled = true;
128        self
129    }
130
131    /// # Errors
132    /// Returns an error if the resource limits are invalid.
133    pub fn build(self) -> MetricResult<MetricsProvider> {
134        if self.limits.max_instruments == 0
135            || self.limits.max_series_per_instrument == 0
136            || self.limits.max_label_bytes == 0
137            || self.limits.max_total_series == 0
138            || self.limits.max_total_series > 1_048_576
139            || self.limits.max_exposition_bytes == 0
140            || self.limits.max_instruments > 4096
141            || self.limits.max_series_per_instrument > 65536
142            || self.limits.max_label_bytes > 4096
143            || self.limits.max_exposition_bytes > 64 * 1024 * 1024
144        {
145            return Err(MetricError::InvalidConfiguration);
146        }
147        let limit = self.limits.max_series_per_instrument;
148        let sdk = self
149            .sdk
150            .with_view(move |_| {
151                opentelemetry_sdk::metrics::Stream::builder()
152                    .with_cardinality_limit(limit)
153                    .build()
154                    .ok()
155            })
156            .build();
157        let meter = sdk.meter("aster_forge");
158        Ok(MetricsProvider {
159            inner: Arc::new(ProviderInner {
160                sdk,
161                meter,
162                enabled: self.enabled,
163                closed: Arc::new(AtomicBool::new(false)),
164                limits: self.limits,
165                descriptors: Mutex::new(BTreeMap::new()),
166                series_count: Arc::new(AtomicUsize::new(0)),
167                #[cfg(feature = "backend-prometheus")]
168                registry: self.registry,
169                #[cfg(feature = "otlp")]
170                otlp_diagnostics: self.otlp_diagnostics,
171                #[cfg(feature = "backend-prometheus")]
172                gather_limit: Arc::new(Semaphore::new(2)),
173                lifecycle_limit: Arc::new(Semaphore::new(1)),
174                shutdown_result: Arc::new(Mutex::new(None)),
175            }),
176        })
177    }
178}
179
180struct ProviderInner {
181    sdk: SdkMeterProvider,
182    meter: Meter,
183    enabled: bool,
184    closed: Arc<AtomicBool>,
185    limits: MetricsLimits,
186    descriptors: Mutex<BTreeMap<String, MetricDescriptor>>,
187    series_count: Arc<AtomicUsize>,
188    #[cfg(feature = "backend-prometheus")]
189    registry: Option<prometheus::Registry>,
190    #[cfg(feature = "otlp")]
191    otlp_diagnostics: Option<crate::OtlpExportDiagnostics>,
192    #[cfg(feature = "backend-prometheus")]
193    gather_limit: Arc<Semaphore>,
194    lifecycle_limit: Arc<Semaphore>,
195    shutdown_result: Arc<Mutex<Option<MetricResult<()>>>>,
196}
197
198/// Clones share one instance; instruments always remain bound to their original instance.
199#[derive(Clone)]
200pub struct MetricsProvider {
201    inner: Arc<ProviderInner>,
202}
203
204impl MetricsProvider {
205    #[must_use]
206    pub fn builder() -> MetricsProviderBuilder {
207        MetricsProviderBuilder {
208            sdk: SdkMeterProvider::builder().with_resource(Resource::builder_empty().build()),
209            limits: MetricsLimits::default(),
210            enabled: false,
211            #[cfg(feature = "backend-prometheus")]
212            registry: None,
213            #[cfg(feature = "otlp")]
214            otlp_diagnostics: None,
215        }
216    }
217
218    #[must_use]
219    pub fn enabled(&self) -> bool {
220        self.inner.enabled && !self.inner.closed.load(Ordering::Acquire)
221    }
222
223    /// Returns local push-export diagnostics without invoking the exporter.
224    #[cfg(feature = "otlp")]
225    #[must_use]
226    pub fn otlp_diagnostics(&self) -> Option<crate::OtlpExportDiagnostics> {
227        self.inner.otlp_diagnostics.clone()
228    }
229
230    /// Validate the complete batch and reserve every derived exposition name before
231    /// constructing SDK instruments. Failure leaves this instance unchanged.
232    /// # Errors
233    /// Returns an error for a closed instance, invalid descriptors, duplicate names or instrument limits. The catalog is unchanged on failure.
234    pub fn register_batch(
235        &self,
236        descriptors: &[MetricDescriptor],
237    ) -> MetricResult<Vec<MetricHandle>> {
238        if self.inner.closed.load(Ordering::Acquire) {
239            return Err(MetricError::Closed);
240        }
241        let mut catalog = self.inner.descriptors.lock();
242        if catalog.len().saturating_add(descriptors.len()) > self.inner.limits.max_instruments {
243            return Err(MetricError::InstrumentLimit);
244        }
245        let mut names = BTreeSet::new();
246        for descriptor in catalog.values() {
247            names.extend(exported_names(descriptor));
248        }
249        for descriptor in descriptors {
250            validate(descriptor)?;
251            for name in exported_names(descriptor) {
252                if !names.insert(name) {
253                    return Err(MetricError::DuplicateRegistration);
254                }
255            }
256        }
257        let handles = descriptors
258            .iter()
259            .map(|descriptor| {
260                MetricHandle::new(
261                    *descriptor,
262                    &self.inner.meter,
263                    self.inner.enabled,
264                    self.inner.closed.clone(),
265                    self.inner.limits.clone(),
266                    self.inner.series_count.clone(),
267                )
268            })
269            .collect();
270        for descriptor in descriptors {
271            catalog.insert(metric_name(descriptor), *descriptor);
272        }
273        Ok(handles)
274    }
275
276    #[must_use]
277    pub fn descriptors(&self) -> Vec<MetricDescriptor> {
278        self.inner.descriptors.lock().values().copied().collect()
279    }
280
281    /// Prometheus export is blocking work with retained concurrency permits. A
282    /// timed-out caller cannot start unlimited gathers that are still running.
283    #[cfg(feature = "backend-prometheus")]
284    /// # Errors
285    /// Returns an error for unavailable or closed providers, concurrent overload, timeout, encoding failure or oversized output.
286    pub async fn export_prometheus(&self, timeout: Duration) -> MetricResult<String> {
287        use prometheus::Encoder;
288        let registry = self
289            .inner
290            .registry
291            .clone()
292            .ok_or(MetricError::Unavailable)?;
293        if self.inner.closed.load(Ordering::Acquire) {
294            return Err(MetricError::Closed);
295        }
296        let permit = self
297            .inner
298            .gather_limit
299            .clone()
300            .try_acquire_owned()
301            .map_err(|_| MetricError::Busy)?;
302        let max_bytes = self.inner.limits.max_exposition_bytes;
303        let task = tokio::task::spawn_blocking(move || {
304            let _permit = permit;
305            let mut buffer = LimitedBuffer {
306                bytes: Vec::new(),
307                remaining: max_bytes,
308            };
309            prometheus::TextEncoder::new()
310                .encode(&registry.gather(), &mut buffer)
311                .map_err(|_| MetricError::Export)?;
312            String::from_utf8(buffer.bytes).map_err(|_| MetricError::Export)
313        });
314        tokio::time::timeout(timeout, task)
315            .await
316            .map_err(|_| MetricError::Timeout)?
317            .map_err(|_| MetricError::Export)?
318    }
319
320    /// Idempotent shutdown. If the caller times out, the job retains its permit;
321    /// callers can poll the actual result with `shutdown_result`.
322    /// # Errors
323    /// Returns an error on concurrent lifecycle operations, timeout or SDK shutdown failure.
324    pub async fn shutdown(&self, timeout: Duration) -> MetricResult<()> {
325        if let Some(result) = *self.inner.shutdown_result.lock() {
326            return result;
327        }
328        let permit = self
329            .inner
330            .lifecycle_limit
331            .clone()
332            .try_acquire_owned()
333            .map_err(|_| MetricError::Busy)?;
334        self.inner.closed.store(true, Ordering::Release);
335        let sdk = self.inner.sdk.clone();
336        let result_slot = self.inner.shutdown_result.clone();
337        let task = tokio::task::spawn_blocking(move || {
338            let _permit = permit;
339            let result = sdk
340                .shutdown_with_timeout(timeout)
341                .map_err(|_| MetricError::Shutdown);
342            *result_slot.lock() = Some(result);
343            result
344        });
345        tokio::time::timeout(timeout, task)
346            .await
347            .map_err(|_| MetricError::Timeout)?
348            .map_err(|_| MetricError::Shutdown)?
349    }
350
351    #[must_use]
352    pub fn shutdown_result(&self) -> Option<MetricResult<()>> {
353        *self.inner.shutdown_result.lock()
354    }
355
356    /// # Errors
357    /// Returns an error for closed providers, concurrent lifecycle operations, timeout or SDK export failure.
358    pub async fn force_flush(&self, timeout: Duration) -> MetricResult<()> {
359        if self.inner.closed.load(Ordering::Acquire) {
360            return Err(MetricError::Closed);
361        }
362        let permit = self
363            .inner
364            .lifecycle_limit
365            .clone()
366            .try_acquire_owned()
367            .map_err(|_| MetricError::Busy)?;
368        let sdk = self.inner.sdk.clone();
369        let task = tokio::task::spawn_blocking(move || {
370            let _permit = permit;
371            sdk.force_flush().map_err(|_| MetricError::Export)
372        });
373        tokio::time::timeout(timeout, task)
374            .await
375            .map_err(|_| MetricError::Timeout)?
376            .map_err(|_| MetricError::Export)?
377    }
378}
379
380pub(crate) fn metric_name(descriptor: &MetricDescriptor) -> String {
381    if descriptor.subsystem.is_empty() {
382        descriptor.name.to_owned()
383    } else {
384        format!("{}_{}", descriptor.subsystem, descriptor.name)
385    }
386}
387
388fn exported_names(descriptor: &MetricDescriptor) -> Vec<String> {
389    let name = metric_name(descriptor);
390    if descriptor.kind == MetricKind::Histogram {
391        vec![
392            name.clone(),
393            format!("{name}_bucket"),
394            format!("{name}_sum"),
395            format!("{name}_count"),
396        ]
397    } else {
398        vec![name]
399    }
400}
401
402fn validate(descriptor: &MetricDescriptor) -> MetricResult<()> {
403    let name = metric_name(descriptor);
404    let valid_name = |value: &str| {
405        value
406            .as_bytes()
407            .first()
408            .is_some_and(u8::is_ascii_alphabetic)
409            && value.len() <= 255
410            && value
411                .bytes()
412                .all(|c| c.is_ascii_alphanumeric() || c == b'_')
413    };
414    if !valid_name(&name)
415        || descriptor.help.is_empty()
416        || descriptor.labels.len() > 8
417        || descriptor.unit.is_empty()
418        || descriptor.unit.len() > 32
419        || !descriptor.unit.bytes().all(|b| b.is_ascii_graphic())
420    {
421        return Err(MetricError::InvalidDescriptor);
422    }
423    let mut labels = BTreeSet::new();
424    for label in descriptor.labels {
425        if !valid_name(label) || *label == "le" || !labels.insert(label) {
426            return Err(MetricError::InvalidDescriptor);
427        }
428    }
429    if (descriptor.kind != MetricKind::Histogram && !descriptor.buckets.is_empty())
430        || descriptor.buckets.len() > 64
431        || descriptor
432            .buckets
433            .iter()
434            .any(|value| !value.is_finite() || *value < 0.0)
435        || descriptor.buckets.windows(2).any(|pair| pair[0] >= pair[1])
436    {
437        return Err(MetricError::InvalidDescriptor);
438    }
439    Ok(())
440}
441
442#[cfg(feature = "backend-prometheus")]
443struct LimitedBuffer {
444    bytes: Vec<u8>,
445    remaining: usize,
446}
447
448#[cfg(feature = "backend-prometheus")]
449impl std::io::Write for LimitedBuffer {
450    fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
451        if bytes.len() > self.remaining {
452            return Err(std::io::Error::other("exposition limit"));
453        }
454        self.bytes.extend_from_slice(bytes);
455        self.remaining -= bytes.len();
456        Ok(bytes.len())
457    }
458    fn flush(&mut self) -> std::io::Result<()> {
459        Ok(())
460    }
461}
462
463#[cfg(all(test, feature = "backend-prometheus"))]
464mod collection_tests {
465    use super::*;
466    use prometheus::core::Collector;
467    use std::sync::{Condvar, Mutex as StdMutex};
468
469    #[derive(Default)]
470    struct Gate {
471        released: StdMutex<bool>,
472        ready: Condvar,
473    }
474    impl Gate {
475        fn release(&self) {
476            *self.released.lock().unwrap() = true;
477            self.ready.notify_all();
478        }
479    }
480    struct ReleaseOnDrop(Arc<Gate>);
481    impl Drop for ReleaseOnDrop {
482        fn drop(&mut self) {
483            self.0.release();
484        }
485    }
486    struct SlowCollector {
487        gauge: prometheus::Gauge,
488        gate: Arc<Gate>,
489    }
490    impl Collector for SlowCollector {
491        fn desc(&self) -> Vec<&prometheus::core::Desc> {
492            self.gauge.desc()
493        }
494        fn collect(&self) -> Vec<prometheus::proto::MetricFamily> {
495            let mut released = self.gate.released.lock().unwrap();
496            while !*released {
497                released = self.gate.ready.wait(released).unwrap();
498            }
499            self.gauge.collect()
500        }
501    }
502
503    #[tokio::test]
504    async fn timed_out_gathers_keep_actual_work_credits_until_completion() {
505        let provider = MetricsProvider::builder()
506            .with_prometheus()
507            .unwrap()
508            .build()
509            .unwrap();
510        let gate = Arc::new(Gate::default());
511        let _release = ReleaseOnDrop(gate.clone());
512        provider
513            .inner
514            .registry
515            .as_ref()
516            .unwrap()
517            .register(Box::new(SlowCollector {
518                gauge: prometheus::Gauge::new("test_slow", "Slow test collector.").unwrap(),
519                gate: gate.clone(),
520            }))
521            .unwrap();
522        for _ in 0..2 {
523            assert_eq!(
524                provider.export_prometheus(Duration::from_millis(10)).await,
525                Err(MetricError::Timeout)
526            );
527        }
528        assert_eq!(provider.inner.gather_limit.available_permits(), 0);
529        assert_eq!(
530            provider.export_prometheus(Duration::from_secs(1)).await,
531            Err(MetricError::Busy)
532        );
533        gate.release();
534        tokio::time::timeout(Duration::from_secs(1), async {
535            while provider.inner.gather_limit.available_permits() != 2 {
536                tokio::task::yield_now().await;
537            }
538        })
539        .await
540        .unwrap();
541        assert!(
542            provider
543                .export_prometheus(Duration::from_secs(1))
544                .await
545                .unwrap()
546                .contains("test_slow")
547        );
548        provider.shutdown(Duration::from_secs(1)).await.unwrap();
549    }
550}
551
552#[cfg(test)]
553mod lifecycle_tests {
554    use super::*;
555    use opentelemetry_sdk::{
556        error::OTelSdkResult,
557        metrics::{
558            InstrumentKind, Pipeline, Temporality, data::ResourceMetrics, reader::MetricReader,
559        },
560    };
561    use std::sync::{Condvar, Mutex as StdMutex, Weak};
562
563    #[derive(Debug, Default)]
564    struct Gate(StdMutex<bool>, Condvar);
565    impl Gate {
566        fn wait(&self) {
567            let mut released = self.0.lock().unwrap();
568            while !*released {
569                released = self.1.wait(released).unwrap();
570            }
571        }
572        fn release(&self) {
573            *self.0.lock().unwrap() = true;
574            self.1.notify_all();
575        }
576    }
577    struct ReleaseOnDrop(Arc<Gate>, Arc<Gate>);
578    impl Drop for ReleaseOnDrop {
579        fn drop(&mut self) {
580            self.0.release();
581            self.1.release();
582        }
583    }
584    #[derive(Debug)]
585    struct SlowReader {
586        flush: Arc<Gate>,
587        stop: Arc<Gate>,
588    }
589    impl MetricReader for SlowReader {
590        fn register_pipeline(&self, _: Weak<Pipeline>) {}
591        fn collect(&self, _: &mut ResourceMetrics) -> OTelSdkResult {
592            Ok(())
593        }
594        fn force_flush(&self) -> OTelSdkResult {
595            self.flush.wait();
596            Ok(())
597        }
598        fn shutdown_with_timeout(&self, _: Duration) -> OTelSdkResult {
599            self.stop.wait();
600            Ok(())
601        }
602        fn temporality(&self, _: InstrumentKind) -> Temporality {
603            Temporality::Cumulative
604        }
605    }
606    #[tokio::test]
607    async fn lifecycle_timeouts_retain_work_permit_and_report_actual_shutdown_completion() {
608        let flush = Arc::new(Gate::default());
609        let stop = Arc::new(Gate::default());
610        let _release = ReleaseOnDrop(flush.clone(), stop.clone());
611        let provider = MetricsProvider::builder()
612            .with_reader(SlowReader {
613                flush: flush.clone(),
614                stop: stop.clone(),
615            })
616            .build()
617            .unwrap();
618        let handle = provider
619            .register_batch(&[MetricDescriptor::counter("test", "events", "Events.", &[])])
620            .unwrap()
621            .remove(0);
622        let counter = crate::ProductCounter::from_handle(handle).unwrap();
623        assert_eq!(
624            provider.force_flush(Duration::from_millis(10)).await,
625            Err(MetricError::Timeout)
626        );
627        assert_eq!(
628            provider.force_flush(Duration::from_secs(1)).await,
629            Err(MetricError::Busy)
630        );
631        assert_eq!(
632            provider.shutdown(Duration::from_secs(1)).await,
633            Err(MetricError::Busy)
634        );
635        assert!(
636            counter.try_inc(&[], 1).is_ok(),
637            "busy shutdown must not close the provider"
638        );
639        flush.release();
640        tokio::time::timeout(Duration::from_secs(1), async {
641            while provider.inner.lifecycle_limit.available_permits() == 0 {
642                tokio::task::yield_now().await;
643            }
644        })
645        .await
646        .unwrap();
647        assert_eq!(
648            provider.shutdown(Duration::from_millis(10)).await,
649            Err(MetricError::Timeout)
650        );
651        assert_eq!(provider.shutdown_result(), None);
652        assert_eq!(counter.try_inc(&[], 1), Err(MetricError::Closed));
653        assert_eq!(
654            provider.shutdown(Duration::from_secs(1)).await,
655            Err(MetricError::Busy)
656        );
657        stop.release();
658        tokio::time::timeout(Duration::from_secs(1), async {
659            while provider.shutdown_result().is_none() {
660                tokio::task::yield_now().await;
661            }
662        })
663        .await
664        .unwrap();
665        assert_eq!(provider.shutdown_result(), Some(Ok(())));
666        assert_eq!(provider.shutdown(Duration::from_secs(1)).await, Ok(()));
667    }
668}