1use 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#[derive(Clone)]
25pub struct OtlpMetricsConfig {
26 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 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#[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 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 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 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); 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 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}