<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Marcus Chen</title>
    <description>The latest articles on DEV Community by Marcus Chen (@realmarcuschen).</description>
    <link>https://dev.to/realmarcuschen</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3940517%2F7b3654df-2cab-42a2-a56a-eae04985c9a4.png</url>
      <title>DEV Community: Marcus Chen</title>
      <link>https://dev.to/realmarcuschen</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/realmarcuschen"/>
    <language>en</language>
    <item>
      <title>Three weeks before the enterprise contract, the voice agent wasnt operator-ready.</title>
      <dc:creator>Marcus Chen</dc:creator>
      <pubDate>Thu, 02 Jul 2026 23:19:50 +0000</pubDate>
      <link>https://dev.to/realmarcuschen/three-weeks-before-the-enterprise-contract-the-voice-agent-wasnt-operator-ready-218c</link>
      <guid>https://dev.to/realmarcuschen/three-weeks-before-the-enterprise-contract-the-voice-agent-wasnt-operator-ready-218c</guid>
      <description>&lt;h1&gt;
  
  
  Three weeks before the enterprise contract, the voice agent wasn't operator-ready
&lt;/h1&gt;

&lt;p&gt;Look. We had 99.2% uptime in staging. We had eval coverage on 1,400 test turns. We had latency under 280ms first-token.&lt;/p&gt;

&lt;p&gt;We were not operator-ready.&lt;/p&gt;

&lt;p&gt;I know this because the enterprise pilot started on a Monday and we had our first critical incident by Tuesday afternoon.&lt;/p&gt;

&lt;p&gt;This is what happened, what broke, and what the gateway layer decision actually looks like when you're under pressure to fix it fast.&lt;/p&gt;

&lt;h2&gt;
  
  
  The incident
&lt;/h2&gt;

&lt;p&gt;The customer was a wealth management firm. Their advisors use a voice agent to pull client portfolio data, answer allocation questions, and schedule follow-ups. We'd been testing with synthetic personas for six weeks. The simulation results were clean.&lt;/p&gt;

&lt;p&gt;Day one: a senior advisor ran a session that included three back-to-back allocation queries with large portfolio values. Our OpenAI rate limit hit at 6pm EST, right during peak advisor usage. Every request after the limit returned a 429. The agent logged nothing useful. The advisor's client was on hold for 4 minutes.&lt;/p&gt;

&lt;p&gt;Day two: a compliance officer tried to pull the audit log for the day-one incident. There wasn't one. We had trace spans. We did not have a per-request log that showed which advisor, which client context, which tool calls, what the agent responded. That's a compliance gap, not a monitoring gap.&lt;/p&gt;

&lt;p&gt;Week two: the VP of operations asked for the cost breakdown by team. We gave them a single number. They wanted per-advisor attribution. We had no per-tenant tagging.&lt;/p&gt;

&lt;p&gt;Week three: the operations team pushed a new prompt version to fix a tone issue. Three hours later, the voice agent started refusing certain allocation questions it had previously handled fine. We had no prompt version pinned at inference time in the trace. We couldn't tell when the failure started or which requests were affected.&lt;/p&gt;

&lt;p&gt;Four incidents. None of them were model quality issues. All of them were the gateway layer we hadn't built.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the gateway layer is supposed to do
&lt;/h2&gt;

&lt;p&gt;Before this pilot I thought of the gateway as routing. Send the request to OpenAI, or Anthropic, or whichever provider. Handle retries. Done.&lt;/p&gt;

&lt;p&gt;That was wrong.&lt;/p&gt;

&lt;p&gt;The gateway for an enterprise operator deployment does at least five things:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rate limiting per tenant.&lt;/strong&gt; Not per account. Per tenant. An advisor with heavy usage should not blow the rate limit for the entire deployment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost attribution.&lt;/strong&gt; Every request tagged with the operator, the team, the user. Without this, you cannot answer the cost-attribution questions that come in month two.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Guardrail enforcement.&lt;/strong&gt; For financial services: no advice that sounds like a specific investment recommendation. The guardrail needs to run on every response, not just when you remember to add it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Audit logging.&lt;/strong&gt; Immutable, per-request, with enough context to replay the interaction. This is a compliance requirement for most regulated industries, not a nice-to-have.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Multi-provider failover.&lt;/strong&gt; When OpenAI hits 429, route to Anthropic. Not as a manual intervention. Automatically. The 4-minute incident on day one was preventable.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I evaluated
&lt;/h2&gt;

&lt;p&gt;After week one, I spent most of a weekend evaluating gateway options. Here's the honest breakdown:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;LiteLLM&lt;/strong&gt; (open-source, self-hosted). Most complete feature set if you want full control. Per-tenant rate limiting, cost tagging, provider fallback, proxy mode. The setup complexity is real: you need to maintain the deployment, configure Redis for rate-limiting persistence, and write your own audit log schema. For a team with Kubernetes infrastructure already in place, this is probably the right call. We were mid-pilot and needed faster setup.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Portkey&lt;/strong&gt; (managed). Zero-config guardrails, built-in prompt versioning with a rollback UI, solid multi-provider routing. Pricing gets expensive at scale but the managed model means fast setup and less ops overhead. Their guardrail policies are more configurable than LiteLLM's out of the box. We ended up here for the pilot because we were under time pressure and needed zero-setup guardrail enforcement.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Future AGI's gateway&lt;/strong&gt; (open-source, part of the future-agi platform). This is the gateway component of their end-to-end eval + observability + guardrail stack. It handles multi-provider routing with guardrail policies, rate limiting, and OTel-native tracing that connects to the same OTel-based observability stack as the rest of the platform. I evaluated this specifically because we were already running FAGI's simulation tooling for our voice eval harness, and the unified stack had real appeal: guardrails, tracing, and eval running through the same FAGI platform.&lt;/p&gt;

&lt;p&gt;For a team already on the FAGI platform for eval and simulation, the gateway is the right next layer. For a team coming in cold with no FAGI tooling, the setup cost is higher than Portkey or Helicone for the first-time operator deployment.&lt;/p&gt;

&lt;p&gt;As of June 2026, the FAGI gateway ships the OpenAI-compatible proxy, multi-provider routing, guardrail policies, and OTel tracing in one stack.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Helicone&lt;/strong&gt; (managed). Strongest on cost attribution and per-user analytics. The tagging system is granular and the dashboard is readable. Weaker on guardrails (less configurable than Portkey). Right call if your primary need is FinOps visibility and you're handling guardrails separately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OpenRouter&lt;/strong&gt; (managed). Pure routing. Multi-provider fallback, good for latency optimization across providers. Does not have per-tenant rate limiting or guardrail enforcement built in. Not the right call for an enterprise deployment that needs compliance features.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bifrost&lt;/strong&gt; (open-source). Fast proxy with interesting performance numbers. Newer, smaller community. I evaluated it and the latency story is real. But it was too new to commit to for a regulated industry deployment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Week three: what we fixed
&lt;/h2&gt;

&lt;p&gt;We were already deployed on Portkey for rate limiting and guardrail enforcement by week three. We added per-advisor tagging to every request. We pinned prompt versions at inference time and logged the version ID in each trace span.&lt;/p&gt;

&lt;p&gt;The prompt-version incident would have been caught immediately with version pinning. The cost-attribution ask would have been answered in two SQL queries.&lt;/p&gt;

&lt;p&gt;The audit log took longer. Financial services audit logging has specific retention and immutability requirements that generic trace systems don't satisfy out of the box. We built a thin write-once layer on top of Portkey's logging that met the compliance spec. That was two days of work we should have done before the pilot.&lt;/p&gt;

&lt;h2&gt;
  
  
  What shipped
&lt;/h2&gt;

&lt;p&gt;Portkey for rate limiting and guardrail enforcement. Per-tenant tagging on every request. Prompt version pinning at inference time. Custom audit log layer for compliance.&lt;/p&gt;

&lt;p&gt;The rate-limit incident did not recur. The cost-attribution question now takes two minutes to answer. The audit log is compliance-satisfying.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell past me
&lt;/h2&gt;

&lt;p&gt;Architect the gateway before you talk to the enterprise customer. Not as an afterthought when the pilot starts hitting limits.&lt;/p&gt;

&lt;p&gt;The questions you'll be asked in month one: "Who spent what, when, doing what, with what outcome." If your gateway doesn't answer those four questions, you are not operator-ready. The model quality is probably fine. The infrastructure around it is what will bite you.&lt;/p&gt;

&lt;p&gt;And if you're already running FAGI's eval and simulation stack: evaluate their gateway component in parallel. The unified data model between guardrails, traces, and eval signals is genuinely useful for regulated deployments where you need the audit trail to connect back to eval coverage.&lt;/p&gt;

&lt;p&gt;What I'm building next: a pre-operator readiness checklist that runs as a CI gate before any enterprise handoff. It checks per-tenant rate limit configuration, audit log schema coverage, and prompt version tracking. None of these should be manual.&lt;/p&gt;

</description>
      <category>voiceagents</category>
      <category>llmproduction</category>
      <category>mlops</category>
      <category>ai</category>
    </item>
    <item>
      <title>The 2am call that dropped before the user finished talking, and the week I spent finding out why my tracer never saw it</title>
      <dc:creator>Marcus Chen</dc:creator>
      <pubDate>Wed, 01 Jul 2026 21:51:13 +0000</pubDate>
      <link>https://dev.to/realmarcuschen/the-2am-call-that-dropped-before-the-user-finished-talking-and-the-week-i-spent-finding-out-why-my-5b10</link>
      <guid>https://dev.to/realmarcuschen/the-2am-call-that-dropped-before-the-user-finished-talking-and-the-week-i-spent-finding-out-why-my-5b10</guid>
      <description>&lt;p&gt;The call came in at 2am. Not a page, an actual support recording, flagged by a customer who said our voice agent "hung up on her mid-sentence." I pulled the trace. The LLM call was perfect. 380ms, clean completion, sensible response. Every dashboard I had was green. The customer was still angry, and my tooling had nothing to say about why.&lt;/p&gt;

&lt;p&gt;That gap is the thing I want to talk about. I build voice agents for a living, the kind that answer phones and book appointments and occasionally embarrass me in production. And after three years of it, here is the hard lesson: tracing the LLM call is the easy 20 percent. For a voice agent, the failures live in the audio layer your tracer never sees.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Week 1: learning what my dashboards were hiding&lt;/strong&gt;&lt;br&gt;
When the LLM is the whole product, an LLM tracer is enough. You see the prompt, the completion, the tokens, the cost, the latency. Beautiful.&lt;/p&gt;

&lt;p&gt;A voice agent is a pipeline, and the LLM is one stage in the middle. Audio comes in, an ASR model transcribes it, an endpointer decides when the human stopped talking, your orchestration assembles context, the LLM responds, TTS speaks it back, and somewhere a barge-in detector is supposed to notice when the human interrupts. The LLM trace covers one box in that chain. The 2am call dropped because the endpointer fired early. The transcript was cut in half before it ever reached the model. My tracer logged a flawless response to half a question.&lt;/p&gt;

&lt;p&gt;So I made a list of what actually breaks, and what I needed to see for each:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;End-of-turn detection timing.&lt;/strong&gt; The endpointer decides the human is done. Too eager and you interrupt them (my 2am call). Too slow and the agent feels dead. This is a latency-plus-decision event, not an LLM span, and most tools have no concept of it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ASR latency and confidence.&lt;/strong&gt; If transcription takes 900ms or comes back at 0.4 confidence, the LLM response can be instant and still wrong. You need the confidence score attached to the turn.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Barge-in detection.&lt;/strong&gt; The human starts talking over the agent. Did the system notice? How fast did it stop talking? Pure audio-layer, invisible to a text tracer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Time-to-first-audio.&lt;/strong&gt; Not time-to-first-token. The human hears nothing until TTS produces sound. That is the latency that matters, and it lives downstream of everything your LLM dashboard shows.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;None of these are exotic.&lt;/strong&gt; They are the daily failure modes of every voice agent in production. And the tooling conversation almost never mentions them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Week 2: checking six tools against that list&lt;/strong&gt;&lt;br&gt;
I went through six observability tools I had either used or seriously trialed, and asked one question of each: how much of the audio layer can I actually see, and how much work is it to get there. I am grading on voice-agent fit, not on general quality. Several of these are excellent tools that simply were not built with a pipeline like mine in mind.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Langfuse.&lt;/strong&gt; OpenTelemetry-based, so the format does not fight you. You can attach custom spans for ASR, endpointing, time-to-first-audio, and they show up in the trace tree. Honest take: on pure LLM observability Langfuse is stronger and more polished than most of this list, including the mid-list option I will get to. The catch is that nothing about the audio layer is automatic. You instrument every span by hand.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Phoenix (Arize).&lt;/strong&gt; Same OTel story. Format-agnostic, custom spans work, strong on eval and drift if that is your world. Same catch: the audio spans are yours to define and emit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Laminar.&lt;/strong&gt; OTel-native and newer, pleasant to instrument. Same pattern: it will hold whatever audio spans you send it, it will not invent them for you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Future AGI (traceAI).&lt;/strong&gt; Sits in the middle of this list for me, and I want to be precise about why. Its tracing layer, traceAI, is OpenTelemetry-native and exports OTLP to any backend, with instrumentors for 50-plus frameworks as of June 2026 (the repo is open at github.com/future-agi/traceAI). For voice work that buys you the same thing the others do: custom audio spans are first-class because OTel is the substrate. Where it earned its spot for me is the eval side, scoring a turn against the audio context rather than just the text. Where it does not win: on raw observability ergonomics, Langfuse and Helicone are simply more refined. I keep it mid-list on purpose. It is a capable option, not a crown.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Helicone.&lt;/strong&gt; Genuinely excellent at LLM-call logging, cost tracking, and gateway-level visibility, and the fastest of this group to stand up for that job. It is also largely silent on the audio layer. That is not a flaw, it is a focus. If your problem is LLM cost and call logging, Helicone may beat everything here. If your problem is a dropped call at 2am, it will not see it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;LangSmith.&lt;/strong&gt; The most LLM-centric of the six and the least audio-aware by default. Tight integration if you live in the LangChain world. You will be doing the most adapting to make a voice pipeline legible inside it.&lt;/p&gt;

&lt;p&gt;The pattern, once I lined them up, was almost boring. The OpenTelemetry-native tools (Langfuse, Phoenix, Laminar, traceAI) can all represent the audio layer, because OTel does not care whether a span wraps an LLM call or an endpointer decision. The LLM-focused tools (Helicone, LangSmith) are sharper at the thing they are built for and quieter about everything else. Nobody on this list ships voice-agent observability that works out of the box. Every one of them needs you to define the audio spans yourself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Week 3: the instrumentation that actually paid off&lt;/strong&gt;&lt;br&gt;
The fix was not a tool swap. It was deciding to instrument the audio layer first and treat the LLM trace as already solved, because it was. Concretely, every turn now emits spans for ASR (with latency and confidence as attributes), endpoint decision (with the timing that would have caught the 2am drop), and time-to-first-audio. Because that is plain OpenTelemetry, it lands in whatever backend I point it at. The LLM span, the one thing all six tools handle beautifully, is the least of my attributes now.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What shipped, and what I would tell the version of me who pulled that 2am trace&lt;/strong&gt;&lt;br&gt;
What shipped: a voice pipeline where the endpointer's decision is a first-class, traceable event, and the dashboard that used to glow green on a broken call now shows the early-fire spike that caused it. Mean-time-to-the-real-cause on audio-layer bugs went from "listen to the recording and guess" to "read the span." The 2am class of incident is now a saved query.&lt;/p&gt;

&lt;p&gt;What I would tell past me: stop staring at the LLM trace. It was always going to be green. The part of the system that was actually deciding whether the call worked, when the human gets to stop talking, how fast they hear a reply, whether an interruption registered, was the part you had not instrumented at all. Pick whichever OpenTelemetry-native tool fits your wallet and your team, the choice between them matters less than people pretend. Then spend your week emitting audio spans, not comparing dashboards. The LLM layer is the solved problem. The voice layer is the one that pages you at 2am.&lt;/p&gt;

</description>
      <category>opentelemetry</category>
      <category>ai</category>
      <category>llm</category>
      <category>observability</category>
    </item>
    <item>
      <title>The 1.4 Seconds That Weren't on Any Span</title>
      <dc:creator>Marcus Chen</dc:creator>
      <pubDate>Wed, 24 Jun 2026 23:38:04 +0000</pubDate>
      <link>https://dev.to/realmarcuschen/the-14-seconds-that-werent-on-any-span-483m</link>
      <guid>https://dev.to/realmarcuschen/the-14-seconds-that-werent-on-any-span-483m</guid>
      <description>&lt;p&gt;On the morning of June 3rd, a customer on a live call sat through 1.4 seconds of dead air after she finished a sentence, long enough that she said "hello?" before the agent answered. I had the trace open in Honeycomb forty seconds later. Every span was green. End-to-end p95 read 980ms, comfortably under our budget, and not one span in that waterfall was longer than 400ms. The dashboard told me everything was fine while the customer was, in fact, talking to silence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; End-to-end voice latency is not the sum of your spans. The number that kills your UX lives in the unattributed time &lt;em&gt;between&lt;/em&gt; spans, most often the gap between turn-end (the moment the user stops talking) and ASR-start (the moment your pipeline begins transcribing). APM-style tracing instruments the work and ignores the waiting, so the gap is invisible by construction. You have to put a span on the handoff itself.&lt;/p&gt;

&lt;p&gt;Here is the thing I keep saying and keep being right about: most "LLM observability" is just APM with extra steps. It watches the model. It traces the LLM call, the tool call, the retrieval, the token count, all the parts a backend engineer already knows how to think about. For a voice agent that is the wrong half of the system. Voice agents do not break inside the LLM call. They break in the audio pipeline, in the orchestration between components, in the handoffs nobody owns a span for. Your model can be fast and your product can still feel broken, and your dashboard will not say a word about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the dashboard showed
&lt;/h2&gt;

&lt;p&gt;Our turn looks like this on paper. VAD/turn-detection decides the user is done. Audio goes to ASR (Whisper Large v3, streaming). The transcript goes to the LLM (gpt-4o-realtime) for a first token, then the full response. The response streams to TTS (ElevenLabs) for the first audio byte, which is the moment the user hears anything. There is network on both ends.&lt;/p&gt;

&lt;p&gt;I pulled the one trace from the 1.4-second call. Not an aggregate, the actual trace. Here is the latency budget I had been staring at for weeks, the summed-span view:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stage&lt;/th&gt;
&lt;th&gt;p50&lt;/th&gt;
&lt;th&gt;p95&lt;/th&gt;
&lt;th&gt;p99&lt;/th&gt;
&lt;th&gt;who owns the span&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;VAD / turn-detection&lt;/td&gt;
&lt;td&gt;60ms&lt;/td&gt;
&lt;td&gt;120ms&lt;/td&gt;
&lt;td&gt;180ms&lt;/td&gt;
&lt;td&gt;orchestrator&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ASR (streaming)&lt;/td&gt;
&lt;td&gt;180ms&lt;/td&gt;
&lt;td&gt;310ms&lt;/td&gt;
&lt;td&gt;540ms&lt;/td&gt;
&lt;td&gt;ASR client&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LLM TTFT&lt;/td&gt;
&lt;td&gt;220ms&lt;/td&gt;
&lt;td&gt;380ms&lt;/td&gt;
&lt;td&gt;720ms&lt;/td&gt;
&lt;td&gt;model client&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LLM full response&lt;/td&gt;
&lt;td&gt;140ms&lt;/td&gt;
&lt;td&gt;260ms&lt;/td&gt;
&lt;td&gt;430ms&lt;/td&gt;
&lt;td&gt;model client&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TTS first byte&lt;/td&gt;
&lt;td&gt;90ms&lt;/td&gt;
&lt;td&gt;190ms&lt;/td&gt;
&lt;td&gt;360ms&lt;/td&gt;
&lt;td&gt;TTS client&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Network (both legs)&lt;/td&gt;
&lt;td&gt;40ms&lt;/td&gt;
&lt;td&gt;90ms&lt;/td&gt;
&lt;td&gt;150ms&lt;/td&gt;
&lt;td&gt;gateway&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Add the p95 column. It comes to roughly 1340ms. Our reported end-to-end p95 was 980ms (the percentiles do not stack, a single request rarely hits the tail on every stage at once, so the real end-to-end p95 sits below the naive sum). Fine. Either way, both numbers are wrong about the call that paged me, because the call that paged me had 1.4 seconds the table does not contain. None of these rows is the dead air. The dead air is the white space between two of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pulling the one trace
&lt;/h2&gt;

&lt;p&gt;When you look at a single voice turn in a normal tracing UI, you get a waterfall of bars. Each bar is a span. The instinct, the APM instinct, is to find the longest bar and optimize it. I spent two days doing exactly that. I made ASR faster. I shaved 40ms off TTFT with a prompt cache. The summed bars got shorter and the dead air did not move, because the dead air was never a bar.&lt;/p&gt;

&lt;p&gt;Here is the timeline I finally drew on a whiteboard, because the tracing UI would not draw it for me.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Flh3.googleusercontent.com%2Fd%2F1vUjVJhOH65Wpaa6ZFYueuQIKnJ8iBILf" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Flh3.googleusercontent.com%2Fd%2F1vUjVJhOH65Wpaa6ZFYueuQIKnJ8iBILf" alt="Voice-turn latency waterfall: spans start 1400ms after turn-end" width="1425" height="771"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Figure: one voice turn. The captured spans are short and correct, but they start 1400ms late. The damage is the unattributed gap to their left.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That bracket on the left is the whole post. The spans were honest. They were short, they were green, they summed to a healthy number. They just started 1.4 seconds after the user stopped talking, and nothing in the trace measured the wait, because the code path between "turn-detection fired" and "ASR client opened a stream" did not open a span. It awaited a coroutine, hit a connection-pool stall under load, and sat there. Silent. Unspanned. Invisible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Root cause
&lt;/h2&gt;

&lt;p&gt;The turn-detection callback handed off to ASR through a queue, and the ASR client lazily established its streaming connection on first use. Under concurrent calls, that connection setup contended on a pool that was sized for steady state, not for the moment six calls all finished a turn inside the same 200ms window. So turn-end fired, the handoff coroutine queued the audio, and then waited on a connection that was busy being born. By the time the ASR span opened, 1.4 seconds had passed. The ASR span itself then ran in 300ms, green and blameless.&lt;/p&gt;

&lt;p&gt;The fix is two parts. Put a span around the handoff so the gap stops being invisible. Then fix the pool. You cannot fix what you cannot see, and the entire reason this lived in production for weeks is that the gap was never a measurable thing.&lt;/p&gt;

&lt;p&gt;Here is the real instrumentation. This is OpenTelemetry Python, &lt;code&gt;opentelemetry-api&lt;/code&gt; and &lt;code&gt;opentelemetry-sdk&lt;/code&gt;, the actual SDK calls, runnable.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;opentelemetry&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;trace&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;opentelemetry.trace&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;SpanKind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;StatusCode&lt;/span&gt;

&lt;span class="n"&gt;tracer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;trace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_tracer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;voice.turn&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;handle_turn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;audio_in&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# The outer span is the whole turn, anchored at turn-end.
&lt;/span&gt;    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;tracer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start_as_current_span&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;voice.turn&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;SpanKind&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SERVER&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;turn_span&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;turn_span&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_attribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;call.id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;turn_span&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_attribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;turn.index&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;turn_index&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="c1"&gt;# THE MISSING SPAN: turn-detection -&amp;gt; ASR-start handoff.
&lt;/span&gt;        &lt;span class="c1"&gt;# Everything that happens between "user stopped talking" and
&lt;/span&gt;        &lt;span class="c1"&gt;# "ASR actually began" gets measured here, including the wait
&lt;/span&gt;        &lt;span class="c1"&gt;# for a streaming connection that used to be invisible.
&lt;/span&gt;        &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;tracer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start_as_current_span&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;voice.handoff.vad_to_asr&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;hs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;hs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_attribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;handoff.from&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;turn_detection&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;hs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_attribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;handoff.to&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;asr&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;asr_stream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asr_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open_stream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;hs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;StatusCode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ERROR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
                &lt;span class="n"&gt;hs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;record_exception&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="k"&gt;raise&lt;/span&gt;
            &lt;span class="c1"&gt;# mark when audio truly starts flowing into ASR
&lt;/span&gt;            &lt;span class="n"&gt;hs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;asr_stream_ready&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="c1"&gt;# ASR itself. Short and green. Never the problem.
&lt;/span&gt;        &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;tracer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start_as_current_span&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;voice.asr&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;asr_span&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;transcript&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asr_stream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transcribe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;audio_in&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;asr_span&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_attribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;asr.transcript_chars&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;transcript&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

        &lt;span class="c1"&gt;# LLM and TTS spans continue as before.
&lt;/span&gt;        &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;tracer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start_as_current_span&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;voice.llm&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;llm_span&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;reply&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;llm_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;complete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;transcript&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;llm_span&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_attribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;llm.model&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;tracer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start_as_current_span&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;voice.tts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;tts_span&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;first_byte&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;tts_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;first_audio_byte&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;tts_span&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_attribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tts.first_byte_ms&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;first_byte&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;elapsed_ms&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;reply&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The point is the &lt;code&gt;voice.handoff.vad_to_asr&lt;/code&gt; span. It wraps the dead zone between two components that each had their own span and were each, individually, fast. Now the wait has a name and a duration. The next time six calls finish a turn at once, the handoff span balloons to 1400ms and the connection-pool stall is right there in the waterfall instead of hiding in the white space.&lt;/p&gt;

&lt;p&gt;And once the span exists, you can query for it. Here is the trace query I now run, written for a backend that speaks SQL-ish over spans (Honeycomb's query builder maps to the same idea, and so does any OTLP store you can point at ClickHouse). It surfaces turns where the handoff alone blew past 250ms:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="n"&gt;trace_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;call_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;duration_ms&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;handoff_ms&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;spans&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'voice.handoff.vad_to_asr'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;duration_ms&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;250&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;handoff_ms&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That query returns nothing on a normal day and lights up the instant the pool starts contending. I wired it to an alert on the handoff span's p95, not the end-to-end p95, because the end-to-end p95 is exactly the number that lied to me on June 3rd.&lt;/p&gt;

&lt;p&gt;The pool fix was unglamorous. Pre-warm the ASR streaming connections, size the pool for burst concurrency instead of average, and keep the connections alive between turns instead of opening lazily. Handoff p95 went from 1400ms on the bad call down to 70ms steady-state. The dead air was gone the same afternoon I shipped the span, because the span told me precisely where to put the fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does NOT solve
&lt;/h2&gt;

&lt;p&gt;Instrumenting the handoff makes the gap visible. It does not make your infrastructure fast. A few honest limits.&lt;/p&gt;

&lt;p&gt;It does not fix jitter under load on its own. The span tells you the handoff is slow, but if your pool, your event loop, or your GC is the bottleneck, you still have to go fix that. The span is a flashlight, not a wrench.&lt;/p&gt;

&lt;p&gt;It does nothing about provider-side queueing you cannot see. When ElevenLabs or your ASR vendor queues your request on their side, your client-side span measures the wait but cannot attribute it past the boundary. You will know &lt;em&gt;that&lt;/em&gt; you waited, not &lt;em&gt;why&lt;/em&gt; the provider made you wait. For that you need their status, their rate-limit headers, sometimes a support ticket.&lt;/p&gt;

&lt;p&gt;And it will not catch every gap automatically. I added the VAD-to-ASR span because that is where this fire was. There are other handoffs (ASR-to-LLM, LLM-to-TTS, barge-in cancellation) and each one needs its own span if you want to see its gap. Instrument the ones that hurt first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; Instrument the handoffs, not just the calls. A green waterfall of short, correct spans can still add up to a customer saying "hello?" into silence, because the damage is the time between the bars, and a trace only shows you the bars you drew. The day I stopped trusting the summed p95 and started putting spans on the gaps is the day the dead air stopped paging me. If you run voice agents, go find your turn-end-to-ASR-start handoff right now, wrap it in a span, and alert on that span alone. It is the cheapest 1.4 seconds you will ever buy back.&lt;/p&gt;

</description>
      <category>opentelemetry</category>
      <category>python</category>
      <category>observability</category>
      <category>voiceagents</category>
    </item>
    <item>
      <title>The Retry That Booked Mrs. Alvarez Twice</title>
      <dc:creator>Marcus Chen</dc:creator>
      <pubDate>Wed, 24 Jun 2026 23:27:22 +0000</pubDate>
      <link>https://dev.to/realmarcuschen/the-retry-that-booked-mrs-alvarez-twice-2dg0</link>
      <guid>https://dev.to/realmarcuschen/the-retry-that-booked-mrs-alvarez-twice-2dg0</guid>
      <description>&lt;p&gt;Week one of the pilot, our voice agent booked appointments for a dental group. Forty operatories, three locations, one phone line per office that never stopped ringing. The agent took the call, checked the calendar, wrote the slot, read it back. Clean.&lt;/p&gt;

&lt;p&gt;The 9pm page came on a Thursday. Front desk had found four double-booked slots from that afternoon. Same callers, same times, two rows each in the scheduling table. The agent swore (in the transcript) it booked once. The database swore it booked twice. Both were telling the truth.&lt;/p&gt;

&lt;p&gt;Here is what actually happened. Our booking call went out to the practice management API. That API was slow that day, p95 around 4200ms, sometimes worse. We had a 3000ms timeout on the HTTP client. So the request would land, the booking would commit on their side, and our client would give up waiting before the 201 came back. The agent saw a timeout, treated it as a failure, and said the line every voice agent says when something goes wrong: "sorry, let me try that again." Then it fired the same booking a second time. The second one was fast enough to return. Two rows. One confused Mrs. Alvarez.&lt;/p&gt;

&lt;p&gt;The retry was the bug. Not the slowness. Slowness is normal. The sin was retrying a write that had no idempotency key, so the downstream system had no way to know the second request was the same intent as the first.&lt;/p&gt;

&lt;p&gt;The fix was small and boring, which is the best kind. Generate a stable key per booking intent (not per HTTP attempt) and pass it through. If the agent decides to book the 2pm slot for this caller, that decision gets one key, and every retry of that decision carries it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;booking_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;call_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;slot_iso&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;provider_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# one key per intent. survives retries, timeouts, agent re-prompts.
&lt;/span&gt;    &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;call_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;slot_iso&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;provider_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()[:&lt;/span&gt;&lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/appointments&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Idempotency-Key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;booking_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;call_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;slot_iso&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;provider_id&lt;/span&gt;&lt;span class="p"&gt;)},&lt;/span&gt;
    &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;8.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# also: stop timing out under their real p95
&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things mattered together. The key made the duplicate write a no-op on the server (their API honored Idempotency-Key, most modern ones do, and if yours does not, you build the dedup yourself with a unique constraint on those three fields). And the timeout went from 3000ms to 8000ms, because a 3000ms ceiling on a 4200ms p95 is not a timeout, it is a duplicate-booking generator with extra steps.&lt;/p&gt;

&lt;p&gt;We shipped the key first, that same night. Double-bookings went to zero across the next 1,800 calls. The timeout bump went out the next morning after I pulled a week of latency histograms and saw the real tail.&lt;/p&gt;

&lt;p&gt;What I would tell week-one me: a voice agent retrying a write is not retrying a question. When the agent says "let me try that again," something on the other end may already be true. Decide what one action means before you let the agent do it twice. Put the key on the intent, not the attempt, and timeouts on the real numbers, not the round ones.&lt;/p&gt;

</description>
      <category>voiceagents</category>
      <category>python</category>
      <category>api</category>
      <category>reliability</category>
    </item>
    <item>
      <title>LLM observability tools are blind to the voice layer. Here is what I checked 6 of them for.</title>
      <dc:creator>Marcus Chen</dc:creator>
      <pubDate>Thu, 18 Jun 2026 22:56:51 +0000</pubDate>
      <link>https://dev.to/realmarcuschen/llm-observability-tools-are-blind-to-the-voice-layer-here-is-what-i-checked-6-of-them-for-3p4o</link>
      <guid>https://dev.to/realmarcuschen/llm-observability-tools-are-blind-to-the-voice-layer-here-is-what-i-checked-6-of-them-for-3p4o</guid>
      <description>&lt;h2&gt;
  
  
  Tracing the LLM call is the easy 20 percent. For a voice agent, the failures live in the audio layer your tracer never sees.
&lt;/h2&gt;

&lt;p&gt;Most LLM observability tools trace the same thing: the prompt, the completion, the tokens, the latency of the model call. For a text agent that is most of the story. For a voice agent it is maybe a fifth of it, because the failures that actually make a voice agent feel broken happen in the audio layer, and a tracer pointed at the LLM call cannot see them. I went through six observability tools (Langfuse, Helicone, Arize Phoenix, LangSmith, Braintrust, and Laminar) asking one question each: can it show me the audio layer, or only the LLM call?&lt;/p&gt;

&lt;p&gt;The audio layer is where the real spans are. End-of-turn detection: how long did the agent wait before deciding the caller was done? ASR latency and confidence: how long did transcription take, and how sure was it? Barge-in: did the caller interrupt, and did the agent yield? Time-to-first-audio: how long from the caller finishing to the agent making a sound? None of these are LLM-call metrics, and a green LLM-latency dashboard tells you nothing about any of them. I have watched a voice agent with a perfectly healthy model-call trace feel sluggish and rude to every caller, because the lag and the interruptions lived in spans the tracer was not capturing.&lt;/p&gt;

&lt;p&gt;So here is how the six landed, all on the same question. Langfuse, Phoenix, and Laminar are OpenTelemetry-based, which is the good news: OTel does not care whether a span is an LLM call or an ASR call, so you can emit custom spans for endpointing, ASR, and barge-in and see them next to the model call. The catch is you have to instrument those spans yourself; none of them ship voice-aware instrumentation, they give you the canvas. Helicone is gateway-first, so it is excellent at LLM-call logging and cost and largely silent on the audio layer unless you add your own telemetry around it. LangSmith is deep on the LLM and LangChain trace and the most LLM-call-centric of the set, least aware of audio by default. Braintrust gives you a clean UI for whatever you send it, so again the audio layer shows up only if you instrument it.&lt;/p&gt;

&lt;p&gt;The pattern is the same across all six: the tool is only as voice-aware as the spans you feed it, and the ones built on OpenTelemetry make that easy because you are just emitting more spans into a format they already understand. That is the actual selection criterion for a voice agent, not the LLM-tracing features every one of them advertises, but whether the model lets you put audio-layer spans right next to the model spans so "it feels slow" maps to a stage instead of a guess.&lt;/p&gt;

&lt;p&gt;If I were choosing today for a voice agent, I would pick an OpenTelemetry-native tool and spend the first day instrumenting the audio layer, endpoint timeout, ASR latency and confidence, barge-in events, time-to-first-audio, before touching a single LLM metric. The LLM trace is the part that is already solved. The voice layer is the part that is invisible, and invisible is where the incidents hide.&lt;/p&gt;

&lt;p&gt;The open question I have not cracked: even with audio-layer spans, "the call felt off" is a subjective, whole-conversation judgment that does not reduce cleanly to any single span. I can show you the endpoint timeout and the barge-in count, but not why the caller hung up frustrated. If anyone has tied per-span audio telemetry to a felt-quality score for a whole call, that is the conversation I want.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>observability</category>
      <category>llm</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>Our voice agent passed every test and still woke me up at 3am</title>
      <dc:creator>Marcus Chen</dc:creator>
      <pubDate>Thu, 11 Jun 2026 10:35:24 +0000</pubDate>
      <link>https://dev.to/realmarcuschen/our-voice-agent-passed-every-test-and-still-woke-me-up-at-3am-37dc</link>
      <guid>https://dev.to/realmarcuschen/our-voice-agent-passed-every-test-and-still-woke-me-up-at-3am-37dc</guid>
      <description>&lt;h2&gt;
  
  
  Replaying real call transcripts as your test set is a trap. The failures come from the inputs a user produces exactly once.
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Our voice-agent regression suite was 312 recorded production calls, all passing. The page at 3am came from a caller who switched between English and Hindi mid-sentence, a pattern that appeared zero times in those 312 calls. Replaying real transcripts tests the confidence you already have. It does not test the inputs that actually break you. We moved to simulating adversarial callers, and below is what I learned trying five tools to generate and grade those simulated conversations (as of June 2026).&lt;/p&gt;

&lt;p&gt;The test set was real, and that was the problem&lt;br&gt;
For about four months our regression set was 312 recorded production calls. It felt rigorous. Real audio, real ASR output, real user intents, replayed on every deploy. Green for weeks.&lt;/p&gt;

&lt;p&gt;Then the 3am page. A caller switched between English and Hindi inside single sentences. Our ASR mis-segmented the mixed-language audio, the intent classifier saw garbage, and the agent fell into a clarification loop it could not exit. The caller hung up. The dashboards were fine the whole time.&lt;/p&gt;

&lt;p&gt;I went looking for that pattern in the 312 calls. It was not there. Not once. The people who code-switch like that had mostly churned months earlier, so the behavior was absent from the recordings exactly because it was a problem we never handled. A test set built from past traffic contains what already happened, weighted toward the common case. The failures that page you are rare by definition, and rare things are missing from a sample of the past.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why replay gives false confidence&lt;/strong&gt;&lt;br&gt;
Replaying recorded calls is a regression test for behavior you have already seen. That is useful. It catches the case where a deploy breaks something that used to work. What it cannot do is produce an input you have never received. For that you have to manufacture the input on purpose: the fast talker who never pauses, the caller who interrupts the agent two words in, the code-switcher, the person who changes their mind halfway through a sentence, the line with a TV on in the background. That is simulation, and it is a different activity from replay.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I tried to generate and grade simulated calls&lt;/strong&gt;&lt;br&gt;
Five tools, roughly a week each, same eight adversarial caller profiles. None of these is voice-specific; I drove them off transcripts plus a separate ASR/TTS layer. Honest notes, your mileage will differ:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Promptfoo:&lt;/strong&gt; fast to wire into CI and good for red-teaming a prompt with generated variants. The fiddly part was that conversation state across turns was a manual build.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LangSmith:&lt;/strong&gt; dataset versioning and the trace view were the best of the set. The simulation half I had to assemble myself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Future AGI Simulate:&lt;/strong&gt; persona-based, you define caller personas and it runs them through the agent, which matched how I already thought about adversarial callers (as of June 2026). Voice was not first-class, so I ran it on transcripts with ASR and TTS bolted on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Braintrust:&lt;/strong&gt; the nicest UI for eyeballing where a run diverged. Persona definitions lived outside it, in my code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DeepEval:&lt;/strong&gt; the most knobs for synthetic-conversation generation. Tuning the synthesizer to stop producing unrealistic turns took a while.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Confident AI:&lt;/strong&gt; a reasonable hosted layer on top of DeepEval, though it is another account and key to manage.
I am deliberately not crowning one. Braintrust had the UI I liked, DeepEval had the most generation control, and the persona abstraction in Future AGI's Simulate (part of their open work at github.com/future-agi) lined up with how I list out adversarial callers. Any of them can run a persona once you have written the persona.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The thing that actually moved the needle was not the tool&lt;br&gt;
It was the persona list. Once we had written eight adversarial callers (the angry caller, the two-words-then-silence caller, the code-switcher, the background-noise line, and so on), every tool above could run them and grade the results. The leverage was in naming the failure modes, not in the framework that executed them. We spent two days arguing about the personas and twenty minutes wiring the runner.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The open question I still have&lt;/strong&gt;&lt;br&gt;
The space of adversarial callers is infinite, and we maintain eight. We chose those eight from incident postmortems, which means we are still only simulating failures we have already been burned by at least once. The genuinely novel failure, the next 3am page, is still unguarded. I do not have a principled way to pick simulation personas before the incident teaches me the persona. If you have one, that is the comment I want to read.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;FAQ&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;Why not just add each failed call to the regression set after the incident?&lt;/strong&gt;&lt;br&gt;
We do. It is still reactive. The replay suite trails production by one outage, permanently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Doesn't simulated traffic drift away from what real users do?&lt;/strong&gt;&lt;br&gt;
Yes, and that is a real cost. We re-sample the real call distribution monthly and adjust how often each persona fires. Simulation supplements replay; it does not replace it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is any of this voice-specific?&lt;/strong&gt;&lt;br&gt;
Most of it applies to text agents too. Voice just adds two more failure surfaces: ASR segmentation and barge-in timing. The code-switching incident was really an ASR segmentation failure that a text agent would never have hit.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>voice</category>
      <category>testing</category>
      <category>llm</category>
    </item>
    <item>
      <title>The 4-layer voice-agent latency stack, traced with OTel spans</title>
      <dc:creator>Marcus Chen</dc:creator>
      <pubDate>Tue, 09 Jun 2026 09:28:48 +0000</pubDate>
      <link>https://dev.to/realmarcuschen/the-4-layer-voice-agent-latency-stack-traced-with-otel-spans-37hp</link>
      <guid>https://dev.to/realmarcuschen/the-4-layer-voice-agent-latency-stack-traced-with-otel-spans-37hp</guid>
      <description>&lt;p&gt;**&lt;/p&gt;

&lt;h2&gt;
  
  
  How I instrument ASR, LLM, TTS, and the client with OpenTelemetry, and which number in each layer I actually look at
&lt;/h2&gt;

&lt;p&gt;**&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR.&lt;/strong&gt; A voice agent is four moving parts stuck together: speech to text, the model that writes the reply, text to speech, and the client that plays the audio back. End to end latency hides which of those four is slow on any given turn, so I stopped tracking it as one number and started tracing each stage as its own OTel span with a shared session id. The number I watch hardest is barge-in: when the user starts talking over the agent, how many milliseconds until the agent actually stops sending audio. In our setup we want that under 200ms, and when p95 barge-in creeps past that, the agent feels like it is talking at you instead of with you. Everything below is how I wire the spans, what attributes go on each one, and the p95 I page on per layer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The thing I keep saying, and the thing that keeps being true:&lt;/strong&gt; voice agents fail in production not because of raw latency but because nobody simulated the audio and LLM pipeline together. You can have a fast ASR, a fast model, a fast TTS, and a voice agent that still feels broken, because the failure lives in the seams between them and in the parts (barge-in, jitter) that no single-stage benchmark touches. Tracing is how I get the seams to show up.&lt;/p&gt;

&lt;p&gt;A note before the layers. This is just the setup we run, the spans we emit, and the mistakes that made us add each attribute. Some of it is probably specific to our stack and will not transfer. I will flag that where I can.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of a turn, and why one span is not enough
&lt;/h2&gt;

&lt;p&gt;One turn is: user says a thing, agent says a thing back. Underneath that is roughly: audio frames come in, ASR turns them into text (streaming partials as it goes); the text plus history goes to the LLM, which streams tokens back; as text comes out, TTS turns it into audio, also streaming; the client receives audio frames and plays them, with some buffering to smooth out jitter.&lt;/p&gt;

&lt;p&gt;If you wrap the whole turn in a single span and call it voice.turn, you get a duration and almost no ability to act on it. A 1,400ms turn could be a slow first token, or TTS waiting on the full sentence before it starts, or the client buffering too aggressively. Same total, three different fixes.&lt;/p&gt;

&lt;p&gt;So the parent span is voice.turn, and each stage is a child span. Every span carries the same audio.session_id and an audio.turn_id, so I can pull one turn out of Tempo and see all four stages laid out in time. The attribute I care about most on the streaming stages is not total duration. It is first byte: how long until the stage produced its first useful output. First byte is what the user feels, because all three stages are streaming and the user starts perceiving progress at the first byte, not the last.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;contextlib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;contextmanager&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;opentelemetry&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;trace&lt;/span&gt;

&lt;span class="n"&gt;tracer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;trace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_tracer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;voice.pipeline&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nd"&gt;@contextmanager&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;stage_span&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;session_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;turn_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;span&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tracer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start_span&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;audio.&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_attribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;audio.stage&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_attribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;audio.session_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;session_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_attribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;audio.turn_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;turn_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;started&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;monotonic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;mark_first_byte&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;
        &lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_attribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;audio.first_byte_ms&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;monotonic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;started&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;1000.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;trace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use_span&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;end_on_exit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;mark_first_byte&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;record_exception&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_attribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;audio.error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="k"&gt;raise&lt;/span&gt;
    &lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_attribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;audio.total_ms&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;monotonic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;started&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;1000.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
            &lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_attribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;audio.first_byte_ms&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# produced nothing
&lt;/span&gt;        &lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Calling it around the LLM stage:&lt;/strong&gt; you call first_byte() inside the streaming loop the first time a token shows up, and the wrapper does the timing math.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run_llm_stage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;turn_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;llm_client&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;chunks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;stage_span&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;llm&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;session_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;turn_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;first_byte&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;llm_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="nf"&gt;first_byte&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;           &lt;span class="c1"&gt;# no-op after the first call
&lt;/span&gt;            &lt;span class="n"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I use time.monotonic() and not time.time() on purpose. Wall clock can jump (NTP corrections), and on a sub-second budget a backwards clock gives you negative latencies that poison the percentiles. One more thing I learned the annoying way: audio.session_id is high cardinality, so I keep it as a span attribute for trace lookup, but I do not turn it into a metric label. Stage goes on the metric label. Session id stays on the trace.&lt;/p&gt;

&lt;h2&gt;
  
  
  ASR: measure first partial, not final transcript
&lt;/h2&gt;

&lt;p&gt;The mistake I made first was timing ASR as audio-in to final-transcript-out. That number is real but it is not the one that matches what the user feels, because a streaming ASR gives you a partial transcript fast and then refines it. So the span gets two numbers: audio.first_byte_ms is time to first partial, and I stash time to final separately.&lt;/p&gt;

&lt;p&gt;The other ASR attribute that earned its place is whether the final transcript disagreed badly with the last partial. We had an incident where ASR turned a customer saying they wanted to confirm an order into the word cancel, and the agent acted on it. After that I started recording a rough measure of how much the final revised the partial, so big late revisions show up in traces instead of only in an angry support ticket. What I look at for ASR: p95 of time to first partial. In our setup that sits under 150ms most days, and when it drifts up it is almost always the audio frames not arriving on time from the client, not the ASR model. A nice example of why you trace the whole thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The LLM: first token is the whole ballgame, and barge-in lives here too
&lt;/h2&gt;

&lt;p&gt;For the model stage, total generation time barely matters for the felt experience, because TTS consumes tokens as they arrive. What matters is time to first token. If the model takes 600ms before the first token, the user hears 600ms of silence after they stopped talking, and that feels like the agent froze. So the LLM span's headline attribute is time to first token.&lt;/p&gt;

&lt;p&gt;Barge-in is the part people forget to instrument, and the part I would instrument first if I were starting over. It is what happens when the user starts talking while the agent is still speaking. The metric: from the moment voice-activity detection fires, to the moment the agent's outbound audio actually goes quiet. The first time we measured it, it was around 500ms and felt terrible, and the breakdown showed most of the time was not detection. It was buffered TTS audio we had already shipped toward the client and could not un-send. We had buffered aggressively to fight jitter, and that same buffer made barge-in slow. Tracing let me see the two goals were fighting. We are at roughly 180ms p95 now.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run_barge_in&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;turn_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;vad&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;agent_audio&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;stage_span&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;barge_in&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;session_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;turn_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;first_byte&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;span&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;trace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_current_span&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;t0&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;monotonic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;vad&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;wait_for_user_speech&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_attribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;audio.vad_detect_ms&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;monotonic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;t0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;1000.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;agent_audio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cancel_generation&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="nf"&gt;first_byte&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_attribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;audio.cancel_ms&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;monotonic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;t0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;1000.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;agent_audio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flush_downstream_buffers&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_attribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;audio.silence_ms&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;monotonic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;t0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;1000.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The number I keep on the wall for the model layer is two numbers honestly: p95 first token, and p95 barge-in silence. Both have to be good.&lt;/p&gt;

&lt;h2&gt;
  
  
  TTS: first audio chunk, and the gap between sentences
&lt;/h2&gt;

&lt;p&gt;TTS is streaming too, so the attribute that matters is first byte, the first chunk of playable audio. We page when p95 first-byte on TTS goes above 350ms, because past that the pause between the user finishing and the agent starting gets long enough that testers describe it as the agent thinking too hard. There is a second TTS thing a single first-byte number misses: the gaps between chunks once audio is flowing. If TTS stalls mid-sentence the user hears a stutter, and average latency looks fine. So I record the largest inter-chunk gap on the TTS span.&lt;/p&gt;

&lt;p&gt;I keep ASR, the model, and TTS all using the exact same audio.first_byte_ms attribute name on purpose, even though "first byte" means a slightly different physical thing for each. Same name means one query pulls first-byte across all three stages and I compare them on one screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  The client: jitter is the number, and you cannot see it from the server
&lt;/h2&gt;

&lt;p&gt;Everything above is server side. The client receives audio over a network you do not control and plays it. The enemy is jitter: frames arriving unevenly. From the server everything can look healthy while the user hears choppy audio. So the client emits its own span per turn, with the jitter it measured and the buffer depth it settled on, shipped to the same collector with the same audio.session_id. Now a glitchy call shows the jitter right next to the three server spans. The honest caveat: client clocks are not synced to your server, so treat client timestamps as approximate. I trust the client span for the jitter and buffer values it reports about itself, not for lining its clock up to the millisecond.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhxynoyobrt4c7hi8n8ho.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhxynoyobrt4c7hi8n8ho.png" alt=" " width="776" height="234"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is the TraceQL I keep saved. It pulls p95 of first-byte latency, grouped by stage.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{ span.audio.stage != "" &amp;amp;&amp;amp; span.audio.first_byte_ms &amp;gt;= 0 }
  | select(span.audio.stage, span.audio.first_byte_ms)
  | quantile_over_time(span.audio.first_byte_ms, 0.95) by (span.audio.stage)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &amp;gt;= 0 filter is there because a stage that produced nothing gets first_byte_ms = -1, and I do not want those poisoning the percentile. To go from aggregate to a single bad call I filter by session: { span.audio.session_id = "sess_8f21c0" }. That gives every span for that session in time order, which is the entire reason I put session_id on every span. A word on percentiles, because it changes what you do: p50 first token might be 280ms and look fine, p99 might be 1,900ms, and in voice that p99 is a real human who had a two-second silence and probably said "hello? are you there?" into the void. Averages I mostly ignore.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I am still chewing on
&lt;/h2&gt;

&lt;p&gt;How do you set the client playout buffer when you cannot see the user's network until the call is already happening? Is barge-in even the right model, when VAD fires on a cough, an "mm-hm", the user's dog? And the question under all of it: I can trace every layer now, but I still do not have a number for "this call felt natural" that does not eventually come down to a human listening to it. The tracing tells me where time went. It does not tell me whether the conversation was any good.&lt;/p&gt;

&lt;p&gt;If you are instrumenting a voice agent and you only have time to add one span this week, add barge-in. It is the one nobody measures and the one users feel the fastest.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>observability</category>
      <category>voice</category>
      <category>rust</category>
    </item>
    <item>
      <title>3 OTel span attributes I tag on every voice-pipeline span</title>
      <dc:creator>Marcus Chen</dc:creator>
      <pubDate>Tue, 02 Jun 2026 17:50:16 +0000</pubDate>
      <link>https://dev.to/realmarcuschen/3-otel-span-attributes-i-tag-on-every-voice-pipeline-span-4pg7</link>
      <guid>https://dev.to/realmarcuschen/3-otel-span-attributes-i-tag-on-every-voice-pipeline-span-4pg7</guid>
      <description>&lt;p&gt;Voice pipelines have 4 stages that need separate latency stories: ASR (speech to text), LLM (the response prompt), TTS (text to speech), and client (jitter on the receiving end). When we wired OTel across all 4, the spans without consistent attributes were useless for queries. 3 attributes ended up on every span and earn their keep.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;audio.stage.Enum&lt;/strong&gt;: asr, llm, tts, client. The single most-queried attribute. The Grafana query for p95 latency by stage is one filter. Without this, you are scrolling raw traces.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;audio.session_id&lt;/strong&gt;: The full conversation. Lets you query "what did the user actually experience" end-to-end. We use a uuid generated at session start, propagated to every downstream call. Tempo's &lt;code&gt;traces by tag&lt;/code&gt; lookup is fast on this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;audio.first_byte_ms&lt;/strong&gt;: The time from request start to first audio byte returned. For ASR and TTS streaming stages. This is what catches barge-in latency regressions before the dashboard's aggregate alert does. We page when p95 first_byte_ms goes above 350ms on TTS.&lt;/p&gt;

&lt;p&gt;Honorable mention attributes that didn't survive the first cleanup: &lt;code&gt;audio.codec&lt;/code&gt; (covered by the service info), &lt;code&gt;audio.session_turn_index&lt;/code&gt; (covered by parent-span linkage), &lt;code&gt;audio.user_id&lt;/code&gt; (privacy concerns at scale; left out).&lt;/p&gt;

&lt;p&gt;If you are starting voice-pipeline observability: tag stage + session_id on every span from day one. The first_byte_ms is the one you will add after the first production incident; you might as well add it on day two.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>observability</category>
      <category>voice</category>
      <category>rust</category>
    </item>
    <item>
      <title>Voice agent latency is a lie. The number you care about is barge-in interrupt rate.</title>
      <dc:creator>Marcus Chen</dc:creator>
      <pubDate>Tue, 26 May 2026 15:58:32 +0000</pubDate>
      <link>https://dev.to/realmarcuschen/voice-agent-latency-is-a-lie-the-number-you-care-about-is-barge-in-interrupt-rate-38fc</link>
      <guid>https://dev.to/realmarcuschen/voice-agent-latency-is-a-lie-the-number-you-care-about-is-barge-in-interrupt-rate-38fc</guid>
      <description>&lt;p&gt;Last quarter we shipped our voice agent into production. The p99 end-to-end latency was 280 milliseconds. Our largest competitor's was 450 milliseconds. On every dashboard, we were faster.&lt;/p&gt;

&lt;p&gt;Our user research panel said our agent felt slower.&lt;/p&gt;

&lt;p&gt;The "felt slower" gap was 8 percentage points on a 5-point Likert. Statistically significant. We had been measuring the wrong thing.&lt;/p&gt;

&lt;p&gt;It took us two weeks to figure out what the panel was actually measuring, and four weeks after that to fix the right number. The wrong number was end-to-end latency. The right number was barge-in interrupt rate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the dashboard lied
&lt;/h2&gt;

&lt;p&gt;Voice agent benchmarks measure response time. ASR converts speech to text, the LLM produces a response, TTS turns it into audio, you ship it. The end-to-end clock is what gets reported.&lt;/p&gt;

&lt;p&gt;That clock is not what users experience as "speed."&lt;/p&gt;

&lt;p&gt;What users experience is the loop between starting to interrupt the agent and the agent shutting up. If they say "wait" mid-sentence and the agent finishes the sentence first, that is a one-to-two-second pause from the user's perspective.&lt;/p&gt;

&lt;p&gt;That gap, the barge-in delay, was 380 milliseconds for us. Our competitor's was 60 milliseconds. Users felt that gap on every interruption.&lt;/p&gt;

&lt;h2&gt;
  
  
  How we measured barge-in interrupt rate
&lt;/h2&gt;

&lt;p&gt;The metric: of attempts where the user starts speaking during agent speech, what percentage result in the agent yielding within X milliseconds?&lt;/p&gt;

&lt;p&gt;Two methods.&lt;/p&gt;

&lt;p&gt;Synthetic. A corpus of 500 recorded interruption attempts pulled from prior support calls. We fed each audio segment into a copy of the agent and measured time-from-first-syllable to agent-stops-speaking.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;python
# barge_in_eval.py (simplified)
def measure_barge_in(agent, recording):
    start = time.monotonic_ns()
    agent.play(recording.agent_response_audio)
    interrupt_t = start + recording.interrupt_offset_ns
    play_user_audio(recording.user_audio, at=interrupt_t)
    stop_t = wait_for_agent_silence()
    return (stop_t - interrupt_t) / 1_000_000  # ms

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Real. Instrumented the production audio pipeline to emit one span when VAD (Voice Activity Detection) fires and another when TTS interrupts. Both go to OTel. Subtracting the timestamps gives the per-call barge-in latency.&lt;/p&gt;

&lt;p&gt;Our barge-in interrupt rate at the 100ms threshold was 41%. At 250ms it was 89%, but 250ms is too slow to feel responsive.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three things we changed
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Pin the audio buffer pages
&lt;/h3&gt;

&lt;p&gt;Our agent ran in a long-lived Tokio runtime. The audio buffers were allocated on the heap and occasionally got paged to swap when the model weights were active.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;libc&lt;/span&gt;&lt;span class="p"&gt;::{&lt;/span&gt;&lt;span class="n"&gt;mlock&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;c_void&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;unsafe&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;pin_buffer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;u8&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nn"&gt;std&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;io&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;ret&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mlock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buf&lt;/span&gt;&lt;span class="nf"&gt;.as_ptr&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="nb"&gt;c_void&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;buf&lt;/span&gt;&lt;span class="nf"&gt;.len&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ret&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;std&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;io&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;last_os_error&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(())&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After this, VAD detected user speech within 25ms of first syllable.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. VAD threshold tuning
&lt;/h3&gt;

&lt;p&gt;A/B tested 0.4 to 0.65 on the synthetic corpus. 0.5 was best. 4% earlier detection than 0.6 with only 1.2% false positive increase.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. TTS interrupt path
&lt;/h3&gt;

&lt;p&gt;The killer. Our TTS streamed audio in 200ms chunks. When VAD fired, the audio queue still held 400ms of buffered audio that played to completion. Users heard the agent finish a fragment of a sentence before silence.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;handle_barge_in&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;AgentState&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="py"&gt;.llm_handle&lt;/span&gt;&lt;span class="nf"&gt;.cancel&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="py"&gt;.tts_queue&lt;/span&gt;&lt;span class="nf"&gt;.clear&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="py"&gt;.audio_out&lt;/span&gt;&lt;span class="nf"&gt;.stop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We dropped chunk size to 30ms and flushed the queue immediately on VAD fire.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;p&gt;Four weeks of work. Barge-in interrupt rate at 100ms threshold moved from 41% to 89%. The "felt slower" gap closed within one user research cycle.&lt;/p&gt;

&lt;p&gt;Our actual p99 latency went up slightly (280ms to 305ms) because of the smaller TTS chunks. The dashboard number got worse. The user-felt number got dramatically better.&lt;/p&gt;

&lt;h2&gt;
  
  
  The number that mattered
&lt;/h2&gt;

&lt;p&gt;Voice agent latency is the dashboard number. Barge-in interrupt rate is the user number.&lt;/p&gt;

&lt;p&gt;Most voice agent teams I have talked to do not measure barge-in interrupt rate. They measure end-to-end latency, they get a number that feels low, they ship. Then their users say "your agent sucks" and the team cannot reconcile what the dashboard says with what the user says.&lt;/p&gt;

&lt;p&gt;The reconciliation is the metric you are not tracking.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I am still tuning
&lt;/h2&gt;

&lt;p&gt;Eight months in, I have stopped trusting the dashboard more than the user research panel. The dashboard wants to be right. The panel just is.&lt;/p&gt;

&lt;p&gt;The barge-in threshold itself is the part I am least sure about. 100ms is our target. 60ms is our competitor's. Whether 60ms gives a meaningful UX delta over 100ms for the users we serve, I genuinely cannot tell yet.&lt;/p&gt;

&lt;p&gt;Distinguishing intentional from filler interrupts is the next obvious area. Yielding on "wait" is correct. Yielding on "mhm" is wrong. We currently treat both the same.&lt;/p&gt;

&lt;p&gt;And the felt-slower measurement is the one I am most aware of being weak on. Our 5-point Likert is the best we have, and it is not great. If anyone is running rigorous voice agent UX studies, the methodology would be more useful to me than the dashboard ever will.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>rust</category>
      <category>performance</category>
      <category>llm</category>
    </item>
  </channel>
</rss>
