use std::sync::Mutex;
use std::sync::OnceLock;
use std::time::{Instant, SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone)]
pub struct OtelConfig {
pub endpoint: String,
pub service_name: String,
}
impl Default for OtelConfig {
fn default() -> Self {
Self {
endpoint: "http://127.0.0.1:4318".to_string(),
service_name: "sdforge".to_string(),
}
}
}
static SPAN_ID: AtomicCounter = AtomicCounter::new(1);
static TRACE_ID: AtomicCounter = AtomicCounter::new(1);
struct AtomicCounter(std::sync::atomic::AtomicU64);
impl AtomicCounter {
const fn new(start: u64) -> Self {
Self(std::sync::atomic::AtomicU64::new(start))
}
fn next(&self) -> u64 {
self.0.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
}
}
fn hex_id(value: u64, width: usize) -> String {
format!("{:0width$x}", value, width = width)
}
#[derive(Debug, Clone)]
pub struct SpanData {
pub trace_id: String,
pub span_id: String,
pub name: String,
pub start_unix_ms: u128,
pub end_unix_ms: u128,
pub duration_ms: u64,
pub attributes: Vec<(String, serde_json::Value)>,
}
#[derive(Debug)]
pub struct ActiveSpan {
trace_id: String,
span_id: String,
name: String,
attributes: Vec<(String, serde_json::Value)>,
start_instant: Instant,
start_unix_ms: u128,
}
pub fn start_span(name: impl Into<String>) -> ActiveSpan {
let trace_id = hex_id(TRACE_ID.next(), 32);
let span_id = hex_id(SPAN_ID.next(), 16);
ActiveSpan {
trace_id,
span_id,
name: name.into(),
attributes: Vec::new(),
start_instant: Instant::now(),
start_unix_ms: unix_ms(),
}
}
pub fn with_attr(mut span: ActiveSpan, key: &str, value: serde_json::Value) -> ActiveSpan {
span.attributes.push((key.to_string(), value));
span
}
pub fn finish_span(span: ActiveSpan) {
let duration = span.start_instant.elapsed();
let data = SpanData {
trace_id: span.trace_id,
span_id: span.span_id,
name: span.name,
start_unix_ms: span.start_unix_ms,
end_unix_ms: unix_ms(),
duration_ms: duration.as_millis() as u64,
attributes: span.attributes,
};
if let Ok(mut guard) = span_buffer().lock() {
guard.push(data);
}
}
fn unix_ms() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0)
}
static SPAN_BUFFER: OnceLock<Mutex<Vec<SpanData>>> = OnceLock::new();
fn span_buffer() -> &'static Mutex<Vec<SpanData>> {
SPAN_BUFFER.get_or_init(|| Mutex::new(Vec::new()))
}
pub fn take_spans() -> Vec<SpanData> {
span_buffer()
.lock()
.map(|mut guard| std::mem::take(&mut *guard))
.unwrap_or_default()
}
pub fn buffered_span_count() -> usize {
span_buffer().lock().map(|g| g.len()).unwrap_or(0)
}
pub fn build_traces_payload(service_name: &str, spans: &[SpanData]) -> serde_json::Value {
let spans_json: Vec<serde_json::Value> = spans
.iter()
.map(|s| {
let attributes: Vec<serde_json::Value> = s
.attributes
.iter()
.map(|(k, v)| {
serde_json::json!({
"key": k,
"value": {"stringValue": v.to_string()},
})
})
.collect();
serde_json::json!({
"traceId": s.trace_id,
"spanId": s.span_id,
"name": s.name,
"kind": "SPAN_KIND_SERVER",
"startTimeUnixNano": (s.start_unix_ms as u64) * 1_000_000u64,
"endTimeUnixNano": (s.end_unix_ms as u64) * 1_000_000u64,
"attributes": attributes,
})
})
.collect();
serde_json::json!({
"resourceSpans": [{
"resource": {
"attributes": [
{"key": "service.name", "value": {"stringValue": service_name}},
{"key": "telemetry.sdk.name", "value": {"stringValue": "sdforge"}},
],
},
"scopeSpans": [{
"scope": {"name": "sdforge-otel"},
"spans": spans_json,
}],
}],
})
}
pub async fn otel_span_middleware(
req: axum::http::Request<axum::body::Body>,
next: axum::middleware::Next,
) -> axum::response::Response {
let route = req
.extensions()
.get::<axum::extract::MatchedPath>()
.map(|mp| mp.as_str().to_string())
.unwrap_or_else(|| "unmatched".to_string());
let span = start_span("http.request");
let span = with_attr(
span,
"http.method",
serde_json::json!(req.method().to_string()),
);
let span = with_attr(span, "http.route", serde_json::json!(route));
let response = next.run(req).await;
let span = with_attr(
span,
"http.status_code",
serde_json::json!(response.status().as_u16()),
);
finish_span(span);
response
}
pub fn post_json(url: &str, body: &serde_json::Value) -> Result<u16, String> {
use std::io::{Read, Write};
let rest = url
.strip_prefix("http://")
.ok_or_else(|| "otel endpoint must be http:// in MVP".to_string())?;
let (host_port, path) = rest.split_once('/').unwrap_or((rest, ""));
let (host, port) = match host_port.rsplit_once(':') {
Some((h, p)) => (h.to_string(), p.parse::<u16>().map_err(|e| e.to_string())?),
None => (host_port.to_string(), 80u16),
};
let path = format!("/{path}");
let payload = body.to_string();
let mut stream = std::net::TcpStream::connect((host.as_str(), port))
.map_err(|e| format!("connect failed: {e}"))?;
let req = format!(
"POST {path} HTTP/1.1\r\nHost: {host}:{port}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
payload.len(),
payload
);
stream
.write_all(req.as_bytes())
.map_err(|e| format!("write failed: {e}"))?;
let mut response = String::new();
stream
.read_to_string(&mut response)
.map_err(|e| format!("read failed: {e}"))?;
let status_line = response.lines().next().unwrap_or_default();
let status = status_line
.split_whitespace()
.nth(1)
.and_then(|s| s.parse::<u16>().ok())
.ok_or_else(|| "malformed response".to_string())?;
Ok(status)
}
pub fn flush_spans(config: &OtelConfig) -> Result<u16, String> {
let spans = take_spans();
if spans.is_empty() {
return Ok(200);
}
post_json(
&format!("{}/v1/traces", config.endpoint),
&build_traces_payload(&config.service_name, &spans),
)
}
pub fn install(config: OtelConfig, interval: std::time::Duration) {
tokio::spawn(async move {
loop {
tokio::time::sleep(interval).await;
if let Err(e) = flush_spans(&config) {
log::warn!("otel: span flush failed: {e}");
}
#[cfg(feature = "metrics")]
{
if let Err(e) = flush_metrics(&config) {
log::warn!("otel: metrics flush failed: {e}");
}
}
}
});
}
#[cfg(feature = "metrics")]
pub fn flush_metrics(config: &OtelConfig) -> Result<u16, String> {
let registry = crate::metrics::global_registry();
let text = registry.render();
let mut total: u64 = 0;
for line in text.lines() {
if let Some(rest) = line.strip_prefix("sdforge_http_requests_total{")
&& let Some(value) = rest.rsplit(' ').next().and_then(|v| v.parse::<u64>().ok())
{
total += value;
}
}
let payload = serde_json::json!({
"resourceMetrics": [{
"resource": {"attributes": [
{"key": "service.name", "value": {"stringValue": config.service_name}},
]},
"scopeMetrics": [{
"scope": {"name": "sdforge-otel"},
"metrics": [{
"name": "sdforge_http_requests_total",
"sum": {"dataPoints": [{"asInt": total}]},
}],
}],
}],
});
post_json(&format!("{}/v1/metrics", config.endpoint), &payload)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn span_lifecycle_records_data() {
let span = start_span("unit.op");
let span = with_attr(span, "k", serde_json::json!("v"));
finish_span(span);
assert!(buffered_span_count() >= 1);
let spans = take_spans();
assert!(spans.iter().any(|s| {
s.name == "unit.op"
&& s.attributes
.iter()
.any(|(k, v)| k == "k" && v == &serde_json::json!("v"))
}));
assert_eq!(buffered_span_count(), 0);
}
#[test]
fn trace_payload_shape_is_otlp_json() {
let span = start_span("shape.check");
finish_span(span);
let spans = take_spans();
let payload = build_traces_payload("svc-under-test", &spans);
let rs = payload["resourceSpans"][0].clone();
assert_eq!(
rs["resource"]["attributes"][0]["key"], "service.name",
"resource attributes must carry service.name"
);
assert_eq!(
rs["resource"]["attributes"][0]["value"]["stringValue"],
"svc-under-test"
);
let span_json = &rs["scopeSpans"][0]["spans"][0];
assert_eq!(span_json["name"], "shape.check");
assert_eq!(span_json["traceId"].as_str().unwrap().len(), 32);
assert_eq!(span_json["spanId"].as_str().unwrap().len(), 16);
}
#[test]
fn post_json_rejects_non_http_schemes() {
assert!(post_json("https://collector:4318/v1/traces", &serde_json::json!({})).is_err());
}
}