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]
def parlotize( agent_id: str, *, endpoint: Optional[str] = None, api_key: Optional[str] = None, capture_genai_content: Optional[bool] = None, service_name: Optional[str] = None, tracer_provider: opentelemetry.trace.TracerProvider | None = None, auto_escalate_sip: bool = False, escalation_metadata_match: dict[str, str] | None = None, version: Optional[str] = None, record: bool | list[str] | None = None, await_recording: bool = False, capture_logs: bool | list[str] | None = None, log_level: Optional[str] = None) -> parlot.core.ParlotContext:
 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 to agent_id or "unknown").
  • tracer_provider: Shared — existing TracerProvider, or build one with Parlot's OTLP exporter and LiveKitGenAIProcessor.
  • 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. Pass version= to set it; otherwise stamped as "unknown".
  • record: Audio recording policy. Boolean or agent-id glob patterns (e.g. ["support-*", "billing"]). Precedence: LiveKit job metadata record → this kwarg → Settings → Recording.
  • await_recording: When True and 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 with await ctx.connect() before session.start() so the first agent greeting is not cut from the session recording. Default False preserves 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 ParlotContext created for this process (or the prior one if already configured).

def install_session_hooks( session: Any, processor: LiveKitGenAIProcessor, tracer: opentelemetry.trace.Tracer) -> None:
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 AgentSession instance to instrument.
  • processor: Span processor that owns session/turn enrichment state.
  • tracer: OpenTelemetry tracer used for Parlot contract spans.
class LiveKitGenAIProcessor(parlot.core.processor.ParlotBaseProcessor):
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:

  1. Conversation Contract — session start/close, turns, agent handoffs.
  2. OTel GenAI (v1.41.0) — LLM inference, tool executions, workflows.
  3. Voice spans — TTS, STT, and end-of-utterance operational timings.
LiveKitGenAIProcessor( capture_genai_content: Optional[bool] = None, handoff_tool_names: Optional[set[str]] = None, *, context: Optional[parlot.core.ParlotContext] = None)
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        )
def on_start(self, span, parent_context=None) -> None:
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.Span that just started.
  • parent_context: The parent context of the span that just started.
def on_end(self, span: opentelemetry.sdk.trace.ReadableSpan) -> None:
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.Span that just ended.
def set_tracer(self, tracer: opentelemetry.trace.Tracer) -> None:
202    def set_tracer(self, tracer: Tracer) -> None:
203        self._tracer = tracer
def set_metrics(self, metrics) -> None:
205    def set_metrics(self, metrics) -> None:
206        self._metrics = metrics
def set_turn_source(self, source: str) -> None:
208    def set_turn_source(self, source: str) -> None:
209        if source in ("spans", "events"):
210            self._turn_source = source
turn_source: str
212    @property
213    def turn_source(self) -> str:
214        return self._turn_source
def enrich_spans_for_export(self, spans: list[opentelemetry.sdk.trace.ReadableSpan]) -> None:
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.

def mark_conversation_item_committed(self, item_id: str) -> bool:
285    def mark_conversation_item_committed(self, item_id: str) -> bool:
286        return self._turns.mark_conversation_item_committed(item_id)
def mark_handoff_item_committed(self, item_id: str) -> None:
288    def mark_handoff_item_committed(self, item_id: str) -> None:
289        self._handoff.mark_handoff_item_committed(item_id)
def committed_handoff_item_ids(self) -> set[str]:
291    def committed_handoff_item_ids(self) -> set[str]:
292        return self._handoff.committed_handoff_item_ids()
def record_handoff_from_event(self, *, from_agent: str = '', to_agent: str = '') -> None:
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        )
def note_user_transcription_meta(self, *, speaker_id: str = '', language: str = '') -> None:
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        )
def note_function_tools_executed(self, count: int) -> None:
308    def note_function_tools_executed(self, count: int) -> None:
309        self._turns.note_function_tools_executed(count)
def apply_session_usage(self, total_in: int, total_out: int, total_cached: int = 0) -> None:
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)
def note_session_error(self, message: str, *, recoverable: bool) -> None:
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
def pop_pending_close_error(self) -> str | None:
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
def commit_user_message( self, text: str, *, interrupted: bool = False, metrics: dict[str, float] | None = None) -> None:
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        )
def commit_agent_message( self, text: str, *, interrupted: bool = False, metrics: dict[str, float] | None = None) -> None:
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        )
def lookup_turn_trace(self, session_id: str, turn_index: int) -> tuple[str, str] | None:
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)
def set_recording_anchor_wall_ms( self, state: parlot.instrumentation.livekit._session_state._LiveKitSessionState, anchor_wall_ms: int) -> None:
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)
def add_platform_ref(kind: str, value: str, *, framework: str = 'custom') -> None:
 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")
@contextmanager
def human_escalation(label: str | None = None) -> Iterator[NoneType]:
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.
def record_human_rep(participant_id: str, *, label: str | None = None) -> None:
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").
def set_session_attribute(key: str, value: str | int | float | bool) -> None:
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, or bool).
def set_session_metadata(**pairs: str | int | float | bool) -> None:
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, or bool).

Example::

from parlot.instrumentation.livekit import set_session_metadata

set_session_metadata(order_id="12345", crm_ticket="TKT-9")
def stamp_platform_refs(span: Any, refs: list[tuple[str, str, str]]) -> None:
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 a ReadableSpan with a mutable _attributes dict.
  • refs: Non-empty list of reference triples.