<?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: Talha Anwar</title>
    <description>The latest articles on DEV Community by Talha Anwar (@ailegend).</description>
    <link>https://dev.to/ailegend</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%2F4067988%2F23197b0f-0d13-439f-8ceb-931a658a3403.png</url>
      <title>DEV Community: Talha Anwar</title>
      <link>https://dev.to/ailegend</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ailegend"/>
    <language>en</language>
    <item>
      <title>I published AI research papers and have 6 YoE building backends. Why do AI coding agents make me feel completely dumb?</title>
      <dc:creator>Talha Anwar</dc:creator>
      <pubDate>Sat, 22 Aug 2026 08:02:26 +0000</pubDate>
      <link>https://dev.to/ailegend/i-published-ai-research-papers-and-have-6-yoe-building-backends-why-do-ai-coding-agents-make-me-4igm</link>
      <guid>https://dev.to/ailegend/i-published-ai-research-papers-and-have-6-yoe-building-backends-why-do-ai-coding-agents-make-me-4igm</guid>
      <description>&lt;p&gt;There is a running joke that AI researchers can't write production software, and backend engineers don't understand research math.&lt;/p&gt;

&lt;p&gt;I happen to do both. I’ve published deep learning papers in top conferences, won ML competitions, and spent the last 6 years as a founding engineer building production APIs, async queues, and distributed systems. I read dense math papers and trace database deadlocks for fun.&lt;/p&gt;

&lt;p&gt;Yet, on any given day, an AI coding agent can "fix a bug" in 20 minutes and make me question if I even know how to read English.&lt;/p&gt;




&lt;h3&gt;
  
  
  The "10x Productivity" Myth vs. Management Expectations
&lt;/h3&gt;

&lt;p&gt;Management reads one article on Twitter and suddenly expects everyone to ship features at &lt;strong&gt;10x speed&lt;/strong&gt;.&lt;br&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%2Frwf4edstvm2dv28sdnbe.gif" 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%2Frwf4edstvm2dv28sdnbe.gif" alt="typing meme" width="320" height="180"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here is what that "10x velocity" actually looks like when hooking up a RAG chatbot to external platform webhooks and syncing vector stores:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;1 hour:&lt;/strong&gt; Planning the sync logic, payload schema, and chunking strategy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;20 minutes:&lt;/strong&gt; The AI agent writes 400 lines of code across 5 files to fix a streaming concurrency bug. Tests pass. The app runs. Pure magic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;4 hours:&lt;/strong&gt; Me sitting frozen in front of my monitor trying to reverse-engineer why it bypassed our API gateway, restructured our entire websocket pipeline, and injected three layers of mysterious custom middleware.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The code is "done" in 20 minutes, but now I’m in a full sweat because tomorrow morning in standup, someone is going to ask:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;"Hey, what was actually broken with the webhook listener?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;And what am I supposed to say?&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%2Fhyensadninsvnygjf49p.gif" 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%2Fhyensadninsvnygjf49p.gif" alt="Sweating in code review" width="504" height="312"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;"Honestly? The AI wizard cast a spell on line 42 and now it works. Please don't ask me anything else."&lt;/em&gt;&lt;/p&gt;




&lt;h3&gt;
  
  
  The 4-Stage Spiral of Shame
&lt;/h3&gt;

&lt;p&gt;When I don't understand why the agent chose a specific architecture to fix the bug, I ask it to explain. That’s when the psychological damage begins, with my confidence dropping on every single attempt:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Attempt 1: The Pretentious Professor&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I ask: &lt;em&gt;"Why did you restructure the vector search callback and event listener this way?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;It replies with a 500-word essay containing words I haven't seen since a GRE test. I re-read it four times. My brain goes numb. &lt;em&gt;Is my English broken? Am I secretly bad at backend engineering?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Attempt 2: The Polite Request&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I say: &lt;em&gt;Please explain in simpler, direct terms without the jargon.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;It uses everyday words, but strings them together so backwards that the logic eats its own tail:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Of course! We just make sure that when the webhook fires, the thing checking the data knows that the data being checked isn't the same data that triggered the check in the first place.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;No fancy jargon, but my brain is completely melted trying to parse the sentence structure. Is my reading comprehension broken? Confidence drops another 25%.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Attempt 3: The Desperation Move&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I type: &lt;em&gt;"Explain like I'm 15."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The AI instantly decides I have the cognitive ability of a toddler:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"Imagine your vector database is a magical post office! When a user sends a message, a friendly owl named Barnaby flies across the forest to find the matching letter..."&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Attempt 4: Installing the "Fixes"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;At this point, I started searching for community prompt hacks. I even installed the popular &lt;a href="https://github.com/ayghri/i-have-adhd" rel="noopener noreferrer"&gt;&lt;code&gt;ayghri/i-have-adhd&lt;/code&gt;&lt;/a&gt; skill to force the agent into concise, no-fluff, step-by-step bullet points.&lt;/p&gt;

&lt;p&gt;And you know what happened? Now the agent gives me blunt, hyper-compressed steps, and I &lt;em&gt;still&lt;/em&gt; spend 30 minutes staring at bullet point #2 trying to understand the mathematical jump it made. Now I feel twice as dumb because I can't even digest the ADHD-friendly version.&lt;/p&gt;

&lt;h2&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%2Fdmfkwnfbhosonanvhl1q.gif" alt="Ai coding depression" width="386" height="480"&gt;
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Real Problem: Reverse-Engineering Fatigue
&lt;/h3&gt;

&lt;p&gt;Getting an AI-generated integration or bug fix to run is easy. &lt;strong&gt;Owning the code&lt;/strong&gt; in a team of senior engineers is the hard part.&lt;/p&gt;

&lt;p&gt;When a teammate looks at the PR and asks:&lt;br&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%2Fflwygoc6c8ki0tqsulc7.gif" 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%2Fflwygoc6c8ki0tqsulc7.gif" alt="code review" width="480" height="245"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;"Why did we switch to this custom event-emitter pattern instead of our standard message queue?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I can't reply:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;"Well, Barnaby the Owl needed a place to rest his wings."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;So you spend half your day decoding AI logic just to translate it into normal human engineer terms. We didn't eliminate the coding bottleneck—we just traded writing code for full-time reverse-engineering.&lt;/p&gt;




&lt;h3&gt;
  
  
  Let's Settle This in the Comments:
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;How do you handle standup when AI fixed a bug but you have zero idea how?&lt;/strong&gt; Do you fake confidence or admit the robot did it?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What is the most insulting analogy an AI has ever given you&lt;/strong&gt; when you asked it to "simplify"?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What exact system prompt do you use&lt;/strong&gt; to make your agent talk like a normal, grounded senior dev without getting a PhD thesis or a fairy tale?&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>discuss</category>
      <category>productivity</category>
      <category>ai</category>
      <category>programming</category>
    </item>
    <item>
      <title>Our SDK Cached One User's Answer and Served It to the Next One Who Asked</title>
      <dc:creator>Talha Anwar</dc:creator>
      <pubDate>Tue, 11 Aug 2026 19:21:55 +0000</pubDate>
      <link>https://dev.to/ailegend/our-sdk-cached-one-users-answer-and-served-it-to-the-next-one-who-asked-55h7</link>
      <guid>https://dev.to/ailegend/our-sdk-cached-one-users-answer-and-served-it-to-the-next-one-who-asked-55h7</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/bugsmash"&gt;DEV's Summer Bug Smash: Clear the Lineup&lt;/a&gt; powered by &lt;a href="https://sentry.io/" rel="noopener noreferrer"&gt;Sentry&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Project Overview
&lt;/h2&gt;

&lt;p&gt;I work on &lt;a href="https://acruxcore.com" rel="noopener noreferrer"&gt;AcruxCore&lt;/a&gt;, an LLM ops platform with a published SDK in &lt;a href="https://www.npmjs.com/package/@acruxcoreai/sdk" rel="noopener noreferrer"&gt;TypeScript&lt;/a&gt; and &lt;a href="https://pypi.org/project/acruxcore/" rel="noopener noreferrer"&gt;Python&lt;/a&gt;. Both SDKs render prompts client-side against a small in-memory cache, so a hot path doesn't make a network round trip on every call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rendered&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;hub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;renderPrompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rag-chat&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;production&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;question&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;renderPrompt&lt;/code&gt; fetches the templated messages once, then serves that same result out of the cache for &lt;code&gt;cacheTtl&lt;/code&gt; milliseconds (60 seconds by default) before checking the API again. That's the whole feature. Two bugs were hiding in how "serve that same result" actually decided what counted as "the same."&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug Fix or Performance Improvement
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Correctness bug — the cache key didn't include the one thing that changes on every real call: the variables.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The key was built from three fields:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cacheKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;hashApiKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;alias&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;API key, prompt name, alias. Nothing about what you're actually asking. &lt;strong&gt;Two different renders of the same prompt with different variables, inside the same TTL window, hashed to the identical key&lt;/strong&gt; — so the second call got the first call's cached answer back, not its own.&lt;/p&gt;

&lt;p&gt;This wasn't a hypothetical. It was live in our own docs. The &lt;a href="https://docs.acruxcore.com/docs/tutorials/build-a-rag-agent-without-the-gateway" rel="noopener noreferrer"&gt;RAG-without-a-gateway tutorial&lt;/a&gt; renders the same prompt per question:&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;rendered&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;hub&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;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rag-chat&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;production&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                                     &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;context&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;question&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ask "Where is my order?", then ask "How do I refund?" within 60 seconds, and the second call's &lt;code&gt;rendered.messages&lt;/code&gt; still says "Where is my order?" — while the trace viewer, which logs what you &lt;em&gt;passed in&lt;/em&gt;, correctly shows "How do I refund?" as the input. &lt;strong&gt;The trace and the actual prompt sent to the model disagree, and nothing errors.&lt;/strong&gt; It's also exactly why a sibling tutorial, &lt;a href="https://docs.acruxcore.com/docs/tutorials/build-a-tool-calling-agent-in-python-sdk" rel="noopener noreferrer"&gt;the tool-calling agent guide&lt;/a&gt;, never puts the question in a template variable at all — it appends it to the messages array in code instead, a design forced by this bug rather than a stylistic choice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The second bug was in the same file, one function down:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;age&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cacheTtl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// fresh — serve it directly&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// stale — serve the old value anyway, refresh in the background&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Setting &lt;code&gt;cacheTtl: 0&lt;/code&gt; to turn caching off did the opposite. &lt;code&gt;age &amp;lt; 0&lt;/code&gt; is never true, so every single call took the &lt;strong&gt;stale&lt;/strong&gt; branch: return the cached value immediately, and kick off a background refetch nobody's waiting on. A TTL of &lt;code&gt;0&lt;/code&gt; didn't mean "don't cache" — it meant "always serve whatever was cached first, forever." There was no setting that actually disabled the cache.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two bugs, one root cause
&lt;/h3&gt;

&lt;p&gt;Both come from the same gap: the cache didn't have a way to represent "this input is different" or "this call opted out." One key was too coarse; one flag had no matching branch. Fixing them meant adding both.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;The fix is public in our mirror — same logic in both SDKs, shown here in TypeScript:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/AcruxCore/AcruxCore/blob/main/packages/sdk/src/client.ts" rel="noopener noreferrer"&gt;&lt;code&gt;packages/sdk/src/client.ts&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Bug 1 — hash the variables into the key:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/**
 * Serialises a value with object keys in sorted order, at every depth, so that
 * two variable maps that differ only in insertion order produce one string.
 */&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;stableStringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;object&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;null&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`[&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stableStringify&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;]`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;entries&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(([,&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;(([&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;b&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="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;b&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`{&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(([&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;stableStringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;}`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;hashVariables&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;variables&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;createHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;stableStringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;variables&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;slice&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;16&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;- const cacheKey = `${hashApiKey(this.apiKey)}:${name}:${alias}`;
&lt;/span&gt;&lt;span class="gi"&gt;+ const cacheKey = `${hashApiKey(this.apiKey)}:${name}:${alias}:${hashVariables(variables)}`;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Bug 2 — make a non-positive TTL actually skip the cache, on read and write:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;  async renderPrompt(name, alias, variables = {}) {
&lt;span class="gi"&gt;+   // A non-positive TTL means "never serve a cached render" — skip the read and
+   // pass a null key so the write side skips too, instead of filling the cache
+   // with entries no read path will ever consult.
+   if (this.cacheTtl &amp;lt;= 0) {
+     return this._fetchAndCache(name, alias, variables, null);
+   }
&lt;/span&gt;    const cache = getCache(DEFAULT_MAX_CACHE_SIZE);
    const cacheKey = `...`;
    ...
  }
&lt;span class="err"&gt;
&lt;/span&gt;  async _fetchAndCache(name, alias, variables, cacheKey /* now: string | null */) {
    ...
&lt;span class="gd"&gt;-   const cache = getCache(DEFAULT_MAX_CACHE_SIZE);
-   cache.set(cacheKey, { value, fetchedAt: Date.now() });
&lt;/span&gt;&lt;span class="gi"&gt;+   if (cacheKey !== null) {
+     const cache = getCache(DEFAULT_MAX_CACHE_SIZE);
+     cache.set(cacheKey, { value, fetchedAt: Date.now() });
+   }
&lt;/span&gt;    return value;
  }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Technical Approach
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why hash sorted keys at every depth instead of &lt;code&gt;JSON.stringify(variables)&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;JSON.stringify&lt;/code&gt; preserves insertion order. &lt;code&gt;{ name: 'Alice', city: 'Lahore' }&lt;/code&gt; and &lt;code&gt;{ city: 'Lahore', name: 'Alice' }&lt;/code&gt; are the same request to any caller, but they'd stringify to two different strings — and split into two cache entries for what should be one. A caller that builds the variables object by spreading defaults first (&lt;code&gt;{ ...defaults, question }&lt;/code&gt;) versus spreading them last would silently halve their own cache hit rate. Sorting keys at every depth, including inside nested objects, makes key order invisible to the cache. A dedicated unit test pins exactly this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;treats the same variables in a different key order as one cache entry&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;global&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fetch&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;ReturnType&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;vi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fn&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;mockResolvedValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;makeOkResponse&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;hub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;renderPrompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;my-prompt&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;production&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Alice&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;city&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Lahore&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;hub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;renderPrompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;my-prompt&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;production&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;city&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Lahore&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Alice&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;global&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toHaveBeenCalledTimes&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="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Why not just delete the "serve stale, refresh in background" path
&lt;/h3&gt;

&lt;p&gt;That's a real reliability feature, not accidental complexity: if the API is briefly unreachable, a caller with a warm cache keeps getting an answer instead of an error. Deleting it to fix the &lt;code&gt;cacheTtl: 0&lt;/code&gt; bug would trade one bug for a regression. The fix instead adds a third state — "don't cache at all" — that bypasses the stale-serving branch entirely rather than replacing it, so &lt;code&gt;cacheTtl: 30_000&lt;/code&gt; still falls back to a stale value during an outage, and only &lt;code&gt;cacheTtl: 0&lt;/code&gt; gives that up on purpose. Both behaviors are documented side by side in the README so the trade-off is explicit, not discovered during an incident.&lt;/p&gt;

&lt;h3&gt;
  
  
  Proving the fix actually fixes it, against a real API
&lt;/h3&gt;

&lt;p&gt;Unit tests mock &lt;code&gt;fetch&lt;/code&gt;. The two bugs above are about what a &lt;em&gt;real&lt;/em&gt; render pipeline does with &lt;em&gt;real&lt;/em&gt; prompt versions, so the integration suite hits an actual Express app and a real Postgres database instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;renderPrompt renders the new variables inside the cache window instead of replaying the first render&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// ...creates a real prompt, a real version with "Question: {{ question }}"...&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hub&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;acruxcore&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;baseUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;cacheTtl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;600&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;maxRetries&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;first&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;hub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;renderPrompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;production&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;question&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Where is my order?&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;second&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;hub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;renderPrompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;production&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;question&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;How do I refund?&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;repeat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;hub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;renderPrompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;production&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;question&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Where is my order?&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;first&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;messages&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="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Question: Where is my order?&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;second&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;messages&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="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Question: How do I refund?&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;repeat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;messages&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="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Question: Where is my order?&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// still cached, not evicted&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;I reverted &lt;code&gt;src/client.ts&lt;/code&gt; and re-ran this test before writing the fix.&lt;/strong&gt; It failed on exactly the assertion you'd expect — &lt;code&gt;second.messages[0].content&lt;/code&gt; came back as &lt;code&gt;'Question: Where is my order?'&lt;/code&gt;, the first question, not the second. That's the failure this whole post describes, caught by a test instead of a support ticket.&lt;/p&gt;

&lt;p&gt;A second integration test does the same for Bug 2 — &lt;code&gt;cacheTtl: 0&lt;/code&gt;, promote a new prompt version, confirm the very next call sees it instead of serving the version that was cached before the promotion.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two existing tests were asserting the bug
&lt;/h3&gt;

&lt;p&gt;The old unit test for caching was itself proof the bug had never been tested against a variable change:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;- it('caches the result — second call does not hit fetch', async () =&amp;gt; {
&lt;/span&gt;&lt;span class="gi"&gt;+ it('caches the result — repeating the same variables does not hit fetch', async () =&amp;gt; {
&lt;/span&gt;    await hub.renderPrompt('my-prompt', 'production', { name: 'Alice' });
&lt;span class="gd"&gt;-   await hub.renderPrompt('my-prompt', 'production', { name: 'Bob' });
&lt;/span&gt;&lt;span class="gi"&gt;+   await hub.renderPrompt('my-prompt', 'production', { name: 'Alice' });
&lt;/span&gt;    expect(global.fetch).toHaveBeenCalledTimes(1);
  });
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It rendered Alice, then Bob, and asserted &lt;strong&gt;one&lt;/strong&gt; fetch call — which is precisely the bug, written down as a passing test. Fixing the implementation without touching this test would have left it green while lying about what "caching" meant. It had to be rewritten to assert the correct behavior — different inputs, different fetches — before the fix could be trusted.&lt;/p&gt;

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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;before&lt;/th&gt;
&lt;th&gt;after&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;second &lt;code&gt;renderPrompt&lt;/code&gt; call, different variables, inside TTL&lt;/td&gt;
&lt;td&gt;first call's cached content (wrong)&lt;/td&gt;
&lt;td&gt;its own rendered content&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;repeated call, same variables as the first, inside TTL&lt;/td&gt;
&lt;td&gt;cached content (correct, coincidentally)&lt;/td&gt;
&lt;td&gt;cached content (correct, by design)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;cacheTtl: 0&lt;/code&gt;, two calls&lt;/td&gt;
&lt;td&gt;1st content served both times&lt;/td&gt;
&lt;td&gt;fresh API call both times&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;entries written to the cache with &lt;code&gt;cacheTtl: 0&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;one per call&lt;/td&gt;
&lt;td&gt;zero&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;variables passed in a different key order&lt;/td&gt;
&lt;td&gt;new cache entry (cache pressure ↑)&lt;/td&gt;
&lt;td&gt;same entry, one hit&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Test suite after the fix: 111 TS unit, 14 TS integration (both new ones failing-then-passing verified against the revert), 112 Python, clean &lt;code&gt;tsc --noEmit&lt;/code&gt;, clean docs build.&lt;/p&gt;

&lt;h3&gt;
  
  
  What I'm carrying to the next cache I write
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A cache key has to include everything the caller varies, not just everything the caller identifies with.&lt;/strong&gt; API key and prompt name answer "who and what" — they don't answer "with what input," and that's the part that actually changes per call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Off" needs its own code path, not a boundary value on an existing one.&lt;/strong&gt; &lt;code&gt;0&lt;/code&gt; looked like it should mean "never fresh, so never serve cached" — the actual check (&lt;code&gt;age &amp;lt; ttl&lt;/code&gt;) made it mean the opposite. A dedicated bypass, not a clever value, is what you want for a real off-switch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A passing cache test that only ever sends one input is testing the plumbing, not the cache key.&lt;/strong&gt; The bug survived in production because the one existing test for "does caching work" happened to use two different inputs and asserted the outcome the bug produces.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;If your SDK caches a rendered or computed result keyed by anything less than the full input, it's worth a five-minute check today: call it twice with different arguments inside your TTL window, and read back what you actually get. What did yours return?&lt;/p&gt;

</description>
      <category>bugsmash</category>
      <category>devchallenge</category>
      <category>devops</category>
      <category>ai</category>
    </item>
    <item>
      <title>LLM Observability Tools Compared: Langfuse vs Helicone vs Opik vs Phoenix</title>
      <dc:creator>Talha Anwar</dc:creator>
      <pubDate>Tue, 11 Aug 2026 19:00:00 +0000</pubDate>
      <link>https://dev.to/ailegend/llm-observability-tools-compared-langfuse-vs-helicone-vs-opik-vs-phoenix-2596</link>
      <guid>https://dev.to/ailegend/llm-observability-tools-compared-langfuse-vs-helicone-vs-opik-vs-phoenix-2596</guid>
      <description>&lt;h2&gt;
  
  
  The first trace looks the same everywhere
&lt;/h2&gt;

&lt;p&gt;Wrap your LLM client with any open-source observability SDK — Langfuse, Helicone, Opik, Phoenix, doesn't matter which — and the first result is identical: a request goes out, a span shows up in a dashboard with the prompt, the response, and how long it took. That part is solved. Every landing page in this space makes the same claim, and for a first trace, they're all telling the truth.&lt;/p&gt;

&lt;p&gt;The next question every team asks is also the same one: can this tool act on a trace, not just show me one? Score it. Alert someone. Block it before it ships. On the surface that looks solved too — all four platforms have an "Evaluation" tab and a "Settings" page, so it's tempting to assume they converge there as well.&lt;/p&gt;

&lt;p&gt;They don't. I have all four self-hosted right now, and going through each dashboard side by side, four specific capabilities stopped me — each one a genuine standout for exactly one platform, and absent from at least two of the other three. Picking a tool because "it does observability" and finding out six months later that the one feature you actually needed lives in a dashboard you didn't choose is an expensive way to learn this.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Langfuse&lt;/strong&gt; — an LLM judge that scores every live production trace on its own, no manual review step required.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Helicone&lt;/strong&gt; — a dedicated rate-limit rule builder, a different object from a simple spend cap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Opik&lt;/strong&gt; — topic and PII guardrails that inspect a call's input or output directly, configurable per project.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Phoenix&lt;/strong&gt; (built by Arize) — PXI, an AI agent docked inside the dashboard that already knows the trace you're looking at.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  At a glance
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;Langfuse&lt;/th&gt;
&lt;th&gt;Helicone&lt;/th&gt;
&lt;th&gt;Opik&lt;/th&gt;
&lt;th&gt;Phoenix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Auto-scores live traces, no manual trigger&lt;/td&gt;
&lt;td&gt;✅ LLM-as-a-judge&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅ Online evaluation rules (same idea, different name)&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dedicated rate-limit rule builder&lt;/td&gt;
&lt;td&gt;❌ (alerts only)&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Topic / PII guardrails on the call itself&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI agent built into the dashboard&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅ PXI&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Opik is the only platform that clears two of the four rows. Full breakdown below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Langfuse: the judge that never sleeps
&lt;/h2&gt;

&lt;p&gt;Most platforms' "evaluation" means a human clicks run against a dataset. Langfuse's Evaluators page does something different: you write an eval template — a scoring prompt, a model, and the trace variables it should read — and it fires that template against every new production trace as it lands. There's no "run experiment" button in the loop at all.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;That's the deciding detail if you're comparing tools on this feature: is scoring something you trigger, or something that's already running before you open the dashboard?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://langfuse.com/docs/evaluation/evaluation-methods/llm-as-a-judge" rel="noopener noreferrer"&gt;Langfuse's own docs&lt;/a&gt; walk through setting one up. Opik's Online evaluation rules do the same job under a different name (see the table above), so Langfuse doesn't own this category outright — but it's the platform where this run found it first, with execution tracing on the evaluator itself as a bonus.&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%2Fswoff7sv1t5krsq8m1va.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%2Fswoff7sv1t5krsq8m1va.png" alt="Langfuse's " width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; if unattended, always-on trace scoring is the requirement, Langfuse and Opik are the two platforms here built for it. Helicone and Phoenix both still need someone to explicitly trigger a run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Helicone: a rate-limit rule, not a spend cap
&lt;/h2&gt;

&lt;p&gt;A spend budget answers one question: how much can this app cost before it stops. A rate-limit rule answers a different one: how many requests can this &lt;em&gt;specific user&lt;/em&gt; make in &lt;em&gt;this window&lt;/em&gt;, independent of what anything costs.&lt;/p&gt;

&lt;p&gt;Helicone's Monitor → Rate Limits page has a "Create Rule" builder for exactly that second question. A rule is a quota, a time window, a unit (requests, tokens, or dollars), and a segment — user, team, or global — expressed as a header like &lt;code&gt;10;w=1000;u=cents;s=user&lt;/code&gt;, which reads as "10 cents of requests per 1000 seconds, per user." &lt;a href="https://docs.helicone.ai/features/advanced-usage/custom-rate-limits" rel="noopener noreferrer"&gt;Helicone's docs&lt;/a&gt; cover the full syntax.&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%2Fwgxi213k9vt9s9qtv1cc.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%2Fwgxi213k9vt9s9qtv1cc.png" alt="Helicone's Rate Limit Rules tab: " width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; if one noisy user needs throttling without shutting the whole app's budget down for everyone else, that's a rate-limit rule, and Helicone is the only one of the four that ships one as its own object.&lt;/p&gt;

&lt;h2&gt;
  
  
  Opik: guardrails that inspect the call itself
&lt;/h2&gt;

&lt;p&gt;Everything above scores or alerts &lt;em&gt;after&lt;/em&gt; a call happens. Opik's guardrails act on the call directly — before or after the model sees it, depending on which check you enable.&lt;/p&gt;

&lt;p&gt;The "Set a guardrail" panel has two checks: a Topic guardrail (a sensitivity slider plus a comma-separated restricted-topics list) and a PII guardrail (per-category toggles — credit card number, phone number, email, and more — each with its own sensitivity threshold). Both ship with a ready &lt;code&gt;opik.guardrails&lt;/code&gt; Python snippet. &lt;a href="https://www.comet.com/site/blog/opik-guardrails-release/" rel="noopener noreferrer"&gt;Comet's announcement post&lt;/a&gt; covers what triggers each one.&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%2Fedyt8q84ko7jfhjlzvox.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%2Fedyt8q84ko7jfhjlzvox.png" alt="Opik's " width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; if the requirement is blocking a category of content before it ships, not scoring it afterward, guardrails are the only feature in this list built for that — and Opik is the only platform that has both this and an auto-scoring rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phoenix: PXI, the assistant that already knows your traces
&lt;/h2&gt;

&lt;p&gt;PXI is a chat panel that lives on every Phoenix page, seeded with suggestions like "Find critical issues" and "Explain a concept." The detail that makes it more than a docs chatbot bolted onto the product: it reads from whatever page you're already on, so it can answer questions about your own traces without you pasting a trace ID or copying a payload into it. &lt;a href="https://arize.com/docs/phoenix/pxi" rel="noopener noreferrer"&gt;Arize's docs&lt;/a&gt; describe it as an agent that debugs traces, builds evaluators, and runs experiments from the context you're already viewing.&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%2Ftqv5hentoqd2fkrwx0kk.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%2Ftqv5hentoqd2fkrwx0kk.png" alt="Phoenix's " width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; if "explain what's happening in this trace, right now, without switching tools" is the requirement, Phoenix is the only platform here with an assistant scoped to the page in front of you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verdict
&lt;/h2&gt;

&lt;p&gt;None of these four features decide which tool is "best" — they decide which tool fits a requirement you may not have hit yet. A team that never needs unattended scoring won't miss Langfuse's judge or Opik's online evaluation. A team running one internal chatbot for five people won't miss Helicone's rate-limit rules.&lt;/p&gt;

&lt;p&gt;But if you're picking a platform today assuming "observability" is a solved, interchangeable checkbox, this is the list of four ways that assumption breaks: automated live-trace scoring, a real rate-limit object, content guardrails, and an in-dashboard agent. Check which of the four you'll actually need before you wrap your first client, not after.&lt;/p&gt;

&lt;p&gt;Which of these four would you actually use — and is there a fifth dashboard feature I missed that deserves its own row?&lt;/p&gt;

&lt;h2&gt;
  
  
  Verdict
&lt;/h2&gt;

&lt;p&gt;None of these four features decide which tool is "best" — they decide which tool fits a requirement you may not have hit yet. A team that never needs unattended scoring won't miss Langfuse's judge or Opik's online evaluation. A team running one internal chatbot for five people won't miss Helicone's rate-limit rules.&lt;/p&gt;

&lt;p&gt;But if you're picking a platform today assuming "observability" is a solved, interchangeable checkbox, this is the list of four ways that assumption breaks: automated live-trace scoring, a real rate-limit object, content guardrails, and an in-dashboard agent. Check which of the four you'll actually need before you wrap your first client, not after.&lt;/p&gt;

&lt;p&gt;This piece only had room for one standout feature per platform. AcruxCore has compared 8 &lt;a href="https://docs.acruxcore.com/blog/hands-on-llm-ops-comparison" rel="noopener noreferrer"&gt;LLM observability tools&lt;/a&gt; in more depth, aspect by aspect.&lt;/p&gt;

&lt;p&gt;Which of these four would you actually use — and is there a fifth dashboard feature I missed that deserves its own row?&lt;/p&gt;

</description>
      <category>llm</category>
      <category>saas</category>
      <category>ai</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Opik vs Langfuse: Where Two Open-Source LLM Observability Tools Actually Agree (and Where They Don't)</title>
      <dc:creator>Talha Anwar</dc:creator>
      <pubDate>Sun, 09 Aug 2026 19:55:19 +0000</pubDate>
      <link>https://dev.to/ailegend/opik-vs-langfuse-where-two-open-source-llm-observability-tools-actually-agree-and-where-they-2anp</link>
      <guid>https://dev.to/ailegend/opik-vs-langfuse-where-two-open-source-llm-observability-tools-actually-agree-and-where-they-2anp</guid>
      <description>&lt;h2&gt;
  
  
  The box both of them check
&lt;/h2&gt;

&lt;p&gt;If you're adding your first bit of visibility into an LLM app, the simplest version is a &lt;code&gt;print()&lt;/code&gt; statement before the API call. That's enough while you're the only one testing it.&lt;/p&gt;

&lt;p&gt;The natural next step is to swap that print statement for a real tracing SDK. You wrap your client, make a call, and a UI shows you a span with the prompt, the response, and how long it took. That feels solved — until your team grows past one prompt and one person testing it by hand.&lt;/p&gt;

&lt;p&gt;That's the point where the real friction shows up: conditional prompt logic, a way to diff two prompt versions before you ship one, a way to catch a call your app got wrong before a user complains, and a currently-live guardrail against risky output. "It produces a trace" doesn't cover any of that. And the frustrating part is that this doesn't fail loudly — most open-source, self-hosted LLM observability tools &lt;em&gt;look&lt;/em&gt; interchangeable from their landing pages, because they'd all check the same three boxes: open source, self-hosted, traces your calls.&lt;/p&gt;

&lt;p&gt;Opik (built by Comet ML) and Langfuse are two of the most-used tools in that box. So we tested the actual boundary: we built the exact same prompt — a support-triage agent that changes tone for VIP customers and lists their open tickets — on both, independently, then set the two runs side by side.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Both platforms hit the &lt;strong&gt;exact same wall&lt;/strong&gt; on prompt templating — neither supports real conditional logic, only flat variable substitution.&lt;/li&gt;
&lt;li&gt;Both confirmed, independently, that their &lt;strong&gt;Playground alone does not produce a trace&lt;/strong&gt; — only a wrapped SDK call does.&lt;/li&gt;
&lt;li&gt;They diverge sharply on &lt;strong&gt;prompt versioning&lt;/strong&gt;: Opik ships a real diff view and environment labels, Langfuse's version history has neither.&lt;/li&gt;
&lt;li&gt;Their &lt;strong&gt;measured client-side overhead&lt;/strong&gt; is different in kind, not just number — and the two numbers come from two separate test sessions, not one shared benchmark.&lt;/li&gt;
&lt;li&gt;Both have a feature that does the &lt;strong&gt;same job under a different name&lt;/strong&gt; — auto-scoring live production traffic.&lt;/li&gt;
&lt;li&gt;They've drifted into genuinely different territory: &lt;strong&gt;guardrails and regression test suites&lt;/strong&gt; (Opik) versus &lt;strong&gt;threshold alerting&lt;/strong&gt; (Langfuse).&lt;/li&gt;
&lt;li&gt;And as of five months ago, they're no longer comparable as two independent startups — &lt;strong&gt;Langfuse was acquired by ClickHouse in January 2026.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  At a glance
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Aspect&lt;/th&gt;
&lt;th&gt;Opik&lt;/th&gt;
&lt;th&gt;Langfuse&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Prompt templating&lt;/td&gt;
&lt;td&gt;Flat &lt;code&gt;{{variable}}&lt;/code&gt; only&lt;/td&gt;
&lt;td&gt;Flat &lt;code&gt;{{variable}}&lt;/code&gt; only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Playground produces a trace&lt;/td&gt;
&lt;td&gt;No — confirmed hands-on&lt;/td&gt;
&lt;td&gt;No — confirmed hands-on&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Version diff&lt;/td&gt;
&lt;td&gt;Real red/green diff view&lt;/td&gt;
&lt;td&gt;No diff control found&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Environment labels&lt;/td&gt;
&lt;td&gt;"Deploy to" tags a version&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;production&lt;/code&gt;/&lt;code&gt;latest&lt;/code&gt; labels&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Measured SDK overhead&lt;/td&gt;
&lt;td&gt;+102ms (real, CI doesn't cross zero)&lt;/td&gt;
&lt;td&gt;+15ms (not distinguishable from zero)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auto-scores live traffic&lt;/td&gt;
&lt;td&gt;Online Evaluation rules&lt;/td&gt;
&lt;td&gt;LLM-as-a-judge evaluators&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Standout feature&lt;/td&gt;
&lt;td&gt;Guardrails (topic + PII) and test suites&lt;/td&gt;
&lt;td&gt;Monitors — alerts to Slack/Webhooks/GitHub&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GitHub stars (checked today)&lt;/td&gt;
&lt;td&gt;21,252&lt;/td&gt;
&lt;td&gt;32,778&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Owned by&lt;/td&gt;
&lt;td&gt;Comet ML (in-house product)&lt;/td&gt;
&lt;td&gt;ClickHouse (acquired Jan 2026)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Full breakdown below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where they hit the exact same wall: templating
&lt;/h2&gt;

&lt;p&gt;Both platforms' prompt editors only do flat &lt;code&gt;{{variable}}&lt;/code&gt; substitution — no &lt;code&gt;{% if %}&lt;/code&gt;, no &lt;code&gt;{% for %}&lt;/code&gt;. We needed a VIP-customer branch and a rendered list of open tickets, and on both platforms we had to flatten that logic into plain text ourselves before the prompt ever saw it, keeping only the simple fields (like &lt;code&gt;{{company}}&lt;/code&gt;) as real template variables.&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%2Fwi4ej995ehnwl2l0kp5q.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%2Fwi4ej995ehnwl2l0kp5q.png" alt="Opik's New chat prompt dialog showing flat {{company}} and {{customer_message}} variable substitution with no conditional syntax available" width="800" height="775"&gt;&lt;/a&gt;&lt;br&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%2Ffiuppd14vci34fn9ec6d.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%2Ffiuppd14vci34fn9ec6d.png" alt="Langfuse's create-prompt screen with a commit message noting the prompt was adapted to flat variables since the platform only supports substitution, not conditionals" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Neither product hides this — it's a real, shared design choice, not a bug in either one. If your prompts need branching logic, both push that logic back onto your own code (a pre-render step) rather than into the template itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where they hit the exact same wall, again: the Playground doesn't trace
&lt;/h2&gt;

&lt;p&gt;This one surprised us enough to check twice on each platform. Running a prompt from Opik's Playground left its Logs tab at "No traces yet." Running a prompt from Langfuse's Playground did the same thing — no trace appeared until we called the SDK-wrapped client instead. On both, the interactive "try it in the UI" surface and the "get a trace out of it" surface are two separate things, which is easy to miss if you only ever click around the Playground before wiring in the SDK.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where they genuinely diverge: prompt versioning
&lt;/h2&gt;

&lt;p&gt;Opik has a real "Diff" button — edit a prompt to create v2, and it shows the old system message in red strikethrough next to the new one in green. It also has a "Deploy to" menu that tags a specific version &lt;code&gt;production&lt;/code&gt;, &lt;code&gt;staging&lt;/code&gt;, or &lt;code&gt;development&lt;/code&gt; with a colored badge.&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%2Fvxgbq9s8sgjuhfui5qa0.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%2Fvxgbq9s8sgjuhfui5qa0.png" alt="Opik's Compare v1 to v2 panel, showing the old system message in red strikethrough on the left and the new message in green on the right" width="800" height="775"&gt;&lt;/a&gt;&lt;br&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%2Fki85qtty5un6kdporbdg.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%2Fki85qtty5un6kdporbdg.png" alt="Opik's Deploy to menu, with v2 tagged production via a green badge next to the version number" width="800" height="775"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Langfuse has environment labels too (&lt;code&gt;production&lt;/code&gt; / &lt;code&gt;latest&lt;/code&gt;), but we could not find any diff or compare control anywhere in its version history UI — each version sits in a flat list, and comparing two of them means opening both and reading side by side yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the numbers genuinely differ (and why you shouldn't stack them)
&lt;/h2&gt;

&lt;p&gt;We measured client-side SDK overhead for both platforms — but in two separate benchmark sessions, run on different days as part of two different posts, each with its own direct-to-provider baseline. That matters: network conditions differ session to session, so these two numbers aren't from one shared interleaved test and shouldn't be read as a single ranked table.&lt;/p&gt;

&lt;p&gt;With that caveat stated plainly:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Opik SDK&lt;/th&gt;
&lt;th&gt;Langfuse SDK&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Measured overhead&lt;/td&gt;
&lt;td&gt;+102ms&lt;/td&gt;
&lt;td&gt;+15ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;95% bootstrap CI&lt;/td&gt;
&lt;td&gt;[+14, +191]ms&lt;/td&gt;
&lt;td&gt;[-90, +150]ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Statistically real?&lt;/td&gt;
&lt;td&gt;Yes — CI does not cross zero&lt;/td&gt;
&lt;td&gt;No — CI crosses zero, indistinguishable from noise&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Comparison with other platform can be found at &lt;a href="https://acruxcore.com/compare" rel="noopener noreferrer"&gt;AcruxCore&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Opik's &lt;code&gt;track_openai()&lt;/code&gt; wrapper adds overhead that clears the noise floor in its own test session. Langfuse's instrumentation, in its own separate test session, didn't clear that floor at all — its "cost" was statistically zero. Both are real findings about &lt;em&gt;client-side instrumentation&lt;/em&gt;, not about which product is "faster" than the other, since neither routes a call through a gateway the way a proxy-style tool would.&lt;/p&gt;

&lt;h2&gt;
  
  
  Same idea, different name: scoring production traffic automatically
&lt;/h2&gt;

&lt;p&gt;Both platforms have a feature that does the same underlying job — score live traffic as it lands, with no manual review step — under completely different names and UI.&lt;/p&gt;

&lt;p&gt;Langfuse's &lt;strong&gt;LLM-as-a-judge Evaluators&lt;/strong&gt; walk you through writing an eval template: a scoring prompt, a model to run it, and the trace variables it reads.&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%2Fswoff7sv1t5krsq8m1va.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%2Fswoff7sv1t5krsq8m1va.png" alt="Langfuse's " width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Opik's &lt;strong&gt;Online Evaluation&lt;/strong&gt; page, under Production, lets you create a rule that scores every matching trace as it arrives.&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%2Fw0pmcz5148o8kp1y1663.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%2Fw0pmcz5148o8kp1y1663.png" alt="Opik's " width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If a platform-neutral feature list only checked for the &lt;em&gt;name&lt;/em&gt; "online evaluation" or "LLM-as-a-judge," it would miss that both products actually offer this.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where they've genuinely diverged: guardrails vs. alerting
&lt;/h2&gt;

&lt;p&gt;Opik ships a &lt;strong&gt;guardrails&lt;/strong&gt; panel — a topic-restriction check with a sensitivity slider and a comma-separated restricted-topics list, plus a PII (personally identifiable information) check that flags categories like credit card numbers, phone numbers, and email addresses, each with its own threshold. It also has a dedicated &lt;strong&gt;Test suites&lt;/strong&gt; object for pre-deployment regression testing, distinct from its Experiments feature — import cases from a CSV/JSON file or the SDK, each with an expected output and a scoring method.&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%2Fedyt8q84ko7jfhjlzvox.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%2Fedyt8q84ko7jfhjlzvox.png" alt="Opik's " width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Langfuse doesn't ship anything like that. What it has instead is &lt;strong&gt;Monitors&lt;/strong&gt; — set a threshold on cost, quality, or latency once, and get a notification routed to Slack, a webhook, or a GitHub Action when a metric moves outside it.&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%2Fu01imk1qzkx48gj45npm.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%2Fu01imk1qzkx48gj45npm.png" alt="Langfuse's Monitors setup: " width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Neither one has the other's feature. If you need to block a call before it reaches a user for containing PII, Langfuse has nothing for that today. If you need a Slack ping when your P95 latency creeps up, Opik has nothing for that today.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who's behind them, and how big is each
&lt;/h2&gt;

&lt;p&gt;This is where the story changed since we first tested these products. Numbers below are live as of this week, pulled straight from each project's GitHub API.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Opik&lt;/th&gt;
&lt;th&gt;Langfuse&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GitHub stars&lt;/td&gt;
&lt;td&gt;21,252&lt;/td&gt;
&lt;td&gt;32,778&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Forks&lt;/td&gt;
&lt;td&gt;1,690&lt;/td&gt;
&lt;td&gt;3,524&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Contributors&lt;/td&gt;
&lt;td&gt;~136&lt;/td&gt;
&lt;td&gt;~190&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Open issues&lt;/td&gt;
&lt;td&gt;178&lt;/td&gt;
&lt;td&gt;764&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Repo created&lt;/td&gt;
&lt;td&gt;May 2023&lt;/td&gt;
&lt;td&gt;May 2023&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Both repos were created within a week of each other in May 2023 and are still receiving commits daily — this isn't a "one's abandoned" story. Langfuse has the larger community footprint on every raw count, including a notably higher open-issue count, which can mean more active usage surfacing more edge cases, or a smaller team keeping up with triage — we didn't dig into issue-close rates to tell which.&lt;/p&gt;

&lt;p&gt;The bigger difference is who owns each project. Opik is a product inside &lt;strong&gt;Comet ML&lt;/strong&gt;, a New York-based ML platform company founded in 2017, with $6.8M in disclosed seed funding (2018, 2020) and nothing disclosed since. Langfuse was an independent, Y Combinator-backed startup — until &lt;a href="https://clickhouse.com/blog/clickhouse-acquires-langfuse-open-source-llm-observability" rel="noopener noreferrer"&gt;ClickHouse acquired it on January 16, 2026&lt;/a&gt;, the same day ClickHouse announced a $400M Series D at a $15B valuation. Both companies have publicly committed to keeping Langfuse open source and self-hostable going forward.&lt;/p&gt;

&lt;p&gt;For a reader picking a platform to build on, that's a real signal worth weighing alongside the feature list: one tool is a side product of a smaller, steady company; the other just became an integration point inside a much larger, newly-flush infrastructure company. Neither is inherently the safer bet — but they're different bets.&lt;/p&gt;

&lt;p&gt;We also compared each project's documentation footprint via its own sitemap, with a caveat: Langfuse's sitemap covers its &lt;em&gt;entire&lt;/em&gt; domain (blog, changelog, academy, and docs together — 822 URLs, of which only 112 are strictly &lt;code&gt;/docs&lt;/code&gt;), while Opik's docs live under Comet's site and its sitemap is scoped to just that docs subtree (709 URLs). So "709 vs 822" isn't an apples-to-apples count of documentation depth — it's closer to "Opik's docs alone" versus "Langfuse's docs plus its blog, changelog, and handbook combined."&lt;/p&gt;

&lt;h2&gt;
  
  
  What we're not calling a winner
&lt;/h2&gt;

&lt;p&gt;We picked seven points, not the full feature list either platform ships, and skipped a symmetric "pro/con" for every row — some things really are one-sided (neither platform gained a fake counterweight just to look fair). Templating and Playground-tracing are genuine ties. Versioning favors Opik. The overhead numbers aren't comparable to each other, only to their own baselines. Guardrails/test-suites and Monitors/alerting are two teams building in different directions, not one being behind the other.&lt;/p&gt;

&lt;p&gt;If you're choosing between them today, the feature list would point you toward whichever gap matters more to your team — guardrails and regression testing, or threshold alerting. The ownership question is the one that didn't exist a year ago, and it might now matter just as much as either feature list.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Sources: &lt;a href="https://github.com/comet-ml/opik" rel="noopener noreferrer"&gt;comet-ml/opik&lt;/a&gt; and &lt;a href="https://github.com/langfuse/langfuse" rel="noopener noreferrer"&gt;langfuse/langfuse&lt;/a&gt; on GitHub (checked 2026-08-10); &lt;a href="https://clickhouse.com/blog/clickhouse-acquires-langfuse-open-source-llm-observability" rel="noopener noreferrer"&gt;ClickHouse's acquisition announcement&lt;/a&gt;; &lt;a href="https://langfuse.com/blog/joining-clickhouse" rel="noopener noreferrer"&gt;Langfuse's own "joining ClickHouse" post&lt;/a&gt;. Product screenshots and hands-on findings are from our own independent testing of both platforms.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>opensource</category>
      <category>observability</category>
    </item>
    <item>
      <title>LangSmith Just Shipped an LLM Gateway. Here's the 3-Year Mess That Made Me Care</title>
      <dc:creator>Talha Anwar</dc:creator>
      <pubDate>Sat, 08 Aug 2026 13:33:14 +0000</pubDate>
      <link>https://dev.to/ailegend/langsmith-just-shipped-an-llm-gateway-heres-the-3-year-mess-that-made-me-care-1bfh</link>
      <guid>https://dev.to/ailegend/langsmith-just-shipped-an-llm-gateway-heres-the-3-year-mess-that-made-me-care-1bfh</guid>
      <description>&lt;p&gt;Just found that langsmith has launched gateway feature and I decided to share my experience. It is about how messy real LLM tooling decisions actually are, and why a small feature announcement from LangSmith made me sit up this month.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it all started
&lt;/h2&gt;

&lt;p&gt;In August 2023, I onboarded my first RAG (retrieval-augmented generation — you fetch relevant documents first, then hand them to the model as context) chatbot client as an AI lead. Back then the models we had access to were much weaker and much less reliable than what you get today. Hallucinations were common, context windows were small, and you spent half your time just making the model behave.&lt;/p&gt;

&lt;p&gt;The setup was simple, though. One client, one model, one hardcoded prompt in the code. No observability tool. No gateway. It just worked because there was nothing to break.&lt;/p&gt;

&lt;h2&gt;
  
  
  The step that looked solved
&lt;/h2&gt;

&lt;p&gt;Then we got client number two. Then three. The obvious move was to copy the same folder, swap the API key, and ship it. For a while, that felt like scaling.&lt;/p&gt;

&lt;p&gt;It was not. Our prompts lived scattered across different codebases. Our logs sat in our own Postgres database, one table per project, with no shared view across clients. When something went wrong for one client, we had no fast way to tell if it was that client's prompt, a model change, or something else entirely.&lt;/p&gt;

&lt;p&gt;That is the real friction nobody warns you about. A broken prompt does not throw an error. It just quietly returns worse answers, and you might not notice for days — by which time a client has already noticed for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decision that forced
&lt;/h2&gt;

&lt;p&gt;That friction split into two separate problems we had to solve on purpose:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Where do we store and version prompts, and see what is actually happening across every client's traces (a trace is the full record of one request — every step, every token, every millisecond)?&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;How do we talk to multiple model providers through one shape, so a provider outage does not become our outage?&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The first is an observability or "LLM ops" platform. The second is a gateway. Here is the order we actually went through both, mistakes included.&lt;/p&gt;

&lt;h2&gt;
  
  
  Round one: LangSmith, and giving up on it
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://www.langchain.com/langsmith" rel="noopener noreferrer"&gt;LangSmith&lt;/a&gt; is LangChain's platform for tracing LLM calls, storing and versioning prompts, and running evaluations. We picked it up early because it was the obvious default in the LangChain ecosystem, and we were already leaning on some LangChain pieces.&lt;/p&gt;

&lt;p&gt;We stuck with it for a few months before we dropped it. It kept breaking in ways that made us stop trusting the data it showed us — traces that did not line up with what we already had in our own Postgres logs. When your monitoring tool is less reliable than the thing it is monitoring, you stop using it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Round two: the platform Anthropic later swallowed whole
&lt;/h2&gt;

&lt;p&gt;So we moved to &lt;a href="https://humanloop.com/" rel="noopener noreferrer"&gt;Humanloop&lt;/a&gt;, a platform built for the same prompt-management-and-evaluation job. It worked well enough for a good stretch.&lt;/p&gt;

&lt;p&gt;Then, in August 2025, &lt;a href="https://techcrunch.com/2025/08/13/anthropic-nabs-humanloop-team-as-competition-for-enterprise-ai-talent-heats-up" rel="noopener noreferrer"&gt;Anthropic acqui-hired Humanloop's founders and most of its team&lt;/a&gt; — no product, no IP, just the people. Humanloop as a platform sunset shortly after. If you have never seen an acqui-hire before: a bigger company hires the team, not the company, and the product itself just stops being maintained. We had to move a second time, through no fault of our own engineering choices.&lt;/p&gt;

&lt;h2&gt;
  
  
  Round three: back to LangSmith, warts and all
&lt;/h2&gt;

&lt;p&gt;By then LangSmith had matured, and honestly, we did not have many other doors left to knock on. We are still on it today. It is not the platform I would design from scratch — there are features we need that are still missing — but it is stable enough now, and it gives us the cross-client visibility we were missing back in 2023.&lt;/p&gt;

&lt;h2&gt;
  
  
  Meanwhile, the gateway problem: why we picked LiteLLM
&lt;/h2&gt;

&lt;p&gt;While all that platform-hopping was happening, we separately needed a gateway (a layer that sits between your app and every model provider, so your code talks to one consistent API instead of five different ones). We landed on &lt;a href="https://docs.litellm.ai/" rel="noopener noreferrer"&gt;LiteLLM&lt;/a&gt;, which we host ourselves on our own server.&lt;/p&gt;

&lt;p&gt;LiteLLM is not perfect. We know about specific bugs in it, and we live with them, because the thing it does right is the thing we cannot do without: &lt;a href="https://docs.litellm.ai/docs/proxy/reliability" rel="noopener noreferrer"&gt;automatic fallback&lt;/a&gt; to another model or provider when the primary one errors out or gets rate-limited. That single feature is probably the most-used piece of LiteLLM in our whole stack. When a provider has a bad day, our clients never know.&lt;/p&gt;

&lt;h2&gt;
  
  
  This month: LangSmith adds its own Gateway
&lt;/h2&gt;

&lt;p&gt;Then this month I noticed LangSmith has its own &lt;a href="https://www.langchain.com/langsmith/llm-gateway" rel="noopener noreferrer"&gt;LLM Gateway&lt;/a&gt; — &lt;a href="https://www.langchain.com/blog/introducing-llm-gateway" rel="noopener noreferrer"&gt;announced in private beta on May 13, 2026, and opened to public beta on July 30, 2026&lt;/a&gt;. My first reaction was genuinely good: one platform for traces, prompts, and provider routing instead of two separate systems to keep in sync.&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%2Fwp1jzgsa652kgqeik9o8.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%2Fwp1jzgsa652kgqeik9o8.png" alt="Langsmith introduces gateway functionality" width="800" height="445"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I went in assuming the one feature we could not give up — fallback — would still be missing, because the original May announcement listed model fallback under "where this is going," as a future plan rather than a shipped feature. I was wrong about that. By the July public beta, &lt;a href="https://www.langchain.com/langsmith/llm-gateway" rel="noopener noreferrer"&gt;LangSmith Gateway already ships configurable model fallbacks&lt;/a&gt;, chained across models, providers, and hosts, triggered by rate limits or provider errors. That is genuinely good news, since fallback is the one LiteLLM feature we would have refused to give up.&lt;/p&gt;

&lt;p&gt;But here is the part I still cannot skip: &lt;strong&gt;latency.&lt;/strong&gt; Our LiteLLM instance is self-hosted, on our own server. A request goes from our app to a local process and out to the provider — no extra hop over the public internet. LangSmith Gateway is a hosted, external service. Routing every completion through it adds a real network round trip we do not currently pay.&lt;/p&gt;

&lt;p&gt;A fallback that reads well on a feature page has not been through a real outage on our own traffic yet. Before we retire LiteLLM, I want to see two numbers with our own eyes: the added latency per request, and how the fallback behaves the first time a provider actually goes down on us, not in a demo.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell you, if you were the one making this call
&lt;/h2&gt;

&lt;p&gt;Do not trust a feature list over battle scars, even when the feature list is telling the truth. Test the exact thing you depend on most, under the exact load you actually run, before you let go of the tool that is already working.&lt;/p&gt;

&lt;p&gt;Three years, four tools, one lesson that keeps repeating: the tool that wins is not the one with the longest feature list. It is the one that is still standing after the outage nobody planned for.&lt;/p&gt;

&lt;p&gt;So tell me your version of this. Has anyone already pushed LangSmith Gateway through a real provider outage or a hard rate limit on real production traffic? Or did you go through your own round of tool-hopping — what made you leave a platform, and what finally made one stick? I would genuinely like to hear it before we run our own test.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>devops</category>
      <category>discuss</category>
      <category>llm</category>
    </item>
    <item>
      <title>Every Check Said Our GA4 Analytics Worked. It Had Never Recorded a Visit</title>
      <dc:creator>Talha Anwar</dc:creator>
      <pubDate>Sat, 08 Aug 2026 10:06:48 +0000</pubDate>
      <link>https://dev.to/ailegend/every-check-said-our-ga4-analytics-worked-it-had-never-recorded-a-visit-1cn0</link>
      <guid>https://dev.to/ailegend/every-check-said-our-ga4-analytics-worked-it-had-never-recorded-a-visit-1cn0</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/bugsmash"&gt;DEV's Summer Bug Smash: Smash Stories&lt;/a&gt; powered by &lt;a href="https://sentry.io/" rel="noopener noreferrer"&gt;Sentry&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Adding analytics to a website is the most boring task in web development. Google hands you eight lines, you paste them above &lt;code&gt;&amp;lt;/head&amp;gt;&lt;/code&gt;, and traffic shows up in the dashboard about a minute later.&lt;/p&gt;

&lt;p&gt;On a real app you tidy it up a little. The measurement ID moves into an environment variable, the snippet becomes a module, and you add a cookie banner with &lt;a href="https://developers.google.com/tag-platform/security/guides/consent" rel="noopener noreferrer"&gt;Consent Mode&lt;/a&gt; so nothing is tracked before a visitor agrees. Still a small job, and it still looks finished when the banner renders.&lt;/p&gt;

&lt;p&gt;Here's the part nobody warns you about: &lt;strong&gt;every layer you just added can fail without saying a word.&lt;/strong&gt; A build tool can drop the env var. A queue can accept commands it will never run. A CI secret can point at a property nobody is watching. None of them throws. None of them logs. The site loads fine, the banner works, and the dashboard stays at zero.&lt;/p&gt;

&lt;p&gt;So the only check that means anything is the last one — did a hit actually arrive? This is the story of the three silent failures I had to clear before the answer was yes, and the second one nearly had me file a bug report against Google.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;I work on &lt;a href="https://acruxcore.com" rel="noopener noreferrer"&gt;AcruxCore&lt;/a&gt;, an LLM ops platform. Two sites: the marketing site and dashboard (&lt;code&gt;apps/web&lt;/code&gt;, React + Vite, deployed in Docker), and the docs site (&lt;code&gt;apps/docs&lt;/code&gt;, Docusaurus, deployed to Cloudflare Pages).&lt;/p&gt;

&lt;p&gt;I shipped Google Analytics 4 to both in one PR. Consent Mode v2 with &lt;code&gt;analytics_storage&lt;/code&gt; denied by default, a cookie banner, consent stored as a first-party cookie shared across subdomains.&lt;/p&gt;

&lt;p&gt;I tested it properly before merging:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Playwright against a real dev server — accept, decline, reload, reopen from the footer&lt;/li&gt;
&lt;li&gt;built the bundle with the env var set and grepped the output for the measurement ID&lt;/li&gt;
&lt;li&gt;built it again with the var unset and confirmed the tag never loads&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All green. I merged it, rebuilt the production image, and opened Google's tag inspector on the live site.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tag not detected.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 1 — the tag was never in the bundle
&lt;/h2&gt;

&lt;p&gt;The cookie banner rendered on production, so the new frontend had definitely deployed. That's what made this confusing: the same PR shipped both, and only half of it arrived.&lt;/p&gt;

&lt;p&gt;I SSH'd into the VPS and worked backward from the artifact rather than forward from the config.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# search the actually-served bundle for Google's script host&lt;/span&gt;
docker &lt;span class="nb"&gt;exec &lt;/span&gt;acruxcore-web &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rc&lt;/span&gt; googletagmanager /usr/share/nginx/html/assets/
&lt;span class="c"&gt;# → 0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero. Not a wrong ID, not a consent problem — the tag was never compiled in. A rebuild with &lt;code&gt;docker compose build web&lt;/code&gt; still gave zero, and a full &lt;code&gt;docker build --no-cache&lt;/code&gt; reproduced the same empty result.&lt;/p&gt;

&lt;p&gt;At that point I'd checked the Docker &lt;code&gt;ARG&lt;/code&gt;/&lt;code&gt;ENV&lt;/code&gt; lines, the &lt;code&gt;args:&lt;/code&gt; block in &lt;code&gt;docker-compose.yml&lt;/code&gt;, and the &lt;code&gt;define&lt;/code&gt; in &lt;code&gt;vite.config.ts&lt;/code&gt;. All three were correct. So I stopped reading config and ran the build one layer down, inside the same built image:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# bypass the orchestrator, call the bundler directly&lt;/span&gt;
&lt;span class="nv"&gt;GA4_MEASUREMENT_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;G-XXXXXXXXXX npx vite build
&lt;span class="c"&gt;# → googletagmanager IS in the output&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;vite build&lt;/code&gt; produced the tag. &lt;code&gt;turbo run build&lt;/code&gt; did not. Same image, same env, same everything else. The bug wasn't in Docker, Compose or Vite — it was in the layer between them.&lt;/p&gt;

&lt;h3&gt;
  
  
  What Turborepo was doing
&lt;/h3&gt;

&lt;p&gt;Our Dockerfile builds through the monorepo task runner:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;RUN &lt;/span&gt;npx turbo run build &lt;span class="nt"&gt;--filter&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;@acruxcore/web
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://turborepo.com/docs/reference/configuration#envmode" rel="noopener noreferrer"&gt;Turborepo's default &lt;code&gt;envMode&lt;/code&gt; is &lt;code&gt;strict&lt;/code&gt;&lt;/a&gt;. Strict means a task's process starts with a &lt;strong&gt;filtered&lt;/strong&gt; environment. It sees only the variables you declared in that task's &lt;code&gt;env&lt;/code&gt; config, plus any auto-inferred from a recognized framework's naming convention. For Vite, that's anything prefixed &lt;code&gt;VITE_&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Our variable is called &lt;code&gt;GA4_MEASUREMENT_ID&lt;/code&gt;. It is deliberately &lt;em&gt;not&lt;/em&gt; &lt;code&gt;VITE_&lt;/code&gt;-prefixed, because &lt;code&gt;vite.config.ts&lt;/code&gt; remaps it at build time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;define&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;import.meta.env.VITE_GA4_MEASUREMENT_ID&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;GA4_MEASUREMENT_ID&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&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;That &lt;code&gt;?? ''&lt;/code&gt; is the whole tragedy in two characters. Turbo stripped &lt;code&gt;GA4_MEASUREMENT_ID&lt;/code&gt; &lt;strong&gt;before Vite's config file ever ran&lt;/strong&gt;, so &lt;code&gt;process.env.GA4_MEASUREMENT_ID&lt;/code&gt; was &lt;code&gt;undefined&lt;/code&gt;, the fallback kicked in, and the bundle compiled a perfectly valid empty string. Analytics reads the ID, finds &lt;code&gt;''&lt;/code&gt;, and correctly decides not to load. No error, because nothing went wrong — every layer did exactly what it was told.&lt;/p&gt;

&lt;p&gt;Docker and Compose passing the variable in correctly made no difference. Turbo removed it after they handed it over.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx turbo run build &lt;span class="nt"&gt;--dry&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;json &lt;span class="nt"&gt;--filter&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;@acruxcore/web &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="s1"&gt;'.tasks[0].envMode, .tasks[0].environmentVariables.specified.env'&lt;/span&gt;
&lt;span class="c"&gt;# → "strict"&lt;/span&gt;
&lt;span class="c"&gt;# → []      ← nothing declared, so nothing gets through&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix is one line in &lt;code&gt;turbo.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt; "build": {
   "dependsOn": ["^build"],
   "outputs": ["dist/**", "build/**", ".docusaurus/**"],
&lt;span class="gi"&gt;+  "env": ["SENTRY_WEB_DSN", "GA4_MEASUREMENT_ID"]
&lt;/span&gt; }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Mirror file: &lt;a href="https://github.com/AcruxCore/AcruxCore/blob/main/turbo.json" rel="noopener noreferrer"&gt;&lt;code&gt;turbo.json&lt;/code&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The part that actually scared me
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;GA4_MEASUREMENT_ID&lt;/code&gt; wasn't alone in that &lt;code&gt;env&lt;/code&gt; array. &lt;code&gt;SENTRY_WEB_DSN&lt;/code&gt; was there too, and I only added it because it has the same shape — a non-&lt;code&gt;VITE_&lt;/code&gt; name remapped in the same &lt;code&gt;define&lt;/code&gt; block.&lt;/p&gt;

&lt;p&gt;Then I reverted the fix and re-ran the build with &lt;code&gt;SENTRY_WEB_DSN&lt;/code&gt; set, the same way I'd reproduced the GA4 failure. Identical result: stripped, empty string, no SDK.&lt;/p&gt;

&lt;p&gt;Sentry had been wired into the web app six days earlier. In that window, the browser SDK on the deployed site had almost certainly never sent a single error report.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And nothing would ever have told me.&lt;/strong&gt; A quiet analytics dashboard is at least ambiguous — maybe nobody visited. A quiet error monitor looks like &lt;em&gt;good news&lt;/em&gt;. That is the failure mode I keep thinking about: the tool whose whole job is telling you when something is wrong can be the one thing that's broken, and its silence reads as success.&lt;/p&gt;

&lt;p&gt;What wasn't affected. The docs site builds in CI with &lt;code&gt;npm run build -w @acruxcore/docs&lt;/code&gt; — never through Turbo — so it picked up the measurement ID on the first deploy. Half the platform worked from day one, which is exactly why I initially suspected something specific to the marketing site.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 2 — the tag loaded, the queue filled, nothing was sent
&lt;/h2&gt;

&lt;p&gt;New image deployed. &lt;code&gt;googletagmanager&lt;/code&gt; now in the bundle, script loading in the network tab, cookie banner working, consent granted.&lt;/p&gt;

&lt;p&gt;GA4 Realtime: &lt;strong&gt;zero users.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I went through the browser console expecting an obvious break, and instead found every single signal healthy:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the &lt;code&gt;https://www.googletagmanager.com/gtag/js?id=…&lt;/code&gt; script loads with a 200&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;window.google_tag_manager&lt;/code&gt; is defined&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;window.dataLayer.push !== Array.prototype.push&lt;/code&gt; — gtag.js has replaced the queue's push, so it &lt;em&gt;is&lt;/em&gt; watching&lt;/li&gt;
&lt;li&gt;the queue fills up with my &lt;code&gt;consent&lt;/code&gt;, &lt;code&gt;js&lt;/code&gt; and &lt;code&gt;config&lt;/code&gt; commands&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And no request to &lt;code&gt;/g/collect&lt;/code&gt;. No &lt;code&gt;_ga&lt;/code&gt; cookie. Nothing dispatched, ever.&lt;/p&gt;

&lt;p&gt;I also ran GA4's own &lt;strong&gt;"Test your website"&lt;/strong&gt; check from the admin panel. It went green — which I now know only means the tag script is present on the page. It never checks whether a hit arrives.&lt;/p&gt;

&lt;h3&gt;
  
  
  The wrong turn
&lt;/h3&gt;

&lt;p&gt;Everything on my side was correct, so I started building the case that the problem was Google's.&lt;/p&gt;

&lt;p&gt;I set up a second, completely unrelated GA4 property and pointed the site at it. Identical zero-dispatch behaviour. That felt conclusive: not my account, not my property, not my tag config.&lt;/p&gt;

&lt;p&gt;I got as far as writing a standalone &lt;code&gt;ga4-zero-hits-repro.html&lt;/code&gt; to hand over, drafting the issue report, and opening a PR to track the escalation. The report was finished and queued to post.&lt;/p&gt;

&lt;h3&gt;
  
  
  The moment it broke
&lt;/h3&gt;

&lt;p&gt;Before posting I re-read my own repro page, and the thing I'd been ignoring finally registered. I had &lt;strong&gt;hand-written that page from Google's documentation&lt;/strong&gt; rather than copying the code my product actually runs.&lt;/p&gt;

&lt;p&gt;Which means it contained Google's canonical stub:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;gtag&lt;/span&gt;&lt;span class="p"&gt;(){&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataLayer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;arguments&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;And my product contained mine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;gtag&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;[]):&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataLayer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataLayer&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataLayer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;args&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;Those look equivalent. TypeScript is happy, ESLint prefers the second one, and every reviewer including me read straight past it. My "repro of the bug" had quietly swapped out the one line that &lt;em&gt;was&lt;/em&gt; the bug.&lt;/p&gt;

&lt;p&gt;So I stopped reasoning and isolated the single variable. Two pages served from the same local origin, same measurement ID, same Consent Mode sequence, differing only in the push form:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;stub&lt;/th&gt;
&lt;th&gt;hits to &lt;code&gt;/g/collect&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;_ga&lt;/code&gt; cookie&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;push(args)&lt;/code&gt; — what shipped&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;not set&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;push(arguments)&lt;/code&gt; — canonical&lt;/td&gt;
&lt;td&gt;2 (&lt;code&gt;page_view&lt;/code&gt;, &lt;code&gt;scroll&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;set&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One line. Everything else identical.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why &lt;code&gt;arguments&lt;/code&gt; is load-bearing
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;gtag.js&lt;/code&gt; replaces &lt;code&gt;dataLayer.push&lt;/code&gt; with its own processor. That queue carries two different kinds of thing, and the processor has to tell them apart:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;commands&lt;/strong&gt; — &lt;code&gt;gtag('config', 'G-…')&lt;/code&gt;, &lt;code&gt;gtag('consent', 'update', {…})&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;data-layer objects&lt;/strong&gt; — ordinary values other tags push in for their own use&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It tells them apart by checking whether the pushed value is a real &lt;code&gt;arguments&lt;/code&gt; object. A rest parameter gives you a plain &lt;code&gt;Array&lt;/code&gt; instead, which fails that check — so the value gets filed away as data and no command ever runs.&lt;/p&gt;

&lt;p&gt;That includes &lt;code&gt;config&lt;/code&gt;. Without &lt;code&gt;config&lt;/code&gt;, the tag is initialised with no measurement ID, so it has nothing to send and no reason to complain. Everything downstream still looks alive because everything downstream &lt;em&gt;is&lt;/em&gt; alive — the script really did load, the queue really is hooked, the commands really are in it. They're just sitting there.&lt;/p&gt;

&lt;p&gt;I didn't get this from the docs. It's just the only difference between the two rows of that table. It's also a decent rule of thumb: when a vendor snippet uses an unfashionable &lt;code&gt;function(){}&lt;/code&gt; where a modern one would do, assume the old form is doing something.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Lint rules encode what's usually true. &lt;code&gt;prefer-rest-params&lt;/code&gt; is right nearly every time, and this is the case where "nearly" costs you all your data.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  The fix
&lt;/h3&gt;

&lt;p&gt;Keep Google's original stub verbatim, and leave a note loud enough to survive the next cleanup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/**
 * **`arguments` here is load-bearing — do not "modernize" it into a rest
 * parameter.** gtag.js tells a command apart from a data-layer object by
 * checking the pushed value is a real `arguments` object. A plain array is
 * silently ignored: the tag loads, the queue fills, and not one hit is sent.
 */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;gtag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;GtagFn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataLayer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataLayer&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="c1"&gt;// eslint-disable-next-line prefer-rest-params -- a rest array is not a command&lt;/span&gt;
  &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataLayer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;arguments&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;ul&gt;
&lt;li&gt;Mirror file: &lt;a href="https://github.com/AcruxCore/AcruxCore/blob/main/apps/web/src/lib/analytics.ts" rel="noopener noreferrer"&gt;&lt;code&gt;apps/web/src/lib/analytics.ts&lt;/code&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A comment alone wasn't enough for me here, because the failure is invisible and the "cleanup" that reintroduces it is a one-character edit. So the test asserts the exact property that matters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;queues each command as an `arguments` object, which is what gtag.js recognizes&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;vi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stubGlobal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;window&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Window&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;gtag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;config&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;G-TEST123&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;queue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataLayer&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prototype&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;toString&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="nx"&gt;queue&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="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[object Arguments]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;queue&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="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;IArguments&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toEqual&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;config&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;G-TEST123&lt;/span&gt;&lt;span class="dl"&gt;'&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;Rewrite the stub as a rest parameter and that test fails immediately with &lt;code&gt;[object Array]&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Verified end to end against a real &lt;code&gt;vite build&lt;/code&gt;: accepting the consent banner produces a &lt;code&gt;gcs=G101&lt;/code&gt; hit and sets &lt;code&gt;_ga&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 3 — the site that worked was reporting to nobody
&lt;/h2&gt;

&lt;p&gt;While isolating bug 2 I went back to the docs site as a control, because it had looked healthy from day one. Its snippet was hand-written directly into &lt;code&gt;docusaurus.config.ts&lt;/code&gt; and never shared code with &lt;code&gt;apps/web&lt;/code&gt;, so it had the correct &lt;code&gt;arguments&lt;/code&gt; form all along.&lt;/p&gt;

&lt;p&gt;It was sending hits. It was sending them to the wrong property.&lt;/p&gt;

&lt;p&gt;Its measurement ID comes from a GitHub Actions secret that was never updated when the property changed, so every visit it recorded had been landing somewhere nobody was looking. No code change — update the secret, re-run the workflow — but it made the point for a third time in one week.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Working and reporting where you're watching are two different states&lt;/strong&gt;, and only one of them shows up on a dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before and after
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;before&lt;/th&gt;
&lt;th&gt;after&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GA4 tag in the production bundle&lt;/td&gt;
&lt;td&gt;absent&lt;/td&gt;
&lt;td&gt;present&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sentry browser SDK in the production bundle&lt;/td&gt;
&lt;td&gt;absent&lt;/td&gt;
&lt;td&gt;present&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gtag commands recognized by gtag.js&lt;/td&gt;
&lt;td&gt;0 of 3&lt;/td&gt;
&lt;td&gt;3 of 3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;hits reaching &lt;code&gt;/g/collect&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;page_view&lt;/code&gt;, &lt;code&gt;scroll&lt;/code&gt;, consent hits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;docs-site hits landing in the watched property&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;all of them&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;checks that had reported a problem&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1 failing unit test if it regresses&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That last row is the one I'd frame. Before the fix, the total number of automated signals warning me about any of this was zero.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'm carrying to the next codebase
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A repro has to copy the product's code path, not your memory of it.&lt;/strong&gt; I re-typed Google's snippet from the docs, which silently replaced the variable under test, and that one shortcut is the entire reason I wrote a bug report against Google. Paste the real function in and the two-page test finds it in ten minutes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Testing a second account felt like ruling something out. It ruled out nothing.&lt;/strong&gt; Both accounts ran the same broken client code, so the experiment could only ever return the same answer. A control group that shares the defect isn't a control group — and the confidence it gave me is what pushed me toward blaming Google instead of looking closer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Assert the last link, not the layers.&lt;/strong&gt; I had a check for the env var, a check for the bundle, a check for the banner, and Google's own check for the tag. Four green lights, zero data. Every one of them verified a &lt;em&gt;precondition&lt;/em&gt; for a hit; none verified a hit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When one config gap swallows a variable, check every variable of the same shape.&lt;/strong&gt; &lt;code&gt;SENTRY_WEB_DSN&lt;/code&gt; was found only because it looked like &lt;code&gt;GA4_MEASUREMENT_ID&lt;/code&gt;, and it was the more dangerous of the two — nobody files a ticket saying "I'm getting suspiciously few errors."&lt;/p&gt;




&lt;p&gt;If you've got analytics or error monitoring wired into a monorepo build, two things are worth ten minutes today: grep your deployed bundle for the tag host, and check the type of what your &lt;code&gt;gtag&lt;/code&gt; stub actually pushes.&lt;/p&gt;

&lt;p&gt;What did yours turn out to be — an &lt;code&gt;Array&lt;/code&gt;, or &lt;code&gt;Arguments&lt;/code&gt;?&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>bugsmash</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>Our Token Counter Took 26 Seconds on a Single Prompt</title>
      <dc:creator>Talha Anwar</dc:creator>
      <pubDate>Fri, 07 Aug 2026 23:37:36 +0000</pubDate>
      <link>https://dev.to/ailegend/our-token-counter-took-26-seconds-on-a-single-prompt-3l9p</link>
      <guid>https://dev.to/ailegend/our-token-counter-took-26-seconds-on-a-single-prompt-3l9p</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for DEV's Summer Bug Smash: Smash Stories powered by Sentry.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Project Overview
&lt;/h2&gt;

&lt;p&gt;I work on &lt;a href="https://acruxcore.com" rel="noopener noreferrer"&gt;AcruxCore&lt;/a&gt;, an LLM ops platform with an Express API gateway. Every completion passes a budget pre-check: estimate the token cost, compare against the team's remaining spend, reject if it doesn't fit.&lt;/p&gt;

&lt;p&gt;For OpenAI-family models, that estimate used &lt;a href="https://github.com/dqbd/tiktoken" rel="noopener noreferrer"&gt;&lt;code&gt;js-tiktoken&lt;/code&gt;&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isOpenAiFamily&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;encoder&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="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Fall through to the heuristic if the encoder rejects the input.&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three lines. They do the right thing. They just don't always do it quickly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug Fix or Performance Improvement
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Performance bug — a quadratic BPE tokenizer on the synchronous request path.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I didn't find this through a user complaint. Two &lt;code&gt;datasets&lt;/code&gt; tests started timing out at Jest's 5-second limit. I assumed I'd broken something, stashed my changes, checked out clean &lt;code&gt;staging&lt;/code&gt;, and ran them again. Still failed.&lt;/p&gt;

&lt;p&gt;The tests were fine. The code under them was just brutally slow.&lt;/p&gt;

&lt;p&gt;The BPE encoder is &lt;strong&gt;quadratic on whitespace-free input&lt;/strong&gt;. BPE works by repeatedly merging the most common adjacent pair of characters. Each pass scans the entire string to find the best pair. One merge per pass, full scan each time. A string of N characters with no whitespace means roughly N passes over N characters — N² work.&lt;/p&gt;

&lt;p&gt;Normal text never hits this because the pre-tokenizer splits on whitespace first. Every piece is one short word (~5 chars), so N² is nothing. A long unbroken string has no whitespace to split on, so the whole thing becomes one piece and N becomes the full length.&lt;/p&gt;

&lt;p&gt;Measured on &lt;code&gt;cl100k_base&lt;/code&gt; (GPT-4's tokenizer):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;single piece&lt;/th&gt;
&lt;th&gt;encode time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;256 chars&lt;/td&gt;
&lt;td&gt;4 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;512 chars&lt;/td&gt;
&lt;td&gt;18 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1,024 chars&lt;/td&gt;
&lt;td&gt;70 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2,048 chars&lt;/td&gt;
&lt;td&gt;281 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9,000 chars&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;5.4 s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;20,000 chars&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;26 s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Doubling the length roughly quadruples the time. Meanwhile, &lt;strong&gt;20,000 characters of ordinary prose encodes in 3 ms&lt;/strong&gt;. It's not the length — it's the lack of whitespace.&lt;/p&gt;

&lt;p&gt;This function runs on every single request. Node runs JavaScript on one thread. A 26-second synchronous encode doesn't just slow down one request — it freezes every other user on that process.&lt;/p&gt;

&lt;p&gt;And the input that triggers it isn't exotic: a base64 blob in a prompt, a minified JSON payload, a corrupted copy-paste. Any authenticated caller could stall the whole process.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;gateway completion, 2-char rendered variable:      82 ms
gateway completion, 9000-char rendered variable:  5506 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same code path. Same mocked provider. One template variable different.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why not just remove the tokenizer?
&lt;/h3&gt;

&lt;p&gt;My first thought. But &lt;code&gt;estimateTokens&lt;/code&gt; has two jobs:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Budget pre-check&lt;/strong&gt; — reject requests that would exceed the spend cap. Overestimate and you reject valid requests. Underestimate and teams blow past their budget.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Billing&lt;/strong&gt; — when a provider streams without usage data, this estimate becomes the number on the user's bill.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And &lt;code&gt;chars / 4&lt;/code&gt; is a rough rule of thumb for English prose. It breaks on other inputs. Our test suite uses a synthetic 400 KB input (1,600 repetitions of 250 &lt;code&gt;x&lt;/code&gt; characters plus a space — the same one that caused the timeouts):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Characters: 1,600 × 251 = 401,600
Exact BPE:  52,800 tokens  →  401,600 / 52,800 = 7.6 chars/token
chars / 4:  100,400 tokens  →  401,600 / 4 = 100,400 (nearly 2× the truth)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your budget allows 80,000 tokens, the real prompt fits (52,800) but &lt;code&gt;chars / 4&lt;/code&gt; says it doesn't (100,400) and rejects it. Using &lt;code&gt;chars / 4&lt;/code&gt; everywhere would overestimate so aggressively that it would reject roughly half of all valid prompts.&lt;/p&gt;

&lt;p&gt;So the tokenizer stays. It just doesn't get to run unbounded anymore.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;The work happened in a private repo, so the PR link won't help. The fixed file is public in our mirror, with the measurement table in the doc comment:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/AcruxCore/AcruxCore/blob/main/apps/api/src/gateway/providers/token-estimate.ts" rel="noopener noreferrer"&gt;&lt;code&gt;apps/api/src/gateway/providers/token-estimate.ts&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Before&lt;/strong&gt; — the entire string in one encoder call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isOpenAiFamily&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;encoder&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="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* fall through to heuristic */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;heuristic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;After&lt;/strong&gt; — two decisions: how much text to encode, and how to chop it up first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isOpenAiFamily&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Bound 2: past 20,000 chars, encode a sample and scale the rest.&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;MAX_BPE_SAMPLE_CHARS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;boundedEncode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sampleTokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;boundedEncode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&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="nx"&gt;MAX_BPE_SAMPLE_CHARS&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ratio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sampleTokens&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;MAX_BPE_SAMPLE_CHARS&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;sampleTokens&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;MAX_BPE_SAMPLE_CHARS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;ratio&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* fall through to heuristic */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;heuristic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  My Improvements
&lt;/h2&gt;

&lt;p&gt;Two bounds. No new dependencies. No change to what it returns for normal input.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bound 1 — cap each piece at 256 characters
&lt;/h3&gt;

&lt;p&gt;Nothing over 256 characters reaches the encoder. Longer pieces get &lt;code&gt;ceil(chars / 4)&lt;/code&gt; as a quick estimate instead.&lt;/p&gt;

&lt;p&gt;This removes the quadratic cost entirely, because 256 chars keeps each encoder call under ~4 ms.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Important:&lt;/strong&gt; 256 is a &lt;em&gt;threshold&lt;/em&gt;, not a chunk size. A 1,024-character piece is not split into four 256-char chunks. It skips the encoder entirely and gets &lt;code&gt;ceil(1024 / 4) = 256&lt;/code&gt; directly. Zero encoder calls.&lt;/p&gt;

&lt;p&gt;The text is only split at whitespace boundaries — to keep token counts accurate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;boundedEncode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;pendingSeparator&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;part&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;(\s&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;part&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;part&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;pendingSeparator&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;part&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;piece&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pendingSeparator&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;part&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;pendingSeparator&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;piece&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;MAX_BPE_PIECE_CHARS&lt;/span&gt;
      &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;heuristic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;piece&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;encoder&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="nx"&gt;piece&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pendingSeparator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;pendingSeparator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;MAX_BPE_PIECE_CHARS&lt;/span&gt;
      &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;heuristic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pendingSeparator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;encoder&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="nx"&gt;pendingSeparator&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;tokens&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;&lt;strong&gt;The easy line to get wrong is &lt;code&gt;pendingSeparator&lt;/code&gt;.&lt;/strong&gt; Whitespace must be attached to the &lt;em&gt;following&lt;/em&gt; word because tiktoken groups &lt;code&gt;" word"&lt;/code&gt; as one token. Encode the space on its own and &lt;code&gt;" the"&lt;/code&gt; becomes two tokens instead of one — every prose prompt gets overcounted by roughly its word count, and budget checks silently start rejecting requests they shouldn't.&lt;/p&gt;

&lt;p&gt;A correctness bug from a performance fix is a bad trade.&lt;/p&gt;

&lt;h4&gt;
  
  
  Effect on the 20,000-character blob
&lt;/h4&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;before&lt;/th&gt;
&lt;th&gt;after&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;encoder calls&lt;/td&gt;
&lt;td&gt;one, on 20,000 chars&lt;/td&gt;
&lt;td&gt;none (20,000 &amp;gt; 256)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;time&lt;/td&gt;
&lt;td&gt;26,952 ms&lt;/td&gt;
&lt;td&gt;0 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;tokens returned&lt;/td&gt;
&lt;td&gt;2,500&lt;/td&gt;
&lt;td&gt;5,000&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The count doubles — that's deliberate. &lt;code&gt;ceil(chars / 4)&lt;/code&gt; overestimates, but 20,000 identical characters isn't a real prompt, and for a budget check, overestimating is the safe direction.&lt;/p&gt;

&lt;p&gt;Ordinary text is untouched. &lt;code&gt;"Hello world"&lt;/code&gt; splits into &lt;code&gt;"Hello"&lt;/code&gt; and &lt;code&gt;" world"&lt;/code&gt;, both under 256, both encoded exactly as before.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bound 2 — cap the total at 20,000 characters
&lt;/h3&gt;

&lt;p&gt;Bound 1 only looks at one piece at a time. It never catches the case of many &lt;em&gt;short&lt;/em&gt; pieces adding up.&lt;/p&gt;

&lt;p&gt;1,600 repetitions of 250 &lt;code&gt;x&lt;/code&gt;s plus a space = 400 KB. Each piece is 250 chars (under the 256 cap), so Bound 1 lets every one through. At ~4 ms each, that's &lt;strong&gt;7 seconds&lt;/strong&gt;. Bound 1 never triggers once.&lt;/p&gt;

&lt;p&gt;Bound 2 fixes this: encode at most 20,000 characters total. For anything longer, encode the first 20K, measure the tokens-per-char ratio, and scale that ratio across the rest.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step by step:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Take the first 20,000 characters&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;boundedEncode&lt;/code&gt; on them (still uses Bound 1 internally)&lt;/li&gt;
&lt;li&gt;Get the token count from the sample&lt;/li&gt;
&lt;li&gt;Divide by 20,000 to get a &lt;code&gt;tokens-per-char&lt;/code&gt; ratio&lt;/li&gt;
&lt;li&gt;Multiply the ratio by the remaining characters&lt;/li&gt;
&lt;li&gt;Add sample tokens + tail estimate = return value&lt;/li&gt;
&lt;/ol&gt;

&lt;h4&gt;
  
  
  Effect on the 400 KB input
&lt;/h4&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;bound 1 only&lt;/th&gt;
&lt;th&gt;both bounds&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;characters encoded&lt;/td&gt;
&lt;td&gt;401,600&lt;/td&gt;
&lt;td&gt;20,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;time&lt;/td&gt;
&lt;td&gt;7,018 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;334 ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;tokens returned&lt;/td&gt;
&lt;td&gt;52,800&lt;/td&gt;
&lt;td&gt;52,771&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The first 20K characters gave 2,628 tokens. Applied to the remaining 381,600 characters: 50,143 tokens. Total: 52,771 vs exact 52,800.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Sampled ratio:  2,628 / 20,000 = 0.13 tokens per character
chars / 4 ratio:            1 / 4 = 0.25 tokens per character
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;0.13 is roughly half of 0.25. Using &lt;code&gt;chars / 4&lt;/code&gt; would claim 95,400 tokens on the tail instead of 50,143 — nearly double the truth. The sample already measured the real ratio, so using it is almost as accurate as encoding everything, without the 7-second cost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;0.05% off, for 5% of the work.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Why 20,000 characters? At the 256-char piece cap, worst case is ~80 pieces (20,000 / 256). At ~4 ms each, that's ~320 ms — fast enough to stay on the request path. Anything bigger gets the sample treatment. The number isn't magic; it's where "encode everything" becomes "too slow."&lt;/p&gt;

&lt;p&gt;Worst case is now roughly &lt;strong&gt;350 ms for any input size.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Error direction
&lt;/h3&gt;

&lt;p&gt;Bound 1 can only &lt;strong&gt;overestimate&lt;/strong&gt; — &lt;code&gt;ceil(chars / 4)&lt;/code&gt; always returns more tokens than real BPE on the inputs that trigger it. For a budget check, that's the safe direction.&lt;/p&gt;

&lt;p&gt;Bound 2 is different: it extrapolates from a sample, so it can land slightly above or below the true count. The 400 KB case came out 0.05% low. But that only applies past 20,000 characters, and a fraction of a percent drift on a huge prompt is not a budget problem. A 7-second event-loop stall is.&lt;/p&gt;

&lt;h3&gt;
  
  
  Proving it didn't change the answer
&lt;/h3&gt;

&lt;p&gt;A bounded estimator that returns different numbers is a new bug, not a fix. Tests pin exactness first, speed second.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Exactness&lt;/strong&gt; — must match whole-string BPE on all normal input (under 20K chars):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;matches whole-string BPE counts for ordinary text&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;encoder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getEncoding&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cl100k_base&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;samples&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Hi Alice, what is the weather in London?&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;The quick brown fox jumps over the lazy dog. &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;repeat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;114&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;const x = foo.bar(baz, 42); // comment here&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;repeat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;a&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hello world&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;b&lt;/span&gt;&lt;span class="p"&gt;:&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="mi"&gt;2&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="na"&gt;c&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;d&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;nested value here&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;repeat&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="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sample&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;samples&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;estimateTokens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sample&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gpt-4o-mini&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;encoder&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="nx"&gt;sample&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;);&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;Prose, code, JSON: identical counts. No existing estimate moved.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Direction&lt;/strong&gt; — pathological input must never come back low:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;never underestimates the pathological input&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;encoder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getEncoding&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cl100k_base&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;repeat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;estimateTokens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gpt-4o-mini&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBeGreaterThanOrEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;encoder&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="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&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;&lt;strong&gt;Speed&lt;/strong&gt; — one test per bound, so removing either guard fails immediately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stays fast on a long unbroken run of characters&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;estimateTokens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;warm up the encoder&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gpt-4o-mini&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;started&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;estimateTokens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;repeat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gpt-4o-mini&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBeGreaterThan&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="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;started&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBeLessThan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stays fast when many pieces sit just under the per-piece cap&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;estimateTokens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;warm up the encoder&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gpt-4o-mini&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;repeat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;250&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt; `&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;repeat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1600&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;started&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;estimateTokens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gpt-4o-mini&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBeGreaterThan&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="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;started&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBeLessThan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1000&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;The 1-second limit is deliberately generous — won't flake on loaded CI, but still fails instantly if someone removes a guard (the unfixed path takes minutes).&lt;/p&gt;

&lt;h3&gt;
  
  
  Results
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;before&lt;/th&gt;
&lt;th&gt;after&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;50,000-char unbroken run&lt;/td&gt;
&lt;td&gt;minutes&lt;/td&gt;
&lt;td&gt;&amp;lt; 1 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;400 KB of 250-char pieces&lt;/td&gt;
&lt;td&gt;7,018 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;334 ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;completion, 9,000-char variable (provider mocked)&lt;/td&gt;
&lt;td&gt;5,506 ms&lt;/td&gt;
&lt;td&gt;~82 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;datasets&lt;/code&gt; test A&lt;/td&gt;
&lt;td&gt;6,340 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;410 ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;datasets&lt;/code&gt; test B&lt;/td&gt;
&lt;td&gt;6,243 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;323 ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The timing-out tests were never touched. They passed because the code underneath stopped being slow.&lt;/p&gt;

&lt;h3&gt;
  
  
  What I'd carry to another codebase
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Test adversarial input, not your own.&lt;/strong&gt; Every prompt I'd written was ordinary prose — the tokenizer's best case. Timing &lt;code&gt;'x'.repeat(20000)&lt;/code&gt; took ten minutes and found the whole bug.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A synchronous function on a request path is a shared resource.&lt;/strong&gt; "It's just a pure function over a string" stops being reassuring when the string is caller-controlled and the cost is superlinear.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pin the old answer before you optimize.&lt;/strong&gt; An exact-match test against whole-string encoding is how I caught the &lt;code&gt;pendingSeparator&lt;/code&gt; detail that would have silently overcounted every prompt.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;If you're running a BPE tokenizer anywhere synchronous, it's worth timing &lt;code&gt;encoder.encode('x'.repeat(20000))&lt;/code&gt; on your own setup today. What did you get?&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>bugsmash</category>
      <category>performance</category>
      <category>llmops</category>
    </item>
  </channel>
</rss>
