Use on_pipeline_started from PipelineTask for startup report

Replace the PipelineSink detection in StartupTimingObserver with an
on_pipeline_started() callback from PipelineTask via TaskObserver.
This fixes premature report emission when using ParallelPipeline,
which has its own inner PipelineSinks per branch.
This commit is contained in:
Mark Backman
2026-03-02 14:33:55 -05:00
parent 0cfd953a90
commit 389d0c3fb6
4 changed files with 37 additions and 17 deletions

View File

@@ -100,3 +100,11 @@ class BaseObserver(BaseObject):
data: The event data containing details about the frame transfer. data: The event data containing details about the frame transfer.
""" """
pass pass
async def on_pipeline_started(self):
"""Called when the pipeline has fully started.
Fired after the ``StartFrame`` has been processed by all processors
in the pipeline, including nested ``ParallelPipeline`` branches.
"""
pass

View File

@@ -43,11 +43,11 @@ from pydantic import BaseModel, Field
from pipecat.frames.frames import BotConnectedFrame, ClientConnectedFrame, StartFrame from pipecat.frames.frames import BotConnectedFrame, ClientConnectedFrame, StartFrame
from pipecat.observers.base_observer import BaseObserver, FrameProcessed, FramePushed from pipecat.observers.base_observer import BaseObserver, FrameProcessed, FramePushed
from pipecat.pipeline.base_pipeline import BasePipeline from pipecat.pipeline.base_pipeline import BasePipeline
from pipecat.pipeline.pipeline import PipelineSink, PipelineSource from pipecat.pipeline.pipeline import PipelineSource
from pipecat.processors.frame_processor import FrameProcessor from pipecat.processors.frame_processor import FrameProcessor
# Internal pipeline types excluded from tracking by default. # Internal pipeline types excluded from tracking by default.
_INTERNAL_TYPES = (PipelineSink, PipelineSource, BasePipeline) _INTERNAL_TYPES = (PipelineSource, BasePipeline)
@dataclass @dataclass
@@ -118,9 +118,9 @@ class StartupTimingObserver(BaseObserver):
- ``client_connected_secs``: When a remote participant connects - ``client_connected_secs``: When a remote participant connects
(triggered by ``ClientConnectedFrame``). (triggered by ``ClientConnectedFrame``).
By default, internal pipeline processors (``PipelineSource``, ``PipelineSink``, By default, internal pipeline processors (``PipelineSource``, ``Pipeline``)
``Pipeline``) are excluded from the report. Pass ``processor_types`` to are excluded from the report. Pass ``processor_types`` to measure only
measure only specific types. specific types.
Event handlers available: Event handlers available:
@@ -211,12 +211,19 @@ class StartupTimingObserver(BaseObserver):
# Default: exclude internal pipeline plumbing. # Default: exclude internal pipeline plumbing.
return not isinstance(processor, _INTERNAL_TYPES) return not isinstance(processor, _INTERNAL_TYPES)
async def on_pipeline_started(self):
"""Emit the startup timing report when the pipeline has fully started.
Called by the ``PipelineTask`` after the ``StartFrame`` has been
processed by all processors, including nested ``ParallelPipeline``
branches.
"""
if self._timings:
await self._emit_report()
async def on_process_frame(self, data: FrameProcessed): async def on_process_frame(self, data: FrameProcessed):
"""Record when a StartFrame arrives at a processor. """Record when a StartFrame arrives at a processor.
When a ``StartFrame`` reaches a ``PipelineSink``, startup is complete
(the frame has traversed the entire pipeline) and the report is emitted.
Args: Args:
data: The frame processing event data. data: The frame processing event data.
""" """
@@ -234,14 +241,6 @@ class StartupTimingObserver(BaseObserver):
elif data.frame.id != self._start_frame_id: elif data.frame.id != self._start_frame_id:
return return
# When the StartFrame reaches a PipelineSink, all processors have
# completed start(). PipelineSinks use direct mode so the outermost
# sink fires last within the same synchronous call chain.
if isinstance(data.processor, PipelineSink):
if self._timings:
await self._emit_report()
return
if self._should_track(data.processor): if self._should_track(data.processor):
self._arrivals[data.processor.id] = _ArrivalInfo( self._arrivals[data.processor.id] = _ArrivalInfo(
processor=data.processor, arrival_ts_ns=data.timestamp processor=data.processor, arrival_ts_ns=data.timestamp

View File

@@ -915,6 +915,7 @@ class PipelineTask(BasePipelineTask):
if isinstance(frame, StartFrame): if isinstance(frame, StartFrame):
await self._call_event_handler("on_pipeline_started", frame) await self._call_event_handler("on_pipeline_started", frame)
await self._observer.on_pipeline_started()
# Start heartbeat tasks now that StartFrame has been processed # Start heartbeat tasks now that StartFrame has been processed
# by all processors in the pipeline # by all processors in the pipeline

View File

@@ -39,6 +39,12 @@ class Proxy:
observer: BaseObserver observer: BaseObserver
class _PipelineStartedSignal:
"""Internal sentinel queued to observers when the pipeline has started."""
pass
class TaskObserver(BaseObserver): class TaskObserver(BaseObserver):
"""Proxy observer that manages multiple observers without blocking the pipeline. """Proxy observer that manages multiple observers without blocking the pipeline.
@@ -129,6 +135,10 @@ class TaskObserver(BaseObserver):
for proxy in self._proxies: for proxy in self._proxies:
await proxy.cleanup() await proxy.cleanup()
async def on_pipeline_started(self):
"""Forward pipeline started signal to all managed observers."""
await self._send_to_proxy(_PipelineStartedSignal())
async def on_process_frame(self, data: FrameProcessed): async def on_process_frame(self, data: FrameProcessed):
"""Queue frame data for all managed observers. """Queue frame data for all managed observers.
@@ -186,7 +196,9 @@ class TaskObserver(BaseObserver):
while True: while True:
data = await queue.get() data = await queue.get()
if isinstance(data, FramePushed): if isinstance(data, _PipelineStartedSignal):
await observer.on_pipeline_started()
elif isinstance(data, FramePushed):
if on_push_frame_deprecated: if on_push_frame_deprecated:
await observer.on_push_frame( await observer.on_push_frame(
data.source, data.destination, data.frame, data.direction, data.timestamp data.source, data.destination, data.frame, data.direction, data.timestamp