1#![deny(missing_docs)]
2
3use std::{
10 borrow::Cow,
11 collections::{BTreeMap, BTreeSet},
12 future::Future,
13 sync::{Arc, LazyLock, Mutex},
14 time::{Duration, Instant},
15};
16
17use tracing::Instrument;
18
19#[cfg(feature = "events")]
20use nidus_events::{EventBus, EventObserver, ObservedEventBus, ObservedEventContext};
21#[cfg(feature = "http")]
22use nidus_http::middleware::{
23 ApiDefaults, HttpMetricsHook, MetricsLayer, PrometheusMetrics, metrics_layer,
24};
25#[cfg(feature = "jobs")]
26use nidus_jobs::{JobObserver, JobResultStatus, ObservedJobContext, ObservedJobRunner};
27
28#[derive(Clone, Debug)]
30pub struct Observability {
31 config: Arc<ObservabilityConfig>,
32 state: Arc<Mutex<ObservabilityState>>,
33 #[cfg(feature = "http")]
34 http_metrics: PrometheusMetrics,
35}
36
37impl Observability {
38 pub fn production(service_name: impl Into<String>) -> Self {
40 let service_name = service_name.into();
41 Self {
42 config: Arc::new(ObservabilityConfig {
43 service_name: service_name.clone(),
44 version: None,
45 environment: None,
46 prometheus: false,
47 http_metrics: true,
48 event_metrics: true,
49 job_metrics: true,
50 adapter_instrumentation: true,
51 tracing: false,
52 max_series: None,
53 excluded_routes: BTreeSet::new(),
54 #[cfg(feature = "otel")]
55 otel: None,
56 }),
57 state: Arc::new(Mutex::new(ObservabilityState::default())),
58 #[cfg(feature = "http")]
59 http_metrics: PrometheusMetrics::new(),
60 }
61 }
62
63 pub fn version(mut self, version: impl Into<String>) -> Self {
65 Arc::make_mut(&mut self.config).version = Some(version.into());
66 self
67 }
68
69 pub fn environment(mut self, environment: impl Into<String>) -> Self {
71 Arc::make_mut(&mut self.config).environment = Some(environment.into());
72 self
73 }
74
75 pub fn prometheus(mut self) -> Self {
77 Arc::make_mut(&mut self.config).prometheus = true;
78 self
79 }
80
81 pub fn tracing(mut self) -> Self {
83 Arc::make_mut(&mut self.config).tracing = true;
84 self
85 }
86
87 #[cfg(feature = "otel")]
92 pub fn otel_from_env(mut self) -> Self {
93 let mut config = nidus_http::otel::OtelConfig::new(self.service_name());
94 if let Some(version) = self.version_label() {
95 config = config.version(version);
96 }
97 if let Some(environment) = self.environment_label() {
98 config = config.environment(environment);
99 }
100 if let Ok(endpoint) = std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT") {
101 config = config.with_otlp_endpoint(endpoint);
102 }
103 Arc::make_mut(&mut self.config).otel = Some(config);
104 self
105 }
106
107 #[cfg(not(feature = "otel"))]
109 pub fn otel_from_env(self) -> Self {
110 self
111 }
112
113 pub fn without_http_metrics(mut self) -> Self {
115 Arc::make_mut(&mut self.config).http_metrics = false;
116 self
117 }
118
119 pub fn without_event_metrics(mut self) -> Self {
121 Arc::make_mut(&mut self.config).event_metrics = false;
122 self
123 }
124
125 pub fn without_job_metrics(mut self) -> Self {
127 Arc::make_mut(&mut self.config).job_metrics = false;
128 self
129 }
130
131 pub fn without_adapter_instrumentation(mut self) -> Self {
133 Arc::make_mut(&mut self.config).adapter_instrumentation = false;
134 self
135 }
136
137 pub fn max_series(mut self, max_series: usize) -> Self {
139 Arc::make_mut(&mut self.config).max_series = Some(max_series);
140 #[cfg(feature = "http")]
141 {
142 self.http_metrics = self.http_metrics.with_max_series(max_series);
143 }
144 self
145 }
146
147 pub fn exclude_route(mut self, route: impl Into<String>) -> Self {
149 let route = route.into();
150 Arc::make_mut(&mut self.config)
151 .excluded_routes
152 .insert(route.clone());
153 #[cfg(feature = "http")]
154 {
155 self.http_metrics = self.http_metrics.exclude_route(route);
156 }
157 self
158 }
159
160 pub fn service_name(&self) -> &str {
162 &self.config.service_name
163 }
164
165 pub fn version_label(&self) -> Option<&str> {
167 self.config.version.as_deref()
168 }
169
170 pub fn environment_label(&self) -> Option<&str> {
172 self.config.environment.as_deref()
173 }
174
175 pub fn http_metrics_enabled(&self) -> bool {
177 self.config.prometheus && self.config.http_metrics
178 }
179
180 pub fn prometheus_enabled(&self) -> bool {
182 self.config.prometheus
183 }
184
185 pub fn tracing_enabled(&self) -> bool {
187 self.config.tracing
188 }
189
190 #[cfg(feature = "otel")]
192 pub fn otel_config(&self) -> Option<&nidus_http::otel::OtelConfig> {
193 self.config.otel.as_ref()
194 }
195
196 #[cfg(feature = "http")]
198 pub fn prometheus_metrics(&self) -> PrometheusMetrics {
199 self.http_metrics.clone()
200 }
201
202 #[cfg(feature = "http")]
204 pub fn http_layer(&self) -> MetricsLayer<ObservabilityHttpMetricsHook> {
205 metrics_layer(self.http_metrics_hook())
206 }
207
208 #[cfg(feature = "http")]
210 pub fn routes(&self) -> nidus_http::Router {
211 if !self.config.prometheus {
212 return nidus_http::Router::new();
213 }
214 let observability = self.clone();
215 nidus_http::Router::new().route(
216 "/metrics",
217 axum::routing::get(move || {
218 let observability = observability.clone();
219 async move { observability.render_prometheus() }
220 }),
221 )
222 }
223
224 #[cfg(feature = "events")]
226 pub fn event_observer(&self) -> ObservabilityEventObserver {
227 ObservabilityEventObserver {
228 observability: self.clone(),
229 }
230 }
231
232 #[cfg(feature = "events")]
234 pub fn observed_event_bus<T>(&self) -> ObservedEventBus<T, ObservabilityEventObserver>
235 where
236 T: Clone + Send + Sync + 'static,
237 {
238 EventBus::new().observed(self.event_observer())
239 }
240
241 #[cfg(feature = "jobs")]
243 pub fn job_observer(&self) -> ObservabilityJobObserver {
244 ObservabilityJobObserver {
245 observability: self.clone(),
246 }
247 }
248
249 #[cfg(feature = "jobs")]
251 pub fn job_runner(&self) -> ObservedJobRunner<ObservabilityJobObserver> {
252 ObservedJobRunner::new(self.job_observer())
253 }
254
255 pub fn adapter_observer(&self) -> ObservabilityAdapterObserver {
257 ObservabilityAdapterObserver {
258 observability: self.clone(),
259 }
260 }
261
262 pub async fn instrument<Fut, T>(
264 &self,
265 operation: impl Into<Cow<'static, str>>,
266 future: Fut,
267 ) -> T
268 where
269 Fut: Future<Output = T>,
270 {
271 let operation = operation.into();
272 future
273 .instrument(tracing::info_span!(
274 "operation",
275 otel.name = %operation,
276 service.name = %self.service_name(),
277 service.version = self.version_label(),
278 deployment.environment = self.environment_label()
279 ))
280 .await
281 }
282
283 pub fn record_module_graph_validation(&self, status: OperationStatus, duration: Duration) {
285 let span = tracing::info_span!(
286 "module.graph.validate",
287 status = status.as_str(),
288 duration_ms = duration.as_millis()
289 );
290 let _entered = span.enter();
291 self.record_lifecycle_operation("module.graph.validate", status, duration);
292 }
293
294 pub fn record_lifecycle_operation(
296 &self,
297 operation: &'static str,
298 status: OperationStatus,
299 duration: Duration,
300 ) {
301 let mut state = lock_state(&self.state);
302 let operation = state.intern_label("lifecycle", operation, self.config.max_series);
303 let status = status.as_str();
304 *state
305 .lifecycle_total
306 .entry((Arc::clone(&operation), status))
307 .or_default() += 1;
308 state
309 .lifecycle_duration
310 .entry((operation, status))
311 .or_default()
312 .observe(duration);
313 }
314
315 pub fn render_prometheus(&self) -> String {
317 if !self.config.prometheus {
318 return String::new();
319 }
320 let mut output = String::new();
321 #[cfg(feature = "http")]
322 {
323 output.push_str(&self.http_metrics.render());
324 }
325 let state = lock_state(&self.state).clone();
326 output.push_str(&render_observability_metrics(&state));
327 output
328 }
329
330 #[cfg(feature = "http")]
331 fn http_metrics_hook(&self) -> ObservabilityHttpMetricsHook {
332 ObservabilityHttpMetricsHook {
333 enabled: self.http_metrics_enabled(),
334 metrics: self.http_metrics.clone(),
335 }
336 }
337
338 #[cfg(feature = "events")]
339 fn record_event(&self, event_name: &str) {
340 if !(self.config.prometheus && self.config.event_metrics) {
341 return;
342 }
343 let mut state = lock_state(&self.state);
344 let event_name = state.intern_label("events", event_name, self.config.max_series);
345 *state.events_published.entry(event_name).or_default() += 1;
346 }
347
348 #[cfg(feature = "jobs")]
349 fn record_job_started(&self, job_name: &'static str) {
350 if !(self.config.prometheus && self.config.job_metrics) {
351 return;
352 }
353 let mut state = lock_state(&self.state);
354 let job_name = state.intern_label("jobs", job_name, self.config.max_series);
355 *state.jobs_started.entry(job_name).or_default() += 1;
356 }
357
358 #[cfg(feature = "jobs")]
359 fn record_job_finished(
360 &self,
361 job_name: &'static str,
362 status: JobStatusLabel,
363 duration: Option<Duration>,
364 ) {
365 if !(self.config.prometheus && self.config.job_metrics) {
366 return;
367 }
368 let mut state = lock_state(&self.state);
369 let job_name = state.intern_label("jobs", job_name, self.config.max_series);
370 let status = status.as_str();
371 *state
372 .jobs_finished
373 .entry((Arc::clone(&job_name), status))
374 .or_default() += 1;
375 if let Some(duration) = duration {
376 state
377 .job_duration
378 .entry((job_name, status))
379 .or_default()
380 .observe(duration);
381 }
382 }
383
384 fn record_adapter(
385 &self,
386 adapter: &'static str,
387 operation: &'static str,
388 status: OperationStatus,
389 duration: Duration,
390 ) {
391 if !(self.config.prometheus && self.config.adapter_instrumentation) {
392 return;
393 }
394 let span = tracing::info_span!(
395 "adapter.operation",
396 adapter.name = adapter,
397 operation.name = operation,
398 status = status.as_str(),
399 duration_ms = duration.as_millis()
400 );
401 let _entered = span.enter();
402 let mut state = lock_state(&self.state);
403 let series = state.adapter_series(adapter, operation, self.config.max_series);
404 let status = status.as_str();
405 *state
406 .adapter_operations
407 .entry((series, status))
408 .or_default() += 1;
409 state
410 .adapter_duration
411 .entry((series, status))
412 .or_default()
413 .observe(duration);
414 }
415}
416
417#[cfg(feature = "http")]
419pub trait ApiDefaultsObservabilityExt {
420 fn observability(self, observability: &Observability) -> Self;
422
423 fn apply_with_observability(
425 self,
426 router: nidus_http::Router,
427 observability: &Observability,
428 ) -> nidus_http::Router;
429}
430
431#[cfg(feature = "http")]
432impl ApiDefaultsObservabilityExt for ApiDefaults {
433 fn observability(self, observability: &Observability) -> Self {
434 if observability.http_metrics_enabled() {
435 self.metrics(observability.prometheus_metrics())
436 } else {
437 self
438 }
439 }
440
441 fn apply_with_observability(
442 self,
443 router: nidus_http::Router,
444 observability: &Observability,
445 ) -> nidus_http::Router {
446 self.observability(observability)
447 .apply(router)
448 .merge(observability.routes())
449 }
450}
451
452#[derive(Clone, Debug)]
453struct ObservabilityConfig {
454 service_name: String,
455 version: Option<String>,
456 environment: Option<String>,
457 prometheus: bool,
458 http_metrics: bool,
459 event_metrics: bool,
460 job_metrics: bool,
461 adapter_instrumentation: bool,
462 tracing: bool,
463 max_series: Option<usize>,
464 excluded_routes: BTreeSet<String>,
465 #[cfg(feature = "otel")]
466 otel: Option<nidus_http::otel::OtelConfig>,
467}
468
469#[derive(Clone, Debug, Default)]
470struct ObservabilityState {
471 known_labels: BTreeMap<&'static str, BTreeSet<Arc<str>>>,
472 known_adapter_series: BTreeSet<AdapterSeries>,
473 events_published: BTreeMap<Arc<str>, u64>,
474 jobs_started: BTreeMap<Arc<str>, u64>,
475 jobs_finished: BTreeMap<(Arc<str>, &'static str), u64>,
476 job_duration: BTreeMap<(Arc<str>, &'static str), DurationHistogram>,
477 lifecycle_total: BTreeMap<(Arc<str>, &'static str), u64>,
478 lifecycle_duration: BTreeMap<(Arc<str>, &'static str), DurationHistogram>,
479 adapter_operations: BTreeMap<(AdapterSeries, &'static str), u64>,
480 adapter_duration: BTreeMap<(AdapterSeries, &'static str), DurationHistogram>,
481}
482
483static OVERFLOW_LABEL: LazyLock<Arc<str>> = LazyLock::new(|| Arc::from("<overflow>"));
484
485impl ObservabilityState {
486 fn intern_label(
487 &mut self,
488 family: &'static str,
489 label: &str,
490 max_series: Option<usize>,
491 ) -> Arc<str> {
492 let labels = self.known_labels.entry(family).or_default();
493 if let Some(label) = labels.get(label) {
494 return Arc::clone(label);
495 }
496 if max_series.is_some_and(|max| labels.len() >= max) {
497 return Arc::clone(&OVERFLOW_LABEL);
498 }
499
500 let label: Arc<str> = Arc::from(label);
501 labels.insert(Arc::clone(&label));
502 label
503 }
504
505 fn adapter_series(
506 &mut self,
507 adapter: &'static str,
508 operation: &'static str,
509 max_series: Option<usize>,
510 ) -> AdapterSeries {
511 let series = AdapterSeries { adapter, operation };
512 if self.known_adapter_series.contains(&series) {
513 return series;
514 }
515 if max_series.is_some_and(|max| self.known_adapter_series.len() >= max) {
516 return AdapterSeries::OVERFLOW;
517 }
518
519 self.known_adapter_series.insert(series);
520 series
521 }
522}
523
524#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
525struct AdapterSeries {
526 adapter: &'static str,
527 operation: &'static str,
528}
529
530impl AdapterSeries {
531 const OVERFLOW: Self = Self {
532 adapter: "<overflow>",
533 operation: "<overflow>",
534 };
535}
536
537#[cfg(feature = "http")]
539#[derive(Clone, Debug)]
540pub struct ObservabilityHttpMetricsHook {
541 enabled: bool,
542 metrics: PrometheusMetrics,
543}
544
545#[cfg(feature = "http")]
546impl HttpMetricsHook for ObservabilityHttpMetricsHook {
547 fn on_request(&self, method: &http::Method, route: Option<&str>) {
548 if self.enabled {
549 self.metrics.on_request(method, route);
550 }
551 }
552
553 fn on_response(
554 &self,
555 method: &http::Method,
556 route: Option<&str>,
557 status: http::StatusCode,
558 latency: Duration,
559 ) {
560 if self.enabled {
561 self.metrics.on_response(method, route, status, latency);
562 }
563 }
564
565 fn on_error(&self, method: &http::Method, route: Option<&str>, latency: Duration) {
566 if self.enabled {
567 self.metrics.on_error(method, route, latency);
568 }
569 }
570}
571
572#[cfg(feature = "events")]
574#[derive(Clone, Debug)]
575pub struct ObservabilityEventObserver {
576 observability: Observability,
577}
578
579#[cfg(feature = "events")]
580impl<T> EventObserver<T> for ObservabilityEventObserver
581where
582 T: Clone + Send + Sync + 'static,
583{
584 fn on_event_published(&self, context: &ObservedEventContext) {
585 let span = tracing::info_span!(
586 "event.publish",
587 event.name = context.event_name(),
588 event.operation_id = context.operation_id()
589 );
590 let _entered = span.enter();
591 self.observability.record_event(context.event_name());
592 }
593}
594
595#[cfg(feature = "jobs")]
597#[derive(Clone, Debug)]
598pub struct ObservabilityJobObserver {
599 observability: Observability,
600}
601
602#[cfg(feature = "jobs")]
603impl JobObserver for ObservabilityJobObserver {
604 fn on_job_started(&self, context: &ObservedJobContext) {
605 self.observability.record_job_started(context.job_name());
606 }
607
608 fn on_job_finished(&self, context: &ObservedJobContext, status: JobResultStatus) {
609 let status = match status {
610 JobResultStatus::Success => JobStatusLabel::Success,
611 JobResultStatus::Failure => JobStatusLabel::Failure,
612 };
613 self.observability
614 .record_job_finished(context.job_name(), status, context.duration());
615 }
616}
617
618#[derive(Clone, Debug)]
620pub struct ObservabilityAdapterObserver {
621 observability: Observability,
622}
623
624impl ObservabilityAdapterObserver {
625 pub fn record(
627 &self,
628 adapter: &'static str,
629 operation: &'static str,
630 status: OperationStatus,
631 duration: Duration,
632 ) {
633 self.observability
634 .record_adapter(adapter, operation, status, duration);
635 }
636
637 pub fn observe_result<T, E>(
639 &self,
640 adapter: &'static str,
641 operation: &'static str,
642 run: impl FnOnce() -> std::result::Result<T, E>,
643 ) -> std::result::Result<T, E> {
644 let started_at = Instant::now();
645 let result = run();
646 self.record(
647 adapter,
648 operation,
649 OperationStatus::from_success(result.is_ok()),
650 started_at.elapsed(),
651 );
652 result
653 }
654}
655
656#[derive(Clone, Copy, Debug, Eq, PartialEq)]
658pub enum OperationStatus {
659 Success,
661 Failure,
663}
664
665impl OperationStatus {
666 pub const fn as_str(self) -> &'static str {
668 match self {
669 Self::Success => "success",
670 Self::Failure => "failure",
671 }
672 }
673
674 pub const fn from_success(ok: bool) -> Self {
676 if ok { Self::Success } else { Self::Failure }
677 }
678}
679
680impl From<bool> for OperationStatus {
681 fn from(ok: bool) -> Self {
682 Self::from_success(ok)
683 }
684}
685
686#[cfg(feature = "jobs")]
687#[derive(Clone, Copy, Debug, Eq, PartialEq)]
688enum JobStatusLabel {
689 Success,
690 Failure,
691}
692
693#[cfg(feature = "jobs")]
694impl JobStatusLabel {
695 const fn as_str(self) -> &'static str {
696 match self {
697 Self::Success => "success",
698 Self::Failure => "failure",
699 }
700 }
701}
702
703#[derive(Clone, Debug, Default)]
704struct DurationHistogram {
705 count: u64,
706 sum: f64,
707 bucket_counts: [u64; DURATION_BUCKETS.len()],
708}
709
710impl DurationHistogram {
711 fn observe(&mut self, duration: Duration) {
712 let seconds = duration.as_secs_f64();
713 self.count += 1;
714 self.sum += seconds;
715 for (bucket, count) in DURATION_BUCKETS.iter().zip(self.bucket_counts.iter_mut()) {
716 if seconds <= *bucket {
717 *count += 1;
718 }
719 }
720 }
721}
722
723const DURATION_BUCKETS: [f64; 11] = [
724 0.005, 0.010, 0.025, 0.050, 0.100, 0.250, 0.500, 1.000, 2.500, 5.000, 10.000,
725];
726
727fn render_observability_metrics(state: &ObservabilityState) -> String {
728 let mut output = String::new();
729 output.push_str("# TYPE nidus_events_published_total counter\n");
730 for (event, count) in &state.events_published {
731 output.push_str(&format!(
732 "nidus_events_published_total{{event=\"{}\"}} {}\n",
733 escape_label(event),
734 count
735 ));
736 }
737 output.push_str("# TYPE nidus_jobs_started_total counter\n");
738 for (job, count) in &state.jobs_started {
739 output.push_str(&format!(
740 "nidus_jobs_started_total{{job=\"{}\"}} {}\n",
741 escape_label(job),
742 count
743 ));
744 }
745 output.push_str("# TYPE nidus_jobs_finished_total counter\n");
746 for ((job, status), count) in &state.jobs_finished {
747 output.push_str(&format!(
748 "nidus_jobs_finished_total{{job=\"{}\",status=\"{}\"}} {}\n",
749 escape_label(job),
750 escape_label(status),
751 count
752 ));
753 }
754 render_histogram(
755 &mut output,
756 "nidus_job_duration_seconds",
757 &["job", "status"],
758 state
759 .job_duration
760 .iter()
761 .map(|((job, status), histogram)| (vec![job.as_ref(), *status], histogram)),
762 );
763 output.push_str("# TYPE nidus_lifecycle_total counter\n");
764 for ((operation, status), count) in &state.lifecycle_total {
765 output.push_str(&format!(
766 "nidus_lifecycle_total{{operation=\"{}\",status=\"{}\"}} {}\n",
767 escape_label(operation),
768 escape_label(status),
769 count
770 ));
771 }
772 render_histogram(
773 &mut output,
774 "nidus_lifecycle_duration_seconds",
775 &["operation", "status"],
776 state
777 .lifecycle_duration
778 .iter()
779 .map(|((operation, status), histogram)| (vec![operation.as_ref(), *status], histogram)),
780 );
781 output.push_str("# TYPE nidus_adapter_operations_total counter\n");
782 for ((series, status), count) in &state.adapter_operations {
783 output.push_str(&format!(
784 "nidus_adapter_operations_total{{adapter=\"{}\",operation=\"{}\",status=\"{}\"}} {}\n",
785 escape_label(series.adapter),
786 escape_label(series.operation),
787 escape_label(status),
788 count
789 ));
790 }
791 render_histogram(
792 &mut output,
793 "nidus_adapter_operation_duration_seconds",
794 &["adapter", "operation", "status"],
795 state
796 .adapter_duration
797 .iter()
798 .map(|((series, status), histogram)| {
799 (vec![series.adapter, series.operation, *status], histogram)
800 }),
801 );
802 output
803}
804
805fn render_histogram<'a>(
806 output: &mut String,
807 name: &str,
808 label_names: &[&str],
809 histograms: impl Iterator<Item = (Vec<&'a str>, &'a DurationHistogram)>,
810) {
811 output.push_str(&format!("# TYPE {name} histogram\n"));
812 for (label_values, histogram) in histograms {
813 for (bucket, count) in DURATION_BUCKETS.iter().zip(histogram.bucket_counts.iter()) {
814 output.push_str(&format!(
815 "{name}_bucket{{{},le=\"{}\"}} {}\n",
816 render_labels(label_names, &label_values),
817 format_bucket(*bucket),
818 count
819 ));
820 }
821 output.push_str(&format!(
822 "{name}_bucket{{{},le=\"+Inf\"}} {}\n",
823 render_labels(label_names, &label_values),
824 histogram.count
825 ));
826 output.push_str(&format!(
827 "{name}_count{{{}}} {}\n",
828 render_labels(label_names, &label_values),
829 histogram.count
830 ));
831 output.push_str(&format!(
832 "{name}_sum{{{}}} {:.6}\n",
833 render_labels(label_names, &label_values),
834 histogram.sum
835 ));
836 }
837}
838
839fn render_labels(names: &[&str], values: &[&str]) -> String {
840 names
841 .iter()
842 .zip(values.iter())
843 .map(|(name, value)| format!("{name}=\"{}\"", escape_label(value)))
844 .collect::<Vec<_>>()
845 .join(",")
846}
847
848fn format_bucket(bucket: f64) -> String {
849 format!("{bucket:.3}")
850}
851
852fn escape_label(value: &str) -> String {
853 value
854 .replace('\\', r"\\")
855 .replace('\n', r"\n")
856 .replace('"', r#"\""#)
857}
858
859fn lock_state(state: &Mutex<ObservabilityState>) -> std::sync::MutexGuard<'_, ObservabilityState> {
860 state
861 .lock()
862 .unwrap_or_else(|poisoned| poisoned.into_inner())
863}
864
865#[cfg(test)]
866mod tests {
867 use std::sync::Arc;
868
869 use super::{AdapterSeries, ObservabilityState};
870
871 #[test]
872 fn state_reuses_interned_and_overflow_labels() {
873 let mut state = ObservabilityState::default();
874 let first = state.intern_label("events", "orders.created", None);
875 let repeated = state.intern_label("events", "orders.created", None);
876 assert!(Arc::ptr_eq(&first, &repeated));
877
878 let first_overflow = state.intern_label("events", "orders.updated", Some(1));
879 let second_overflow = state.intern_label("events", "orders.deleted", Some(1));
880 assert_eq!(&*first_overflow, "<overflow>");
881 assert!(Arc::ptr_eq(&first_overflow, &second_overflow));
882 }
883
884 #[test]
885 fn adapter_series_preserve_label_boundaries_and_share_overflow() {
886 let mut state = ObservabilityState::default();
887 let first = state.adapter_series("adapter:primary", "get", Some(2));
888 let second = state.adapter_series("adapter", "primary:get", Some(2));
889 assert_ne!(first, second);
890 assert_eq!(
891 state.adapter_series("adapter:primary", "get", Some(2)),
892 first
893 );
894
895 let overflow = state.adapter_series("adapter", "set", Some(2));
896 assert_eq!(overflow, AdapterSeries::OVERFLOW);
897 }
898}