1use std::collections::{HashMap, VecDeque};
2use std::fs::{File, OpenOptions};
3use std::io::{BufWriter, Write};
4use std::path::{Path, PathBuf};
5use std::sync::atomic::{AtomicU64, Ordering};
6use std::sync::{Arc, Condvar, Mutex, Weak};
7use std::thread;
8use std::time::{SystemTime, UNIX_EPOCH};
9
10use crate::trace::event::{TraceEnvelope, TraceEvent, TraceEventContext};
11
12const DEFAULT_SOURCE_ID: &str = "ratel";
13const ENVELOPE_VERSION: u32 = 2;
14const MAX_PENDING_INVOCATIONS_PER_TOOL: usize = 1_024;
15const QUEUE_OVERFLOW: &str = "queue_overflow";
16
17#[must_use = "dropping the handle unsubscribes the sink"]
19pub struct FanoutSubscription {
20 id: u64,
21 inner: Arc<Subscriber>,
22 owner: Weak<FanoutInner>,
23}
24
25#[derive(Clone)]
28pub struct FanoutSink {
29 inner: Arc<FanoutInner>,
30}
31
32struct FanoutInner {
33 factory: Arc<EnvelopeFactory>,
34 subscribers: Mutex<HashMap<u64, Arc<Subscriber>>>,
35 dropped: AtomicU64,
36 next_id: AtomicU64,
37}
38
39struct Subscriber {
40 capacity: usize,
41 dropped: AtomicU64,
42 sink: Arc<dyn TraceSink>,
43 state: Mutex<SubscriberState>,
44 changed: Condvar,
45}
46
47#[derive(Default)]
48struct SubscriberState {
49 queue: VecDeque<TraceEnvelope>,
50 pending_loss: Option<DropWindow>,
51 delivering: bool,
52 closed: bool,
53}
54
55struct DropWindow {
56 count: u64,
57 start_ts: u64,
58 end_ts: u64,
59}
60
61struct EnvelopeFactory {
62 session_id: String,
63 source_id: String,
64 pending_invocations: Mutex<HashMap<String, VecDeque<String>>>,
65}
66
67impl EnvelopeFactory {
68 fn new(session_id: impl Into<String>, source_id: impl Into<String>) -> Self {
69 Self {
70 session_id: session_id.into(),
71 source_id: source_id.into(),
72 pending_invocations: Mutex::new(HashMap::new()),
73 }
74 }
75
76 fn wrap(&self, event: TraceEvent, mut context: TraceEventContext) -> TraceEnvelope {
77 self.correlate_invocation(&event, &mut context);
78 TraceEnvelope {
79 v: ENVELOPE_VERSION,
80 event_id: context.event_id.take().unwrap_or_else(new_ulid),
81 ts: now_ms(),
82 session_id: self.session_id.clone(),
83 source_id: self.source_id.clone(),
84 invocation_id: context.invocation_id,
85 catalog_version: context.catalog_version,
86 environment: context.environment,
87 end_user_id: context.end_user_id,
88 trace_id: context.trace_id,
89 span_id: context.span_id,
90 event,
91 }
92 }
93
94 fn correlate_invocation(&self, event: &TraceEvent, context: &mut TraceEventContext) {
95 match event {
100 TraceEvent::InvokeStart { tool_id, .. } => {
101 let has_explicit_invocation = context.invocation_id.is_some();
102 let invocation_id = context.invocation_id.get_or_insert_with(new_ulid).clone();
103 if has_explicit_invocation {
104 return;
105 }
106 if let Ok(mut pending) = self.pending_invocations.lock() {
107 let ids = pending.entry(tool_id.clone()).or_default();
108 if ids.len() == MAX_PENDING_INVOCATIONS_PER_TOOL {
109 ids.pop_front();
110 }
111 ids.push_back(invocation_id);
112 }
113 }
114 TraceEvent::InvokeEnd { tool_id, .. } | TraceEvent::InvokeError { tool_id, .. } => {
115 if context.invocation_id.is_none() {
116 context.invocation_id =
117 self.take_invocation(tool_id).or_else(|| Some(new_ulid()));
118 } else {
119 self.remove_invocation(tool_id, context.invocation_id.as_deref());
120 }
121 }
122 TraceEvent::SkillInvoke { .. }
123 | TraceEvent::GatewayInvoke { .. }
124 | TraceEvent::GatewayError { .. }
125 | TraceEvent::UpstreamInvoke { .. }
126 | TraceEvent::UpstreamError { .. } => {
127 context.invocation_id.get_or_insert_with(new_ulid);
128 }
129 _ => {}
130 }
131 }
132
133 fn take_invocation(&self, tool_id: &str) -> Option<String> {
134 let mut pending = self.pending_invocations.lock().ok()?;
135 let ids = pending.get_mut(tool_id)?;
136 let invocation_id = ids.pop_front();
137 if ids.is_empty() {
138 pending.remove(tool_id);
139 }
140 invocation_id
141 }
142
143 fn remove_invocation(&self, tool_id: &str, invocation_id: Option<&str>) {
144 let Some(invocation_id) = invocation_id else {
145 return;
146 };
147 let Ok(mut pending) = self.pending_invocations.lock() else {
148 return;
149 };
150 let Some(ids) = pending.get_mut(tool_id) else {
151 return;
152 };
153 ids.retain(|id| id != invocation_id);
154 if ids.is_empty() {
155 pending.remove(tool_id);
156 }
157 }
158}
159
160pub trait TraceSink: Send + Sync {
170 fn record(&self, event: TraceEvent);
174
175 fn record_with_context(&self, event: TraceEvent, _context: TraceEventContext) {
178 self.record(event);
179 }
180
181 fn record_envelope(&self, envelope: TraceEnvelope) {
185 self.record(envelope.event);
186 }
187
188 fn sample_rate(&self) -> f64 {
192 1.0
193 }
194}
195
196pub struct NoopSink;
200
201impl TraceSink for NoopSink {
202 fn record(&self, _event: TraceEvent) {}
203}
204
205impl FanoutSink {
206 pub fn new(session_id: impl Into<String>) -> Self {
209 Self::with_source(session_id, default_source_id())
210 }
211
212 pub fn with_source(session_id: impl Into<String>, source_id: impl Into<String>) -> Self {
214 Self {
215 inner: Arc::new(FanoutInner {
216 factory: Arc::new(EnvelopeFactory::new(session_id, source_id)),
217 subscribers: Mutex::new(HashMap::new()),
218 dropped: AtomicU64::new(0),
219 next_id: AtomicU64::new(1),
220 }),
221 }
222 }
223
224 pub fn subscribe(&self, sink: Arc<dyn TraceSink>, queue_capacity: usize) -> FanoutSubscription {
227 let id = self.inner.next_id.fetch_add(1, Ordering::Relaxed);
228 let subscriber = Arc::new(Subscriber {
229 capacity: queue_capacity.max(1),
230 dropped: AtomicU64::new(0),
231 sink,
232 state: Mutex::new(SubscriberState::default()),
233 changed: Condvar::new(),
234 });
235 self.inner
236 .subscribers
237 .lock()
238 .unwrap_or_else(std::sync::PoisonError::into_inner)
239 .insert(id, subscriber.clone());
240 spawn_dispatcher(subscriber.clone(), self.inner.factory.clone());
241 FanoutSubscription {
242 id,
243 inner: subscriber,
244 owner: Arc::downgrade(&self.inner),
245 }
246 }
247
248 pub fn flush(&self) {
250 let subscribers: Vec<_> = self
251 .inner
252 .subscribers
253 .lock()
254 .unwrap_or_else(std::sync::PoisonError::into_inner)
255 .values()
256 .cloned()
257 .collect();
258 for subscriber in subscribers {
259 subscriber.flush();
260 }
261 }
262
263 pub fn dropped_count(&self) -> u64 {
266 self.inner.dropped.load(Ordering::Relaxed)
267 }
268}
269
270impl TraceSink for FanoutSink {
271 fn record(&self, event: TraceEvent) {
272 self.record_with_context(event, TraceEventContext::default());
273 }
274
275 fn record_with_context(&self, event: TraceEvent, context: TraceEventContext) {
276 let envelope = self.inner.factory.wrap(event, context);
277 self.record_envelope(envelope);
278 }
279
280 fn record_envelope(&self, envelope: TraceEnvelope) {
281 let subscribers: Vec<_> = self
282 .inner
283 .subscribers
284 .lock()
285 .unwrap_or_else(std::sync::PoisonError::into_inner)
286 .values()
287 .cloned()
288 .collect();
289 for subscriber in subscribers {
290 if subscriber.enqueue(envelope.clone()) {
291 self.inner.dropped.fetch_add(1, Ordering::Relaxed);
292 }
293 }
294 }
295}
296
297impl FanoutSubscription {
298 pub fn dropped_count(&self) -> u64 {
300 self.inner.dropped.load(Ordering::Relaxed)
301 }
302
303 pub fn flush(&self) {
305 self.inner.flush();
306 }
307}
308
309impl Drop for FanoutSubscription {
310 fn drop(&mut self) {
311 if let Some(owner) = self.owner.upgrade()
312 && let Ok(mut subscribers) = owner.subscribers.lock()
313 {
314 subscribers.remove(&self.id);
315 }
316 self.inner.close();
317 }
318}
319
320impl Drop for FanoutInner {
321 fn drop(&mut self) {
322 let subscribers = self
323 .subscribers
324 .get_mut()
325 .unwrap_or_else(std::sync::PoisonError::into_inner);
326 for subscriber in subscribers.values() {
327 subscriber.close();
328 }
329 }
330}
331
332impl Subscriber {
333 fn enqueue(&self, envelope: TraceEnvelope) -> bool {
334 let Ok(mut state) = self.state.lock() else {
335 return false;
336 };
337 if state.closed {
338 return false;
339 }
340 let dropped = state.queue.len() == self.capacity;
341 if dropped {
342 state.queue.pop_front();
343 let dropped_at = now_ms();
344 let loss = state.pending_loss.get_or_insert(DropWindow {
345 count: 0,
346 start_ts: dropped_at,
347 end_ts: dropped_at,
348 });
349 loss.count += 1;
350 loss.end_ts = dropped_at;
351 self.dropped.fetch_add(1, Ordering::Relaxed);
352 }
353 state.queue.push_back(envelope);
354 self.changed.notify_one();
355 dropped
356 }
357
358 fn flush(&self) {
359 let mut state = self
360 .state
361 .lock()
362 .unwrap_or_else(std::sync::PoisonError::into_inner);
363 while !state.queue.is_empty() || state.pending_loss.is_some() || state.delivering {
364 state = self
365 .changed
366 .wait(state)
367 .unwrap_or_else(std::sync::PoisonError::into_inner);
368 }
369 }
370
371 fn close(&self) {
372 if let Ok(mut state) = self.state.lock() {
373 state.closed = true;
374 self.changed.notify_all();
375 }
376 }
377}
378
379fn spawn_dispatcher(subscriber: Arc<Subscriber>, factory: Arc<EnvelopeFactory>) {
380 thread::spawn(move || dispatch(subscriber, factory));
381}
382
383fn dispatch(subscriber: Arc<Subscriber>, factory: Arc<EnvelopeFactory>) {
384 loop {
385 let envelope = {
386 let mut state = subscriber
387 .state
388 .lock()
389 .unwrap_or_else(std::sync::PoisonError::into_inner);
390 while state.queue.is_empty() && state.pending_loss.is_none() && !state.closed {
391 state = subscriber
392 .changed
393 .wait(state)
394 .unwrap_or_else(std::sync::PoisonError::into_inner);
395 }
396 let envelope = if let Some(loss) = state.pending_loss.take() {
397 factory.wrap(
398 TraceEvent::EventsDropped {
399 dropped_count: loss.count,
400 reason: QUEUE_OVERFLOW.into(),
401 window_start_ts: loss.start_ts,
402 window_end_ts: loss.end_ts,
403 },
404 TraceEventContext::default(),
405 )
406 } else if let Some(envelope) = state.queue.pop_front() {
407 envelope
408 } else {
409 return;
410 };
411 state.delivering = true;
412 envelope
413 };
414
415 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
416 subscriber.sink.record_envelope(envelope);
417 }));
418 if let Ok(mut state) = subscriber.state.lock() {
419 state.delivering = false;
420 subscriber.changed.notify_all();
421 }
422 }
423}
424
425pub struct MemorySink {
430 factory: EnvelopeFactory,
431 events: Mutex<Vec<TraceEnvelope>>,
432}
433
434impl MemorySink {
435 pub fn new(session_id: impl Into<String>) -> Self {
438 Self::with_source(session_id, default_source_id())
439 }
440
441 pub fn with_source(session_id: impl Into<String>, source_id: impl Into<String>) -> Self {
443 Self {
444 factory: EnvelopeFactory::new(session_id, source_id),
445 events: Mutex::new(Vec::new()),
446 }
447 }
448
449 pub fn snapshot(&self) -> Vec<TraceEnvelope> {
452 self.events.lock().expect("trace sink poisoned").clone()
453 }
454
455 pub fn drain(&self) -> Vec<TraceEnvelope> {
458 let mut guard = self.events.lock().expect("trace sink poisoned");
459 std::mem::take(&mut *guard)
460 }
461
462 pub fn session_id(&self) -> &str {
464 &self.factory.session_id
465 }
466}
467
468impl TraceSink for MemorySink {
469 fn record(&self, event: TraceEvent) {
470 self.record_with_context(event, TraceEventContext::default());
471 }
472
473 fn record_with_context(&self, event: TraceEvent, context: TraceEventContext) {
474 let envelope = self.factory.wrap(event, context);
475 self.record_envelope(envelope);
476 }
477
478 fn record_envelope(&self, envelope: TraceEnvelope) {
479 if let Ok(mut guard) = self.events.lock() {
480 guard.push(envelope);
481 }
482 }
483}
484
485pub struct JsonlSink {
491 factory: EnvelopeFactory,
492 file: Mutex<BufWriter<File>>,
493}
494
495impl JsonlSink {
496 pub fn new(session_id: impl Into<String>, path: impl AsRef<Path>) -> std::io::Result<Self> {
506 Self::with_source(session_id, default_source_id(), path)
507 }
508
509 pub fn with_source(
511 session_id: impl Into<String>,
512 source_id: impl Into<String>,
513 path: impl AsRef<Path>,
514 ) -> std::io::Result<Self> {
515 let path: PathBuf = path.as_ref().to_path_buf();
516 if let Some(parent) = path.parent()
517 && !parent.as_os_str().is_empty()
518 {
519 std::fs::create_dir_all(parent)?;
520 }
521 let file = OpenOptions::new().create(true).append(true).open(&path)?;
522 #[cfg(unix)]
523 {
524 use std::os::unix::fs::PermissionsExt;
525 let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
526 }
527 Ok(Self {
528 factory: EnvelopeFactory::new(session_id, source_id),
529 file: Mutex::new(BufWriter::new(file)),
530 })
531 }
532}
533
534impl TraceSink for JsonlSink {
535 fn record(&self, event: TraceEvent) {
536 self.record_with_context(event, TraceEventContext::default());
537 }
538
539 fn record_with_context(&self, event: TraceEvent, context: TraceEventContext) {
540 let envelope = self.factory.wrap(event, context);
541 self.record_envelope(envelope);
542 }
543
544 fn record_envelope(&self, envelope: TraceEnvelope) {
545 let Ok(line) = serde_json::to_string(&envelope) else {
546 return;
547 };
548 if let Ok(mut guard) = self.file.lock() {
549 let _ = writeln!(guard, "{line}");
551 let _ = guard.flush();
552 }
553 }
554}
555
556pub struct FnSink<F: Fn(&str) + Send + Sync> {
583 factory: EnvelopeFactory,
584 emit: F,
585}
586
587impl<F: Fn(&str) + Send + Sync> FnSink<F> {
588 pub fn new(session_id: impl Into<String>, emit: F) -> Self {
592 Self::with_source(session_id, default_source_id(), emit)
593 }
594
595 pub fn with_source(
597 session_id: impl Into<String>,
598 source_id: impl Into<String>,
599 emit: F,
600 ) -> Self {
601 Self {
602 factory: EnvelopeFactory::new(session_id, source_id),
603 emit,
604 }
605 }
606}
607
608impl<F: Fn(&str) + Send + Sync> TraceSink for FnSink<F> {
609 fn record(&self, event: TraceEvent) {
610 self.record_with_context(event, TraceEventContext::default());
611 }
612
613 fn record_with_context(&self, event: TraceEvent, context: TraceEventContext) {
614 let envelope = self.factory.wrap(event, context);
615 self.record_envelope(envelope);
616 }
617
618 fn record_envelope(&self, envelope: TraceEnvelope) {
619 let Ok(line) = serde_json::to_string(&envelope) else {
620 return;
621 };
622 (self.emit)(&line);
623 }
624}
625
626fn now_ms() -> u64 {
627 SystemTime::now()
628 .duration_since(UNIX_EPOCH)
629 .map(|d| d.as_millis() as u64)
630 .unwrap_or(0)
631}
632
633fn new_ulid() -> String {
634 ulid::Ulid::new().to_string()
635}
636
637fn default_source_id() -> String {
638 std::env::var("OTEL_SERVICE_NAME")
639 .ok()
640 .filter(|value| !value.is_empty())
641 .unwrap_or_else(|| DEFAULT_SOURCE_ID.into())
642}