use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use async_trait::async_trait;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct OtlpSpanData {
pub name: String,
pub service_name: String,
pub trace_id: String,
pub span_id: String,
pub start_unix_nano: u64,
pub end_unix_nano: u64,
pub attributes: Vec<(String, String)>,
pub events: Vec<(String, Vec<(String, String)>)>,
pub error: Option<String>,
}
#[async_trait]
pub trait OtlpTransport: Send + Sync {
async fn send(&self, endpoint: &str, body: &serde_json::Value) -> Result<(), String>;
}
pub struct HttpTransport {
timeout_ms: u64,
}
impl HttpTransport {
pub fn new(timeout_ms: u64) -> Self {
Self { timeout_ms }
}
}
#[async_trait]
impl OtlpTransport for HttpTransport {
async fn send(&self, endpoint: &str, body: &serde_json::Value) -> Result<(), String> {
let endpoint = endpoint.to_string();
let payload = serde_json::to_vec(body).map_err(|e| format!("serialize failed: {e}"))?;
let timeout_ms = self.timeout_ms;
tokio::task::spawn_blocking(move || http_post(&endpoint, &payload, timeout_ms))
.await
.map_err(|e| format!("otlp http task failed: {e}"))?
}
}
#[derive(Default)]
pub struct InMemoryTransport {
requests: std::sync::Mutex<Vec<(String, serde_json::Value)>>,
}
impl InMemoryTransport {
pub fn new() -> Self {
Self::default()
}
pub fn requests(&self) -> Vec<(String, serde_json::Value)> {
self.requests.lock().unwrap().clone()
}
pub fn clear(&self) {
self.requests.lock().unwrap().clear();
}
}
#[async_trait]
impl OtlpTransport for InMemoryTransport {
async fn send(&self, endpoint: &str, body: &serde_json::Value) -> Result<(), String> {
self.requests
.lock()
.unwrap()
.push((endpoint.to_string(), body.clone()));
Ok(())
}
}
fn http_post(endpoint: &str, body: &[u8], timeout_ms: u64) -> Result<(), String> {
use std::io::{Read, Write};
use std::net::TcpStream;
use std::time::Duration;
let (host, port, path) = parse_endpoint(endpoint)?;
let addr = format!("{host}:{port}");
let mut stream = TcpStream::connect(&addr)
.map_err(|e| format!("connect to {addr} failed: {e}"))
.and_then(|s| {
s.set_read_timeout(Some(Duration::from_millis(timeout_ms)))
.and_then(|_| s.set_write_timeout(Some(Duration::from_millis(timeout_ms))))
.map(|_| s)
.map_err(|e| e.to_string())
})
.map_err(|e| format!("prepare connection failed: {e}"))?;
let request = 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",
body.len()
);
stream
.write_all(request.as_bytes())
.and_then(|_| stream.write_all(body))
.map_err(|e| format!("write failed: {e}"))?;
let mut response = Vec::new();
stream
.read_to_end(&mut response)
.map_err(|e| format!("read failed: {e}"))?;
let text = String::from_utf8_lossy(&response);
let status = text.split_whitespace().nth(1).unwrap_or("000").to_string();
if status.starts_with('2') {
Ok(())
} else {
Err(format!("otlp endpoint returned status {status}"))
}
}
fn parse_endpoint(endpoint: &str) -> Result<(String, u16, String), String> {
let rest = endpoint
.strip_prefix("http://")
.ok_or_else(|| format!("only http endpoints supported, got: {endpoint}"))?;
let (host_port, path) = match rest.find('/') {
Some(idx) => (&rest[..idx], &rest[idx..]),
None => (rest, "/"),
};
let (host, port) = match host_port.rfind(':') {
Some(idx) => (
host_port[..idx].to_string(),
host_port[idx + 1..]
.parse::<u16>()
.map_err(|e| format!("invalid port: {e}"))?,
),
None => (host_port.to_string(), 80u16),
};
Ok((host, port, path.to_string()))
}
pub struct OtlpSpanExporter {
service_name: String,
transport: Arc<dyn OtlpTransport>,
endpoint: String,
}
impl OtlpSpanExporter {
pub fn new(
service_name: impl Into<String>,
transport: Arc<dyn OtlpTransport>,
endpoint: impl Into<String>,
) -> Self {
Self {
service_name: service_name.into(),
transport,
endpoint: endpoint.into(),
}
}
pub async fn export(&self, span: &OtlpSpanData) -> Result<(), String> {
let body = self.envelope(span);
self.transport.send(&self.endpoint, &body).await
}
pub fn envelope(&self, span: &OtlpSpanData) -> serde_json::Value {
let mut attributes: Vec<serde_json::Value> = span
.attributes
.iter()
.map(|(k, v)| attribute(k, v))
.collect();
if let Some(err) = &span.error {
attributes.push(attribute("error.message", err));
}
let events: Vec<serde_json::Value> = span
.events
.iter()
.map(|(name, attrs)| {
serde_json::json!({
"name": name,
"timeUnixNano": span.end_unix_nano.to_string(),
"attributes": attrs.iter().map(|(k, v)| attribute(k, v)).collect::<Vec<_>>(),
})
})
.collect();
serde_json::json!({
"resourceSpans": [{
"resource": {
"attributes": [attribute("service.name", &self.service_name)],
},
"scopeSpans": [{
"scope": { "name": "limiteron", "version": env!("CARGO_PKG_VERSION") },
"spans": [{
"name": span.name,
"traceId": span.trace_id,
"spanId": span.span_id,
"kind": "SPAN_KIND_INTERNAL",
"startTimeUnixNano": span.start_unix_nano.to_string(),
"endTimeUnixNano": span.end_unix_nano.to_string(),
"attributes": attributes,
"events": events,
"status": if span.error.is_some() { "STATUS_CODE_ERROR" } else { "STATUS_CODE_UNSET" },
}],
}],
}]
})
}
pub fn spawn_worker(
exporter: OtlpSpanExporter,
channel_capacity: usize,
) -> (SpanSink, SpanExportWorker) {
let (tx, rx) = tokio::sync::mpsc::channel(channel_capacity.max(1));
let sink = SpanSink {
service_name: exporter.service_name.clone(),
sender: tx,
};
(
sink,
SpanExportWorker {
exporter: Arc::new(exporter),
receiver: rx,
},
)
}
}
fn attribute(k: &str, v: &str) -> serde_json::Value {
serde_json::json!({ "key": k, "value": { "stringValue": v } })
}
#[derive(Clone, Debug)]
pub struct SpanSink {
service_name: String,
sender: tokio::sync::mpsc::Sender<OtlpSpanData>,
}
impl SpanSink {
pub fn service_name(&self) -> &str {
&self.service_name
}
pub fn submit(&self, span: OtlpSpanData) {
if let Err(e) = self.sender.try_send(span) {
log::warn!(target: "telemetry", "otlp span dropped: {e}");
}
}
}
pub struct SpanExportWorker {
exporter: Arc<OtlpSpanExporter>,
receiver: tokio::sync::mpsc::Receiver<OtlpSpanData>,
}
impl SpanExportWorker {
pub async fn run(mut self) {
while let Some(span) = self.receiver.recv().await {
if let Err(e) = self.exporter.export(&span).await {
log::warn!(target: "telemetry", "otlp export failed for span {}: {e}", span.name);
}
}
}
}
pub(crate) fn next_id_hex(bytes: usize) -> String {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0x9e37_79b9_7f4a_7c15);
const GOLDEN: u64 = 0x9e37_79b9_7f4a_7c15;
let mut out = String::with_capacity(bytes * 2);
while out.len() < bytes * 2 {
let n = COUNTER.fetch_add(GOLDEN, Ordering::Relaxed);
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
out.push_str(&format!("{:016x}", n.rotate_left(17) ^ ts));
}
out.truncate(bytes * 2);
out
}
pub(crate) fn unix_nano_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_span() -> OtlpSpanData {
OtlpSpanData {
name: "governor_check".to_string(),
service_name: "limiteron".to_string(),
trace_id: "0af7651916cd43dd8448eb211c80319c".to_string(),
span_id: "b7ad6b7169203331".to_string(),
start_unix_nano: 1_000,
end_unix_nano: 2_000,
attributes: vec![("rule.id".to_string(), "r1".to_string())],
events: vec![],
error: None,
}
}
#[test]
fn test_t606_envelope_structure() {
let exporter = OtlpSpanExporter::new(
"limiteron",
Arc::new(InMemoryTransport::new()),
"http://127.0.0.1:4318/v1/traces",
);
let env = exporter.envelope(&sample_span());
let rs = env["resourceSpans"].as_array().unwrap();
assert_eq!(rs.len(), 1);
assert_eq!(rs[0]["scopeSpans"][0]["spans"][0]["name"], "governor_check");
let attrs = rs[0]["scopeSpans"][0]["spans"][0]["attributes"]
.as_array()
.unwrap();
assert_eq!(attrs[0]["key"], "rule.id");
assert_eq!(attrs[0]["value"]["stringValue"], "r1");
}
#[tokio::test]
async fn test_t606_in_memory_transport_receives_export() {
let transport = Arc::new(InMemoryTransport::new());
let exporter = OtlpSpanExporter::new(
"limiteron",
transport.clone(),
"http://collector:4318/v1/traces",
);
exporter.export(&sample_span()).await.unwrap();
let requests = transport.requests();
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].0, "http://collector:4318/v1/traces");
assert!(requests[0].1["resourceSpans"].is_array());
}
#[test]
fn test_t606_parse_endpoint() {
assert_eq!(
parse_endpoint("http://localhost:4318/v1/traces").unwrap(),
("localhost".to_string(), 4318u16, "/v1/traces".to_string())
);
assert_eq!(
parse_endpoint("http://collector").unwrap(),
("collector".to_string(), 80u16, "/".to_string())
);
assert!(parse_endpoint("https://collector").is_err());
}
}