Integrating xray into an existing LiveKit Agents worker
This is the main walkthrough. LiveKit Agents is a framework for building voice agents (programs you talk to). A "worker" is the process that runs your agent.
Do you already have a LiveKit Agents worker? Do you want xray to record and replay its conversations? Then read this page top-to-bottom and copy the code blocks.
You'll need:
- xray running. Use the latest image, with a mounted volume for
/data. The reference compose snippet is at the bottom of this doc. - LiveKit server ≥ v1.7. It must be reachable from both the test driver and the agent worker. The xray SDK propagates the replay context through
participant.attributes, a field added in 1.7. ("Propagate" here means it passes the replay info along so the agent can read it.) - Python 3.10+ for the agent. The xray SDK runs on the same Python you ship your agent on. You do not need to upgrade your Python version.
The example below is a real voice-service worker. The wiring is the same for any LiveKit Agents codebase.
Request flow at a glance
xray.run(...) makes the calls below for you. You don't write any of them by hand. But this is the sequence to keep in your head when something fails:
There are two surfaces, with one trust boundary between them.
- The control plane is the first two POSTs, plus the audio, analyze, events, and PATCH calls. It is the only write path that can create rows in the database.
- The OTLP receiver is the agent's
POST /v1/otlp/v1/traces. (OTLP is the OpenTelemetry Protocol, a standard way to send tracing data. A "span" is one timed unit of work in a trace, like a single tool call.) This receiver is a filter. If a span is tagged with anxray.replay.idthe server doesn't know, the span is dropped without an error.
Why is dropping unknown spans safe? Because the replay row is always created first, before the runtime sends its first span. So a known replay id always has a row waiting for it.
1. Install the SDK on the agent side
pip install "xray-py[livekit]"The [livekit] extra pulls in livekit and livekit-api. (An "extra" is an optional set of dependencies you opt into with the square brackets.) Drop the [livekit] part if you write your own driver class.
Set XRAY_OTLP_ENDPOINT on the agent worker:
export XRAY_OTLP_ENDPOINT=http://xray:8080xray's OTLP receiver accepts two formats:
application/x-protobuf, which is the stock OTel HTTP exporter's default.application/json.
So your existing OpenTelemetry pipelines work too. xray.attach ships an OTLP/JSON exporter already pointed at xray.
2. Wrap the worker entrypoint with xray.attach
import xray
from livekit.agents import JobContext, WorkerOptions, cli, AutoSubscribe
async def entrypoint(ctx: JobContext) -> None:
await ctx.connect(auto_subscribe=AutoSubscribe.AUDIO_ONLY)
async with xray.attach(ctx, service_name="my-agent") as session:
# `session` is None when no xray-tagged participant joined.
# Inside the block, OTEL baggage carries:
# xray.replay.id, xray.conversation.hash, xray.modality
# The bundled span processor lifts those onto every span at start.
# On block exit, the tracer provider force-flushes so spans land
# in xray before the worker shuts down.
# Your existing strategy / pipeline runs here:
await your_agent.run(ctx, session=session)
cli.run_app(WorkerOptions(entrypoint_fnc=entrypoint))Notes:
xray.attachis an async context manager, not a decorator. (A context manager is theasync withblock above. It runs setup code when you enter and cleanup code when you exit.) Decorator wrappers break LiveKit Agents' multiprocessing forkserver pickling. LiveKit runs each job in a fresh subprocess, and that subprocess finds the entrypoint by looking up__main__.entrypoint.- Call
xray.attachafterctx.connect(...). Before connect,ctx.room.remote_participantsis empty, so the bind has nothing to scan.
3. Emit OTEL spans
xray's OTLP receiver accepts every span the agent worker emits. ("Emit" means the agent sends the span out.)
Some vocabularies are recognized: xray.*, OTel GenAI gen_ai.*, and Langfuse langfuse.*. (A "vocabulary" is a known set of attribute names.) Recognized spans are saved as raw spans. They are also pulled into structured database rows when the vocabulary supports it:
gen_ai.operation.name = execute_toolbecomes atool_callsrow.gen_ai.operation.name = chatortext_completion(and the Langfusegenerationobservation) becomes amodel_usagerow.xray.turn,xray.stage.stt, andxray.stage.ttsspans land in the rawspanstable for the inspector's timeline. They carry no structured payload. Server evaluation runs from the declaredAssertionandJudgevariants, not from driver-emitted spans.
The full attribute contract is in wire-contract.md.
Spans from unrecognized vocabularies are dropped without an error. This is the "filter, not a gate" design. It keeps noisy framework spans from filling the database.
Tool calls and model usage land in xray automatically. This works for any OTel-instrumented LLM client, such as the opentelemetry-instrumentation-openai-v2 package or Langfuse. No xray-specific code is required.
4. Write a test
import asyncio
import xray
from xray import Assertion, Judge
from xray.conversation import RecordedAudio
from xray.runtime.livekit import LiveKitRuntime
async def main():
conv = xray.Conversation(
name="booking-happy-path",
turns=[
xray.Turn.agent(key="a-greeting"),
xray.Turn.user(
"Book a table for two at 7pm.",
key="u-question",
audio=RecordedAudio(path="/path/to/utterance.wav"),
),
xray.Turn.agent(
key="a-answer",
assertions=(
Assertion.contains("confirmed"),
Assertion.tool_called("reserve_table"),
Assertion.tool_args_match("reserve_table", {"party_size": 2}),
Assertion.max_latency_ms(2_000),
),
),
],
judges=(
Judge.text_match(
"The agent confirms a reservation for two at 7pm.",
pass_score=80,
),
),
)
driver = LiveKitRuntime(
url="ws://localhost:7880",
api_key="devkey",
api_secret="devsecret32charsminimumlengthxyz123",
room=f"booking-test-{__import__('uuid').uuid4().hex[:6]}",
)
result = await xray.run(
conversation=conv,
runtime=driver,
xray_url="http://localhost:8080",
run_config=xray.RunConfig(name="baseline", model="gpt-4o", temperature=0.5),
)
print(f"replay: {result.replay_id} passed={result.passed}")
for a in result.assertions:
print(f" turn {a.turn_idx} [{a.kind}]: {a.status} {a.message or ''}")
for j in result.judges:
print(f" judge {j.judge_idx} [{j.kind}]: {j.status} score={j.score} - {j.reason}")
asyncio.run(main())In pytest the same call is one assertion:
async def test_booking_happy_path():
result = await xray.run(conversation=conv, runtime=driver, xray_url=XRAY_URL)
assert result.passed, xray.format_failures(result)xray.run is async. Wrap it in asyncio.run for sync test harnesses. There is no sync xray.run. The old sync version was a footgun in loops that are already running, such as pytest-asyncio, Jupyter, and LiveKit Agents.
What xray.run does:
- POST the Conversation. This is an idempotent upsert, meaning calling it twice with the same input is safe. Assertions and judges are part of the canonical spec the server hashes.
- POST the Replay row eagerly (
lifecycle_state='pending'). - Bind the driver, attach replay baggage, and run the driver. This plays the user audio and records the agent's audio continuously for the whole run (so off-turn agent speech is captured too), along with its transcripts.
- Assemble a 48 kHz int16 stereo WAV (L = user, R = agent, wall-clock-aligned) and POST it to
/v1/replays/:id/audio. Set theX-Recording-Started-Atheader to the wall-clock time (ISO-8601 UTC) of audio sample 0. This anchor is the sole origin for mapping span timestamps onto the audio timeline. The server uses it to work out which turn each tool, model, and span row belongs to. A customRuntimethat produces audio MUST report it. ReturnRuntimeResult.recording_started_at_epoch(Unix epoch seconds of sample 0), andxray.runsends the header for you. If you omit it, span-to-turn attribution is skipped. Then everytool_called,tool_not_called,tool_args_match, andmax_ttft_msassertion comes backerrored. - POST
/v1/replays/:id/analyze. The server enqueues the three-stage analyze chain (analyze-replay, thencalculate-metrics, thenevaluate-replay). - Stream SSE on
/v1/replays/:id/eventsuntilevaluation_complete(the chain finished) orfailed(the chain stopped). (SSE is Server-Sent Events, a one-way stream of updates from the server.) - Translate the
evaluation_completepayload intoxray.ReplayResultand return it.
Failure model: assertion failures don't raise. They are outcomes on result.assertions. Infrastructure failures do raise xray.ReplayEvaluationError. Examples are the transcription provider being down or the judge LLM being unavailable.
User-turn audio formats:
RecordedAudio(path=...). This is a 48 kHz mono int16 WAV file on disk. It is uploaded to xray with the conversation spec.TtsAudio()(or noaudioat all). This is synthesized server-side during the conversation upsert. (TTS means text-to-speech: turning written text into spoken audio.) The xray server uses whichever provider it is configured with (XRAY_TTS_PROVIDER: OpenAI, Google, Mistral, or Deepgram). PassTtsAudio(language="de")for non-English turns — Mistral voices are language-specific, and the server then picks a voice matching the language (from its presets or your org's cloned voices) instead of an English voice speaking the text with an accent. The generated audio is stored content-addressed, and its sha256 is part of the conversation hash. The driver pulls the exact bytes back before joining the room. There is no TTS key in the SDK's process.
For Cartesia or 11Labs, synthesize the audio yourself and pass the output as RecordedAudio. Adding more server-side TTS providers is one file each in src/server/tts/.
5. Read the result
xray.run(...) returns xray.ReplayResult:
passed: bool. This isTrueonly if every assertion and every judge ran to apassedstatus. Anerroredstatus counts as not-passed.assertions: tuple[AssertionOutcome, ...]. There is one entry per declared assertion, in the order they appear on each turn. Each one carriesturn_idx,assertion_idx,kind,status(passed,failed, orerrored), andmessage(the reason, set when the status is not passed).judges: tuple[JudgeOutcome, ...]. There is one entry per declared judge. Each one carriesjudge_idx,kind,status, the LLM's 0..100score, and the LLM's natural-languagereason. A judge scores the transcript together with each turn's tool calls, model calls, and timing metrics, so a reference like "looks up the balance before quoting it" is checked against what the agent actually did. See Judges.metrics: tuple[TurnMetrics, ...]. These are per-turn timings computed server-side:agent_response_ms,interrupted, andyield_ms(how long an interrupted turn kept talking after a barge-in began;Noneotherwise). (TTFT means time to first token. Model TTFT is a per-call attribute on the replay'smodel_usagerows. It is populated when the agent's instrumentation emitsgen_ai.response.time_to_first_chunk. It is not a per-turn metric.)replay_idandconversation_hash. These point back to the server-side rows for follow-up inspection.
Do you need the live audio plus the turn boundaries the server derived? For example, for a custom UI or ad-hoc analysis. Then GET /v1/replays/:id carries the full detail. GET /v1/replays/:id/result returns the same ReplayResult payload outside of the SSE stream, so late subscribers can fetch it directly.
6. Run xray itself
Production-shape compose:
services:
xray:
image: ghcr.io/xray-eval/xray:latest
restart: unless-stopped
ports: ["8080:8080"]
volumes: ["xray-data:/data"]
read_only: true
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
# Optional: move bunqueue's SQLite file out of /data
# environment:
# BUNQUEUE_DATA_PATH: /data/bunqueue.db
volumes:
xray-data:xray ships as a single Docker image. Two SQLite files share the mounted volume:
/data/xray.db. This holds conversations, replays, run_configs, replay_turns, speech_segments, spans, tool_calls, model_usage, turn_transcripts, replay_metrics, assertion_results, judge_results, replay_evaluations, and tts_synth_cache (14 tables; seearchitecture.md)./data/bunqueue.db. This holds bunqueue's job queue and DLQ. (DLQ means dead-letter queue, where failed jobs go.) Theanalyze-replayworker runs embedded in the same Bun process.
The inspector UI is at http://localhost:8080.
What changed from earlier alphas
This release moves assertion and judge evaluation onto the server.
- Declarative assertions + judges. Replace per-turn lambdas with
Assertion.contains(...),Assertion.tool_called(...),Assertion.max_latency_ms(...), and so on. Replace per-replay judge callables withJudge.text_match(reference, pass_score=...). Both ship on the wire and run server-side. So every SDK (Python today, others tomorrow) speaks the same shape. xray.run(...)returnsReplayResult. It includespassedplus per-assertion, per-judge, and per-turn-metrics data. Assertion failures don't raise. The pytest idiom isassert result.passed, format_failures(result). Only infrastructure failures raiseReplayEvaluationError, such as the transcription provider being down or the judge crashing.- Three-stage server chain.
/analyzenow enqueuesanalyze-replay(VAD plus per-turn transcription through the configured STT provider; each turn's slice covers its full attribution window, not just the VAD-detected speech, with trailing silence trimmed off so a recording that outlives the conversation doesn't hand the provider a minute of nothing). That enqueuescalculate-metrics(agent_response_ms, interrupted, yield_ms). That enqueuesevaluate-replay, which runs all assertions and judges, then emits theevaluation_completeSSE. (VAD means voice activity detection: finding which parts of the audio contain speech.) - SSE event renamed. The
completedevent is gone. The chain now emitsevaluation_completewith the fullReplayResultpayload. GET /v1/replays/:id/result. This fetches the same payload outside the SSE stream, for late subscribers.- Server requires a provider key at runtime (
OPENAI_API_KEY,GOOGLE_API_KEY,MISTRAL_API_KEY,DEEPGRAM_API_KEY, orAWS_BEARER_TOKEN_BEDROCK) for TTS, transcription, and judge calls. Deepgram covers transcription (Nova-3 — word-level timings + language detection) and TTS (Aura-2 — language-specific voices incl. German/French/Spanish/Italian/Dutch/Japanese) but not the judge; AWS Bedrock covers the judge only (Claude Opus 4.8 by default) — pair each with another key for the remaining stages. Bedrock auth accepts either form: a Bedrock API key viaAWS_BEARER_TOKEN_BEDROCK, or standard SigV4 access keys viaAWS_ACCESS_KEY_ID+AWS_SECRET_ACCESS_KEY(+AWS_SESSION_TOKENfor temporary/assumed-role credentials) — signed in-process, no AWS SDK. Bearer wins if both are set. The SigV4 vars are used only whenXRAY_JUDGE_PROVIDER=bedrockis set explicitly, so ambient AWS credentials never auto-select the judge. Account prerequisites: the identity needs Bedrock invoke permissions, and Anthropic models additionally need model access enabled (an AWS Marketplace subscription — one-time, per region, in the Bedrock console; enablement is regional, so setXRAY_BEDROCK_REGIONto a region where Opus access is granted and pair it with the matching geo model id, e.g.XRAY_JUDGE_MODEL=eu.anthropic.claude-opus-4-8). You can override each stage:XRAY_TTS_PROVIDER,XRAY_TRANSCRIPTION_PROVIDER, andXRAY_JUDGE_PROVIDER, plusXRAY_TTS_MODEL,XRAY_TTS_VOICE,XRAY_TRANSCRIPTION_MODEL,XRAY_JUDGE_MODEL, andXRAY_BEDROCK_REGION(bedrock-runtime endpoint region, defaultus-east-1). - No more SDK-side enrichment fetch / final PATCH. The SDK only PATCHes when the driver itself fails, such as a mixdown error or a missing audio file.
- Schema reset. Are you upgrading an existing volume? Then drop
xray.dbandbunqueue.dbbefore starting the new container. The column shape ofreplays, plus the newturn_transcripts,replay_metrics,assertion_results,judge_results, andreplay_evaluationstables, are not migration-compatible.