|
| 1 | +# |
| 2 | +# Copyright (c) 2024-2026, Daily |
| 3 | +# |
| 4 | +# SPDX-License-Identifier: BSD 2-Clause License |
| 5 | +# |
| 6 | + |
| 7 | +"""Observer reporting what each service spent and consumed. |
| 8 | +
|
| 9 | +Services report metrics as they finish a piece of work, and this observer |
| 10 | +turns each one into a record: what was measured, which processor and model |
| 11 | +reported it, and when. Nothing is summed, so a consumer groups the records by |
| 12 | +turn, session or model as it needs, and a session that ends abruptly still |
| 13 | +leaves behind everything that happened before it did. |
| 14 | +""" |
| 15 | + |
| 16 | +import time |
| 17 | +from collections.abc import Callable |
| 18 | +from enum import StrEnum |
| 19 | + |
| 20 | +from pydantic import BaseModel |
| 21 | + |
| 22 | +from pipecat.frames.frames import MetricsFrame |
| 23 | +from pipecat.metrics.metrics import ( |
| 24 | + LLMUsageMetricsData, |
| 25 | + MetricsData, |
| 26 | + STTUsageMetricsData, |
| 27 | + TTFAMetricsData, |
| 28 | + TTFATMetricsData, |
| 29 | + TTFBMetricsData, |
| 30 | + TTSUsageMetricsData, |
| 31 | +) |
| 32 | +from pipecat.observers.base_observer import BaseObserver, FramePushed |
| 33 | + |
| 34 | + |
| 35 | +class ServiceLatencyKind(StrEnum): |
| 36 | + """Which measurement of a service's own time a record carries.""" |
| 37 | + |
| 38 | + TTFB = "ttfb" |
| 39 | + TTFA = "ttfa" |
| 40 | + TTFAT = "ttfat" |
| 41 | + |
| 42 | + |
| 43 | +class ServiceUsageKind(StrEnum): |
| 44 | + """Which kind of service consumed something.""" |
| 45 | + |
| 46 | + STT = "stt" |
| 47 | + LLM = "llm" |
| 48 | + TTS = "tts" |
| 49 | + |
| 50 | + |
| 51 | +class ServiceLatencyRecord(BaseModel): |
| 52 | + """One measurement of how long a service took. |
| 53 | +
|
| 54 | + Parameters: |
| 55 | + kind: Which wait was measured. |
| 56 | + processor: Name of the processor that reported it. |
| 57 | + model: Model the processor was using, where it names one. |
| 58 | + timestamp: Unix timestamp when the measurement was observed. |
| 59 | + seconds: The measurement itself. |
| 60 | + ttfb_secs: The time to first byte the measurement builds on, for the |
| 61 | + kinds that report one. |
| 62 | + leading_silence_secs: Silence at the head of the first audio, for |
| 63 | + time to first audio. |
| 64 | + thinking_time_secs: Time between a model's first output and its first |
| 65 | + answer token, for time to first answer token. |
| 66 | + """ |
| 67 | + |
| 68 | + kind: ServiceLatencyKind |
| 69 | + processor: str |
| 70 | + model: str | None = None |
| 71 | + timestamp: float |
| 72 | + seconds: float |
| 73 | + ttfb_secs: float | None = None |
| 74 | + leading_silence_secs: float | None = None |
| 75 | + thinking_time_secs: float | None = None |
| 76 | + |
| 77 | + |
| 78 | +class ServiceUsageRecord(BaseModel): |
| 79 | + """What one service consumed doing a piece of work. |
| 80 | +
|
| 81 | + A field is set only where the kind of service reports it, so an LLM record |
| 82 | + carries token counts and a text-to-speech record carries characters. |
| 83 | +
|
| 84 | + Parameters: |
| 85 | + kind: Which kind of service reported. |
| 86 | + processor: Name of the processor that reported it. |
| 87 | + model: Model the processor was using, where it names one. |
| 88 | + timestamp: Unix timestamp when the usage was observed. |
| 89 | + audio_seconds: Audio transcribed, for speech-to-text. |
| 90 | + characters: Characters synthesised, for text-to-speech. |
| 91 | + prompt_tokens: Tokens in the prompt, for an LLM. |
| 92 | + completion_tokens: Tokens generated, for an LLM. |
| 93 | + total_tokens: Tokens in the prompt and the completion together. |
| 94 | + cache_read_input_tokens: Prompt tokens served from cache. |
| 95 | + cache_creation_input_tokens: Prompt tokens written to cache. |
| 96 | + reasoning_tokens: Tokens spent reasoning before answering. |
| 97 | + input_audio_tokens: Audio tokens in the prompt. |
| 98 | + output_audio_tokens: Audio tokens generated. |
| 99 | + cache_read_input_audio_tokens: Audio prompt tokens served from cache. |
| 100 | + """ |
| 101 | + |
| 102 | + kind: ServiceUsageKind |
| 103 | + processor: str |
| 104 | + model: str | None = None |
| 105 | + timestamp: float |
| 106 | + |
| 107 | + audio_seconds: float | None = None |
| 108 | + characters: int | None = None |
| 109 | + |
| 110 | + prompt_tokens: int | None = None |
| 111 | + completion_tokens: int | None = None |
| 112 | + total_tokens: int | None = None |
| 113 | + cache_read_input_tokens: int | None = None |
| 114 | + cache_creation_input_tokens: int | None = None |
| 115 | + reasoning_tokens: int | None = None |
| 116 | + input_audio_tokens: int | None = None |
| 117 | + output_audio_tokens: int | None = None |
| 118 | + cache_read_input_audio_tokens: int | None = None |
| 119 | + |
| 120 | + |
| 121 | +class ServiceMetricsObserver(BaseObserver): |
| 122 | + """Reports each metric a service publishes as its own record. |
| 123 | +
|
| 124 | + A record arrives per piece of work rather than per turn or per session: a |
| 125 | + turn that runs two inferences reports two, and a consumer that wants a |
| 126 | + total groups them itself. Summing here would lose the grain, and a total |
| 127 | + held in memory is lost with the process holding it. |
| 128 | +
|
| 129 | + What a service made someone wait for is here; what it did with its own |
| 130 | + time is not. Processing time, text aggregation and smart-turn predictions |
| 131 | + are all deliberately absent: aggregation already appears as a span in |
| 132 | + :class:`~pipecat.observers.user_bot_latency_observer.LatencyBreakdown`, and |
| 133 | + the other two describe how work was done rather than what it cost the |
| 134 | + person waiting. |
| 135 | +
|
| 136 | + Events: |
| 137 | + on_service_latency(observer, record): Emitted for each measurement of |
| 138 | + a service's own time, as a :class:`ServiceLatencyRecord`. |
| 139 | + on_service_usage(observer, record): Emitted for each report of what a |
| 140 | + service consumed, as a :class:`ServiceUsageRecord`. |
| 141 | +
|
| 142 | + Example:: |
| 143 | +
|
| 144 | + observer = ServiceMetricsObserver() |
| 145 | +
|
| 146 | + @observer.event_handler("on_service_usage") |
| 147 | + async def on_service_usage(observer, record): |
| 148 | + logger.info(record.model_dump_json()) |
| 149 | + """ |
| 150 | + |
| 151 | + def __init__(self, *, time_source: Callable[[], float] = time.time, **kwargs): |
| 152 | + """Initialize the service metrics observer. |
| 153 | +
|
| 154 | + Args: |
| 155 | + time_source: Reads the current time in seconds. Supplying one lets |
| 156 | + a test place records without waiting. |
| 157 | + **kwargs: Additional arguments passed to parent class. |
| 158 | + """ |
| 159 | + super().__init__(**kwargs) |
| 160 | + self._now = time_source |
| 161 | + # Every processor that passes a frame along reports it, so a metric is |
| 162 | + # remembered once it has been read. Only metrics frames are kept, and a |
| 163 | + # call produces few enough of them for the set to stay small. |
| 164 | + self._reported: set[int] = set() |
| 165 | + |
| 166 | + self._register_event_handler("on_service_latency") |
| 167 | + self._register_event_handler("on_service_usage") |
| 168 | + |
| 169 | + async def on_push_frame(self, data: FramePushed): |
| 170 | + """Report the metrics carried by a frame, the first time it is seen. |
| 171 | +
|
| 172 | + Metrics travel in one direction, so a frame is identified by its ID |
| 173 | + alone. A frame broadcast both ways would arrive as two frames with two |
| 174 | + IDs, and would be reported twice. |
| 175 | +
|
| 176 | + Args: |
| 177 | + data: Frame push event containing the frame and direction. |
| 178 | + """ |
| 179 | + if not isinstance(data.frame, MetricsFrame) or data.frame.id in self._reported: |
| 180 | + return |
| 181 | + |
| 182 | + self._reported.add(data.frame.id) |
| 183 | + |
| 184 | + for metrics in data.frame.data: |
| 185 | + latency = self._as_latency(metrics) |
| 186 | + if latency: |
| 187 | + await self._call_event_handler("on_service_latency", latency) |
| 188 | + continue |
| 189 | + usage = self._as_usage(metrics) |
| 190 | + if usage: |
| 191 | + await self._call_event_handler("on_service_usage", usage) |
| 192 | + |
| 193 | + def _as_latency(self, metrics: MetricsData) -> ServiceLatencyRecord | None: |
| 194 | + """Build a latency record, for the metrics that measure time spent. |
| 195 | +
|
| 196 | + Args: |
| 197 | + metrics: One metric a processor reported. |
| 198 | +
|
| 199 | + Returns: |
| 200 | + The record, or None if this metric measures something else. |
| 201 | + """ |
| 202 | + common = { |
| 203 | + "processor": metrics.processor, |
| 204 | + "model": metrics.model, |
| 205 | + "timestamp": self._now(), |
| 206 | + } |
| 207 | + if isinstance(metrics, TTFAMetricsData): |
| 208 | + return ServiceLatencyRecord( |
| 209 | + kind=ServiceLatencyKind.TTFA, |
| 210 | + seconds=metrics.ttfa, |
| 211 | + ttfb_secs=metrics.ttfb, |
| 212 | + leading_silence_secs=metrics.leading_silence, |
| 213 | + **common, |
| 214 | + ) |
| 215 | + elif isinstance(metrics, TTFATMetricsData): |
| 216 | + return ServiceLatencyRecord( |
| 217 | + kind=ServiceLatencyKind.TTFAT, |
| 218 | + seconds=metrics.ttfat, |
| 219 | + ttfb_secs=metrics.ttfb, |
| 220 | + thinking_time_secs=metrics.thinking_time, |
| 221 | + **common, |
| 222 | + ) |
| 223 | + elif isinstance(metrics, TTFBMetricsData): |
| 224 | + return ServiceLatencyRecord( |
| 225 | + kind=ServiceLatencyKind.TTFB, seconds=metrics.value, **common |
| 226 | + ) |
| 227 | + return None |
| 228 | + |
| 229 | + def _as_usage(self, metrics: MetricsData) -> ServiceUsageRecord | None: |
| 230 | + """Build a usage record, for the metrics that measure what was consumed. |
| 231 | +
|
| 232 | + Args: |
| 233 | + metrics: One metric a processor reported. |
| 234 | +
|
| 235 | + Returns: |
| 236 | + The record, or None if this metric measures something else. |
| 237 | + """ |
| 238 | + common = { |
| 239 | + "processor": metrics.processor, |
| 240 | + "model": metrics.model, |
| 241 | + "timestamp": self._now(), |
| 242 | + } |
| 243 | + if isinstance(metrics, LLMUsageMetricsData): |
| 244 | + tokens = metrics.value |
| 245 | + return ServiceUsageRecord( |
| 246 | + kind=ServiceUsageKind.LLM, |
| 247 | + prompt_tokens=tokens.prompt_tokens, |
| 248 | + completion_tokens=tokens.completion_tokens, |
| 249 | + total_tokens=tokens.total_tokens, |
| 250 | + cache_read_input_tokens=tokens.cache_read_input_tokens, |
| 251 | + cache_creation_input_tokens=tokens.cache_creation_input_tokens, |
| 252 | + reasoning_tokens=tokens.reasoning_tokens, |
| 253 | + input_audio_tokens=tokens.input_audio_tokens, |
| 254 | + output_audio_tokens=tokens.output_audio_tokens, |
| 255 | + cache_read_input_audio_tokens=tokens.cache_read_input_audio_tokens, |
| 256 | + **common, |
| 257 | + ) |
| 258 | + elif isinstance(metrics, STTUsageMetricsData): |
| 259 | + return ServiceUsageRecord( |
| 260 | + kind=ServiceUsageKind.STT, audio_seconds=metrics.value.audio_seconds, **common |
| 261 | + ) |
| 262 | + elif isinstance(metrics, TTSUsageMetricsData): |
| 263 | + return ServiceUsageRecord(kind=ServiceUsageKind.TTS, characters=metrics.value, **common) |
| 264 | + return None |
0 commit comments