parlot.instrumentation.livekit
parlot-instrumentation-livekit: OTel instrumentation for LiveKit Agents.
1"""parlot-instrumentation-livekit: OTel instrumentation for LiveKit 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 parlotize 13from ._events import install_session_hooks 14from ._processor import LiveKitGenAIProcessor 15 16__all__ = [ 17 "parlotize", 18 "install_session_hooks", 19 "LiveKitGenAIProcessor", 20 "add_platform_ref", 21 "human_escalation", 22 "record_human_rep", 23 "set_session_attribute", 24 "set_session_metadata", 25 "stamp_platform_refs", 26]
30def parlotize( 31 agent_id: str, 32 *, 33 endpoint: Optional[str] = None, 34 api_key: Optional[str] = None, 35 capture_genai_content: Optional[bool] = None, 36 service_name: Optional[str] = None, 37 tracer_provider: TracerProvider | None = None, 38 auto_escalate_sip: bool = False, 39 escalation_metadata_match: dict[str, str] | None = None, 40 version: Optional[str] = None, 41 record: bool | list[str] | None = None, 42 await_recording: bool = False, 43 capture_logs: bool | list[str] | None = None, 44 log_level: Optional[str] = None, 45) -> ParlotContext: 46 """Parlotize LiveKit instrumentation and OTLP export. 47 48 Call before constructing ``AgentSession``. Builds a ``TracerProvider`` with 49 an OTLP exporter, registers it with ``livekit.agents.telemetry``, fetches 50 telemetry bootstrap when ``PARLOT_API_KEY`` is set, patches 51 ``JobContext.connect`` / ``AgentSession.__init__``, and installs session 52 event hooks. 53 54 Shared parameters (``agent_id``, plus keyword-only ``endpoint``, ``api_key``, 55 ``version``, ``capture_genai_content``, ``capture_logs``, ``log_level``, 56 ``service_name``, ``tracer_provider``) match every adapter — see 57 ``parlot.core.ParlotizeProtocol``. 58 59 Under LiveKit ``dev`` / job workers, ``__main__`` is often LiveKit's IPC 60 entrypoint, not your agent file — pass ``agent_id`` / ``version=`` 61 explicitly when you care about stable deployment identity. 62 63 Args: 64 agent_id: Shared — required canonical ``session.agent_id``. Must be 65 non-empty after stripping whitespace. 66 endpoint: Shared — Parlot OTLP base URL (or ``PARLOT_ENDPOINT``). 67 api_key: Shared — org API key (or ``PARLOT_API_KEY``). 68 capture_genai_content: Shared — GenAI payload capture override. 69 Precedence: job metadata → this kwarg → Settings → Generative AI → on. 70 service_name: Shared — OTel ``service.name`` (defaults to ``agent_id`` 71 or ``"unknown"``). 72 tracer_provider: Shared — existing ``TracerProvider``, or build one with 73 Parlot's OTLP exporter and ``LiveKitGenAIProcessor``. 74 auto_escalate_sip: When ``True``, mark the session escalated when a SIP 75 participant joins the room. 76 escalation_metadata_match: Participant metadata key/value pairs that 77 classify joining participants as human representatives. 78 version: Shared — ``gen_ai.agent.version``. Pass ``version=`` to set it; 79 otherwise stamped as ``\"unknown\"``. 80 record: Audio recording policy. Boolean or agent-id glob patterns 81 (e.g. ``["support-*", "billing"]``). Precedence: LiveKit job 82 metadata ``record`` → this kwarg → Settings → Recording. 83 await_recording: When ``True`` and recording is enabled, ``ctx.connect()`` 84 bootstraps the Parlot session if needed and waits until Room Composite 85 egress has started (recording timeline t=0) before returning. Use with 86 ``await ctx.connect()`` before ``session.start()`` so the first agent 87 greeting is not cut from the session recording. Default ``False`` 88 preserves low connect latency (egress may start after the greeting). 89 capture_logs: Shared — session log capture (bool or globs). Precedence: 90 job metadata → this kwarg → Settings → Logs → on. 91 log_level: Shared — minimum level for session log capture. 92 93 Returns: 94 The ``ParlotContext`` created for this process (or the prior one if 95 already configured). 96 """ 97 global _configured, _auto_escalate_sip, _escalation_metadata_match 98 global _configured_agent_id, _configured_agent_version 99 global _configured_record, _configured_await_recording 100 global _configured_capture_genai_content, _configured_capture_logs, _configured_log_level 101 global _parlot_context 102 if _configured: 103 logger.debug("parlot-instrumentation.livekit already configured — skipping") 104 assert _parlot_context is not None 105 return _parlot_context 106 107 # Note: do not skip under ``lk agent dev`` / ``start --dev``. Hot-reload is 108 # owned by the Go CLI; this Python process *is* the worker (LiveKit removed 109 # the in-process watch parent that used to spawn a child). 110 111 _auto_escalate_sip = auto_escalate_sip 112 _escalation_metadata_match = ( 113 dict(escalation_metadata_match) if escalation_metadata_match else None 114 ) 115 from parlot.core.parlotize import normalize_glob_or_bool 116 from parlot.core.provider import resolve_capture_genai_content 117 118 _configured_record = normalize_glob_or_bool(record) 119 _configured_await_recording = bool(await_recording) 120 _configured_capture_genai_content = resolve_capture_genai_content(capture_genai_content) 121 _configured_capture_logs = normalize_glob_or_bool(capture_logs) 122 _configured_log_level = log_level.strip().upper() if log_level else None 123 124 from ._agent_version import resolve_agent_version 125 _configured_agent_version = resolve_agent_version(version) 126 127 from parlot.core import base_parlotize 128 129 res = base_parlotize( 130 agent_id, 131 endpoint=endpoint, 132 api_key=api_key, 133 capture_genai_content=capture_genai_content, 134 tracer_provider=tracer_provider, 135 version=_configured_agent_version, 136 capture_logs=capture_logs, 137 log_level=log_level, 138 ) 139 _parlot_context = res.context 140 _configured_agent_id = res.agent_id 141 142 from ._recording_guard import ( 143 current_job_capture_logs_metadata, 144 livekit_session_log_fields, 145 recording_agent_id_from_ctx, 146 ) 147 148 def _agent_id_resolver() -> str: 149 try: 150 from livekit.agents.job import get_job_context 151 152 ctx = get_job_context() 153 if ctx is not None: 154 return recording_agent_id_from_ctx(ctx) 155 except Exception: 156 pass 157 return _configured_agent_id or "" 158 159 res.context.session_logs.set_resolvers( 160 session_resolver=livekit_session_log_fields, 161 agent_id_resolver=_agent_id_resolver, 162 metadata_resolver=current_job_capture_logs_metadata, 163 ) 164 165 tracer_provider = res.tracer_provider 166 if tracer_provider is None: 167 tracer_provider = _build_provider( 168 endpoint=res.endpoint, 169 api_key=res.api_key, 170 service_name=service_name, 171 service_version=_configured_agent_version or None, 172 context=res.context, 173 ) 174 175 from parlot.core.processor import assert_sync_span_processors 176 177 from ._session import set_span_context_attach_enabled 178 179 attach_ok = assert_sync_span_processors(tracer_provider) 180 set_span_context_attach_enabled(attach_ok) 181 182 _register_with_livekit(tracer_provider) 183 _patch_agent_session_with_tracer(tracer_provider) 184 _patch_job_context_connect() 185 _install_telemetry_compare() 186 187 _configured = True 188 from parlot.core.parlotize import log_parlotize_ready 189 190 bootstrap_ok = res.context.runtime is not None 191 # Treat missing endpoint/key as not-ready (same as prior warning path). 192 if not res.endpoint or not res.api_key: 193 bootstrap_ok = False 194 log_parlotize_ready( 195 "livekit", 196 agent_id=res.agent_id, 197 endpoint=res.endpoint or "", 198 api_key=res.api_key or "", 199 bootstrap_ok=bootstrap_ok, 200 log=logger, 201 ) 202 return res.context
Parlotize LiveKit instrumentation and OTLP export.
Call before constructing AgentSession. Builds a TracerProvider with
an OTLP exporter, registers it with livekit.agents.telemetry, fetches
telemetry bootstrap when PARLOT_API_KEY is set, patches
JobContext.connect / AgentSession.__init__, and installs session
event hooks.
Shared parameters (agent_id, plus keyword-only endpoint, api_key,
version, capture_genai_content, capture_logs, log_level,
service_name, tracer_provider) match every adapter — see
parlot.core.ParlotizeProtocol.
Under LiveKit dev / job workers, __main__ is often LiveKit's IPC
entrypoint, not your agent file — pass agent_id / version=
explicitly when you care about stable deployment identity.
Arguments:
- agent_id: Shared — required canonical
session.agent_id. Must be non-empty after stripping whitespace. - 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. Precedence: job metadata → this kwarg → Settings → Generative AI → on.
- service_name: Shared — OTel
service.name(defaults toagent_idor"unknown"). - tracer_provider: Shared — existing
TracerProvider, or build one with Parlot's OTLP exporter andLiveKitGenAIProcessor. - auto_escalate_sip: When
True, mark the session escalated when a SIP participant joins the room. - escalation_metadata_match: Participant metadata key/value pairs that classify joining participants as human representatives.
- version: Shared —
gen_ai.agent.version. Passversion=to set it; otherwise stamped as"unknown". - record: Audio recording policy. Boolean or agent-id glob patterns
(e.g.
["support-*", "billing"]). Precedence: LiveKit job metadatarecord→ this kwarg → Settings → Recording. - await_recording: When
Trueand recording is enabled,ctx.connect()bootstraps the Parlot session if needed and waits until Room Composite egress has started (recording timeline t=0) before returning. Use withawait ctx.connect()beforesession.start()so the first agent greeting is not cut from the session recording. DefaultFalsepreserves low connect latency (egress may start after the greeting). - capture_logs: Shared — session log capture (bool or globs). Precedence: job metadata → this kwarg → Settings → Logs → on.
- log_level: Shared — minimum level for session log capture.
Returns:
The
ParlotContextcreated for this process (or the prior one if already configured).
491def install_session_hooks( 492 session: Any, 493 processor: "LiveKitGenAIProcessor", 494 tracer: "Tracer", 495) -> None: 496 """Install all AgentSession event listeners for Parlot instrumentation. 497 498 ``parlotize()`` patches ``AgentSession.__init__`` to invoke this 499 automatically. Call directly only when manually instantiating unpatched 500 sessions. 501 502 Args: 503 session: LiveKit ``AgentSession`` instance to instrument. 504 processor: Span processor that owns session/turn enrichment state. 505 tracer: OpenTelemetry tracer used for Parlot contract spans. 506 """ 507 LiveKitEventBridge(processor, tracer).install(session)
Install all AgentSession event listeners for Parlot instrumentation.
parlotize() patches AgentSession.__init__ to invoke this
automatically. Call directly only when manually instantiating unpatched
sessions.
Arguments:
- session: LiveKit
AgentSessioninstance to instrument. - processor: Span processor that owns session/turn enrichment state.
- tracer: OpenTelemetry tracer used for Parlot contract spans.
112class LiveKitGenAIProcessor(ParlotBaseProcessor): 113 """Enriches LiveKit Agent spans in-place with Parlot conventions. 114 115 Intercepts spans from the LiveKit Agents SDK and normalizes them into 116 Parlot's three-layer semantic vocabulary: 117 118 1. **Conversation Contract** — session start/close, turns, agent handoffs. 119 2. **OTel GenAI (v1.41.0)** — LLM inference, tool executions, workflows. 120 3. **Voice spans** — TTS, STT, and end-of-utterance operational timings. 121 """ 122 123 def __init__( 124 self, 125 capture_genai_content: Optional[bool] = None, 126 handoff_tool_names: Optional[set[str]] = None, 127 *, 128 context: Optional["ParlotContext"] = None, 129 ) -> None: 130 from parlot.core.context import ParlotContext 131 132 # Explicit override for tests / rare call sites; None → policy at emit time. 133 self._capture_genai_content_override = capture_genai_content 134 self._handoff_tools = handoff_tool_names or set() 135 self._context = context if context is not None else ParlotContext() 136 self._sessions: dict[str, _LiveKitSessionState] = {} 137 self._turn_trace_registry: dict[str, dict[int, tuple[str, str]]] = {} 138 self._tracer: Tracer | None = None 139 self._metrics = None 140 self._turn_source: str = "spans" 141 142 self._handoff = HandoffTracker( 143 set_attr=self._set, 144 handoff_tools=self._handoff_tools, 145 ) 146 self._tokens = TokenAggregator( 147 set_attr=self._set, 148 plugin_host=self, 149 ) 150 self._recording = RecordingCoordinator( 151 get_capture_override=lambda: self._capture_genai_content_override, 152 active_agent_id_fn=self._active_agent_id, 153 get_context=lambda: self._context, 154 ) 155 self._turns = TurnEnricher( 156 set_attr=self._set, 157 add_event=self._add_event, 158 maybe_update=self._maybe_update, 159 get_turn_source=lambda: self._turn_source, 160 get_tracer=lambda: self._tracer, 161 get_metrics=lambda: self._metrics, 162 genai_content_enabled=self._recording.genai_content_enabled, 163 active_agent_id=self._active_agent_id, 164 stamp_agent_identity=self._stamp_agent_identity, 165 stamp_response_model_if_distinct=self._stamp_response_model_if_distinct, 166 handoff_tracker=self._handoff, 167 token_aggregator=self._tokens, 168 recording=self._recording, 169 turn_trace_registry=self._turn_trace_registry, 170 ) 171 172 def on_start(self, span, parent_context=None) -> None: 173 super().on_start(span, parent_context) 174 # Capture turn.index while open_agent_turn_index is still set — child 175 # llm/tts spans often end after conversation_item_added clears it. 176 bootstrap = get_job_bootstrap() 177 if bootstrap is None or not bootstrap.state.parlot_session_id: 178 return 179 self._turns.stamp_turn_index_at_start(span, bootstrap.state) 180 181 def on_end(self, span: ReadableSpan) -> None: 182 super().on_end(span) 183 try: 184 name = span.name 185 if name == SPAN_CONVERSATION_SESSION: 186 handle_conversation_session_on_end() 187 return 188 self._enrich(span) 189 self._log_compare_span(span) 190 except Exception as exc: 191 logger.error( 192 "parlot: LiveKitGenAIProcessor failed on span %r — %s", 193 span.name, 194 exc, 195 ) 196 logger.debug( 197 "parlot: LiveKitGenAIProcessor failed on span %r", 198 span.name, 199 exc_info=True, 200 ) 201 202 def set_tracer(self, tracer: Tracer) -> None: 203 self._tracer = tracer 204 205 def set_metrics(self, metrics) -> None: 206 self._metrics = metrics 207 208 def set_turn_source(self, source: str) -> None: 209 if source in ("spans", "events"): 210 self._turn_source = source 211 212 @property 213 def turn_source(self) -> str: 214 return self._turn_source 215 216 def enrich_spans_for_export(self, spans: list[ReadableSpan]) -> None: 217 """Deferred enrichment, turn-attr copy, then GenAI/voice rename for export.""" 218 self._turns.correct_function_tool_timing(spans) 219 220 llm_spans = sorted( 221 (s for s in spans if s.name == "llm_node"), 222 key=lambda s: s.end_time or 0, 223 ) 224 for span in llm_spans: 225 # FIFO token attach before stamping active_speech_id (which would 226 # pin every span to the latest speech and break multi-node batches). 227 self._tokens.apply_plugin_llm_usage_to_span( 228 span, dict(span.attributes or {}), prefer_fifo=True 229 ) 230 attrs = span.attributes or {} 231 if not attrs.get(ATTR_LK_SPEECH_ID): 232 bootstrap = get_job_bootstrap() 233 speech_id = "" 234 if bootstrap is not None: 235 speech_id = bootstrap.state.active_speech_id.strip() 236 if speech_id: 237 self._set(span, ATTR_LK_SPEECH_ID, speech_id) 238 239 self._turns.merge_native_turn_attrs_onto_parlot_turns(spans) 240 241 from ._telemetry_compare import compare_enabled, get_compare_logger 242 243 if compare_enabled(): 244 bootstrap = get_job_bootstrap() 245 if bootstrap is not None and bootstrap.state.parlot_session_id: 246 session_id = bootstrap.state.parlot_session_id 247 for span in spans: 248 if span.name == "llm_node": 249 get_compare_logger().accumulate_export_tokens( 250 session_id, 251 span_name=span.name or "", 252 attrs=dict(span.attributes or {}), 253 ) 254 255 # Rename LiveKit-native ops → GenAI/voice names before export filter. 256 for span in spans: 257 native = span.name or "" 258 if native in NATIVE_TURN_SPANS: 259 # Dropped by export filter (remap returns None); attrs already merged. 260 continue 261 if remap_livekit_span_name(native, span.attributes or {}) is not None: 262 apply_livekit_span_rename(span) 263 role = livekit_agent_role_for_span(span.name or "") 264 if role and not (span.attributes or {}).get(ATTR_AGENT_ROLE): 265 self._set(span, ATTR_AGENT_ROLE, role) 266 267 def _log_compare_span(self, span: ReadableSpan) -> None: 268 from ._session import get_job_bootstrap 269 from ._telemetry_compare import get_compare_logger 270 271 bootstrap = get_job_bootstrap() 272 if bootstrap is None or not bootstrap.state.parlot_session_id: 273 return 274 attrs = span.attributes or {} 275 get_compare_logger().log_span( 276 bootstrap.state.parlot_session_id, 277 span_name=span.name or "", 278 attrs=dict(attrs), 279 ) 280 281 # ------------------------------------------------------------------ 282 # Public facades (called by _events / _egress) 283 # ------------------------------------------------------------------ 284 285 def mark_conversation_item_committed(self, item_id: str) -> bool: 286 return self._turns.mark_conversation_item_committed(item_id) 287 288 def mark_handoff_item_committed(self, item_id: str) -> None: 289 self._handoff.mark_handoff_item_committed(item_id) 290 291 def committed_handoff_item_ids(self) -> set[str]: 292 return self._handoff.committed_handoff_item_ids() 293 294 def record_handoff_from_event( 295 self, *, from_agent: str = "", to_agent: str = "" 296 ) -> None: 297 self._handoff.record_handoff_from_event( 298 from_agent=from_agent, to_agent=to_agent 299 ) 300 301 def note_user_transcription_meta( 302 self, *, speaker_id: str = "", language: str = "" 303 ) -> None: 304 self._turns.note_user_transcription_meta( 305 speaker_id=speaker_id, language=language 306 ) 307 308 def note_function_tools_executed(self, count: int) -> None: 309 self._turns.note_function_tools_executed(count) 310 311 def apply_session_usage( 312 self, total_in: int, total_out: int, total_cached: int = 0 313 ) -> None: 314 self._tokens.apply_session_usage(total_in, total_out, total_cached) 315 316 def note_session_error(self, message: str, *, recoverable: bool) -> None: 317 if not recoverable and message: 318 bootstrap = get_job_bootstrap() 319 if bootstrap is not None: 320 bootstrap.state.pending_close_error = message 321 322 def pop_pending_close_error(self) -> str | None: 323 bootstrap = get_job_bootstrap() 324 if bootstrap is None: 325 return None 326 err = bootstrap.state.pending_close_error.strip() 327 bootstrap.state.pending_close_error = "" 328 return err or None 329 330 def commit_user_message( 331 self, 332 text: str, 333 *, 334 interrupted: bool = False, 335 metrics: dict[str, float] | None = None, 336 ) -> None: 337 self._turns.commit_user_message( 338 text, interrupted=interrupted, metrics=metrics 339 ) 340 341 def commit_agent_message( 342 self, 343 text: str, 344 *, 345 interrupted: bool = False, 346 metrics: dict[str, float] | None = None, 347 ) -> None: 348 self._turns.commit_agent_message( 349 text, interrupted=interrupted, metrics=metrics 350 ) 351 352 def lookup_turn_trace( 353 self, session_id: str, turn_index: int 354 ) -> tuple[str, str] | None: 355 return self._turns.lookup_turn_trace(session_id, turn_index) 356 357 def set_recording_anchor_wall_ms( 358 self, state: _LiveKitSessionState, anchor_wall_ms: int 359 ) -> None: 360 self._recording.set_recording_anchor_wall_ms(state, anchor_wall_ms) 361 362 def _apply_plugin_llm_usage_to_span( 363 self, 364 span: ReadableSpan, 365 attrs: Mapping[str, AttributeValue], 366 *, 367 prefer_fifo: bool = False, 368 ) -> None: 369 self._tokens.apply_plugin_llm_usage_to_span( 370 span, attrs, prefer_fifo=prefer_fifo 371 ) 372 373 # ------------------------------------------------------------------ 374 # Session / agent identity helpers (kept on processor) 375 # ------------------------------------------------------------------ 376 377 def _resolve_session_state( 378 self, span: ReadableSpan, attrs: Mapping[str, AttributeValue] 379 ) -> _LiveKitSessionState | None: 380 bootstrap = get_job_bootstrap() 381 if bootstrap is not None: 382 return bootstrap.state 383 sid = attrs.get(ATTR_SESSION_ID) 384 if sid: 385 state = self._sessions.get(str(sid)) 386 if state is not None: 387 return state 388 job_id = attrs.get(ATTR_LK_JOB_ID) or attrs.get(METADATA_JOB_ID) 389 if job_id: 390 state = self._sessions.get(str(job_id)) 391 if state is not None: 392 return state 393 name = span.name or "" 394 if ( 395 name in _AGENT_PIPELINE_SPANS 396 or name in _AGENT_LABEL_SPANS 397 or job_id is not None 398 ): 399 logger.debug( 400 "parlot: no session bootstrap for span %s (trace=%s); skipping enrich", 401 name, 402 self._trace_id_hex(span), 403 ) 404 return None 405 logger.error( 406 "parlot: no session bootstrap for span %s (trace=%s)", 407 name, 408 self._trace_id_hex(span), 409 ) 410 return None 411 412 def _track_agent_label( 413 self, state: _LiveKitSessionState, attrs: Mapping[str, AttributeValue] 414 ) -> None: 415 raw_label = attrs.get(ATTR_LK_AGENT_LABEL) 416 raw_name = attrs.get(ATTR_LK_AGENT_NAME) 417 label_str = raw_label if isinstance(raw_label, str) else None 418 name_str = raw_name if isinstance(raw_name, str) else None 419 label = topology_agent_name(label_str) or topology_agent_name(name_str) 420 if label: 421 self._maybe_update(state, "agent_label", label) 422 canonical = explicit_agent_id() 423 if label != canonical: 424 append_agent_chain_step(state, label) 425 426 def _active_agent_id( 427 self, 428 state: _LiveKitSessionState, 429 attrs: Mapping[str, AttributeValue], 430 *, 431 label_override: str | None = None, 432 ) -> str: 433 if label_override: 434 override = topology_agent_name(label_override) 435 if override: 436 return override 437 # Prefer sub-agent labels over worker name; never use LiveKit AD_* dispatch ids. 438 for candidate in ( 439 attrs.get(ATTR_LK_AGENT_LABEL), 440 state.agent_label, 441 attrs.get(ATTR_LK_AGENT_NAME), 442 state.worker_agent_name, 443 explicit_agent_id(), 444 state.agent_chain[-1] if state.agent_chain else "", 445 ): 446 cand_str = candidate if isinstance(candidate, str) else None 447 name = topology_agent_name(cand_str) 448 if name: 449 return name 450 return "unknown" 451 452 def _stamp_agent_identity( 453 self, 454 span: ReadableSpan, 455 state: _LiveKitSessionState, 456 attrs: Mapping[str, AttributeValue] | None = None, 457 *, 458 label_override: str | None = None, 459 ) -> None: 460 resolved: Mapping[str, AttributeValue] = ( 461 attrs if attrs is not None else (span.attributes or {}) 462 ) 463 if not resolved.get(ATTR_GEN_AI_AGENT_NAME): 464 agent_id = self._active_agent_id( 465 state, resolved, label_override=label_override 466 ) 467 if agent_id == "unknown": 468 agent_id = configured_agent_id() or "unknown" 469 if agent_id != "unknown": 470 self._set(span, ATTR_GEN_AI_AGENT_NAME, agent_id) 471 version = configured_agent_version() 472 if version and not resolved.get(ATTR_GEN_AI_AGENT_VERSION): 473 self._set(span, ATTR_GEN_AI_AGENT_VERSION, version) 474 475 def _stamp_response_model_if_distinct( 476 self, 477 span: ReadableSpan, 478 attrs: Mapping[str, AttributeValue], 479 ) -> None: 480 response_model = str(attrs.get(ATTR_GEN_AI_RESPONSE_MODEL, "") or "").strip() 481 if not response_model: 482 return 483 request_model = str(attrs.get(ATTR_GEN_AI_MODEL, "") or "").strip() 484 if request_model and response_model == request_model: 485 return 486 self._set(span, ATTR_GEN_AI_RESPONSE_MODEL, response_model) 487 488 def _stamp_session_turn_attrs( 489 self, span: ReadableSpan, state: _LiveKitSessionState 490 ) -> None: 491 if state.parlot_session_id: 492 self._set(span, ATTR_SESSION_ID, state.parlot_session_id) 493 self._set(span, ATTR_SESSION_CONVERSATION_ID, state.conversation_id) 494 self._set(span, ATTR_GEN_AI_CONVERSATION_ID, state.conversation_id) 495 if (span.attributes or {}).get(ATTR_TURN_INDEX) is not None: 496 return 497 active = self._turns.active_turn_index(state, span.name) 498 if active is not None: 499 self._set(span, ATTR_TURN_INDEX, active) 500 501 def _stamp_error_status_if_needed(self, span: ReadableSpan) -> None: 502 """Normalize failed pipeline spans to OTel ERROR status for OTLP export.""" 503 attrs = span.attributes or {} 504 if attrs.get(ATTR_EXCEPTION_TYPE) or attrs.get(ATTR_LK_FNC_TOOL_ERROR): 505 span._status = Status(StatusCode.ERROR) 506 507 # ------------------------------------------------------------------ 508 # Enrichment dispatcher 509 # ------------------------------------------------------------------ 510 511 def _enrich(self, span: ReadableSpan) -> None: 512 name = span.name 513 if name in ("parlot.turn", SPAN_PARLOT_SESSION_CLOSE, SPAN_CONVERSATION_SESSION): 514 return 515 attrs = span.attributes or {} 516 state = self._resolve_session_state(span, attrs) 517 if state is None: 518 # Post-close JudgeGroup LLM: sticky closed session only — no aggregates. 519 if name in ( 520 "llm_request", 521 "llm_request_run", 522 "llm_node", 523 "realtime_inference", 524 ): 525 self._enrich_late_evaluation_llm(span, attrs) 526 return 527 528 explicit_job = attrs.get(ATTR_LK_JOB_ID) or attrs.get(METADATA_JOB_ID) 529 if explicit_job: 530 self._maybe_update(state, "session_id", explicit_job) 531 self._maybe_update( 532 state, 533 "room_name", 534 attr_get(attrs, ATTR_LK_ROOM_NAME, ATTR_ROOM_NAME_LEGACY), 535 ) 536 self._maybe_update(state, "room_sid", attrs.get(ATTR_LK_ROOM_SID) or attrs.get(METADATA_ROOM_ID)) 537 538 if name in _AGENT_LABEL_SPANS: 539 self._track_agent_label(state, attrs) 540 541 if state.session_id and not state.room_sid: 542 rn, rs = lookup_room_context(state.session_id) 543 self._maybe_update(state, "room_name", rn or None) 544 self._maybe_update(state, "room_sid", rs or None) 545 546 if state.session_id: 547 self._set(span, ATTR_LK_JOB_ID, state.session_id) 548 if state.room_name: 549 self._set(span, ATTR_LK_ROOM_NAME, state.room_name) 550 if state.room_sid: 551 self._set(span, ATTR_LK_ROOM_SID, state.room_sid) 552 553 stamp_livekit_platform_refs( 554 span, 555 job_id=state.session_id, 556 room_name=state.room_name, 557 room_sid=state.room_sid, 558 ) 559 560 self._stamp_session_turn_attrs(span, state) 561 self._set(span, ATTR_AGENT_FRAMEWORK, "livekit") 562 stage = livekit_agent_stage_for_span(name) 563 if stage and not (span.attributes or {}).get(ATTR_AGENT_STAGE): 564 self._set(span, ATTR_AGENT_STAGE, stage) 565 566 if name in ("llm_request", "llm_request_run"): 567 self._enrich_llm_request(span, state) 568 elif name == "llm_node": 569 self._turns.enrich_llm_node(span, state) 570 elif name == "realtime_inference": 571 self._turns.enrich_realtime_inference(span, state) 572 elif name == "realtime_metrics": 573 self._turns.enrich_realtime_metrics(span, state) 574 elif name == "tts_node": 575 self._turns.enrich_tts_node(span, state) 576 elif name == "tts_request_run": 577 self._turns.enrich_tts_request(span, state) 578 elif name == "function_tool": 579 self._turns.enrich_function_tool(span, state) 580 elif name == "user_turn": 581 self._turns.enrich_user_turn(span, state) 582 elif name == "agent_turn": 583 self._turns.enrich_agent_turn(span, state) 584 elif name == "drain_agent_activity": 585 self._turns.enrich_drain(span, state) 586 elif name == "eou_detection": 587 self._turns.enrich_eou(span, state) 588 elif name == "amd": 589 self._turns.enrich_amd(span, state) 590 elif name == SPAN_AGENT_HANDOFF: 591 self._handoff.enrich_handoff( 592 span, 593 state, 594 stamp_agent_identity=self._stamp_agent_identity, 595 turn_source=self._turn_source, 596 ) 597 598 if name in _AGENT_PIPELINE_SPANS: 599 self._stamp_error_status_if_needed(span) 600 601 def _enrich_late_evaluation_llm( 602 self, 603 span: ReadableSpan, 604 attrs: Mapping[str, AttributeValue], 605 ) -> None: 606 """Stamp closed-session ids + evaluation attrs; do not mutate turn/token state.""" 607 job_id = attrs.get(ATTR_LK_JOB_ID) or attrs.get(METADATA_JOB_ID) 608 if not job_id: 609 try: 610 from livekit.agents.job import get_job_context 611 612 ctx = get_job_context() 613 if ctx is not None: 614 job_id = str(ctx.job.id) 615 except Exception: 616 job_id = None 617 if not job_id: 618 return 619 sticky = get_sticky_closed_session(str(job_id)) 620 if sticky is None: 621 return 622 self._set(span, ATTR_SESSION_ID, sticky.session_id) 623 self._set(span, ATTR_SESSION_CONVERSATION_ID, sticky.conversation_id) 624 self._set(span, ATTR_GEN_AI_CONVERSATION_ID, sticky.conversation_id) 625 self._set(span, ATTR_LK_JOB_ID, str(job_id)) 626 self._set(span, ATTR_AGENT_FRAMEWORK, "livekit") 627 # Evaluation is a Parlot Layer-3 signal (parlot.span.kind), not a GenAI op. 628 self._set(span, ATTR_PARLOT_SPAN_KIND, PARLOT_SPAN_KIND_EVALUATION) 629 stage = livekit_agent_stage_for_span(span.name or "") 630 if stage and not attrs.get(ATTR_AGENT_STAGE): 631 self._set(span, ATTR_AGENT_STAGE, stage) 632 self._stamp_error_status_if_needed(span) 633 634 def _enrich_llm_request( 635 self, span: ReadableSpan, state: _LiveKitSessionState 636 ) -> None: 637 attrs = span.attributes or {} 638 self._stamp_agent_identity(span, state, attrs) 639 self._turns._normalize_provider_attrs(span, attrs) 640 641 self._tokens.accumulate_llm_request_tokens(span, state, attrs) 642 self._handoff.stamp_transfer_latency_if_pending(span, state) 643 self._stamp_response_model_if_distinct(span, span.attributes or attrs) 644 645 def _apply_root_to_live_span( 646 self, session_span: Any, state: _LiveKitSessionState 647 ) -> None: 648 """Stamp session aggregates on the live ``parlot.session`` span.""" 649 if session_span is None or not hasattr(session_span, "set_attribute"): 650 return 651 session_span.set_attribute(ATTR_SESSION_TURN_COUNT, state.turn_count) 652 session_span.set_attribute(ATTR_SESSION_TOOL_CALL_COUNT, state.tool_call_count) 653 session_span.set_attribute(ATTR_SESSION_HANDOFF_COUNT, state.handoff_count) 654 session_span.set_attribute( 655 ATTR_SESSION_TOTAL_INPUT_TOKENS, state.total_input_tokens 656 ) 657 session_span.set_attribute( 658 ATTR_SESSION_TOTAL_OUTPUT_TOKENS, state.total_output_tokens 659 ) 660 session_span.set_attribute( 661 ATTR_SESSION_TOTAL_CACHED_TOKENS, state.total_cached_tokens 662 ) 663 if state.user_id: 664 session_span.set_attribute(ATTR_SESSION_USER_ID, state.user_id) 665 ensure_agent_chain_seeded(state) 666 for agent in state.agent_chain: 667 state.topology.push_agent_chain(agent) 668 state.topology.stamp_session_span(session_span, final_turn=state.turn_count) 669 stamp_session_agent_identity(session_span, state) 670 stamp_session_sdk_version(session_span) 671 if state.amd: 672 session_span.set_attribute(ATTR_SESSION_AMD, state.amd) 673 if state.recording_anchor_wall_ms is not None: 674 session_span.set_attribute( 675 ATTR_SESSION_RECORDING_ANCHOR_WALL_MS, 676 state.recording_anchor_wall_ms, 677 ) 678 if state.languages_seen: 679 session_span.set_attribute( 680 ATTR_SESSION_LANGUAGES, 681 json.dumps(sorted(state.languages_seen)), 682 ) 683 if state.session_id: 684 stamp_livekit_platform_refs( 685 session_span, 686 job_id=state.session_id, 687 room_name=state.room_name, 688 room_sid=state.room_sid, 689 ) 690 if self._metrics and state.parlot_session_id: 691 self._metrics.record_session_close(state)
Enriches LiveKit Agent spans in-place with Parlot conventions.
Intercepts spans from the LiveKit Agents SDK and normalizes them into Parlot's three-layer semantic vocabulary:
- Conversation Contract — session start/close, turns, agent handoffs.
- OTel GenAI (v1.41.0) — LLM inference, tool executions, workflows.
- Voice spans — TTS, STT, and end-of-utterance operational timings.
123 def __init__( 124 self, 125 capture_genai_content: Optional[bool] = None, 126 handoff_tool_names: Optional[set[str]] = None, 127 *, 128 context: Optional["ParlotContext"] = None, 129 ) -> None: 130 from parlot.core.context import ParlotContext 131 132 # Explicit override for tests / rare call sites; None → policy at emit time. 133 self._capture_genai_content_override = capture_genai_content 134 self._handoff_tools = handoff_tool_names or set() 135 self._context = context if context is not None else ParlotContext() 136 self._sessions: dict[str, _LiveKitSessionState] = {} 137 self._turn_trace_registry: dict[str, dict[int, tuple[str, str]]] = {} 138 self._tracer: Tracer | None = None 139 self._metrics = None 140 self._turn_source: str = "spans" 141 142 self._handoff = HandoffTracker( 143 set_attr=self._set, 144 handoff_tools=self._handoff_tools, 145 ) 146 self._tokens = TokenAggregator( 147 set_attr=self._set, 148 plugin_host=self, 149 ) 150 self._recording = RecordingCoordinator( 151 get_capture_override=lambda: self._capture_genai_content_override, 152 active_agent_id_fn=self._active_agent_id, 153 get_context=lambda: self._context, 154 ) 155 self._turns = TurnEnricher( 156 set_attr=self._set, 157 add_event=self._add_event, 158 maybe_update=self._maybe_update, 159 get_turn_source=lambda: self._turn_source, 160 get_tracer=lambda: self._tracer, 161 get_metrics=lambda: self._metrics, 162 genai_content_enabled=self._recording.genai_content_enabled, 163 active_agent_id=self._active_agent_id, 164 stamp_agent_identity=self._stamp_agent_identity, 165 stamp_response_model_if_distinct=self._stamp_response_model_if_distinct, 166 handoff_tracker=self._handoff, 167 token_aggregator=self._tokens, 168 recording=self._recording, 169 turn_trace_registry=self._turn_trace_registry, 170 )
172 def on_start(self, span, parent_context=None) -> None: 173 super().on_start(span, parent_context) 174 # Capture turn.index while open_agent_turn_index is still set — child 175 # llm/tts spans often end after conversation_item_added clears it. 176 bootstrap = get_job_bootstrap() 177 if bootstrap is None or not bootstrap.state.parlot_session_id: 178 return 179 self._turns.stamp_turn_index_at_start(span, bootstrap.state)
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.
181 def on_end(self, span: ReadableSpan) -> None: 182 super().on_end(span) 183 try: 184 name = span.name 185 if name == SPAN_CONVERSATION_SESSION: 186 handle_conversation_session_on_end() 187 return 188 self._enrich(span) 189 self._log_compare_span(span) 190 except Exception as exc: 191 logger.error( 192 "parlot: LiveKitGenAIProcessor failed on span %r — %s", 193 span.name, 194 exc, 195 ) 196 logger.debug( 197 "parlot: LiveKitGenAIProcessor failed on span %r", 198 span.name, 199 exc_info=True, 200 )
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.
216 def enrich_spans_for_export(self, spans: list[ReadableSpan]) -> None: 217 """Deferred enrichment, turn-attr copy, then GenAI/voice rename for export.""" 218 self._turns.correct_function_tool_timing(spans) 219 220 llm_spans = sorted( 221 (s for s in spans if s.name == "llm_node"), 222 key=lambda s: s.end_time or 0, 223 ) 224 for span in llm_spans: 225 # FIFO token attach before stamping active_speech_id (which would 226 # pin every span to the latest speech and break multi-node batches). 227 self._tokens.apply_plugin_llm_usage_to_span( 228 span, dict(span.attributes or {}), prefer_fifo=True 229 ) 230 attrs = span.attributes or {} 231 if not attrs.get(ATTR_LK_SPEECH_ID): 232 bootstrap = get_job_bootstrap() 233 speech_id = "" 234 if bootstrap is not None: 235 speech_id = bootstrap.state.active_speech_id.strip() 236 if speech_id: 237 self._set(span, ATTR_LK_SPEECH_ID, speech_id) 238 239 self._turns.merge_native_turn_attrs_onto_parlot_turns(spans) 240 241 from ._telemetry_compare import compare_enabled, get_compare_logger 242 243 if compare_enabled(): 244 bootstrap = get_job_bootstrap() 245 if bootstrap is not None and bootstrap.state.parlot_session_id: 246 session_id = bootstrap.state.parlot_session_id 247 for span in spans: 248 if span.name == "llm_node": 249 get_compare_logger().accumulate_export_tokens( 250 session_id, 251 span_name=span.name or "", 252 attrs=dict(span.attributes or {}), 253 ) 254 255 # Rename LiveKit-native ops → GenAI/voice names before export filter. 256 for span in spans: 257 native = span.name or "" 258 if native in NATIVE_TURN_SPANS: 259 # Dropped by export filter (remap returns None); attrs already merged. 260 continue 261 if remap_livekit_span_name(native, span.attributes or {}) is not None: 262 apply_livekit_span_rename(span) 263 role = livekit_agent_role_for_span(span.name or "") 264 if role and not (span.attributes or {}).get(ATTR_AGENT_ROLE): 265 self._set(span, ATTR_AGENT_ROLE, role)
Deferred enrichment, turn-attr copy, then GenAI/voice rename for export.
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.