1use crate::config::environment_names::logging::otlp as env_otlp;
15use crate::metrics::MetricsRegistry;
16use opentelemetry_proto::tonic::collector::metrics::v1::ExportMetricsServiceRequest;
17use opentelemetry_proto::tonic::collector::metrics::v1::metrics_service_client::MetricsServiceClient;
18use opentelemetry_proto::tonic::common::v1::{AnyValue, InstrumentationScope, KeyValue, any_value};
19use opentelemetry_proto::tonic::metrics::v1::{
20 AggregationTemporality, DataPointFlags, Gauge, Histogram, HistogramDataPoint, Metric,
21 NumberDataPoint, ResourceMetrics, ScopeMetrics, Sum, Summary, SummaryDataPoint, metric,
22 number_data_point, summary_data_point::ValueAtQuantile,
23};
24use opentelemetry_proto::tonic::resource::v1::Resource;
25use prometheus::proto::{MetricFamily, MetricType};
26use std::collections::HashMap;
27use std::time::{Duration, SystemTime, UNIX_EPOCH};
28use tokio_util::sync::CancellationToken;
29
30const SCOPE_NAME: &str = "dynamo.runtime.metrics";
31
32pub fn to_resource_metrics(
37 collected: &crate::metrics::CollectedFamilies,
38 resource_attrs: &[KeyValue],
39 start_time: SystemTime,
40) -> ResourceMetrics {
41 let families = &collected.families;
42 let now = unix_nanos(SystemTime::now());
43 let start = unix_nanos(start_time);
44
45 let created: HashMap<(String, Vec<(String, String)>), u64> = families
50 .iter()
51 .filter(|f| f.name().ends_with("_created"))
52 .flat_map(|f| {
53 let parent = f.name().trim_end_matches("_created").to_string();
54 f.get_metric().iter().map(move |m| {
55 (
56 (parent.clone(), label_key(m)),
57 (m.get_gauge().value() * 1_000_000_000.0) as u64,
58 )
59 })
60 })
61 .collect();
62
63 let metrics = families
64 .iter()
65 .filter(|family| !family.name().ends_with("_created"))
66 .filter_map(|family| {
67 to_metric(
68 family,
69 start,
70 now,
71 &created,
72 &collected.units,
73 &collected.prom_types,
74 )
75 })
76 .collect();
77
78 ResourceMetrics {
79 resource: Some(Resource {
80 attributes: resource_attrs.to_vec(),
81 dropped_attributes_count: 0,
82 entity_refs: Vec::new(),
83 }),
84 scope_metrics: vec![ScopeMetrics {
85 scope: Some(InstrumentationScope {
86 name: SCOPE_NAME.to_string(),
87 version: env!("CARGO_PKG_VERSION").to_string(),
88 attributes: Vec::new(),
89 dropped_attributes_count: 0,
90 }),
91 metrics,
92 schema_url: String::new(),
93 }],
94 schema_url: String::new(),
95 }
96}
97
98fn label_key(metric: &prometheus::proto::Metric) -> Vec<(String, String)> {
101 let mut labels: Vec<(String, String)> = metric
102 .get_label()
103 .iter()
104 .map(|l| (l.name().to_string(), l.value().to_string()))
105 .collect();
106 labels.sort();
107 labels
108}
109
110type CreatedTimes = HashMap<(String, Vec<(String, String)>), u64>;
111
112fn ucum(word: &str) -> &str {
118 match word {
119 "days" => "d",
120 "hours" => "h",
121 "minutes" => "min",
122 "seconds" => "s",
123 "milliseconds" => "ms",
124 "microseconds" => "us",
125 "nanoseconds" => "ns",
126 "bytes" => "By",
127 "kibibytes" => "KiBy",
128 "mebibytes" => "MiBy",
129 "gibibytes" => "GiBy",
130 "tebibytes" => "TiBy",
131 "kilobytes" => "kBy",
132 "megabytes" => "MBy",
133 "gigabytes" => "GBy",
134 "terabytes" => "TBy",
135 "meters" => "m",
136 "volts" => "V",
137 "amperes" => "A",
138 "joules" => "J",
139 "watts" => "W",
140 "grams" => "g",
141 "celsius" => "Cel",
142 "hertz" => "Hz",
143 "percent" => "%",
144 other => other,
145 }
146}
147
148fn unit_for(family: &MetricFamily, units: &HashMap<String, String>) -> String {
150 units
151 .get(family.name())
152 .map(|word| ucum(word).to_string())
153 .unwrap_or_default()
154}
155
156fn type_metadata(prom_type: &str) -> KeyValue {
159 KeyValue {
160 key: "prometheus.type".to_string(),
161 value: Some(AnyValue {
162 value: Some(any_value::Value::StringValue(prom_type.to_string())),
163 }),
164 }
165}
166
167fn prometheus_type_name(family: &MetricFamily) -> &'static str {
169 match family.get_field_type() {
170 MetricType::COUNTER => "counter",
171 MetricType::GAUGE => "gauge",
172 MetricType::HISTOGRAM => "histogram",
173 MetricType::SUMMARY => "summary",
174 MetricType::UNTYPED => "unknown",
175 }
176}
177
178fn to_metric(
179 family: &MetricFamily,
180 start: u64,
181 now: u64,
182 created: &CreatedTimes,
183 units: &HashMap<String, String>,
184 prom_types: &HashMap<String, String>,
185) -> Option<Metric> {
186 let prom_type = prom_types
187 .get(family.name())
188 .map(String::as_str)
189 .unwrap_or_else(|| prometheus_type_name(family));
190
191 if prom_type == "info" || prom_type == "stateset" {
196 return Some(Metric {
197 name: family.name().to_string(),
198 description: family.help().to_string(),
199 unit: unit_for(family, units),
200 metadata: vec![type_metadata(prom_type)],
201 data: Some(metric::Data::Sum(Sum {
202 data_points: family
203 .get_metric()
204 .iter()
205 .map(|m| number_point(m, m.get_gauge().value(), 0, now))
206 .collect(),
207 aggregation_temporality: AggregationTemporality::Cumulative as i32,
208 is_monotonic: false,
209 })),
210 });
211 }
212
213 let data = match family.get_field_type() {
214 MetricType::COUNTER => metric::Data::Sum(Sum {
215 data_points: family
216 .get_metric()
217 .iter()
218 .map(|m| {
219 let start = start_for(family, m, start, created);
220 number_point(m, m.get_counter().value(), start, now)
221 })
222 .collect(),
223 aggregation_temporality: AggregationTemporality::Cumulative as i32,
226 is_monotonic: true,
227 }),
228 MetricType::GAUGE => metric::Data::Gauge(Gauge {
229 data_points: family
230 .get_metric()
231 .iter()
232 .map(|m| number_point(m, m.get_gauge().value(), 0, now))
235 .collect(),
236 }),
237 MetricType::HISTOGRAM => metric::Data::Histogram(Histogram {
238 data_points: family
239 .get_metric()
240 .iter()
241 .map(|m| histogram_point(m, start_for(family, m, start, created), now))
242 .collect(),
243 aggregation_temporality: AggregationTemporality::Cumulative as i32,
244 }),
245 MetricType::SUMMARY => metric::Data::Summary(Summary {
246 data_points: family
247 .get_metric()
248 .iter()
249 .map(|m| summary_point(m, start_for(family, m, start, created), now))
250 .collect(),
251 }),
252 MetricType::UNTYPED => metric::Data::Gauge(Gauge {
256 data_points: family
257 .get_metric()
258 .iter()
259 .map(|m| number_point(m, m.untyped.value(), 0, now))
260 .collect(),
261 }),
262 };
263
264 Some(Metric {
269 name: family.name().to_string(),
270 description: family.help().to_string(),
271 unit: unit_for(family, units),
272 metadata: vec![type_metadata(prom_type)],
273 data: Some(data),
274 })
275}
276
277fn observed_at(metric: &prometheus::proto::Metric, now: u64) -> u64 {
282 match metric.timestamp_ms() {
283 ms if ms <= 0 => now,
287 ms => (ms as u64).saturating_mul(1_000_000),
288 }
289}
290
291fn start_for(
294 family: &MetricFamily,
295 metric: &prometheus::proto::Metric,
296 fallback: u64,
297 created: &CreatedTimes,
298) -> u64 {
299 if created.is_empty() {
304 return fallback;
305 }
306 created
307 .get(&(family.name().to_string(), label_key(metric)))
308 .copied()
309 .unwrap_or(fallback)
310}
311
312fn number_point(
313 metric: &prometheus::proto::Metric,
314 value: f64,
315 start: u64,
316 now: u64,
317) -> NumberDataPoint {
318 let (flags, value) = if value.is_nan() {
322 (DataPointFlags::NoRecordedValueMask as u32, None)
323 } else {
324 (0, Some(number_data_point::Value::AsDouble(value)))
325 };
326
327 NumberDataPoint {
328 attributes: attributes(metric),
329 start_time_unix_nano: start,
330 time_unix_nano: observed_at(metric, now),
331 exemplars: Vec::new(),
332 flags,
333 value,
334 }
335}
336
337fn histogram_point(metric: &prometheus::proto::Metric, start: u64, now: u64) -> HistogramDataPoint {
338 let h = metric.get_histogram();
339
340 let mut bounds = Vec::new();
350 let mut counts = Vec::new();
351 let mut previous = 0u64;
352 for bucket in h.get_bucket() {
353 let cumulative = bucket.cumulative_count();
354 counts.push(cumulative.saturating_sub(previous));
355 previous = cumulative;
356 if bucket.upper_bound().is_finite() {
357 bounds.push(bucket.upper_bound());
358 }
359 }
360 if counts.len() == bounds.len() {
362 counts.push(h.sample_count().saturating_sub(previous));
363 }
364
365 HistogramDataPoint {
366 attributes: attributes(metric),
367 start_time_unix_nano: start,
368 time_unix_nano: observed_at(metric, now),
369 count: h.sample_count(),
370 sum: (!h.sample_sum().is_nan()).then(|| h.sample_sum()),
371 bucket_counts: counts,
372 explicit_bounds: bounds,
373 exemplars: Vec::new(),
374 flags: 0,
375 min: None,
376 max: None,
377 }
378}
379
380fn summary_point(metric: &prometheus::proto::Metric, start: u64, now: u64) -> SummaryDataPoint {
381 let s = metric.get_summary();
382 SummaryDataPoint {
383 attributes: attributes(metric),
384 start_time_unix_nano: start,
385 time_unix_nano: observed_at(metric, now),
386 count: s.sample_count(),
387 sum: if s.sample_sum().is_nan() {
390 0.0
391 } else {
392 s.sample_sum()
393 },
394 quantile_values: s
395 .get_quantile()
396 .iter()
397 .map(|q| ValueAtQuantile {
398 quantile: q.quantile(),
399 value: q.value(),
400 })
401 .collect(),
402 flags: 0,
403 }
404}
405
406fn attributes(metric: &prometheus::proto::Metric) -> Vec<KeyValue> {
407 metric
408 .get_label()
409 .iter()
410 .map(|label| KeyValue {
411 key: label.name().to_string(),
412 value: Some(AnyValue {
413 value: Some(any_value::Value::StringValue(label.value().to_string())),
414 }),
415 })
416 .collect()
417}
418
419fn unix_nanos(time: SystemTime) -> u64 {
420 time.duration_since(UNIX_EPOCH)
421 .map(|d| d.as_nanos() as u64)
422 .unwrap_or_default()
423}
424
425pub struct ExportConfig {
427 pub endpoint: String,
428 pub interval: Duration,
429 pub service_name: String,
430 pub headers: Vec<(String, String)>,
433 pub resource_attributes: Vec<(String, String)>,
435}
436
437pub(crate) fn parse_key_value_list(raw: &str) -> Vec<(String, String)> {
443 raw.split(',')
444 .filter_map(|pair| pair.split_once('='))
445 .map(|(k, v)| (k.trim().to_string(), v.trim().to_string()))
446 .filter(|(k, _)| !k.is_empty())
447 .collect()
448}
449
450impl ExportConfig {
451 pub fn from_env() -> anyhow::Result<Option<Self>> {
454 let Ok(exporter) = std::env::var(env_otlp::OTEL_METRICS_EXPORTER) else {
455 return Ok(None);
456 };
457 if !exporter.trim().eq_ignore_ascii_case("otlp") {
458 return Ok(None);
459 }
460
461 let metrics_protocol = std::env::var(env_otlp::OTEL_EXPORTER_OTLP_METRICS_PROTOCOL).ok();
466 let generic_protocol = std::env::var(env_otlp::OTEL_EXPORTER_OTLP_PROTOCOL).ok();
467 let configured = [
468 (
469 metrics_protocol.as_deref(),
470 env_otlp::OTEL_EXPORTER_OTLP_METRICS_PROTOCOL,
471 ),
472 (
473 generic_protocol.as_deref(),
474 env_otlp::OTEL_EXPORTER_OTLP_PROTOCOL,
475 ),
476 ]
477 .into_iter()
478 .find_map(|(value, source)| {
479 value
480 .map(str::trim)
481 .filter(|value| !value.is_empty())
482 .map(|value| (value, source))
483 });
484
485 if let Some((value, source)) = configured
486 && !value.eq_ignore_ascii_case("grpc")
487 {
488 anyhow::bail!(
489 "{source}={value} is not supported for metrics; only grpc is implemented. \
490 Set {}=grpc to override.",
491 env_otlp::OTEL_EXPORTER_OTLP_METRICS_PROTOCOL
492 );
493 }
494 let protocol = crate::logging::OtlpProtocol::Grpc;
495
496 let headers = std::env::var(env_otlp::OTEL_EXPORTER_OTLP_METRICS_HEADERS)
499 .or_else(|_| std::env::var(env_otlp::OTEL_EXPORTER_OTLP_HEADERS))
500 .map(|raw| parse_key_value_list(&raw))
501 .unwrap_or_default();
502
503 let resource_attributes = std::env::var(env_otlp::OTEL_RESOURCE_ATTRIBUTES)
504 .map(|raw| parse_key_value_list(&raw))
505 .unwrap_or_default();
506
507 Ok(Some(Self {
508 headers,
509 resource_attributes,
510 endpoint: crate::logging::resolve_otlp_endpoint(
511 protocol,
512 std::env::var(env_otlp::OTEL_EXPORTER_OTLP_METRICS_ENDPOINT).ok(),
513 std::env::var(env_otlp::OTEL_EXPORTER_OTLP_ENDPOINT).ok(),
514 "/v1/metrics",
515 ),
516 interval: export_interval(),
517 service_name: crate::logging::get_service_name(),
518 }))
519 }
520}
521
522fn export_interval() -> Duration {
523 const DEFAULT_MS: u64 = 60_000;
524 let millis = std::env::var(env_otlp::OTEL_METRIC_EXPORT_INTERVAL)
525 .ok()
526 .and_then(|value| value.trim().parse::<u64>().ok())
527 .filter(|millis| *millis > 0)
528 .unwrap_or(DEFAULT_MS);
529 Duration::from_millis(millis)
530}
531
532fn attributes_for(config: &ExportConfig) -> Vec<KeyValue> {
538 let mut attrs: Vec<KeyValue> = config
539 .resource_attributes
540 .iter()
541 .filter(|(key, _)| key != "service.name")
542 .map(|(key, value)| KeyValue {
543 key: key.clone(),
544 value: Some(AnyValue {
545 value: Some(any_value::Value::StringValue(value.clone())),
546 }),
547 })
548 .collect();
549 attrs.push(KeyValue {
550 key: "service.name".to_string(),
551 value: Some(AnyValue {
552 value: Some(any_value::Value::StringValue(config.service_name.clone())),
553 }),
554 });
555 attrs
556}
557
558pub async fn run(registry: MetricsRegistry, config: ExportConfig, cancel: CancellationToken) {
568 let attrs = attributes_for(&config);
569 let start_time = SystemTime::now();
572
573 const MAX_RPC_DEADLINE: Duration = Duration::from_secs(30);
577 let rpc_deadline = config.interval.min(MAX_RPC_DEADLINE);
578 let mut ticker = tokio::time::interval(config.interval);
579 ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
580 let mut client: Option<MetricsServiceClient<tonic::transport::Channel>> = None;
581
582 loop {
583 tokio::select! {
584 _ = cancel.cancelled() => break,
585 _ = ticker.tick() => {}
586 }
587
588 if client.is_none() {
592 let connecting = async {
593 tonic::transport::Endpoint::from_shared(config.endpoint.clone())?
594 .connect_timeout(rpc_deadline)
595 .timeout(rpc_deadline)
596 .connect()
597 .await
598 .map_err(anyhow::Error::from)
599 };
600 let connected = tokio::select! {
601 _ = cancel.cancelled() => break,
602 result = connecting => result,
603 };
604 match connected {
605 Ok(channel) => client = Some(MetricsServiceClient::new(channel)),
606 Err(error) => {
607 tracing::warn!(%error, endpoint = %config.endpoint, "OTLP metrics connect failed");
608 continue;
609 }
610 }
611 }
612
613 let collector = registry.clone();
618 let collected =
619 tokio::task::spawn_blocking(move || collector.metric_families_combined()).await;
620 let families = match collected {
621 Ok(Ok(families)) => families,
622 Ok(Err(error)) => {
623 tracing::warn!(%error, "OTLP metrics collection failed");
624 continue;
625 }
626 Err(error) => {
627 tracing::warn!(%error, "OTLP metrics collection task failed");
628 continue;
629 }
630 };
631
632 let request = ExportMetricsServiceRequest {
633 resource_metrics: vec![to_resource_metrics(&families, &attrs, start_time)],
634 };
635 let mut request = tonic::Request::new(request);
636 for (key, value) in &config.headers {
637 match (
638 tonic::metadata::MetadataKey::from_bytes(key.as_bytes()),
639 tonic::metadata::MetadataValue::try_from(value),
640 ) {
641 (Ok(key), Ok(value)) => {
642 request.metadata_mut().insert(key, value);
643 }
644 _ => {
645 tracing::warn!(header = %key, "skipping unusable OTLP header");
648 }
649 }
650 }
651
652 if let Some(connected) = client.as_mut() {
653 let exported = tokio::select! {
654 _ = cancel.cancelled() => break,
655 result = connected.export(request) => result,
656 };
657 match exported {
658 Ok(response) => {
659 if let Some(partial) = response.into_inner().partial_success
664 && partial.rejected_data_points > 0
665 {
666 tracing::warn!(
667 rejected_data_points = partial.rejected_data_points,
668 error_message = %partial.error_message,
669 endpoint = %config.endpoint,
670 "OTLP collector rejected part of the export"
671 );
672 }
673 }
674 Err(error) => {
675 tracing::warn!(%error, endpoint = %config.endpoint, "OTLP metrics export failed");
676 client = None;
679 }
680 }
681 }
682 }
683}
684
685#[cfg(test)]
686mod tests {
687 use super::*;
688 use crate::metrics::prom_typed::{TypedFamily, build_families};
689
690 fn collected_from(
693 built: Vec<crate::metrics::prom_typed::BuiltFamily>,
694 ) -> crate::metrics::CollectedFamilies {
695 let mut units = HashMap::new();
696 let mut prom_types = HashMap::new();
697 let mut families = Vec::new();
698 for b in built {
699 if !b.unit.is_empty() {
700 units.insert(b.family.name().to_string(), b.unit);
701 }
702 prom_types.insert(b.family.name().to_string(), b.prom_type);
703 families.push(b.family);
704 }
705 crate::metrics::CollectedFamilies {
706 families,
707 units,
708 prom_types,
709 }
710 }
711
712 fn export(typed_json: &str) -> Vec<Metric> {
713 let typed: Vec<TypedFamily> = serde_json::from_str(typed_json).expect("typed");
714 let collected = collected_from(build_families(typed));
715 let rm = to_resource_metrics(&collected, &[], UNIX_EPOCH);
716 rm.scope_metrics.into_iter().next().expect("scope").metrics
717 }
718
719 #[test]
722 fn histogram_buckets_are_de_cumulated() {
723 let metrics = export(
724 r#"[{"name":"d_seconds","help":"","type":"histogram","samples":[
725 {"name":"d_seconds_bucket","labels":{"le":"0.1"},"value":"2"},
726 {"name":"d_seconds_bucket","labels":{"le":"0.5"},"value":"5"},
727 {"name":"d_seconds_bucket","labels":{"le":"+Inf"},"value":"9"},
728 {"name":"d_seconds_sum","labels":{},"value":"1.5"},
729 {"name":"d_seconds_count","labels":{},"value":"9"}]}]"#,
730 );
731
732 let Some(metric::Data::Histogram(h)) = &metrics[0].data else {
733 panic!("expected histogram, got {:?}", metrics[0].data);
734 };
735 let point = &h.data_points[0];
736 assert_eq!(point.explicit_bounds, vec![0.1, 0.5]);
737 assert_eq!(point.bucket_counts, vec![2, 3, 4]);
738 assert_eq!(point.bucket_counts.len(), point.explicit_bounds.len() + 1);
739 assert_eq!(point.count, 9);
740 assert_eq!(point.sum, Some(1.5));
741 assert_eq!(
742 h.aggregation_temporality,
743 AggregationTemporality::Cumulative as i32
744 );
745 }
746
747 #[test]
751 fn counter_maps_to_monotonic_cumulative_sum_with_help() {
752 let metrics = export(
753 r#"[{"name":"d_requests","help":"Total requests","type":"counter","samples":[
754 {"name":"d_requests_total","labels":{"model":"a"},"value":"17"}]}]"#,
755 );
756
757 assert_eq!(
758 metrics[0].name, "d_requests",
759 "spec: the Prometheus name is used as-is and SHOULD NOT be altered"
760 );
761 assert_eq!(metrics[0].description, "Total requests");
762
763 let Some(metric::Data::Sum(sum)) = &metrics[0].data else {
764 panic!("expected sum, got {:?}", metrics[0].data);
765 };
766 assert!(sum.is_monotonic);
767 assert_eq!(
768 sum.aggregation_temporality,
769 AggregationTemporality::Cumulative as i32
770 );
771 assert_eq!(
772 sum.data_points[0].value,
773 Some(number_data_point::Value::AsDouble(17.0))
774 );
775 assert_eq!(sum.data_points[0].attributes[0].key, "model");
776 }
777
778 #[test]
781 fn config_is_opt_in_and_rejects_unsupported_protocol() {
782 temp_env::with_vars([(env_otlp::OTEL_METRICS_EXPORTER, None::<&str>)], || {
783 assert!(ExportConfig::from_env().expect("disabled").is_none())
784 });
785
786 temp_env::with_vars(
787 [
788 (env_otlp::OTEL_METRICS_EXPORTER, Some("otlp")),
789 (
790 env_otlp::OTEL_EXPORTER_OTLP_METRICS_ENDPOINT,
791 Some("http://collector:4317"),
792 ),
793 (env_otlp::OTEL_METRIC_EXPORT_INTERVAL, None),
794 ],
795 || {
796 let config = ExportConfig::from_env()
797 .expect("enabled")
798 .expect("some config");
799 assert_eq!(config.endpoint, "http://collector:4317");
800 assert_eq!(config.interval, Duration::from_millis(60_000));
801 },
802 );
803
804 temp_env::with_vars(
805 [
806 (env_otlp::OTEL_METRICS_EXPORTER, Some("otlp")),
807 (
808 env_otlp::OTEL_EXPORTER_OTLP_METRICS_PROTOCOL,
809 Some("http/protobuf"),
810 ),
811 ],
812 || assert!(ExportConfig::from_env().is_err()),
813 );
814
815 temp_env::with_vars(
819 [
820 (env_otlp::OTEL_METRICS_EXPORTER, Some("otlp")),
821 (
822 env_otlp::OTEL_EXPORTER_OTLP_METRICS_PROTOCOL,
823 Some("htp/protbuf"),
824 ),
825 ],
826 || assert!(ExportConfig::from_env().is_err()),
827 );
828
829 temp_env::with_vars(
831 [
832 (env_otlp::OTEL_METRICS_EXPORTER, Some("otlp")),
833 (env_otlp::OTEL_EXPORTER_OTLP_METRICS_PROTOCOL, None),
834 (env_otlp::OTEL_EXPORTER_OTLP_PROTOCOL, Some("http/protobuf")),
835 ],
836 || assert!(ExportConfig::from_env().is_err()),
837 );
838 }
839
840 #[test]
845 fn native_dynamo_families_survive_the_mapping() {
846 let registry = MetricsRegistry::new();
847
848 let counter =
849 prometheus::IntCounter::new("dynamo_requests_total", "Requests").expect("counter");
850 counter.inc_by(7);
851 registry
852 .get_prometheus_registry()
853 .register(Box::new(counter))
854 .expect("register counter");
855
856 let histogram = prometheus::Histogram::with_opts(
857 prometheus::HistogramOpts::new("dynamo_latency_seconds", "Latency")
858 .buckets(vec![0.1, 1.0]),
859 )
860 .expect("histogram");
861 histogram.observe(0.5);
862 registry
863 .get_prometheus_registry()
864 .register(Box::new(histogram))
865 .expect("register histogram");
866
867 let collected = registry.metric_families_combined().expect("combined");
868 let metrics = to_resource_metrics(&collected, &[], UNIX_EPOCH)
869 .scope_metrics
870 .into_iter()
871 .next()
872 .expect("scope")
873 .metrics;
874
875 let by_name = |n: &str| metrics.iter().find(|m| m.name == n).cloned();
876
877 let Some(metric::Data::Sum(sum)) = by_name("dynamo_requests_total").expect("counter").data
878 else {
879 panic!("counter should map to a Sum");
880 };
881 assert!(sum.is_monotonic);
882 assert_eq!(
883 sum.data_points[0].value,
884 Some(number_data_point::Value::AsDouble(7.0))
885 );
886
887 let Some(metric::Data::Histogram(h)) =
888 by_name("dynamo_latency_seconds").expect("histogram").data
889 else {
890 panic!("histogram should map to a Histogram");
891 };
892 assert_eq!(h.data_points[0].count, 1);
893 assert_eq!(h.data_points[0].sum, Some(0.5));
894 }
895
896 #[test]
900 fn untyped_family_maps_to_a_gauge() {
901 let mut family = MetricFamily::new();
902 family.set_name("legacy_metric".to_string());
903 family.set_field_type(MetricType::UNTYPED);
904 let mut metric = prometheus::proto::Metric::new();
905 let mut untyped = prometheus::proto::Untyped::new();
906 untyped.set_value(3.5);
907 metric.untyped = Some(untyped).into();
908 family.mut_metric().push(metric);
909
910 let collected = crate::metrics::CollectedFamilies {
911 families: vec![family],
912 units: HashMap::new(),
913 prom_types: HashMap::new(),
914 };
915 let metrics = to_resource_metrics(&collected, &[], UNIX_EPOCH)
916 .scope_metrics
917 .into_iter()
918 .next()
919 .expect("scope")
920 .metrics;
921
922 assert_eq!(metrics.len(), 1, "UNTYPED family was dropped");
923 let Some(metric::Data::Gauge(g)) = &metrics[0].data else {
924 panic!("expected gauge, got {:?}", metrics[0].data);
925 };
926 assert_eq!(
927 g.data_points[0].value,
928 Some(number_data_point::Value::AsDouble(3.5))
929 );
930 }
931
932 #[cfg(feature = "integration")]
942 #[tokio::test]
943 async fn exporter_is_waited_for_and_releases_on_phase_one() {
944 use crate::distributed::distributed_test_utils::create_test_drt_async;
945 use tokio::net::TcpListener;
946
947 let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
948 let port = listener.local_addr().expect("addr").port();
949
950 temp_env::async_with_vars(
951 [
952 (env_otlp::OTEL_METRICS_EXPORTER, Some("otlp")),
953 (
954 env_otlp::OTEL_EXPORTER_OTLP_METRICS_ENDPOINT,
955 Some(format!("http://127.0.0.1:{port}").as_str()),
956 ),
957 (env_otlp::OTEL_METRIC_EXPORT_INTERVAL, Some("600000")),
961 ],
962 async {
963 let drt = create_test_drt_async().await;
964 let tracker = drt.runtime().graceful_shutdown_tracker();
965 assert!(
966 tracker.get_count() > 0,
967 "exporter did not register for graceful shutdown"
968 );
969
970 drt.runtime().shutdown();
971
972 let released = tokio::time::timeout(Duration::from_secs(10), async {
975 while tracker.get_count() > 0 {
976 tokio::time::sleep(Duration::from_millis(50)).await;
977 }
978 })
979 .await;
980 assert!(
981 released.is_ok(),
982 "exporter never released its shutdown guard; Phase 2 would block until timeout"
983 );
984 },
985 )
986 .await;
987 }
988
989 #[test]
994 fn headers_and_resource_attributes_are_read_from_the_environment() {
995 temp_env::with_vars(
996 [
997 (env_otlp::OTEL_METRICS_EXPORTER, Some("otlp")),
998 (env_otlp::OTEL_EXPORTER_OTLP_ENDPOINT, Some("http://c:4317")),
999 (env_otlp::OTEL_METRIC_EXPORT_INTERVAL, None),
1000 (
1003 env_otlp::OTEL_EXPORTER_OTLP_HEADERS,
1004 Some("authorization=Bearer abc==,x-key=v"),
1005 ),
1006 (
1007 env_otlp::OTEL_RESOURCE_ATTRIBUTES,
1008 Some("deployment.environment=prod,service.name=ignored"),
1009 ),
1010 ],
1011 || {
1012 let config = ExportConfig::from_env().expect("enabled").expect("some");
1013 assert_eq!(
1014 config.headers,
1015 vec![
1016 ("authorization".to_string(), "Bearer abc==".to_string()),
1017 ("x-key".to_string(), "v".to_string()),
1018 ]
1019 );
1020 assert_eq!(
1021 config.resource_attributes,
1022 vec![
1023 ("deployment.environment".to_string(), "prod".to_string()),
1024 ("service.name".to_string(), "ignored".to_string()),
1025 ]
1026 );
1027
1028 let empty = crate::metrics::CollectedFamilies {
1031 families: Vec::new(),
1032 units: HashMap::new(),
1033 prom_types: HashMap::new(),
1034 };
1035 let rm = to_resource_metrics(&empty, &attributes_for(&config), UNIX_EPOCH);
1036 let keys: Vec<&str> = rm
1037 .resource
1038 .as_ref()
1039 .expect("resource")
1040 .attributes
1041 .iter()
1042 .map(|kv| kv.key.as_str())
1043 .collect();
1044 assert_eq!(keys, vec!["deployment.environment", "service.name"]);
1045 },
1046 );
1047 }
1048
1049 #[test]
1052 fn metrics_headers_replace_the_generic_ones() {
1053 temp_env::with_vars(
1054 [
1055 (env_otlp::OTEL_METRICS_EXPORTER, Some("otlp")),
1056 (env_otlp::OTEL_EXPORTER_OTLP_ENDPOINT, Some("http://c:4317")),
1057 (env_otlp::OTEL_METRIC_EXPORT_INTERVAL, None),
1058 (env_otlp::OTEL_EXPORTER_OTLP_HEADERS, Some("generic=1")),
1059 (
1060 env_otlp::OTEL_EXPORTER_OTLP_METRICS_HEADERS,
1061 Some("specific=2"),
1062 ),
1063 ],
1064 || {
1065 let config = ExportConfig::from_env().expect("enabled").expect("some");
1066 assert_eq!(
1067 config.headers,
1068 vec![("specific".to_string(), "2".to_string())]
1069 );
1070 },
1071 );
1072 }
1073
1074 #[cfg(feature = "integration")]
1082 #[tokio::test]
1083 async fn export_starts_without_the_system_status_server() {
1084 use crate::distributed::distributed_test_utils::create_test_drt_async;
1085 use tokio::net::TcpListener;
1086
1087 let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
1088 let port = listener.local_addr().expect("addr").port();
1089
1090 let connected = temp_env::async_with_vars(
1093 [
1094 (env_otlp::OTEL_METRICS_EXPORTER, Some("otlp")),
1095 (
1096 env_otlp::OTEL_EXPORTER_OTLP_METRICS_ENDPOINT,
1097 Some(format!("http://127.0.0.1:{port}").as_str()),
1098 ),
1099 (env_otlp::OTEL_METRIC_EXPORT_INTERVAL, Some("100")),
1100 ],
1101 async {
1102 let _drt = create_test_drt_async().await;
1103 tokio::time::timeout(Duration::from_secs(10), listener.accept())
1104 .await
1105 .is_ok()
1106 },
1107 )
1108 .await;
1109
1110 assert!(
1111 connected,
1112 "exporter never dialled the collector with the system status server disabled"
1113 );
1114 }
1115
1116 #[test]
1122 fn histogram_without_a_count_is_dropped() {
1123 let metrics = export(
1124 r#"[{"name":"d_ok","help":"ok","type":"histogram","samples":[
1125 {"name":"d_ok_bucket","labels":{"le":"1"},"value":"2"},
1126 {"name":"d_ok_sum","labels":{},"value":"1.0"},
1127 {"name":"d_ok_count","labels":{},"value":"2"}]},
1128 {"name":"d_missing","help":"missing","type":"histogram","samples":[
1129 {"name":"d_missing_bucket","labels":{"le":"1"},"value":"2"},
1130 {"name":"d_missing_sum","labels":{},"value":"1.0"}]}]"#,
1131 );
1132
1133 let names: Vec<&str> = metrics.iter().map(|m| m.name.as_str()).collect();
1134 assert!(names.contains(&"d_ok"), "got {names:?}");
1135 assert!(
1136 !names.contains(&"d_missing"),
1137 "a histogram with no _count must be dropped: {names:?}"
1138 );
1139
1140 let zero = export(
1142 r#"[{"name":"d_zero","help":"z","type":"histogram","samples":[
1143 {"name":"d_zero_bucket","labels":{"le":"1"},"value":"0"},
1144 {"name":"d_zero_count","labels":{},"value":"0"}]}]"#,
1145 );
1146 assert_eq!(zero.len(), 1, "a zero count must still be exported");
1147 }
1148
1149 #[test]
1155 fn unit_metadata_is_carried_and_translated() {
1156 let collected = collected_from(build_families(
1157 serde_json::from_str(
1158 r#"[{"name":"d_latency_seconds","help":"L","type":"gauge","unit":"seconds",
1159 "samples":[{"name":"d_latency_seconds","labels":{},"value":"0.5"}]},
1160 {"name":"d_widgets","help":"W","type":"gauge","unit":"widgets",
1161 "samples":[{"name":"d_widgets","labels":{},"value":"2"}]},
1162 {"name":"d_plain","help":"P","type":"gauge",
1163 "samples":[{"name":"d_plain","labels":{},"value":"1"}]}]"#,
1164 )
1165 .expect("fixture"),
1166 ));
1167 let metrics = to_resource_metrics(&collected, &[], UNIX_EPOCH)
1168 .scope_metrics
1169 .into_iter()
1170 .next()
1171 .expect("scope")
1172 .metrics;
1173
1174 let unit_of = |n: &str| {
1175 metrics
1176 .iter()
1177 .find(|m| m.name == n)
1178 .unwrap_or_else(|| panic!("{n} missing"))
1179 .unit
1180 .clone()
1181 };
1182
1183 assert_eq!(unit_of("d_latency_seconds"), "s", "translated to UCUM");
1184 assert_eq!(unit_of("d_widgets"), "widgets");
1186 assert_eq!(unit_of("d_plain"), "", "no UNIT metadata, no unit");
1187
1188 assert!(metrics.iter().any(|m| m.name == "d_latency_seconds"));
1190 }
1191
1192 #[test]
1198 fn metric_names_are_not_rewritten() {
1199 let metrics = export(
1200 r#"[{"name":"d_latency_seconds","help":"Latency","type":"gauge","samples":[
1201 {"name":"d_latency_seconds","labels":{},"value":"0.5"}]},
1202 {"name":"d_request_bytes","help":"Bytes","type":"counter","samples":[
1203 {"name":"d_request_bytes_total","labels":{},"value":"9"}]}]"#,
1204 );
1205
1206 let names: Vec<&str> = metrics.iter().map(|m| m.name.as_str()).collect();
1207 assert!(names.contains(&"d_latency_seconds"), "got {names:?}");
1208 assert!(names.contains(&"d_request_bytes"), "got {names:?}");
1209
1210 let latency = metrics
1212 .iter()
1213 .find(|m| m.name == "d_latency_seconds")
1214 .unwrap();
1215 assert_eq!(latency.metadata[0].key, "prometheus.type");
1216 }
1217
1218 #[test]
1222 fn nan_becomes_no_recorded_value() {
1223 let metrics = export(
1224 r#"[{"name":"d_level","help":"Level","type":"gauge","samples":[
1225 {"name":"d_level","labels":{},"value":"NaN"}]},
1226 {"name":"d_latency","help":"Latency","type":"histogram","samples":[
1227 {"name":"d_latency_bucket","labels":{"le":"1"},"value":"0"},
1228 {"name":"d_latency_sum","labels":{},"value":"NaN"},
1229 {"name":"d_latency_count","labels":{},"value":"0"}]}]"#,
1230 );
1231
1232 let Some(metric::Data::Gauge(g)) = &metrics
1233 .iter()
1234 .find(|m| m.name == "d_level")
1235 .expect("gauge")
1236 .data
1237 else {
1238 panic!("expected gauge");
1239 };
1240 assert_eq!(
1241 g.data_points[0].flags,
1242 DataPointFlags::NoRecordedValueMask as u32
1243 );
1244 assert!(
1245 g.data_points[0].value.is_none(),
1246 "the value must be unset, not a NaN"
1247 );
1248
1249 let Some(metric::Data::Histogram(h)) = &metrics
1250 .iter()
1251 .find(|m| m.name == "d_latency")
1252 .expect("histogram")
1253 .data
1254 else {
1255 panic!("expected histogram");
1256 };
1257 assert!(
1258 h.data_points[0].sum.is_none(),
1259 "an unrecorded histogram sum must be unset rather than NaN"
1260 );
1261 }
1262
1263 #[test]
1272 fn created_becomes_the_start_time_and_gauges_carry_none() {
1273 let metrics = export(
1274 r#"[{"name":"d_requests","help":"Requests","type":"counter","samples":[
1275 {"name":"d_requests_total","labels":{"model":"a"},"value":"17"},
1276 {"name":"d_requests_created","labels":{"model":"a"},"value":"1700000000"}]},
1277 {"name":"d_queue","help":"Queue","type":"gauge","samples":[
1278 {"name":"d_queue","labels":{},"value":"3"}]}]"#,
1279 );
1280
1281 assert!(
1282 !metrics.iter().any(|m| m.name.ends_with("_created")),
1283 "_created must not be exported as a metric: {:?}",
1284 metrics.iter().map(|m| &m.name).collect::<Vec<_>>()
1285 );
1286
1287 let counter = metrics
1288 .iter()
1289 .find(|m| m.name == "d_requests")
1290 .expect("counter");
1291 let Some(metric::Data::Sum(sum)) = &counter.data else {
1292 panic!("expected sum");
1293 };
1294 assert_eq!(
1295 sum.data_points[0].start_time_unix_nano, 1_700_000_000_000_000_000,
1296 "the counter must start at its _created time"
1297 );
1298
1299 let gauge = metrics.iter().find(|m| m.name == "d_queue").expect("gauge");
1300 let Some(metric::Data::Gauge(g)) = &gauge.data else {
1301 panic!("expected gauge");
1302 };
1303 assert_eq!(
1304 g.data_points[0].start_time_unix_nano, 0,
1305 "spec: gauges carry no start time"
1306 );
1307 }
1308
1309 #[test]
1314 fn sample_timestamps_survive_instead_of_the_export_instant() {
1315 let metrics = export(
1318 r#"[{"name":"engine_queue","help":"Queue","type":"gauge","samples":[
1319 {"name":"engine_queue","labels":{},"value":"4","timestamp":1700000000.0}]},
1320 {"name":"engine_fresh","help":"Fresh","type":"gauge","samples":[
1321 {"name":"engine_fresh","labels":{},"value":"1"}]}]"#,
1322 );
1323
1324 let point = |name: &str| {
1325 let m = metrics.iter().find(|m| m.name == name).expect("metric");
1326 let Some(metric::Data::Gauge(g)) = &m.data else {
1327 panic!("expected gauge");
1328 };
1329 g.data_points[0].time_unix_nano
1330 };
1331
1332 assert_eq!(
1333 point("engine_queue"),
1334 1_700_000_000_000_000_000,
1335 "a carried timestamp must be reported as the observation time"
1336 );
1337 assert_ne!(
1338 point("engine_fresh"),
1339 0,
1340 "a sample without one still gets the export instant"
1341 );
1342 }
1343
1344 #[test]
1352 fn info_family_keeps_its_rendered_name() {
1353 let metrics = export(
1354 r#"[{"name":"example_build","help":"Build info","type":"info","samples":[
1355 {"name":"example_build_info","labels":{"version":"1.2.3"},"value":"1"}]}]"#,
1356 );
1357
1358 assert_eq!(metrics[0].name, "example_build_info");
1359
1360 let Some(metric::Data::Sum(sum)) = &metrics[0].data else {
1365 panic!("expected a Sum, got {:?}", metrics[0].data);
1366 };
1367 assert!(!sum.is_monotonic, "info is a non-monotonic sum");
1368 assert_eq!(sum.data_points[0].attributes[0].key, "version");
1369 assert_eq!(metrics[0].metadata[0].key, "prometheus.type");
1370 let Some(any_value::Value::StringValue(t)) =
1371 &metrics[0].metadata[0].value.as_ref().unwrap().value
1372 else {
1373 panic!("expected a string");
1374 };
1375 assert_eq!(t, "info", "the original type word, not the proto collapse");
1376 }
1377
1378 #[test]
1381 fn summary_quantiles_survive() {
1382 let metrics = export(
1383 r#"[{"name":"d_pause_seconds","help":"","type":"summary","samples":[
1384 {"name":"d_pause_seconds","labels":{"quantile":"0.99"},"value":"0.2"},
1385 {"name":"d_pause_seconds_sum","labels":{},"value":"12.5"},
1386 {"name":"d_pause_seconds_count","labels":{},"value":"300"}]}]"#,
1387 );
1388
1389 let Some(metric::Data::Summary(s)) = &metrics[0].data else {
1390 panic!("expected summary, got {:?}", metrics[0].data);
1391 };
1392 let point = &s.data_points[0];
1393 assert_eq!(point.count, 300);
1394 assert_eq!(point.sum, 12.5);
1395 assert_eq!(point.quantile_values[0].quantile, 0.99);
1396 assert_eq!(point.quantile_values[0].value, 0.2);
1397 }
1398}