parlot.instrumentation.pipecat
parlot-instrumentation-pipecat: OTel instrumentation for Pipecat agents.
1"""parlot-instrumentation-pipecat: OTel instrumentation for Pipecat agents.""" 2 3from parlot.core import ( 4 add_platform_ref, 5 human_escalation, 6 record_human_rep, 7 set_session_attribute, 8 set_session_metadata, 9 stamp_platform_refs, 10) 11 12from ._auto import close_session, parlotize 13from ._processor import PipecatGenAIProcessor 14from ._recording import ParlotRecordingProcessor 15from parlot.core import stamp_external_recording 16 17__all__ = [ 18 "parlotize", 19 "close_session", 20 "PipecatGenAIProcessor", 21 "ParlotRecordingProcessor", 22 "stamp_external_recording", 23 "add_platform_ref", 24 "human_escalation", 25 "record_human_rep", 26 "set_session_attribute", 27 "set_session_metadata", 28 "stamp_platform_refs", 29]
25def parlotize( 26 agent_id: str, 27 *, 28 endpoint: Optional[str] = None, 29 api_key: Optional[str] = None, 30 capture_genai_content: Optional[bool] = None, 31 service_name: Optional[str] = None, 32 tracer_provider: TracerProvider | None = None, 33 version: Optional[str] = None, 34 capture_logs: bool | list[str] | None = None, 35 log_level: Optional[str] = None, 36 record: bool | list[str] | None = None, 37) -> ParlotContext: 38 """Parlotize Pipecat instrumentation and OTLP export. 39 40 Call before constructing ``Pipeline`` / ``PipelineWorker``. Builds a 41 ``TracerProvider`` with an OTLP exporter (unless one is adopted), registers 42 it as the global provider (so Pipecat's ``enable_tracing=True`` path uses 43 it), and patches ``PipelineWorker`` to default metrics + turn tracing on. 44 45 Do **not** call Pipecat's ``setup_tracing()`` afterward — it replaces the 46 global provider and would drop Parlot processors. ``parlotize`` is the 47 Parlot equivalent of that setup. 48 49 Shared parameters match every adapter — see ``parlot.core.ParlotizeProtocol``. 50 51 Args: 52 agent_id: Shared — required canonical ``session.agent_id``. 53 endpoint: Shared — Parlot OTLP base URL (or ``PARLOT_ENDPOINT``). 54 api_key: Shared — org API key (or ``PARLOT_API_KEY``). 55 capture_genai_content: Shared — GenAI payload capture override. 56 service_name: Shared — OTel ``service.name`` (defaults to ``agent_id``). 57 tracer_provider: Shared — existing ``TracerProvider``, or build one. 58 version: Shared — ``gen_ai.agent.version``. 59 capture_logs: Shared — session log capture. 60 log_level: Shared — minimum level for session log capture. 61 record: Audio recording policy. Boolean or agent-id glob patterns. 62 When enabled, place ``ParlotRecordingProcessor`` after 63 ``transport.output()`` (WAV segments by default). 64 65 Returns: 66 The ``ParlotContext`` for this process. 67 """ 68 global _configured, _configured_agent_id, _configured_agent_version 69 global _configured_record, _parlot_context 70 if _configured: 71 logger.debug("parlot-instrumentation.pipecat already configured — skipping") 72 assert _parlot_context is not None 73 return _parlot_context 74 75 from parlot.core import base_parlotize 76 from parlot.core.parlotize import normalize_glob_or_bool 77 from parlot.core.sdk_version import resolve_parlot_sdk_version 78 79 _configured_agent_version = (version or "").strip() or "unknown" 80 _configured_record = normalize_glob_or_bool(record) 81 82 res = base_parlotize( 83 agent_id, 84 endpoint=endpoint, 85 api_key=api_key, 86 capture_genai_content=capture_genai_content, 87 tracer_provider=tracer_provider, 88 version=_configured_agent_version, 89 capture_logs=capture_logs, 90 log_level=log_level, 91 ) 92 _parlot_context = res.context 93 _configured_agent_id = res.agent_id 94 95 tracer_provider = res.tracer_provider 96 if tracer_provider is None: 97 if not res.endpoint: 98 raise ValueError( 99 "No OTLP endpoint configured. Pass endpoint= or set the " 100 "PARLOT_ENDPOINT environment variable." 101 ) 102 tracer_provider = _build_provider( 103 endpoint=res.endpoint, 104 api_key=res.api_key, 105 service_name=service_name or res.agent_id, 106 service_version=_configured_agent_version or None, 107 context=res.context, 108 ) 109 else: 110 # Adopt existing provider: still attach our processor when possible. 111 _attach_processor_if_needed(tracer_provider, res.context) 112 113 from parlot.core.provider import try_set_global_tracer_provider 114 from parlot.core.parlotize import log_parlotize_ready 115 116 try_set_global_tracer_provider(tracer_provider) 117 118 from ._hooks import install_pipeline_worker_hooks, install_tool_call_hooks 119 from ._session import set_identity, set_tracer 120 121 set_identity(_configured_agent_id, _configured_agent_version) 122 set_tracer( 123 tracer_provider.get_tracer( 124 "parlot.instrumentation.pipecat", 125 resolve_parlot_sdk_version() or None, 126 ) 127 ) 128 _wire_session_logs(res.context, _configured_agent_id) 129 install_pipeline_worker_hooks() 130 install_tool_call_hooks() 131 _neutralize_pipecat_setup_tracing() 132 133 _configured = True 134 log_parlotize_ready( 135 "pipecat", 136 agent_id=res.agent_id, 137 endpoint=res.endpoint or "", 138 api_key=res.api_key or "", 139 log=logger, 140 ) 141 return res.context
Parlotize Pipecat instrumentation and OTLP export.
Call before constructing Pipeline / PipelineWorker. Builds a
TracerProvider with an OTLP exporter (unless one is adopted), registers
it as the global provider (so Pipecat's enable_tracing=True path uses
it), and patches PipelineWorker to default metrics + turn tracing on.
Do not call Pipecat's setup_tracing() afterward — it replaces the
global provider and would drop Parlot processors. parlotize is the
Parlot equivalent of that setup.
Shared parameters match every adapter — see parlot.core.ParlotizeProtocol.
Arguments:
- agent_id: Shared — required canonical
session.agent_id. - endpoint: Shared — Parlot OTLP base URL (or
PARLOT_ENDPOINT). - api_key: Shared — org API key (or
PARLOT_API_KEY). - capture_genai_content: Shared — GenAI payload capture override.
- service_name: Shared — OTel
service.name(defaults toagent_id). - tracer_provider: Shared — existing
TracerProvider, or build one. - version: Shared —
gen_ai.agent.version. - capture_logs: Shared — session log capture.
- log_level: Shared — minimum level for session log capture.
- record: Audio recording policy. Boolean or agent-id glob patterns.
When enabled, place
ParlotRecordingProcessoraftertransport.output()(WAV segments by default).
Returns:
The
ParlotContextfor this process.
365def close_session(conversation_id: str, *, reason: str = "completed") -> None: 366 """Public helper to close a Pipecat-owned session by conversation_id.""" 367 from ._session import close_session as _close 368 369 # Best-effort sync bridge: schedule finish if a loop is running. 370 try: 371 loop = asyncio.get_running_loop() 372 loop.create_task(finish_active_recordings()) 373 except RuntimeError: 374 try: 375 asyncio.run(finish_active_recordings()) 376 except Exception: 377 logger.debug("parlot: sync recording finish skipped", exc_info=True) 378 379 _close(conversation_id, reason=reason)
Public helper to close a Pipecat-owned session by conversation_id.
99class PipecatGenAIProcessor(ParlotBaseProcessor): 100 """Enrich and remap Pipecat OTEL spans for Parlot export.""" 101 102 def __init__(self, *, context: ParlotContext | None = None) -> None: 103 self._context = context 104 # conversation_id → (start_ns by turn_number) for duration-preserving remint 105 self._turn_starts: dict[str, dict[int, int]] = {} 106 # Live parent spans (turn/conversation) so children can read attrs set 107 # after start_span (Pipecat sets conversation.id / turn.number post-start). 108 self._open_spans: dict[str, Any] = {} 109 # conversation_id → turn_number → utterance + timing/latency buffers 110 self._utterances: dict[str, dict[int, dict[str, Any]]] = {} 111 self._active_conversation_id: str = "" 112 self._active_turn_number: int | None = None 113 114 def _bucket(self, cid: str, turn_n: int) -> dict[str, Any]: 115 return self._utterances.setdefault(cid, {}).setdefault(turn_n, {}) 116 117 @staticmethod 118 def _debug_log_span(name: str) -> bool: 119 """Skip FastAPI/httpx noise when PARLOT_DEBUG_LEVEL=DEBUG.""" 120 n = (name or "").lower() 121 if not n: 122 return False 123 if n in (NATIVE_CONVERSATION, NATIVE_TURN) or n.startswith("parlot."): 124 return True 125 if n in ("stt", "tts", "llm") or n.startswith( 126 ("stt", "tts", "llm", "chat", "execute_tool") 127 ): 128 return True 129 return False 130 131 def on_start(self, span, parent_context=None) -> None: # noqa: ANN001 132 # SDK Span (not API Span) — leave untyped like LiveKit processor. 133 name = getattr(span, "name", None) or "" 134 if self._debug_log_span(name): 135 super().on_start(span, parent_context) 136 sid = _span_id_hex(span) 137 if sid and name in (NATIVE_CONVERSATION, NATIVE_TURN): 138 self._open_spans[sid] = span 139 # Activate session as early as possible so INFO logs correlate. 140 if name == NATIVE_TURN: 141 parent_attrs = self._parent_attrs(span) 142 cid = str( 143 (getattr(span, "attributes", None) or {}).get(ATTR_PIPECAT_CONVERSATION_ID) 144 or parent_attrs.get(ATTR_PIPECAT_CONVERSATION_ID) 145 or self._active_conversation_id 146 or "" 147 ).strip() 148 if cid: 149 self._active_conversation_id = cid 150 ensure_session(cid) 151 152 def on_end(self, span: ReadableSpan) -> None: 153 name = span.name or "" 154 if self._debug_log_span(name): 155 super().on_end(span) 156 attrs = span.attributes or {} 157 sid = "" 158 try: 159 ctx = span.get_span_context() 160 if ctx is not None: 161 sid = format(int(ctx.span_id), "016x") 162 except Exception: 163 sid = "" 164 if sid: 165 self._open_spans.pop(sid, None) 166 167 # Passthrough contract / GenAI spans we mint (emit_turn / ensure_session). 168 if name in CONTRACT_SPAN_NAMES or is_genai_span_name(name): 169 return 170 171 if name == NATIVE_CONVERSATION: 172 cid = str(attrs.get(ATTR_PIPECAT_CONVERSATION_ID) or "").strip() 173 if cid: 174 self._active_conversation_id = cid 175 ensure_session(cid) 176 span._name = "__parlot_drop_conversation__" 177 close_session(cid, reason="completed") 178 self._utterances.pop(cid, None) 179 self._turn_starts.pop(cid, None) 180 return 181 182 if name == NATIVE_TURN: 183 self._note_turn_context(attrs, span) 184 self._mint_contract_turn(span) 185 span._name = "__parlot_drop_turn__" 186 return 187 188 exported = remap_pipecat_span_name(name, attrs) 189 if exported is None: 190 span._name = f"__parlot_drop_{name}__" 191 return 192 193 apply_pipecat_span_rename(span) 194 self._stamp_session_linkage(span) 195 self._stamp_agent_role(span) 196 self._map_pipecat_metrics(span) 197 self._buffer_span_timing(span) 198 self._map_transcript_text(span) 199 self._record_usage_metrics(span) 200 # Orphan / tool-continuation TTS: mint agent before the next user STT 201 # arrives so indices stay chronological (user N, agent N+1). 202 if self._is_tts(name): 203 self._maybe_emit_orphan_agent(span) 204 205 def _note_turn_context( 206 self, attrs: Mapping[str, AttributeValue], span: ReadableSpan 207 ) -> None: 208 cid = str(attrs.get(ATTR_PIPECAT_CONVERSATION_ID) or "").strip() 209 turn_n = self._turn_number(attrs) 210 if cid: 211 self._active_conversation_id = cid 212 ensure_session(cid) 213 if turn_n is not None: 214 self._active_turn_number = turn_n 215 if cid: 216 starts = self._turn_starts.setdefault(cid, {}) 217 if turn_n not in starts and span.start_time: 218 starts[turn_n] = int(span.start_time) 219 220 def _parent_attrs(self, span: ReadableSpan) -> Mapping[str, AttributeValue]: 221 parent_id = _parent_span_id_hex(span) 222 if not parent_id: 223 return {} 224 parent = self._open_spans.get(parent_id) 225 if parent is None: 226 return {} 227 return getattr(parent, "attributes", None) or {} 228 229 def _resolve_conversation_and_turn( 230 self, span: ReadableSpan 231 ) -> tuple[str, int | None]: 232 attrs = span.attributes or {} 233 parent_attrs = self._parent_attrs(span) 234 cid = str( 235 attrs.get(ATTR_PIPECAT_CONVERSATION_ID) 236 or parent_attrs.get(ATTR_PIPECAT_CONVERSATION_ID) 237 or self._active_conversation_id 238 or "" 239 ).strip() 240 if not cid: 241 state = _pipecat_state(get_active_session()) 242 if state and state.conversation_id: 243 cid = state.conversation_id 244 turn_n = self._turn_number(attrs) or self._turn_number(parent_attrs) 245 if turn_n is None: 246 turn_n = self._active_turn_number 247 if turn_n is None: 248 state = lookup_session(cid) if cid else _pipecat_state(get_active_session()) 249 if state and state.open_turn_index is not None: 250 turn_n = state.open_turn_index 251 return cid, turn_n 252 253 def _conversation_id_from_context( 254 self, attrs: Mapping[str, AttributeValue] 255 ) -> str: 256 cid = str(attrs.get(ATTR_PIPECAT_CONVERSATION_ID) or "").strip() 257 if cid: 258 return cid 259 if self._active_conversation_id: 260 return self._active_conversation_id 261 state = _pipecat_state(get_active_session()) 262 if state and state.conversation_id: 263 return state.conversation_id 264 return "" 265 266 @staticmethod 267 def _turn_number(attrs: Mapping[str, AttributeValue]) -> int | None: 268 raw = attrs.get(ATTR_PIPECAT_TURN_NUMBER) 269 if raw is None or isinstance(raw, (list, tuple)): 270 return None 271 try: 272 return int(raw) # type: ignore[arg-type] 273 except (TypeError, ValueError): 274 return None 275 276 def _find_agent_bucket( 277 self, cid: str, prefer_turn: int | None = None 278 ) -> tuple[int | None, dict[str, Any]]: 279 """Locate a buffer that still holds unemitted agent text.""" 280 buckets = self._utterances.get(cid) or {} 281 if prefer_turn is not None and prefer_turn in buckets: 282 b = buckets[prefer_turn] 283 if str(b.get("agent") or "").strip() and not b.get("agent_emitted"): 284 return prefer_turn, b 285 for key, b in list(buckets.items()): 286 if str(b.get("agent") or "").strip() and not b.get("agent_emitted"): 287 return key if isinstance(key, int) else None, b 288 return prefer_turn, {} 289 290 def _flush_pending_agent_only( 291 self, 292 state: _PipecatSessionState, 293 *, 294 cid: str, 295 prefer_turn: int | None = None, 296 interrupted: bool = False, 297 e2e: float | None = None, 298 ) -> bool: 299 """Emit a reserved agent-only turn (tool continuation) before a new user. 300 301 Without this, STT allocates ``user = open_agent + 1`` while keeping the 302 old agent index — timeline then sorts user (earlier STT) before agent 303 (later LLM) despite a lower agent turn index. 304 """ 305 if state.open_agent_turn_index is None: 306 return False 307 if state.open_user_turn_index is not None: 308 return False 309 agent_idx = state.open_agent_turn_index 310 _key, buffered = self._find_agent_bucket(cid, prefer_turn) 311 agent_text = str(buffered.get("agent") or "").strip() 312 if not agent_text: 313 # Indices reserved by LLM/TTS with no text yet — drop reservation. 314 clear_open_turn_indices(state) 315 return False 316 agent_start = buffered.get("agent_start_ns") 317 agent_end = buffered.get("agent_end_ns") 318 llm_ttft = buffered.get("llm_ttft_s") 319 tts_ttfb = buffered.get("tts_ttfb_s") 320 emit_turn( 321 state, 322 turn_index=agent_idx, 323 role="agent", 324 interrupted=interrupted, 325 e2e_latency_s=e2e if e2e is not None else _as_float(buffered.get("e2e")), 326 llm_ttft_s=float(llm_ttft) if llm_ttft is not None else None, 327 tts_ttfb_s=float(tts_ttfb) if tts_ttfb is not None else None, 328 agent_text=agent_text, 329 start_time_ns=int(agent_start) if agent_start else None, 330 end_time_ns=int(agent_end) if agent_end else None, 331 ) 332 buffered["agent_emitted"] = True 333 buffered["agent"] = "" 334 for key in ( 335 "agent_start_ns", 336 "agent_end_ns", 337 "llm_ttft_s", 338 "tts_ttfb_s", 339 "e2e", 340 ): 341 buffered.pop(key, None) 342 clear_open_turn_indices(state) 343 return True 344 345 def _emit_user_from_stt( 346 self, 347 state: _PipecatSessionState, 348 *, 349 cid: str, 350 turn_n: int | None, 351 text: str, 352 start_ns: int | None, 353 end_ns: int | None, 354 ) -> None: 355 """Mint the user turn as soon as STT finals (LiveKit-style ordering).""" 356 self._flush_pending_agent_only(state, cid=cid, prefer_turn=turn_n) 357 if state.user_turn_emitted and state.open_user_turn_index is not None: 358 return 359 user_idx = allocate_user_turn_index(state) 360 allocate_agent_turn_index(state) # reserve agent slot after user 361 emit_turn( 362 state, 363 turn_index=user_idx, 364 role="user", 365 user_text=text, 366 start_time_ns=start_ns, 367 end_time_ns=end_ns, 368 ) 369 state.user_turn_emitted = True 370 if turn_n is not None: 371 self._bucket(cid, turn_n)["user_emitted"] = True 372 373 def _native_turn_open(self, cid: str) -> bool: 374 for sp in self._open_spans.values(): 375 if (getattr(sp, "name", None) or "") != NATIVE_TURN: 376 continue 377 attrs = getattr(sp, "attributes", None) or {} 378 sp_cid = str(attrs.get(ATTR_PIPECAT_CONVERSATION_ID) or "").strip() 379 if sp_cid == cid or (not sp_cid and self._active_conversation_id == cid): 380 return True 381 return False 382 383 def _maybe_emit_orphan_agent(self, span: ReadableSpan) -> None: 384 """Mint agent speech that lands after its native turn already closed. 385 386 Late tool-follow-up TTS otherwise buffers onto the *next* turn and gets 387 paired with the next user STT (inverted indices / timeline order). 388 """ 389 cid, turn_n = self._resolve_conversation_and_turn(span) 390 state = lookup_session(cid) if cid else _pipecat_state(get_active_session()) 391 if state is None or not cid: 392 return 393 if state.open_user_turn_index is not None: 394 return 395 if self._native_turn_open(cid): 396 return 397 bucket = self._bucket(cid, turn_n) if turn_n is not None else {} 398 agent_text = str(bucket.get("agent") or "").strip() 399 if not agent_text or bucket.get("agent_emitted"): 400 return 401 if state.open_agent_turn_index is None: 402 allocate_agent_turn_index(state) 403 self._flush_pending_agent_only(state, cid=cid, prefer_turn=turn_n) 404 405 def _mint_contract_turn(self, span: ReadableSpan) -> None: 406 attrs = span.attributes or {} 407 cid = str(attrs.get(ATTR_PIPECAT_CONVERSATION_ID) or "").strip() 408 state = lookup_session(cid) if cid else _pipecat_state(get_active_session()) 409 if state is None or not state.conversation_id: 410 if not cid: 411 return 412 state = ensure_session(cid) 413 if state is None: 414 return 415 pipecat_turn = self._turn_number(attrs) 416 if pipecat_turn is not None: 417 self._active_turn_number = pipecat_turn 418 self._active_conversation_id = state.conversation_id 419 interrupted = bool(attrs.get(ATTR_PIPECAT_TURN_INTERRUPTED)) 420 e2e = _as_float(attrs.get(ATTR_PIPECAT_TURN_USER_BOT_LATENCY)) 421 start_ns = None 422 starts = self._turn_starts.get(state.conversation_id) or {} 423 if pipecat_turn is not None and pipecat_turn in starts: 424 start_ns = starts.pop(pipecat_turn, None) 425 end_ns = span.end_time 426 buf_key = pipecat_turn if pipecat_turn is not None else -1 427 # Flush any prior agent-only reservation *before* popping this turn's 428 # bucket (flush reads from ``_utterances``). 429 if state.open_user_turn_index is None and state.open_agent_turn_index is not None: 430 # Peek whether this turn has user text; if not, we'll emit agent below. 431 peek = (self._utterances.get(state.conversation_id) or {}).get(buf_key) or {} 432 peek_user = str(peek.get("user") or "").strip() 433 if peek_user: 434 self._flush_pending_agent_only( 435 state, cid=state.conversation_id, prefer_turn=None 436 ) 437 438 buffered = (self._utterances.get(state.conversation_id) or {}).pop(buf_key, {}) 439 user_text = str(buffered.get("user") or "").strip() 440 agent_text = str(buffered.get("agent") or "").strip() 441 user_already = bool(buffered.get("user_emitted") or state.user_turn_emitted) 442 agent_already = bool(buffered.get("agent_emitted")) 443 stt_start = buffered.get("stt_start_ns") 444 stt_end = buffered.get("stt_end_ns") 445 agent_start = buffered.get("agent_start_ns") 446 agent_end = buffered.get("agent_end_ns") 447 llm_ttft = buffered.get("llm_ttft_s") 448 tts_ttfb = buffered.get("tts_ttfb_s") 449 450 user_idx = state.open_user_turn_index 451 agent_idx = state.open_agent_turn_index 452 if user_text and not user_already and user_idx is None: 453 user_idx = allocate_user_turn_index(state) 454 agent_idx = allocate_agent_turn_index(state) 455 if agent_text and not agent_already and agent_idx is None: 456 agent_idx = allocate_agent_turn_index(state) 457 458 # Split wall clocks: shared native-turn bounds made user+agent share 459 # the same offset (inflated "gaps"). Prefer STT / LLM+TTS windows. 460 user_start = int(stt_start) if stt_start else start_ns 461 user_end = int(stt_end) if stt_end else ( 462 int(agent_start) if agent_start else end_ns 463 ) 464 agent_start_ns = int(agent_start) if agent_start else ( 465 int(stt_end) if stt_end else start_ns 466 ) 467 agent_end_ns = int(agent_end) if agent_end else end_ns 468 469 if user_text and not user_already and user_idx is not None: 470 emit_turn( 471 state, 472 turn_index=user_idx, 473 role="user", 474 user_text=user_text, 475 start_time_ns=user_start, 476 end_time_ns=user_end, 477 ) 478 state.user_turn_emitted = True 479 if agent_text and not agent_already and agent_idx is not None: 480 emit_turn( 481 state, 482 turn_index=agent_idx, 483 role="agent", 484 interrupted=interrupted, 485 e2e_latency_s=e2e, 486 llm_ttft_s=float(llm_ttft) if llm_ttft is not None else None, 487 tts_ttfb_s=float(tts_ttfb) if tts_ttfb is not None else None, 488 agent_text=agent_text, 489 start_time_ns=agent_start_ns, 490 end_time_ns=agent_end_ns, 491 ) 492 clear_open_turn_indices(state) 493 494 @staticmethod 495 def _is_stt(name: str) -> bool: 496 return name == "stt" or name.startswith("stt") 497 498 @staticmethod 499 def _is_tts(name: str) -> bool: 500 return name == "tts" or name.startswith("tts") 501 502 @staticmethod 503 def _is_llm(name: str) -> bool: 504 # Native ``llm`` before rename, or GenAI ``chat`` / ``chat {model}`` after. 505 if name.startswith("llm_tool"): 506 return False 507 return ( 508 name == "llm" 509 or name.startswith("llm") 510 or name == "chat" 511 or name.startswith("chat") 512 ) 513 514 @staticmethod 515 def _is_tool(name: str) -> bool: 516 return name.startswith("execute_tool") or name.startswith("llm_tool") 517 518 def _parlot_turn_index_for_span( 519 self, span: ReadableSpan, state: _PipecatSessionState 520 ) -> int | None: 521 """Map pipeline spans onto Parlot turn indices for timeline nesting. 522 523 The app nests ``stt`` / ``llm`` / ``tts`` under **agent** turns only. 524 STT at a user-only turn_index shows as an orphan row (odd indentation). 525 So we allocate the user index for the utterance row, then stamp STT 526 (and LLM/TTS) on the paired agent index. 527 """ 528 name = span.name or "" 529 if self._is_stt(name): 530 # Tool-continuation may have reserved an agent index with no user. 531 # Flush it first so this STT becomes user N / agent N+1 (not N+1 / N). 532 self._flush_pending_agent_only(state, cid=state.conversation_id) 533 allocate_user_turn_index(state) 534 return allocate_agent_turn_index(state) 535 if self._is_tts(name) or self._is_llm(name) or self._is_tool(name): 536 return allocate_agent_turn_index(state) 537 return state.open_agent_turn_index or state.open_user_turn_index 538 539 def _stamp_session_linkage(self, span: ReadableSpan) -> None: 540 cid, pipecat_turn = self._resolve_conversation_and_turn(span) 541 state = lookup_session(cid) if cid else _pipecat_state(get_active_session()) 542 if state is None and cid: 543 state = ensure_session(cid) 544 if state is None: 545 return 546 if cid: 547 self._active_conversation_id = cid 548 if pipecat_turn is not None: 549 self._active_turn_number = pipecat_turn 550 parlot_turn = self._parlot_turn_index_for_span(span, state) 551 self._set(span, ATTR_SESSION_ID, state.session_id) 552 self._set(span, ATTR_SESSION_CONVERSATION_ID, state.conversation_id) 553 if parlot_turn is not None: 554 self._set(span, ATTR_TURN_INDEX, parlot_turn) 555 agent_id = configured_agent_id() 556 if agent_id: 557 self._set(span, ATTR_GEN_AI_AGENT_ID, agent_id) 558 self._set(span, ATTR_GEN_AI_AGENT_NAME, agent_id) 559 self._set(span, ATTR_SESSION_AGENT_ID, agent_id) 560 stamp_pipecat_platform_refs(span, conversation_id=state.conversation_id) 561 562 def _stamp_agent_role(self, span: ReadableSpan) -> None: 563 name = span.name or "" 564 attrs = span.attributes or {} 565 if attrs.get(ATTR_AGENT_ROLE): 566 return 567 if self._is_stt(name): 568 self._set(span, ATTR_AGENT_ROLE, "stt") 569 elif self._is_tts(name): 570 self._set(span, ATTR_AGENT_ROLE, "tts") 571 elif self._is_llm(name): 572 self._set(span, ATTR_AGENT_ROLE, "llm") 573 elif self._is_tool(name): 574 self._set(span, ATTR_AGENT_ROLE, "tool") 575 576 def _map_pipecat_metrics(self, span: ReadableSpan) -> None: 577 attrs = span.attributes or {} 578 name = span.name or "" 579 ttfb = _as_float(attrs.get(ATTR_PIPECAT_METRICS_TTFB)) 580 # Only TTS owns gen_ai.tts.ttfb_s — do not stamp LLM TTFB there. 581 if ( 582 ttfb is not None 583 and self._is_tts(name) 584 and not attrs.get(ATTR_GEN_AI_TTS_TTFB_S) 585 ): 586 self._set(span, ATTR_GEN_AI_TTS_TTFB_S, ttfb) 587 if not attrs.get(ATTR_GEN_AI_MODEL): 588 model = attrs.get("gen_ai.request.model") or attrs.get("model") 589 if model is not None and not isinstance(model, (list, tuple)): 590 self._set(span, ATTR_GEN_AI_MODEL, str(model)) 591 592 def _buffer_span_timing(self, span: ReadableSpan) -> None: 593 """Record STT/LLM/TTS windows + TTFB for reminted turn bounds / latency.""" 594 cid, turn_n = self._resolve_conversation_and_turn(span) 595 if not cid or turn_n is None: 596 return 597 name = span.name or "" 598 bucket = self._bucket(cid, turn_n) 599 start = span.start_time 600 end = span.end_time 601 ttfb = _as_float((span.attributes or {}).get(ATTR_PIPECAT_METRICS_TTFB)) 602 if self._is_stt(name): 603 if start: 604 bucket["stt_start_ns"] = int(start) 605 if end: 606 bucket["stt_end_ns"] = int(end) 607 return 608 if self._is_llm(name) or self._is_tts(name): 609 # After an agent-only flush, the next LLM/TTS is a new utterance — 610 # do not keep the previous window via min()/max(). 611 if bucket.get("agent_emitted"): 612 bucket["agent_start_ns"] = int(start) if start else None 613 bucket["agent_end_ns"] = int(end) if end else None 614 bucket.pop("llm_ttft_s", None) 615 bucket.pop("tts_ttfb_s", None) 616 else: 617 if start: 618 prev = bucket.get("agent_start_ns") 619 bucket["agent_start_ns"] = ( 620 min(int(prev), int(start)) if prev else int(start) 621 ) 622 if end: 623 prev_end = bucket.get("agent_end_ns") 624 bucket["agent_end_ns"] = ( 625 max(int(prev_end), int(end)) if prev_end else int(end) 626 ) 627 if ttfb is not None: 628 if self._is_llm(name): 629 bucket["llm_ttft_s"] = ttfb 630 elif bucket.get("tts_ttfb_s") is None: 631 # Prefer first successful TTFB (retry after "no audio" is noisier). 632 bucket["tts_ttfb_s"] = ttfb 633 634 def _record_usage_metrics(self, span: ReadableSpan) -> None: 635 recorder = get_metrics_recorder() 636 if recorder is None: 637 return 638 attrs = span.attributes or {} 639 session_id = str(attrs.get(ATTR_SESSION_ID) or "").strip() 640 if not session_id: 641 return 642 name = span.name or "" 643 model = str(attrs.get(ATTR_GEN_AI_MODEL) or attrs.get("gen_ai.request.model") or "") 644 provider = str(attrs.get("gen_ai.provider.name") or "") 645 if self._is_stt(name): 646 audio = _as_float(attrs.get(ATTR_PIPECAT_METRICS_AUDIO_SECONDS)) 647 if audio: 648 recorder.record_stt_audio( 649 session_id=session_id, 650 audio_seconds=audio, 651 model=model, 652 provider=provider, 653 ) 654 elif self._is_tts(name): 655 raw_chars = attrs.get(ATTR_PIPECAT_METRICS_CHAR_COUNT) 656 try: 657 chars = int(raw_chars) if raw_chars is not None else 0 # type: ignore[arg-type] 658 except (TypeError, ValueError): 659 chars = 0 660 if chars: 661 recorder.record_tts_characters( 662 session_id=session_id, 663 characters=chars, 664 model=model, 665 provider=provider, 666 ) 667 elif self._is_llm(name): 668 try: 669 tin = int(attrs.get("gen_ai.usage.input_tokens") or 0) # type: ignore[arg-type] 670 except (TypeError, ValueError): 671 tin = 0 672 try: 673 tout = int(attrs.get("gen_ai.usage.output_tokens") or 0) # type: ignore[arg-type] 674 except (TypeError, ValueError): 675 tout = 0 676 if tin or tout: 677 recorder.record_llm_tokens( 678 session_id=session_id, 679 input_tokens=tin, 680 output_tokens=tout, 681 model=model, 682 provider=provider, 683 ) 684 # Keep session totals for session.* close histograms. 685 cid = str( 686 attrs.get(ATTR_SESSION_CONVERSATION_ID) 687 or attrs.get(ATTR_PIPECAT_CONVERSATION_ID) 688 or "" 689 ).strip() 690 sess = lookup_session(cid) if cid else _pipecat_state(get_active_session()) 691 if sess is not None and sess.session_id == session_id: 692 if tin: 693 sess.total_input_tokens += tin 694 if tout: 695 sess.total_output_tokens += tout 696 697 def _map_transcript_text(self, span: ReadableSpan) -> None: 698 attrs = span.attributes or {} 699 name = span.name or "" 700 # Prefer final transcripts; interim STT still updates the buffer but we 701 # only remint the user turn on finals (or when is_final is absent). 702 is_final = attrs.get("is_final") 703 transcript = ( 704 attrs.get(ATTR_PIPECAT_TRANSCRIPT) 705 or attrs.get(ATTR_PIPECAT_TEXT) 706 or attrs.get("output") 707 ) 708 if transcript is None or isinstance(transcript, (list, tuple)): 709 return 710 text = str(transcript).strip() 711 if not text: 712 return 713 cid, turn_n = self._resolve_conversation_and_turn(span) 714 if self._is_stt(name): 715 if not attrs.get(ATTR_TURN_USER_TEXT): 716 self._set(span, ATTR_TURN_USER_TEXT, text) 717 if not cid: 718 return 719 if turn_n is not None: 720 self._bucket(cid, turn_n)["user"] = text 721 # Interim results: keep buffer only. 722 if is_final is False: 723 return 724 state = lookup_session(cid) or _pipecat_state(get_active_session()) 725 if state is None: 726 state = ensure_session(cid) 727 if state is None: 728 return 729 self._emit_user_from_stt( 730 state, 731 cid=cid, 732 turn_n=turn_n, 733 text=text, 734 start_ns=int(span.start_time) if span.start_time else None, 735 end_ns=int(span.end_time) if span.end_time else None, 736 ) 737 elif self._is_tts(name) or self._is_llm(name): 738 if not attrs.get(ATTR_TURN_AGENT_TEXT): 739 self._set(span, ATTR_TURN_AGENT_TEXT, text) 740 if cid and turn_n is None: 741 return 742 bucket = self._bucket(cid, turn_n) if cid and turn_n is not None else None 743 if bucket is None: 744 return 745 if self._is_tts(name): 746 prev = str(bucket.get("agent") or "") 747 # Avoid "hello hello" when Pipecat retries / double-fires TTS. 748 if prev == text or prev.endswith(text): 749 return 750 if text.startswith(prev) and prev: 751 bucket["agent"] = text 752 else: 753 bucket["agent"] = f"{prev} {text}".strip() if prev else text 754 # New speech after a prior flush (tool then follow-up answer). 755 bucket["agent_emitted"] = False 756 elif not bucket.get("agent"): 757 # LLM output as fallback when TTS never lands text. 758 bucket["agent"] = text 759 bucket["agent_emitted"] = False
Enrich and remap Pipecat OTEL spans for Parlot export.
102 def __init__(self, *, context: ParlotContext | None = None) -> None: 103 self._context = context 104 # conversation_id → (start_ns by turn_number) for duration-preserving remint 105 self._turn_starts: dict[str, dict[int, int]] = {} 106 # Live parent spans (turn/conversation) so children can read attrs set 107 # after start_span (Pipecat sets conversation.id / turn.number post-start). 108 self._open_spans: dict[str, Any] = {} 109 # conversation_id → turn_number → utterance + timing/latency buffers 110 self._utterances: dict[str, dict[int, dict[str, Any]]] = {} 111 self._active_conversation_id: str = "" 112 self._active_turn_number: int | None = None
131 def on_start(self, span, parent_context=None) -> None: # noqa: ANN001 132 # SDK Span (not API Span) — leave untyped like LiveKit processor. 133 name = getattr(span, "name", None) or "" 134 if self._debug_log_span(name): 135 super().on_start(span, parent_context) 136 sid = _span_id_hex(span) 137 if sid and name in (NATIVE_CONVERSATION, NATIVE_TURN): 138 self._open_spans[sid] = span 139 # Activate session as early as possible so INFO logs correlate. 140 if name == NATIVE_TURN: 141 parent_attrs = self._parent_attrs(span) 142 cid = str( 143 (getattr(span, "attributes", None) or {}).get(ATTR_PIPECAT_CONVERSATION_ID) 144 or parent_attrs.get(ATTR_PIPECAT_CONVERSATION_ID) 145 or self._active_conversation_id 146 or "" 147 ).strip() 148 if cid: 149 self._active_conversation_id = cid 150 ensure_session(cid)
Called when a opentelemetry.trace.Span is started.
This method is called synchronously on the thread that starts the span, therefore it should not block or throw an exception.
Arguments:
- span: The
opentelemetry.trace.Spanthat just started. - parent_context: The parent context of the span that just started.
152 def on_end(self, span: ReadableSpan) -> None: 153 name = span.name or "" 154 if self._debug_log_span(name): 155 super().on_end(span) 156 attrs = span.attributes or {} 157 sid = "" 158 try: 159 ctx = span.get_span_context() 160 if ctx is not None: 161 sid = format(int(ctx.span_id), "016x") 162 except Exception: 163 sid = "" 164 if sid: 165 self._open_spans.pop(sid, None) 166 167 # Passthrough contract / GenAI spans we mint (emit_turn / ensure_session). 168 if name in CONTRACT_SPAN_NAMES or is_genai_span_name(name): 169 return 170 171 if name == NATIVE_CONVERSATION: 172 cid = str(attrs.get(ATTR_PIPECAT_CONVERSATION_ID) or "").strip() 173 if cid: 174 self._active_conversation_id = cid 175 ensure_session(cid) 176 span._name = "__parlot_drop_conversation__" 177 close_session(cid, reason="completed") 178 self._utterances.pop(cid, None) 179 self._turn_starts.pop(cid, None) 180 return 181 182 if name == NATIVE_TURN: 183 self._note_turn_context(attrs, span) 184 self._mint_contract_turn(span) 185 span._name = "__parlot_drop_turn__" 186 return 187 188 exported = remap_pipecat_span_name(name, attrs) 189 if exported is None: 190 span._name = f"__parlot_drop_{name}__" 191 return 192 193 apply_pipecat_span_rename(span) 194 self._stamp_session_linkage(span) 195 self._stamp_agent_role(span) 196 self._map_pipecat_metrics(span) 197 self._buffer_span_timing(span) 198 self._map_transcript_text(span) 199 self._record_usage_metrics(span) 200 # Orphan / tool-continuation TTS: mint agent before the next user STT 201 # arrives so indices stay chronological (user N, agent N+1). 202 if self._is_tts(name): 203 self._maybe_emit_orphan_agent(span)
Called when a opentelemetry.trace.Span is ended.
This method is called synchronously on the thread that ends the span, therefore it should not block or throw an exception.
Arguments:
- span: The
opentelemetry.trace.Spanthat just ended.
108class ParlotRecordingProcessor(AudioBufferProcessor): 109 """Mixed dual-mono stereo → ~30s WAV (or Opus) segments → presigned PUT → ``/complete``. 110 111 Place after ``transport.output()`` so user and bot are buffered. Pipecat mixes 112 both tracks (``num_channels=1``); we expand to stereo with the same mix on L and R 113 so playback matches LiveKit Room Composite rather than hard-panned L/R. 114 115 Fail-open: never raises into the call path; stamps ``session.recording.status=failed`` 116 with a reason when capture/upload cannot complete. 117 """ 118 119 def __init__( 120 self, 121 *, 122 sample_rate: int | None = 16_000, 123 segment_seconds: float = DEFAULT_SEGMENT_SECONDS, 124 format: str = "pcm_wav", 125 context: ParlotContext | None = None, 126 **kwargs: Any, 127 ) -> None: 128 mono_bytes = int(max(1.0, segment_seconds) * _DEFAULT_MONO_BYTES_PER_SEC) 129 if sample_rate and sample_rate != 16_000: 130 mono_bytes = int(max(1.0, segment_seconds) * sample_rate * 2) 131 # Mono merge in Pipecat (not user-L / bot-R); we dual-mono to stereo on upload. 132 super().__init__( 133 sample_rate=sample_rate, 134 num_channels=1, 135 buffer_size=mono_bytes, 136 auto_start_recording=True, 137 **kwargs, 138 ) 139 self._segment_seconds = float(segment_seconds) 140 self._format = "opus_ogg" if format == "opus_ogg" else "pcm_wav" 141 self._context = context 142 self._segment_index = 0 143 self._uploaded_segments: list[dict[str, int]] = [] 144 self._sample_offset = 0 145 self._sample_count = 0 146 self._anchor_wall_ms: int | None = None 147 self._audio_uri: str | None = None 148 self._finished = False 149 self._upload_lock = asyncio.Lock() 150 self._had_non_silent = False 151 self._received_audio = False 152 # Cached so /complete still works after CancelFrame tears down the session. 153 self._cached_session_id: str = "" 154 self._cached_conversation_id: str = "" 155 self._cached_runtime: Any = None 156 self._cached_session_span: Any = None 157 158 try: 159 from ._auto import register_recording_processor 160 161 register_recording_processor(self) 162 except Exception: 163 pass 164 165 @self.event_handler("on_audio_data") 166 async def _on_audio(processor, audio, sample_rate, num_channels): 167 try: 168 await self._upload_segment(audio, sample_rate, num_channels) 169 except Exception: 170 logger.warning("parlot: segment upload failed", exc_info=True) 171 stamp_recording_error(self._session_span(), "segment_upload_error") 172 173 @self.event_handler("on_recording_started") 174 async def _on_started(processor): 175 try: 176 self._maybe_stamp_started() 177 except Exception: 178 logger.debug("parlot: recording started stamp failed", exc_info=True) 179 180 async def process_frame(self, frame, direction: FrameDirection): # type: ignore[override] 181 # Complete *before* End/Cancel propagate: pushing CancelFrame ends the 182 # Pipecat conversation span, which calls close_session and clears the 183 # active session — finish() would then skip /complete (no session id). 184 if isinstance(frame, (EndFrame, CancelFrame)): 185 try: 186 await self.finish() 187 except Exception: 188 logger.warning("parlot: recording finish on end failed", exc_info=True) 189 await super().process_frame(frame, direction) 190 return 191 await super().process_frame(frame, direction) 192 193 def _runtime(self): 194 if self._cached_runtime is not None: 195 return self._cached_runtime 196 ctx = self._context 197 if ctx is None: 198 from ._auto import configured_context 199 200 ctx = configured_context() 201 if ctx is None: 202 return None 203 return getattr(ctx, "runtime", None) 204 205 def _session_span(self): 206 span = get_active_session_span() 207 if span is not None: 208 self._cached_session_span = span 209 return span 210 return self._cached_session_span 211 212 def _session_state(self): 213 state = get_active_session() 214 if state is not None: 215 return state 216 return any_open_session() 217 218 def _session_id(self) -> str: 219 state = self._session_state() 220 sid = str(getattr(state, "session_id", "") or "") if state else "" 221 if sid: 222 self._cached_session_id = sid 223 return sid 224 return self._cached_session_id 225 226 def _cache_binding(self) -> None: 227 runtime = self._runtime() 228 if runtime is not None: 229 self._cached_runtime = runtime 230 sid = self._session_id() 231 if sid: 232 self._cached_session_id = sid 233 state = self._session_state() 234 cid = str(getattr(state, "conversation_id", "") or "") if state else "" 235 if cid: 236 self._cached_conversation_id = cid 237 span = get_active_session_span() 238 if span is not None: 239 self._cached_session_span = span 240 241 def _maybe_stamp_started(self) -> None: 242 if self._anchor_wall_ms is not None: 243 return 244 self._anchor_wall_ms = int(time.time() * 1000) 245 self._cache_binding() 246 state = self._session_state() 247 if state is not None: 248 set_recording_anchor_wall_ms(state, self._anchor_wall_ms) 249 stamp_recording_started( 250 self._session_span(), 251 anchor_wall_ms=self._anchor_wall_ms, 252 source="pipecat_buffer", 253 format=self._format, 254 ) 255 256 async def _upload_segment( 257 self, audio: bytes, sample_rate: int, num_channels: int 258 ) -> None: 259 if self._finished or not audio: 260 return 261 async with self._upload_lock: 262 await self._upload_segment_locked(audio, sample_rate, num_channels) 263 264 async def _upload_segment_locked( 265 self, audio: bytes, sample_rate: int, num_channels: int 266 ) -> None: 267 self._received_audio = True 268 self._maybe_stamp_started() 269 self._cache_binding() 270 if not _pcm_is_silent(audio): 271 self._had_non_silent = True 272 273 runtime = self._cached_runtime or self._runtime() 274 session_id = self._cached_session_id or self._session_id() 275 if runtime is None or not session_id: 276 logger.warning( 277 "parlot: recording segment skipped — no runtime/session yet" 278 ) 279 return 280 281 # Timeline frames from the buffer (mono mix); encode as dual-mono stereo. 282 frames = len(audio) // (2 * max(1, num_channels)) 283 pcm = ( 284 _mono_pcm_to_dual_stereo(audio) 285 if num_channels == 1 286 else audio 287 ) 288 out_channels = 2 if num_channels == 1 else num_channels 289 290 content_type = "audio/ogg" if self._format == "opus_ogg" else "audio/wav" 291 if self._format == "opus_ogg": 292 encoded = _encode_opus_ogg( 293 pcm, sample_rate=sample_rate, num_channels=out_channels 294 ) 295 if encoded is None: 296 self._format = "pcm_wav" 297 content_type = "audio/wav" 298 body = _wav_bytes( 299 pcm, sample_rate=sample_rate, num_channels=out_channels 300 ) 301 else: 302 body = encoded 303 else: 304 body = _wav_bytes( 305 pcm, sample_rate=sample_rate, num_channels=out_channels 306 ) 307 308 grant = await fetch_upload_grant( 309 runtime, 310 session_id=session_id, 311 segment_index=self._segment_index, 312 sample_offset=self._sample_offset, 313 content_type=content_type, 314 ) 315 if grant is None: 316 stamp_recording_error(self._session_span(), "upload_grant_failed") 317 return 318 put_url = grant_put_url(grant) 319 if not put_url: 320 stamp_recording_error(self._session_span(), "upload_grant_failed") 321 return 322 ok = await put_bytes_to_presigned_url( 323 put_url, body, content_type=content_type 324 ) 325 if not ok: 326 stamp_recording_error(self._session_span(), "segment_put_failed") 327 return 328 329 uri = grant_audio_uri(grant) 330 if uri: 331 self._audio_uri = uri 332 self._uploaded_segments.append( 333 { 334 "segment_index": self._segment_index, 335 "sample_offset": self._sample_offset, 336 } 337 ) 338 self._sample_offset += frames 339 self._sample_count += frames 340 self._segment_index += 1 341 342 async def finish(self) -> None: 343 """Flush last partial segment and POST ``/complete``. Fail-open.""" 344 if self._finished: 345 return 346 self._finished = True 347 try: 348 from ._auto import unregister_recording_processor 349 350 unregister_recording_processor(self) 351 except Exception: 352 pass 353 try: 354 await asyncio.shield( 355 asyncio.wait_for(self._finish_inner(), timeout=CLOSE_TIMEOUT_S) 356 ) 357 except asyncio.TimeoutError: 358 stamp_recording_error(self._session_span(), "complete_timeout") 359 except asyncio.CancelledError: 360 # Pipeline cancel can interrupt us; still try to complete once. 361 try: 362 await self._finish_inner() 363 except Exception as exc: 364 stamp_recording_error( 365 self._session_span(), f"complete_failed:{type(exc).__name__}" 366 ) 367 raise 368 except Exception as exc: 369 stamp_recording_error( 370 self._session_span(), f"complete_failed:{type(exc).__name__}" 371 ) 372 logger.warning("parlot: recording finish failed — %s", exc, exc_info=True) 373 374 async def _finish_inner(self) -> None: 375 try: 376 await self.stop_recording() 377 except Exception: 378 logger.debug("parlot: stop_recording during finish failed", exc_info=True) 379 380 if self._segment_index == 0 and self._sample_count == 0: 381 if not self._received_audio: 382 stamp_recording_error(self._session_span(), "no_audio") 383 else: 384 stamp_recording_error(self._session_span(), "silent") 385 return 386 387 if not self._had_non_silent: 388 stamp_recording_error(self._session_span(), "silent") 389 return 390 391 runtime = self._cached_runtime or self._runtime() 392 session_id = self._cached_session_id or self._session_id() 393 if runtime is None or not session_id: 394 stamp_recording_error(self._session_span(), "complete_no_session") 395 logger.warning( 396 "parlot: recording complete skipped — no session " 397 "(cached_id=%r)", 398 self._cached_session_id, 399 ) 400 return 401 402 wall_end_ms = int(time.time() * 1000) 403 logger.info( 404 "parlot: recording complete session_id=%s segments=%s samples=%s", 405 session_id, 406 self._segment_index, 407 self._sample_count, 408 ) 409 conversation_id = self._cached_conversation_id 410 if not conversation_id: 411 state = self._session_state() 412 conversation_id = ( 413 str(getattr(state, "conversation_id", "") or "") if state else "" 414 ) 415 result = await complete_recording( 416 runtime, 417 session_id=session_id, 418 format=self._format, 419 conversation_id=conversation_id or None, 420 segment_count=self._segment_index, 421 segments=list(self._uploaded_segments), 422 sample_rate=self.sample_rate or 16_000, 423 anchor_wall_ms=self._anchor_wall_ms, 424 sample_count=self._sample_count, 425 wall_end_ms=wall_end_ms, 426 ) 427 if result is None: 428 stamp_recording_error(self._session_span(), "complete_failed") 429 return 430 431 audio_uri = str(result.get("audio_recording_uri") or self._audio_uri or "") 432 partial = bool(result.get("partial")) 433 if audio_uri: 434 stamp_recording_uploaded( 435 self._session_span(), 436 audio_uri=audio_uri, 437 partial=partial, 438 sample_count=self._sample_count, 439 wall_end_ms=wall_end_ms, 440 format=self._format, 441 ) 442 443 async def fail(self, reason: str) -> None: 444 stamp_recording_error(self._session_span(), reason) 445 self._finished = True
Mixed dual-mono stereo → ~30s WAV (or Opus) segments → presigned PUT → /complete.
Place after transport.output() so user and bot are buffered. Pipecat mixes
both tracks (num_channels=1); we expand to stereo with the same mix on L and R
so playback matches LiveKit Room Composite rather than hard-panned L/R.
Fail-open: never raises into the call path; stamps session.recording.status=failed
with a reason when capture/upload cannot complete.
119 def __init__( 120 self, 121 *, 122 sample_rate: int | None = 16_000, 123 segment_seconds: float = DEFAULT_SEGMENT_SECONDS, 124 format: str = "pcm_wav", 125 context: ParlotContext | None = None, 126 **kwargs: Any, 127 ) -> None: 128 mono_bytes = int(max(1.0, segment_seconds) * _DEFAULT_MONO_BYTES_PER_SEC) 129 if sample_rate and sample_rate != 16_000: 130 mono_bytes = int(max(1.0, segment_seconds) * sample_rate * 2) 131 # Mono merge in Pipecat (not user-L / bot-R); we dual-mono to stereo on upload. 132 super().__init__( 133 sample_rate=sample_rate, 134 num_channels=1, 135 buffer_size=mono_bytes, 136 auto_start_recording=True, 137 **kwargs, 138 ) 139 self._segment_seconds = float(segment_seconds) 140 self._format = "opus_ogg" if format == "opus_ogg" else "pcm_wav" 141 self._context = context 142 self._segment_index = 0 143 self._uploaded_segments: list[dict[str, int]] = [] 144 self._sample_offset = 0 145 self._sample_count = 0 146 self._anchor_wall_ms: int | None = None 147 self._audio_uri: str | None = None 148 self._finished = False 149 self._upload_lock = asyncio.Lock() 150 self._had_non_silent = False 151 self._received_audio = False 152 # Cached so /complete still works after CancelFrame tears down the session. 153 self._cached_session_id: str = "" 154 self._cached_conversation_id: str = "" 155 self._cached_runtime: Any = None 156 self._cached_session_span: Any = None 157 158 try: 159 from ._auto import register_recording_processor 160 161 register_recording_processor(self) 162 except Exception: 163 pass 164 165 @self.event_handler("on_audio_data") 166 async def _on_audio(processor, audio, sample_rate, num_channels): 167 try: 168 await self._upload_segment(audio, sample_rate, num_channels) 169 except Exception: 170 logger.warning("parlot: segment upload failed", exc_info=True) 171 stamp_recording_error(self._session_span(), "segment_upload_error") 172 173 @self.event_handler("on_recording_started") 174 async def _on_started(processor): 175 try: 176 self._maybe_stamp_started() 177 except Exception: 178 logger.debug("parlot: recording started stamp failed", exc_info=True)
Initialize the audio buffer processor.
Arguments:
- sample_rate: Desired output sample rate. If None, uses source rate.
- num_channels: Number of channels (1 for mono, 2 for stereo). Defaults to 1.
- buffer_size: Size of buffer before triggering events. 0 for no buffering.
- enable_turn_audio: Whether turn audio event handlers should be triggered.
- auto_start_recording: Whether to start recording automatically when
the pipeline starts, without requiring a call to
start_recording()or anAudioBufferStartRecordingFrame. - **kwargs: Additional arguments passed to parent class.
180 async def process_frame(self, frame, direction: FrameDirection): # type: ignore[override] 181 # Complete *before* End/Cancel propagate: pushing CancelFrame ends the 182 # Pipecat conversation span, which calls close_session and clears the 183 # active session — finish() would then skip /complete (no session id). 184 if isinstance(frame, (EndFrame, CancelFrame)): 185 try: 186 await self.finish() 187 except Exception: 188 logger.warning("parlot: recording finish on end failed", exc_info=True) 189 await super().process_frame(frame, direction) 190 return 191 await super().process_frame(frame, direction)
Process incoming audio frames and manage audio buffers.
Arguments:
- frame: The frame to process.
- direction: The direction of frame flow in the pipeline.
342 async def finish(self) -> None: 343 """Flush last partial segment and POST ``/complete``. Fail-open.""" 344 if self._finished: 345 return 346 self._finished = True 347 try: 348 from ._auto import unregister_recording_processor 349 350 unregister_recording_processor(self) 351 except Exception: 352 pass 353 try: 354 await asyncio.shield( 355 asyncio.wait_for(self._finish_inner(), timeout=CLOSE_TIMEOUT_S) 356 ) 357 except asyncio.TimeoutError: 358 stamp_recording_error(self._session_span(), "complete_timeout") 359 except asyncio.CancelledError: 360 # Pipeline cancel can interrupt us; still try to complete once. 361 try: 362 await self._finish_inner() 363 except Exception as exc: 364 stamp_recording_error( 365 self._session_span(), f"complete_failed:{type(exc).__name__}" 366 ) 367 raise 368 except Exception as exc: 369 stamp_recording_error( 370 self._session_span(), f"complete_failed:{type(exc).__name__}" 371 ) 372 logger.warning("parlot: recording finish failed — %s", exc, exc_info=True)
Flush last partial segment and POST /complete. Fail-open.
30def stamp_external_recording( 31 *, 32 audio_uri: str, 33 anchor_wall_ms: int, 34 mode: str = "link", 35 format: str = "opus_ogg", 36 sample_count: Optional[int] = None, 37 wall_end_ms: Optional[int] = None, 38 pii_redaction_required: bool = False, 39 span: Any = None, 40) -> bool: 41 """BYO recording stamp. 42 43 * ``mode="link"`` — external URI only. Rejected when *pii_redaction_required*. 44 * ``mode="ingest"`` — caller already uploaded via grant; stamp as uploaded. 45 46 Returns False when rejected or no session span. 47 """ 48 target = span if span is not None else get_active_session_span() 49 if target is None: 50 logger.warning("parlot: stamp_external_recording skipped — no session span") 51 return False 52 53 if mode == "link" and pii_redaction_required: 54 stamp_recording_error( 55 target, 56 "byo_link_rejected:tenant_pii_redaction_enabled", 57 ) 58 return False 59 60 source = "byo_link" if mode == "link" else "byo_ingest" 61 stamp_recording_started( 62 target, 63 anchor_wall_ms=anchor_wall_ms, 64 source=source, 65 format=format, 66 ) 67 stamp_recording_uploaded( 68 target, 69 audio_uri=audio_uri, 70 partial=False, 71 sample_count=sample_count, 72 wall_end_ms=wall_end_ms, 73 format=format, 74 ) 75 return True
BYO recording stamp.
mode="link"— external URI only. Rejected when pii_redaction_required.mode="ingest"— caller already uploaded via grant; stamp as uploaded.
Returns False when rejected or no session span.
89def add_platform_ref( 90 kind: str, 91 value: str, 92 *, 93 framework: str = "custom", 94) -> None: 95 """ 96 Attach a searchable external ID to the active Parlot session. 97 98 Stamps ``platform.ref.{kind}`` (and the primary triple when this is the 99 first ref) on the live session span so the session can be found by that 100 value in Parlot search / resolve. 101 102 Args: 103 kind: Identifier type (e.g. ``"crm_ticket"``, ``"order_number"``, 104 ``"call_sid"``). 105 value: Unique identifier value (e.g. ``"TKT-9921"``). 106 framework: Originating framework name. Defaults to ``"custom"``. 107 108 Example:: 109 110 from parlot.instrumentation.livekit import add_platform_ref 111 112 add_platform_ref("crm_ticket", "TKT-9") 113 """ 114 kind = str(kind or "").strip() 115 value = str(value or "").strip() 116 framework = str(framework or "custom").strip() or "custom" 117 if not kind or not value: 118 return 119 span = get_active_session_span() 120 if span is None: 121 return 122 stamp_platform_refs(span, [(framework, kind, value)])
Attach a searchable external ID to the active Parlot session.
Stamps platform.ref.{kind} (and the primary triple when this is the
first ref) on the live session span so the session can be found by that
value in Parlot search / resolve.
Arguments:
- kind: Identifier type (e.g.
"crm_ticket","order_number","call_sid"). - value: Unique identifier value (e.g.
"TKT-9921"). - framework: Originating framework name. Defaults to
"custom".
Example::
from parlot.instrumentation.livekit import add_platform_ref
add_platform_ref("crm_ticket", "TKT-9")
50@contextmanager 51def human_escalation(label: str | None = None) -> Iterator[None]: 52 """Mark the next participant who joins the active session as a human rep. 53 54 Args: 55 label: Optional role or team label for the incoming human representative. 56 """ 57 token = _pending_escalation_label.set(label) 58 try: 59 yield 60 finally: 61 _pending_escalation_label.reset(token)
Mark the next participant who joins the active session as a human rep.
Arguments:
- label: Optional role or team label for the incoming human representative.
19def record_human_rep(participant_id: str, *, label: str | None = None) -> None: 20 """ 21 Mark a participant as a human representative. 22 23 Stamps ``session.topology.agents`` on the active session span and registers 24 the participant for ``turn.participant_role=human_rep`` on future turns. 25 26 Args: 27 participant_id: Participant identifier within the room/session. 28 label: Optional human-readable name (e.g. ``"Tier 2 Escalation Desk"``). 29 """ 30 participant_id = str(participant_id or "").strip() 31 if not participant_id: 32 return 33 34 state = get_active_session() 35 if state is not None: 36 state.human_rep_participant_ids.add(participant_id) 37 entry: dict[str, str] = {"id": participant_id, "role": "human_rep"} 38 if label: 39 entry["label"] = label 40 if not any(a.get("id") == participant_id for a in state.topology_agents): 41 state.topology_agents.append(entry) 42 43 span = get_active_session_span() 44 if span is not None and state is not None and state.topology_agents: 45 span.set_attribute( 46 ATTR_SESSION_TOPOLOGY_AGENTS, json.dumps(state.topology_agents) 47 )
Mark a participant as a human representative.
Stamps session.topology.agents on the active session span and registers
the participant for turn.participant_role=human_rep on future turns.
Arguments:
- participant_id: Participant identifier within the room/session.
- label: Optional human-readable name (e.g.
"Tier 2 Escalation Desk").
20def set_session_attribute(key: str, value: str | int | float | bool) -> None: 21 """ 22 Stamp one custom attribute on the active session under ``session.metadata.*``. 23 24 Keys are normalized to ``session.metadata.<key>``. Values are stored as 25 strings on the live session span and remembered on session state so they 26 are also present on ``parlot.session.close``. 27 28 Args: 29 key: Attribute name. If not prefixed with ``session.metadata.``, the 30 prefix is added automatically. 31 value: Value to record (``str``, ``int``, ``float``, or ``bool``). 32 """ 33 full_key = session_metadata_key(key) 34 if not full_key or full_key == ATTR_SESSION_METADATA_PREFIX: 35 return 36 str_value = value if isinstance(value, str) else str(value) 37 38 state = get_active_session() 39 if state is not None: 40 state.custom_metadata[full_key] = str_value 41 42 span = get_active_session_span() 43 if span is not None and hasattr(span, "set_attribute"): 44 span.set_attribute(full_key, str_value)
Stamp one custom attribute on the active session under session.metadata.*.
Keys are normalized to session.metadata.<key>. Values are stored as
strings on the live session span and remembered on session state so they
are also present on parlot.session.close.
Arguments:
- key: Attribute name. If not prefixed with
session.metadata., the prefix is added automatically. - value: Value to record (
str,int,float, orbool).
47def set_session_metadata(**pairs: str | int | float | bool) -> None: 48 """ 49 Attach custom key/value metadata to the active Parlot session. 50 51 Each keyword becomes ``session.metadata.<name>`` on the session span and 52 appears in session detail in the Parlot UI. 53 54 Args: 55 **pairs: Keyword metadata pairs (values ``str``, ``int``, ``float``, 56 or ``bool``). 57 58 Example:: 59 60 from parlot.instrumentation.livekit import set_session_metadata 61 62 set_session_metadata(order_id="12345", crm_ticket="TKT-9") 63 """ 64 for key, value in pairs.items(): 65 set_session_attribute(key, value)
Attach custom key/value metadata to the active Parlot session.
Each keyword becomes session.metadata.<name> on the session span and
appears in session detail in the Parlot UI.
Arguments:
- **pairs: Keyword metadata pairs (values
str,int,float, orbool).
Example::
from parlot.instrumentation.livekit import set_session_metadata
set_session_metadata(order_id="12345", crm_ticket="TKT-9")
46def stamp_platform_refs( 47 span: Any, 48 refs: list[tuple[str, str, str]], 49) -> None: 50 """Stamp ``platform.ref.*`` triples onto a span. 51 52 ``refs`` is a list of ``(framework, kind, value)`` tuples, e.g. 53 ``("livekit", "room_sid", "RM_abc")``. The first tuple is also written 54 to the canonical triple attributes (``platform.ref.framework/kind/value``) 55 so backends can pivot on a single primary ref. 56 57 Every tuple is also written as a flat ``platform.ref.{kind} = value`` key 58 for ingestion fallbacks. 59 60 Args: 61 span: A live OTel span (``set_attribute``) or a ``ReadableSpan`` with a 62 mutable ``_attributes`` dict. 63 refs: Non-empty list of reference triples. 64 """ 65 if not refs: 66 return 67 68 def _write(key: str, value: str) -> None: 69 if hasattr(span, "set_attribute") and callable(span.set_attribute): 70 try: 71 span.set_attribute(key, value) 72 return 73 except Exception: 74 pass 75 try: 76 ParlotBaseProcessor._set(span, key, value) 77 except Exception: 78 return 79 80 fw, kind, val = refs[0] 81 _write(ATTR_PLATFORM_FRAMEWORK, fw) 82 _write(ATTR_PLATFORM_KIND, kind) 83 _write(ATTR_PLATFORM_VALUE, val) 84 85 for _fw_i, kind_i, val_i in refs: 86 _write(platform_ref_flat_key(kind_i), val_i)
Stamp platform.ref.* triples onto a span.
refs is a list of (framework, kind, value) tuples, e.g.
("livekit", "room_sid", "RM_abc"). The first tuple is also written
to the canonical triple attributes (platform.ref.framework/kind/value)
so backends can pivot on a single primary ref.
Every tuple is also written as a flat platform.ref.{kind} = value key
for ingestion fallbacks.
Arguments:
- span: A live OTel span (
set_attribute) or aReadableSpanwith a mutable_attributesdict. - refs: Non-empty list of reference triples.