Skip to main content

flowcat_core/observer/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2//
3//! Non-intrusive pipeline monitoring (PROCESSOR-DESIGN §5.1).
4//!
5//! A [`FrameObserver`] sees every processed/pushed frame without sitting in the
6//! chain — the seam OpenTelemetry/Sentry/Langfuse/RTVI observers plug into. The
7//! [`Observer`] fan-out is cheaply clonable; hooks are invoked synchronously on
8//! the hot path **only when at least one observer is registered** (the run loop
9//! skips the call entirely otherwise — zero-cost-when-off).
10//!
11//! Mirrors pipecat `BaseObserver` (`observers/base_observer.py:70`).
12//!
13//! The [`rtvi`] sibling is a pure frame→event RTVI-protocol observer; the
14//! **network exporters** (OpenTelemetry/Sentry/Langfuse) live in
15//! `flowcat-services` so core stays dependency-light.
16
17pub mod rtvi;
18
19pub use rtvi::{
20    FunctionCallReportLevel, RtviMessage, RtviObserver, RtviObserverParams, RtviSink, VecSink,
21    MESSAGE_LABEL as RTVI_MESSAGE_LABEL, PROTOCOL_VERSION as RTVI_PROTOCOL_VERSION,
22};
23
24use std::sync::atomic::{AtomicI64, AtomicU64, Ordering};
25use std::sync::{Arc, Mutex};
26
27use async_trait::async_trait;
28
29use crate::processor::frame::{Direction, Frame, FrameKind, FrameMeta};
30use crate::processor::metrics::MetricsData;
31
32/// A processor is about to handle a frame.
33pub struct FrameEvent<'a> {
34    pub processor: &'a str,
35    pub frame: &'a Frame,
36    pub meta: &'a FrameMeta,
37    pub direction: Direction,
38    /// Pipeline-clock timestamp (ns).
39    pub timestamp_ns: i64,
40}
41
42/// A frame was pushed source→destination.
43pub struct FramePushEvent<'a> {
44    pub source: &'a str,
45    pub destination: &'a str,
46    pub frame: &'a Frame,
47    pub meta: &'a FrameMeta,
48    pub direction: Direction,
49    pub timestamp_ns: i64,
50}
51
52/// Non-intrusive observer. Mirrors pipecat `BaseObserver` (`base_observer.py`).
53#[async_trait]
54pub trait FrameObserver: Send + Sync {
55    /// A processor is about to handle a frame (`base_observer.py:79`).
56    async fn on_process(&self, _e: &FrameEvent<'_>) {}
57    /// A frame was pushed source→destination (`base_observer.py:91`).
58    async fn on_push(&self, _e: &FramePushEvent<'_>) {}
59    /// The pipeline finished starting (`base_observer.py:103`).
60    async fn on_pipeline_started(&self) {}
61}
62
63/// Cheap clonable fan-out over many observers (pipecat `TaskObserver` proxy,
64/// `task.py:401`). When empty, the run loop never calls into it.
65#[derive(Clone, Default)]
66pub struct Observer(Arc<[Arc<dyn FrameObserver>]>);
67
68impl Observer {
69    /// Build a fan-out over `observers`.
70    pub fn new(observers: Vec<Arc<dyn FrameObserver>>) -> Self {
71        Observer(observers.into())
72    }
73
74    /// Whether any observer is registered (the run loop checks this to stay
75    /// zero-cost when off — though it also `Option`-gates the whole `Observer`).
76    pub fn is_empty(&self) -> bool {
77        self.0.is_empty()
78    }
79
80    /// Fan `on_process` out to every observer.
81    pub async fn on_process(&self, e: &FrameEvent<'_>) {
82        for o in self.0.iter() {
83            o.on_process(e).await;
84        }
85    }
86
87    /// Fan `on_push` out to every observer.
88    pub async fn on_push(&self, e: &FramePushEvent<'_>) {
89        for o in self.0.iter() {
90            o.on_push(e).await;
91        }
92    }
93
94    /// Fan `on_pipeline_started` out to every observer.
95    pub async fn on_pipeline_started(&self) {
96        for o in self.0.iter() {
97            o.on_pipeline_started().await;
98        }
99    }
100}
101
102// ---------------------------------------------------------------------------
103// Built-in observers (PROCESSOR-DESIGN §5.1). Network exporters live in flowcat-services.
104// ---------------------------------------------------------------------------
105
106/// A turn boundary: who spoke and when (ns on the pipeline clock). Mirrors
107/// pipecat's `TurnTrackingObserver` output.
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub enum TurnEdge {
110    UserStarted,
111    UserStopped,
112    BotStarted,
113    BotStopped,
114}
115
116/// Tracks user/bot speaking edges → turn boundaries (pipecat
117/// `TurnTrackingObserver`). Records each boundary with its timestamp so tests
118/// and exporters can read the turn sequence.
119#[derive(Default)]
120pub struct TurnTrackingObserver {
121    edges: Mutex<Vec<(TurnEdge, i64)>>,
122}
123
124impl TurnTrackingObserver {
125    pub fn new() -> Self {
126        Self::default()
127    }
128
129    /// The recorded turn boundaries (edge, timestamp_ns), in observation order.
130    pub fn edges(&self) -> Vec<(TurnEdge, i64)> {
131        self.edges.lock().map(|g| g.clone()).unwrap_or_default()
132    }
133}
134
135#[async_trait]
136impl FrameObserver for TurnTrackingObserver {
137    async fn on_process(&self, e: &FrameEvent<'_>) {
138        let edge = match e.frame.kind() {
139            FrameKind::UserStartedSpeaking => Some(TurnEdge::UserStarted),
140            FrameKind::UserStoppedSpeaking => Some(TurnEdge::UserStopped),
141            FrameKind::BotStartedSpeaking => Some(TurnEdge::BotStarted),
142            FrameKind::BotStoppedSpeaking => Some(TurnEdge::BotStopped),
143            _ => None,
144        };
145        if let Some(edge) = edge {
146            if let Ok(mut g) = self.edges.lock() {
147                g.push((edge, e.timestamp_ns));
148            }
149        }
150    }
151}
152
153/// Measures the user-stop → first-bot-audio latency (TTFB of the bot's reply).
154/// Mirrors pipecat `UserBotLatencyObserver`.
155#[derive(Default)]
156pub struct UserBotLatencyObserver {
157    /// ns at which the user last stopped speaking; -1 = none pending.
158    user_stop_ns: AtomicI64,
159    /// last measured latency in ns; -1 = none yet.
160    last_latency_ns: AtomicI64,
161}
162
163impl UserBotLatencyObserver {
164    pub fn new() -> Self {
165        Self {
166            user_stop_ns: AtomicI64::new(-1),
167            last_latency_ns: AtomicI64::new(-1),
168        }
169    }
170
171    /// The most recently measured user-stop → bot-audio latency in ns, if any.
172    pub fn last_latency_ns(&self) -> Option<i64> {
173        let v = self.last_latency_ns.load(Ordering::Relaxed);
174        (v >= 0).then_some(v)
175    }
176}
177
178#[async_trait]
179impl FrameObserver for UserBotLatencyObserver {
180    async fn on_process(&self, e: &FrameEvent<'_>) {
181        match e.frame.kind() {
182            FrameKind::UserStoppedSpeaking => {
183                self.user_stop_ns.store(e.timestamp_ns, Ordering::Relaxed);
184            }
185            FrameKind::BotStartedSpeaking | FrameKind::TtsAudio | FrameKind::OutputAudio => {
186                let start = self.user_stop_ns.swap(-1, Ordering::Relaxed);
187                if start >= 0 {
188                    let lat = (e.timestamp_ns - start).max(0);
189                    self.last_latency_ns.store(lat, Ordering::Relaxed);
190                }
191            }
192            _ => {}
193        }
194    }
195}
196
197/// Drives idle detection (PROCESSOR-DESIGN §4.1 / pipecat `task.py:70`): bumps a
198/// monotonic counter every time one of the configured "activity" frame kinds is
199/// observed. The [`PipelineTask`](crate::pipeline::PipelineTask) idle watcher
200/// reads the counter and fires a timeout when it stops advancing.
201pub struct IdleFrameObserver {
202    activity: Vec<FrameKind>,
203    counter: AtomicU64,
204}
205
206impl IdleFrameObserver {
207    /// Observe `activity` frame kinds as "not idle" signals.
208    pub fn new(activity: Vec<FrameKind>) -> Self {
209        Self {
210            activity,
211            counter: AtomicU64::new(0),
212        }
213    }
214
215    /// The current activity counter (advances on each observed activity frame).
216    pub fn activity_count(&self) -> u64 {
217        self.counter.load(Ordering::Relaxed)
218    }
219}
220
221#[async_trait]
222impl FrameObserver for IdleFrameObserver {
223    async fn on_process(&self, e: &FrameEvent<'_>) {
224        if self.activity.contains(&e.frame.kind()) {
225            self.counter.fetch_add(1, Ordering::Relaxed);
226        }
227    }
228}
229
230/// One observed metric, tagged with the pipeline-clock timestamp it was seen at.
231/// The collected record the [`MetricsLogObserver`] keeps for inspection/export.
232#[derive(Debug, Clone, PartialEq)]
233pub struct ObservedMetric {
234    /// The metric payload (TTFB / processing / usage / turn).
235    pub data: MetricsData,
236    /// Pipeline-clock timestamp (ns) the [`Frame::Metrics`] was observed at.
237    pub timestamp_ns: i64,
238}
239
240/// Collects (and `tracing`-logs) every [`MetricsData`] flowing on
241/// [`Frame::Metrics`] — a pure, network-free metrics observer. Mirrors pipecat's
242/// `MetricsLogObserver`: it watches the push hook, de-dupes by frame id, and
243/// records each metric with its timestamp so tests (and metric exporters) can
244/// read TTFB/RTF/processing/usage semantics without a live sink.
245///
246/// "RTF" (real-time factor) is derived, not a first-class metric: it is
247/// `processing_seconds / audio_seconds` for a stage that reports both — see
248/// [`MetricsLogObserver::real_time_factor`].
249#[derive(Default)]
250pub struct MetricsLogObserver {
251    seen: Mutex<MetricsLogState>,
252}
253
254#[derive(Default)]
255struct MetricsLogState {
256    frames_seen: Vec<u64>,
257    metrics: Vec<ObservedMetric>,
258}
259
260impl MetricsLogObserver {
261    /// A fresh collector.
262    pub fn new() -> Self {
263        Self::default()
264    }
265
266    /// Every metric observed so far, in observation order.
267    pub fn metrics(&self) -> Vec<ObservedMetric> {
268        self.seen
269            .lock()
270            .map(|g| g.metrics.clone())
271            .unwrap_or_default()
272    }
273
274    /// The most-recent TTFB (seconds) reported by `processor`, if any. TTFB is
275    /// the user-stop → first-output latency a service measures via
276    /// [`Link::start_ttfb`](crate::processor::Link::start_ttfb) /
277    /// [`Link::stop_ttfb`](crate::processor::Link::stop_ttfb).
278    pub fn ttfb_seconds(&self, processor: &str) -> Option<f64> {
279        self.last_seconds(processor, |d| match d {
280            MetricsData::Ttfb {
281                processor: p,
282                seconds,
283                ..
284            } if p == processor => Some(*seconds),
285            _ => None,
286        })
287    }
288
289    /// The most-recent processing time (seconds) reported by `processor`.
290    pub fn processing_seconds(&self, processor: &str) -> Option<f64> {
291        self.last_seconds(processor, |d| match d {
292            MetricsData::Processing {
293                processor: p,
294                seconds,
295                ..
296            } if p == processor => Some(*seconds),
297            _ => None,
298        })
299    }
300
301    /// Real-time factor for `processor`: its last processing-time divided by
302    /// `audio_seconds` of audio it processed. RTF < 1 ⇒ faster than real time.
303    /// Returns `None` if no processing metric was seen or `audio_seconds == 0`.
304    pub fn real_time_factor(&self, processor: &str, audio_seconds: f64) -> Option<f64> {
305        if audio_seconds <= 0.0 {
306            return None;
307        }
308        self.processing_seconds(processor)
309            .map(|p| p / audio_seconds)
310    }
311
312    /// Total LLM tokens summed across every `LlmUsage` metric seen.
313    pub fn total_llm_tokens(&self) -> u64 {
314        self.metrics()
315            .iter()
316            .filter_map(|m| match &m.data {
317                MetricsData::LlmUsage { tokens, .. } => Some(tokens.total_tokens),
318                _ => None,
319            })
320            .sum()
321    }
322
323    fn last_seconds(
324        &self,
325        _processor: &str,
326        pick: impl Fn(&MetricsData) -> Option<f64>,
327    ) -> Option<f64> {
328        self.metrics().iter().rev().find_map(|m| pick(&m.data))
329    }
330}
331
332#[async_trait]
333impl FrameObserver for MetricsLogObserver {
334    async fn on_push(&self, e: &FramePushEvent<'_>) {
335        let Frame::Metrics(batch) = e.frame else {
336            return;
337        };
338        // Broadcast frames: only the downstream copy (avoid double counting).
339        if e.meta.broadcast_sibling_id.is_some() && e.direction != Direction::Downstream {
340            return;
341        }
342        let Ok(mut st) = self.seen.lock() else {
343            return;
344        };
345        if st.frames_seen.contains(&e.meta.id) {
346            return;
347        }
348        st.frames_seen.push(e.meta.id);
349        for d in batch {
350            tracing::debug!(processor = ?metric_processor(d), ?d, "metric");
351            st.metrics.push(ObservedMetric {
352                data: d.clone(),
353                timestamp_ns: e.timestamp_ns,
354            });
355        }
356    }
357}
358
359/// The processor name a metric is attributed to (for logging/filtering).
360fn metric_processor(d: &MetricsData) -> &str {
361    match d {
362        MetricsData::Ttfb { processor, .. }
363        | MetricsData::Processing { processor, .. }
364        | MetricsData::LlmUsage { processor, .. }
365        | MetricsData::TtsUsage { processor, .. }
366        | MetricsData::TurnPrediction { processor, .. } => processor,
367    }
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373    use crate::processor::frame::FrameMeta;
374
375    fn meta_for(f: &Frame) -> FrameMeta {
376        FrameMeta::new(f)
377    }
378
379    #[tokio::test]
380    async fn turn_tracking_emits_boundaries_off_scripted_edges() {
381        let obs = TurnTrackingObserver::new();
382        let script = [
383            (Frame::UserStartedSpeaking, 100),
384            (Frame::UserStoppedSpeaking, 200),
385            (Frame::BotStartedSpeaking, 300),
386            (Frame::BotStoppedSpeaking, 400),
387            (Frame::Text("noise".into()), 450), // ignored
388        ];
389        for (frame, ts) in script {
390            let m = meta_for(&frame);
391            let ev = FrameEvent {
392                processor: "p",
393                frame: &frame,
394                meta: &m,
395                direction: Direction::Downstream,
396                timestamp_ns: ts,
397            };
398            obs.on_process(&ev).await;
399        }
400        assert_eq!(
401            obs.edges(),
402            vec![
403                (TurnEdge::UserStarted, 100),
404                (TurnEdge::UserStopped, 200),
405                (TurnEdge::BotStarted, 300),
406                (TurnEdge::BotStopped, 400),
407            ]
408        );
409    }
410
411    #[tokio::test]
412    async fn user_bot_latency_measures_stop_to_bot_audio() {
413        let obs = UserBotLatencyObserver::new();
414        let stop = Frame::UserStoppedSpeaking;
415        let bot = Frame::BotStartedSpeaking;
416        let m1 = meta_for(&stop);
417        obs.on_process(&FrameEvent {
418            processor: "p",
419            frame: &stop,
420            meta: &m1,
421            direction: Direction::Downstream,
422            timestamp_ns: 1_000,
423        })
424        .await;
425        let m2 = meta_for(&bot);
426        obs.on_process(&FrameEvent {
427            processor: "p",
428            frame: &bot,
429            meta: &m2,
430            direction: Direction::Downstream,
431            timestamp_ns: 1_500,
432        })
433        .await;
434        assert_eq!(obs.last_latency_ns(), Some(500));
435    }
436
437    #[tokio::test]
438    async fn idle_observer_counts_only_activity_frames() {
439        let obs = IdleFrameObserver::new(vec![FrameKind::BotSpeaking, FrameKind::UserSpeaking]);
440        let frames = [
441            Frame::BotSpeaking,
442            Frame::Text("x".into()), // not activity
443            Frame::UserSpeaking,
444        ];
445        for f in frames {
446            let m = meta_for(&f);
447            obs.on_process(&FrameEvent {
448                processor: "p",
449                frame: &f,
450                meta: &m,
451                direction: Direction::Downstream,
452                timestamp_ns: 0,
453            })
454            .await;
455        }
456        assert_eq!(obs.activity_count(), 2);
457    }
458
459    #[tokio::test]
460    async fn empty_observer_fanout_is_a_noop() {
461        let obs = Observer::default();
462        assert!(obs.is_empty());
463        // No panic, nothing to assert — just that calling through is harmless.
464        let f = Frame::Text("x".into());
465        let m = meta_for(&f);
466        obs.on_process(&FrameEvent {
467            processor: "p",
468            frame: &f,
469            meta: &m,
470            direction: Direction::Downstream,
471            timestamp_ns: 0,
472        })
473        .await;
474        obs.on_pipeline_started().await;
475    }
476
477    // --- MetricsLogObserver -------------------------------------------------
478
479    use crate::processor::metrics::LlmTokenUsage;
480
481    async fn push_metrics(obs: &MetricsLogObserver, batch: Vec<MetricsData>, ts: i64) {
482        let f = Frame::Metrics(batch);
483        let m = meta_for(&f);
484        obs.on_push(&FramePushEvent {
485            source: "src",
486            destination: "dst",
487            frame: &f,
488            meta: &m,
489            direction: Direction::Downstream,
490            timestamp_ns: ts,
491        })
492        .await;
493    }
494
495    #[tokio::test]
496    async fn metrics_observer_records_ttfb_processing_and_rtf() {
497        let obs = MetricsLogObserver::new();
498        push_metrics(
499            &obs,
500            vec![
501                MetricsData::Ttfb {
502                    processor: "tts".into(),
503                    model: None,
504                    seconds: 0.25,
505                },
506                MetricsData::Processing {
507                    processor: "tts".into(),
508                    model: None,
509                    seconds: 0.5,
510                },
511            ],
512            1_000,
513        )
514        .await;
515        assert_eq!(obs.ttfb_seconds("tts"), Some(0.25));
516        assert_eq!(obs.processing_seconds("tts"), Some(0.5));
517        // RTF = processing / audio = 0.5 / 2.0 = 0.25 (faster than real time).
518        assert_eq!(obs.real_time_factor("tts", 2.0), Some(0.25));
519        // No metric for an unknown processor / zero audio.
520        assert_eq!(obs.ttfb_seconds("stt"), None);
521        assert_eq!(obs.real_time_factor("tts", 0.0), None);
522        // Both metrics recorded with their timestamp.
523        let recorded = obs.metrics();
524        assert_eq!(recorded.len(), 2);
525        assert!(recorded.iter().all(|m| m.timestamp_ns == 1_000));
526    }
527
528    #[tokio::test]
529    async fn metrics_observer_sums_llm_tokens_and_dedups() {
530        let obs = MetricsLogObserver::new();
531        let f = Frame::Metrics(vec![MetricsData::LlmUsage {
532            processor: "llm".into(),
533            model: Some("gpt".into()),
534            tokens: LlmTokenUsage {
535                total_tokens: 30,
536                ..Default::default()
537            },
538        }]);
539        let m = meta_for(&f);
540        // Same frame id seen twice (two links) → counted once.
541        for dst in ["a", "b"] {
542            obs.on_push(&FramePushEvent {
543                source: "llm",
544                destination: dst,
545                frame: &f,
546                meta: &m,
547                direction: Direction::Downstream,
548                timestamp_ns: 0,
549            })
550            .await;
551        }
552        assert_eq!(obs.total_llm_tokens(), 30);
553        assert_eq!(obs.metrics().len(), 1);
554    }
555
556    #[tokio::test]
557    async fn metrics_observer_takes_last_ttfb_for_a_processor() {
558        let obs = MetricsLogObserver::new();
559        push_metrics(
560            &obs,
561            vec![MetricsData::Ttfb {
562                processor: "llm".into(),
563                model: None,
564                seconds: 0.4,
565            }],
566            0,
567        )
568        .await;
569        push_metrics(
570            &obs,
571            vec![MetricsData::Ttfb {
572                processor: "llm".into(),
573                model: None,
574                seconds: 0.1,
575            }],
576            10,
577        )
578        .await;
579        // Most-recent wins (report_only_initial_ttfb is the producer's concern).
580        assert_eq!(obs.ttfb_seconds("llm"), Some(0.1));
581    }
582}