<?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: Jasmine Park</title>
    <description>The latest articles on DEV Community by Jasmine Park (@jasmine_park_dev).</description>
    <link>https://dev.to/jasmine_park_dev</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%2F3940554%2F33355dac-c999-4ac2-ba72-34c28bf9f1d7.png</url>
      <title>DEV Community: Jasmine Park</title>
      <link>https://dev.to/jasmine_park_dev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jasmine_park_dev"/>
    <language>en</language>
    <item>
      <title>One tenant, one polling loop, and $9,100 we didn't budget for</title>
      <dc:creator>Jasmine Park</dc:creator>
      <pubDate>Tue, 21 Jul 2026 17:28:17 +0000</pubDate>
      <link>https://dev.to/jasmine_park_dev/one-tenant-one-polling-loop-and-9100-we-didnt-budget-for-24an</link>
      <guid>https://dev.to/jasmine_park_dev/one-tenant-one-polling-loop-and-9100-we-didnt-budget-for-24an</guid>
      <description>&lt;p&gt;The page that mattered never fired. Finance sent it instead, on a Thursday, in Slack: "Projected LLM spend this month is 41% over. Is that expected?" It was not expected. It took me four hours to find out why, and every one of those hours was a monitoring failure, not a model failure.&lt;/p&gt;

&lt;p&gt;Here is the short version. One customer shipped a change that put an LLM call inside a polling loop. Their integration went from roughly 2,000 calls a day to about 140,000 calls a day, overnight. We paid for all of it. It ran for six days before anyone looked, because every dashboard I owned was aggregate, and the aggregate looked boring.&lt;/p&gt;

&lt;p&gt;The hot take I keep repeating in incident reviews: cost blowups are a metering gap. The bill breaks down per tenant, and if you only watch the total you miss the tenant that is on fire.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happened
&lt;/h2&gt;

&lt;p&gt;The customer's feature polled a downstream job for completion. Someone refactored it and, instead of polling the job status, the loop re-issued the full LLM summarization call on every tick. Tick interval was five seconds. Do the math on a handful of concurrent sessions running most of the day and you land around 140k calls. Their code, our endpoint, our invoice.&lt;/p&gt;

&lt;p&gt;None of it errored. That is the part that stings. Every one of those calls returned a clean 200. Latency was fine. Our SLO burn was zero. The system was, by every signal we alerted on, perfectly healthy. It was also setting money on fire at a steady rate.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it cost
&lt;/h2&gt;

&lt;p&gt;Our average cost per call for that feature is about $0.011 (short prompt, capped output). Normal footprint for this tenant:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;2,000 calls/day&lt;/li&gt;
&lt;li&gt;about $22/day&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After the loop bug:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;about 140,000 calls/day&lt;/li&gt;
&lt;li&gt;about $1,540/day&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The delta is about $1,518 a day. It ran six days before finance flagged it. That is roughly $9,100 of spend we had no budget line for, on one tenant, on one feature, for output nobody consumed (they were throwing away 69 of every 70 responses).&lt;/p&gt;

&lt;p&gt;For context, our whole platform's LLM spend was averaging around $1,800/day. This one tenant nearly doubled it. And I still did not see it, because our daily total already swung 30% on a normal week (weekend dips, batch backfills, onboarding spikes). A jump from $1,800 to $3,300 read like "big customer doing something legitimate." We had genuinely had 2x days before that were fine. So the signal sat inside the noise band, and the noise band was wide because I had never bothered to narrow it per tenant.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it missed at scale
&lt;/h2&gt;

&lt;p&gt;The scary math is not the $9,100. It is the slope. At $1,518/day this was a $45k/month leak from a single misbehaving integration, and we have hundreds of tenants. Any one of them can do this to us on any day. Our exposure was never "how expensive is the model." It was "how long can one runaway tenant run before a human happens to look at a bill." Six days, apparently. That is the actual SLO I had, and I had never written it down or defended it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;The refactor was easy: tag every call, attribute cost per tenant and per feature, and alert on spend rate the same way I alert on error rate. We already emitted tenant_id and feature on the request span. We just were not multiplying tokens by price and summing by tenant anywhere a human or an alert would see it.&lt;/p&gt;

&lt;p&gt;The core alert is a spend-rate anomaly per tenant against that tenant's own recent baseline. Rolled up hourly, it looks like this:&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="c1"&gt;-- hourly spend per tenant vs its trailing 7-day median&lt;/span&gt;
&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;hourly&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="n"&gt;date_trunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'hour'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ts&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;hr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;calls&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;cost_per_call&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;spend&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;llm_calls&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;ts&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'8 days'&lt;/span&gt;
  &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="n"&gt;baseline&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="n"&gt;percentile_cont&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;WITHIN&lt;/span&gt; &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="p"&gt;(&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;spend&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;med_spend&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;hourly&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;hr&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;date_trunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'hour'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;      &lt;span class="c1"&gt;-- exclude current window&lt;/span&gt;
  &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;spend&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;med_spend&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;spend&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="k"&gt;nullif&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;med_spend&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;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;AS&lt;/span&gt; &lt;span class="n"&gt;ratio&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;hourly&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;baseline&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;date_trunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'hour'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;spend&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;med_spend&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;            &lt;span class="c1"&gt;-- 3x its own median&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;spend&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;                          &lt;span class="c1"&gt;-- ignore trivial tenants&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;ratio&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two guards matter. Compare a tenant to itself, not to the fleet (a big customer is allowed to be big, they just are not allowed to suddenly 70x). And put a floor on absolute spend so you do not page at 3am because a tiny tenant went from 4 cents to 15. On our data, this alert would have fired inside the first hour of the loop bug, at a ratio near 70x, six days before finance did.&lt;/p&gt;

&lt;p&gt;I also wired a projection: current month-to-date spend, extrapolated to month end, compared against budget. That is the check that would have caught it even if the per-tenant alert had a gap. It is cheap and it maps directly to the number the business actually cares about.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd page on
&lt;/h2&gt;

&lt;p&gt;This is the dashboard and alert set I now run for anything that spends money per request. If you take one thing, take the fact that none of these are latency or error signals. Cost needs its own alerts, the same way latency and errors do.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Per-tenant spend rate. Page when any tenant's hourly spend exceeds 3x its trailing 7-day median, with a floor (we use $5/hour) so trivial tenants stay quiet. (The current hour is partial, so this catches a 60-70x spike fast and lags a subtle drift; pair it with the projection below.)&lt;/li&gt;
&lt;li&gt;Top-tenant concentration. Page if a single non-whitelisted tenant crosses 40% of total hourly spend. One customer owning the bill is an incident until proven otherwise.&lt;/li&gt;
&lt;li&gt;Calls/min per (tenant, feature). Page on step changes (greater than 5x hour over hour). This catches the loop before the dollars pile up, because request-count moves before the invoice does.&lt;/li&gt;
&lt;li&gt;Cost per call, p99, per feature. Page when it climbs. Rising cost per call with flat volume means prompt or context bloat, a different leak with the same symptom.&lt;/li&gt;
&lt;li&gt;Month-end spend projection vs budget. Page if the linear projection exceeds budget by more than 15%. This is the backstop that speaks finance's language.&lt;/li&gt;
&lt;li&gt;New entrants in the top-10 spenders. Not a page, a daily digest. A (tenant, feature) pair you have never seen in the top 10 is worth 30 seconds of a human's attention.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The uncomfortable lesson: a green dashboard is not proof that nothing is wrong. It is proof that nothing you decided to measure is wrong. I measured latency and errors because those page loudly and customers complain. Nobody complains about a bill that is quietly too high, so nobody measured it, so it ran for six days.&lt;/p&gt;

</description>
      <category>sre</category>
      <category>observability</category>
      <category>llm</category>
      <category>finops</category>
    </item>
    <item>
      <title>Our LLM service had no backpressure. The provider got 7x slower and our p99 got 25x worse.</title>
      <dc:creator>Jasmine Park</dc:creator>
      <pubDate>Fri, 17 Jul 2026 19:21:07 +0000</pubDate>
      <link>https://dev.to/jasmine_park_dev/our-llm-service-had-no-backpressure-the-provider-got-7x-slower-and-our-p99-got-25x-worse-4hc6</link>
      <guid>https://dev.to/jasmine_park_dev/our-llm-service-had-no-backpressure-the-provider-got-7x-slower-and-our-p99-got-25x-worse-4hc6</guid>
      <description>&lt;p&gt;TL;DR. Our summarization endpoint holds a p99 under 3 seconds. On 21/05 our provider degraded: median call time went from 620ms to about 4.3 seconds, roughly 7x. Our p99 went to 35 seconds, roughly 25x, and it stayed there for 52 minutes. The provider was slow for 12 of those minutes. The other 40 were us draining a queue we had never bounded and could not see. Every arrival became an in-flight task, in-flight peaked near 9,000 across 12 pods, and the latency budget went on queue wait rather than on the provider. CPU sat at 7%, so the autoscaler never moved: the workers were I/O-bound, parked on a socket, burning no CPU at all. More pods were never the answer. What worked: a bounded queue, a concurrency semaphore, a wait budget that drops requests whose caller already left, and two metrics where we previously had one. Queue wait and provider latency are different numbers. Report them as one and you will blame your provider for your own queue.&lt;/p&gt;

&lt;p&gt;Our summarization endpoint has one SLO anyone cares about: p99 under 3 seconds, 99% of the month. It held for two quarters. On 21/05 it missed for 52 minutes and burned about 13% of a 28-day error budget in an afternoon.&lt;/p&gt;

&lt;p&gt;The provider was slow for 12 minutes.&lt;/p&gt;

&lt;p&gt;That gap is the whole post. A 12-minute problem upstream became a 52-minute problem for us, and the extra 40 minutes were self-inflicted. All numbers here are ours, rounded, from our incident. The code at the bottom runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of it
&lt;/h2&gt;

&lt;p&gt;14:14, latency alert: p99 over 3s for five minutes. I opened the provider's status page, which was green, and our dashboard, which said p99 31s and climbing.&lt;/p&gt;

&lt;p&gt;Nothing had deployed since 09:40. Error rate was 0.02%, which is normal. The endpoint was returning 200s. Slowly, but returning them.&lt;/p&gt;

&lt;p&gt;Our provider-latency graph had gone from 620ms median to about 4.3s. Real degradation, and 7x. I filed a ticket with them and started the incident note with "upstream provider degradation" as the cause, because that is exactly what it looked like.&lt;/p&gt;

&lt;p&gt;Then the arithmetic stopped working.&lt;/p&gt;

&lt;p&gt;7x on a 620ms call gets you 4.3s. Our p99 was 35s. Even if every request paid the full degraded latency twice, that is 9s, not 35. Something was adding twenty-five seconds that was not the provider.&lt;/p&gt;

&lt;p&gt;At 14:24 the provider recovered. Median went back to 640ms. I watched our p99 and it did not move. It sat at 35s for another forty minutes, on a healthy provider, with a green status page, while the alert kept firing.&lt;/p&gt;

&lt;p&gt;At 15:06 I restarted the deployment. That dropped every queued request on the floor, and p99 was back under 3s within ninety seconds. A rolling restart is a crude, indiscriminate load-shed, and it is the only thing that ended the incident. I did not enjoy learning that.&lt;/p&gt;

&lt;p&gt;A service still broken forty minutes after its dependency is healthy is not suffering from its dependency. It is suffering from what it did while its dependency was unhealthy. We had built a backlog, and the backlog had to clear before anybody got a fast answer. The provider's 12 bad minutes bought us 40 of our own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the autoscaler sat still
&lt;/h2&gt;

&lt;p&gt;First thing I checked was the HPA. CPU: 7%. Target: 70%. Nothing was computing, so the autoscaler had never had a reason to act and by its own logic it was right: every pod was sitting on await, holding a socket open to a provider taking four seconds to answer.&lt;/p&gt;

&lt;p&gt;I wrote up the general version of this a couple of weeks ago, the week we found our autoscaler tracking request rate while the bill tracked tokens, so I will spare you the re-derivation and give you the line that transfers: scaling on a signal your incident cannot move is the same as not scaling at all. Here it would also have hurt, because more pods means more concurrent calls into a provider that was already saturating for us.&lt;/p&gt;

&lt;p&gt;The saturation signal for an I/O-bound LLM service is in-flight requests against a concurrency limit, and queue depth against a queue bound. We had neither number, because we had neither limit.&lt;/p&gt;

&lt;h2&gt;
  
  
  The queue I could not see
&lt;/h2&gt;

&lt;p&gt;Here is what I shipped, simplified to the shape that matters:&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="nd"&gt;@app.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;/summarize&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;summarize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;body&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;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&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="k"&gt;await&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="n"&gt;PROVIDER_URL&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="nf"&gt;build&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;   &lt;span class="c1"&gt;# httpx.AsyncClient
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No queue in that code. That is the problem: there is no queue in that code &lt;em&gt;that I can name, measure, or bound&lt;/em&gt;. There are three, and I wrote none of them.&lt;/p&gt;

&lt;p&gt;The event loop's task list is the first. Uvicorn accepts a connection, schedules a coroutine. At 40 requests per second with each parked for 4.3 seconds, you are holding about 172 at once. Nothing in that handler stops the number growing.&lt;/p&gt;

&lt;p&gt;The second lives inside httpx. A default AsyncClient carries Limits(max_connections=100, max_keepalive_connections=20) (&lt;a href="https://www.python-httpx.org/advanced/resource-limits/" rel="noopener noreferrer"&gt;documented here&lt;/a&gt;; I also checked httpx._config.DEFAULT_LIMITS on 0.28.1 to be sure). The 101st concurrent request does not fail and does not reach the provider. It waits for a free connection, inside the pool, with no metric on it. We were timing await client.post(...), which includes that wait. So our "provider latency" graph was never measuring the provider. It measured the provider plus however long we sat in our own connection pool, and during the incident the second term dominated.&lt;/p&gt;

&lt;p&gt;Third is the kernel accept queue, which I will not pretend I looked at.&lt;/p&gt;

&lt;p&gt;None of these are bugs. Each is a sensible default doing exactly what it documents. Together, with 40 rps arriving and no admission control anywhere, they add up to a queue that grows without bound and reports itself to you as "the provider is slow."&lt;/p&gt;

&lt;h2&gt;
  
  
  What it cost
&lt;/h2&gt;

&lt;p&gt;Close to nothing on the invoice, so this section is short: the provider bills tokens, and a slow call costs the same as a fast one. Financially this incident was a rounding error. That is precisely why it ran for 52 minutes without anyone escalating on cost.&lt;/p&gt;

&lt;p&gt;It was expensive in the budget that applied. At 40 rps, a 28-day window is roughly 97 million requests, so a 99% latency SLO permits about 970,000 breaches of the 3-second line. We put roughly 125,000 requests over it in 52 minutes: about 13% of a month's error budget, in under an hour, caused by a dependency that was unhealthy for 12 minutes.&lt;/p&gt;

&lt;p&gt;Around 100,000 of those had already timed out at 30 seconds and gone. We computed responses in full and wrote them to closed sockets.&lt;/p&gt;

&lt;h2&gt;
  
  
  Little's Law, and the number I did not have
&lt;/h2&gt;

&lt;p&gt;The arithmetic that explains all of this is 65 years old and fits on one line.&lt;/p&gt;

&lt;p&gt;L = λW. The average number of items in a queuing system equals the average arrival rate times the average time each item spends in it. John Little published the proof in 1961 (&lt;em&gt;A Proof for the Queuing Formula: L = λW&lt;/em&gt;, Operations Research 9(3), 383-387) and wrote a genuinely readable retrospective on its fiftieth anniversary: &lt;a href="https://people.cs.umass.edu/~emery/classes/cmpsci691st/readings/OS/Littles-Law-50-Years-Later.pdf" rel="noopener noreferrer"&gt;Little's Law as Viewed on Its 50th Anniversary&lt;/a&gt;, Operations Research 59(3), 2011. It assumes almost nothing. No distribution, no independence, no particular queue discipline, and it holds when arrivals are nonstationary, which is exactly what an incident is. It describes your service whether or not you have thought about it.&lt;/p&gt;

&lt;p&gt;Healthy: λ = 40 rps, W = 0.62s, so L = 25 concurrent calls. Comfortable. It is why nobody had ever needed a concurrency limit to exist.&lt;/p&gt;

&lt;p&gt;Degraded: W = 4.3s, so holding 40 rps requires L = 172 concurrent calls. Twelve pods at 100 pooled connections each gave us room for 1,200, so we could physically open 172. We did. And that is what did the damage, because the provider would not serve 172 of our calls at once. Their effective throughput for us fell to about 27 rps.&lt;/p&gt;

&lt;p&gt;Now the law runs the wrong way. Arrivals 40. Departures 27. The queue grows at 13 per second, 780 per minute, and over 12 minutes that is about 9,400. We measured a peak near 9,000 in-flight. That agreement is the only reason I trust the reconstruction at all.&lt;/p&gt;

&lt;p&gt;Then invert it. L = 9,000, λ = 40, so W = L/λ = 225 seconds. Our clients time out at 30. At the peak we were producing answers that were, on average, 195 seconds too late to be wanted, and we kept producing them for forty minutes after the provider was fine. Nothing recovers from that on its own, which is why a restart was the only lever left.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: a queue you can name
&lt;/h2&gt;

&lt;p&gt;More pods would not have helped. A longer timeout is the other reflex, and it is strictly worse: a longer timeout means the caller waits longer before leaving, which raises W, which raises L. Timeouts are not a capacity strategy.&lt;/p&gt;

&lt;p&gt;Four things went in that week. Roughly in the order I would put them back:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A concurrency semaphore. A hard cap on simultaneous provider calls, set below the point where the provider's latency curve bends. This is the one that stops 172 from ever happening. (&lt;a href="https://docs.python.org/3/library/asyncio-sync.html" rel="noopener noreferrer"&gt;asyncio.Semaphore&lt;/a&gt; is the whole implementation.)&lt;/li&gt;
&lt;li&gt;A bounded queue with fail-fast admission. Past the bound, refuse immediately: 429 plus Retry-After. That is the same contract as the token-budget admission gate from that same write-up, moved down a layer and re-denominated. That one bounded tokens in flight, because tokens were what saturated the GPU. This one bounds calls in flight, because concurrency is what saturates a provider you do not own. asyncio.Queue(maxsize=N) raises QueueFull from put_nowait the moment it is full, which is the behaviour you want (&lt;a href="https://docs.python.org/3/library/asyncio-queue.html" rel="noopener noreferrer"&gt;docs&lt;/a&gt;). A 429 in one millisecond is a kinder answer than a 200 in 225 seconds.&lt;/li&gt;
&lt;li&gt;A wait budget. When a worker picks a job up, check how long it sat. If it sat longer than the caller will wait, drop it and never call the provider. Calling on behalf of someone who has already hung up spends provider capacity that the callers still waiting need.&lt;/li&gt;
&lt;li&gt;Two timers instead of one. One extra perf_counter() call. Cheapest thing on this list and the reason I understood any of the rest, which is why I have given it room further down rather than a bullet.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The whole thing, runnable, no dependencies:&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="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Bounded concurrency + admission control for LLM calls.  python3 pool.py&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&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;dataclasses&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;dataclass&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;field&lt;/span&gt;

&lt;span class="n"&gt;CONCURRENCY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;    &lt;span class="c1"&gt;# simultaneous provider calls
&lt;/span&gt;&lt;span class="n"&gt;QUEUE_MAX&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;11&lt;/span&gt;     &lt;span class="c1"&gt;# admission buffer: drain rate x WAIT_BUDGET, not a vibe
&lt;/span&gt;&lt;span class="n"&gt;WAIT_BUDGET&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;2.0&lt;/span&gt;  &lt;span class="c1"&gt;# sec. waited longer than this and the caller is gone
&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Shed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Queue full at admission. -&amp;gt; 429 + Retry-After.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Stale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Waited past budget. -&amp;gt; 503. Never reaches the provider.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;


&lt;span class="nd"&gt;@dataclass&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Job&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;rid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;
    &lt;span class="n"&gt;fut&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Future&lt;/span&gt;
    &lt;span class="n"&gt;enqueued_at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default_factory&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="n"&gt;perf_counter&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;BoundedPool&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;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;concurrency&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;CONCURRENCY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;queue_max&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;QUEUE_MAX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                 &lt;span class="n"&gt;wait_budget&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;WAIT_BUDGET&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wait_budget&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;wait_budget&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;concurrency&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Queue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;maxsize&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;queue_max&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sem&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Semaphore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;concurrency&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wait_ms&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;provider_ms&lt;/span&gt; &lt;span class="o"&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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&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;__aenter__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_workers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_worker&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_n&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;self&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;__aexit__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&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;for&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_workers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cancel&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;gather&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_workers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;return_exceptions&lt;/span&gt;&lt;span class="o"&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;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;submit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rid&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;job&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Job&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_running_loop&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;create_future&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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put_nowait&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;        &lt;span class="c1"&gt;# QueueFull once maxsize is reached
&lt;/span&gt;        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;QueueFull&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shed&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;Shed&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;req &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;rid&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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;qsize&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; already queued, refusing&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="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fut&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;_worker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;job&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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&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="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sem&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                    &lt;span class="c1"&gt;# Everything above this line is our fault, not theirs.
&lt;/span&gt;                    &lt;span class="n"&gt;waited&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;perf_counter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;enqueued_at&lt;/span&gt;
                    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wait_ms&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;waited&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&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;waited&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wait_budget&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stale&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
                        &lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fut&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_exception&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Stale&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;req &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rid&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;waited&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
                        &lt;span class="k"&gt;continue&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;perf_counter&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;result&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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rid&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;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                        &lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fut&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_exception&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
                        &lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fut&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_result&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)&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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;provider_ms&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;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perf_counter&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="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;task_done&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;pct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;xs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;xs&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;s&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;int&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;p&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="o"&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;s&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="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;


&lt;span class="c1"&gt;# A stand-in provider with a real ceiling: serves 12 at a time, never errors,
# and goes 7x slower two seconds in. Swap in your own client here.
&lt;/span&gt;&lt;span class="n"&gt;_sem&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;START&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.0&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;provider&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rid&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;with&lt;/span&gt; &lt;span class="n"&gt;_sem&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1.40&lt;/span&gt; &lt;span class="k"&gt;if&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;perf_counter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;START&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;2.0&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mf"&gt;0.20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&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;resp-&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;rid&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&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;main&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;global&lt;/span&gt; &lt;span class="n"&gt;_sem&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;START&lt;/span&gt;
    &lt;span class="n"&gt;_sem&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;START&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Semaphore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&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;perf_counter&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;with&lt;/span&gt; &lt;span class="nc"&gt;BoundedPool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;provider&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;pool&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;one&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rid&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="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;submit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="nf"&gt;except &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Shed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Stale&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                &lt;span class="k"&gt;pass&lt;/span&gt;  &lt;span class="c1"&gt;# in a request handler, return 429 / 503 here
&lt;/span&gt;
        &lt;span class="n"&gt;tasks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;rid&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;tasks&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;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;one&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rid&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# 40 rps of arrivals
&lt;/span&gt;        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;gather&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;return_exceptions&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="nf"&gt;print&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;served=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ok&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  shed=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shed&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  stale=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stale&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="nf"&gt;print&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;queue wait  p50=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;pct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wait_ms&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;6.0&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;ms  p99=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;pct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wait_ms&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;99&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;6.0&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;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;print&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;provider    p50=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;pct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;provider_ms&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;6.0&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;ms  p99=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;pct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;provider_ms&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;99&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;6.0&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;ms&lt;/span&gt;&lt;span class="sh"&gt;"&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;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The stand-in provider has a ceiling (12 concurrent) and goes 7x slower two seconds in. It is not our incident in miniature, and I want to be precise about the gap: the stand-in never recovers, where ours did after 12 minutes and the backlog outlived it by forty. So the demo shows a backlog outliving its arrivals. It does not show one outliving the degradation that caused it, and that second thing is what made 21/05 confusing enough that restarting pods looked like the only lever. On my machine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;served=136  shed=255  stale=9
queue wait  p50=     0ms  p99=  2574ms
provider    p50=   201ms  p99=  1401ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run the same 400 arrivals with no pool, one task per request, the way we had it, and you get this instead, again on my machine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;served=400/400  peak in-flight=263  drained in 40s
end-to-end  p50=  12273ms   p99=  29696ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Compare the provider numbers. The bounded run says the provider's p99 is 1,401ms, which is the truth: 1.4s is literally the constant inside the sleep. The unbounded run says 29,696ms. Same provider, same degradation, and a 21x difference in the number you would paste into a support ticket.&lt;/p&gt;

&lt;p&gt;Watch the drain, too. Arrivals stop at 10 seconds; the unbounded run does not finish until 40. Three quarters of that run is backlog burning down after the last caller has arrived. Ours was forty minutes of drain on twelve minutes of degradation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Timing our wait separately from their call
&lt;/h2&gt;

&lt;p&gt;The highest-leverage line in that file is where the second timer starts.&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;with&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sem&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;waited&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;perf_counter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;enqueued_at&lt;/span&gt;   &lt;span class="c1"&gt;# ours
&lt;/span&gt;    &lt;span class="bp"&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;perf_counter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;result&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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                &lt;span class="c1"&gt;# theirs
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two timers, split at the moment we actually begin talking to the provider. Everything before is our queueing: admission, queue, semaphore. Everything after is theirs.&lt;/p&gt;

&lt;p&gt;We had been exporting one histogram, llm_request_duration_seconds, wrapped around the whole handler. That histogram is worse than having none, because it is confidently wrong. It read 35 seconds while pointing at a provider that was answering in 4.3. I took that graph into a support ticket and asked a vendor to explain a number my own service had manufactured. That is embarrassing, and one extra perf_counter() call would have prevented it.&lt;/p&gt;

&lt;p&gt;We now export three: llm_queue_wait_seconds, llm_provider_duration_seconds, and end-to-end. The first two should roughly sum to the third, and when they stop summing, the difference is time being spent somewhere neither timer covers. That divergence is its own signal.&lt;/p&gt;

&lt;p&gt;The rule I would hand my past self: if a request waits inside your process before you do the thing, that wait is a metric. Any queue you are not measuring is unbounded, because you cannot bound what you cannot see.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it misses at scale
&lt;/h2&gt;

&lt;p&gt;Four things still wrong, in descending order of how much they bother me.&lt;/p&gt;

&lt;p&gt;I cannot defend the queue depth. I derived QUEUE_MAX = 11 from Little's Law: useful depth = drain rate x wait budget, and the degraded drain rate is 8 permits / 1.4s = 5.7 rps, so 5.7 x 2.0 ≈ 11. But drain rate is not a constant, so the derivation is thinner than it looks. Healthy, it is 8/0.2 = 40 rps, so the right depth would be 80. A fixed number is wrong in one direction at all times. I ran the demo at both depths, several times each, on my machine: served never moved outside noise (135 to 141 at depth 32, 131 to 141 at depth 11) while stale dropped from the seventies to around ten. Your absolute numbers will move with load; the gap between the two columns does not. Depth buys you no throughput whatsoever. It only decides whether you refuse a request in one millisecond or waste two seconds of its life first. The wait budget is what actually protects the caller. The depth only decides how much memory the backlog is allowed to occupy while it waits.&lt;/p&gt;

&lt;p&gt;The SRE book disagrees with my ratio and may well be right. Chapter 22 of the Google SRE book, &lt;a href="https://sre.google/sre-book/addressing-cascading-failures/" rel="noopener noreferrer"&gt;Addressing Cascading Failures&lt;/a&gt;, recommends keeping queue size small relative to pool size, on the order of 50% or less, so that a server rejects early under sustained overload. Mine is 11 against 8 permits, about 1.4x. I picked that deliberately for bursty arrivals: a short burst that clears inside the wait budget should be absorbed rather than refused. If your traffic is steady rather than bursty, take their ratio over mine.&lt;/p&gt;

&lt;p&gt;The concurrency limit is static. Ours is a number I chose by watching where the provider's latency curve bends, then re-chose twice. The honest version measures it continuously, because a provider's capacity for you is neither constant nor published. That is real work, and we have not done it.&lt;/p&gt;

&lt;p&gt;Shedding is not free, and the demo is blunt about the bill: 255 shed, 9 stale, 136 served out of 400. A 66% rejection rate. No amount of engineering fixes that, because a pool draining at 5.7 rps cannot absorb 40 rps of arrivals. Physics gets a vote. The only decision available is who finds out, and how quickly. Shedding does not rescue the requests it drops. It moves the failure to a moment you picked in advance, while you can still answer in one millisecond with a status code the caller can act on. If you read one thing before this post, read &lt;a href="https://sre.google/sre-book/handling-overload/" rel="noopener noreferrer"&gt;Handling Overload&lt;/a&gt;, chapter 21 of the same book.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd page on
&lt;/h2&gt;

&lt;p&gt;I had four dashboards for this service on 21/05. Every one of them was green while the SLO burned, because all four watched CPU, error rate, request count, and provider latency: the two that cannot see queueing, and two that actively lie about it. Here is what replaced them. Copy the metrics. The thresholds are ours.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Queue wait p99, alone, as its own series. Page when it exceeds half the wait budget. Not end-to-end, not provider latency: the wait, by itself. Had this existed on 21/05 it would have read 200 seconds while the provider graph read 4.3, and nobody would have spent nineteen minutes reading a status page.&lt;/li&gt;
&lt;li&gt;Shed rate and stale rate, separately, never summed. They mean opposite things. Shed climbing means admission control is working and arrivals exceed capacity: expected, page only if sustained past five minutes. Stale climbing means we admitted work we could not finish in time, which is an admission-control bug, and the queue is too deep for the budget. That distinction is what QUEUE_MAX = 11 is for, and stale is the metric that proves the number is right.&lt;/li&gt;
&lt;li&gt;Provider latency timed around the call and nothing else. Not the handler. Not the pool acquire. The call. This is the only number worth taking to a vendor, and until 21/05 we did not have it, which is why my support ticket was fiction.&lt;/li&gt;
&lt;li&gt;Backlog drain time, derived: queue depth divided by observed drain rate. L/λ, on a graph. It answers "if arrivals stopped right now, how long until we are clear," and it is the number that tells you a restart is the only remaining lever, roughly forty minutes before you work it out by hand.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I have written before about a green dashboard hiding a real problem, so rather than run that argument again I will mark what is different here. That one was a bill: every run passed, nothing errored, and the damage showed up on an invoice. This one is time. Our error rate on 21/05 sat at 0.02%, normal for us, and the provider's status page stayed green from the first alert to the last. Neither number was wrong. Neither number was about where the 195 seconds went, because we were measuring how much time a request took and never which queue it spent it in.&lt;/p&gt;

&lt;p&gt;Three questions decide whether you are running my 21/05 service. What happens to the 173rd concurrent call? How long did the last request sit before we dialed anyone? If arrivals stopped this second, when would the backlog be clear? We could not answer any of the three, and it took a provider having a bad twelve minutes to show us.&lt;/p&gt;

</description>
      <category>sre</category>
      <category>devops</category>
      <category>performance</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Self-hosted LLM observability: six stacks, weighed by what they cost you to run</title>
      <dc:creator>Jasmine Park</dc:creator>
      <pubDate>Tue, 14 Jul 2026 21:24:23 +0000</pubDate>
      <link>https://dev.to/jasmine_park_dev/self-hosted-llm-observability-six-stacks-weighed-by-what-they-cost-you-to-run-55m5</link>
      <guid>https://dev.to/jasmine_park_dev/self-hosted-llm-observability-six-stacks-weighed-by-what-they-cost-you-to-run-55m5</guid>
      <description>&lt;p&gt;TL;DR: If your LLM traffic sends personal data, or your obs invoice scales with token volume, self-hosting the trace pipeline is worth costing out. Storage is the cheap part (roughly 60 GB and about 18 dollars a month for a million spans a day at 30-day retention). The expensive part is that you now run it: retention, cardinality, ingest lag, and the pager. Here are six open-source stacks you can run yourself, what each is actually good at, and what each costs you to operate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why self-host at all
&lt;/h2&gt;

&lt;p&gt;Two reasons show up in real incidents. The first is data residency: if a trace payload contains a prompt with a customer's PII, sending it to someone else's cloud is a compliance conversation you do not want to have after the fact. Self-hosting keeps the payload inside your VPC. The second is cost shape. Managed LLM observability tends to price on events or token volume, so the invoice grows exactly when your product succeeds. I inherited a managed bill that had quietly tripled over two quarters because traffic tripled. We moved the high-volume traces in-house. The invoice went down. The number of dashboards I now own went up.&lt;/p&gt;

&lt;p&gt;That is the trade. You are swapping a predictable invoice for operational surface area. Before you make it, cost out the operational side honestly, because "open-source" is a license, not a free lunch. The question is never "is it free," it is "what does it take to keep it green at 3x the traffic."&lt;/p&gt;

&lt;h2&gt;
  
  
  What "self-hostable" has to mean here
&lt;/h2&gt;

&lt;p&gt;I only included stacks you can actually run on your own infrastructure without a sales call, that speak a standard trace format (most of these are built on OpenTelemetry), and that a small team can operate. Every option below is open-source and self-hostable today. I have put them in no particular ranking order, because the right pick depends on whether you want pure tracing, a full platform, or just instrumentation you point at a backend you already run.&lt;/p&gt;

&lt;h2&gt;
  
  
  The six stacks
&lt;/h2&gt;

&lt;p&gt;Langfuse. The one most teams reach for first. It is open-source and self-hostable, and it combines tracing with evals and prompt management, so it is more than a trace viewer. Strong at the prompt-iteration loop: versioned prompts, scores, and a clean trace UI. What it costs to run: a Postgres plus a ClickHouse-backed deployment for the self-hosted stack, which is a real database to operate at volume. What it can miss at scale: high-cardinality trace attributes will punish your storage if you do not sample. github.com/langfuse/langfuse&lt;/p&gt;

&lt;p&gt;Arize Phoenix. Open-source, self-hostable, and built on OpenTelemetry. Like Langfuse it is more than tracing: it pairs trace collection with an evaluation layer, and it is notebook-friendly, which makes it strong for offline analysis and debugging a RAG pipeline span by span. What it costs to run: light to stand up for a single team, heavier once you want durable multi-tenant storage rather than an analysis session. What it can miss at scale: it started life analysis-first, so treat the production, always-on deployment as the part you validate under load. github.com/Arize-ai/phoenix&lt;/p&gt;

&lt;p&gt;Helicone. Proxy-first. You route model calls through it and get logging, cost, latency, and caching almost immediately, which is the fastest path to a spend dashboard. Open-source and self-hostable. What it costs to run: low to start, but a proxy in the request path is now a thing on your critical path, so its availability is your availability. What it can miss at scale: proxy-based capture is excellent for cost and latency and less focused on deep multi-span agent traces than the OTel-native tools. github.com/Helicone/helicone&lt;/p&gt;

&lt;p&gt;Future AGI. Open-source and self-hostable, and OpenTelemetry-native for tracing through its traceAI framework. It sits at the platform end of this list rather than the pure-tracing end: the same stack also carries evaluation, simulation, and a model gateway, so the draw is running one self-hosted system instead of several. Honest placement: it is younger at pure tracing than Langfuse or Phoenix, so if all you want is the most battle-tested trace viewer, it is not your first pick. What it costs to run: a full platform, so you operate more surface than a single-purpose tracer. What it can miss at scale: validate the tracing path at your volume before you retire the incumbent. github.com/future-agi/future-agi&lt;/p&gt;

&lt;p&gt;SigNoz. The general-purpose APM option. It is open-source, self-hostable, and OpenTelemetry-native, and it treats LLM spans as spans inside your broader application traces. Strong if you already want one observability backend for services and models rather than an LLM-only tool. What it costs to run: a ClickHouse-backed APM, which your infra team may already know how to operate, which is a point in its favor. What it can miss at scale: it is not LLM-specialized, so prompt-level ergonomics (diffing prompt versions, judge scores) are not its focus. github.com/SigNoz/signoz&lt;/p&gt;

&lt;p&gt;Traceloop OpenLLMetry. Not a backend at all, and that is the point. It is an open-source set of OpenTelemetry instrumentations for LLM apps that you point at whatever OTel-compatible backend you already run. Strong if you have an observability stack and just want your model calls to show up in it without vendor lock-in on the storage side. What it costs to run: almost nothing on its own, because it is instrumentation, but you still need a backend, so its true cost is whatever you send the data to. What it can miss at scale: it gives you spans, not opinions, so the eval and prompt-management layers are on you. github.com/traceloop/openllmetry&lt;/p&gt;

&lt;h2&gt;
  
  
  Cost out the storage before the on-call
&lt;/h2&gt;

&lt;p&gt;The invoice you escaped was the easy number. Here is the one people skip. This is a rough hot-storage estimate, not a full TCO, but it anchors the conversation.&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;trace_storage_gb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;spans_per_day&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bytes_per_span&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;retention_days&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;spans_per_day&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;bytes_per_span&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;retention_days&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;1e9&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;monthly_cost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;spans_per_day&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bytes_per_span&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;retention_days&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                 &lt;span class="n"&gt;usd_per_gb_month&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;replication&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;gb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;trace_storage_gb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;spans_per_day&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bytes_per_span&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;retention_days&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;gb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;gb&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;usd_per_gb_month&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;replication&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;spd&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1_000_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;20_000_000&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;gb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cost&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;monthly_cost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;spd&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;print&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="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;spd&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; spans/day -&amp;gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;gb&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;8.1&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; GB hot, ~$&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;cost&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;7.2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/mo storage (x3 repl)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   1,000,000 spans/day -&amp;gt;     60.0 GB hot, ~$  18.00/mo storage (x3 repl)
  20,000,000 spans/day -&amp;gt;   1200.0 GB hot, ~$ 360.00/mo storage (x3 repl)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Storage is cheap. Eighteen dollars a month for a million spans a day is not what makes self-hosting expensive. What makes it expensive is the ClickHouse or Postgres you are now running, the retention job you have to get right, the cardinality that blows up when someone puts a UUID in a span attribute, and the fact that when ingest lags, that is your page. Plug your real numbers in. If the storage line is trivial and the operator line is not, you have learned the actual shape of the decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd page on
&lt;/h2&gt;

&lt;p&gt;If you self-host any of these, do not page on "the dashboard is down." Page on the things that mean you are silently losing data:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Ingest lag over threshold (your traces are minutes behind reality, so every incident you debug is stale).&lt;/li&gt;
&lt;li&gt;Dropped or refused spans greater than zero (the exporter queue is full, and you are blind to exactly the traffic spike you most need to see).&lt;/li&gt;
&lt;li&gt;Storage growth rate outrunning your retention budget, or trace attribute cardinality spiking (a UUID landed in a label, and your index is about to hurt).&lt;/li&gt;
&lt;li&gt;Sampling rate drift (someone changed the sampler, and your tail-latency traces quietly stopped being collected).&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>observability</category>
      <category>opensource</category>
      <category>sre</category>
      <category>opentelemetry</category>
    </item>
    <item>
      <title>A $3,900 overnight bill from our LLM eval suite: the incident, and the spend guard I shipped after</title>
      <dc:creator>Jasmine Park</dc:creator>
      <pubDate>Tue, 14 Jul 2026 21:21:13 +0000</pubDate>
      <link>https://dev.to/jasmine_park_dev/a-3900-overnight-bill-from-our-llm-eval-suite-the-incident-and-the-spend-guard-i-shipped-after-j09</link>
      <guid>https://dev.to/jasmine_park_dev/a-3900-overnight-bill-from-our-llm-eval-suite-the-incident-and-the-spend-guard-i-shipped-after-j09</guid>
      <description>&lt;p&gt;TL;DR. Our LLM-judge eval suite had no cost ceiling. It ran the full judge over 1,200 cases on every CI trigger. On 08/07 a dependency bot opened 41 pull requests between 01:00 and 04:00, and our merge queue re-ran the whole suite on every push and every rebase: roughly 270 full runs at about $14.40 each. That one window cost $3,900 against a $1,730 monthly eval budget. No dashboard fired, because ours watched request rate and 5xx, not tokens or dollars. We found out from the invoice, because nothing we monitored watched spend. What I shipped was four guards under the suite: a pre-flight cost cap (estimate tokens with tiktoken, multiply by price, refuse the run if it would breach a daily ceiling), a result cache keyed on the candidate answer, sampling on non-main branches, and an alert on token-spend rate. Code is below.&lt;/p&gt;

&lt;p&gt;I run reliability for a small ML platform team. We ship an LLM feature and gate it with an offline eval suite: 1,200 graded cases, each scored by a separate judge model against a rubric. Standard setup. It has caught real regressions. It also carried, for four months, a failure mode I built and did not see until it cost us most of a monthly budget in nine hours.&lt;/p&gt;

&lt;p&gt;This is the writeup. Numbers are from our incident, rounded. The prices are the ones we paid at the time, not a benchmark.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I found out
&lt;/h2&gt;

&lt;p&gt;The first signal came in as an email.&lt;/p&gt;

&lt;p&gt;At 09:40 the next morning, finance forwarded a cost-anomaly notice from our model provider: the prior day's spend on one API key was 68x its trailing average. My first reaction was that the anomaly detector was wrong. We had shipped nothing overnight. No incident channel, no 5xx, no latency alarm. Green board.&lt;/p&gt;

&lt;p&gt;Then I read which key. It was ci-eval, not prod. I had no dashboard for ci-eval, because eval traffic never paged anyone, so I had never built one. I pulled the provider's usage export for that key and sorted by hour. Between 01:00 and 04:00 it had billed just over a billion tokens. A normal day for that key is about 14 million.&lt;/p&gt;

&lt;p&gt;I checked the deploy log twice, expecting a runaway retry loop or a stuck worker hammering the API. There was neither. The requests were clean, sequential, and successful. Whatever had done this had done it deliberately, at 200 OK, which meant the provider was behaving correctly and the bug was in my own cost arithmetic.&lt;/p&gt;

&lt;p&gt;That is how I learned my eval suite had a cost bug: about twelve hours late, from a finance email, in dollars rather than in the tokens or request counts I was actually watching.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it cost
&lt;/h2&gt;

&lt;p&gt;The arithmetic is the whole story, so here it is.&lt;/p&gt;

&lt;p&gt;One judged case, at our rubric size, costs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;input: about 2,600 tokens (rubric, question, candidate answer, reference answer)&lt;/li&gt;
&lt;li&gt;output: about 550 tokens (a score plus a short justification)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;At the prices we paid ($2.50 per million input, $10.00 per million output), that is $0.0065 plus $0.0055, so roughly $0.012 per case. The full suite is 1,200 cases, so one run is 1,200 x $0.012 = $14.40. On a normal day CI fires it three or four times: a couple of merges to main, a manual rerun or two. Call it $57 a day, about $1,730 a month. That budget line had been flat for a quarter. So I stopped looking at it. That was mistake one.&lt;/p&gt;

&lt;p&gt;On the night of 08/07, a dependency bot opened 41 pull requests in three hours. Each PR triggered the full suite on its first push. Each then got a lockfile follow-up commit, which triggered it again. Then the merge queue rebased each PR onto main before merging and ran the suite once more. Six to seven full runs per PR. About 270 runs total.&lt;/p&gt;

&lt;p&gt;270 x $14.40 = $3,888. Just over a billion tokens. In one overnight window we spent 2.25x our entire monthly eval budget, and every single run passed. Nothing was broken. That is the part that still bothers me. The suite did exactly what I had configured it to do, 270 times, and nothing malfunctioned. The entire bill came from correct runs firing far more often than they ever should have.&lt;/p&gt;

&lt;p&gt;Split the billion tokens and it maps straight back to the bill: about 842 million input tokens at $2.50 per million is $2,106, and about 178 million output tokens at $10.00 per million is $1,782. Sum $3,888. Output was 17% of the tokens and 46% of the cost, which is worth internalizing. On a rubric-heavy judge, the short justification you ask it to write is nearly half the spend. Trimming the justification moves the bill more than trimming the rubric does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Root cause, and my part in it
&lt;/h2&gt;

&lt;p&gt;Two settings did this. Both were mine.&lt;/p&gt;

&lt;p&gt;Our CI ran the eval job on the GitHub synchronize event, which fires on every push to an open PR. I wrote that trigger in March so a PR's eval status stayed fresh as you pushed fixes. Reasonable for a human pushing three commits to one branch. Pathological for a bot pushing 41 branches with lockfile follow-ups.&lt;/p&gt;

&lt;p&gt;The merge queue had "re-run required checks after rebase" turned on. I enabled that in May, after a stale check let a bad merge through. Also reasonable in isolation. Combined with the synchronize trigger and a bot storm, it meant every PR paid for the full suite at least three times before it even cleared the queue.&lt;/p&gt;

&lt;p&gt;Neither setting is wrong on its own. Together, with no cost ceiling underneath them, they are a spend amplifier. I built both halves, two months apart, and never once did the multiplication. That combination is the actual root cause. The dependency bot was only the trigger: it exercised a gap I had left open, 41 times overnight.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the dashboards missed
&lt;/h2&gt;

&lt;p&gt;Three things were watching this system. All three were structurally blind to a cost spike.&lt;/p&gt;

&lt;p&gt;The provider billing console. Aggregated by calendar day, updated on a lag of roughly 24 hours. At 04:00, while the spend was actually happening, it still showed the previous day's $58. It is accurate but far too slow for a spike that starts and finishes inside one night.&lt;/p&gt;

&lt;p&gt;Our Grafana panels. Built on request count and error rate, scoped to the prod key. Two separate problems. First, count is the wrong unit. 270 eval runs is not a remarkable request volume; one prod minute makes more requests than that. The signal was never in the count, it was in tokens-per-request times price. Second, the panels were scoped to prod, and the runaway was on ci-eval, which had no panel at all.&lt;/p&gt;

&lt;p&gt;The one alert we did have. A 5xx and rate-limit alarm on the provider. It never fired, because the provider was perfectly happy. It served all billion tokens without a single error. This is the trap: a cost runaway is not an error condition. The provider will keep billing at 200 OK and never surface an error for you to alert on.&lt;/p&gt;

&lt;p&gt;So the real failure was not that an alert broke. It is that I had never expressed "dollars per hour on the eval key" as a number that anything watched. I monitored availability and errors, which are what page you at 3am. Spend was only ever reviewed in a monthly budget meeting, so nothing watched it continuously.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Four changes, ordered by how much they mattered.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A pre-flight cost cap. Before any eval run, estimate its token cost with tiktoken, multiply by the price, and refuse to run if it would push the key past a daily ceiling. This is the guard that stops run number nine, not run number 270.
&lt;/li&gt;
&lt;/ol&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;tiktoken&lt;/span&gt;

&lt;span class="c1"&gt;# Judge pricing, USD per 1M tokens (from the provider's public pricing page).
&lt;/span&gt;&lt;span class="n"&gt;PRICE_IN_PER_M&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;2.50&lt;/span&gt;
&lt;span class="n"&gt;PRICE_OUT_PER_M&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;10.00&lt;/span&gt;
&lt;span class="n"&gt;DAILY_BUDGET_USD&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;120.00&lt;/span&gt;  &lt;span class="c1"&gt;# hard ceiling for the ci-eval key
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;encoding_for&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="nb"&gt;str&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="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;tiktoken&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encoding_for_model&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;except&lt;/span&gt; &lt;span class="nb"&gt;KeyError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# Newer judge models aren't in tiktoken's registry yet; fall back.
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;tiktoken&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_encoding&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;o200k_base&lt;/span&gt;&lt;span class="sh"&gt;"&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;projected_cost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&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;est_output_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&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="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;float&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;enc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;encoding_for&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="n"&gt;in_tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&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;enc&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="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;prompts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;out_tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;est_output_tokens&lt;/span&gt; &lt;span class="o"&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;prompts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;in_tokens&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;1e6&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;PRICE_IN_PER_M&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;out_tokens&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;1e6&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;PRICE_OUT_PER_M&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;guard&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;est_output_tokens&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="n"&gt;spent_today&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;run_cost&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;projected_cost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;est_output_tokens&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;if&lt;/span&gt; &lt;span class="n"&gt;spent_today&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;run_cost&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;DAILY_BUDGET_USD&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;SystemExit&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;eval blocked: projected $&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;run_cost&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;,.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; would push today&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s &lt;/span&gt;&lt;span class="sh"&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;ci-eval spend past the $&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;DAILY_BUDGET_USD&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;,.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; ceiling&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;run_cost&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The edge case that bit us while testing this: encoding_for_model raises KeyError for judge models tiktoken has not shipped a mapping for yet. If you let that propagate, the guard crashes, and depending on how you wired it that either blocks all evals or (worse) gets swallowed by a broad except so the cap silently stops applying. Fall back to a known encoding: o200k_base for recent models, cl100k_base for older ones. The estimate is then approximate, which is fine here. This is a budget fuse, not an invoice. tiktoken is the actual tokenizer for this model family and it is open source (github.com/openai/tiktoken), so the estimate lands within a couple of percent of billed tokens in practice.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;A result cache. Key the judge result on (rubric_version, case_id, sha256(candidate_answer)). On a dependency-bump PR, the model under test emits byte-identical answers, so the candidate hash does not change and the judge never runs twice on the same input. Rerunning 270 times over the same 1,200 unchanged candidates should have been about 1,200 judge calls and 322,800 cache hits: a hit rate near 99%. Cost with the cache in place: about $14, paid once.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Sampling off main. The full 1,200-case suite runs on merges to main and in the merge queue's final gate. Every other trigger (feature pushes, draft PRs, bot PRs) runs a fixed 10% stratified sample, 120 cases, about $1.44. You still catch gross regressions on a branch. You stop paying full freight for a lockfile bump.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Spend-rate alerting. This one gets its own section below, because it is the part I most want you to copy.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We shipped all four in two days. The cap and the cache did the heavy lifting. Together they take the worst case of a storm like this from $3,900 down to about $120, because once the ceiling is hit the cap simply stops starting new runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it misses at scale
&lt;/h2&gt;

&lt;p&gt;I am not going to pretend this is finished.&lt;/p&gt;

&lt;p&gt;The pre-flight estimate uses a fixed est_output_tokens. Judges that write longer justifications on hard cases will be under-estimated, so the fuse is loosest on exactly the runs that cost the most. We set the constant to our 90th-percentile output length, which over-charges most runs on purpose. Safer, less precise. That is the trade, and it is deliberate.&lt;/p&gt;

&lt;p&gt;The cache is only correct while the rubric and the judge model are pinned. Bump either one and every cache key changes, so the first run after a judge upgrade pays full price, and 270x full price if the same bot storm lands that same morning. The cap catches that case. The cache does not.&lt;/p&gt;

&lt;p&gt;Sampling trades cost for coverage. A 10% branch sample will miss a regression living in the 90% you skipped, and you only see it at the main-merge gate. For us that is acceptable, because branch evals are advisory and the main gate is the one that blocks release. For a team that ships straight off branches, it is not, and they should not copy the 10%.&lt;/p&gt;

&lt;p&gt;And a daily ceiling is a blunt instrument. Set it too low and you block legitimate work at 16:00 on a heavy release day. Set it too high and it would not have stopped this incident. We landed on 2x a normal day and accept that a genuinely large legitimate day needs a human to raise it by hand. The ongoing work here is tuning that threshold, not writing more code.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd page on
&lt;/h2&gt;

&lt;p&gt;The lesson was not "eval is expensive." It was that I had been monitoring the wrong quantities. I watched request counts and error rates; the failure only ever showed up in tokens and dollars, which nothing tracked. So here is the dashboard I built afterward, and the four things it pages on. Copy the metrics, not the numbers. The numbers are ours.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Token-spend rate, per API key, per hour. The primary signal. Alert when any key crosses 3x its own trailing 7-day hourly median. This is the one that would have paged me at 01:20 instead of routing through finance at 09:40. Per-key matters: ci-eval and prod have different baselines and have to alarm independently, or the loud one masks the quiet one.&lt;/li&gt;
&lt;li&gt;Cost per eval run. Emit it as a metric from the harness itself, tagged by trigger (main, merge-queue, branch, bot). Page if a single run exceeds $20 (our full run is $14.40) or if runs-per-hour exceeds 12. A run-count spike is the earliest sign of a trigger loop, and it shows up before the dollars land in any billing export.&lt;/li&gt;
&lt;li&gt;Budget burn, daily and month-to-date. Track spend against the daily ceiling as a percentage: warn at 70%, page at 100% (by which point the cap has already blocked runs, so the page is really telling a human to decide whether to raise it). Track month-to-date against the $1,730 line and warn at 80% before the 20th, so an expensive first half of the month is visible while there is still time to react.&lt;/li&gt;
&lt;li&gt;Cache hit rate on the judge. A drop below 60% means either a rubric or model change invalidated the cache (expected, transient, no page) or the cache key is broken (not expected, page). On a normal week we sit near 40% from genuinely new candidates. A sudden collapse to single digits during a PR storm is the tell that the cache is not absorbing what it should, which is the early warning I did not have on 08/07.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these watch availability. Availability was fine the entire night. The provider returned 200 to every one of a billion tokens' worth of requests, and that was precisely the problem. Reliability for an LLM system has to include a spend SLO, and a spend SLO needs continuous monitoring rather than a monthly budget review.&lt;/p&gt;

&lt;p&gt;If you run an LLM-judge eval suite and you cannot answer, from a dashboard, right now, "what does one run cost, and what stops it running a thousand times tonight," that is your next on-call ticket. We paid $3,900 to find that out.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>cicd</category>
      <category>llm</category>
      <category>testing</category>
    </item>
    <item>
      <title>We alerted on errors. The silent failure was a truncated answer.</title>
      <dc:creator>Jasmine Park</dc:creator>
      <pubDate>Wed, 08 Jul 2026 18:31:42 +0000</pubDate>
      <link>https://dev.to/jasmine_park_dev/we-alerted-on-errors-the-silent-failure-was-a-truncated-answer-34ll</link>
      <guid>https://dev.to/jasmine_park_dev/we-alerted-on-errors-the-silent-failure-was-a-truncated-answer-34ll</guid>
      <description>&lt;p&gt;Every monitor was green. HTTP 200s, latency inside SLO, zero exceptions. Meanwhile users were getting half an answer and cut-off JSON, and nothing on our side had noticed, because a truncated completion is not an error.&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdalh5j785yjlvt6x481v.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdalh5j785yjlvt6x481v.png" alt=" " width="800" height="387"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The page never fired. That was the whole problem. Support forwarded a handful of complaints that answers were "getting cut off," and I opened the dashboards expecting to see the cause. Error rate flat at zero. Latency well under budget. No exceptions in the logs, no failed calls, no 5xx. By every signal we watched, the service was healthy. The signals were wrong. It was handing back broken output with a 200 stamped on it, and none of our monitors were built to notice.&lt;/p&gt;

&lt;p&gt;What the monitors were watching, and what they missed&lt;/p&gt;

&lt;p&gt;We had inherited the standard web-service monitoring shape: watch HTTP status, watch latency, watch exceptions. That shape assumes failure looks like an error. For a normal API that holds. For an LLM call it does not, because the most common user-facing failure throws nothing at all.&lt;/p&gt;

&lt;p&gt;The failure was truncation. When a completion hits the max_tokens ceiling, the model stops mid-thought and hands back what it had so far. The provider returns a perfectly valid response object with a 200. The only tell is a field in the payload: finish_reason comes back as length instead of stop. No exception, no error code, no latency anomaly. A response that stopped because it ran out of room looks identical, at the transport layer, to one that finished naturally.&lt;/p&gt;

&lt;p&gt;So the answer that got cut in half was a 200. The JSON blob that ended after {"status": "app was a 200. Our completeness was failing and our monitors were structurally blind to it, because they were counting the wrong kind of failure.&lt;/p&gt;

&lt;p&gt;What it cost before we caught it&lt;/p&gt;

&lt;p&gt;This ran for the better part of two weeks before the pattern was clear enough to act on. Best I can reconstruct, somewhere around 3 to 4 percent of completions were truncating, and a big share of those were the long-output ones: multi-step answers, long summaries, and the structured JSON responses another service consumed downstream.&lt;/p&gt;

&lt;p&gt;The JSON case was the expensive one. A downstream service parsed those completions. A truncated blob is invalid JSON, so that service caught a parse exception, swallowed it, and fell back to a default. No page there either. So one silent failure (truncation with a 200) fed a second silent failure (a swallowed parse error and a default value), and the only place reality surfaced was a slow trickle of user complaints. Two weeks of that is a lot of quietly wrong answers.&lt;/p&gt;

&lt;p&gt;Treat finish_reason as a first-class signal&lt;/p&gt;

&lt;p&gt;The fix starts with promoting finish_reason from a field nobody reads to a metric you alert on. Every completion, check why it stopped, and emit that. A rising rate of length finishes is a truncation incident in progress, and it will show up here long before support forwards the first complaint.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;from collections import Counter

_finish_reasons = Counter()

def record_finish_reason(response) -&amp;gt; str:
    # OpenAI-style: response.choices[0].finish_reason
    # "stop" = natural end, "length" = hit max_tokens (truncated), plus others
    reason = response.choices[0].finish_reason or "unknown"
    _finish_reasons[reason] += 1
    return reason

def truncation_rate() -&amp;gt; float:
    total = sum(_finish_reasons.values())
    return _finish_reasons["length"] / total if total else 0.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the signal we never had. truncation_rate() is the number that should have paged us on day one.&lt;/p&gt;

&lt;p&gt;Monitor completeness, not just finish_reason&lt;/p&gt;

&lt;p&gt;Finish reason tells you the model ran out of room. It does not tell you whether the answer the user got was usable. For that, check the output itself. For structured responses, the sharpest signal is whether the payload parses and carries the fields you require. A valid-JSON-parse rate and an expected-fields-present rate turn "the answer looks complete" into a number you can alert on.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;import json

_completeness = {"parse_ok": 0, "parse_fail": 0, "fields_ok": 0, "fields_missing": 0}
REQUIRED_FIELDS = ("status", "summary", "items")

def check_completeness(text: str, structured: bool) -&amp;gt; dict:
    result = {"truncated_json": False, "missing_fields": []}
    if not structured:
        return result
    try:
        obj = json.loads(text)
        _completeness["parse_ok"] += 1
    except json.JSONDecodeError:
        # a cut-off JSON blob lands here: valid 200, unusable payload
        _completeness["parse_fail"] += 1
        result["truncated_json"] = True
        return result
    missing = [f for f in REQUIRED_FIELDS if f not in obj]
    if missing:
        _completeness["fields_missing"] += 1
        result["missing_fields"] = missing
    else:
        _completeness["fields_ok"] += 1
    return result

def json_parse_rate() -&amp;gt; float:
    total = _completeness["parse_ok"] + _completeness["parse_fail"]
    return _completeness["parse_ok"] / total if total else 1.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now a truncated JSON response is not a downstream default swallowed in silence. It is a parse_fail, counted, and it moves a rate you can wake someone up over.&lt;/p&gt;

&lt;p&gt;Set max_tokens from the data, not a guess&lt;/p&gt;

&lt;p&gt;The last piece is the setting that caused it. Our max_tokens was a round number somebody picked once and never revisited. It was too low for the long-output traffic, which is exactly the traffic that truncates. The right value is not a guess, it is the measured distribution of actual output lengths with headroom on top.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;def recommend_max_tokens(observed_output_lengths, headroom=1.2, pct=99):
    # size the ceiling off the p99 real output, not a round number
    s = sorted(observed_output_lengths)
    p99 = s[min(len(s) - 1, int(pct / 100 * len(s)))]
    return int(p99 * headroom)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Feed it a few days of real completion lengths and it hands back a ceiling that clears your p99 output with room to spare, instead of a number that clips your longest and most important answers. Reserving output budget you rarely use costs a little on the concurrency side, so this is a trade to make with eyes open, not a free lever. But clipping real answers costs correctness, and correctness is not a thing to save money on quietly.&lt;/p&gt;

&lt;p&gt;Once truncation rate, JSON parse rate, and expected-fields-present were on the wall, the incident stopped being a support-ticket archaeology exercise. The next time a prompt change pushed outputs longer and truncation started climbing, the rate moved within minutes and we caught it before a single user did.&lt;/p&gt;

&lt;p&gt;What I'd page on&lt;/p&gt;

&lt;p&gt;Different checklist from the token-cost write-up. This one is about a failure that never throws.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Truncation rate (&lt;code&gt;finish_reason == "length"&lt;/code&gt;). The headline. Fraction of completions that stopped because they hit max_tokens. Warn at a low single-digit percent, page on a sharp climb. This fires on the exact failure that green error dashboards hide.&lt;/li&gt;
&lt;li&gt;JSON parse-success rate. For any structured output, the fraction that parses. A drop is truncated or malformed payloads reaching consumers. Page on it, because the downstream service will swallow the failure and you will not hear about it otherwise.&lt;/li&gt;
&lt;li&gt;Expected-fields-present rate. Of the payloads that do parse, the fraction carrying every required field. Catches the answer that parsed but came back half-filled.&lt;/li&gt;
&lt;li&gt;Output-length distribution vs max_tokens. Plot p50 / p95 / p99 output length against the ceiling. When p99 marches toward the ceiling, truncation is about to start. This is the leading indicator.&lt;/li&gt;
&lt;li&gt;Downstream default-fallback rate. How often the consuming service fell back to a default because it could not use the response. A silent failure feeding a silent failure is the worst case, so surface the second one too.&lt;/li&gt;
&lt;li&gt;Completeness by route. Truncation is not uniform. Break the truncation rate out by endpoint or prompt template so the long-output routes, the ones that actually clip, are not averaged into invisibility by the short ones.&lt;/li&gt;
&lt;/ul&gt;

</description>
    </item>
    <item>
      <title>Our cache hit rate was 90 percent and the bill still climbed</title>
      <dc:creator>Jasmine Park</dc:creator>
      <pubDate>Wed, 08 Jul 2026 18:22:36 +0000</pubDate>
      <link>https://dev.to/jasmine_park_dev/our-cache-hit-rate-was-90-percent-and-the-bill-still-climbed-3j5a</link>
      <guid>https://dev.to/jasmine_park_dev/our-cache-hit-rate-was-90-percent-and-the-bill-still-climbed-3j5a</guid>
      <description>&lt;p&gt;The dashboard said we were serving nine of every ten requests from cache. The invoice said we were paying more every week. Both numbers were correct. The hit rate was counting requests and the bill was counting dollars, and those are not the same thing once your traffic is lopsided.&lt;/p&gt;

&lt;p&gt;The response cache went in for exactly the reason you would expect. Repeated prompts were hitting the model over and over, so we keyed on the prompt, stored the completion, and served the stored answer on a match. Within a week the hit-rate panel settled around 90 percent and stayed there. Nice flat line. Everyone moved on.&lt;/p&gt;

&lt;p&gt;Then the monthly spend kept creeping. Not a spike, a slope. Up a bit, up a bit more, up again. The cache was clearly working (90 percent of requests were free), so nobody looked at it as the culprit. That was the mistake. The cache was doing exactly what the panel measured. We had picked the wrong thing to measure.&lt;/p&gt;

&lt;p&gt;What the hit rate was actually counting&lt;/p&gt;

&lt;p&gt;A hit rate is a ratio of requests. Ninety percent hit means nine of ten requests were served from the store and one went to the model. What that number never told us is which requests were in which bucket.&lt;/p&gt;

&lt;p&gt;Our traffic was not uniform. The bulk of it was short, cheap, repetitive calls: the same handful of classification and lookup prompts, hammered thousands of times an hour. Those cached beautifully. They were also nearly free per call. Meanwhile a thin slice of traffic was long-context work, tens of thousands of tokens of document stuffed into the prompt, and those were mostly unique. They missed the cache almost every time.&lt;/p&gt;

&lt;p&gt;So the 90 percent hit rate was 90 percent of the cheap requests and almost none of the expensive ones. The cache was serving the traffic that barely cost anything and passing through the traffic that cost real money. Counted by request, the cache looked like it was carrying us. Counted by dollars spent, it was barely touching the bill. The panel only knew how to count requests, so it never showed us the second number.&lt;/p&gt;

&lt;p&gt;What it actually cost&lt;/p&gt;

&lt;p&gt;Put rough numbers on it. Say a cheap call is 200 tokens and a long-context call is 30,000 tokens, so the expensive one costs roughly 150 times more. If 95 percent of requests are cheap and 5 percent are long-context, and the cheap ones hit cache 94 percent of the time while the long ones hit 8 percent of the time, the request-weighted hit rate still lands around 90 percent. The dollar-weighted hit rate, dollars served from cache over total dollars, was under 15 percent.&lt;/p&gt;

&lt;p&gt;That is the whole gap. Ninety percent of requests free, and we were still on the hook for something like 85 percent of the money. The panel we trusted only counted requests, and requests were not what showed up on the invoice.&lt;/p&gt;

&lt;p&gt;Two more things the cache was quietly doing wrong&lt;/p&gt;

&lt;p&gt;Once we started looking, two smaller problems fell out of the same investigation.&lt;/p&gt;

&lt;p&gt;The keys were too strict. We keyed on the raw prompt string. A trailing space, a reordered pair of retrieved chunks, a timestamp injected into the system prompt, any of these produced a different key and a forced miss on what was semantically the same request. We were busting the cache on prompts that should have collided. Normalizing the key (strip whitespace, sort the parts that are order-insensitive, drop the volatile fields that do not change the answer) recovered a real chunk of hits on the traffic that mattered.&lt;/p&gt;

&lt;p&gt;The cheap calls were inflating the number we bragged about. Because the tiny repetitive calls dominated the request count and cached almost perfectly, they dragged the headline hit rate up toward 90 no matter what the expensive traffic did. The one metric on the wall was structurally incapable of showing us the miss cost, because the misses were rare by count and huge by dollar.&lt;/p&gt;

&lt;p&gt;The metric we should have had from day one&lt;/p&gt;

&lt;p&gt;The fix is to weight the hit rate by cost instead of by request. Every lookup carries a dollar estimate. Sum the dollars you served from cache, sum the total dollars you would have spent, and divide. That number moves when an expensive request misses, which is exactly when you want it to move.&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;dataclasses&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;dataclass&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;field&lt;/span&gt;

&lt;span class="c1"&gt;# price per 1k tokens for the model you are caching in front of.
# use your real numbers; these are placeholders.
&lt;/span&gt;&lt;span class="n"&gt;INPUT_PER_1K&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0005&lt;/span&gt;
&lt;span class="n"&gt;OUTPUT_PER_1K&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0015&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;dollar_cost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;output_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&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;float&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt_tokens&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;INPUT_PER_1K&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;output_tokens&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;OUTPUT_PER_1K&lt;/span&gt;

&lt;span class="nd"&gt;@dataclass&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CostWeightedCacheStats&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;dollars_from_cache&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;   &lt;span class="c1"&gt;# cost we avoided by serving a hit
&lt;/span&gt;    &lt;span class="n"&gt;dollars_total&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;        &lt;span class="c1"&gt;# cost we would have paid with no cache
&lt;/span&gt;    &lt;span class="n"&gt;hits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="n"&gt;misses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="n"&gt;miss_costs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default_factory&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# per-miss dollar cost
&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;record_hit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prompt_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;output_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;dollar_cost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt_tokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;output_tokens&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dollars_from_cache&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dollars_total&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;record_miss&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prompt_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;output_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;dollar_cost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt_tokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;output_tokens&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dollars_total&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;miss_costs&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;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;misses&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;

    &lt;span class="nd"&gt;@property&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;request_hit_rate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&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;float&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;misses&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;

    &lt;span class="nd"&gt;@property&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;dollar_hit_rate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&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;float&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# the number that actually tracks the bill
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dollars_from_cache&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dollars_total&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dollars_total&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;miss_cost_percentiles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="c1"&gt;# where the missed money lives: is it the long tail?
&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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;miss_costs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
        &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;miss_costs&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;pct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&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;s&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;min&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;s&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="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="o"&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;s&lt;/span&gt;&lt;span class="p"&gt;)))]&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;p50&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;pct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;p90&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;pct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;p99&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;pct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;99&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;max&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;s&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wire it around the cache lookup and both numbers fall out of the same code path.&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="n"&gt;stats&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;CostWeightedCacheStats&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;cached_call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;normalize_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;     &lt;span class="c1"&gt;# strip, sort order-insensitive parts, drop volatile fields
&lt;/span&gt;    &lt;span class="n"&gt;hit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&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;hit&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;stats&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;record_hit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prompt_tokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;output_tokens&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;hit&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;call_model&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;stats&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;record_miss&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prompt_tokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;output_tokens&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resp&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;resp&lt;/span&gt;

&lt;span class="c1"&gt;# emit both, side by side, so nobody trusts the wrong one again
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;emit_metrics&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cache.request_hit_rate&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;stats&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request_hit_rate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cache.dollar_hit_rate&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;stats&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dollar_hit_rate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cache.miss_cost&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;stats&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;miss_cost_percentiles&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;When we put those two lines on the same panel, the gap was undeniable. Request hit rate near 90, dollar hit rate scraping the bottom. The miss-cost percentiles made it concrete: the p99 miss was hundreds of times the median miss. All the money was in the tail, and the tail was invisible on the old graph.&lt;/p&gt;

&lt;p&gt;Normalizing the keys pulled the dollar hit rate up, because some of that expensive traffic was more repetitive than the strict key let us see. But the honest conclusion was that a response cache in front of mostly-unique long-context calls was never going to save much. Knowing that stopped us from over-investing in a cache that could not move the bill, and pointed us at prompt trimming and shorter retrieved context instead, which did.&lt;/p&gt;

&lt;p&gt;What I'd page on&lt;/p&gt;

&lt;p&gt;Different checklist from the autoscaling write-up. This one is about a cache lying to you by measuring the wrong axis.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Dollar-weighted hit rate. Dollars served from cache over total dollars. This is the headline. Warn if it drops below your target while request hit rate stays high, because that divergence is the exact failure in this post. Request hit rate alone is a vanity metric here.&lt;/li&gt;
&lt;li&gt;Request hit rate vs dollar hit rate, on one panel. Never show one without the other. The wider the gap between them, the more spend the request number is hiding.&lt;/li&gt;
&lt;li&gt;Miss cost distribution, p50 / p90 / p99. Where the unserved money lives. A p99 miss orders of magnitude above the median means your expensive traffic is bypassing the cache, and that is where to spend engineering time.&lt;/li&gt;
&lt;li&gt;Key-collision rate. Fraction of lookups where a normalized key would have hit but the raw key missed. A rising value means your keys are too strict and you are busting cache on semantically identical prompts. Alert when it climbs.&lt;/li&gt;
&lt;li&gt;Cost per served request, hourly. Total spend over requests served. Flat is healthy. Rising while the request hit rate holds steady is the surprise invoice forming, and it is invisible on any request-count panel.&lt;/li&gt;
&lt;li&gt;Cache dollar savings, absolute per day. Not a rate, a number: dollars the cache actually kept off the bill today. If it stops growing while traffic grows, the cache has stopped earning its keep and it is time to look upstream at prompt and context size.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>llm</category>
      <category>observability</category>
      <category>sre</category>
      <category>finops</category>
    </item>
    <item>
      <title>What wakes you at 2am when an enterprise operator deploys your agent</title>
      <dc:creator>Jasmine Park</dc:creator>
      <pubDate>Thu, 02 Jul 2026 08:13:02 +0000</pubDate>
      <link>https://dev.to/jasmine_park_dev/what-wakes-you-at-2am-when-an-enterprise-operator-deploys-your-agent-3dlm</link>
      <guid>https://dev.to/jasmine_park_dev/what-wakes-you-at-2am-when-an-enterprise-operator-deploys-your-agent-3dlm</guid>
      <description>&lt;p&gt;Month one of our enterprise rollout: three 3am pages. None of them were code bugs. None of them were model quality issues. All of them were operational surprises we had not accounted for before the handoff.&lt;/p&gt;

&lt;p&gt;We were production-ready. We were not operator-ready. Those are different things.&lt;/p&gt;

&lt;p&gt;Here's what the three incidents were, what they cost, and the alert set I now require before any enterprise agent deployment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Incident one: rate limits hit at 6pm EST
&lt;/h2&gt;

&lt;p&gt;The operator's usage pattern had a daily spike between 5pm and 7pm EST. Their team was closing out the day, reviewing agent outputs, running follow-up queries. Peak usage was 4x average.&lt;/p&gt;

&lt;p&gt;Our OpenAI rate limit was sized for average load. At 6:04pm on day eight, we hit it. Every request after that returned 429 errors for eleven minutes. The agent returned a generic error message. Eleven minutes of silent failure during peak business hours.&lt;/p&gt;

&lt;p&gt;Nobody had tested for burst traffic. We had tested for average load. The cost of finding this in production instead of before handoff: one executive complaint, two support tickets, and an emergency weekend call.&lt;/p&gt;

&lt;p&gt;The fix was multi-provider routing with automatic failover: when provider A returns 429, route to provider B. We had this wired up within 48 hours. We should have had it before day one.&lt;/p&gt;

&lt;p&gt;What this incident taught us about operator-readiness: size for the operator's peak, not your average. The operator's usage pattern will not match your test environment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Incident two: $11,800 overage in 18 days
&lt;/h2&gt;

&lt;p&gt;The operator had one very active team. Four engineers running batch analysis jobs against our agent at hours that made no operational sense (11pm local, Saturdays). Within 18 days, their usage represented 68% of total API cost.&lt;/p&gt;

&lt;p&gt;We had a total cost ceiling. We did not have a per-tenant ceiling. When we hit the total ceiling, the entire deployment slowed down. The heavy team's cost was invisible to us until the month-end bill.&lt;/p&gt;

&lt;p&gt;The question from the customer's VP of operations on day 20: "Can you break down the cost by team?"&lt;/p&gt;

&lt;p&gt;We could not.&lt;/p&gt;

&lt;p&gt;This is a FinOps problem that looks like a monitoring problem. Per-tenant cost tagging needs to be in the request headers before the first request goes out. Not added after month-end when someone asks the question.&lt;/p&gt;

&lt;p&gt;Cost of finding this in production instead of before handoff: $11,800 overage, one executive conversation, two weeks of retroactive tagging work to approximate the breakdown.&lt;/p&gt;

&lt;p&gt;Minimum viable cost attribution setup: tag every request with operator ID, team ID, and use-case ID at the gateway layer. Aggregate daily by tag. Alert when any single tag hits 70% of its monthly budget by day 15.&lt;/p&gt;

&lt;h2&gt;
  
  
  Incident three: no audit trail for a compliance request
&lt;/h2&gt;

&lt;p&gt;Six weeks in, the operator's compliance officer needed to reconstruct a decision the agent made on a specific document at a specific time. Customer data question. The agent had made a routing decision that the compliance team needed to audit.&lt;/p&gt;

&lt;p&gt;We had trace spans in our observability system. We did not have an immutable per-request audit log that showed: which document, which agent version, which model version, which prompt version, what the output was, what confidence score.&lt;/p&gt;

&lt;p&gt;Trace spans are not audit logs. They're operational data. An audit log needs to be write-once, timestamped, and correlated with the business object (the document, the customer record, whatever the operator's domain object is).&lt;/p&gt;

&lt;p&gt;We spent four days building a retroactive audit log approximation. It was not what the compliance officer needed, but it was the best we could do. The real audit log went live in week eight.&lt;/p&gt;

&lt;p&gt;Cost of finding this in production instead of before handoff: one compliance near-miss, four days of engineering time, one uncomfortable meeting.&lt;/p&gt;

&lt;h2&gt;
  
  
  What operator-ready means vs. what production-ready means
&lt;/h2&gt;

&lt;p&gt;Production-ready is about your system. Uptime, latency, error rates. Metrics you control.&lt;/p&gt;

&lt;p&gt;Operator-ready is about what happens when your system runs inside someone else's organization, with their usage patterns, their cost constraints, their compliance requirements, their data.&lt;/p&gt;

&lt;p&gt;The three incidents above were all foreseeable. The operator's usage pattern is discoverable before handoff (just ask them). Per-tenant cost attribution is a gateway configuration decision that takes a day to implement. Audit log requirements for regulated industries are documented in their compliance frameworks.&lt;/p&gt;

&lt;p&gt;We didn't discover any of these things before the handoff because we didn't ask the right questions before the handoff.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pre-operator readiness checklist I run now
&lt;/h2&gt;

&lt;p&gt;Before any enterprise agent deployment, I go through five operational questions. These are not code reviews. They're operational configuration checks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rate limit sizing:&lt;/strong&gt; What is the operator's expected peak usage, and what is our per-provider rate limit at that peak? If peak usage exceeds 60% of the rate limit, configure burst handling or multi-provider routing before go-live.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost attribution:&lt;/strong&gt; Is every request tagged with the minimum attribution set (operator, team, use-case) at the gateway layer? If not, implement it before the first request. Do not add it retroactively.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Audit log schema:&lt;/strong&gt; Does the operator operate in a regulated industry? If yes, map their compliance requirements to a specific log schema before deployment. Generic trace spans do not satisfy financial services, healthcare, or legal audit requirements.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Failover configuration:&lt;/strong&gt; Is there a secondary provider configured for automatic failover? If not, is there a documented manual procedure and a stated SLA for the outage window?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost ceiling configuration:&lt;/strong&gt; Is there a per-tenant monthly budget ceiling with an alert at 70% of budget? Not a per-account ceiling. Per tenant. An over-active team should not consume another team's budget.&lt;/p&gt;

&lt;p&gt;These five checks take about two hours to complete and review. Three incidents in month one took about three weeks to fully resolve, plus the relationship cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd page on
&lt;/h2&gt;

&lt;p&gt;If I were setting up the alert set for a new enterprise agent deployment, these are the four alerts I'd configure first:&lt;/p&gt;

&lt;p&gt;Alert one: per-tenant request rate above 80% of the tenant's configured rate limit. Not 100%. Leave 20% headroom to investigate before hitting the ceiling.&lt;/p&gt;

&lt;p&gt;Alert two: per-request cost moving average above threshold for any single tenant (set the threshold based on the expected per-request cost, alert at 3x). Catches batch jobs and runaway loops before month-end.&lt;/p&gt;

&lt;p&gt;Alert three: agent response with no trace ID in the audit log. Means the audit trail has a gap. You want to know about this in real time, not when a compliance officer asks.&lt;/p&gt;

&lt;p&gt;Alert four: first-request p99 latency above 2x the steady-state p99. Catches cold-start regressions before the operator's peak usage hits them.&lt;/p&gt;

&lt;p&gt;None of these alerts require custom infrastructure. They require that your gateway and your agent emit the right metadata on every request.&lt;/p&gt;

&lt;p&gt;Get the metadata right before handoff. Fix the alerts before go-live. The 3am page is not a production incident. It's a pre-deployment checklist item you deferred.&lt;/p&gt;

</description>
      <category>mlops</category>
      <category>observability</category>
      <category>sre</category>
      <category>llmproduction</category>
    </item>
    <item>
      <title>The Langfuse migration that cost us a sprint: how I now budget LLM observability</title>
      <dc:creator>Jasmine Park</dc:creator>
      <pubDate>Fri, 26 Jun 2026 21:37:56 +0000</pubDate>
      <link>https://dev.to/jasmine_park_dev/the-langfuse-migration-that-cost-us-a-sprint-how-i-now-budget-llm-observability-ane</link>
      <guid>https://dev.to/jasmine_park_dev/the-langfuse-migration-that-cost-us-a-sprint-how-i-now-budget-llm-observability-ane</guid>
      <description>&lt;p&gt;We moved off our first tracer in month eight. The migration took one engineer the better part of a sprint, because the trace data lived in a schema we did not own. Nobody costed that line item on day one. I am writing this so you can.&lt;/p&gt;

&lt;p&gt;I run reliability for a small team shipping LLM features. When the pager goes off at 2am, I do not care which dashboard is prettiest. I care about two numbers: what this tool costs me per month, and what it costs me to leave. Those two numbers are the whole story, and they are almost never on the comparison page.&lt;/p&gt;

&lt;p&gt;So here are six Langfuse alternatives. For each I tracked both numbers: the monthly bill on the invoice, and the exit bill that only shows up the day you migrate. I compared Helicone, Arize Phoenix, LangSmith, Braintrust, Laminar, and Future AGI traceAI. They all trace LLM calls (prompts, tokens, retrieval spans, latency). The axis that decides your exit cost is whether the trace format is OpenTelemetry-native or a vendor schema. Get that wrong and the migration bill lands later, with interest.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cost nobody puts on the pricing page
&lt;/h2&gt;

&lt;p&gt;Your monthly invoice is the visible cost. The exit cost is the invisible one: re-instrumenting the app, rebuilding integrations, and losing historical traces when the schema does not travel. If your spans are OTel, the exit cost trends toward zero because the data is portable by construction. If they are proprietary, you are paying a deferred bill every month you stay. Sort on that first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Helicone.&lt;/strong&gt; The gateway-first option. You proxy model calls through it and get logging, cost tracking, and analytics with almost no code change. Apache-2.0, self-hostable, roughly 5,800 GitHub stars as of June 2026. On pure observability ergonomics this is one of the strongest picks, and the proxy model means low setup cost. The thing to watch at scale: a gateway in the request path is one more hop to reason about when latency spikes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Arize Phoenix.&lt;/strong&gt; The open-source OTel option. Tracing plus evals, self-hostable, around 10,000 stars as of June 2026. Because it is OTel-native, your exit cost stays low. The commercial Arize AX tier adds ML monitoring and enterprise features. If portability is your top line, this and traceAI are the two that keep the invisible bill near zero.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;LangSmith.&lt;/strong&gt; The LangChain-native option. If you live in LangChain or LangGraph, instrumentation is automatic and the developer experience is strong. Proprietary and closed-source, tightly coupled to the LangChain ecosystem. This is the most lock-in of the group: the day-one cost is the lowest, the day-200 cost is the highest. Worth it only if you are certain you are never leaving LangChain.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Braintrust.&lt;/strong&gt; The polished SaaS option. One of the better eval and observability experiences, and the people who do not page (PMs, leads) tend to like the UI. Proprietary trace schema, closed-source, managed by default. Even on enterprise deployments you operate inside their format, so the exit cost stays on the books.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Laminar.&lt;/strong&gt; The newer open-source entrant. OTel-based tracing with evals, smaller and younger than Phoenix, in the low-thousands of stars as of June 2026. Lower lock-in on the same OTel logic. The cost to weigh here is maturity, not portability: a smaller project means fewer battle-tested edges, which matters more for an on-call rotation than a demo.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Future AGI traceAI.&lt;/strong&gt; The instrumentation-layer option. Worth being precise here, because it is not the same kind of thing as the others. traceAI is not an observability dashboard. It is an Apache-2.0, OpenTelemetry-native instrumentation SDK (pip install fi-instrumentation-otel) that emits portable OTel spans for 50-plus frameworks as of June 2026. The spans go wherever you point your collector. Future AGI's broader platform adds evals on top (50-plus metrics under one evaluate() call as of June 2026), but on raw observability ergonomics Helicone and Phoenix are more mature dashboards. Where traceAI earns its place on this list is the exit-cost column: because it speaks OTel, the cost of leaving is roughly the cost of changing a collector endpoint. Code: github.com/future-agi/traceAI.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two numbers, side by side
&lt;/h2&gt;

&lt;p&gt;Visible cost is easy: read the pricing page, multiply by your span volume, done. Invisible cost is the one that bit me. The open-source OTel tools (Phoenix, Laminar, traceAI as the instrumentation layer) keep your exit near free. The proprietary ones (LangSmith, Braintrust) front-load convenience and back-load the migration. Helicone sits in between: open and portable, with a proxy hop to account for. Pick the lock-in profile you can afford in month eight, then argue about features.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd page on
&lt;/h2&gt;

&lt;p&gt;If I were standing this up again, here is the dashboard and alert set I would build before I cared about anything else:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trace export success rate&lt;/strong&gt; below 99 percent over 5 minutes. A silent collector drop is invisible until you need the trace you do not have.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Span ingestion cost per day&lt;/strong&gt; trending above your budget line. Token spend gets watched; span volume does not, and it scales with traffic too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;P99 added latency from the tracing path&lt;/strong&gt; above your SLO budget. If the tracer (or proxy) adds tail latency, that is a reliability cost masquerading as observability.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Percent of spans in a portable (OTel) format.&lt;/strong&gt; This is your exit-cost gauge. If it drifts down because someone added a proprietary integration, you just took on migration debt. Page on it before it compounds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dropped-trace rate during incidents specifically.&lt;/strong&gt; Tracing tends to fail exactly when load is highest, which is exactly when you need it. Alert on the correlation, not just the absolute.&lt;/p&gt;

&lt;p&gt;Build those five first. The dashboard you actually page on is cheaper than the migration you did not plan for.&lt;/p&gt;

</description>
      <category>llmops</category>
      <category>langfuse</category>
      <category>opentelemetry</category>
      <category>observability</category>
    </item>
    <item>
      <title>The gateway tax: 6 OpenAI-compatible gateways.</title>
      <dc:creator>Jasmine Park</dc:creator>
      <pubDate>Fri, 26 Jun 2026 21:35:09 +0000</pubDate>
      <link>https://dev.to/jasmine_park_dev/the-gateway-tax-6-openai-compatible-gateways-5f1e</link>
      <guid>https://dev.to/jasmine_park_dev/the-gateway-tax-6-openai-compatible-gateways-5f1e</guid>
      <description>&lt;p&gt;On March 14, 2026, our LLM bill came in at $9,140 for the month, up from about $5,200, and I could not tell you which team spent it. The gateway in front of every provider emitted one cost line and one trace span per request, all tagged &lt;code&gt;service=llm-gateway&lt;/code&gt;, so the platform team ate the whole overage in the FinOps review while three product teams shrugged.&lt;/p&gt;

&lt;p&gt;That month is the reason I now treat cost attribution as a gateway design decision, not an afterthought. If you cannot answer "which team, which feature, which key spent this" from the layer every call already passes through, you will answer it never. This is a comparison of the OpenAI-compatible LLM gateways I have evaluated for exactly that job: LiteLLM, Portkey, Helicone, Cloudflare AI Gateway, and Bifrost, plus one newer open-source entrant I introduce in the comparison table below. The lens is an SRE lens. What does it cost you in p99, and how granularly can you bill it back.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;Cost attribution belongs at the gateway, not in each app's SDK and not in your provider's dashboard. The gateway is the one chokepoint every call crosses, so it is the only place where per-team, per-feature, per-key spend is both complete and consistent.&lt;/p&gt;

&lt;p&gt;Every OpenAI-compatible gateway you put in that path adds latency. Call it the gateway tax. It is real, it is usually single-digit milliseconds at the proxy hop, and it varies with what you turn on (caching, guardrails, semantic lookups). The tax is not the deciding factor for most teams, because provider latency dwarfs it. What actually differs across gateways, by a lot, is attribution granularity: whether you can slice spend by virtual key, by route, by user, and whether the cost shows up as a first-class OpenTelemetry span attribute or as a number you have to scrape out of a dashboard later.&lt;/p&gt;

&lt;p&gt;So the decision rule is short. Pick the gateway whose tax you can afford at your p99 budget, and whose attribution you can actually bill against. Most teams over-index on the first half and never check the second. Then March happens.&lt;/p&gt;

&lt;p&gt;One honesty note up front, because it matters for how you read everything below. We did not re-run a latency benchmark across these six gateways on one rig. Anybody who hands you a clean cross-vendor p99 table either ran a heroic apples-to-apples harness (rare) or is quietly comparing numbers each vendor measured on different hardware against different upstreams (common). Where I cite latency, it is the vendor's own published number, labeled as such. The capability columns (self-host, caching type, attribution granularity, OTel-native, guardrails, license) are checked against each project's public docs and READMEs, because those are verifiable and they are what you will actually live with.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not the app SDK, and why not the provider dashboard
&lt;/h2&gt;

&lt;p&gt;Before the table, kill the two alternatives, because most teams reach for one of them first and it is why their numbers never reconcile.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost attribution does not belong in each app's SDK.&lt;/strong&gt; The pitch is seductive: every service instruments its own OpenAI client, tags spend with its own team name, ships it to your metrics backend. In practice you now have N implementations of "compute token cost" drifting against each other. One team is on an old pricing table. One forgot to count cached input tokens at the discounted rate. One service calls the provider directly in a cron job and bypasses instrumentation entirely, so that spend is simply invisible. When the provider changes per-token pricing (they do, quietly), you are editing N codebases to stay correct. SDK metering is great for in-process latency spans. It is a bad system of record for dollars, because the source of truth is smeared across every repo and every deploy cadence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost attribution does not belong in the provider dashboard either.&lt;/strong&gt; The OpenAI or Anthropic billing console knows your org spent the money. It does not know your org chart. It cannot tell you that &lt;code&gt;team-checkout&lt;/code&gt; spent $4k and &lt;code&gt;team-search&lt;/code&gt; spent $300, because your teams are not a concept the provider has. The best you get is per-API-key, and only if you had the discipline to mint one key per team up front and never share them, which under load nobody does. Multi-provider makes it worse: now you are stitching three billing consoles, three export formats, three currencies of "cost," into one spreadsheet a human maintains by hand. That spreadsheet is wrong by the second week.&lt;/p&gt;

&lt;p&gt;The gateway is the only layer that sees every request, knows which credential made it, can compute cost once against one pricing table, and can stamp that cost onto a span before the response leaves the building. That is the whole argument. Now, which gateway.&lt;/p&gt;

&lt;h2&gt;
  
  
  Definitions, so the table means something
&lt;/h2&gt;

&lt;p&gt;Two terms do all the work in this post. Pin them down before you read the comparison.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost-attribution granularity&lt;/strong&gt; is the finest dimension along which the gateway can split spend without you doing post-hoc log surgery. I rank it in three tiers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;em&gt;Per-key&lt;/em&gt;: the gateway issues virtual keys (its own keys, mapped to upstream provider keys) and tracks spend and budget per virtual key. You hand &lt;code&gt;team-checkout&lt;/code&gt; a virtual key, and its spend is isolated. This is the floor for billing back, and honestly it is enough for most orgs.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;Per-route / per-model&lt;/em&gt;: spend split by which model or endpoint served the call, so you can see that GPT-4-class traffic is 80% of cost while being 10% of calls.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;Per-user / per-metadata&lt;/em&gt;: arbitrary tags (end-user id, feature flag, tenant) attached at request time and queryable later. This is what you need for usage-based billing to &lt;em&gt;your&lt;/em&gt; customers, not just internal chargeback.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A gateway that only gives you per-key is fine for internal FinOps. A gateway that gives you per-user metadata is what you need if you resell LLM features and bill your customers per seat.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The gateway tax&lt;/strong&gt; is the latency the gateway hop adds on top of provider latency. It has a floor (the proxy itself: parse, auth, route, re-serialize) and a variable part (every feature you enable adds a little: an exact-cache lookup is cheap, a semantic-cache vector search is not free, each inline guardrail is a synchronous scan). The tax is paid on every request that is not a cache hit. On a cache hit you skip the provider entirely and the gateway &lt;em&gt;saves&lt;/em&gt; you latency, which is the one case where the tax goes negative. The mistake teams make is benchmarking the bare proxy, seeing 2 ms, and budgeting as if guardrails and semantic cache are free. They are not. Measure the tax with your real feature set on, or do not quote it.&lt;/p&gt;

&lt;p&gt;And again, the number you measure on your rig is not comparable to the number a vendor measured on theirs. Different CPU, different upstream, different concurrency, different request body size. Treat every cross-vendor latency claim, including the ones in this post, as directional.&lt;/p&gt;

&lt;h2&gt;
  
  
  The comparison
&lt;/h2&gt;

&lt;p&gt;Read this as capabilities first, latency last. The capability columns are what you live with daily. The latency column is vendor-published and not re-run by us, so it is the least load-bearing thing here.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Gateway&lt;/th&gt;
&lt;th&gt;Self-host?&lt;/th&gt;
&lt;th&gt;Caching (exact / semantic)&lt;/th&gt;
&lt;th&gt;Cost-attribution granularity&lt;/th&gt;
&lt;th&gt;OTel-native?&lt;/th&gt;
&lt;th&gt;Inline guardrails?&lt;/th&gt;
&lt;th&gt;License&lt;/th&gt;
&lt;th&gt;Verdict&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;LiteLLM&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Exact (Redis/in-mem/disk/S3/GCS) + semantic (Qdrant/Redis)&lt;/td&gt;
&lt;td&gt;Per-key, per-team, per-user (virtual keys + budgets + spend tags)&lt;/td&gt;
&lt;td&gt;Via OTel callback/integration&lt;/td&gt;
&lt;td&gt;Via plugins + Guardrails hooks&lt;/td&gt;
&lt;td&gt;MIT (OSS); paid Enterprise tier&lt;/td&gt;
&lt;td&gt;Broadest provider + ecosystem coverage. Default pick if you want the biggest model zoo.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Portkey&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes (gateway is OSS; full platform is SaaS)&lt;/td&gt;
&lt;td&gt;Simple (exact) + semantic&lt;/td&gt;
&lt;td&gt;Per virtual key + metadata tags; rich SaaS dashboards&lt;/td&gt;
&lt;td&gt;Partial / via integrations&lt;/td&gt;
&lt;td&gt;Yes (integrated Guardrails)&lt;/td&gt;
&lt;td&gt;Gateway MIT; platform proprietary SaaS&lt;/td&gt;
&lt;td&gt;Most polished managed dashboards and config UI. Default if you want a hosted control plane, not a DIY one.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Helicone&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes (self-host available)&lt;/td&gt;
&lt;td&gt;Exact-match only (cache-key hash)&lt;/td&gt;
&lt;td&gt;Custom properties (per-user / per-feature) via metadata; per-key&lt;/td&gt;
&lt;td&gt;OTLP ingest (observability-first)&lt;/td&gt;
&lt;td&gt;Limited / not the focus&lt;/td&gt;
&lt;td&gt;OSS (observability platform)&lt;/td&gt;
&lt;td&gt;Observability-first, not a routing-heavy gateway. Default if logging + analytics is the job.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Cloudflare AI Gateway&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No (Cloudflare edge, cloud-only)&lt;/td&gt;
&lt;td&gt;Caching (exact); no documented semantic cache&lt;/td&gt;
&lt;td&gt;Per-request analytics, basic metadata; provider/token/cost metrics&lt;/td&gt;
&lt;td&gt;No documented OTel export&lt;/td&gt;
&lt;td&gt;Not the focus&lt;/td&gt;
&lt;td&gt;Proprietary (managed service)&lt;/td&gt;
&lt;td&gt;Zero-ops edge gateway. Default if you are already all-in on Cloudflare and want one toggle.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Bifrost&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Semantic caching (exact also supported)&lt;/td&gt;
&lt;td&gt;Hierarchical budgets: virtual keys, teams, customers&lt;/td&gt;
&lt;td&gt;Yes (Prometheus + OTel/tracing)&lt;/td&gt;
&lt;td&gt;Yes (plugin middleware / enterprise guardrails)&lt;/td&gt;
&lt;td&gt;Apache-2.0 (Go)&lt;/td&gt;
&lt;td&gt;Fast Go OSS gateway with strong budget hierarchy. Default if you want OSS + native budgets and live in Go.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Future AGI Agent Command Center&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes (single Go binary)&lt;/td&gt;
&lt;td&gt;Exact (6 backends) + semantic (4 backends)&lt;/td&gt;
&lt;td&gt;Per virtual key budgets/quotas + per-request cost on the span&lt;/td&gt;
&lt;td&gt;Yes, OTel-native (W3C trace context) + Prometheus &lt;code&gt;/metrics&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Yes, 18 built-in scanners + external adapters&lt;/td&gt;
&lt;td&gt;Apache-2.0 (Go)&lt;/td&gt;
&lt;td&gt;End-to-end OSS platform where the gateway is one piece beside eval/observability. Default if you want OTel + Prometheus + caching + guardrails in one binary.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Notes on the latency column, deliberately kept out of the table because it is not comparable: LiteLLM publishes proxy-overhead figures in the single-digit-millisecond range on their own harness; Future AGI publishes a vendor benchmark of roughly +1.4 ms P95 added by three inline guardrails and a lower added-latency figure than LiteLLM measured on Future AGI's own rig (their numbers, their methodology, not verified by us); Bifrost publishes its own low-microsecond internal-selection numbers. None of these were measured against each other. Do not put them in a slide as if they were.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gateway by gateway
&lt;/h2&gt;

&lt;h3&gt;
  
  
  LiteLLM
&lt;/h3&gt;

&lt;p&gt;The one with the longest provider list and the deepest ecosystem. If a model exists, LiteLLM probably has a route to it, and the &lt;code&gt;litellm&lt;/code&gt; SDK is already in half the agent frameworks you will touch. For attribution it is genuinely strong: virtual keys, budgets, and spend tracking down to key, team, and user, plus cache (exact via Redis and friends, semantic via Qdrant). OpenTelemetry is available through its callback/integration system rather than being the native wire format, which means you wire it up rather than getting it for free. The tax is the usual proxy hop; LiteLLM publishes single-digit-ms overhead on their own harness. The cost of all that breadth is configuration surface: there is a lot of it, and a lot of ways to hold it wrong.&lt;/p&gt;

&lt;p&gt;Choose LiteLLM when your priority is provider coverage and ecosystem fit, and you have someone who will own the config.&lt;/p&gt;

&lt;h3&gt;
  
  
  Portkey
&lt;/h3&gt;

&lt;p&gt;The most polished managed experience. The gateway core is open source and you can run it with &lt;code&gt;npx @portkey-ai/gateway&lt;/code&gt;, but the part people actually pay for is the hosted control plane: the dashboards, the config UI, the virtual-key and metadata management without you standing up storage. Caching is simple plus semantic, guardrails are integrated, attribution is per-virtual-key plus metadata tags. If you want to hand a non-platform team a screen where they can see their own spend without you building it, Portkey is the shortest path. The trade is that the nice parts are SaaS and proprietary, so the dependency is on Portkey-the-company, not just Portkey-the-binary.&lt;/p&gt;

&lt;p&gt;Choose Portkey when you want a managed control plane and dashboards out of the box, and SaaS dependency is acceptable.&lt;/p&gt;

&lt;h3&gt;
  
  
  Helicone
&lt;/h3&gt;

&lt;p&gt;Observability-first. Helicone is excellent at logging every request, tagging it with custom properties, and giving you analytics over that, including per-user and per-feature cost slicing via metadata. Caching is exact-match only (the cache key is a hash of URL, body, and relevant headers, so "Hello" and "Hi" are different entries). It is self-hostable and open source, and it leans into OTLP-style ingest because its center of gravity is the observability plane, not heavy multi-provider routing or failover. If your real problem is "I cannot see what my LLM calls are doing," Helicone solves that cleanly. If your real problem is "I need 15 routing strategies and inline guardrails," it is not aimed there.&lt;/p&gt;

&lt;p&gt;Choose Helicone when logging, analytics, and per-feature cost visibility are the job and routing is secondary.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cloudflare AI Gateway
&lt;/h3&gt;

&lt;p&gt;The zero-ops option. It runs on Cloudflare's edge, so there is no binary to operate and no SPOF you own (you inherit Cloudflare's). It does caching and gives you analytics: request counts, tokens, cost. What you do not get, per the public docs, is self-hosting, a documented OpenTelemetry export, or deep per-team attribution beyond request-level metadata. It is the right answer when you are already on Cloudflare, you want one dashboard and one toggle, and your attribution needs stop at "roughly how much, roughly where."&lt;/p&gt;

&lt;p&gt;Choose Cloudflare AI Gateway when you want a managed edge gateway with near-zero ops and you already live on Cloudflare.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bifrost
&lt;/h3&gt;

&lt;p&gt;A fast Go OSS gateway (Apache-2.0) with a genuinely good cost model: hierarchical budgets across virtual keys, teams, and customers, which maps cleanly onto chargeback. It ships native Prometheus metrics and distributed tracing / OTel, semantic caching, and a plugin middleware system for analytics and guardrail-style logic. It is newer and the ecosystem is smaller than LiteLLM's, so you trade provider breadth for a tight, performant core and a budget hierarchy that is built in rather than bolted on.&lt;/p&gt;

&lt;p&gt;Choose Bifrost when you want OSS, native budget hierarchy, and Prometheus + OTel, and you are comfortable in the Go ecosystem.&lt;/p&gt;

&lt;h3&gt;
  
  
  Future AGI Agent Command Center
&lt;/h3&gt;

&lt;p&gt;An OpenAI-compatible gateway shipped as a single Go binary, Apache-2.0, open source (repo at github.com/future-agi). As of June 2026 it ships 15 routing strategies, two-tier caching (6 exact-match backends and 4 semantic backends), and 18 built-in guardrail scanners plus adapters for external guardrail vendors. The piece that matters for this post: it is OpenTelemetry-native using W3C trace context and also exposes a Prometheus &lt;code&gt;/metrics&lt;/code&gt; endpoint, and it tracks per-virtual-key budgets and quotas, so cost can ride on the span rather than living only in a dashboard. It also ships a committed, reproducible benchmark harness (a &lt;code&gt;bench/&lt;/code&gt; directory with a mock upstream), which I respect more than a marketing number, because it means you can re-run their claim instead of trusting it.&lt;/p&gt;

&lt;p&gt;On their own published benchmark (vendor numbers, not verified by us), three inline guardrails add roughly +1.4 ms at P95, and they claim lower added latency than LiteLLM measured on their rig. Same caveat as everywhere else: their hardware, their upstream, their methodology. The honest positioning: LiteLLM still has the broadest provider and ecosystem coverage, and Portkey has the more polished managed SaaS and dashboards. Future AGI's actual edge is that the gateway is one component of an end-to-end open-source platform that also does eval and observability, with native OTel plus Prometheus and built-in caching and guardrails in a single binary, so you are not assembling four tools to get attribution onto a span.&lt;/p&gt;

&lt;p&gt;Choose Agent Command Center when you want OTel + Prometheus + caching + guardrails in one OSS binary, and you value the gateway being part of one eval/observability platform.&lt;/p&gt;

&lt;h2&gt;
  
  
  The diagram you should draw on your whiteboard
&lt;/h2&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%2F1o6-cyigVBiyxu3Qh4sqDjUgkvCIBJ4_m" 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%2F1o6-cyigVBiyxu3Qh4sqDjUgkvCIBJ4_m" alt="Gateway control points with the OTel cost span emitted at govern/cost" width="1483" height="789"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Figure: the gateway is the one layer every call crosses. Stamp cost on the OpenTelemetry span at GOVERN/COST and attribution stays complete and consistent.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The single most important thing in that diagram is where the span is emitted. It is emitted inside the gateway, at the govern/cost control point, after the gateway has resolved the credential and computed the cost. That is what makes attribution complete (every call crosses it) and consistent (one pricing table, one cost function). Move that emission into each app and you reintroduce every drift problem from the "why not the SDK" section above.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest limitations: where every one of these adds risk
&lt;/h2&gt;

&lt;p&gt;No gateway is free of downside. If you put one in your hot path, you have signed up for these, regardless of vendor.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Single point of failure.&lt;/strong&gt; Every request now depends on the gateway being up. A managed edge service (Cloudflare) trades your SPOF for theirs, which may be a better or worse bet than your own uptime. A self-hosted binary (LiteLLM, Bifrost, Future AGI) is yours to make HA: run more than one replica, put a real load balancer in front, and test failover before you need it. "We deployed one gateway pod" is not a control plane, it is an incident waiting for a node drain.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cache poisoning and stale answers.&lt;/strong&gt; Semantic caching is the feature most likely to bite you. A vector-similarity hit can return a cached answer for a prompt that is &lt;em&gt;close&lt;/em&gt; but not equivalent, and now one user sees another user's response, or a stale answer to a changed question. Exact caching is safer but still leaks across users if your cache key does not include the right scoping. Scope cache keys per tenant where correctness matters, and keep semantic caching off for anything with PII or per-user state until you have measured the false-hit rate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Span-cardinality blowup.&lt;/strong&gt; The fix for attribution (rich tags on every span) is also the way you melt your metrics backend. Put &lt;code&gt;end_user_id&lt;/code&gt; as a label on a Prometheus metric and you have just created one time series per user. That is a cardinality bomb. Keep high-cardinality identifiers (user id, request id) on &lt;em&gt;traces and logs&lt;/em&gt;, where high cardinality is fine, and keep your &lt;em&gt;metric&lt;/em&gt; labels low-cardinality (team, model, provider, cache_hit). Conflating the two is the most common way an attribution rollout pages the observability team instead of the FinOps team.&lt;/p&gt;

&lt;h2&gt;
  
  
  A pasteable artifact: per-key budget plus OTel export
&lt;/h2&gt;

&lt;p&gt;Here is a minimal, runnable setup for one gateway (LiteLLM, because its config is the most widely deployed and the spend tracking is mature), showing a per-virtual-key budget and OpenTelemetry export, plus the queries that turn it into a bill-back.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;docker-compose.yml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;litellm&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/berriai/litellm:main-stable&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;4000:4000"&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;OPENAI_API_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${OPENAI_API_KEY}&lt;/span&gt;
      &lt;span class="na"&gt;LITELLM_MASTER_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${LITELLM_MASTER_KEY}&lt;/span&gt;
      &lt;span class="na"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres://litellm:litellm@db:5432/litellm&lt;/span&gt;
      &lt;span class="c1"&gt;# Send OTel spans to your collector&lt;/span&gt;
      &lt;span class="na"&gt;OTEL_EXPORTER_OTLP_ENDPOINT&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://otel-collector:4317&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--config"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/app/config.yaml"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--port"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;4000"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./config.yaml:/app/config.yaml:ro&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;db&lt;/span&gt;

  &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:16&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;litellm&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;litellm&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;litellm&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;litellm-pg:/var/lib/postgresql/data&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;litellm-pg&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;config.yaml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;model_list&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;model_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gpt-4o&lt;/span&gt;
    &lt;span class="na"&gt;litellm_params&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;openai/gpt-4o&lt;/span&gt;
      &lt;span class="na"&gt;api_key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;os.environ/OPENAI_API_KEY&lt;/span&gt;

&lt;span class="na"&gt;litellm_settings&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="c1"&gt;# Emit an OpenTelemetry span per request, with cost + tokens as attributes.&lt;/span&gt;
  &lt;span class="na"&gt;callbacks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;otel"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="c1"&gt;# Track and persist spend so it can be queried per key/team/user.&lt;/span&gt;
  &lt;span class="na"&gt;store_model_in_db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

&lt;span class="na"&gt;general_settings&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;master_key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;os.environ/LITELLM_MASTER_KEY&lt;/span&gt;
  &lt;span class="na"&gt;database_url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;os.environ/DATABASE_URL&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Mint a virtual key for one team, with a hard monthly budget, so March cannot happen silently:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://localhost:4000/key/generate &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$LITELLM_MASTER_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
        "key_alias": "team-checkout",
        "models": ["gpt-4o"],
        "max_budget": 500,
        "budget_duration": "30d",
        "metadata": {"team": "checkout", "cost_center": "cc-4471"}
      }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That key now refuses traffic once &lt;code&gt;team-checkout&lt;/code&gt; crosses $500 in a 30-day window, and every call it makes carries &lt;code&gt;team=checkout&lt;/code&gt; into the spend store and onto the OTel span.&lt;/p&gt;

&lt;p&gt;Attributing spend to a team comes from the gateway's own spend store. With LiteLLM's spend logs in Postgres, the bill-back for last month is one query:&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;metadata&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'team'&lt;/span&gt;      &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;team&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&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;requests&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;ROUND&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;spend&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&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;usd&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="nv"&gt;"LiteLLM_SpendLogs"&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="nv"&gt;"startTime"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;date_trunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'month'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'1 month'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="nv"&gt;"startTime"&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;  &lt;span class="n"&gt;date_trunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'month'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;1&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;usd&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And for the live alerting view, scrape low-cardinality cost metrics into Prometheus and rank current-month spend by team. With a gateway that exposes a per-team cost counter (label &lt;code&gt;team&lt;/code&gt;, deliberately low-cardinality), the PromQL is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;topk(5,
  sum by (team) (
    increase(llm_gateway_cost_usd_total[30d])
  )
)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep &lt;code&gt;team&lt;/code&gt;, &lt;code&gt;model&lt;/code&gt;, and &lt;code&gt;provider&lt;/code&gt; as metric labels. Keep &lt;code&gt;end_user_id&lt;/code&gt; and &lt;code&gt;request_id&lt;/code&gt; out of metrics and on the trace instead. That one discipline is the difference between an attribution dashboard and a cardinality incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  Paste this into your PRD
&lt;/h2&gt;

&lt;p&gt;A scenario matrix for the decision review, so the next person does not re-derive it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Priority&lt;/th&gt;
&lt;th&gt;Default pick&lt;/th&gt;
&lt;th&gt;Escalate to&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Internal chargeback, many providers&lt;/td&gt;
&lt;td&gt;Provider breadth + per-team spend&lt;/td&gt;
&lt;td&gt;LiteLLM&lt;/td&gt;
&lt;td&gt;Bifrost (if you want native budget hierarchy in Go)&lt;/td&gt;
&lt;td&gt;Biggest model zoo, mature virtual keys and spend tracking; budgets get you per-team bill-back.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Non-platform teams need their own spend screen&lt;/td&gt;
&lt;td&gt;Managed dashboards, low build cost&lt;/td&gt;
&lt;td&gt;Portkey&lt;/td&gt;
&lt;td&gt;LiteLLM self-host (if SaaS dependency is a no)&lt;/td&gt;
&lt;td&gt;Hosted control plane and config UI mean you do not build the dashboard yourself.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"I cannot see what my LLM calls do"&lt;/td&gt;
&lt;td&gt;Logging + per-feature cost visibility&lt;/td&gt;
&lt;td&gt;Helicone&lt;/td&gt;
&lt;td&gt;Future AGI ACC (if you also need routing + guardrails)&lt;/td&gt;
&lt;td&gt;Observability-first with custom-property attribution; exact-match cache.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Already on Cloudflare, want near-zero ops&lt;/td&gt;
&lt;td&gt;One toggle, no binary to run&lt;/td&gt;
&lt;td&gt;Cloudflare AI Gateway&lt;/td&gt;
&lt;td&gt;Any self-hosted gateway (when you outgrow request-level attribution)&lt;/td&gt;
&lt;td&gt;Edge-managed, no SPOF you operate; attribution stops at request-level metadata.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Want OTel + Prometheus + cache + guardrails in one OSS binary&lt;/td&gt;
&lt;td&gt;One platform, attribution on the span&lt;/td&gt;
&lt;td&gt;Future AGI Agent Command Center&lt;/td&gt;
&lt;td&gt;LiteLLM (for wider provider coverage) or Portkey (for managed dashboards)&lt;/td&gt;
&lt;td&gt;Native OTel (W3C) + Prometheus, two-tier cache, 18 guardrail scanners in one Go binary, part of an eval/observability platform.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Resell LLM features, bill your customers per seat&lt;/td&gt;
&lt;td&gt;Per-user / per-metadata attribution&lt;/td&gt;
&lt;td&gt;LiteLLM or Portkey (rich metadata)&lt;/td&gt;
&lt;td&gt;Helicone (for the analytics layer on top)&lt;/td&gt;
&lt;td&gt;You need arbitrary per-user tags queryable later, not just per-key.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What I'd page on
&lt;/h2&gt;

&lt;p&gt;This is the on-call checklist for a gateway in your hot path. If you adopt one of these gateways and do not wire these alerts, you are flying blind and the next $9k month is already in flight.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Gateway p99 latency, by route.&lt;/strong&gt; Page if p99 of the gateway-added overhead (gateway span duration minus upstream span duration) exceeds your budget for 5 minutes. This is the gateway tax going bad. Separate the proxy hop from provider latency or you will blame the wrong layer at 2am.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gateway error rate and saturation.&lt;/strong&gt; Page on 5xx rate from the gateway above baseline, and on CPU saturation, because at high concurrency CPU is the bottleneck, not the network. A saturated gateway fails every team at once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-team budget burn.&lt;/strong&gt; Page (or auto-throttle) when any virtual key crosses, say, 80% of its monthly budget before the month is 80% over. This is the alert that would have caught March on March 6, not March 31.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Total spend rate-of-change.&lt;/strong&gt; Page on day-over-day total LLM spend up more than X%. A runaway retry loop or a new feature shipping uncapped shows up here first, hours before the invoice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache hit rate drop.&lt;/strong&gt; Page if cache hit rate falls below your assumed floor, because your cost model and your latency budget both silently assumed those hits. A cache that quietly stopped hitting is a bill increase and a latency regression in one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Semantic-cache false-hit signal.&lt;/strong&gt; If you run semantic caching on anything user-facing, alert on user reports or eval-detected wrong answers correlated with cache hits. This is correctness, not cost, and it is the one that becomes a postmortem instead of a FinOps slide.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Span cardinality / metrics ingestion.&lt;/strong&gt; Page if your metrics backend's active series count jumps after a deploy. That is usually someone putting a user id on a metric label. Catch it before it takes down the observability stack.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Provider failover events.&lt;/strong&gt; Alert (not page) when the gateway fails over between providers, so a silent provider degradation does not hide inside your routing logic until the bill from the more expensive fallback shows up.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pick the gateway whose tax you can afford and whose attribution you can bill against. Then wire the eight alerts above, because the gateway is now load-bearing infrastructure, and load-bearing infrastructure gets a pager.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Capability claims here reflect each project's public docs and READMEs as of June 2026. Latency figures are vendor-published on each vendor's own harness, not re-run on a common rig, and are not comparable across vendors. Future AGI's gateway (Agent Command Center) is open source at github.com/future-agi.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>llmops</category>
      <category>gateway</category>
      <category>observability</category>
      <category>finops</category>
    </item>
    <item>
      <title>Langfuse alternatives: 6 LLM observability tools, sorted by the thing that bites you in month eight</title>
      <dc:creator>Jasmine Park</dc:creator>
      <pubDate>Fri, 19 Jun 2026 09:33:19 +0000</pubDate>
      <link>https://dev.to/jasmine_park_dev/langfuse-alternatives-6-llm-observability-tools-sorted-by-the-thing-that-bites-you-in-month-eight-22j8</link>
      <guid>https://dev.to/jasmine_park_dev/langfuse-alternatives-6-llm-observability-tools-sorted-by-the-thing-that-bites-you-in-month-eight-22j8</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;I went looking for Langfuse alternatives after living with a proprietary tracer for eight months and then paying to migrate off it.&lt;/p&gt;

&lt;p&gt;I compared six options:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Helicone&lt;/li&gt;
&lt;li&gt;Arize Phoenix&lt;/li&gt;
&lt;li&gt;LangSmith&lt;/li&gt;
&lt;li&gt;Braintrust&lt;/li&gt;
&lt;li&gt;Laminar&lt;/li&gt;
&lt;li&gt;Future AGI traceAI&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;They all trace LLM calls.&lt;/p&gt;

&lt;p&gt;The axis that actually mattered was &lt;strong&gt;OpenTelemetry-native (OTel) vs proprietary tracing&lt;/strong&gt;, because that's what determines whether you can leave without re-instrumenting everything.&lt;/p&gt;

&lt;p&gt;Four of the six are open-source, ranging from roughly &lt;strong&gt;200 to 10,000 GitHub stars&lt;/strong&gt; (June 2026). That spread turned out to predict almost nothing about what I actually cared about: &lt;strong&gt;portability&lt;/strong&gt;.&lt;/p&gt;




&lt;h1&gt;
  
  
  The Axis That Bites You in Month Eight: Whose Traces Are These?
&lt;/h1&gt;

&lt;p&gt;Every tracer captures:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;LLM calls&lt;/li&gt;
&lt;li&gt;Prompts&lt;/li&gt;
&lt;li&gt;Tokens&lt;/li&gt;
&lt;li&gt;Retrieval events&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The question nobody asks on day one and everybody regrets on day 200:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Is the trace format OpenTelemetry (portable) or the vendor's own schema (locked to their dashboard)?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If it's proprietary, switching tools later often means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Re-instrumenting your application&lt;/li&gt;
&lt;li&gt;Rebuilding integrations&lt;/li&gt;
&lt;li&gt;Losing historical trace data&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I learned this the expensive way.&lt;/p&gt;

&lt;p&gt;So today I sort observability tools by &lt;strong&gt;lock-in first&lt;/strong&gt; and &lt;strong&gt;features second&lt;/strong&gt;.&lt;/p&gt;




&lt;h1&gt;
  
  
  The Six Tools, Sorted by Lock-In
&lt;/h1&gt;

&lt;h2&gt;
  
  
  1. Helicone
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The gateway-first open-source pick.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Often the first Langfuse alternative people mention.&lt;/p&gt;

&lt;p&gt;You proxy model calls through Helicone and get:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Logging&lt;/li&gt;
&lt;li&gt;Cost tracking&lt;/li&gt;
&lt;li&gt;Analytics&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;with very little code change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Highlights&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Open-source (Apache-2.0)&lt;/li&gt;
&lt;li&gt;Self-hostable&lt;/li&gt;
&lt;li&gt;~5,800 GitHub stars (June 2026)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; teams that want a fast observability layer with minimal engineering effort.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Arize Phoenix
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The open-source OTel pick.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Phoenix combines:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;OTel-based tracing&lt;/li&gt;
&lt;li&gt;Evaluations&lt;/li&gt;
&lt;li&gt;Self-hosted deployment&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The core project is free and open-source.&lt;/p&gt;

&lt;p&gt;Arize's commercial offering (AX) adds:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Enterprise capabilities&lt;/li&gt;
&lt;li&gt;Advanced ML monitoring&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Highlights&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Open-source&lt;/li&gt;
&lt;li&gt;OTel-native&lt;/li&gt;
&lt;li&gt;Self-hosted&lt;/li&gt;
&lt;li&gt;~10,000 GitHub stars (June 2026)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; teams that want portable tracing and full ownership.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. LangSmith
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The LangChain-native pick.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you're already using LangChain or LangGraph, LangSmith provides:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Automatic instrumentation&lt;/li&gt;
&lt;li&gt;Deep framework integration&lt;/li&gt;
&lt;li&gt;Strong developer experience&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tradeoff is coupling.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Highlights&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Proprietary&lt;/li&gt;
&lt;li&gt;Closed-source&lt;/li&gt;
&lt;li&gt;Closely tied to the LangChain ecosystem&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; teams fully committed to LangChain.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Most lock-in of the group.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Braintrust
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The polished SaaS pick.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Braintrust has one of the strongest experiences for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Evaluations&lt;/li&gt;
&lt;li&gt;Observability&lt;/li&gt;
&lt;li&gt;Cross-functional visibility&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Non-technical stakeholders tend to like the UI.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Highlights&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Proprietary trace schema&lt;/li&gt;
&lt;li&gt;Closed-source&lt;/li&gt;
&lt;li&gt;Managed SaaS by default&lt;/li&gt;
&lt;li&gt;Enterprise deployment options available&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Even in enterprise deployments, you're still operating within their trace format.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; organizations that prioritize product polish over portability.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Future AGI traceAI
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The no-lock-in instrumentation pick.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;traceAI is different from the others.&lt;/p&gt;

&lt;p&gt;It is &lt;strong&gt;not an observability platform&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;It is an &lt;strong&gt;Apache-2.0 OpenTelemetry instrumentation layer&lt;/strong&gt; that captures:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;LLM calls&lt;/li&gt;
&lt;li&gt;Prompts&lt;/li&gt;
&lt;li&gt;Tokens&lt;/li&gt;
&lt;li&gt;Retrieval&lt;/li&gt;
&lt;li&gt;Agent steps&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;and exports them to any OTel-compatible backend:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Datadog&lt;/li&gt;
&lt;li&gt;Grafana&lt;/li&gt;
&lt;li&gt;Jaeger&lt;/li&gt;
&lt;li&gt;Vendor platforms&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In other words:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;It handles instrumentation, not dashboards.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If you want a polished product out of the box, tools like Langfuse or Helicone are more complete.&lt;/p&gt;

&lt;p&gt;If you want portable traces that you own, this is the lightest approach I found.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Highlights&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Apache-2.0&lt;/li&gt;
&lt;li&gt;OTel-native&lt;/li&gt;
&lt;li&gt;Backend-agnostic&lt;/li&gt;
&lt;li&gt;~200 GitHub stars (June 2026)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; teams optimizing for long-term portability.&lt;/p&gt;

&lt;p&gt;It's also the youngest project here, so think of it as a bet on the instrumentation-first approach rather than a mature platform.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. Laminar
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The newer open-source pick.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Laminar combines:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;OTel-based observability&lt;/li&gt;
&lt;li&gt;Evaluations&lt;/li&gt;
&lt;li&gt;Modern architecture&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It is newer than Phoenix but worth evaluating.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Highlights&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Apache-2.0&lt;/li&gt;
&lt;li&gt;OTel-native&lt;/li&gt;
&lt;li&gt;~3,000 GitHub stars (June 2026)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; teams looking for a modern open-source observability stack.&lt;/p&gt;




&lt;h1&gt;
  
  
  My Take
&lt;/h1&gt;

&lt;p&gt;I'm not crowning a winner.&lt;/p&gt;

&lt;p&gt;Different tools optimize for different priorities.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;If you want...&lt;/th&gt;
&lt;th&gt;Look at...&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Fastest onboarding&lt;/td&gt;
&lt;td&gt;Helicone&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Self-hosted OTel observability&lt;/td&gt;
&lt;td&gt;Phoenix or Laminar&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deep LangChain integration&lt;/td&gt;
&lt;td&gt;LangSmith&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Polished SaaS workflows&lt;/td&gt;
&lt;td&gt;Braintrust&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pure OTel instrumentation&lt;/td&gt;
&lt;td&gt;traceAI&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The proprietary tools are completely reasonable choices.&lt;/p&gt;

&lt;p&gt;Until the day you want to leave.&lt;/p&gt;




&lt;h1&gt;
  
  
  What I Would Do Differently
&lt;/h1&gt;

&lt;p&gt;I would choose &lt;strong&gt;OTel-native instrumentation from day one.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not because proprietary tools are bad.&lt;/p&gt;

&lt;p&gt;In many cases they're actually easier and more pleasant to use.&lt;/p&gt;

&lt;p&gt;But the cost of switching is paid later through:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Re-instrumentation&lt;/li&gt;
&lt;li&gt;Migration effort&lt;/li&gt;
&lt;li&gt;Lost historical context&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And on day one, you have no idea whether you'll outgrow the tool.&lt;/p&gt;

&lt;p&gt;The argument is simple:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Instrument once with OpenTelemetry. Point it at whatever backend you want. Change backends without changing application code.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's the entire case for OTel.&lt;/p&gt;

&lt;p&gt;And it's the one strong opinion I came away with.&lt;/p&gt;




&lt;h1&gt;
  
  
  FAQ
&lt;/h1&gt;

&lt;h2&gt;
  
  
  Is Langfuse bad?
&lt;/h2&gt;

&lt;p&gt;No.&lt;/p&gt;

&lt;p&gt;Langfuse is genuinely good.&lt;/p&gt;

&lt;p&gt;It's self-hostable, widely adopted, and has roughly &lt;strong&gt;29,000 GitHub stars&lt;/strong&gt; as of June 2026.&lt;/p&gt;

&lt;p&gt;This post is about alternatives and tradeoffs, not criticism.&lt;/p&gt;




&lt;h2&gt;
  
  
  If I'm OTel-native, does that mean I don't get a dashboard?
&lt;/h2&gt;

&lt;p&gt;No.&lt;/p&gt;

&lt;p&gt;You still need somewhere to view traces.&lt;/p&gt;

&lt;p&gt;Examples include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Grafana&lt;/li&gt;
&lt;li&gt;Datadog&lt;/li&gt;
&lt;li&gt;Jaeger&lt;/li&gt;
&lt;li&gt;Vendor platforms&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;OTel-native simply means you can change the backend later without changing instrumentation.&lt;/p&gt;




&lt;h2&gt;
  
  
  Should I self-host or use a managed service?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Self-host&lt;/strong&gt; if you need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Data residency&lt;/li&gt;
&lt;li&gt;Infrastructure control&lt;/li&gt;
&lt;li&gt;Lower long-term platform dependency&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Managed&lt;/strong&gt; if you want:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Faster setup&lt;/li&gt;
&lt;li&gt;Less operational burden&lt;/li&gt;
&lt;li&gt;A more polished experience&lt;/li&gt;
&lt;/ul&gt;




&lt;h1&gt;
  
  
  Open Question
&lt;/h1&gt;

&lt;p&gt;Lock-in is easy to reason about.&lt;/p&gt;

&lt;p&gt;What I still struggle to quantify is the value difference between:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A polished proprietary platform&lt;/li&gt;
&lt;li&gt;A portable-but-rawer OTel stack&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The proprietary tools genuinely save time every day.&lt;/p&gt;

&lt;p&gt;The portability benefits only pay off if you actually switch.&lt;/p&gt;

&lt;p&gt;I don't have a clean framework for valuing that optionality.&lt;/p&gt;

&lt;p&gt;If you've found a useful way to think about that tradeoff, I'd love to hear it.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>monitoring</category>
      <category>tooling</category>
    </item>
    <item>
      <title>Per-project LLM cost attribution with OTel spans: the wiring</title>
      <dc:creator>Jasmine Park</dc:creator>
      <pubDate>Thu, 04 Jun 2026 21:01:52 +0000</pubDate>
      <link>https://dev.to/jasmine_park_dev/per-project-llm-cost-attribution-with-otel-spans-the-wiring-3897</link>
      <guid>https://dev.to/jasmine_park_dev/per-project-llm-cost-attribution-with-otel-spans-the-wiring-3897</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR.&lt;/strong&gt; If your LLM bill is one line item on a cloud invoice, you cannot answer "which team spent that." We fixed this by tagging every gateway span with &lt;code&gt;team.id&lt;/code&gt;, &lt;code&gt;project.id&lt;/code&gt;, and &lt;code&gt;feature.id&lt;/code&gt;, plus the OpenInference token-count attributes, shipping those spans through an OTel collector into Tempo, and rolling cost up per team with TraceQL in Grafana. The payoff that sold it internally: one team's monthly spend quietly went from a few hundred dollars to over a thousand because of a retry loop, and the org-level dashboard never flinched. The per-team view caught it in a day. Below is the wiring, the collector config, the rollup query, the alert, and the attributes I tried and threw away.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The problem is attribution, not collection
&lt;/h2&gt;

&lt;p&gt;Most teams already collect LLM telemetry. Spans exist, tokens get counted, traces land somewhere. What is missing is the dimension that finance and eng leads actually ask about: who owns this spend. The provider invoice gives you one number per month per API key. If you share keys across services (most people do at some point), that number is useless for chargeback. You cannot tell the platform team's spend from the support-bot team's spend.&lt;/p&gt;

&lt;p&gt;So the design goal was narrow. Every LLM call has to carry enough labels that I can group spend by team, by project under that team, and by feature inside that project. Three levels. No more, because deeper than feature and nobody reads the dashboard. I standardized the whole pipeline on OpenTelemetry and OpenInference, and I will state the one opinion plainly: I want the labels, the wire format, and the storage to be things I can swap without rewriting instrumentation. We tag spans with open semantic conventions so the day we change a backend or a dashboard tool, the gateway code does not move. That is a portability decision, not a verdict on anyone's product.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Which attributes get tagged, and where
&lt;/h2&gt;

&lt;p&gt;Tag at the gateway, not in each service. We run an LLM gateway (every call to every provider goes through it), so it is the one place that sees model, token counts, and request context together. A new service gets attribution for free as long as it routes through the gateway and forwards the three context headers.&lt;/p&gt;

&lt;p&gt;The cost-math group comes straight from OpenInference semantic conventions: &lt;code&gt;llm.model_name&lt;/code&gt;, &lt;code&gt;llm.token_count.prompt&lt;/code&gt;, &lt;code&gt;llm.token_count.completion&lt;/code&gt;. The attribution group is custom, set from request headers: &lt;code&gt;team.id&lt;/code&gt;, &lt;code&gt;project.id&lt;/code&gt;, &lt;code&gt;feature.id&lt;/code&gt;. Cost is not a span attribute. I compute it at query time from token counts and a small price lookup, because prices change and I do not want last quarter's spans frozen at last quarter's rates.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Attribute&lt;/th&gt;
&lt;th&gt;What it buys you&lt;/th&gt;
&lt;th&gt;Keep or drop&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;team.id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Top-level chargeback. The number a director asks for.&lt;/td&gt;
&lt;td&gt;Keep&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;project.id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Splits a team's spend across its services.&lt;/td&gt;
&lt;td&gt;Keep&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;feature.id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Which feature drove a spike inside a project.&lt;/td&gt;
&lt;td&gt;Keep&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;llm.model_name&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Lets you weight tokens by per-model price.&lt;/td&gt;
&lt;td&gt;Keep&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;llm.token_count.prompt&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Input side of the cost.&lt;/td&gt;
&lt;td&gt;Keep&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;llm.token_count.completion&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Output side. Usually the expensive half and the one that runs away.&lt;/td&gt;
&lt;td&gt;Keep&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;user.id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Per-user spend, in theory. A privacy liability in traces.&lt;/td&gt;
&lt;td&gt;Drop&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;request.id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Already covered by the trace and span IDs.&lt;/td&gt;
&lt;td&gt;Drop&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  3. The collector config
&lt;/h2&gt;

&lt;p&gt;OTLP in, batch, set anything the gateway missed, Tempo out. The one processor worth calling out is &lt;code&gt;transform&lt;/code&gt;: I use it to backfill &lt;code&gt;team.id&lt;/code&gt; with a sentinel when a service forgets the header, so unlabeled spend shows up as unattributed instead of vanishing. Cost with no label is cost you will never find.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;receivers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;otlp&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;protocols&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;grpc&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;endpoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;0.0.0.0&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;4317&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;http&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;endpoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;0.0.0.0&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;4318&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;

&lt;span class="na"&gt;processors&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;batch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;5s&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;send_batch_size&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;1024&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;transform/attribution&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;trace_statements&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;span&lt;/span&gt;
        &lt;span class="na"&gt;statements&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;set(attributes["team.id"], "unattributed") where attributes["team.id"] == nil&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;set(attributes["project.id"], "unknown") where attributes["project.id"] == nil&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;set(attributes["feature.id"], "unknown") where attributes["feature.id"] == nil&lt;/span&gt;

&lt;span class="na"&gt;exporters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;otlp/tempo&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tempo:4317&lt;/span&gt;
    &lt;span class="na"&gt;tls&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;insecure&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;true&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;

&lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pipelines&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;traces&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;receivers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;otlp&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;processors&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;transform/attribution&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;batch&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;exporters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;otlp/tempo&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two notes from running this. Put &lt;code&gt;transform&lt;/code&gt; before &lt;code&gt;batch&lt;/code&gt; so the backfill happens per span while the data is still cheap to touch. And keep the price table out of the collector. I tried encoding per-model rates as collector attributes once. Every price change became a config deploy, and the rates drifted out of sync with what we were actually billed. Pricing lives next to the query now.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Rolling cost up by team in Grafana
&lt;/h2&gt;

&lt;p&gt;Tempo stores spans, not dollars. So the rollup is two steps: TraceQL pulls token sums grouped by the attribution attributes, and a small price map turns tokens into cost downstream. I start from this, which aggregates output-token counts (the number I watch most, because completion tokens are usually where the money and the runaways are):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{ .team.id = "support-platform" &amp;amp;&amp;amp; .llm.token_count.completion &amp;gt; 0 }
| select(.project.id, .feature.id, .llm.model_name, .llm.token_count.prompt, .llm.token_count.completion)
| by(.team.id, .project.id, .llm.model_name)
| sum(.llm.token_count.completion)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Drop the &lt;code&gt;team.id&lt;/code&gt; filter and group by it instead for the all-teams board. The grouping by &lt;code&gt;llm.model_name&lt;/code&gt; matters: a mini-tier model and a frontier model can differ by more than an order of magnitude per token, so summing raw tokens across models hides which team is expensive because of volume versus model choice. The dollar step is deliberately dumb: a lookup from &lt;code&gt;llm.model_name&lt;/code&gt; to input-price and output-price per thousand tokens, multiplied through, summed per team. Keeping it dumb and external is what lets me re-price history when a provider changes rates.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The alert: page when a team's output tokens jump
&lt;/h2&gt;

&lt;p&gt;Cost attribution is reporting. The thing that earns its keep is the page. The rule I run is week-over-week on output tokens per team: if this week's completion-token total for any team is more than 2x the same window last week, page. Output tokens, not input, because the runaway failure modes (retry storms, an agent that loops, a prompt-chaining bug that re-asks) all show up as generation volume first. Why 2x week-over-week and not a fixed dollar ceiling: a fixed ceiling either pages constantly for your big teams or never fires for your small ones. A relative jump normalizes across team size on its own. The team whose spend doubled in the story above would have tripped a 2x rule on day one. It did not trip our dollar alert because the absolute number was still small against the org total. Small against the org, doubled for the team, is exactly the blind spot per-team attribution exists to close. Route it to whoever owns the team's budget, not a shared channel where it gets ignored.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. What I tagged and then dropped
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;user.id&lt;/code&gt;.&lt;/strong&gt; Per-user spend sounds useful and is occasionally asked for. But putting a user identifier on every span means every trace is now PII, and your whole tracing backend inherits the retention, access, and deletion obligations that come with that. The attribution win did not come close to justifying the compliance surface. Dropped it, have not missed it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;request.id&lt;/code&gt;.&lt;/strong&gt; Pure redundancy. A trace already has a trace ID and every span has a span ID. Anywhere I thought I wanted it, the trace ID was already there and already correct.&lt;/p&gt;

&lt;p&gt;The pattern in both: an attribute is only worth tagging if it answers a question the cheaper attributes cannot, and if its cost (privacy, plumbing, drift) is lower than that answer is worth.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why compute cost at query time instead of writing a cost attribute on the span?&lt;/strong&gt; Prices change and I want to re-cost history when they do. A cost attribute freezes the rate at write time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I need the gateway, or can each service tag its own spans?&lt;/strong&gt; You can tag per service. I prefer the gateway because it sees model and tokens and request context in one place, so a new service gets attribution by routing through it and forwarding three headers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why Tempo specifically?&lt;/strong&gt; It is what we run, and TraceQL's aggregation over span attributes does the rollup I need. The attribute conventions are OpenInference, so the labels are not tied to Tempo. The point of standardizing on open conventions is that this choice is reversible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What if a service forgets the attribution headers?&lt;/strong&gt; The collector backfills unattributed. The spend still shows up, just in a bucket whose name tells me to go fix the instrumentation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is week-over-week 2x too noisy?&lt;/strong&gt; For steady traffic, no. For genuinely spiky workloads, raise the ratio or widen the comparison window. I bias toward a slightly noisy page over a silent doubling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Open questions
&lt;/h2&gt;

&lt;p&gt;Caching breaks the token-to-cost math. Cached prompt tokens bill at a different rate (sometimes free), and I do not yet tag cache hits cleanly enough to price them right. Streaming and cancelled generations: if a client disconnects mid-stream, what is the honest output-token count, and does the provider bill for tokens generated after the cancel? Feature-level granularity has a ceiling, and I keep wanting per-prompt-version attribution but every level deeper is one more label nobody reads. And whether 2x week-over-week should itself be per-team, since some teams are spiky by nature and one global ratio serves both imperfectly. If you have wired cached-token pricing into a span-based cost model in a way that survives a provider changing its cache rates, I want to hear how.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>observability</category>
      <category>opentelemetry</category>
      <category>mlops</category>
    </item>
    <item>
      <title>Span attributes that catch LLM cost regressions before billing does</title>
      <dc:creator>Jasmine Park</dc:creator>
      <pubDate>Tue, 02 Jun 2026 19:09:31 +0000</pubDate>
      <link>https://dev.to/jasmine_park_dev/span-attributes-that-catch-llm-cost-regressions-before-billing-does-472n</link>
      <guid>https://dev.to/jasmine_park_dev/span-attributes-that-catch-llm-cost-regressions-before-billing-does-472n</guid>
      <description>&lt;p&gt;The default OTel + OpenInference span has &lt;code&gt;llm.tokens.input&lt;/code&gt; and &lt;code&gt;llm.tokens.output&lt;/code&gt; as numeric attributes. Useful for trace-level debugging. Not useful for per-team cost regressions, because nothing groups traces by team.&lt;/p&gt;

&lt;p&gt;The 3 attribute additions that earned their keep:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;team.id&lt;/code&gt;. Tagged on every span at the gateway layer (before the call routes to the LLM provider). This is the column that makes the cost rollup possible. Without it, you can attribute spend to an org but not to a team inside the org.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;feature.id&lt;/code&gt;. The product feature that triggered the call (chat_assistant, summarizer, rag_search). Lets you see when one feature's token cost spikes vs the overall trend.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;llm.model&lt;/code&gt;. Already standard in OpenInference but worth flagging: without this, you cannot separate a cheap mini-tier model's spikes from a frontier model's spikes when both are in the same feature.&lt;/p&gt;

&lt;p&gt;The daily Tempo + Grafana query (TraceQL):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight prometheus"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;resource&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="na"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"llm-gateway"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="err"&gt;|&lt;/span&gt; &lt;span class="nb"&gt;histogram_quantile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.95&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;llm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;output_total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;team&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;feature&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;llm&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The alert rule: page when 7-day-trailing average of output-tokens-per-team-per-feature jumps more than 2x week-over-week. We caught a runaway retry loop last quarter that the org-level spend dashboard missed because the total stayed within budget while one team's bill quietly doubled.&lt;/p&gt;

&lt;p&gt;What we tried and dropped:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;user.id&lt;/code&gt; tagging: privacy concerns at scale, and the rollup-by-team covered the use case.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;request.id&lt;/code&gt; tagging: redundant with the trace_id; just adds cardinality.&lt;/p&gt;

&lt;p&gt;Drafted with AI assistance, edited and verified by author.&lt;/p&gt;

</description>
      <category>observability</category>
      <category>ai</category>
      <category>llm</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
