use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Condvar, Mutex, OnceLock};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use crate::metrics::http;
#[derive(Clone, Debug)]
pub struct MetricsExportConfig {
pub endpoint: String,
pub interval: Duration,
pub service_name: String,
pub service_version: String,
pub headers: Vec<(String, String)>,
}
struct Exporter {
config: MetricsExportConfig,
start_ns: u64,
stop: AtomicBool,
wake: (Mutex<()>, Condvar),
}
static EXPORTER: OnceLock<Exporter> = OnceLock::new();
fn now_ns() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_nanos() as u64).unwrap_or(0)
}
pub fn start(config: MetricsExportConfig) {
let interval = config.interval.max(Duration::from_millis(100));
let exporter = Exporter { config, start_ns: now_ns(), stop: AtomicBool::new(false), wake: (Mutex::new(()), Condvar::new()) };
if EXPORTER.set(exporter).is_err() {
return;
}
let _ = std::thread::Builder::new().name("rws-otlp-metrics".to_string()).spawn(move || {
let exporter = EXPORTER.get().expect("set above");
loop {
let guard = exporter.wake.0.lock().unwrap_or_else(|e| e.into_inner());
let _ = exporter.wake.1.wait_timeout(guard, interval);
if exporter.stop.load(Ordering::Relaxed) {
return;
}
export(exporter);
}
});
}
pub fn start_from_env() -> bool {
let var = |name: &str| std::env::var(name).ok().map(|v| v.trim().to_string()).filter(|v| !v.is_empty());
if var("OTEL_METRICS_EXPORTER").is_some_and(|v| v.eq_ignore_ascii_case("none")) {
return false;
}
let Some(endpoint) = var("OTEL_EXPORTER_OTLP_METRICS_ENDPOINT")
.or_else(|| var("OTEL_EXPORTER_OTLP_ENDPOINT").map(|base| format!("{}/v1/metrics", base.trim_end_matches('/'))))
else {
return false;
};
let interval = var("OTEL_METRIC_EXPORT_INTERVAL").and_then(|v| v.parse().ok()).unwrap_or(60_000);
let headers = var("OTEL_EXPORTER_OTLP_METRICS_HEADERS").or_else(|| var("OTEL_EXPORTER_OTLP_HEADERS")).map(|h| super::parse_headers(&h)).unwrap_or_default();
start(MetricsExportConfig {
endpoint,
interval: Duration::from_millis(interval),
service_name: var("OTEL_SERVICE_NAME").unwrap_or_else(|| "rws".to_string()),
service_version: env!("CARGO_PKG_VERSION").to_string(),
headers,
});
true
}
pub(crate) fn shutdown() {
if let Some(exporter) = EXPORTER.get() {
if !exporter.stop.swap(true, Ordering::Relaxed) {
exporter.wake.1.notify_all();
export(exporter);
}
}
}
fn export(exporter: &Exporter) {
let body = body(&exporter.config.service_name, &exporter.config.service_version, exporter.start_ns, now_ns());
let client = crate::http_client::Client::new().timeout_ms(10_000);
let mut request = client.post(&exporter.config.endpoint).body_json(&body);
for (name, value) in &exporter.config.headers {
request = request.header(name, value);
}
match request.send() {
Ok(response) if response.is_success() => {}
Ok(response) => eprintln!("OTLP metrics export to {}: HTTP {}", exporter.config.endpoint, response.status()),
Err(e) => eprintln!("OTLP metrics export to {}: {}", exporter.config.endpoint, e),
}
}
pub fn body(service_name: &str, service_version: &str, start_ns: u64, now_ns: u64) -> String {
let points: Vec<String> = http::snapshot()
.into_iter()
.map(|(series, histogram)| {
let attributes = [
string_attr("http.request.method", series.method()),
int_attr("http.response.status_code", series.status as i64),
string_attr("network.protocol.name", "http"),
string_attr("network.protocol.version", series.protocol_version()),
string_attr("url.scheme", series.scheme()),
]
.join(",");
let counts: Vec<String> = histogram.counts.iter().map(|c| format!("\"{c}\"")).collect();
let bounds: Vec<String> = http::BOUNDS.iter().map(|b| b.to_string()).collect();
format!(
"{{\"attributes\":[{attributes}],\"startTimeUnixNano\":\"{start_ns}\",\"timeUnixNano\":\"{now_ns}\",\
\"count\":\"{}\",\"sum\":{},\"bucketCounts\":[{}],\"explicitBounds\":[{}]}}",
histogram.count,
histogram.sum,
counts.join(","),
bounds.join(","),
)
})
.collect();
let metrics = if points.is_empty() {
String::new()
} else {
format!(
"{{\"name\":\"http.server.request.duration\",\"description\":\"Duration of HTTP server requests.\",\"unit\":\"s\",\
\"histogram\":{{\"aggregationTemporality\":2,\"dataPoints\":[{}]}}}}",
points.join(",")
)
};
format!(
"{{\"resourceMetrics\":[{{\"resource\":{{\"attributes\":[{},{}]}},\
\"scopeMetrics\":[{{\"scope\":{{\"name\":\"rust-web-server\",\"version\":\"{}\"}},\"metrics\":[{metrics}]}}]}}]}}",
string_attr("service.name", service_name),
string_attr("service.version", service_version),
env!("CARGO_PKG_VERSION"),
)
}
fn string_attr(key: &str, value: &str) -> String {
format!("{{\"key\":\"{key}\",\"value\":{{\"stringValue\":\"{}\"}}}}", super::json_escape(value))
}
fn int_attr(key: &str, value: i64) -> String {
format!("{{\"key\":\"{key}\",\"value\":{{\"intValue\":\"{value}\"}}}}")
}
#[cfg(test)]
mod tests;