1use std::sync::atomic::{AtomicBool, Ordering};
13use std::sync::{Condvar, Mutex, OnceLock};
14use std::time::{Duration, SystemTime, UNIX_EPOCH};
15
16use crate::metrics::http;
17
18#[derive(Clone, Debug)]
20pub struct MetricsExportConfig {
21 pub endpoint: String,
23 pub interval: Duration,
25 pub service_name: String,
27 pub service_version: String,
29 pub headers: Vec<(String, String)>,
31}
32
33struct Exporter {
34 config: MetricsExportConfig,
35 start_ns: u64,
36 stop: AtomicBool,
37 wake: (Mutex<()>, Condvar),
38}
39
40static EXPORTER: OnceLock<Exporter> = OnceLock::new();
41
42fn now_ns() -> u64 {
43 SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_nanos() as u64).unwrap_or(0)
44}
45
46pub fn start(config: MetricsExportConfig) {
48 let interval = config.interval.max(Duration::from_millis(100));
49 let exporter = Exporter { config, start_ns: now_ns(), stop: AtomicBool::new(false), wake: (Mutex::new(()), Condvar::new()) };
50 if EXPORTER.set(exporter).is_err() {
51 return;
52 }
53 let _ = std::thread::Builder::new().name("rws-otlp-metrics".to_string()).spawn(move || {
54 let exporter = EXPORTER.get().expect("set above");
55 loop {
56 let guard = exporter.wake.0.lock().unwrap_or_else(|e| e.into_inner());
57 let _ = exporter.wake.1.wait_timeout(guard, interval);
58 if exporter.stop.load(Ordering::Relaxed) {
59 return;
60 }
61 export(exporter);
62 }
63 });
64}
65
66pub fn start_from_env() -> bool {
77 let var = |name: &str| std::env::var(name).ok().map(|v| v.trim().to_string()).filter(|v| !v.is_empty());
78 if var("OTEL_METRICS_EXPORTER").is_some_and(|v| v.eq_ignore_ascii_case("none")) {
79 return false;
80 }
81 let Some(endpoint) = var("OTEL_EXPORTER_OTLP_METRICS_ENDPOINT")
82 .or_else(|| var("OTEL_EXPORTER_OTLP_ENDPOINT").map(|base| format!("{}/v1/metrics", base.trim_end_matches('/'))))
83 else {
84 return false;
85 };
86 let interval = var("OTEL_METRIC_EXPORT_INTERVAL").and_then(|v| v.parse().ok()).unwrap_or(60_000);
87 let headers = var("OTEL_EXPORTER_OTLP_METRICS_HEADERS").or_else(|| var("OTEL_EXPORTER_OTLP_HEADERS")).map(|h| super::parse_headers(&h)).unwrap_or_default();
88 start(MetricsExportConfig {
89 endpoint,
90 interval: Duration::from_millis(interval),
91 service_name: var("OTEL_SERVICE_NAME").unwrap_or_else(|| "rws".to_string()),
92 service_version: env!("CARGO_PKG_VERSION").to_string(),
93 headers,
94 });
95 true
96}
97
98pub(crate) fn shutdown() {
100 if let Some(exporter) = EXPORTER.get() {
101 if !exporter.stop.swap(true, Ordering::Relaxed) {
102 exporter.wake.1.notify_all();
103 export(exporter);
104 }
105 }
106}
107
108fn export(exporter: &Exporter) {
109 let body = body(&exporter.config.service_name, &exporter.config.service_version, exporter.start_ns, now_ns());
110 let client = crate::http_client::Client::new().timeout_ms(10_000);
111 let mut request = client.post(&exporter.config.endpoint).body_json(&body);
112 for (name, value) in &exporter.config.headers {
113 request = request.header(name, value);
114 }
115 match request.send() {
116 Ok(response) if response.is_success() => {}
117 Ok(response) => eprintln!("OTLP metrics export to {}: HTTP {}", exporter.config.endpoint, response.status()),
118 Err(e) => eprintln!("OTLP metrics export to {}: {}", exporter.config.endpoint, e),
119 }
120}
121
122pub fn body(service_name: &str, service_version: &str, start_ns: u64, now_ns: u64) -> String {
124 let points: Vec<String> = http::snapshot()
125 .into_iter()
126 .map(|(series, histogram)| {
127 let attributes = [
128 string_attr("http.request.method", series.method()),
129 int_attr("http.response.status_code", series.status as i64),
130 string_attr("network.protocol.name", "http"),
131 string_attr("network.protocol.version", series.protocol_version()),
132 string_attr("url.scheme", series.scheme()),
133 ]
134 .join(",");
135 let counts: Vec<String> = histogram.counts.iter().map(|c| format!("\"{c}\"")).collect();
136 let bounds: Vec<String> = http::BOUNDS.iter().map(|b| b.to_string()).collect();
137 format!(
138 "{{\"attributes\":[{attributes}],\"startTimeUnixNano\":\"{start_ns}\",\"timeUnixNano\":\"{now_ns}\",\
139 \"count\":\"{}\",\"sum\":{},\"bucketCounts\":[{}],\"explicitBounds\":[{}]}}",
140 histogram.count,
141 histogram.sum,
142 counts.join(","),
143 bounds.join(","),
144 )
145 })
146 .collect();
147 let metrics = if points.is_empty() {
148 String::new()
149 } else {
150 format!(
151 "{{\"name\":\"http.server.request.duration\",\"description\":\"Duration of HTTP server requests.\",\"unit\":\"s\",\
152 \"histogram\":{{\"aggregationTemporality\":2,\"dataPoints\":[{}]}}}}",
153 points.join(",")
154 )
155 };
156 format!(
157 "{{\"resourceMetrics\":[{{\"resource\":{{\"attributes\":[{},{}]}},\
158 \"scopeMetrics\":[{{\"scope\":{{\"name\":\"rust-web-server\",\"version\":\"{}\"}},\"metrics\":[{metrics}]}}]}}]}}",
159 string_attr("service.name", service_name),
160 string_attr("service.version", service_version),
161 env!("CARGO_PKG_VERSION"),
162 )
163}
164
165fn string_attr(key: &str, value: &str) -> String {
166 format!("{{\"key\":\"{key}\",\"value\":{{\"stringValue\":\"{}\"}}}}", super::json_escape(value))
167}
168
169fn int_attr(key: &str, value: i64) -> String {
170 format!("{{\"key\":\"{key}\",\"value\":{{\"intValue\":\"{value}\"}}}}")
171}
172
173#[cfg(test)]
174mod tests;