1use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
2use std::sync::mpsc::{sync_channel, Receiver, RecvTimeoutError, SyncSender, TrySendError};
3use std::sync::{Arc, Condvar, Mutex, OnceLock};
4use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
5
6use serde_json::{json, Value};
7use tracing::field::{Field, Visit};
8use tracing::span::{Attributes, Id, Record};
9use tracing::Subscriber;
10use tracing_subscriber::layer::Context;
11use tracing_subscriber::registry::LookupSpan;
12use tracing_subscriber::Layer;
13
14use crate::export;
15
16pub static EXPORTED: AtomicU64 = AtomicU64::new(0);
17pub static DROPPED_QUEUE_FULL: AtomicU64 = AtomicU64::new(0);
18pub static DROPPED_REJECTED: AtomicU64 = AtomicU64::new(0);
19pub static DROPPED_EXPORT_FAILED: AtomicU64 = AtomicU64::new(0);
20
21pub const DEFAULT_FILTER: &str = "info,superfluid_daemon::scheduler=debug";
22
23#[derive(Debug, Clone)]
24pub struct OtlpConfig {
25 pub endpoint: String,
26 pub headers: Vec<(String, String)>,
27 pub service_name: String,
28 pub resource: Vec<(String, String)>,
29 pub filter: String,
30 pub queue: usize,
31 pub batch_max: usize,
32 pub interval: Duration,
33 pub timeout: Duration,
34}
35
36impl Default for OtlpConfig {
37 fn default() -> Self {
38 OtlpConfig {
39 endpoint: String::new(),
40 headers: Vec::new(),
41 service_name: "superfluid".into(),
42 resource: Vec::new(),
43 filter: DEFAULT_FILTER.into(),
44 queue: 8192,
45 batch_max: 512,
46 interval: Duration::from_millis(1000),
47 timeout: Duration::from_millis(5000),
48 }
49 }
50}
51
52impl OtlpConfig {
53 pub fn traces_url(&self) -> String {
54 let e = self.endpoint.trim_end_matches('/');
55 if e.ends_with("/v1/traces") {
56 e.to_string()
57 } else {
58 format!("{e}/v1/traces")
59 }
60 }
61
62 pub fn validate(&self) -> Result<(), String> {
63 validate_http_url(&self.endpoint)?;
64 for (k, v) in &self.headers {
65 ureq::http::HeaderName::from_bytes(k.as_bytes())
66 .map_err(|_| format!("--otlp-header: bad header name {k:?}"))?;
67 ureq::http::HeaderValue::from_str(v)
68 .map_err(|_| format!("--otlp-header: bad value for {k:?}"))?;
69 }
70 tracing_subscriber::EnvFilter::try_new(&self.filter)
71 .map_err(|e| format!("--otlp-filter {:?}: {e}", self.filter))?;
72 if self.queue == 0 || self.batch_max == 0 {
73 return Err("--otlp-queue and the batch size must be > 0".into());
74 }
75 if self.interval.is_zero() {
76 return Err("--otlp-batch-ms must be > 0".into());
77 }
78 if self.timeout.is_zero() {
79 return Err("--otlp-timeout-ms must be > 0".into());
80 }
81 Ok(())
82 }
83
84 pub fn is_loopback(&self) -> bool {
85 let rest = self.endpoint.trim_start_matches("http://");
86 let host = rest.split('/').next().unwrap_or("");
87 let host = match host.strip_prefix('[') {
88 Some(v6) => v6.split(']').next().unwrap_or(""),
89 None => host.rsplit_once(':').map_or(host, |(h, _)| h),
90 };
91 host == "localhost"
92 || host
93 .parse::<std::net::IpAddr>()
94 .map(|ip| ip.is_loopback())
95 .unwrap_or(false)
96 }
97}
98
99pub fn validate_http_url(e: &str) -> Result<(), String> {
100 if e.starts_with("https://") {
101 return Err(format!(
102 "{e}: https is not supported (this build's HTTP client has no TLS) — \
103 point it at a local OpenTelemetry collector over http:// and let it \
104 forward with TLS"
105 ));
106 }
107 if !e.starts_with("http://") {
108 return Err(format!("{e}: expected an http:// URL"));
109 }
110 let uri: ureq::http::Uri = e
111 .parse()
112 .map_err(|err| format!("{e}: not a valid URL: {err}"))?;
113 let auth = uri
114 .authority()
115 .ok_or_else(|| format!("{e}: missing host"))?;
116 if auth.host().is_empty() {
117 return Err(format!("{e}: missing host"));
118 }
119 if let Some((_, port)) = auth.as_str().rsplit_once(':') {
120 if !auth.as_str().ends_with(']') && port.parse::<u16>().is_err() {
121 return Err(format!("{e}: bad port {port:?}"));
122 }
123 }
124 if uri.query().is_some() {
125 return Err(format!("{e}: a query string is not allowed"));
126 }
127 Ok(())
128}
129
130#[derive(Debug, Clone, Default)]
131pub struct TraceScope {
132 pub model: String,
133 pub ns: u64,
134}
135
136impl TraceScope {
137 pub fn model(model: &str) -> TraceScope {
138 TraceScope {
139 model: model.to_string(),
140 ns: 0,
141 }
142 }
143}
144
145const STRING_FIELDS: &[&str] = &["model"];
146
147#[derive(Debug, Clone)]
148enum Val {
149 I(i64),
150 U(u64),
151 F(f64),
152 B(bool),
153 S(String),
154}
155
156struct SpanData {
157 trace: [u8; 16],
158 span: [u8; 8],
159 parent: Option<[u8; 8]>,
160 start_ns: u64,
161 attrs: Vec<(&'static str, Val)>,
162 links: Vec<([u8; 16], [u8; 8])>,
163}
164
165struct Finished {
166 name: &'static str,
167 target: &'static str,
168 end_ns: u64,
169 data: SpanData,
170}
171
172struct Fields<'a>(&'a mut Vec<(&'static str, Val)>, bool);
173
174fn daemon_owned(target: &str) -> bool {
175 target == "superfluid_daemon" || target.starts_with("superfluid_daemon::")
176}
177
178impl Visit for Fields<'_> {
179 fn record_i64(&mut self, f: &Field, v: i64) {
180 self.0.push((f.name(), Val::I(v)));
181 }
182 fn record_u64(&mut self, f: &Field, v: u64) {
183 self.0.push((f.name(), Val::U(v)));
184 }
185 fn record_f64(&mut self, f: &Field, v: f64) {
186 self.0.push((f.name(), Val::F(v)));
187 }
188 fn record_bool(&mut self, f: &Field, v: bool) {
189 self.0.push((f.name(), Val::B(v)));
190 }
191 fn record_i128(&mut self, f: &Field, v: i128) {
192 if let Ok(v) = i64::try_from(v) {
193 self.record_i64(f, v);
194 }
195 }
196 fn record_u128(&mut self, f: &Field, v: u128) {
197 if let Ok(v) = u64::try_from(v) {
198 self.record_u64(f, v);
199 }
200 }
201 fn record_str(&mut self, f: &Field, v: &str) {
202 if self.1 && STRING_FIELDS.contains(&f.name()) {
203 self.0.push((f.name(), Val::S(v.to_string())));
204 }
205 }
206 fn record_debug(&mut self, _: &Field, _: &dyn std::fmt::Debug) {}
207}
208
209fn now_ns() -> u64 {
210 SystemTime::now()
211 .duration_since(UNIX_EPOCH)
212 .map(|d| d.as_nanos() as u64)
213 .unwrap_or(0)
214}
215
216fn fresh_u64() -> u64 {
217 static SEED: OnceLock<u64> = OnceLock::new();
218 static N: AtomicU64 = AtomicU64::new(0);
219 let seed = *SEED.get_or_init(|| now_ns() ^ ((std::process::id() as u64) << 32));
220 let n = N.fetch_add(1, Ordering::Relaxed);
221 xxhash_rust::xxh3::xxh3_64_with_seed(&n.to_le_bytes(), seed).max(1)
222}
223
224fn fresh_trace() -> [u8; 16] {
225 let mut t = [0u8; 16];
226 t[..8].copy_from_slice(&fresh_u64().to_be_bytes());
227 t[8..].copy_from_slice(&fresh_u64().to_be_bytes());
228 t
229}
230
231pub struct OtlpLayer {
232 tx: SyncSender<Finished>,
233}
234
235impl<S> Layer<S> for OtlpLayer
236where
237 S: Subscriber + for<'a> LookupSpan<'a>,
238{
239 fn on_new_span(&self, attrs: &Attributes<'_>, id: &Id, ctx: Context<'_, S>) {
240 let Some(span) = ctx.span(id) else {
241 return;
242 };
243 let mut fields = Vec::new();
244 let owned = daemon_owned(span.metadata().target());
245 attrs.record(&mut Fields(&mut fields, owned));
246 let parent = span
247 .parent()
248 .and_then(|p| p.extensions().get::<SpanData>().map(|d| (d.trace, d.span)));
249 let num = |name: &str| {
250 fields.iter().find_map(|(k, v)| match (*k == name, v) {
251 (true, Val::U(x)) => Some(*x),
252 (true, Val::I(x)) if *x >= 0 => Some(*x as u64),
253 _ => None,
254 })
255 };
256 let session = num("session")
257 .filter(|s| *s != 0)
258 .zip(num("store_ns").filter(|ns| *ns != 0 && owned));
259 let (trace, parent, links) = match (parent, session) {
260 (Some((t, p)), _) => (t, Some(p), Vec::new()),
261 (None, Some((s, ns))) => (
262 fresh_trace(),
263 None,
264 vec![(
265 export::trace_id_bytes_ns(ns, s),
266 export::span_id_bytes_ns(ns, s, export::ROOT),
267 )],
268 ),
269 (None, None) => (fresh_trace(), None, Vec::new()),
270 };
271 span.extensions_mut().insert(SpanData {
272 trace,
273 span: fresh_u64().to_be_bytes(),
274 parent,
275 start_ns: now_ns(),
276 attrs: fields,
277 links,
278 });
279 }
280
281 fn on_record(&self, id: &Id, values: &Record<'_>, ctx: Context<'_, S>) {
282 if let Some(span) = ctx.span(id) {
283 if let Some(d) = span.extensions_mut().get_mut::<SpanData>() {
284 let owned = daemon_owned(span.metadata().target());
285 values.record(&mut Fields(&mut d.attrs, owned));
286 }
287 }
288 }
289
290 fn on_follows_from(&self, id: &Id, follows: &Id, ctx: Context<'_, S>) {
291 if id == follows {
292 return;
293 }
294 let Some(target) = ctx
295 .span(follows)
296 .and_then(|s| s.extensions().get::<SpanData>().map(|d| (d.trace, d.span)))
297 else {
298 return;
299 };
300 if let Some(span) = ctx.span(id) {
301 if let Some(d) = span.extensions_mut().get_mut::<SpanData>() {
302 d.links.push(target);
303 }
304 }
305 }
306
307 fn on_close(&self, id: Id, ctx: Context<'_, S>) {
308 let Some(span) = ctx.span(&id) else {
309 return;
310 };
311 let Some(data) = span.extensions_mut().remove::<SpanData>() else {
312 return;
313 };
314 let meta = span.metadata();
315 let fin = Finished {
316 name: meta.name(),
317 target: meta.target(),
318 end_ns: now_ns(),
319 data,
320 };
321 match self.tx.try_send(fin) {
322 Ok(()) => {}
323 Err(TrySendError::Full(_)) | Err(TrySendError::Disconnected(_)) => {
324 DROPPED_QUEUE_FULL.fetch_add(1, Ordering::Relaxed);
325 crate::telemetry::DROPPED.fetch_add(1, Ordering::Relaxed);
326 }
327 }
328 }
329}
330
331fn rejected_spans(body: &str) -> u64 {
332 let Ok(v) = serde_json::from_str::<Value>(body) else {
333 return 0;
334 };
335 let r = &v["partialSuccess"]["rejectedSpans"];
336 r.as_u64()
337 .or_else(|| r.as_str().and_then(|s| s.parse().ok()))
338 .unwrap_or(0)
339}
340
341fn attr_key(field: &str) -> String {
342 match field {
343 "session" => "superfluid.session_id".into(),
344 f => format!("superfluid.{f}"),
345 }
346}
347
348fn val_json(v: &Val) -> Value {
349 match *v {
350 Val::I(i) => json!(i),
351 Val::U(u) => json!(u as i64),
352 Val::F(f) => json!(f),
353 Val::B(b) => json!(b),
354 Val::S(ref s) => json!(s),
355 }
356}
357
358fn span_json(s: &Finished) -> Value {
359 let d = &s.data;
360 let mut attrs = Vec::with_capacity(d.attrs.len() + 1);
361 attrs.push(export::attr("superfluid.target", json!(s.target)));
362 for (k, v) in &d.attrs {
363 attrs.push(export::attr(&attr_key(k), val_json(v)));
364 }
365 let mut o = json!({
366 "traceId": export::hex(&d.trace),
367 "spanId": export::hex(&d.span),
368 "name": s.name,
369 "kind": 1,
370 "startTimeUnixNano": d.start_ns.to_string(),
371 "endTimeUnixNano": s.end_ns.max(d.start_ns).to_string(),
372 "attributes": attrs,
373 });
374 if let Some(p) = d.parent {
375 o["parentSpanId"] = json!(export::hex(&p));
376 }
377 if !d.links.is_empty() {
378 o["links"] = Value::Array(
379 d.links
380 .iter()
381 .map(|(t, sp)| json!({"traceId": export::hex(t), "spanId": export::hex(sp)}))
382 .collect(),
383 );
384 }
385 o
386}
387
388fn encode(cfg: &OtlpConfig, host: &str, batch: &[Finished]) -> String {
389 let mut resource = vec![
390 export::attr("service.name", json!(cfg.service_name)),
391 export::attr("service.version", json!(env!("CARGO_PKG_VERSION"))),
392 export::attr("host.name", json!(host)),
393 export::attr("superfluid.signal", json!("operational")),
394 export::attr("superfluid.content_included", json!(false)),
395 ];
396 for (k, v) in &cfg.resource {
397 resource.push(export::attr(k, json!(v)));
398 }
399 let spans = batch.iter().map(span_json).collect();
400 export::trace_request(resource, "superfluid.operational", spans).to_string()
401}
402
403struct Shared {
404 stop: AtomicBool,
405 done: Mutex<bool>,
406 cv: Condvar,
407}
408
409#[derive(Clone)]
410pub struct OtlpHandle {
411 shared: Arc<Shared>,
412}
413
414impl OtlpHandle {
415 pub fn shutdown(&self, bound: Duration) -> bool {
416 self.shared.stop.store(true, Ordering::SeqCst);
417 let done = self.shared.done.lock().unwrap_or_else(|e| e.into_inner());
418 let (done, _) = self
419 .shared
420 .cv
421 .wait_timeout_while(done, bound, |d| !*d)
422 .unwrap_or_else(|e| e.into_inner());
423 *done
424 }
425}
426
427static GLOBAL: OnceLock<OtlpHandle> = OnceLock::new();
428
429pub(crate) fn set_global(h: OtlpHandle) {
430 let _ = GLOBAL.set(h);
431}
432
433pub fn shutdown(bound: Duration) -> bool {
434 GLOBAL.get().map(|h| h.shutdown(bound)).unwrap_or(true)
435}
436
437pub fn start(cfg: OtlpConfig) -> Result<(OtlpLayer, OtlpHandle), String> {
438 cfg.validate()?;
439 let (tx, rx) = sync_channel::<Finished>(cfg.queue);
440 let shared = Arc::new(Shared {
441 stop: AtomicBool::new(false),
442 done: Mutex::new(false),
443 cv: Condvar::new(),
444 });
445 let sh = Arc::clone(&shared);
446 std::thread::Builder::new()
447 .name("superfluid-otlp".into())
448 .spawn(move || {
449 run_exporter(&cfg, &rx, &sh);
450 *sh.done.lock().unwrap_or_else(|e| e.into_inner()) = true;
451 sh.cv.notify_all();
452 })
453 .map_err(|e| e.to_string())?;
454 Ok((OtlpLayer { tx }, OtlpHandle { shared }))
455}
456
457const STOP_POLL: Duration = Duration::from_millis(50);
458
459fn run_exporter(cfg: &OtlpConfig, rx: &Receiver<Finished>, sh: &Shared) {
460 let url = cfg.traces_url();
461 let host = crate::metrics::hostname();
462 let agent = ureq::Agent::config_builder()
463 .timeout_global(Some(cfg.timeout))
464 .build()
465 .new_agent();
466 let mut failing = false;
467 let mut flush = |batch: &mut Vec<Finished>| {
468 if batch.is_empty() {
469 return;
470 }
471 let n = batch.len() as u64;
472 let body = encode(cfg, &host, batch);
473 batch.clear();
474 let mut req = agent.post(&url).header("content-type", "application/json");
475 for (k, v) in &cfg.headers {
476 req = req.header(k.as_str(), v.as_str());
477 }
478 match req.send(body.as_bytes()) {
479 Ok(mut resp) => {
480 let text = resp
481 .body_mut()
482 .with_config()
483 .limit(64 * 1024)
484 .read_to_string()
485 .unwrap_or_default();
486 let rejected = rejected_spans(&text).min(n);
487 EXPORTED.fetch_add(n - rejected, Ordering::Relaxed);
488 if rejected > 0 {
489 DROPPED_REJECTED.fetch_add(rejected, Ordering::Relaxed);
490 crate::telemetry::DROPPED.fetch_add(rejected, Ordering::Relaxed);
491 }
492 if failing {
493 failing = false;
494 tracing::info!(target: "superfluid_daemon::otlp", "otlp trace export recovered");
495 }
496 }
497 Err(e) => {
498 DROPPED_EXPORT_FAILED.fetch_add(n, Ordering::Relaxed);
499 crate::telemetry::DROPPED.fetch_add(n, Ordering::Relaxed);
500 if !failing {
501 failing = true;
502 tracing::warn!(target: "superfluid_daemon::otlp", error = %e, "otlp trace export failing; spans are dropped until it recovers");
503 }
504 }
505 }
506 };
507 let mut batch: Vec<Finished> = Vec::with_capacity(cfg.batch_max);
508 let mut deadline = Instant::now() + cfg.interval;
509 loop {
510 if sh.stop.load(Ordering::SeqCst) {
511 while let Ok(s) = rx.try_recv() {
512 batch.push(s);
513 if batch.len() >= cfg.batch_max {
514 flush(&mut batch);
515 }
516 }
517 flush(&mut batch);
518 return;
519 }
520 let now = Instant::now();
521 let wait = deadline.saturating_duration_since(now).min(STOP_POLL);
522 match rx.recv_timeout(wait) {
523 Ok(s) => {
524 batch.push(s);
525 if batch.len() >= cfg.batch_max || Instant::now() >= deadline {
526 flush(&mut batch);
527 deadline = Instant::now() + cfg.interval;
528 }
529 }
530 Err(RecvTimeoutError::Timeout) => {
531 if Instant::now() >= deadline {
532 flush(&mut batch);
533 deadline = Instant::now() + cfg.interval;
534 }
535 }
536 Err(RecvTimeoutError::Disconnected) => {
537 flush(&mut batch);
538 return;
539 }
540 }
541 }
542}
543
544#[cfg(test)]
545mod tests {
546 use super::*;
547
548 #[test]
549 fn traces_url_and_validation() {
550 let c = |e: &str| OtlpConfig {
551 endpoint: e.into(),
552 ..Default::default()
553 };
554 assert_eq!(c("http://h:4318").traces_url(), "http://h:4318/v1/traces");
555 assert_eq!(c("http://h:4318/").traces_url(), "http://h:4318/v1/traces");
556 assert_eq!(
557 c("http://h:4318/v1/traces").traces_url(),
558 "http://h:4318/v1/traces"
559 );
560 assert!(c("http://h:4318").validate().is_ok());
561 assert!(c("https://h:4318").validate().unwrap_err().contains("TLS"));
562 assert!(c("h:4318").validate().is_err());
563 assert!(c("http://").validate().is_err());
564 assert!(c("http://localhost:notaport")
565 .validate()
566 .unwrap_err()
567 .contains("port"));
568 assert!(c("http://localhost:99999").validate().is_err());
569 assert!(c("http://h:4318/v1/traces?x=1").validate().is_err());
570 assert!(c("http://[::1]:4318").validate().is_ok());
571 assert!(c("http://collector/otlp").validate().is_ok());
572 let mut bad = c("http://h:1");
573 bad.headers.push(("x-auth".into(), "a\nb".into()));
574 assert!(bad.validate().unwrap_err().contains("bad value"));
575 assert!(c("http://127.0.0.1:4318").is_loopback());
576 assert!(c("http://localhost:4318").is_loopback());
577 assert!(c("http://[::1]:4318/x").is_loopback());
578 assert!(!c("http://10.0.0.2:4318").is_loopback());
579 assert!(!c("http://collector:4318").is_loopback());
580 let mut bad = c("http://h:1");
581 bad.headers.push(("x auth".into(), "v".into()));
582 assert!(bad.validate().is_err());
583 let mut bad = c("http://h:1");
584 bad.filter = "not a directive[".into();
585 assert!(bad.validate().unwrap_err().contains("--otlp-filter"));
586 let mut bad = c("http://h:1");
587 bad.interval = Duration::ZERO;
588 assert!(bad.validate().unwrap_err().contains("--otlp-batch-ms"));
589 let mut bad = c("http://h:1");
590 bad.timeout = Duration::ZERO;
591 assert!(bad.validate().unwrap_err().contains("--otlp-timeout-ms"));
592 }
593
594 #[test]
595 fn partial_success_counts_rejected_spans() {
596 assert_eq!(rejected_spans("{}"), 0);
597 assert_eq!(rejected_spans(""), 0);
598 assert_eq!(rejected_spans(r#"{"partialSuccess":{}}"#), 0);
599 assert_eq!(
600 rejected_spans(r#"{"partialSuccess":{"rejectedSpans":"3"}}"#),
601 3
602 );
603 assert_eq!(
604 rejected_spans(r#"{"partialSuccess":{"rejectedSpans":4}}"#),
605 4
606 );
607 }
608
609 #[test]
610 fn store_namespaces_session_ids() {
611 use crate::store::StoreId;
612 assert_eq!(export::trace_id_bytes_ns(0, 7), export::trace_id_bytes(7));
613 assert_eq!(export::span_id_bytes_ns(0, 7, 3), export::span_id_bytes(7, 3));
614 let a = export::store_trace_ns(&StoreId([1; 16]));
615 let b = export::store_trace_ns(&StoreId([2; 16]));
616 assert!(a != 0 && b != 0 && a != b);
617 assert!(a <= i64::MAX as u64 && b <= i64::MAX as u64, "int64 domain");
618 assert_eq!(a, export::store_trace_ns(&StoreId([1; 16])), "stable");
619 assert_ne!(export::trace_id_bytes_ns(a, 1), export::trace_id_bytes_ns(b, 1));
620 assert_ne!(export::trace_id_bytes_ns(a, 1), export::trace_id_bytes(1));
621 }
622}