Skip to content

Commit a95aefd

Browse files
authored
Merge pull request #5607 from pipecat-ai/metrics-observer
Report what each service spent and consumed as records
2 parents 53ee7ac + 9948e2a commit a95aefd

3 files changed

Lines changed: 495 additions & 0 deletions

File tree

changelog/5607.added.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- Added `ServiceMetricsObserver`, which reports each metric a service publishes as a record rather than a log line. `on_service_latency` carries time to first byte, first audio and first answer token, with the parts each decomposes into; `on_service_usage` carries speech-to-text audio seconds, text-to-speech characters, and the token counts an LLM reports. One record per metric, never summed, so a consumer groups them by turn, session or model.
Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
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

Comments
 (0)