There is no span boundary
Nothing on the wire marks a request. Audio simply streams. Instrument it naively and you get one span per session covering twenty exchanges — which tells you nothing about any of them.
Every LLM observability tool assumes request → response. Real-time voice agents speak over a persistent duplex socket where neither exists — so the silence your user actually sat through never appears in a single span.
cadence reconstructs conversational turns from a raw streaming audio socket, measures the silence, catches every interruption, and ships it all to SigNoz over OTLP.
No signup, no API key — the replay runs a recorded session through the real console.
The GenAI semantic conventions are still pre-1.0, and they model generative AI as a request/response call. For chat completions that is exactly right. For a voice agent it falls apart.
Nothing on the wire marks a request. Audio simply streams. Instrument it naively and you get one span per session covering twenty exchanges — which tells you nothing about any of them.
What makes an agent feel alive isn't how long the exchange took. It's how long the human sat in silence before hearing anything. A turn can have great total duration and still feel broken, because all the delay landed at the front.
The most common failure mode of voice agents — the user talking over the model — has no representation in any convention, because in a request/response world it cannot happen.
Input isn't a countable prompt. It's an open microphone billed per second, plus video frames. Token totals without a modality split hide which stream is spending the money.
realtime.* composes with gen_ai.* rather than
replacing it. Model inference inside a turn is still a standard
chat span, so existing backends light up unchanged.
realtime.session one connected session ├── realtime.turn one exchange │ ├── realtime.audio.user_utterance VAD start → end │ ├── chat [gen_ai.*] │ │ └── execute_tool incl. mid-stream │ └── realtime.audio.agent_utterance first audio → done │ └── offset_ms └── realtime.turn …
From the last inbound frame of user audio to the first outbound frame of agent audio — deliberately including VAD dwell, network, queueing and time-to-first-token, because all of it is silence to the person waiting.
An interruption isn't an error inside a turn. It's the user taking the floor, which is what a new turn is. The event lands on the utterance it cut off, at its true offset.
Agent-initiated turns have no user speech to measure from, so TTFA is omitted rather than faked from session start. A fabricated value silently corrupts the p95 — and you'd act on it.
Users aren't interrupting — your agent thinks they are, because voice activity detection is triggering on background noise. Fix the VAD sensitivity.
People are cutting it off out of impatience. Shorten the replies. Nothing is wrong with the audio pipeline at all.
A barge-in counter cannot tell these apart. The offset
histogram can — which is why realtime.barge_in.offset is a
histogram, not a count.
cadence exports over OTLP. The demo agent then queries the SigNoz API for its own traces — ask how fast it's been responding and it runs a p95 against the histogram its own turns populated seconds earlier, then tells you out loud.
realtime.turn.time_to_first_audiohistogramThe silence the user sat through. Buckets 50ms–5s, not OTel's HTTP-shaped defaults.realtime.barge_in.offsethistogramHow far into a reply interruptions land. The distribution is the diagnostic.realtime.turn.countcounterSplit by end reason — completed, interrupted, abandoned.realtime.audio.input.secondscounterThe meter that runs while nobody is talking.gen_ai.client.token.usagecounterStandard GenAI instrument, plus a modality dimension. Audio dominates.
Cardinality: realtime.session.id is deliberately not
a metric attribute. Unique ids on metric dimensions are the standard way to
melt a time-series backend. Session correlation belongs on spans, where it's free.
import cadence cadence.configure(service_name="my-voice-agent") async with client.aio.live.connect(model=MODEL, config=config) as raw: async with cadence.CadenceSession(raw, model=MODEL) as session: async for message in session.receive(): ... # every message passes through untouched
CadenceSession wraps rather than monkey-patches — the
Live API surface is still moving, and patching a library mid-flight is how
instrumentation gets silently broken by a minor release. Instrumentation
also never raises into the audio path: a bug in cadence must not take down
the agent it's watching. There's a test for exactly that.