Skip to main content

ratel_ai_core/trace/
sink.rs

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/// A handle to one [`FanoutSink`] subscriber.
18#[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/// A sink that wraps each event once, then asynchronously dispatches the same
26/// envelope to any number of bounded subscribers.
27#[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        // Explicit context is the concurrency-safe path. The pending queues keep
96        // legacy sequential record(event) callers conformant until higher layers
97        // can carry context; same-tool calls that may finish out of order must use
98        // TraceEventContext::new_invocation().
99        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
160/// A best-effort sink for trace events. Implementations must be cheap on the
161/// hot path — see ADR-0007 for the query-log reliability profile (lossy on
162/// backpressure is fine, blocking the agent loop is not).
163///
164/// Five implementations ship with the crate: [`NoopSink`] (discard — the
165/// registries' default), [`FanoutSink`] (bounded asynchronous subscribers),
166/// [`MemorySink`] (unbounded in-memory buffer for tests and introspection),
167/// [`JsonlSink`] (append-to-file local persistence), and [`FnSink`] (hands each
168/// envelope line to a closure, for hosts this crate cannot write to itself).
169pub trait TraceSink: Send + Sync {
170    /// Record one event. Called synchronously on the hot path, so it must be
171    /// cheap and non-blocking; on failure, drop the event rather than
172    /// propagate (trace events are observations, never load-bearing).
173    fn record(&self, event: TraceEvent);
174
175    /// Record an event with correlation fields known at the emission site.
176    /// Legacy sinks may ignore the context; envelope-aware sinks preserve it.
177    fn record_with_context(&self, event: TraceEvent, _context: TraceEventContext) {
178        self.record(event);
179    }
180
181    /// Accept an event already wrapped by an upstream fan-out sink. Envelope-aware
182    /// sinks override this so identity survives fan-out; legacy sinks still see
183    /// the underlying event.
184    fn record_envelope(&self, envelope: TraceEnvelope) {
185        self.record(envelope.event);
186    }
187
188    /// Per-sink rate limit hint. Currently a documentation knob — nothing
189    /// rate-limits yet — but the contract is in place so consumers can adopt
190    /// it without a breaking change.
191    fn sample_rate(&self) -> f64 {
192        1.0
193    }
194}
195
196/// A sink that discards every event — the default of a registry built with
197/// [`crate::ToolRegistry::new`] / [`crate::SkillRegistry::new`], and the
198/// right choice when tracing is off.
199pub struct NoopSink;
200
201impl TraceSink for NoopSink {
202    fn record(&self, _event: TraceEvent) {}
203}
204
205impl FanoutSink {
206    /// Create an empty fan-out whose source defaults to `OTEL_SERVICE_NAME`,
207    /// falling back to `ratel`.
208    pub fn new(session_id: impl Into<String>) -> Self {
209        Self::with_source(session_id, default_source_id())
210    }
211
212    /// Create an empty fan-out for one session and explicit stable source.
213    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    /// Add an asynchronously dispatched subscriber with a bounded queue.
225    /// Zero capacity is treated as one so emission always has a usable slot.
226    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    /// Wait until every current subscriber finishes all accepted work.
249    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    /// Total events dropped across every subscriber since this fan-out was created.
264    /// This monotonic counter is the core seam for an OpenTelemetry counter projection.
265    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    /// Total events dropped from this subscriber's queue since subscription.
299    pub fn dropped_count(&self) -> u64 {
300        self.inner.dropped.load(Ordering::Relaxed)
301    }
302
303    /// Wait until this subscriber finishes accepted work and loss reports.
304    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
425/// A sink that buffers enveloped events in memory, for tests and in-process
426/// introspection: record, then assert on [`Self::snapshot`] or
427/// [`Self::drain`]. The buffer is unbounded, so drain it periodically if the
428/// producer is long-lived.
429pub struct MemorySink {
430    factory: EnvelopeFactory,
431    events: Mutex<Vec<TraceEnvelope>>,
432}
433
434impl MemorySink {
435    /// An empty sink stamped with `session_id` and a source defaulting to
436    /// `OTEL_SERVICE_NAME`, falling back to `ratel`.
437    pub fn new(session_id: impl Into<String>) -> Self {
438        Self::with_source(session_id, default_source_id())
439    }
440
441    /// An empty sink with explicit stable `source_id` identity.
442    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    /// A copy of the recorded envelopes, oldest first, leaving the buffer in
450    /// place.
451    pub fn snapshot(&self) -> Vec<TraceEnvelope> {
452        self.events.lock().expect("trace sink poisoned").clone()
453    }
454
455    /// Remove and return the recorded envelopes, oldest first, emptying the
456    /// buffer.
457    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    /// The session id stamped on every envelope this sink records.
463    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
485/// A sink that appends events to a JSONL file, one [`TraceEnvelope`] per
486/// line — local persistence for the offline inspector and reporting
487/// (ADR-0007; the consuming shells bucket files under `~/.ratel/telemetry/`,
488/// but the sink accepts any path). Writes are best-effort: a serialization or
489/// I/O failure drops the event rather than disturb the agent loop.
490pub struct JsonlSink {
491    factory: EnvelopeFactory,
492    file: Mutex<BufWriter<File>>,
493}
494
495impl JsonlSink {
496    /// Open (or create) the JSONL file at `path` in append mode, creating
497    /// missing parent directories. The source defaults to `OTEL_SERVICE_NAME`,
498    /// falling back to `ratel`. On Unix the file's permissions are
499    /// tightened to `0600` (best-effort) since traces can carry query text.
500    ///
501    /// # Errors
502    ///
503    /// Any [`std::io::Error`] from creating the parent directories or opening
504    /// the file.
505    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    /// Open a JSONL sink with explicit stable `source_id` identity.
510    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            // Best-effort: a write failure should not crash the agent loop.
550            let _ = writeln!(guard, "{line}");
551            let _ = guard.flush();
552        }
553    }
554}
555
556/// A sink that hands each enveloped event to a closure, for hosts whose trace
557/// destination this crate cannot own — a process-per-request server writing to
558/// a database, a language binding forwarding to its runtime, anything
559/// distributed enough that a local file is the wrong answer.
560///
561/// The closure receives the **serialized envelope line** — the same wire form
562/// [`JsonlSink`] would have written for the same event, session, and source,
563/// field for field, differing only in the two per-record identity fields every
564/// envelope-aware sink mints for itself (`ts`, sampled at wrap time, and
565/// `event_id`, a fresh ULID). Replay reads neither. That identity is the point:
566/// a host can collect lines from many processes, join them with newlines, and
567/// feed the result straight back to
568/// [`crate::ToolRegistry::build_intent_graph`] without re-deriving the wire
569/// form. It also keeps language bindings trivial — they forward a string
570/// rather than re-modelling [`TraceEnvelope`].
571///
572/// The session id stamped here is a **default, not an identity**: a host that
573/// reassembles a turn from its own storage generally knows a better one (per
574/// turn, or per client/actor/workspace unit) and may restamp the line before
575/// persisting it. Replay tracks one pending query per session in log order, so
576/// a shared id is only a problem once lines from several producers are merged
577/// without preserving each turn's search-then-invoke adjacency.
578///
579/// Best-effort like every sink: a serialization failure drops the event. A
580/// closure that panics is the host's bug and unwinds normally — keep it cheap
581/// and non-blocking, per [`TraceSink::record`].
582pub struct FnSink<F: Fn(&str) + Send + Sync> {
583    factory: EnvelopeFactory,
584    emit: F,
585}
586
587impl<F: Fn(&str) + Send + Sync> FnSink<F> {
588    /// A sink whose envelopes are stamped with `session_id` and a source
589    /// defaulting to `OTEL_SERVICE_NAME` (falling back to `ratel`), handed to
590    /// `emit` as JSON lines.
591    pub fn new(session_id: impl Into<String>, emit: F) -> Self {
592        Self::with_source(session_id, default_source_id(), emit)
593    }
594
595    /// A sink with explicit stable `source_id` identity.
596    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}