1use crate::model::{Event, ExecutionState, HistoryEvent, WorkflowStatus};
20use serde_json::{json, Value};
21use sha2::{Digest, Sha256};
22use std::sync::mpsc;
23use std::time::Duration;
24
25#[derive(Debug, Clone)]
26pub struct TelemetryConfig {
27 pub endpoint: String,
30 pub headers: Vec<(String, String)>,
32 pub service_name: String,
33}
34
35impl TelemetryConfig {
36 pub fn from_env() -> Option<TelemetryConfig> {
38 let endpoint = std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT")
39 .ok()
40 .filter(|e| !e.is_empty())?;
41 Some(TelemetryConfig::new(&endpoint))
42 }
43
44 pub fn new(endpoint: &str) -> TelemetryConfig {
45 let headers = std::env::var("OTEL_EXPORTER_OTLP_HEADERS")
46 .unwrap_or_default()
47 .split(',')
48 .filter_map(|kv| kv.split_once('='))
49 .map(|(k, v)| (k.trim().to_string(), v.trim().to_string()))
50 .collect();
51 TelemetryConfig {
52 endpoint: endpoint.trim_end_matches('/').to_string(),
53 headers,
54 service_name: std::env::var("OTEL_SERVICE_NAME")
55 .unwrap_or_else(|_| "orbit-runtime".into()),
56 }
57 }
58}
59
60#[derive(Debug, Clone)]
63pub struct TraceContext {
64 pub trace_id: String,
65 pub parent_span_id: Option<String>,
66}
67
68fn hex(bytes: &[u8]) -> String {
69 bytes.iter().map(|b| format!("{b:02x}")).collect()
70}
71
72fn digest(s: &str) -> [u8; 32] {
73 Sha256::digest(s.as_bytes()).into()
74}
75
76pub fn trace_id(root_execution: &str) -> String {
78 hex(&digest(&format!("orbit-trace:{root_execution}"))[..16])
79}
80
81pub fn root_span_id(execution: &str) -> String {
82 hex(&digest(&format!("orbit-span:{execution}"))[..8])
83}
84
85pub fn unit_span_id(execution: &str, unit: &str, dispatch: u32) -> String {
87 hex(&digest(&format!("orbit-span:{execution}/{unit}#{dispatch}"))[..8])
88}
89
90fn attr(key: &str, value: impl Into<Value>) -> Value {
91 let v: Value = value.into();
92 let typed = match &v {
93 Value::Bool(b) => json!({ "boolValue": b }),
94 Value::Number(n) if n.is_i64() || n.is_u64() => json!({ "intValue": n.to_string() }),
95 Value::Number(n) => json!({ "doubleValue": n }),
96 Value::String(s) => json!({ "stringValue": s }),
97 other => json!({ "stringValue": other.to_string() }),
98 };
99 json!({ "key": key, "value": typed })
100}
101
102fn nanos(ms: u64) -> String {
103 (ms as u128 * 1_000_000).to_string()
104}
105
106struct Span<'a> {
108 trace: &'a str,
109 id: &'a str,
110 parent: Option<&'a str>,
111 name: String,
112 start_ms: u64,
113 end_ms: u64,
114 attrs: Vec<Value>,
115 error: Option<String>,
116}
117
118impl Span<'_> {
119 fn encode(self) -> Value {
120 let mut s = json!({
121 "traceId": self.trace,
122 "spanId": self.id,
123 "name": self.name,
124 "kind": 1,
125 "startTimeUnixNano": nanos(self.start_ms),
126 "endTimeUnixNano": nanos(self.end_ms.max(self.start_ms)),
127 "attributes": self.attrs,
128 "status": match &self.error {
129 Some(m) => json!({ "code": 2, "message": m }),
130 None => json!({ "code": 1 }),
131 },
132 });
133 if let Some(p) = self.parent {
134 s["parentSpanId"] = json!(p);
135 }
136 s
137 }
138}
139
140pub fn spans_for(state: &ExecutionState, ctx: &TraceContext, batch: &[HistoryEvent]) -> Vec<Value> {
142 let root = root_span_id(&state.id);
143 let mut out = vec![];
144 for h in batch {
145 let ended = |key: &str, error: Option<String>, outcome: &str| -> Option<Value> {
146 let u = state.units.get(key)?;
147 let start = u.dispatched_at_ms.or(u.scheduled_at_ms)?;
148 let mut attrs = vec![
149 attr("orbit.execution_id", state.id.clone()),
150 attr("orbit.workflow", state.workflow.clone()),
151 attr("orbit.unit", key.to_string()),
152 attr("orbit.attempt", u.attempt),
153 attr("orbit.outcome", outcome.to_string()),
154 ];
155 if let Some(w) = &u.worker_id {
156 attrs.push(attr("orbit.worker_id", w.clone()));
157 }
158 if u.redelivery {
159 attrs.push(attr("orbit.redelivery", true));
160 }
161 let id = if state.run > 1 {
163 unit_span_id(
164 &state.id,
165 &format!("run{}/{key}", state.run),
166 u.dispatch_count.max(1),
167 )
168 } else {
169 unit_span_id(&state.id, key, u.dispatch_count.max(1))
170 };
171 Some(
172 Span {
173 trace: &ctx.trace_id,
174 id: &id,
175 parent: Some(&root),
176 name: format!("step {key}"),
177 start_ms: start,
178 end_ms: h.timestamp_ms,
179 attrs,
180 error,
181 }
182 .encode(),
183 )
184 };
185 let s = match &h.event {
186 Event::StepSucceeded { step, .. } => ended(step, None, "succeeded"),
187 Event::TimerFired { step } => ended(step, None, "timer fired"),
188 Event::StepAttemptFailed { step, error, .. } => ended(
189 step,
190 Some(format!("{}: {}", error.code, error.message)),
191 "failed",
192 ),
193 Event::StepAttemptUncertain { step, reason, .. } => ended(
194 step,
195 Some(format!("outcome unknown: {reason}")),
196 "uncertain",
197 ),
198 Event::WorkflowCompleted { .. }
199 | Event::WorkflowFailed { .. }
200 | Event::WorkflowCancelled { .. }
201 | Event::WorkflowTimedOut { .. } => {
202 let mut attrs = vec![
203 attr("orbit.execution_id", state.id.clone()),
204 attr("orbit.workflow", state.workflow.clone()),
205 attr("orbit.workflow_version", state.version.clone()),
206 attr("orbit.status", state.status.as_str().to_string()),
207 ];
208 if let Some(c) = state.compensation {
209 attrs.push(attr("orbit.compensation", format!("{c:?}").to_lowercase()));
210 }
211 let error = match state.status {
212 WorkflowStatus::Completed => None,
213 _ => Some(
214 state
215 .error
216 .as_ref()
217 .map(|e| format!("{}: {}", e.code, e.message))
218 .unwrap_or_else(|| state.status.as_str().to_string()),
219 ),
220 };
221 Some(
222 Span {
223 trace: &ctx.trace_id,
224 id: &root,
225 parent: ctx.parent_span_id.as_deref(),
226 name: format!("workflow {}", state.workflow),
227 start_ms: state.created_at_ms,
228 end_ms: h.timestamp_ms,
229 attrs,
230 error,
231 }
232 .encode(),
233 )
234 }
235 _ => None,
236 };
237 out.extend(s);
238 }
239 out
242}
243
244pub struct Exporter {
246 tx: mpsc::SyncSender<Vec<Value>>,
247}
248
249impl Exporter {
250 pub fn start(config: TelemetryConfig) -> Exporter {
251 let (tx, rx) = mpsc::sync_channel::<Vec<Value>>(1024);
252 std::thread::Builder::new()
253 .name("orbit-otlp".into())
254 .spawn(move || {
255 let agent: ureq::Agent = ureq::Agent::config_builder()
256 .http_status_as_error(false)
257 .timeout_global(Some(Duration::from_secs(10)))
258 .build()
259 .into();
260 let url = format!("{}/v1/traces", config.endpoint);
261 while let Ok(first) = rx.recv() {
262 let mut spans = first;
264 while let Ok(more) = rx.try_recv() {
265 spans.extend(more);
266 if spans.len() >= 512 {
267 break;
268 }
269 }
270 let body = json!({
271 "resourceSpans": [{
272 "resource": { "attributes": [
273 attr("service.name", config.service_name.clone()),
274 attr("service.version", env!("CARGO_PKG_VERSION")),
275 ]},
276 "scopeSpans": [{ "scope": { "name": "orbit-runtime", "version": env!("CARGO_PKG_VERSION") }, "spans": spans }],
277 }]
278 });
279 let mut req = agent.post(&url).header("content-type", "application/json");
280 for (k, v) in &config.headers {
281 req = req.header(k, v);
282 }
283 match req.send(body.to_string()) {
284 Ok(r) if r.status().is_success() => {}
285 Ok(r) => tracing::warn!(status = r.status().as_u16(), "OTLP collector rejected spans"),
286 Err(e) => tracing::warn!(error = %e, "cannot export spans to the OTLP collector"),
287 }
288 }
289 })
290 .expect("start OTLP exporter thread");
291 Exporter { tx }
292 }
293
294 pub fn export(&self, spans: Vec<Value>) {
295 if spans.is_empty() {
296 return;
297 }
298 if self.tx.try_send(spans).is_err() {
299 tracing::warn!("OTLP export queue is full; dropping spans");
300 }
301 }
302}