Skip to main content

aster_forge_metrics/
otlp.rs

1//! Explicit OTLP HTTP/protobuf transport. SDK environment variables never take precedence.
2
3use crate::{MetricError, MetricResult, MetricsProviderBuilder};
4use opentelemetry_proto::tonic::collector::metrics::v1::{
5    ExportMetricsServiceRequest, ExportMetricsServiceResponse,
6};
7use opentelemetry_sdk::{
8    error::{OTelSdkError, OTelSdkResult},
9    metrics::{PeriodicReader, Temporality, data::ResourceMetrics, exporter::PushMetricExporter},
10};
11use prost::Message;
12use std::{
13    collections::BTreeMap,
14    fmt,
15    io::Read,
16    sync::{
17        Arc,
18        atomic::{AtomicU64, Ordering},
19    },
20    time::{Duration, SystemTime, UNIX_EPOCH},
21};
22
23/// Credentials and endpoint are deliberately omitted from Debug and errors.
24#[derive(Clone)]
25pub struct OtlpMetricsConfig {
26    /// Full signal endpoint, e.g. `https://collector.example/v1/metrics`.
27    pub endpoint: String,
28    pub headers: BTreeMap<String, String>,
29    pub timeout: Duration,
30    pub interval: Duration,
31    pub max_request_bytes: usize,
32    pub max_response_bytes: usize,
33}
34
35impl fmt::Debug for OtlpMetricsConfig {
36    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37        f.debug_struct("OtlpMetricsConfig")
38            .field("endpoint", &"[REDACTED]")
39            .field("headers", &"[REDACTED]")
40            .field("timeout", &self.timeout)
41            .field("interval", &self.interval)
42            .finish_non_exhaustive()
43    }
44}
45
46impl OtlpMetricsConfig {
47    /// # Errors
48    /// Returns a redacted error for invalid endpoints, headers, timeouts or size limits.
49    pub fn validate(&self) -> MetricResult<()> {
50        let url =
51            reqwest::Url::parse(&self.endpoint).map_err(|_| MetricError::InvalidConfiguration)?;
52        if !matches!(url.scheme(), "http" | "https")
53            || url.host().is_none()
54            || !url.username().is_empty()
55            || url.password().is_some()
56            || url.query().is_some()
57            || url.fragment().is_some()
58            || self.timeout.is_zero()
59            || self.timeout > Duration::from_mins(1)
60            || self.interval < Duration::from_secs(1)
61            || self.interval > Duration::from_hours(1)
62            || self.timeout > self.interval
63            || self.headers.len() > 16
64            || self.max_request_bytes == 0
65            || self.max_request_bytes > 64 * 1024 * 1024
66            || self.max_response_bytes == 0
67            || self.max_response_bytes > 1024 * 1024
68        {
69            return Err(MetricError::InvalidConfiguration);
70        }
71        for (key, value) in &self.headers {
72            let name = reqwest::header::HeaderName::from_bytes(key.as_bytes())
73                .map_err(|_| MetricError::InvalidConfiguration)?;
74            if matches!(
75                name.as_str(),
76                "content-type" | "content-length" | "host" | "connection" | "transfer-encoding"
77            ) || value.len() > 4096
78                || reqwest::header::HeaderValue::from_str(value).is_err()
79            {
80                return Err(MetricError::InvalidConfiguration);
81            }
82        }
83        Ok(())
84    }
85}
86
87/// This snapshot is local and remains readable when the push destination fails.
88#[derive(Clone, Default)]
89pub struct OtlpExportDiagnostics {
90    inner: Arc<Diagnostics>,
91}
92#[derive(Default)]
93struct Diagnostics {
94    successes: AtomicU64,
95    failures: AtomicU64,
96    last_success_unix_seconds: AtomicU64,
97}
98
99#[derive(Clone, Copy, Debug, PartialEq, Eq)]
100pub struct OtlpExportSnapshot {
101    pub successes: u64,
102    pub failures: u64,
103    pub last_success_unix_seconds: u64,
104}
105impl OtlpExportDiagnostics {
106    #[must_use]
107    pub fn snapshot(&self) -> OtlpExportSnapshot {
108        OtlpExportSnapshot {
109            successes: self.inner.successes.load(Ordering::Relaxed),
110            failures: self.inner.failures.load(Ordering::Relaxed),
111            last_success_unix_seconds: self.inner.last_success_unix_seconds.load(Ordering::Relaxed),
112        }
113    }
114}
115
116impl MetricsProviderBuilder {
117    /// The standard SDK owns cumulative aggregation and its one periodic worker.
118    /// One observation feeds both readers; requests never run in product call paths.
119    /// # Errors
120    /// Returns a redacted error if configuration is invalid or the HTTP client cannot be created.
121    pub fn with_otlp(
122        mut self,
123        config: OtlpMetricsConfig,
124    ) -> MetricResult<(Self, OtlpExportDiagnostics)> {
125        config.validate()?;
126        if self.otlp_diagnostics.is_some() {
127            return Err(MetricError::InvalidConfiguration);
128        }
129        let timeout = config.timeout;
130        // reqwest's blocking client must be built outside an async runtime.
131        let client = std::thread::spawn(move || {
132            reqwest::blocking::Client::builder()
133                .timeout(timeout)
134                .connect_timeout(timeout)
135                .redirect(reqwest::redirect::Policy::none())
136                .no_proxy()
137                .build()
138        })
139        .join()
140        .map_err(|_| MetricError::Initialization)?
141        .map_err(|_| MetricError::Initialization)?;
142        let interval = config.interval;
143        let diagnostics = OtlpExportDiagnostics::default();
144        let exporter = HttpExporter {
145            config,
146            client,
147            diagnostics: diagnostics.clone(),
148        };
149        self.sdk = self.sdk.with_reader(
150            PeriodicReader::builder(exporter)
151                .with_interval(interval)
152                .build(),
153        );
154        self.otlp_diagnostics = Some(diagnostics.clone());
155        self.enabled = true;
156        Ok((self, diagnostics))
157    }
158}
159
160struct HttpExporter {
161    config: OtlpMetricsConfig,
162    client: reqwest::blocking::Client,
163    diagnostics: OtlpExportDiagnostics,
164}
165
166impl HttpExporter {
167    fn send(&self, metrics: &ResourceMetrics) -> MetricResult<()> {
168        use opentelemetry_sdk::metrics::data::{AggregatedMetrics, MetricData};
169        for scope in metrics.scope_metrics() {
170            for metric in scope.metrics() {
171                if let AggregatedMetrics::U64(MetricData::Sum(sum)) = metric.data()
172                    && sum
173                        .data_points()
174                        .any(|point| i64::try_from(point.value()).is_err())
175                {
176                    return Err(MetricError::InvalidValue);
177                }
178            }
179        }
180        let request: ExportMetricsServiceRequest = metrics.into();
181        if request.encoded_len() > self.config.max_request_bytes {
182            return Err(MetricError::Export);
183        }
184        let mut builder = self
185            .client
186            .post(&self.config.endpoint)
187            .header(reqwest::header::CONTENT_TYPE, "application/x-protobuf");
188        for (key, value) in &self.config.headers {
189            builder = builder.header(key, value);
190        }
191        let response = builder
192            .body(request.encode_to_vec())
193            .send()
194            .map_err(|_| MetricError::Export)?;
195        if response.status() != reqwest::StatusCode::OK {
196            return Err(MetricError::Export);
197        }
198        let mut bytes = Vec::new();
199        response
200            .take((self.config.max_response_bytes as u64).saturating_add(1))
201            .read_to_end(&mut bytes)
202            .map_err(|_| MetricError::Export)?;
203        if bytes.len() > self.config.max_response_bytes {
204            return Err(MetricError::Export);
205        }
206        let response = ExportMetricsServiceResponse::decode(bytes.as_slice())
207            .map_err(|_| MetricError::Export)?;
208        if response.partial_success.is_some_and(|partial| {
209            partial.rejected_data_points > 0 || !partial.error_message.is_empty()
210        }) {
211            return Err(MetricError::Export);
212        }
213        Ok(())
214    }
215}
216
217impl PushMetricExporter for HttpExporter {
218    async fn export(&self, metrics: &ResourceMetrics) -> OTelSdkResult {
219        // PeriodicReader executes this on its dedicated thread. No hidden retry
220        // queue: a failure is retried with the next cumulative snapshot.
221        let result = self.send(metrics);
222        if result.is_ok() {
223            self.diagnostics
224                .inner
225                .successes
226                .fetch_add(1, Ordering::Relaxed);
227            self.diagnostics.inner.last_success_unix_seconds.store(
228                SystemTime::now()
229                    .duration_since(UNIX_EPOCH)
230                    .unwrap_or_default()
231                    .as_secs(),
232                Ordering::Relaxed,
233            );
234        } else {
235            self.diagnostics
236                .inner
237                .failures
238                .fetch_add(1, Ordering::Relaxed);
239            tracing::warn!("OTLP metrics export failed");
240        }
241        result.map_err(|_| OTelSdkError::InternalFailure("OTLP metrics export failed".into()))
242    }
243    fn force_flush(&self) -> OTelSdkResult {
244        Ok(())
245    }
246    fn shutdown_with_timeout(&self, _timeout: Duration) -> OTelSdkResult {
247        Ok(())
248    }
249    fn temporality(&self) -> Temporality {
250        Temporality::Cumulative
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257    use crate::{MetricDescriptor, MetricsProvider, ProductCounter};
258    use std::{
259        io::{Read, Write},
260        net::TcpListener,
261    };
262
263    fn receiver(status: &str, response: Vec<u8>) -> (String, std::thread::JoinHandle<Vec<u8>>) {
264        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
265        let endpoint = format!("http://{}/v1/metrics", listener.local_addr().unwrap());
266        let status = status.to_owned();
267        let thread = std::thread::spawn(move || {
268            let (mut stream, _) = listener.accept().unwrap();
269            stream
270                .set_read_timeout(Some(Duration::from_secs(5)))
271                .unwrap();
272            let mut data = Vec::new();
273            let mut buffer = [0_u8; 4096];
274            loop {
275                let count = stream.read(&mut buffer).unwrap();
276                assert!(count > 0);
277                data.extend_from_slice(&buffer[..count]);
278                if let Some(offset) = data.windows(4).position(|w| w == b"\r\n\r\n") {
279                    let headers = std::str::from_utf8(&data[..offset])
280                        .unwrap()
281                        .to_ascii_lowercase();
282                    let length: usize = headers
283                        .lines()
284                        .find_map(|line| line.strip_prefix("content-length: "))
285                        .unwrap()
286                        .parse()
287                        .unwrap();
288                    if data.len() >= offset + 4 + length {
289                        assert!(headers.contains("authorization: static-secret"));
290                        let _ = write!(
291                            stream,
292                            "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
293                            response.len()
294                        );
295                        let _ = stream.write_all(&response);
296                        return data[offset + 4..offset + 4 + length].to_vec();
297                    }
298                }
299            }
300        });
301        (endpoint, thread)
302    }
303
304    fn config(endpoint: String) -> OtlpMetricsConfig {
305        OtlpMetricsConfig {
306            endpoint,
307            headers: BTreeMap::from([("authorization".into(), "static-secret".into())]),
308            timeout: Duration::from_millis(250),
309            interval: Duration::from_mins(1),
310            max_request_bytes: 1024 * 1024,
311            max_response_bytes: 1024,
312        }
313    }
314
315    #[tokio::test]
316    async fn otlp_only_and_dual_readers_export_one_cumulative_schema() {
317        for pull in [false, true] {
318            let (endpoint, received) = receiver("200 OK", Vec::new());
319            let builder = MetricsProvider::builder();
320            #[cfg(feature = "backend-prometheus")]
321            let builder = if pull {
322                builder.with_prometheus().unwrap()
323            } else {
324                builder
325            };
326            #[cfg(not(feature = "backend-prometheus"))]
327            let _ = pull;
328            let (builder, diagnostics) = builder.with_otlp(config(endpoint)).unwrap();
329            let provider = builder.build().unwrap();
330            let handles = provider
331                .register_batch(&[MetricDescriptor::counter(
332                    "file",
333                    "bytes_total",
334                    "Bytes.",
335                    &["direction"],
336                )
337                .with_unit("By")])
338                .unwrap();
339            let counter = ProductCounter::from_handle(handles[0].clone()).unwrap();
340            counter.inc(&["ingress"], 5);
341            counter.inc(&["ingress"], 7);
342            provider.force_flush(Duration::from_secs(2)).await.unwrap();
343            let request =
344                ExportMetricsServiceRequest::decode(received.join().unwrap().as_slice()).unwrap();
345            let metric = &request.resource_metrics[0].scope_metrics[0].metrics[0];
346            assert_eq!(metric.name, "file_bytes_total");
347            assert_eq!(metric.unit, "By");
348            let Some(opentelemetry_proto::tonic::metrics::v1::metric::Data::Sum(sum)) =
349                &metric.data
350            else {
351                panic!("expected sum");
352            };
353            assert_eq!(sum.aggregation_temporality, 2); // cumulative
354            assert_eq!(
355                sum.data_points[0].value,
356                Some(opentelemetry_proto::tonic::metrics::v1::number_data_point::Value::AsInt(12))
357            );
358            assert_eq!(sum.data_points[0].attributes[0].key, "direction");
359            assert_eq!(diagnostics.snapshot().successes, 1);
360            #[cfg(feature = "backend-prometheus")]
361            if pull {
362                let output = provider
363                    .export_prometheus(Duration::from_secs(1))
364                    .await
365                    .unwrap();
366                assert!(output.contains("file_bytes_total{direction=\"ingress\"} 12"));
367                assert_eq!(
368                    diagnostics.snapshot().successes,
369                    1,
370                    "scraping must not push"
371                );
372            }
373            // The server accepted one request. Shutdown's last export may fail,
374            // but cannot block beyond the explicit network deadline.
375            assert_eq!(
376                provider.shutdown(Duration::from_secs(2)).await,
377                Err(MetricError::Shutdown)
378            );
379        }
380    }
381
382    #[tokio::test]
383    async fn rejection_is_locally_observable_without_secret_details() {
384        let (endpoint, received) =
385            receiver("401 Unauthorized", b"secret remote diagnostics".to_vec());
386        let cfg = config(endpoint);
387        assert!(!format!("{cfg:?}").contains("static-secret"));
388        let (builder, diagnostics) = MetricsProvider::builder().with_otlp(cfg).unwrap();
389        let provider = builder.build().unwrap();
390        let handle = provider
391            .register_batch(&[MetricDescriptor::counter(
392                "test",
393                "events_total",
394                "Events.",
395                &[],
396            )])
397            .unwrap()
398            .remove(0);
399        ProductCounter::from_handle(handle).unwrap().inc(&[], 1);
400        let _ = provider.force_flush(Duration::from_secs(2)).await;
401        received.join().unwrap();
402        assert_eq!(diagnostics.snapshot().failures, 1);
403        assert_eq!(diagnostics.snapshot().last_success_unix_seconds, 0);
404        assert_eq!(
405            provider.shutdown(Duration::from_secs(2)).await,
406            Err(MetricError::Shutdown)
407        );
408    }
409
410    #[test]
411    fn configuration_errors_are_redacted_and_bounded() {
412        let mut cfg = config("https://user:secret@collector.invalid/v1/metrics".into());
413        assert_eq!(cfg.validate(), Err(MetricError::InvalidConfiguration));
414        assert!(!cfg.validate().unwrap_err().to_string().contains("secret"));
415        cfg.endpoint = "http://127.0.0.1:4318/v1/metrics".into();
416        cfg.headers.insert("content-type".into(), "bad".into());
417        assert!(cfg.validate().is_err());
418        cfg.headers.remove("content-type");
419        cfg.timeout = Duration::ZERO;
420        assert!(cfg.validate().is_err());
421    }
422    #[test]
423    fn transport_rejects_partial_success_malformed_oversized_and_redirect_responses() {
424        use opentelemetry_proto::tonic::collector::metrics::v1::ExportMetricsPartialSuccess;
425        let partial = ExportMetricsServiceResponse {
426            partial_success: Some(ExportMetricsPartialSuccess {
427                rejected_data_points: 1,
428                error_message: "remote-secret".into(),
429            }),
430        }
431        .encode_to_vec();
432        for (status, response, limit) in [
433            ("200 OK", partial, 1024),
434            ("200 OK", vec![0xff], 1024),
435            ("200 OK", vec![0; 1025], 1024),
436            ("307 Temporary Redirect", Vec::new(), 1024),
437        ] {
438            let (endpoint, received) = receiver(status, response);
439            let mut config = config(endpoint);
440            config.max_response_bytes = limit;
441            let exporter = HttpExporter {
442                client: reqwest::blocking::Client::builder()
443                    .redirect(reqwest::redirect::Policy::none())
444                    .no_proxy()
445                    .timeout(config.timeout)
446                    .build()
447                    .unwrap(),
448                config,
449                diagnostics: OtlpExportDiagnostics::default(),
450            };
451            assert_eq!(
452                exporter.send(&ResourceMetrics::default()),
453                Err(MetricError::Export)
454            );
455            received.join().unwrap();
456        }
457    }
458
459    #[test]
460    fn unresponsive_collector_is_bounded_by_the_explicit_network_timeout() {
461        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
462        let mut config = config(format!(
463            "http://{}/v1/metrics",
464            listener.local_addr().unwrap()
465        ));
466        config.timeout = Duration::from_millis(20);
467        let server = std::thread::spawn(move || {
468            let (stream, _) = listener.accept().unwrap();
469            std::thread::sleep(Duration::from_millis(100));
470            drop(stream);
471        });
472        let exporter = HttpExporter {
473            client: reqwest::blocking::Client::builder()
474                .no_proxy()
475                .timeout(config.timeout)
476                .build()
477                .unwrap(),
478            config,
479            diagnostics: OtlpExportDiagnostics::default(),
480        };
481        let start = std::time::Instant::now();
482        assert_eq!(
483            exporter.send(&ResourceMetrics::default()),
484            Err(MetricError::Export)
485        );
486        assert!(start.elapsed() < Duration::from_secs(1));
487        server.join().unwrap();
488    }
489}