<?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: InferHaven</title>
    <description>The latest articles on DEV Community by InferHaven (@inferhaven).</description>
    <link>https://dev.to/inferhaven</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%2F3986382%2Fb70b5bca-f72b-45d8-a3f3-9996dd271234.png</url>
      <title>DEV Community: InferHaven</title>
      <link>https://dev.to/inferhaven</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/inferhaven"/>
    <language>en</language>
    <item>
      <title>Adding error monitoring nearly leaked my users' API keys</title>
      <dc:creator>InferHaven</dc:creator>
      <pubDate>Thu, 13 Aug 2026 08:42:56 +0000</pubDate>
      <link>https://dev.to/inferhaven/adding-error-monitoring-nearly-leaked-my-users-api-keys-aj0</link>
      <guid>https://dev.to/inferhaven/adding-error-monitoring-nearly-leaked-my-users-api-keys-aj0</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;p&gt;I found four places where my app fails without telling anyone. Fixing them meant sending those failures to Sentry, and the test I wrote to prove the fix worked failed for a reason I did not expect at all: the report would have carried the learner's whole conversation and a decrypted API key along with it.&lt;/p&gt;

&lt;p&gt;The near miss turned out to be the more useful half, but first the bug.&lt;/p&gt;

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

&lt;p&gt;CodeTrain is an AI tutor with one rule: it never writes your code. You type every line, it plans the steps, runs what you wrote and grades it. The control plane is FastAPI on Fly, Postgres on Neon, and it has had Sentry wired in since June.&lt;/p&gt;

&lt;p&gt;"Wired in" is roughly just that. The entire integration was two calls: &lt;code&gt;sentry_sdk.init()&lt;/code&gt; gated on a DSN, and one &lt;code&gt;capture_exception&lt;/code&gt; inside a catch-all handler. No custom tags or spans, and no context beyond the environment name. It had never caught anything, which means there was no obvious issues right?&lt;/p&gt;

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

&lt;p&gt;None of the four is sloppiness. Each one is a piece of defensive code doing exactly what it was written to do, but with invisibility as an unintended side effect.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Every model provider failure.&lt;/strong&gt; &lt;code&gt;run_managed_turn&lt;/code&gt; is the single path every metered model call goes through. It catches provider exceptions and re-raises them as a clean 502:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HTTP_502_BAD_GATEWAY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;model provider error: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is good API behaviour and a total blind spot. FastAPI routes &lt;code&gt;HTTPException&lt;/code&gt; to its own handler, which is not my catch-all handler, so &lt;code&gt;capture_exception&lt;/code&gt; never sees it. Every provider outage this product has had was a clean 502 for the learner and complete silence for me.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2, 3 and 4. Three places that swallow malformed model output.&lt;/strong&gt; When a model returns something that is not the JSON the prompt asked for, the parser falls back to a plausible default:&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="nf"&gt;except &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JSONDecodeError&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;raw_steps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No log line. The learner gets a lesson with no steps, or a "retry" verdict assembled from raw model prose, and nothing anywhere records that the model went off contract.&lt;/p&gt;

&lt;p&gt;This is the same shape as &lt;a href="https://inferhaven.com/blog/2026-08-07-the-bug-your-test-cannot-see/" rel="noopener noreferrer"&gt;the bug your test cannot see&lt;/a&gt;, which I wrote about last week: a failure that throws tells you where it is, and a failure that returns a plausible value does not. You cannot grep for something that was never written down.&lt;/p&gt;

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

&lt;p&gt;The provider site now reports before it converts. Three lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;observability&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;capture_exception&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model_tier&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;model_tier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;surface&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;surface&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;managed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;used_managed&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;key_provider&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;key_provider&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;session_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;session_id&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;none&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HTTP_502_BAD_GATEWAY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;model provider error: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The three parse sites were harder, because of a constraint specific to my codebase. The tutor engine exists twice: once in the control plane, and once in a CLI agent that ships as a tarball and has no Sentry wired in. Importing a control plane service into the shared engine would have broken that. So the engine declares a hook and defaults it to nothing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;on_parse_failure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;site&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Called when model output could not be parsed and a fallback was returned instead.

    A no-op by default, and deliberately so: the engine stays free of any control-plane
    dependency, and importing it bare must never need Sentry. `app.main.create_app`
    rebinds this to `services.observability.note_parse_failure`.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and each fallback reports through it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nf"&gt;except &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JSONDecodeError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;on_parse_failure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;course&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chars&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;lang&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;lang&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                     &lt;span class="n"&gt;found_object&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JSONDecodeError&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                     &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;_parse_error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;raw_steps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note what is in that payload and what is not. Length, whether a JSON object was located at all, and json's own positional complaint. Never the model's output. That constraint is the whole next section.&lt;/p&gt;

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

&lt;p&gt;What actually changed:&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;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;Provider failures&lt;/td&gt;
&lt;td&gt;Silent 502&lt;/td&gt;
&lt;td&gt;Reported, tagged, rate limited&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Malformed model output&lt;/td&gt;
&lt;td&gt;Silent fallback&lt;/td&gt;
&lt;td&gt;Reported with a positional reason&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Frame locals&lt;/td&gt;
&lt;td&gt;Prompt and key attached&lt;/td&gt;
&lt;td&gt;Never transmitted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sentry config&lt;/td&gt;
&lt;td&gt;Inline at the init call&lt;/td&gt;
&lt;td&gt;One shared dict, prod and tests&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tutor turns&lt;/td&gt;
&lt;td&gt;Untraced&lt;/td&gt;
&lt;td&gt;19 spans, tokens, cost&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Plus 24 tests, six of them on the rate limiter alone, and a script that reproduces all four failures on demand.&lt;/p&gt;

&lt;h2&gt;
  
  
  Best Use of Sentry
&lt;/h2&gt;

&lt;p&gt;I wrote the tests for this against a real Sentry client with a capturing transport, rather than mocking my own wrapper. A mock would only have proven that my code calls the function I told it to call. I wanted to see the actual bytes.&lt;/p&gt;

&lt;p&gt;The test asserted that a captured provider failure contains no prompt and no key. It failed immediately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sentry attaches every stack frame's local variables to an exception event, and &lt;code&gt;send_default_pii=False&lt;/code&gt; does not seem to cover them.&lt;/strong&gt; That flag governs request and user data. Frame locals are a separate option, &lt;code&gt;include_local_variables&lt;/code&gt;, and it does default to on.&lt;/p&gt;

&lt;p&gt;Look at where that lands. The frames around a failed provider call hold &lt;code&gt;messages&lt;/code&gt;, which is the learner's entire conversation, and &lt;code&gt;org_creds&lt;/code&gt;, which is a customer's decrypted BYO API key.&lt;/p&gt;

&lt;p&gt;Here is that event, sent with the SDK's default configuration:&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%2F9h19uhaupr1phi6ploar.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%2F9h19uhaupr1phi6ploar.png" alt="The before event: key_override, org_creds, messages and system all in plaintext" width="800" height="807"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;key_override&lt;/code&gt; in plaintext. &lt;code&gt;org_creds&lt;/code&gt; in plaintext, same key again. The full conversation. The system prompt.&lt;/p&gt;

&lt;h3&gt;
  
  
  The part that almost fooled me
&lt;/h3&gt;

&lt;p&gt;My first version of that reproduction named its variables &lt;code&gt;api_key&lt;/code&gt; and &lt;code&gt;credentials&lt;/code&gt;. Sentry came back like this:&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%2Foogfdz3xl4o4khh87x4q.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%2Foogfdz3xl4o4khh87x4q.png" alt="The same failure with differently named variables: credentials and api_key both show Filtered" width="800" height="658"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;[Filtered]&lt;/code&gt;, &lt;code&gt;[Filtered]&lt;/code&gt;. If I had stopped there I would have written a very confident paragraph about how Sentry protects you automatically, and I would have been wrong.&lt;/p&gt;

&lt;p&gt;Sentry's server-side scrubber matches on field &lt;strong&gt;name&lt;/strong&gt;. &lt;code&gt;api_key&lt;/code&gt; and &lt;code&gt;credentials&lt;/code&gt; are on its list. &lt;code&gt;org_creds&lt;/code&gt; and &lt;code&gt;key_override&lt;/code&gt; are not, and neither is &lt;code&gt;messages&lt;/code&gt;. Two things follow. The protection evaporates the moment you rename a variable, and it happens after transmission anyway, so it is redaction at rest rather than a control over what leaves your server.&lt;/p&gt;

&lt;p&gt;Look again at that second screenshot, though. &lt;code&gt;prompt&lt;/code&gt; is sitting there in plaintext, directly underneath two &lt;code&gt;[Filtered]&lt;/code&gt; rows. Even in the case where the scrubber works, it never had any opinion about user content.&lt;/p&gt;

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

&lt;p&gt;One option, in one place, shared by production and the test suite so they cannot drift:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;client_options&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;environment&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;app_env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traces_sample_rate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sentry_traces_sample_rate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;send_default_pii&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;include_local_variables&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same failure, same frame, after:&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%2Fduw788nb64fakm0p3yv8.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%2Fduw788nb64fakm0p3yv8.png" alt="The after event: no local variables section at all" width="799" height="413"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The test that found this generates its secrets at runtime, so they cannot appear in the source context Sentry also attaches, and it fails if anyone flips the option back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_frame_locals_never_reach_sentry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;monkeypatch&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;learner asked: &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uuid4&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nb"&gt;hex&lt;/span&gt;
    &lt;span class="n"&gt;api_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sk-live-&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uuid4&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nb"&gt;hex&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;prompt&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;the learner&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s prompt reached Sentry via frame locals&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;api_key&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;a decrypted BYO API key reached Sentry via frame locals&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I checked it is not a vacuous guard by flipping the option back and watching it go red.&lt;/p&gt;

&lt;h3&gt;
  
  
  Keeping the events useful
&lt;/h3&gt;

&lt;p&gt;Scrubbing locals is only correct if what remains still tells you something. Tags carry the operational shape of the turn, and a parse failure carries enough to diagnose it without a byte of model output:&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%2F60km854fnodh1xt62xbm.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%2F60km854fnodh1xt62xbm.png" alt="Parse failure context: chars, error, found_object, lang" width="681" height="168"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Expecting ',' delimiter (char 52)&lt;/code&gt; is json's own positional message. It says where the model's output broke without repeating any of it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Not spending the month's quota on one bad afternoon
&lt;/h3&gt;

&lt;p&gt;I am currently on the free plan, 5k errors a month, and Sentry's own per-key rate limiting is a paid feature. A provider outage fails every turn at once, so uncapped, one bad afternoon spends the month and then Sentry drops everything afterwards silently. That is the &lt;code&gt;bug&lt;/code&gt; I just fixed from the perspective of my codebase.&lt;/p&gt;

&lt;p&gt;So the ceiling lives in my code: a token bucket per failure kind, five events immediately because an incident should be visible on its first failure, then one an hour while it continues. A simulated 24 hour outage of 86,400 failures sends only 28 events.&lt;/p&gt;

&lt;p&gt;The cap is only allowed to be quiet about volume, never about the fact of it. Every event that gets through reports what it held back:&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%2Fyt5vk09t4l0kk9iw7gyd.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%2Fyt5vk09t4l0kk9iw7gyd.png" alt="Tags showing suppressed_since_last 195, model_tier haiku, provider managed" width="800" height="135"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;201 failures, 6 events, and the last one says 195. The local log line still fires every time, because Fly's logs are free and an incident should stay fully reconstruct-able.&lt;/p&gt;

&lt;h3&gt;
  
  
  Agent tracing
&lt;/h3&gt;

&lt;p&gt;The tutor is an agent loop, so I wrapped one turn in a manually created transaction. Sentry's AI instrumentation picked up the model call on its own:&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%2Fkj8kpg7pr8wh7abwnl20.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%2Fkj8kpg7pr8wh7abwnl20.png" alt="Trace waterfall showing tutor.turn, gen_ai.chat, and the Anthropic HTTP call" width="800" height="358"&gt;&lt;/a&gt;&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%2Ffd3p1dgl3psdyqgw1pjw.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%2Ffd3p1dgl3psdyqgw1pjw.png" alt="Agent Activity tab: model, 971 in + 248 out tokens, cost breakdown" width="800" height="463"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Nineteen spans for one turn: the entitlement checks, the budget queries, the model call, the usage insert. &lt;code&gt;gen_ai.chat&lt;/code&gt;, &lt;code&gt;claude-haiku-4-5&lt;/code&gt;, 971 in and 248 out, cost to four decimal places, context utilisation at 1%.&lt;/p&gt;

&lt;p&gt;Worth noticing what the Input panel says on that span: "No input for this span." The trace tells me the shape of the turn, which step, which model, how long, what it cost. It does not tell me what anybody typed, which is the same boundary everything else here is drawn on.&lt;/p&gt;

&lt;p&gt;That is also why the transaction is created by hand rather than by raising the global sample rate. Raising it auto-instruments every request the app serves, which is unbounded volume for visibility I only want on model turns. Production runs at &lt;code&gt;0.0&lt;/code&gt;, so the transaction is created unsampled and never sent, and the traces above come from a dev environment. My privacy policy discloses Sentry for error monitoring, and I would rather keep the code inside that promise instead of widen it.&lt;/p&gt;

&lt;h2&gt;
  
  
  About the impact, honestly
&lt;/h2&gt;

&lt;p&gt;I can tell you exactly how many learners have been hit by the parse bugs, because the fallback feedback is persisted and I can count it with a query like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'fail_md'&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'%"verdict"%'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;parse_fallbacks&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;study_sessions&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;
&lt;span class="k"&gt;CROSS&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="k"&gt;LATERAL&lt;/span&gt; &lt;span class="n"&gt;jsonb_array_elements&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;coalesce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;state&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;jsonb&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'record'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'[]'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;jsonb&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the grand total of real customers affected = Zero. Across every completed step since late July. I then ran a real lesson through the instrumented build, four model calls including a wrong answer and a follow-up question, and got zero there too.&lt;/p&gt;

&lt;p&gt;I could have left that out. It is a better story if the bug was hurting people. But the honest result is that instrumentation turned "I have no idea whether this happens" into a number, and the number is zero, and knowing that is worth something on its own. The parse sites were structurally unable to report, which is the bug, whether or not it has fired yet.&lt;/p&gt;

&lt;p&gt;The leak is the one with teeth, and even there I want to be precise. My catch-all handler has had &lt;code&gt;include_local_variables&lt;/code&gt; on since June, so the exposure was real for about seven weeks. I audited every event in the project before publishing this. Nothing sensitive had been captured. It never fired in a frame that held a real key, so nothing needs rotating.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell you to go check
&lt;/h2&gt;

&lt;p&gt;If you run the Python SDK with &lt;code&gt;send_default_pii=False&lt;/code&gt; and assume that covers you, open any exception event you already have and expand a frame. Everything in scope at the moment it threw is in there.&lt;/p&gt;

&lt;p&gt;Then check it with your real variable names, not with something called &lt;code&gt;api_key&lt;/code&gt;. That was the difference between a paragraph that was wrong and one that was right, and it took one extra test to find out which one I was writing.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>bugsmash</category>
      <category>python</category>
      <category>testing</category>
    </item>
    <item>
      <title>The bug your test cannot see</title>
      <dc:creator>InferHaven</dc:creator>
      <pubDate>Mon, 10 Aug 2026 07:15:16 +0000</pubDate>
      <link>https://dev.to/inferhaven/the-bug-your-test-cannot-see-5aio</link>
      <guid>https://dev.to/inferhaven/the-bug-your-test-cannot-see-5aio</guid>
      <description>&lt;p&gt;Here is a function. It has one job: give me the three highest scores.&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;topThree&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;scores&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="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="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;a&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;scores&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;3&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;It works. &lt;code&gt;topThree([72, 98, 64, 91, 87, 55])&lt;/code&gt; returns &lt;code&gt;[98, 91, 87]&lt;/code&gt;, which is correct. It throws nothing. It has a test, and the test is green.&lt;/p&gt;

&lt;p&gt;Now watch what happens when real code calls it.&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;const&lt;/span&gt; &lt;span class="nx"&gt;names&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="s2"&gt;Ada&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="s2"&gt;Bo&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="s2"&gt;Cy&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="s2"&gt;Di&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="s2"&gt;Eve&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="s2"&gt;Fay&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;scores&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;  &lt;span class="mi"&gt;72&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="mi"&gt;98&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="mi"&gt;91&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="mi"&gt;87&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="mi"&gt;55&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;pair&lt;/span&gt;  &lt;span class="o"&gt;=&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="nx"&gt;i&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;n&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&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;board&lt;/span&gt; &lt;span class="o"&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="nx"&gt;names&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;pair&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="s2"&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;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;before:   &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;board&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;top&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;topThree&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;after:    &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;board&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;before:    Ada 72   Bo 98   Cy 64   Di 91   Eve 87   Fay 55
after:     Ada 98   Bo 91   Cy 87   Di 72   Eve 64   Fay 55
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ada scored 72. Ada now has 98. Every name in that list is sitting next to somebody else's score, and the function that did it also returned exactly the right answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the test could not have caught this
&lt;/h2&gt;

&lt;p&gt;The obvious reaction is that the test was lazy. Write a better one.&lt;/p&gt;

&lt;p&gt;That reaction is wrong in a way I think is worth analyzing, because it is the difference between "we got sloppy" and "this class of bug needs a different kind of attention."&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Array.prototype.sort&lt;/code&gt; sorts in place. It rearranges the array you handed it and then returns that same array. So the &lt;code&gt;topThree&lt;/code&gt; function shown above has two effects: the value it returns, and the change it makes to its argument. The original test only ever looked at the first one.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A test of the return value cannot observe a side effect. Not because it was written badly, but because a return value is not where the side effect resides.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;You could write an assertion that catches it. &lt;code&gt;expect(scores).toEqual([72, 98, 64, 91, 87, 55])&lt;/code&gt; after the call would go red immediately. But you only write that assertion if you already suspected the function touched its argument, and if you already suspected that, you would have fixed the function instead.&lt;/p&gt;

&lt;p&gt;That is what makes this expensive. A bug that throws tells you where it is. A bug that fails a test tells you where it is. This one returns the right answer, passes review, and shows up three weeks later as a support ticket about the wrong name on a leaderboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  The near miss that catches experienced people
&lt;/h2&gt;

&lt;p&gt;The fix is to sort a copy instead of the original.&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="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;scores&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="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="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;a&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;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is the version I find genuinely interesting, because it looks like the fix and is not:&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="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;scores&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="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="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;a&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;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same characters, different order. The spread is on the outside, so &lt;code&gt;.sort()&lt;/code&gt; runs first, on the caller's array, and by the time the copy happens the damage is already done. It returns the correct answer and mutates, exactly like the original.&lt;/p&gt;

&lt;p&gt;I have seen people who have been writing JS for a decade write that one, and its not a knowledge gap. It is that the two versions look identical at a glance and only one of them is about ordering of operations.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;toSorted, and why it is not the whole answer&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;toSorted&lt;/code&gt; is the modern answer and it does the right thing: it returns a new sorted array and leaves yours alone. It landed in ES2023, so Node below 20, Safari below 16 and Chrome below 110 throw on it. &lt;code&gt;[...arr].sort()&lt;/code&gt; is the version with no version requirement.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  This is roughly why lodash &lt;code&gt;orderBy&lt;/code&gt; exists
&lt;/h2&gt;

&lt;p&gt;If you have ever wondered what a utility library is buying you when the language already has &lt;code&gt;sort&lt;/code&gt;, this is a large part of the answer. &lt;code&gt;_.orderBy(scores, [], ['desc'])&lt;/code&gt; sorts a copy and hands that back. The array you passed in is never touched, and that guarantee is the product.&lt;/p&gt;

&lt;p&gt;The naming does not help anyone. &lt;code&gt;sort&lt;/code&gt;, &lt;code&gt;reverse&lt;/code&gt;, &lt;code&gt;splice&lt;/code&gt;, &lt;code&gt;push&lt;/code&gt; and &lt;code&gt;pop&lt;/code&gt; all change the array you call them on. &lt;code&gt;map&lt;/code&gt;, &lt;code&gt;filter&lt;/code&gt;, &lt;code&gt;slice&lt;/code&gt; and &lt;code&gt;concat&lt;/code&gt; all give you a new one. Nothing in those names really tells you which is which, so it comes down to memory, and memory is exactly what fails at the worst times.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I turned this into the demo on my landing page
&lt;/h2&gt;

&lt;p&gt;I launched &lt;a href="https://codetrain.ai" rel="noopener noreferrer"&gt;CodeTrain&lt;/a&gt; three weeks ago. It is an AI tutor with one rule: it never writes your code. You type every line, it plans the steps, runs what you wrote and grades it.&lt;/p&gt;

&lt;p&gt;The thing I got wrong was assuming that if people heard about it, the ones who wanted it would sign up. So I did the whole distribution playbook. What I actually had was a funnel where people arrived, hit an email field, and had no way to find out whether the thing was any good before handing it over.&lt;/p&gt;

&lt;p&gt;So the lesson above is now the demo, and it runs on the landing page with no account and no install.&lt;/p&gt;

&lt;p&gt;It is a real lesson, not a video of one. Your code executes in a real Web Worker in your own tab. The checks are real assertions against what your function actually returned and what it did to its argument, including one case you cannot see, because a fix that only works on the numbers in front of you is not a fix. Break the slice and the check goes red. Delete the function and you get a real missing-entry error.&lt;/p&gt;

&lt;p&gt;The one thing that is not live is the tutor's replies. Those are written in advance, the page says so before you start, and every reply is labeled as such. A preview that implies a live model when there is not one is a lie about the product, and I would rather lose the visitor than start there.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bit I nearly shipped backwards
&lt;/h2&gt;

&lt;p&gt;The first version of that demo had a problem I did not see until the fourth or fifth read.&lt;/p&gt;

&lt;p&gt;Step one showed a passing check next to broken code, and the tutor said something like "the check did not catch it." I meant it as a statement about tests in general. What it actually reads as, on my own product page, is that CodeTrain wrote a check that misses bugs.&lt;/p&gt;

&lt;p&gt;The fix for the demo lesson was to reword the claim. The check in step one belongs to the codebase in the example, not to CodeTrain, and it is now labelled that way on screen for clarity. What CodeTrain checks is step two, and there are three assertions there including the one about the argument. The original test asked one question. The tutor asks three, and one of them is the question that matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The lesson is at &lt;a href="https://codetrain.ai/#try" rel="noopener noreferrer"&gt;codetrain.ai/#try&lt;/a&gt;. Two steps, about a minute, no account.&lt;/p&gt;

&lt;p&gt;Two steps is a real lesson at the short end, incidentally. The tutor builds between two and six depending on how big the topic is. The difference in the real thing is that it reads whatever you actually wrote, so the questions come from your code instead of from a list I authored ahead of time. Ten lessons a month on the free tier, no card, and you can point it at any public repository and get a lesson built from the code that is really in it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://inferhaven.com/blog/2026-08-07-the-bug-your-test-cannot-see" rel="noopener noreferrer"&gt;InferHaven blog&lt;/a&gt;. I'm the founder, and I'm in the comments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>codetrain</category>
      <category>javascript</category>
      <category>testing</category>
      <category>code</category>
    </item>
    <item>
      <title>How I built a tutor that refuses to write your code</title>
      <dc:creator>InferHaven</dc:creator>
      <pubDate>Wed, 29 Jul 2026 18:54:58 +0000</pubDate>
      <link>https://dev.to/inferhaven/how-i-built-a-tutor-that-refuses-to-write-your-code-2pf5</link>
      <guid>https://dev.to/inferhaven/how-i-built-a-tutor-that-refuses-to-write-your-code-2pf5</guid>
      <description>&lt;p&gt;Two weeks ago I launched &lt;a href="https://codetrain.ai" rel="noopener noreferrer"&gt;CodeTrain&lt;/a&gt;, an AI tutor with one rule: it never writes your code. You type every line, it plans the steps, runs what you wrote, and grades it.&lt;/p&gt;

&lt;p&gt;People probably assume the rule is a prompt. Something like "do not write code for the user," pasted at the top of a system message, and done. That was the first version. It lasted about an hour.&lt;/p&gt;

&lt;p&gt;What follows is what the rule actually cost to build: a grading contract instead of a chat reply, two separate model calls that must never be merged, two execution paths so the free tier costs almost nothing to run, and a short list of things that broke in front of real users.&lt;/p&gt;

&lt;h2&gt;
  
  
  A refusal is not prompted, its contracted
&lt;/h2&gt;

&lt;p&gt;Here is the failure mode nobody warns you about. Ask a model to teach rather than solve, and it agrees enthusiastically. Then the learner gets stuck, and the model helps. It writes "you could try something like this" and drops in four lines. Technically it did not actually solve the exercise. Practically the lesson is over..&lt;/p&gt;

&lt;p&gt;The fix to this was to stop asking for prose and start requiring a decision. Every grading turn returns JSON with a fixed shape, or it is treated as a failure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"verdict"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"advance"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"retry"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"comment"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"feedback_md"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;concise Socratic markdown&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"checks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="nl"&gt;"label"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"prints exactly Hello"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"pass"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"learned"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;short concept the learner demonstrated&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That single change did more for the product than any amount of instruction tuning in the prompt. A model writing prose has infinite room to be helpful in the wrong direction. A model that has to pick &lt;code&gt;advance&lt;/code&gt; or &lt;code&gt;retry&lt;/code&gt;, and list the criteria it checked with a boolean next to each, has to commit to a judgment about the learner's code.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&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;Agreeable by default, because agreeing reads as helpful&lt;/td&gt;
&lt;td&gt;Has to commit: advance, retry, or comment&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Slides into writing the fix when the learner struggles&lt;/td&gt;
&lt;td&gt;Every criterion carries a boolean the learner can see&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No stable signal for the UI to render&lt;/td&gt;
&lt;td&gt;Failing criteria are required, so the gap is visible&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Impossible to tell a pass from a polite non-answer&lt;/td&gt;
&lt;td&gt;The UI renders check marks instead of paragraphs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;checks&lt;/code&gt; array is the part learners actually respond to. Each item is one concrete criterion with pass or fail, and the prompt requires failing criteria to be included rather than quietly dropped. You see exactly which two of five things your code did, which is a very different experience from a paragraph explaining that you are on the right track.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep the two calls apart
&lt;/h2&gt;

&lt;p&gt;Lesson authoring and grading are separate calls, with separate prompts, and merging them is the single worst thing you can do to a tutor like this.&lt;/p&gt;

&lt;p&gt;I know because I tried it. One call, full context, plan the next step and grade the last one at the same time. It saves a round trip and it is meaningfully cheaper. It also produces a tutor that could write the answer into the question. When the same call that just saw a struggling learner also composes the next step, the step it writes gets suspiciously specific. "Now add the &lt;code&gt;except KeyError&lt;/code&gt; branch that returns an empty list" is just the solution with a task label on it.&lt;/p&gt;

&lt;p&gt;Separated, the authoring call never sees the failure and cannot overfit to it. That constraint is now enforced in the codebase itself: control-plane bookkeeping stays in the router, the tutor engine stays synchronised with the agent's copy of the prompt, and a captured-prompt test fails the build if the two drift apart.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The rule the grader needed most&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not "never write code." That part was easy. The hard rule was: do not be pedantic. Early graders would refuse to advance correct code because the learner had not exercised every branch at run time. A correct &lt;code&gt;if/else&lt;/code&gt; is correct even if only one side ran. The prompt now says so explicitly, and instead of gating, it politely suggests an input that would show the other branch.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Two execution paths, and why the free tier does none of the work
&lt;/h2&gt;

&lt;p&gt;A tutor that grades code has to run code. The obvious design puts a container behind every Run button, and it is the reason a lot of similar products have no free tier, or one with a queue in front of it.&lt;/p&gt;

&lt;p&gt;Python and JavaScript run entirely in the learner's browser. Python goes through Pyodide in a dedicated worker, JavaScript runs in its own worker, and both stay pooled so the second Run does not pay startup again. A prewarm kicks off when the lesson opens, which usually finishes before anyone types their first line. Runs are capped at 20 seconds, which is generous for a teaching step and short enough that a runaway loop does not hang the tab.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;On the free tier, the Python and JavaScript a learner writes never reaches a server, which is the only reason the tier can exist without a card and without a queue.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;free tier economics&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That holds for the two languages most lessons use. bash, ruby, perl and php genuinely need a real interpreter and a filesystem, so those go to an isolated server sandbox with per-minute and per-day caps, on free accounts too. The browser runtimes cover the common case; the sandbox covers what a browser cannot honestly fake.&lt;/p&gt;

&lt;p&gt;The measured cost tracks either way. Across a recent two-week window the managed model spend for every free-tier session on the platform came to about 25 cents total. Compute for running the learner's code was zero, because it happened on their machine, in a tab they already had open.&lt;/p&gt;

&lt;p&gt;The languages that cannot run in a browser fall back to a real sandbox on our infrastructure: bash, ruby, perl and php, with a real shell and real files, no network, and per-user rate and daily caps. That sandbox is Alpine with BusyBox, which produced its own class of bug. The grader would demand a GNU coreutils flag that BusyBox does not implement, then fail a learner whose solution was correct. The prompt now names that constraint directly and instructs the grader to accept any working BusyBox-compatible approach.&lt;/p&gt;

&lt;p&gt;Dockerfile lessons get static linting instead of a real build. Letting anonymous users build containers on my infrastructure is a speedrun to an incident writeup, and the lesson value was in the file, not the image.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things that broke in front of real users
&lt;/h2&gt;

&lt;p&gt;If you are going to skip ahead to anything in this article, here is the interesting stuff.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The model emitted JSON that was not JSON.&lt;/strong&gt; Specifically, feedback containing a stray backslash, usually from a regex or a Windows path in the learner's code, which invalidated an otherwise perfect verdict. The parser now repairs unescaped backslashes before parsing, and falls back to a &lt;code&gt;retry&lt;/code&gt; carrying the raw text if it still cannot be read. A tutor that hard-fails because a learner typed &lt;code&gt;\d&lt;/code&gt; is not a tutor that can function well.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Older model output used plain strings for checks&lt;/strong&gt; instead of objects with a label and a boolean. Rather than break those, the coercion layer treats a bare string as a passing check. It is not elegant. It is the difference between rendering something useful and rendering an error.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The tutor asked permission to continue.&lt;/strong&gt; Early versions ended a correct submission with "ready to move on?" Every single time, learners answered the question instead of coding, and the lesson turned into a conversation. Advancing is now the confirmation, stated as a hard rule in the prompt: when the submission is correct, advance, do not ask.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Browser Python is not Python.&lt;/strong&gt; No subprocess, no real files, no network. A learner following an authored step involving &lt;code&gt;subprocess&lt;/code&gt; was hitting a wall the tutor had built for them. The grader is now told to never fault the learner for a runtime limit, because that step should not have been written in the first place, and to guide toward a runnable approach or simply advance. I will be improving this behaviour into the future as well.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The pattern in all four&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every one of these is a case where the model needed to be guided to perform as a more acceptable tutor instead of just a sophisticated chatbot. Each one had a simple yet effective fix and I plan to be always improving CodeTrains effectiveness as a tutor.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Repo mode and the patch at the end
&lt;/h2&gt;

&lt;p&gt;The free tier teaches on examples and any public repository you point it at. Paid repo mode runs against a real checkout on your machine, which raises a question I got wrong at first: what happens to the code you wrote?&lt;/p&gt;

&lt;p&gt;The first design applied each step as its own commit. It felt responsive and it was a mistake. Nobody wants seven commits titled "step 3" in their history, and a half-finished lesson left the working tree in a state the learner had to clean up.&lt;/p&gt;

&lt;p&gt;Now the lesson holds everything and proposes one patch at the end, containing only the code you typed, against a branch you choose. You review it like any other diff. If you abandon the lesson, nothing touches your repository at all.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;One rule that has not moved&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The repo reader refuses secrets. The denylist that keeps &lt;code&gt;.env&lt;/code&gt; files and key material out of lesson context is mirrored in two places, the local agent and the control plane, and changing one without the other is treated as a bug.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What I'm tuning next
&lt;/h2&gt;

&lt;p&gt;The tutor is at its best on a focused concept in code it can read directly. Lesson scope is the dial still getting tuned in: a tight lesson on one module lands harder than one that ranges across a whole framework, so more of the authoring work is going into keeping lessons narrow by default.&lt;/p&gt;

&lt;p&gt;Browser Python runs on Pyodide, whose package set is a subset of PyPI. The prompt-level guardrails already steer authored steps toward what the runtime can actually import, and widening that coverage, including routing more lesson types to the server sandbox where it makes sense, is the next piece of that work.&lt;/p&gt;

&lt;p&gt;The grading loop is a model making a judgment, so the design choice was to make the judgment inspectable. Every criterion is listed with a pass or fail rather than one overall score, which means you can always see the basis for a verdict instead of blind faith in the model itself. That visibility is also our own best feedback channel: every reported step comes back with the checks attached, which is how the tuning above gets prioritised.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try to break it
&lt;/h2&gt;

&lt;p&gt;The free tier runs in your browser, ten lessons a month, no card and no trial clock: &lt;a href="https://codetrain.ai" rel="noopener noreferrer"&gt;codetrain.ai&lt;/a&gt;. Point it at a public repository you did not write and ask for a lesson on the part you understand least. That is the fastest honest test of everything above.&lt;/p&gt;

&lt;p&gt;If you talk it into writing your code outright, send me the screenshot. I will fix the loop, and probably frame the screenshot!&lt;/p&gt;

&lt;p&gt;-- Ethan L.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://inferhaven.com/blog/2026-07-29-tutor-that-refuses-to-write-code" rel="noopener noreferrer"&gt;InferHaven blog&lt;/a&gt;. I'm the founder, and I'm in the comments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>codetrain</category>
      <category>ai</category>
      <category>python</category>
      <category>sandboxing</category>
    </item>
    <item>
      <title>I built a production AI agent for our NOC ticket queue in one shift</title>
      <dc:creator>InferHaven</dc:creator>
      <pubDate>Fri, 17 Jul 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/inferhaven/i-built-a-production-ai-agent-for-our-noc-ticket-queue-in-one-shift-3fka</link>
      <guid>https://dev.to/inferhaven/i-built-a-production-ai-agent-for-our-noc-ticket-queue-in-one-shift-3fka</guid>
      <description>&lt;p&gt;Our NOC gets a steady stream of tickets every day, and a good chunk of them are routine: the same site issue reported twice, a ticket that just needs the right category to be organized clearly, a slow-connection report that needs proper network diagnostics. None of that requires real judgment. All of it eats time.&lt;/p&gt;

&lt;p&gt;I'd already been using OpenCode to just mess around and test things for free while on shift, so I decided to wire up an autonomous agent to reduce some of the repetitive workload of managing our tickets.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it actually does&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Each separate task of the workflow basically lives in a skill file, loaded by the OpenCode agent in correct order with an AGENTS.md to orchestrate the entire workflow, pretty standard.&lt;br&gt;
A webhook receives a new ticket, waits sixty seconds to see if more come in (batching saves a lot of redundant work), then hands the batch to OpenCode running in non-interactive mode. From there it checks for duplicates against other open tickets for the same site and issue, categorizes the ticket by type, and for anything flagged as a slow connection, kicks off a deeper network diagnostic and posts the result straight to the ticket. It pings the team in chat only for tickets that actually need a human, which is still most of them now as its main job here to start is simply take on 80% of the initial ticket analysis and investigation workload.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What actually took the day&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The AI part was the easy part, weirdly. What ate the hours was the plumbing underneath it. Duplicate detection needed exact-match site names, and site naming in our system turned out messier than expected, learned that one the hard way. The network diagnostic tool uses different naming conventions than the ticket system for the same physical sites, so there's a whole lookup step just to translate between the two. The first version had a five-minute timeout on the diagnostic step; turns out some sites take seven-plus minutes to fully analyze especially if there is an issue causing network slowdowns on the way. I bumped the default timeout to ten and made configurable via the .env file so I'm not guessing next time. And there's a fallback notification for when the agent itself crashes or runs out of budget, because a ticket disappearing silently is worse than the ticket never being automated at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where it landed&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It's running as a systemd service now, processing real tickets. Duplicates get caught and routed without anyone touching them. The diagnostic runs automatically on the tickets that need it. The team only sees what actually needs attention, and the total ticket count is already trending down since it went live.&lt;/p&gt;

&lt;p&gt;The part that surprised me most wasn't that an AI agent could do this. It's that the whole thing, idea to running in production, fit inside one shift alongside the other daily workloads I always have going. A year ago this would've been a sprint's worth of work for a team, and half of it would've gone to exactly the plumbing described above, not the interesting part.&lt;/p&gt;

&lt;p&gt;If you're running a similar agent, curious what your batching and timeout setup looks like. Feels like the kind of dead simple thing everyone's probably independently reinventing right now.&lt;/p&gt;

</description>
      <category>agents</category>
      <category>ai</category>
      <category>networking</category>
      <category>automation</category>
    </item>
    <item>
      <title>[Boost]</title>
      <dc:creator>InferHaven</dc:creator>
      <pubDate>Thu, 16 Jul 2026 12:49:51 +0000</pubDate>
      <link>https://dev.to/inferhaven/-3j7f</link>
      <guid>https://dev.to/inferhaven/-3j7f</guid>
      <description>&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/inferhaven/i-built-an-ai-tutor-that-refuses-to-write-your-code-5d01" class="crayons-story__hidden-navigation-link"&gt;I built an AI tutor that refuses to write your code&lt;/a&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;

          &lt;a href="/inferhaven" class="crayons-avatar  crayons-avatar--l  "&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%2Fuser%2Fprofile_image%2F3986382%2Fb70b5bca-f72b-45d8-a3f3-9996dd271234.png" alt="inferhaven profile" class="crayons-avatar__image"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/inferhaven" class="crayons-story__secondary fw-medium m:hidden"&gt;
              InferHaven
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                InferHaven
                
              
              &lt;div id="story-author-preview-content-4156227" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/inferhaven" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&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%2Fuser%2Fprofile_image%2F3986382%2Fb70b5bca-f72b-45d8-a3f3-9996dd271234.png" class="crayons-avatar__image" alt=""&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;InferHaven&lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

          &lt;/div&gt;
          &lt;a href="https://dev.to/inferhaven/i-built-an-ai-tutor-that-refuses-to-write-your-code-5d01" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;Jul 16&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/inferhaven/i-built-an-ai-tutor-that-refuses-to-write-your-code-5d01" id="article-link-4156227"&gt;
          I built an AI tutor that refuses to write your code
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag crayons-tag--filled  " href="/t/showdev"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;showdev&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/ai"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;ai&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/learning"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;learning&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/coding"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;coding&lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
          &lt;a href="https://dev.to/inferhaven/i-built-an-ai-tutor-that-refuses-to-write-your-code-5d01" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left"&gt;
            &lt;div class="multiple_reactions_aggregate"&gt;
              &lt;span class="multiple_reactions_icons_container"&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/fire-f60e7a582391810302117f987b22a8ef04a2fe0df7e3258a5f49332df1cec71e.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
              &lt;/span&gt;
              &lt;span class="aggregate_reactions_counter"&gt;1&lt;span class="hidden s:inline"&gt;&amp;nbsp;reaction&lt;/span&gt;&lt;/span&gt;
            &lt;/div&gt;
          &lt;/a&gt;
            &lt;a href="https://dev.to/inferhaven/i-built-an-ai-tutor-that-refuses-to-write-your-code-5d01#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              

              &lt;span class="hidden s:inline"&gt;Add&amp;nbsp;Comment&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            5 min read
          &lt;/small&gt;
            
              &lt;span class="bm-initial crayons-icon c-btn__icon"&gt;
                

              &lt;/span&gt;
              &lt;span class="bm-success crayons-icon c-btn__icon"&gt;
                

              &lt;/span&gt;
            
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/div&gt;


</description>
    </item>
    <item>
      <title>I built an AI tutor that refuses to write your code</title>
      <dc:creator>InferHaven</dc:creator>
      <pubDate>Thu, 16 Jul 2026 12:00:00 +0000</pubDate>
      <link>https://dev.to/inferhaven/i-built-an-ai-tutor-that-refuses-to-write-your-code-5d01</link>
      <guid>https://dev.to/inferhaven/i-built-an-ai-tutor-that-refuses-to-write-your-code-5d01</guid>
      <description>&lt;p&gt;Somewhere on your team right now, a pull request is getting approved by someone who couldn't rewrite it from scratch. Not because they're lazy. The assistant wrote it, the tests passed, the diff looked plausible, and the sprint doesn't stop for philosophy. Do that for a year and you get a team that ships faster every quarter and understands a little less of its own codebase every quarter too.&lt;/p&gt;

&lt;p&gt;I ran into this a few times while building the start of InferHaven. The whole point of this company is that you shouldn't have to hand your code to someone else to get the benefits of AI. But there's a second thing quietly leaving the building, and no firewall catches it: the skill. When the model types and the human tabs through, the knowledge of how your system actually works stops living in your engineers and starts living in a vendor's autocomplete.&lt;/p&gt;

&lt;p&gt;So I built the opposite tool. Today I'm launching &lt;strong&gt;CodeTrain&lt;/strong&gt;: a hands-on AI trainer that teaches developers on their &lt;em&gt;own&lt;/em&gt; codebase, and refuses to write the code for them.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;**The learner writes every line. CodeTrain plans the lesson, sets up each step, runs your code, and grades the result, but it will not type your solution. Not even a "tiny" one-liner. When you're stuck it shrinks the step or gives you a sharper hint. It does not give you the answer, because the answer was never the point.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What a lesson actually looks like
&lt;/h2&gt;

&lt;p&gt;You ask CodeTrain something real. "Walk me through adding a health check to this Dockerfile." "I don't actually understand our retry logic, teach it to me." It breaks that into a short lesson, usually two to six steps, each one small enough to hold in your head.&lt;/p&gt;

&lt;p&gt;Each step is one concrete thing to write in a syntax-highlighted editor with a Run button. You type it. The code executes immediately: Python and JavaScript run right in your browser, shell and a few other runtimes run in an isolated sandbox, web stuff renders in a live preview. Then the tutor reviews what you wrote against the step's criteria, shows you ✓ and ✗ per criterion, and either advances you or asks the question that makes you see what's off. Fail a step multiple times and it doesn't dump the solution on you. It cuts the step in half.&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="nv"&gt;$ &lt;/span&gt;codetrain start &lt;span class="nt"&gt;--mode&lt;/span&gt; repo
Session a6502084e8254276a394d5be578e9229 started &lt;span class="o"&gt;(&lt;/span&gt;repo mode&lt;span class="o"&gt;)&lt;/span&gt;&lt;span class="nb"&gt;.&lt;/span&gt;
  Plan:     team
  Modes:    sandbox, repo
  Sessions: 0/1000 this month
  Usage:    0% of your monthly allowance &lt;span class="o"&gt;(&lt;/span&gt;100% left&lt;span class="o"&gt;)&lt;/span&gt;
  Models:   haiku, sonnet

Your tutor is open &lt;span class="k"&gt;in &lt;/span&gt;the browser:
    http://127.0.0.1:7341
&lt;span class="o"&gt;(&lt;/span&gt;workspace: /tmp/codetrain-repo-v555lhi7/repo&lt;span class="o"&gt;)&lt;/span&gt;

Tutoring is live &lt;span class="k"&gt;in &lt;/span&gt;your browser — keep this window open. Ctrl-C to stop.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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%2Fz48kolf4favo9soj30t8.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%2Fz48kolf4favo9soj30t8.png" alt="Starting repo mode" width="662" height="723"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Before the lesson: pick your level, how much help you want, and whether the session may build toward a real edit. The goal here is the health check from the terminal above.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That's repo mode, the paid tier. The tutor reads your actual repository, builds lessons out of your actual code, and at the end of a guided lesson (can run exploration only if desired) it proposes one integrated patch that lands the code &lt;em&gt;you&lt;/em&gt; wrote onto your real branch. You review it like any other diff. Free tier does not include repo mode and drops you into sandbox practice instead: same tutor, same you-type-it rule, generated exercises from natural language.&lt;/p&gt;

&lt;p&gt;Here is that health-check lesson, start to finish. Step 2 has you draft the probe on a scratch Dockerfile where mistakes are free:&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%2Fmantz6inh971t0cdwudk.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%2Fmantz6inh971t0cdwudk.png" alt="Test in scratch file" width="800" height="434"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;You write the HEALTHCHECK on scratch first. Run gives you the linter, Send gives you the tutor's verdict against the step's criteria.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Step 3 moves the line you wrote into the real Dockerfile, all 360-odd lines of it, in the one spot where Docker will actually honour 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%2Fweq97hh1yj6j36kcwam5.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%2Fweq97hh1yj6j36kcwam5.png" alt="Edits to real file" width="800" height="478"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Same instruction, real file. Placement is the lesson here: Docker only honours the last HEALTHCHECK, so it goes right before ENTRYPOINT/CMD.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Pass the final step and the tutor offers the one targeted patch it guided you on: the diff of what &lt;em&gt;you&lt;/em&gt; typed, applied to your working tree only after you approve 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%2F3ekq9dpf29utir12wp9h.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%2F3ekq9dpf29utir12wp9h.png" alt="Patchback review" width="670" height="950"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Every criterion checked, then the patch-back: your change as a reviewable diff. A .codetrain.bak backup is saved before anything is written.&lt;/em&gt;&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%2Fcujsgdssxpb1ytfhyouv.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%2Fcujsgdssxpb1ytfhyouv.png" alt="Change shipped" width="799" height="346"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Change shipped. A real HEALTHCHECK lives in the repo, and the recap lists what you practiced, not what was generated for you.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  This is not another assistant
&lt;/h2&gt;

&lt;p&gt;Don't get me wrong. I use coding assistants daily and they're great at their job. Their job is producing code. CodeTrain's job is producing engineers who understand code, and those jobs basically pull in opposite directions.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;AI assistant (Cursor, Copilot, Claude Code)&lt;/th&gt;
&lt;th&gt;CodeTrain&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;You describe, it writes&lt;/td&gt;
&lt;td&gt;It plans tiny steps, you write them&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Output: working code, fast&lt;/td&gt;
&lt;td&gt;Output: a developer who gets it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Understanding is optional&lt;/td&gt;
&lt;td&gt;Understanding is graded, per step&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Great when shipping is the goal&lt;/td&gt;
&lt;td&gt;Great when the goal is your team&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Use both. Seriously. Let the assistant ship the sprint while CodeTrain makes sure the person accepting its diffs could have written them.&lt;/p&gt;

&lt;p&gt;"Code got cheap. Understanding didn't. Train the part that's still expensive."&lt;/p&gt;

&lt;h2&gt;
  
  
  An InferHaven product
&lt;/h2&gt;

&lt;p&gt;InferHaven exists because your code is yours and should stay on your hardware. CodeTrain extends the same instinct to where your code lives secondly: wetware, brains, good ol' humans. Keep the code in-house, keep the skill in-house with it.&lt;/p&gt;

&lt;p&gt;It's built the way you'd expect from us. The agent runs on your machine. Sandbox lessons execute in your browser, not on a stranger's box. Repo mode works against your local checkout. And if you'd rather run lessons through your own model account, bring your own key: Anthropic, OpenRouter, Bedrock, Vertex, or even an Ollama endpoint you host yourself!&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs, honestly
&lt;/h2&gt;

&lt;p&gt;The free tier is real: the in-browser trainer, ten sessions a month, streaks and progress tracking, no credit card. It's capped because every managed session costs us actual inference money.&lt;/p&gt;

&lt;p&gt;Pro is $24/month and adds repo mode on your real projects, fair-use session limits, sync across devices, and optional BYOK. Team is $32 per seat and adds the part managers keep asking me about: a dashboard showing who's ramping, per-seat budgets, central billing, and onboarding journeys for new hires. Enterprise is the usual conversation about SSO and code that can't leave your network. If that's you, &lt;a href="mailto:sales@codetrain.ai"&gt;sales@codetrain.ai&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Go type something
&lt;/h2&gt;

&lt;p&gt;CodeTrain is live at &lt;a href="https://codetrain.ai" rel="noopener noreferrer"&gt;codetrain.ai&lt;/a&gt;. Sign in, ask it to teach you something you've been faking your way around, and see how it feels to be the one controlling the keyboard again. There's a one-minute demo on the landing page if you want to watch before you commit to the free tier's grand total of... zero dollars.&lt;/p&gt;

&lt;p&gt;InferHaven keeps your code yours. CodeTrain keeps the skill yours. Same haven, different cargo. The beacon is active!&lt;/p&gt;

&lt;p&gt;— Ethan L.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This is cross-posted from the &lt;a href="https://inferhaven.com/blog/2026-07-13-introducing-codetrain" rel="noopener noreferrer"&gt;InferHaven blog&lt;/a&gt;. I'm the founder and I'll be in the comments; if you try a lesson and it teaches you something wrong or hands you the answer on a silver platter, I want to hear about it more than I want the compliments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>learning</category>
      <category>coding</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Building haven bench in the open, and the flaky CI ghost it flushed out</title>
      <dc:creator>InferHaven</dc:creator>
      <pubDate>Wed, 24 Jun 2026 17:19:43 +0000</pubDate>
      <link>https://dev.to/inferhaven/building-haven-bench-in-the-open-and-the-flaky-ci-ghost-it-flushed-out-1426</link>
      <guid>https://dev.to/inferhaven/building-haven-bench-in-the-open-and-the-flaky-ci-ghost-it-flushed-out-1426</guid>
      <description>&lt;p&gt;&lt;em&gt;A debugging story from building InferHaven in the open: how a benchmark feature flushed out a flaky-CI race that had nothing to do with it.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I shipped a tokens/sec benchmark for local models. The unit tests were green, and then CI turned red in a way that looked like my fault. It wasn't. Here's the whole hunt: a chown that raced the tide, set -e, and a zsh lock file that vanished mid-sweep.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I built a small thing this week and it caught a bigger thing. That's the whole post, really. But the shape of how it happened is worth writing down, because it's the kind of story that usually gets quietly squashed into a one-line commit message and never told. Building in the open means showing the part where the harbor light flickers, not just the part where the boat docks clean.&lt;/p&gt;

&lt;p&gt;The small thing is &lt;code&gt;haven bench&lt;/code&gt;, a command that tells you how fast a model actually runs on your hardware. The bigger thing was a flaky CI failure that wore my new feature as a disguise.&lt;/p&gt;

&lt;h2&gt;
  
  
  The small thing: a number you can trust
&lt;/h2&gt;

&lt;p&gt;If you run local models, you live and die by tokens per second. It's the single number everyone in &lt;code&gt;r/LocalLLaMA&lt;/code&gt; trades like baseball cards, and yet most people read it off a vibe: "feels fast on my 3090." I wanted InferHaven to just tell you, honestly, on your own box.&lt;/p&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;haven bench qwen2.5-coder:3b &lt;span class="nt"&gt;--runs&lt;/span&gt; 3

  InferHaven bench — qwen2.5-coder:3b-instruct-q4_K_M
  run 1          106.0 tok/s
  run 2          106.3 tok/s
  run 3          106.0 tok/s
  generation     106.1 tok/s  &amp;lt;- decode rate &lt;span class="o"&gt;(&lt;/span&gt;avg of 3 runs&lt;span class="o"&gt;)&lt;/span&gt;
  prompt &lt;span class="nb"&gt;eval   &lt;/span&gt;3507.8 tok/s  &lt;span class="o"&gt;(&lt;/span&gt;40 prompt tokens&lt;span class="o"&gt;)&lt;/span&gt;
  load            0.17 s     &lt;span class="o"&gt;(&lt;/span&gt;run 1: weights -&amp;gt; VRAM&lt;span class="o"&gt;)&lt;/span&gt;
  total           1.39 s     &lt;span class="o"&gt;(&lt;/span&gt;run 1&lt;span class="o"&gt;)&lt;/span&gt;
  method       &lt;span class="nv"&gt;num_predict&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;128, &lt;span class="nv"&gt;seed&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0, &lt;span class="nv"&gt;temp&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0, &lt;span class="nv"&gt;runs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;p&gt;The headline is &lt;code&gt;generation&lt;/code&gt;, the decode rate. Under the hood Ollama hands back its timings in nanoseconds, and the math is just &lt;code&gt;eval_count / (eval_duration / 1e9)&lt;/code&gt;. The reason that's the honest number and not, say, &lt;code&gt;total&lt;/code&gt; is subtle and important: &lt;code&gt;eval_duration&lt;/code&gt; &lt;em&gt;excludes&lt;/em&gt; model load and prompt ingestion. So it's the pure speed of the model writing tokens, and it holds steady whether the model was cold or already warm in VRAM.&lt;/p&gt;

&lt;p&gt;&lt;br&gt;
The first run pays to haul the weights into VRAM (that &lt;code&gt;load&lt;/code&gt; line). The second run doesn't. If you quote "total time" you're really benchmarking your disk and your luck. Decode rate is the speed of the engine itself, the thing that's actually true about the model on your card. That's the figure worth screenshotting.&lt;br&gt;
&lt;/p&gt;

&lt;p&gt;One more bit of honesty baked into the output: &lt;code&gt;prompt eval&lt;/code&gt; is gloriously noisy on a short prompt (3507 tok/s above, but it bounces between runs), because you're dividing a tiny token count by a tinier duration. So &lt;code&gt;bench&lt;/code&gt; reports it, but quietly. The number it puts in green, the one it wants you to believe, is generation. A benchmark that oversells itself is just a vibe with extra steps.&lt;/p&gt;

&lt;p&gt;I wrote it the slow way on purpose: the core tokens/sec math by hand, with a unit test seeded from a real run off my RTX 3060, so I could actually explain every line of it instead of cargo-culting a one-liner. Seven assertions, all green. Shellcheck clean. Ran it live against three models. Pushed the PR.&lt;/p&gt;

&lt;p&gt;And then CI turned red.&lt;/p&gt;

&lt;h2&gt;
  
  
  The red light
&lt;/h2&gt;

&lt;p&gt;Two smoke jobs run on every PR: a slim &lt;strong&gt;codespaces&lt;/strong&gt; stack and the full &lt;strong&gt;full-stack&lt;/strong&gt; one. Full-stack went green. Codespaces failed, and not in my test. It failed &lt;em&gt;bringing the container up&lt;/em&gt;, before my code ever ran:&lt;/p&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;Running the postCreateCommand from devcontainer.json...
Error response from daemon: container 46a4… is not running
postCreateCommand from devcontainer.json failed with &lt;span class="nb"&gt;exit &lt;/span&gt;code 1.
&lt;span class="c"&gt;##[error]Process completed with exit code 1.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;p&gt;This is the moment that decides whether you're a good engineer or a fast one. The tempting move is: &lt;em&gt;it's my PR, it's the only thing that changed&lt;/em&gt; Poke at the test, re-run it, add a &lt;code&gt;sleep&lt;/code&gt;, wrap something in a &lt;code&gt;|| true&lt;/code&gt; and move on. But if you don't get to the root of the issue it will most likely just surface again.&lt;/p&gt;

&lt;p&gt;&lt;br&gt;
No fix without a root cause first. A red test you "fixed" by re-running is just a bug you've agreed to meet again later, usually in front of a stranger.&lt;br&gt;
&lt;/p&gt;

&lt;p&gt;So I did the boring thing instead and actually looked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Following the evidence, not the vibe
&lt;/h2&gt;

&lt;p&gt;The accusation was "your PR broke the build." The evidence disagreed, layer by layer.&lt;/p&gt;

&lt;p&gt;What it looked like -- My PR broke CI&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;-Only my branch changed
-Red appeared right after I pushed
-It's the new feature, obviously
-Just re-run it / patch the test
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;vs&lt;/p&gt;

&lt;p&gt;What the evidence said -- My PR was a bystander&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;-My diff touched zero boot-path files
-Full-stack booted the SAME image fine
-The failure was in container startup, before my code ran
-main had failed this exact way before, intermittently
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Three facts did the work. First, my diff touched a CLI command, a library function, a test, and some docs. Nothing in the container's startup path. Second, the &lt;em&gt;full-stack&lt;/em&gt; job built the very same workspace image and came up clean; if my scripts could kill a boot, both jobs would die, not one. Third, the failure happened during &lt;code&gt;up&lt;/code&gt;, before the step that runs my new code ever executed.&lt;/p&gt;

&lt;p&gt;That's not a guilty feature. That's a flaky boot that happened to be standing next to me when the cops showed up.&lt;/p&gt;

&lt;p&gt;The good news is I'd wired the CI to dump the dying container's logs on failure, and the container's last words were the whole case:&lt;/p&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;workspace-1 | &lt;span class="nb"&gt;chown&lt;/span&gt;: cannot access &lt;span class="s1"&gt;'/home/haven/.zcompdump-46a4482c870c-5.9.lock'&lt;/span&gt;:
              No such file or directory
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;p&gt;There it is. &lt;code&gt;46a4482c870c&lt;/code&gt; is the container's own ID. &lt;code&gt;.zcompdump-…-5.9.lock&lt;/code&gt; is a zsh completion lock file, created and deleted in milliseconds while the shell builds its completion cache. And the thing that tripped over it was my entrypoint's first-boot ownership sweep: a recursive &lt;code&gt;chown -R&lt;/code&gt; over the home directory.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug: a chown that raced the tide
&lt;/h2&gt;

&lt;p&gt;Here's the entire bug, and it's a beauty because there's almost nothing to it:&lt;/p&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt;
&lt;span class="c"&gt;# ...&lt;/span&gt;
&lt;span class="nb"&gt;chown&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;HAVEN_USER&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;HAVEN_USER&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;HOME_DIR&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;p&gt;&lt;code&gt;chown -R&lt;/code&gt; walks the tree, builds a list of things to change, then changes them. If a transient file (say, a zsh completion lock) exists when the walk lists it but is &lt;em&gt;gone&lt;/em&gt; by the time &lt;code&gt;chown&lt;/code&gt; reaches it, &lt;code&gt;chown&lt;/code&gt; exits non-zero. And &lt;code&gt;set -e&lt;/code&gt; says: any command that fails, abort the script. So the entrypoint dies. So the container exits. So forty seconds later, when the devcontainer tries to run its setup step, the daemon shrugs and says &lt;em&gt;that container is not running.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;It's a time-of-check versus time-of-use race, and like all races it only loses sometimes, which is exactly why &lt;code&gt;main&lt;/code&gt; was usually green and only occasionally, mysteriously, wasn't. My PR didn't cause it. My PR just rolled the dice enough times to hit it.&lt;/p&gt;

&lt;p&gt;The fix is the same guard the &lt;em&gt;other&lt;/em&gt; recursive chowns in that file already had. I'd just missed these two:&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;- chown -R "${HAVEN_USER}:${HAVEN_USER}" "${HOME_DIR}"
&lt;/span&gt;&lt;span class="gi"&gt;+ chown -R "${HAVEN_USER}:${HAVEN_USER}" "${HOME_DIR}" 2&amp;gt;/dev/null || true
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;p&gt;&lt;code&gt;|| true&lt;/code&gt; tells &lt;code&gt;set -e&lt;/code&gt; to let this one slide, and a vanished lock file stops being a death sentence for the whole container. I proved the mechanism in isolation first (a deliberately failing &lt;code&gt;chown -R&lt;/code&gt; under &lt;code&gt;set -e&lt;/code&gt; halts the script; the guarded version sails right past it), then shipped it as its own small PR, separate from the benchmark, so the history reads honestly: here's a feature, and here's an unrelated bug the feature flushed out.&lt;/p&gt;

&lt;p&gt;&lt;br&gt;
The instinct that &lt;code&gt;|| true&lt;/code&gt; is "hiding errors" is usually correct. But here the error &lt;em&gt;is&lt;/em&gt; the bug. A recursive chown racing a temp file is a known, benign class of failure, and the surrounding chowns in this same script already tolerate it. The band-aid would've been re-running CI until it was green. Tolerating a known race at the exact line it occurs is the actual fix.&lt;br&gt;
&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually want you to take from this
&lt;/h2&gt;

&lt;p&gt;The benchmark is nice. Go run &lt;code&gt;haven bench&lt;/code&gt; on your own card and post the number. Honest decode rates are good for everyone, and the more of them in the wild the less anyone has to guess.&lt;/p&gt;

&lt;p&gt;But the part I think is worth more than the feature is the shape of the hunt:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A green unit test is not a green build.&lt;/strong&gt; My math was perfect. The bug was three layers away from my math, in startup code I didn't touch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make your failures talk.&lt;/strong&gt; That "dump logs on failure" step cost me five minutes to write months ago and handed me the entire diagnosis in one line. Future-you is a stranger; leave them evidence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resist the re-run.&lt;/strong&gt; The single most expensive habit in software is treating a flaky test as noise. Flaky almost always means &lt;em&gt;real bug, intermittent trigger.&lt;/em&gt; This one had been quietly failing for weeks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fix the root, label it honestly.&lt;/strong&gt; The race got its own PR with its own explanation. Nobody reading the history six months from now has to wonder why a chown grew a &lt;code&gt;|| true&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's the whole reason I'm building this in the open. Not because the wins make good screenshots, but because the &lt;em&gt;misses&lt;/em&gt; are where the actual craft lives, and most of the industry hides them.&lt;/p&gt;

&lt;p&gt;Both PRs are merged. The light's back to steady.&lt;/p&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;git clone https://github.com/InferHaven/inferhaven-core
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;inferhaven-core
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;cp&lt;/span&gt; .env.example .env
&lt;span class="nv"&gt;$ &lt;/span&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;ssh haven@localhost
&lt;span class="nv"&gt;$ &lt;/span&gt;haven bench    &lt;span class="c"&gt;# tell me what your card does&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;p&gt;Float your boat up to the dock, &lt;a href="https://github.com/InferHaven/inferhaven-core" rel="noopener noreferrer"&gt;clone the repo&lt;/a&gt;, run the benchmark, and if you want the managed version when it's ready, the waitlist on the homepage is the way in. The lighthouse is on. And now it doesn't flicker.&lt;/p&gt;

&lt;p&gt;— Ethan L.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>testing</category>
      <category>bash</category>
      <category>buildinpublic</category>
    </item>
  </channel>
</rss>
