Skip to main content

dynamo_runtime/metrics/
otlp_export.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Map Prometheus metric families to OTLP.
5//!
6//! The OTel Rust SDK's metric data model is read-only -- no public
7//! constructors, no external producer hook, and no `Summary` variant -- so this
8//! builds `opentelemetry-proto` messages directly instead of going through
9//! `SdkMeterProvider`.
10//!
11//! Deliberately free of any runtime types: the same mapping serves an
12//! out-of-process scraper.
13
14use 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
32/// Build an OTLP payload from Prometheus families.
33///
34/// `start_time` anchors cumulative sums; it must stay fixed for the process
35/// lifetime or consumers will read every export as a counter reset.
36pub 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    // `_created` is a Python-client artifact, not a metric: the spec says use
46    // it as the parent's start time and drop it. Keeping it would emit a
47    // spurious gauge per counter and histogram, and would throw away the only
48    // signal a backend has for telling a counter reset from a jump.
49    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
98/// Label pairs in a stable order, so a `_created` series can be matched to the
99/// parent series it belongs to.
100fn 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
112/// Prometheus unit words translated to their UCUM abbreviation.
113///
114/// "The unit MUST be translated from words to the UCUM abbreviation if it is in
115/// the following set of commonly-used units." Anything outside that set is
116/// passed through unchanged, which is what the conditional requires.
117fn 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
148/// The UCUM unit for a family, when its source declared one.
149fn 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
156/// "The TYPE metadata MUST also be added to the OTLP metric.metadata under the
157/// `prometheus.type` key."
158fn 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
167/// The `prometheus.type` value the spec requires in `Metric.metadata`.
168fn 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    // "A Prometheus Info metric MUST be converted to an OTLP Non-Monotonic
192    // Sum... the value of 1 is intended to be viewed as a count." The proto
193    // model has no Info variant, so the original type word is what tells them
194    // apart from a real gauge.
195    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            // Prometheus is always cumulative; converting to delta would need
224            // reset detection we have no basis for.
225            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                // Spec: gauges carry no start time. A non-zero one makes
233                // backends treat the series as cumulative and hunt for resets.
234                .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        // Native families bypass the typed builder, so UNTYPED arrives as-is.
253        // Exposition format treats it as a gauge; returning None instead would
254        // drop the family without a trace.
255        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    // "The Prometheus Metric Name MUST be added as the Name of the OTLP metric.
265    // The name SHOULD NOT be altered." Suffixes like `_total` and `_seconds`
266    // are added when going OTLP -> Prometheus; coming back the other way they
267    // stay put. The unit is carried separately, from UNIT metadata.
268    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
277/// When a sample carried its own timestamp, that is when it was observed;
278/// otherwise the export instant. Standard clients leave it unset, but a
279/// federated source sets it, and relabelling a stale sample to "now" misleads
280/// anything reading `time_unix_nano`.
281fn observed_at(metric: &prometheus::proto::Metric, now: u64) -> u64 {
282    match metric.timestamp_ms() {
283        // Negative as well as zero falls back: `timestamp_ms` is an i64, and
284        // casting a negative one to u64 wraps to a timestamp far in the future
285        // -- worse than simply reporting the export instant.
286        ms if ms <= 0 => now,
287        ms => (ms as u64).saturating_mul(1_000_000),
288    }
289}
290
291/// The series' own `_created` time when the client reported one, else the
292/// process start time.
293fn start_for(
294    family: &MetricFamily,
295    metric: &prometheus::proto::Metric,
296    fallback: u64,
297    created: &CreatedTimes,
298) -> u64 {
299    // Building the key allocates a String and a Vec of label Strings, per data
300    // point. Almost every collection has no `_created` at all -- Dynamo's own
301    // metrics never emit one -- so skip the work rather than allocate to look
302    // up an empty map.
303    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    // Several backends reject a raw NaN payload; the spec's representation is
319    // the no-recorded-value flag with the value unset. Prometheus tells a stale
320    // marker from an ordinary NaN by bit pattern, but only an f64 reaches here.
321    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    // Prometheus buckets are cumulative and include a final `+Inf`; OTLP wants
341    // per-bucket counts and omits the implicit overflow bound.
342    //
343    // Exemplars cannot be carried: upstream Prometheus defines
344    // `Bucket.exemplar` (field 3) and `Counter.exemplar` (field 2), but the
345    // `prometheus` crate vendors a reduced copy of that schema with neither, so
346    // there is nowhere for one to arrive. Nothing is dropped here; the type
347    // simply cannot represent it. Supporting them means the crate gaining the
348    // fields, or a side channel like the one units use.
349    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    // OTLP requires exactly one more count than bounds.
361    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        // A summary's sum is not optional in the proto, so an unrecorded one
388        // becomes 0 with the flag set rather than a NaN on the wire.
389        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
425/// Resolved OTLP metrics export settings.
426pub struct ExportConfig {
427    pub endpoint: String,
428    pub interval: Duration,
429    pub service_name: String,
430    /// Sent as gRPC metadata on every export; how authenticated collectors are
431    /// reached. Empty unless configured.
432    pub headers: Vec<(String, String)>,
433    /// Resource attributes applied to every export, beyond `service.name`.
434    pub resource_attributes: Vec<(String, String)>,
435}
436
437/// Parse the `key=value,key=value` form used for headers and resource
438/// attributes.
439///
440/// Splits on the *first* `=` so base64 padding in a bearer token survives.
441/// Percent-decoding, which the spec allows, is deliberately not applied.
442pub(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    /// `None` unless `OTEL_METRICS_EXPORTER=otlp`. Prometheus scraping is
452    /// unaffected either way.
453    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        // Only gRPC is implemented. Validated as *configured* rather than as
462        // resolved: the shared resolver falls back to grpc for anything it does
463        // not recognise, so a typo would otherwise be sent over gRPC silently.
464        // The error names whichever variable actually supplied the value.
465        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        // Signal-specific headers replace the generic set rather than merging,
497        // per the OTLP exporter spec.
498        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
532/// Resource attributes for every export.
533///
534/// `OTEL_SERVICE_NAME` wins over a `service.name` in `OTEL_RESOURCE_ATTRIBUTES`
535/// per the resource spec, so the configured one is dropped rather than sent
536/// twice.
537fn 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
558/// Collect and export until `cancel` fires.
559///
560/// Export failures are logged and retried on the next tick: metrics are
561/// resendable, so a transient collector outage should not tear down the task.
562///
563/// Both the connect and the export are bounded by an explicit deadline and
564/// raced against `cancel`. Tonic applies no default RPC timeout, so a
565/// collector that accepts the connection but never answers would otherwise
566/// hold this task past shutdown.
567pub async fn run(registry: MetricsRegistry, config: ExportConfig, cancel: CancellationToken) {
568    let attrs = attributes_for(&config);
569    // Fixed for the process lifetime; a moving start time reads as a counter
570    // reset on every export.
571    let start_time = SystemTime::now();
572
573    // The interval is the natural deadline -- a call still outstanding when the
574    // next is due has missed its window -- but capped, so a long interval does
575    // not buy an equally long hang.
576    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        // Connect lazily and retry on every tick. Connecting once up front
589        // would strand the exporter for the process lifetime if the collector
590        // happened to be down when this worker started.
591        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        // Callbacks take the Python GIL, so collection must not park an async
614        // worker. Awaited rather than raced against `cancel`: a blocking task
615        // cannot be cancelled, so racing it would leave the closure running
616        // detached, reaching for the GIL during teardown.
617        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                    // Logged once per export rather than dropped silently: a
646                    // malformed auth header means every request is rejected.
647                    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                    // A successful status can still report dropped series --
660                    // cardinality limits are the usual cause. Reporting the
661                    // export as clean would hide data loss the collector has
662                    // already told us about.
663                    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                    // Drop the channel so the next tick reconnects; a broken
677                    // transport will not recover on its own.
678                    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    /// Mirror of what `metric_families_combined` assembles, so tests exercise
691    /// the same shape the exporter consumes.
692    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    /// Prometheus buckets are cumulative and carry `+Inf`; OTLP wants per-bucket
720    /// counts with the overflow bound implicit and one more count than bounds.
721    #[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    /// Counters must be monotonic cumulative sums, and HELP must reach
748    /// `description` -- the fidelity a scraping sidecar cannot preserve.
749    ///
750    #[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    /// Export is opt-in, and an unsupported protocol must fail loudly rather
779    /// than silently not export.
780    #[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        // A typo must fail too. The shared resolver silently falls back to
816        // grpc for anything it does not recognise, so validating the resolved
817        // protocol would export over gRPC and call it success.
818        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        // The generic variable is the fallback and must be validated too.
830        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    /// Dynamo's own metrics reach OTLP by a different route than engine
841    /// metrics: `Registry::gather()` straight into the mapper, never through
842    /// the typed builder. Every other test here feeds engine-side data, so
843    /// this is the only cover for that half of the exporter's input.
844    #[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    /// An UNTYPED family has not been through the typed builder's
897    /// normalisation, so the mapper must decide for itself. Dropping it would
898    /// remove the family from OTLP silently.
899    #[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    /// The exporter participates in graceful shutdown, so a final in-flight
933    /// export is not abandoned mid-RPC.
934    ///
935    /// The hazard worth pinning is a deadlock rather than the wait itself:
936    /// Phase 2 blocks on the guard, so if the exporter only stopped on the
937    /// main token -- cancelled in Phase 3, after that wait -- it would hold
938    /// the guard until the timeout fired. `child_token()` derives from the
939    /// endpoint shutdown token, which Phase 1 cancels first, so the guard is
940    /// released promptly.
941    #[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                // Long enough that the exporter is idle at its tick when
958                // shutdown lands, so this measures the guard and not a race
959                // against a busy export.
960                (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                // Phase 1 cancels the endpoint token; the exporter should drop
973                // its guard well before Phase 2's timeout.
974                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    /// Authenticated collectors are reached with headers, and deployments are
990    /// told apart by resource attributes. Neither is supplied by the SDK here:
991    /// traces and logs get them from `opentelemetry-otlp`, which this exporter
992    /// bypasses because the SDK's metric data model is read-only.
993    #[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                // A bearer token: base64 padding means the value itself
1001                // contains `=`, so only the first one separates key from value.
1002                (
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                // service.name from OTEL_SERVICE_NAME wins, and is not emitted
1029                // twice alongside the one in OTEL_RESOURCE_ATTRIBUTES.
1030                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    /// The signal-specific variable replaces the generic set rather than
1050    /// merging with it, per the OTLP exporter spec.
1051    #[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    /// The exporter must not depend on the system status server, which is
1075    /// disabled by default (`DYN_SYSTEM_PORT=-1`). Gating export on it made
1076    /// the documented `OTEL_METRICS_EXPORTER=otlp` opt-in a silent no-op in
1077    /// the default configuration.
1078    ///
1079    /// Asserts a connection actually arrives, rather than that some code ran:
1080    /// a spawn that never dials proves nothing.
1081    #[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        // DYN_SYSTEM_PORT is left unset, so the status server takes its
1091        // default of -1 (disabled) -- the configuration the bug hid in.
1092        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    /// "If `_count` is not present, the metric MUST be dropped." A histogram
1117    /// with buckets but no count is incoherent: the buckets and the total can
1118    /// disagree, and anything computing a quantile from it gets nonsense. A
1119    /// count of zero is a real value, so absence is tracked rather than
1120    /// inferred from it.
1121    #[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        // A zero count is a value, not an absence.
1141        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    /// UNIT metadata reaches OTLP, translated to its UCUM abbreviation.
1150    ///
1151    /// It cannot ride inside the family -- the proto model has no unit field --
1152    /// so it travels beside it. Without that, the unit the client declared is
1153    /// dropped at the boundary and backends have nothing to label axes with.
1154    #[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        // Outside the spec's table, so passed through unchanged.
1185        assert_eq!(unit_of("d_widgets"), "widgets");
1186        assert_eq!(unit_of("d_plain"), "", "no UNIT metadata, no unit");
1187
1188        // The name is untouched either way.
1189        assert!(metrics.iter().any(|m| m.name == "d_latency_seconds"));
1190    }
1191
1192    /// The spec is explicit that this direction does not rewrite names: the
1193    /// `_total` and unit suffixes are things OTLP -> Prometheus *adds*, and
1194    /// coming back the other way they stay put. Pinned because a plausible
1195    /// reading of the reverse-direction rules says otherwise, and acting on it
1196    /// renames a third of the exported metrics.
1197    #[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        // TYPE metadata is required to travel with the metric.
1211        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    /// A NaN is reported as no-recorded-value with the value unset, not as a
1219    /// raw NaN on the wire. Engines produce them routinely -- any ratio with a
1220    /// zero denominator -- and several backends reject the payload.
1221    #[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    /// The compatibility spec treats `_created` as the parent's start time, not
1264    /// as a metric: it must not appear in the export, and the counter it
1265    /// belongs to must carry it as `start_time_unix_nano`. Emitting it as a
1266    /// gauge produced one spurious metric per counter and threw away the only
1267    /// signal a backend has for distinguishing a counter reset from a jump.
1268    ///
1269    /// Gauges must carry no start time at all -- a non-zero one makes backends
1270    /// treat the series as cumulative.
1271    #[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    /// A sample that carries its own timestamp is reported as observed then,
1310    /// not at the export instant. Standard client metrics leave it unset, but a
1311    /// custom collector or federated source sets it, and relabelling a stale
1312    /// sample to "now" misleads anything reading `time_unix_nano`.
1313    #[test]
1314    fn sample_timestamps_survive_instead_of_the_export_instant() {
1315        // Carried all the way from the Python-side tuple, through the typed
1316        // builder, into the datapoint.
1317        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    /// An Info family keeps its rendered `_info` name and converts to a
1345    /// non-monotonic Sum, and the original type word survives into metadata.
1346    ///
1347    /// `prometheus_client` reports an Info family under its bare name but
1348    /// renders it as `<name>_info`. Exporting the bare name would give the
1349    /// series a different identity than `/metrics` shows, and could collide
1350    /// with a real gauge of that name when families are merged.
1351    #[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        // "A Prometheus Info metric MUST be converted to an OTLP Non-Monotonic
1361        // Sum ... because the value of 1 is intended to be viewed as a count,
1362        // which should be summed together when aggregating away labels." As a
1363        // Gauge, aggregating away a label would not add the 1s.
1364        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    /// Summaries only survive because we bypass the SDK, whose data model has
1379    /// no `Summary` variant.
1380    #[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}