Add BotConnectedFrame and on_transport_timing_report event

Add BotConnectedFrame (SystemFrame) pushed by SFU transports (Daily,
LiveKit, HeyGen, Tavus) when the bot joins the room. Replace the
on_transport_readiness_measured event with on_transport_timing_report
which includes both bot_connected_secs and client_connected_secs.
This commit is contained in:
Mark Backman
2026-03-02 10:41:05 -05:00
parent de87894778
commit 68e8732e72
12 changed files with 215 additions and 57 deletions

View File

@@ -0,0 +1 @@
Added `BotConnectedFrame` for SFU transports and `on_transport_timing_report` event to `StartupTimingObserver` with bot and client connection timing.

View File

@@ -111,9 +111,11 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
for timing in report.processor_timings: for timing in report.processor_timings:
logger.info(f" {timing.processor_name}: {timing.duration_secs:.3f}s") logger.info(f" {timing.processor_name}: {timing.duration_secs:.3f}s")
@startup_observer.event_handler("on_transport_readiness_measured") @startup_observer.event_handler("on_transport_timing_report")
async def on_transport_readiness_measured(observer, report): async def on_transport_timing_report(observer, report):
logger.info(f"Transport readiness: {report.readiness_secs:.3f}s") if report.bot_connected_secs is not None:
logger.info(f"Bot connected: {report.bot_connected_secs:.3f}s")
logger.info(f"Client connected: {report.client_connected_secs:.3f}s")
turn_observer = task.turn_tracking_observer turn_observer = task.turn_tracking_observer
if turn_observer: if turn_observer:

View File

@@ -1910,6 +1910,18 @@ class StopFrame(ControlFrame, UninterruptibleFrame):
pass pass
@dataclass
class BotConnectedFrame(SystemFrame):
"""Frame indicating the bot has connected to the transport service.
Pushed downstream by SFU transports (Daily, LiveKit, HeyGen, Tavus)
when the bot successfully joins the room. Non-SFU transports do not
emit this frame.
"""
pass
@dataclass @dataclass
class ClientConnectedFrame(SystemFrame): class ClientConnectedFrame(SystemFrame):
"""Frame indicating that a client has connected to the transport. """Frame indicating that a client has connected to the transport.

View File

@@ -12,9 +12,9 @@ when a ``StartFrame`` arrives at a processor (``on_process_frame``) versus
when it leaves (``on_push_frame``), giving the exact ``start()`` duration when it leaves (``on_push_frame``), giving the exact ``start()`` duration
for each processor in the pipeline. for each processor in the pipeline.
It also measures transport readiness — the time from ``StartFrame`` to the It also measures transport timing — the time from ``StartFrame`` to the
first ``ClientConnectedFrame`` — via a separate ``on_transport_readiness_measured`` first ``BotConnectedFrame`` (SFU transports only) and ``ClientConnectedFrame``
event. — via a separate ``on_transport_timing_report`` event.
Example:: Example::
@@ -25,9 +25,11 @@ Example::
for t in report.processor_timings: for t in report.processor_timings:
print(f"{t.processor_name}: {t.duration_secs:.3f}s") print(f"{t.processor_name}: {t.duration_secs:.3f}s")
@observer.event_handler("on_transport_readiness_measured") @observer.event_handler("on_transport_timing_report")
async def on_readiness(observer, report): async def on_transport(observer, report):
print(f"Transport ready in {report.readiness_secs:.3f}s") if report.bot_connected_secs is not None:
print(f"Bot connected in {report.bot_connected_secs:.3f}s")
print(f"Client connected in {report.client_connected_secs:.3f}s")
task = PipelineTask(pipeline, observers=[observer]) task = PipelineTask(pipeline, observers=[observer])
""" """
@@ -35,9 +37,7 @@ Example::
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Dict, List, Optional, Tuple, Type from typing import Dict, List, Optional, Tuple, Type
from loguru import logger from pipecat.frames.frames import BotConnectedFrame, ClientConnectedFrame, StartFrame
from pipecat.frames.frames import 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 PipelineSink, PipelineSource
@@ -74,14 +74,17 @@ class StartupTimingReport:
@dataclass @dataclass
class TransportReadinessReport: class TransportTimingReport:
"""Time from pipeline start to first client connection. """Time from pipeline start to transport connection milestones.
Parameters: Parameters:
readiness_secs: Seconds from StartFrame to first ClientConnectedFrame. bot_connected_secs: Seconds from StartFrame to first BotConnectedFrame
(only set for SFU transports).
client_connected_secs: Seconds from StartFrame to first ClientConnectedFrame.
""" """
readiness_secs: float bot_connected_secs: Optional[float] = None
client_connected_secs: Optional[float] = None
class StartupTimingObserver(BaseObserver): class StartupTimingObserver(BaseObserver):
@@ -92,9 +95,13 @@ class StartupTimingObserver(BaseObserver):
pushed downstream. This captures WebSocket connections, API authentication, pushed downstream. This captures WebSocket connections, API authentication,
model loading, and other initialization work. model loading, and other initialization work.
Also measures transport readiness — the time from ``StartFrame`` to the Also measures transport timing, the time from ``StartFrame`` to connection
first ``ClientConnectedFrame`` — indicating how long it takes for a client milestones:
to connect after the pipeline starts.
- ``bot_connected_secs``: When the bot joins the transport room
(SFU transports only, triggered by ``BotConnectedFrame``).
- ``client_connected_secs``: When a remote participant connects
(triggered by ``ClientConnectedFrame``).
By default, internal pipeline processors (``PipelineSource``, ``PipelineSink``, By default, internal pipeline processors (``PipelineSource``, ``PipelineSink``,
``Pipeline``) are excluded from the report. Pass ``processor_types`` to ``Pipeline``) are excluded from the report. Pass ``processor_types`` to
@@ -104,8 +111,9 @@ class StartupTimingObserver(BaseObserver):
- on_startup_timing_report: Called once after startup completes with the full - on_startup_timing_report: Called once after startup completes with the full
timing report. timing report.
- on_transport_readiness_measured: Called once when the first client connects with the - on_transport_timing_report: Called once when the first client connects with a
transport readiness timing. TransportTimingReport containing client_connected_secs and bot_connected_secs
(if available).
Example:: Example::
@@ -118,9 +126,11 @@ class StartupTimingObserver(BaseObserver):
for t in report.processor_timings: for t in report.processor_timings:
logger.info(f"{t.processor_name}: {t.duration_secs:.3f}s") logger.info(f"{t.processor_name}: {t.duration_secs:.3f}s")
@observer.event_handler("on_transport_readiness_measured") @observer.event_handler("on_transport_timing_report")
async def on_readiness(observer, report): async def on_transport(observer, report):
logger.info(f"Transport ready in {report.readiness_secs:.3f}s") if report.bot_connected_secs is not None:
logger.info(f"Bot connected in {report.bot_connected_secs:.3f}s")
logger.info(f"Client connected in {report.client_connected_secs:.3f}s")
task = PipelineTask(pipeline, observers=[observer]) task = PipelineTask(pipeline, observers=[observer])
@@ -157,14 +167,17 @@ class StartupTimingObserver(BaseObserver):
# Whether we've already emitted the startup timing report. # Whether we've already emitted the startup timing report.
self._startup_timing_reported = False self._startup_timing_reported = False
# Whether we've already measured transport readiness. # Whether we've already measured transport timing.
self._transport_readiness_measured = False self._transport_timing_reported = False
# Timestamp (ns) when we first see a StartFrame arrive at a processor. # Timestamp (ns) when we first see a StartFrame arrive at a processor.
self._start_frame_arrival_ns: Optional[int] = None self._start_frame_arrival_ns: Optional[int] = None
# Bot connected timing (stored for inclusion in the transport report).
self._bot_connected_secs: Optional[float] = None
self._register_event_handler("on_startup_timing_report") self._register_event_handler("on_startup_timing_report")
self._register_event_handler("on_transport_readiness_measured") self._register_event_handler("on_transport_timing_report")
def _should_track(self, processor: FrameProcessor) -> bool: def _should_track(self, processor: FrameProcessor) -> bool:
"""Check if a processor should be tracked for timing. """Check if a processor should be tracked for timing.
@@ -216,11 +229,16 @@ class StartupTimingObserver(BaseObserver):
async def on_push_frame(self, data: FramePushed): async def on_push_frame(self, data: FramePushed):
"""Record when a StartFrame leaves a processor and compute the delta. """Record when a StartFrame leaves a processor and compute the delta.
Also handles ``ClientConnectedFrame`` to measure transport readiness. Also handles ``BotConnectedFrame`` and ``ClientConnectedFrame`` to
measure transport timing.
Args: Args:
data: The frame push event data. data: The frame push event data.
""" """
if isinstance(data.frame, BotConnectedFrame):
self._handle_bot_connected(data)
return
if isinstance(data.frame, ClientConnectedFrame): if isinstance(data.frame, ClientConnectedFrame):
await self._handle_client_connected(data) await self._handle_client_connected(data)
return return
@@ -249,16 +267,27 @@ class StartupTimingObserver(BaseObserver):
) )
) )
async def _handle_client_connected(self, data: FramePushed): def _handle_bot_connected(self, data: FramePushed):
"""Measure transport readiness on first client connection.""" """Record bot connected timing on first BotConnectedFrame."""
if self._transport_readiness_measured or self._start_frame_arrival_ns is None: if self._bot_connected_secs is not None or self._start_frame_arrival_ns is None:
return return
self._transport_readiness_measured = True
delta_ns = data.timestamp - self._start_frame_arrival_ns delta_ns = data.timestamp - self._start_frame_arrival_ns
readiness_secs = delta_ns / 1e9 self._bot_connected_secs = delta_ns / 1e9
report = TransportReadinessReport(readiness_secs=readiness_secs)
await self._call_event_handler("on_transport_readiness_measured", report) async def _handle_client_connected(self, data: FramePushed):
"""Emit transport timing report on first ClientConnectedFrame."""
if self._transport_timing_reported or self._start_frame_arrival_ns is None:
return
self._transport_timing_reported = True
delta_ns = data.timestamp - self._start_frame_arrival_ns
client_connected_secs = delta_ns / 1e9
report = TransportTimingReport(
bot_connected_secs=self._bot_connected_secs,
client_connected_secs=client_connected_secs,
)
await self._call_event_handler("on_transport_timing_report", report)
async def _emit_report(self): async def _emit_report(self):
"""Build and emit the startup timing report.""" """Build and emit the startup timing report."""

View File

@@ -62,10 +62,12 @@ class HeyGenCallbacks(BaseModel):
"""Callback handlers for HeyGen events. """Callback handlers for HeyGen events.
Parameters: Parameters:
on_participant_connected: Called when a participant connects on_connected: Called when the bot connects to the LiveKit room.
on_participant_disconnected: Called when a participant disconnects on_participant_connected: Called when a participant connects.
on_participant_disconnected: Called when a participant disconnects.
""" """
on_connected: Callable[[], Awaitable[None]]
on_participant_connected: Callable[[str], Awaitable[None]] on_participant_connected: Callable[[str], Awaitable[None]]
on_participant_disconnected: Callable[[str], Awaitable[None]] on_participant_disconnected: Callable[[str], Awaitable[None]]
@@ -251,6 +253,7 @@ class HeyGenClient:
logger.debug(f"HeyGenClient send_interval: {self._send_interval}") logger.debug(f"HeyGenClient send_interval: {self._send_interval}")
await self._ws_connect() await self._ws_connect()
await self._livekit_connect() await self._livekit_connect()
self._call_event_callback(self._callbacks.on_connected)
async def stop(self) -> None: async def stop(self) -> None:
"""Stop the client and terminate all connections. """Stop the client and terminate all connections.

View File

@@ -128,6 +128,7 @@ class HeyGenVideoService(AIService):
session_request=self._session_request, session_request=self._session_request,
service_type=self._service_type, service_type=self._service_type,
callbacks=HeyGenCallbacks( callbacks=HeyGenCallbacks(
on_connected=self._on_connected,
on_participant_connected=self._on_participant_connected, on_participant_connected=self._on_participant_connected,
on_participant_disconnected=self._on_participant_disconnected, on_participant_disconnected=self._on_participant_disconnected,
), ),
@@ -144,6 +145,10 @@ class HeyGenVideoService(AIService):
await self._client.cleanup() await self._client.cleanup()
self._client = None self._client = None
async def _on_connected(self):
"""Handle bot connected to LiveKit room."""
logger.info("HeyGen bot connected to LiveKit room")
async def _on_participant_connected(self, participant_id: str): async def _on_participant_connected(self, participant_id: str):
"""Handle participant connected events.""" """Handle participant connected events."""
logger.info(f"Participant connected {participant_id}") logger.info(f"Participant connected {participant_id}")

View File

@@ -94,6 +94,7 @@ class TavusVideoService(AIService):
""" """
await super().setup(setup) await super().setup(setup)
callbacks = TavusCallbacks( callbacks = TavusCallbacks(
on_joined=self._on_joined,
on_participant_joined=self._on_participant_joined, on_participant_joined=self._on_participant_joined,
on_participant_left=self._on_participant_left, on_participant_left=self._on_participant_left,
) )
@@ -119,6 +120,10 @@ class TavusVideoService(AIService):
await self._client.cleanup() await self._client.cleanup()
self._client = None self._client = None
async def _on_joined(self, data):
"""Handle bot joined the Daily room."""
logger.info("Tavus bot joined Daily room")
async def _on_participant_left(self, participant, reason): async def _on_participant_left(self, participant, reason):
"""Handle participant leaving the session.""" """Handle participant leaving the session."""
participant_id = participant["id"] participant_id = participant["id"]

View File

@@ -24,6 +24,7 @@ from pydantic import BaseModel
from pipecat.audio.vad.vad_analyzer import VADAnalyzer, VADParams from pipecat.audio.vad.vad_analyzer import VADAnalyzer, VADParams
from pipecat.frames.frames import ( from pipecat.frames.frames import (
BotConnectedFrame,
CancelFrame, CancelFrame,
ClientConnectedFrame, ClientConnectedFrame,
DataFrame, DataFrame,
@@ -2579,6 +2580,8 @@ class DailyTransport(BaseTransport):
if error: if error:
await self._on_error(f"Unable to start transcription: {error}") await self._on_error(f"Unable to start transcription: {error}")
await self._call_event_handler("on_joined", data) await self._call_event_handler("on_joined", data)
if self._input:
await self._input.push_frame(BotConnectedFrame())
async def _on_left(self): async def _on_left(self):
"""Handle room left events.""" """Handle room left events."""

View File

@@ -23,6 +23,7 @@ from loguru import logger
from pipecat.frames.frames import ( from pipecat.frames.frames import (
AudioRawFrame, AudioRawFrame,
BotConnectedFrame,
BotStartedSpeakingFrame, BotStartedSpeakingFrame,
BotStoppedSpeakingFrame, BotStoppedSpeakingFrame,
CancelFrame, CancelFrame,
@@ -340,6 +341,7 @@ class HeyGenTransport(BaseTransport):
session_request=session_request, session_request=session_request,
service_type=service_type, service_type=service_type,
callbacks=HeyGenCallbacks( callbacks=HeyGenCallbacks(
on_connected=self._on_connected,
on_participant_connected=self._on_participant_connected, on_participant_connected=self._on_participant_connected,
on_participant_disconnected=self._on_participant_disconnected, on_participant_disconnected=self._on_participant_disconnected,
), ),
@@ -350,9 +352,16 @@ class HeyGenTransport(BaseTransport):
# Register supported handlers. The user will only be able to register # Register supported handlers. The user will only be able to register
# these handlers. # these handlers.
self._register_event_handler("on_connected")
self._register_event_handler("on_client_connected") self._register_event_handler("on_client_connected")
self._register_event_handler("on_client_disconnected") self._register_event_handler("on_client_disconnected")
async def _on_connected(self):
"""Handle bot connected to LiveKit room."""
await self._call_event_handler("on_connected")
if self._input:
await self._input.push_frame(BotConnectedFrame())
async def _on_participant_disconnected(self, participant_id: str): async def _on_participant_disconnected(self, participant_id: str):
logger.debug(f"HeyGen participant {participant_id} disconnected") logger.debug(f"HeyGen participant {participant_id} disconnected")
if participant_id != "heygen": if participant_id != "heygen":

View File

@@ -23,6 +23,7 @@ from pipecat.audio.utils import create_stream_resampler
from pipecat.audio.vad.vad_analyzer import VADAnalyzer from pipecat.audio.vad.vad_analyzer import VADAnalyzer
from pipecat.frames.frames import ( from pipecat.frames.frames import (
AudioRawFrame, AudioRawFrame,
BotConnectedFrame,
CancelFrame, CancelFrame,
ClientConnectedFrame, ClientConnectedFrame,
EndFrame, EndFrame,
@@ -1132,6 +1133,8 @@ class LiveKitTransport(BaseTransport):
async def _on_connected(self): async def _on_connected(self):
"""Handle room connected events.""" """Handle room connected events."""
await self._call_event_handler("on_connected") await self._call_event_handler("on_connected")
if self._input:
await self._input.push_frame(BotConnectedFrame())
async def _on_disconnected(self): async def _on_disconnected(self):
"""Handle room disconnected events.""" """Handle room disconnected events."""

View File

@@ -21,6 +21,7 @@ from loguru import logger
from pydantic import BaseModel from pydantic import BaseModel
from pipecat.frames.frames import ( from pipecat.frames.frames import (
BotConnectedFrame,
CancelFrame, CancelFrame,
ClientConnectedFrame, ClientConnectedFrame,
EndFrame, EndFrame,
@@ -133,10 +134,12 @@ class TavusCallbacks(BaseModel):
"""Callback handlers for Tavus events. """Callback handlers for Tavus events.
Parameters: Parameters:
on_joined: Called when the bot joins the Daily room.
on_participant_joined: Called when a participant joins the conversation. on_participant_joined: Called when a participant joins the conversation.
on_participant_left: Called when a participant leaves the conversation. on_participant_left: Called when a participant leaves the conversation.
""" """
on_joined: Callable[[Mapping[str, Any]], Awaitable[None]]
on_participant_joined: Callable[[Mapping[str, Any]], Awaitable[None]] on_participant_joined: Callable[[Mapping[str, Any]], Awaitable[None]]
on_participant_left: Callable[[Mapping[str, Any], str], Awaitable[None]] on_participant_left: Callable[[Mapping[str, Any], str], Awaitable[None]]
@@ -271,6 +274,7 @@ class TavusTransportClient:
async def _on_joined(self, data): async def _on_joined(self, data):
"""Handle joined event.""" """Handle joined event."""
logger.debug("TavusTransportClient joined!") logger.debug("TavusTransportClient joined!")
await self._callbacks.on_joined(data)
async def _on_left(self): async def _on_left(self):
"""Handle left event.""" """Handle left event."""
@@ -703,6 +707,7 @@ class TavusTransport(BaseTransport):
self._params = params self._params = params
callbacks = TavusCallbacks( callbacks = TavusCallbacks(
on_joined=self._on_joined,
on_participant_joined=self._on_participant_joined, on_participant_joined=self._on_participant_joined,
on_participant_left=self._on_participant_left, on_participant_left=self._on_participant_left,
) )
@@ -721,9 +726,16 @@ class TavusTransport(BaseTransport):
# Register supported handlers. The user will only be able to register # Register supported handlers. The user will only be able to register
# these handlers. # these handlers.
self._register_event_handler("on_joined")
self._register_event_handler("on_client_connected") self._register_event_handler("on_client_connected")
self._register_event_handler("on_client_disconnected") self._register_event_handler("on_client_disconnected")
async def _on_joined(self, data):
"""Handle bot joined room event."""
await self._call_event_handler("on_joined", data)
if self._input:
await self._input.push_frame(BotConnectedFrame())
async def _on_participant_left(self, participant, reason): async def _on_participant_left(self, participant, reason):
"""Handle participant left events.""" """Handle participant left events."""
persona_name = await self._client.get_persona_name() persona_name = await self._client.get_persona_name()

View File

@@ -1,11 +1,17 @@
import asyncio import asyncio
import unittest import unittest
from pipecat.frames.frames import ClientConnectedFrame, Frame, StartFrame, TextFrame from pipecat.frames.frames import (
BotConnectedFrame,
ClientConnectedFrame,
Frame,
StartFrame,
TextFrame,
)
from pipecat.observers.startup_timing_observer import ( from pipecat.observers.startup_timing_observer import (
StartupTimingObserver, StartupTimingObserver,
StartupTimingReport, StartupTimingReport,
TransportReadinessReport, TransportTimingReport,
) )
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
from pipecat.tests.utils import run_test from pipecat.tests.utils import run_test
@@ -182,16 +188,16 @@ class TestStartupTimingObserver(unittest.IsolatedAsyncioTestCase):
f"Internal processor {t.processor_name} should be excluded by default", f"Internal processor {t.processor_name} should be excluded by default",
) )
async def test_transport_readiness_measured(self): async def test_transport_timing_client_only(self):
"""Test that ClientConnectedFrame after startup emits on_transport_readiness_measured.""" """Test that ClientConnectedFrame emits on_transport_timing_report."""
observer = StartupTimingObserver() observer = StartupTimingObserver()
processor = FastProcessor() processor = FastProcessor()
readiness_reports = [] transport_reports = []
@observer.event_handler("on_transport_readiness_measured") @observer.event_handler("on_transport_timing_report")
async def on_readiness(obs, report): async def on_transport(obs, report):
readiness_reports.append(report) transport_reports.append(report)
frames_to_send = [ClientConnectedFrame(), TextFrame(text="hello")] frames_to_send = [ClientConnectedFrame(), TextFrame(text="hello")]
@@ -202,21 +208,22 @@ class TestStartupTimingObserver(unittest.IsolatedAsyncioTestCase):
observers=[observer], observers=[observer],
) )
self.assertEqual(len(readiness_reports), 1) self.assertEqual(len(transport_reports), 1)
report = readiness_reports[0] report = transport_reports[0]
self.assertIsInstance(report, TransportReadinessReport) self.assertIsInstance(report, TransportTimingReport)
self.assertGreater(report.readiness_secs, 0) self.assertGreater(report.client_connected_secs, 0)
self.assertIsNone(report.bot_connected_secs)
async def test_transport_readiness_only_first(self): async def test_transport_timing_only_first_client(self):
"""Test that only the first ClientConnectedFrame triggers the event.""" """Test that only the first ClientConnectedFrame triggers the event."""
observer = StartupTimingObserver() observer = StartupTimingObserver()
processor = FastProcessor() processor = FastProcessor()
readiness_reports = [] transport_reports = []
@observer.event_handler("on_transport_readiness_measured") @observer.event_handler("on_transport_timing_report")
async def on_readiness(obs, report): async def on_transport(obs, report):
readiness_reports.append(report) transport_reports.append(report)
frames_to_send = [ frames_to_send = [
ClientConnectedFrame(), ClientConnectedFrame(),
@@ -231,9 +238,9 @@ class TestStartupTimingObserver(unittest.IsolatedAsyncioTestCase):
observers=[observer], observers=[observer],
) )
self.assertEqual(len(readiness_reports), 1) self.assertEqual(len(transport_reports), 1)
async def test_transport_readiness_without_start_frame(self): async def test_transport_timing_without_start_frame(self):
"""Test that ClientConnectedFrame before StartFrame does not crash.""" """Test that ClientConnectedFrame before StartFrame does not crash."""
observer = StartupTimingObserver() observer = StartupTimingObserver()
@@ -253,7 +260,74 @@ class TestStartupTimingObserver(unittest.IsolatedAsyncioTestCase):
await observer.on_push_frame(data) await observer.on_push_frame(data)
# No event should have been emitted. # No event should have been emitted.
self.assertFalse(observer._transport_readiness_measured) self.assertFalse(observer._transport_timing_reported)
async def test_bot_and_client_connected(self):
"""Test that BotConnectedFrame timing is included in the transport report."""
observer = StartupTimingObserver()
processor = FastProcessor()
transport_reports = []
@observer.event_handler("on_transport_timing_report")
async def on_transport(obs, report):
transport_reports.append(report)
frames_to_send = [
BotConnectedFrame(),
ClientConnectedFrame(),
TextFrame(text="hello"),
]
await run_test(
processor,
frames_to_send=frames_to_send,
expected_down_frames=[BotConnectedFrame, ClientConnectedFrame, TextFrame],
observers=[observer],
)
self.assertEqual(len(transport_reports), 1)
report = transport_reports[0]
self.assertGreater(report.client_connected_secs, 0)
self.assertIsNotNone(report.bot_connected_secs)
self.assertGreater(report.bot_connected_secs, 0)
# Client connected should be >= bot connected.
self.assertGreaterEqual(report.client_connected_secs, report.bot_connected_secs)
async def test_bot_connected_only_first(self):
"""Test that only the first BotConnectedFrame is recorded."""
observer = StartupTimingObserver()
processor = FastProcessor()
transport_reports = []
@observer.event_handler("on_transport_timing_report")
async def on_transport(obs, report):
transport_reports.append(report)
frames_to_send = [
BotConnectedFrame(),
BotConnectedFrame(),
ClientConnectedFrame(),
TextFrame(text="hello"),
]
await run_test(
processor,
frames_to_send=frames_to_send,
expected_down_frames=[
BotConnectedFrame,
BotConnectedFrame,
ClientConnectedFrame,
TextFrame,
],
observers=[observer],
)
# Only one transport report, with bot timing from first frame.
self.assertEqual(len(transport_reports), 1)
self.assertIsNotNone(transport_reports[0].bot_connected_secs)
if __name__ == "__main__": if __name__ == "__main__":