use crate::model::{Event, ExecutionState, HistoryEvent, WorkflowStatus};
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use std::sync::mpsc;
use std::time::Duration;
#[derive(Debug, Clone)]
pub struct TelemetryConfig {
pub endpoint: String,
pub headers: Vec<(String, String)>,
pub service_name: String,
}
impl TelemetryConfig {
pub fn from_env() -> Option<TelemetryConfig> {
let endpoint = std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT")
.ok()
.filter(|e| !e.is_empty())?;
Some(TelemetryConfig::new(&endpoint))
}
pub fn new(endpoint: &str) -> TelemetryConfig {
let headers = std::env::var("OTEL_EXPORTER_OTLP_HEADERS")
.unwrap_or_default()
.split(',')
.filter_map(|kv| kv.split_once('='))
.map(|(k, v)| (k.trim().to_string(), v.trim().to_string()))
.collect();
TelemetryConfig {
endpoint: endpoint.trim_end_matches('/').to_string(),
headers,
service_name: std::env::var("OTEL_SERVICE_NAME")
.unwrap_or_else(|_| "orbit-runtime".into()),
}
}
}
#[derive(Debug, Clone)]
pub struct TraceContext {
pub trace_id: String,
pub parent_span_id: Option<String>,
}
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
fn digest(s: &str) -> [u8; 32] {
Sha256::digest(s.as_bytes()).into()
}
pub fn trace_id(root_execution: &str) -> String {
hex(&digest(&format!("orbit-trace:{root_execution}"))[..16])
}
pub fn root_span_id(execution: &str) -> String {
hex(&digest(&format!("orbit-span:{execution}"))[..8])
}
pub fn unit_span_id(execution: &str, unit: &str, dispatch: u32) -> String {
hex(&digest(&format!("orbit-span:{execution}/{unit}#{dispatch}"))[..8])
}
fn attr(key: &str, value: impl Into<Value>) -> Value {
let v: Value = value.into();
let typed = match &v {
Value::Bool(b) => json!({ "boolValue": b }),
Value::Number(n) if n.is_i64() || n.is_u64() => json!({ "intValue": n.to_string() }),
Value::Number(n) => json!({ "doubleValue": n }),
Value::String(s) => json!({ "stringValue": s }),
other => json!({ "stringValue": other.to_string() }),
};
json!({ "key": key, "value": typed })
}
fn nanos(ms: u64) -> String {
(ms as u128 * 1_000_000).to_string()
}
struct Span<'a> {
trace: &'a str,
id: &'a str,
parent: Option<&'a str>,
name: String,
start_ms: u64,
end_ms: u64,
attrs: Vec<Value>,
error: Option<String>,
}
impl Span<'_> {
fn encode(self) -> Value {
let mut s = json!({
"traceId": self.trace,
"spanId": self.id,
"name": self.name,
"kind": 1,
"startTimeUnixNano": nanos(self.start_ms),
"endTimeUnixNano": nanos(self.end_ms.max(self.start_ms)),
"attributes": self.attrs,
"status": match &self.error {
Some(m) => json!({ "code": 2, "message": m }),
None => json!({ "code": 1 }),
},
});
if let Some(p) = self.parent {
s["parentSpanId"] = json!(p);
}
s
}
}
pub fn spans_for(state: &ExecutionState, ctx: &TraceContext, batch: &[HistoryEvent]) -> Vec<Value> {
let root = root_span_id(&state.id);
let mut out = vec![];
for h in batch {
let ended = |key: &str, error: Option<String>, outcome: &str| -> Option<Value> {
let u = state.units.get(key)?;
let start = u.dispatched_at_ms.or(u.scheduled_at_ms)?;
let mut attrs = vec![
attr("orbit.execution_id", state.id.clone()),
attr("orbit.workflow", state.workflow.clone()),
attr("orbit.unit", key.to_string()),
attr("orbit.attempt", u.attempt),
attr("orbit.outcome", outcome.to_string()),
];
if let Some(w) = &u.worker_id {
attrs.push(attr("orbit.worker_id", w.clone()));
}
if u.redelivery {
attrs.push(attr("orbit.redelivery", true));
}
let id = unit_span_id(&state.id, key, u.dispatch_count.max(1));
Some(
Span {
trace: &ctx.trace_id,
id: &id,
parent: Some(&root),
name: format!("step {key}"),
start_ms: start,
end_ms: h.timestamp_ms,
attrs,
error,
}
.encode(),
)
};
let s = match &h.event {
Event::StepSucceeded { step, .. } => ended(step, None, "succeeded"),
Event::TimerFired { step } => ended(step, None, "timer fired"),
Event::StepAttemptFailed { step, error, .. } => ended(
step,
Some(format!("{}: {}", error.code, error.message)),
"failed",
),
Event::StepAttemptUncertain { step, reason, .. } => ended(
step,
Some(format!("outcome unknown: {reason}")),
"uncertain",
),
Event::WorkflowCompleted { .. }
| Event::WorkflowFailed { .. }
| Event::WorkflowCancelled { .. }
| Event::WorkflowTimedOut { .. } => {
let mut attrs = vec![
attr("orbit.execution_id", state.id.clone()),
attr("orbit.workflow", state.workflow.clone()),
attr("orbit.workflow_version", state.version.clone()),
attr("orbit.status", state.status.as_str().to_string()),
];
if let Some(c) = state.compensation {
attrs.push(attr("orbit.compensation", format!("{c:?}").to_lowercase()));
}
let error = match state.status {
WorkflowStatus::Completed => None,
_ => Some(
state
.error
.as_ref()
.map(|e| format!("{}: {}", e.code, e.message))
.unwrap_or_else(|| state.status.as_str().to_string()),
),
};
Some(
Span {
trace: &ctx.trace_id,
id: &root,
parent: ctx.parent_span_id.as_deref(),
name: format!("workflow {}", state.workflow),
start_ms: state.created_at_ms,
end_ms: h.timestamp_ms,
attrs,
error,
}
.encode(),
)
}
_ => None,
};
out.extend(s);
}
out
}
pub struct Exporter {
tx: mpsc::SyncSender<Vec<Value>>,
}
impl Exporter {
pub fn start(config: TelemetryConfig) -> Exporter {
let (tx, rx) = mpsc::sync_channel::<Vec<Value>>(1024);
std::thread::Builder::new()
.name("orbit-otlp".into())
.spawn(move || {
let agent: ureq::Agent = ureq::Agent::config_builder()
.http_status_as_error(false)
.timeout_global(Some(Duration::from_secs(10)))
.build()
.into();
let url = format!("{}/v1/traces", config.endpoint);
while let Ok(first) = rx.recv() {
let mut spans = first;
while let Ok(more) = rx.try_recv() {
spans.extend(more);
if spans.len() >= 512 {
break;
}
}
let body = json!({
"resourceSpans": [{
"resource": { "attributes": [
attr("service.name", config.service_name.clone()),
attr("service.version", env!("CARGO_PKG_VERSION")),
]},
"scopeSpans": [{ "scope": { "name": "orbit-runtime", "version": env!("CARGO_PKG_VERSION") }, "spans": spans }],
}]
});
let mut req = agent.post(&url).header("content-type", "application/json");
for (k, v) in &config.headers {
req = req.header(k, v);
}
match req.send(body.to_string()) {
Ok(r) if r.status().is_success() => {}
Ok(r) => tracing::warn!(status = r.status().as_u16(), "OTLP collector rejected spans"),
Err(e) => tracing::warn!(error = %e, "cannot export spans to the OTLP collector"),
}
}
})
.expect("start OTLP exporter thread");
Exporter { tx }
}
pub fn export(&self, spans: Vec<Value>) {
if spans.is_empty() {
return;
}
if self.tx.try_send(spans).is_err() {
tracing::warn!("OTLP export queue is full; dropping spans");
}
}
}