<?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: Asuran</title>
    <description>The latest articles on DEV Community by Asuran (@zkasuran).</description>
    <link>https://dev.to/zkasuran</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%2F4072471%2F23ca2796-c59f-4181-abb0-4dec9252b824.png</url>
      <title>DEV Community: Asuran</title>
      <link>https://dev.to/zkasuran</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/zkasuran"/>
    <language>en</language>
    <item>
      <title>A test said the server started. I deleted the server. It still passed.</title>
      <dc:creator>Asuran</dc:creator>
      <pubDate>Fri, 28 Aug 2026 03:18:57 +0000</pubDate>
      <link>https://dev.to/zkasuran/a-test-said-the-server-started-i-deleted-the-server-it-still-passed-5gch</link>
      <guid>https://dev.to/zkasuran/a-test-said-the-server-started-i-deleted-the-server-it-still-passed-5gch</guid>
      <description>&lt;p&gt;Here is a test from a real, well run Node project:&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="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;server starts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;build&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;server started&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It reads fine in review. It runs green. Now delete the body of &lt;code&gt;build()&lt;/code&gt; so the server never comes up. The test is still green, because the only thing it asserts is &lt;code&gt;true&lt;/code&gt;. In the same file two more of these caught the error in a &lt;code&gt;catch&lt;/code&gt; and asserted &lt;code&gt;true&lt;/code&gt; there too, so even the failure path was green.&lt;/p&gt;

&lt;p&gt;That is not a made up example. I found it in fastify at a pinned commit and opened a PR to fix it. More on that at the end.&lt;/p&gt;

&lt;h2&gt;
  
  
  A whole class of tests cannot fail
&lt;/h2&gt;

&lt;p&gt;Once you start looking, the pattern turns up in a few shapes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A literal: &lt;code&gt;assert.ok(true)&lt;/code&gt;, &lt;code&gt;expect(1).toBe(1)&lt;/code&gt;, a snapshot of a constant.&lt;/li&gt;
&lt;li&gt;An assertion parked in a &lt;code&gt;catch&lt;/code&gt; the happy path never reaches, so nothing is checked when the code works and nothing is checked when it breaks.&lt;/li&gt;
&lt;li&gt;A status list that accepts both outcomes: &lt;code&gt;assert.ok([200, 500].includes(res.status))&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each one runs, counts toward coverage and guards nothing. Coverage is the trap. The line executed, so the tool that counts executed lines is happy. Whether the line would go red on a regression is a different question. It is the one that matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why review misses it
&lt;/h2&gt;

&lt;p&gt;A reviewer reading the diff sees a test called &lt;code&gt;server starts&lt;/code&gt;, an &lt;code&gt;await listen&lt;/code&gt; and a green tick. The name states intent. The assertion is what actually runs, yet &lt;code&gt;ok(true)&lt;/code&gt; does not look like a problem until you stop and ask what would ever turn this test red. A missing check does not show up in a diff the way a wrong line does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding them
&lt;/h2&gt;

&lt;p&gt;I wrote a small scanner for this. No account, no config file, no network call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx margyn-scan /path/to/repo
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One of its checks is &lt;code&gt;cannot-fail&lt;/code&gt;: tests whose assertions hold whatever the code does. It also flags tests that assert nothing at all, files the build reads that git never committed, gates declared in &lt;code&gt;package.json&lt;/code&gt; that no workflow invokes and linter exclusions that quietly drop tracked source. Every finding prints the command that reproduces it, so you confirm each one yourself instead of trusting a report.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fastify case
&lt;/h2&gt;

&lt;p&gt;The two tests above asserted &lt;code&gt;ok(true)&lt;/code&gt; right after starting a server. I opened &lt;a href="https://github.com/fastify/fastify/pull/6962" rel="noopener noreferrer"&gt;fastify/fastify#6962&lt;/a&gt; to assert the server is actually listening instead. A maintainer reviewed it and suggested tightening it further to &lt;code&gt;t.assert.ok(server.listening)&lt;/code&gt; with &lt;code&gt;t.after&lt;/code&gt; for teardown. Both are in and the PR is open for review. The point is not that fastify is careless. It is one of the most carefully tested projects in the ecosystem, which is exactly why it matters that this still slips through.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;A green suite tells you the tests ran. It does not tell you they would go red if the code they cover regressed. Those are two different claims. Teams merge on the first while believing the second. The gap is worth auditing, by hand or with something like &lt;a href="https://margyn.xyz" rel="noopener noreferrer"&gt;margyn&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>testing</category>
      <category>javascript</category>
      <category>node</category>
      <category>devops</category>
    </item>
    <item>
      <title>I built a scanner that audits your test suite instead of your code</title>
      <dc:creator>Asuran</dc:creator>
      <pubDate>Tue, 18 Aug 2026 01:14:39 +0000</pubDate>
      <link>https://dev.to/zkasuran/i-built-a-scanner-that-audits-your-test-suite-instead-of-your-code-5c1g</link>
      <guid>https://dev.to/zkasuran/i-built-a-scanner-that-audits-your-test-suite-instead-of-your-code-5c1g</guid>
      <description>&lt;p&gt;A passing test suite is supposed to mean something. Here are three ways it lies. All three stay green forever.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A test that asserts nothing.&lt;/strong&gt; It calls the function, nothing throws, the runner prints a tick. It counts toward coverage and guards nothing. fastify at commit &lt;code&gt;6e95cb9&lt;/code&gt; has seven of them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An assertion that cannot fail.&lt;/strong&gt; A literal answered inside a catch, a swallowed assertion, a status list that accepts both the success and the failure. The test reads as rigorous and holds whatever the code does. fastify has two.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A gate nobody invokes.&lt;/strong&gt; A &lt;code&gt;test-cov&lt;/code&gt; or &lt;code&gt;verify&lt;/code&gt; script sits in package.json, reads as coverage to every reviewer, then no workflow ever calls it. express declares three.&lt;/p&gt;

&lt;p&gt;None of that is visible to a code reviewer, to a coverage percentage or to an AI reading the diff, because the absence of a check does not appear in a diff.&lt;/p&gt;

&lt;h2&gt;
  
  
  So the tool reads the checks rather than the code
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx margyn-scan /path/to/repo
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No account, no config file, zero dependencies, nothing uploaded. It answers in about a second. Every finding prints one command that reproduces it on your machine.&lt;/p&gt;

&lt;p&gt;Six checks: &lt;code&gt;no-assertion&lt;/code&gt;, &lt;code&gt;cannot-fail&lt;/code&gt;, &lt;code&gt;ignored-source&lt;/code&gt; (a file the build reads that git never committed, so a clean clone cannot build), &lt;code&gt;unrun-check&lt;/code&gt;, &lt;code&gt;lint-blindspot&lt;/code&gt; (linter exclusions that come from the ignore file rather than the linter's own config) and a mutation proof.&lt;/p&gt;

&lt;p&gt;Measured at pinned commits, nothing tuned for the demo and nothing left out because the number was inconvenient:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Repository&lt;/th&gt;
&lt;th&gt;Commit&lt;/th&gt;
&lt;th&gt;Findings&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;chalk/chalk&lt;/td&gt;
&lt;td&gt;&lt;code&gt;661317e&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;sindresorhus/execa&lt;/td&gt;
&lt;td&gt;&lt;code&gt;8017b27&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;sindresorhus/got&lt;/td&gt;
&lt;td&gt;&lt;code&gt;e3924aa&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1 unrun gate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;expressjs/express&lt;/td&gt;
&lt;td&gt;&lt;code&gt;a371447&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;3 unrun gates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;fastify/fastify&lt;/td&gt;
&lt;td&gt;&lt;code&gt;6e95cb9&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;13 (7 assert nothing, 2 cannot fail, 4 unrun)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Proof mode, because a scanner should not be believed either
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx margyn-scan &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;--prove&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It runs each finding's own command, reads the output for the markers that finding predicted, then certifies it REPRODUCED. Anything it cannot reproduce is RETRACTED and dropped, so a CI gate never fails a build on a claim the tool could not show on your own tree. On fastify today: 13 findings, 13 reproduced.&lt;/p&gt;

&lt;p&gt;That mode exists because the tool was badly wrong once. An earlier version of &lt;code&gt;ignored-source&lt;/code&gt; produced 22 false positives across four real repositories. Proof mode certified every one of them, because the check and its proof had inherited the same wrong premise. &lt;a href="https://dev.to/zkasuran/my-scanner-was-wrong-22-times-and-its-own-proof-certified-every-one-2m5n"&gt;I wrote that up separately&lt;/a&gt;, fixture included, since it is the more interesting half of building this.&lt;/p&gt;

&lt;h2&gt;
  
  
  It runs in CI and posts on the pull request
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;zkasuran/margyn@v0&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
    &lt;span class="na"&gt;comment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;sarif&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It comments on the request, edits that same comment in place on every push, writes the report to the job summary and uploads the findings to code scanning, all with your own &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; and no service of mine in the path.&lt;/p&gt;

&lt;p&gt;There is a live run to click rather than a screenshot to trust: &lt;a href="https://github.com/zkasuran/margyn/pull/1" rel="noopener noreferrer"&gt;pull request 1&lt;/a&gt; plants two faults on purpose, a test that calls the scanner and asserts nothing plus a script no workflow invokes. The suite still reads 109 tests passing. The comment lists both findings with a reproduction each, both appear in the Security tab, then the job exits 1, which is the gate working.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does to itself
&lt;/h2&gt;

&lt;p&gt;The mutation proof inverts a line, runs your suite, then reports the suite that stayed green anyway. Over this repository: 40 files tracked as source, 27 carry a mutation it knows how to make, 27 mutated, 27 caught, no survivors, across 108 tests.&lt;/p&gt;

&lt;p&gt;Two honest numbers to go with that. It takes 208 seconds, because every mutation runs the whole suite once, which is why the default cap is four rather than every file. And zero survivors is a claim about this suite against seven operators, not a claim that the code is correct.&lt;/p&gt;

&lt;p&gt;The first time it ran on itself it found eight survivors, one of them inside the mutation checker. The one that mattered most was in the worker: inverted, the entitlement branch handed a licence to every customer who had not paid. Nothing was watching that line.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it will not do
&lt;/h2&gt;

&lt;p&gt;It does not test your product, it audits the suite you already have. It does not upload your code, because there is no endpoint that accepts it. It will not tell you your code is correct. It tells you which of your existing checks cannot fail, which is a smaller claim and a checkable one.&lt;/p&gt;

&lt;p&gt;The five static checks are free forever. The mutation proof is the paid one, because it runs your suite N times and that is the part with a real cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it on something you own
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx margyn-scan &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;MIT, no telemetry, no account: &lt;a href="https://margyn.xyz" rel="noopener noreferrer"&gt;margyn.xyz&lt;/a&gt; and &lt;a href="https://github.com/zkasuran/margyn" rel="noopener noreferrer"&gt;github.com/zkasuran/margyn&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The question I would genuinely like answered, because the default is still open: when a tool finds a hollow test in your suite, should it fail the build or only comment on the pull request? Margyn defaults to failing. I am not certain that is right.&lt;/p&gt;

&lt;p&gt;Built during the tiun x Microlaunch challenge on &lt;a href="https://hackwithus.dev" rel="noopener noreferrer"&gt;hackwithus.dev&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;AI assistance (Claude, Anthropic) was used while building this and while writing this post. The design, the review and every number above are mine. Each number ships with the command that re-derives it.&lt;/p&gt;

</description>
      <category>testing</category>
      <category>javascript</category>
      <category>node</category>
      <category>devops</category>
    </item>
    <item>
      <title>My scanner was wrong 22 times and its own proof certified every one</title>
      <dc:creator>Asuran</dc:creator>
      <pubDate>Tue, 18 Aug 2026 00:53:06 +0000</pubDate>
      <link>https://dev.to/zkasuran/my-scanner-was-wrong-22-times-and-its-own-proof-certified-every-one-2m5n</link>
      <guid>https://dev.to/zkasuran/my-scanner-was-wrong-22-times-and-its-own-proof-certified-every-one-2m5n</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/bugsmash"&gt;DEV's Summer Bug Smash: Smash Stories&lt;/a&gt; powered by &lt;a href="https://sentry.io/" rel="noopener noreferrer"&gt;Sentry&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I built a scanner that audits the verification layer of a repository rather than the code. One of its checks looks for a file the build reads that git never committed: the file is on your disk untracked, so every local run is green while a clean clone or a CI runner cannot read it at all. The diff looks innocent because the defect is an absence.&lt;/p&gt;

&lt;p&gt;Because a scanner that only asserts is not much better than the tests it audits, it also has a proof mode. Every finding carries a machine-checkable command. &lt;code&gt;--prove&lt;/code&gt; runs that command, reads the output for the markers the finding predicted, then certifies the finding as REPRODUCED. Anything it cannot reproduce is retracted and dropped, so a CI gate never fails a build on a claim the tool could not show on your own tree.&lt;/p&gt;

&lt;p&gt;Then I pointed it at five real repositories I had lying around. It produced 22 findings on four of them. Every single one was wrong. Here is the part that still bothers me: the proof certified all 22.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the false positive looked like
&lt;/h2&gt;

&lt;p&gt;Reduced to the shape it kept taking, on a repository anybody can build in ten seconds:&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="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; web/public/tour web/src &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; git init
&lt;span class="nb"&gt;echo &lt;/span&gt;webm-bytes &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; web/public/tour/clip.webm
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'export const clip = "/tour/clip.webm";'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; web/src/app.js
&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'node_modules\nweb/dist\n'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; .gitignore
git add &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"app plus the clip it plays, dist ignored"&lt;/span&gt;
npm run build     &lt;span class="c"&gt;# or just: mkdir -p web/dist/tour &amp;amp;&amp;amp; cp web/public/tour/clip.webm web/dist/tour/&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;clip.webm&lt;/code&gt; is committed. It sits in &lt;code&gt;web/public&lt;/code&gt;, which is exactly where a Vite or Next project keeps assets it wants copied verbatim. The build then writes a second copy into &lt;code&gt;web/dist&lt;/code&gt;, which is gitignored because a clean clone rebuilds it.&lt;/p&gt;

&lt;p&gt;My check walked the source, found &lt;code&gt;"/tour/clip.webm"&lt;/code&gt;, resolved it against the files on disk, hit &lt;code&gt;web/dist/tour/clip.webm&lt;/code&gt; first, saw that git ignores it and reported HIGH: the build reads a file that is not in the commit.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. web/dist/tour/clip.webm is read by web/src/app.js but git ignores it, so it is not in the commit
   HIGH  ignored-source  web/dist/tour/clip.webm
   ignore rule: .gitignore:2:web/dist
   why: A clean clone or a CI runner cannot read this file.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read on its own, that finding is defensible. The file it names really is untracked. Every fact in it is true. It is still wrong, because the thing the reader asked for is in the commit. A clean clone answers the request perfectly.&lt;/p&gt;

&lt;p&gt;On the real repositories it was the same fault at scale: a Vite app reporting its whole public directory, a Foundry project reporting &lt;code&gt;contracts/out&lt;/code&gt; (declared &lt;code&gt;out = "out"&lt;/code&gt; in its own &lt;code&gt;foundry.toml&lt;/code&gt;), a CLI reporting &lt;code&gt;dist/cli.mjs&lt;/code&gt; (declared &lt;code&gt;outdir: "dist"&lt;/code&gt; in its own build script). 22 findings, 4 repositories, zero real defects.&lt;/p&gt;

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

&lt;p&gt;Here is the same fixture through proof mode, on the unfixed scanner:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. web/dist/tour/clip.webm is read by web/src/app.js but git ignores it, so it is not in the commit
   HIGH  ignored-source  web/dist/tour/clip.webm  REPRODUCED
   MARGYN_ABSENT_FROM_HEAD
   MARGYN_PRESENT_ON_DISK

2 findings: 2 reproduced.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;REPRODUCED. The proof ran, both markers matched and the tool certified its own mistake with a green badge.&lt;/p&gt;

&lt;p&gt;The proof was this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git archive HEAD | &lt;span class="nb"&gt;tar&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-qx&lt;/span&gt; &lt;span class="s1"&gt;'web/dist/tour/clip.webm'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'ABSENT from HEAD: web/dist/tour/clip.webm'&lt;/span&gt;
&lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s1"&gt;'web/dist/tour/clip.webm'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'PRESENT on disk: web/dist/tour/clip.webm'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both lines are correct. &lt;code&gt;web/dist/tour/clip.webm&lt;/code&gt; genuinely is absent from HEAD and genuinely is present on disk. The proof passes because it asks the same wrong question the check asked: &lt;strong&gt;is this path committed&lt;/strong&gt;, when the question that decides the bug is &lt;strong&gt;does anything in the commit answer the path the reader asked for&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That is the lesson I paid for. A check and its proof can agree, both be internally valid and both be wrong, because they inherited the same premise. Independent verification means asking a different question, not running the assertion twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two fixes
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A reader asks for a path, not for a file on your disk.&lt;/strong&gt; The check now collects every needle a reader names, then reports only when no committed file answers it. &lt;code&gt;web/public/tour/clip.webm&lt;/code&gt; in the commit satisfies &lt;code&gt;/tour/clip.webm&lt;/code&gt;, whatever the build left in &lt;code&gt;dist&lt;/code&gt;. The proof asks that question too, so a finding that slips through retracts itself instead of failing somebody's build over a copy of a committed file.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Regenerated output is not missing source.&lt;/strong&gt; The check skips output a tool in the repository declares it writes: Foundry's &lt;code&gt;out&lt;/code&gt; from &lt;code&gt;foundry.toml&lt;/code&gt;, a Vite or Next or Cargo target from the script that runs it, an &lt;code&gt;outdir&lt;/code&gt; in a build script or a &lt;code&gt;tsconfig&lt;/code&gt;. Read from declarations and resolved to real paths, never from the directory happening to be called &lt;code&gt;dist&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That second rule matters more than it looks. One of the five repositories had vendored real source into &lt;code&gt;vendor/dist&lt;/code&gt;, a path no tool there declares. That is exactly the defect this check exists for. A crude "ignore anything named dist" would have deleted the only true positive in the set.&lt;/p&gt;

&lt;p&gt;So the fixture keeps both cases. Same tree, one import added, scanner at HEAD:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;1. vendor/dist/real.mjs is &lt;span class="nb"&gt;read &lt;/span&gt;by web/src/vendorised.js but git ignores it, so it is not &lt;span class="k"&gt;in &lt;/span&gt;the commit
   HIGH  ignored-source  vendor/dist/real.mjs
   reproduce:
     git archive HEAD | &lt;span class="nb"&gt;tar&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-qE&lt;/span&gt; &lt;span class="s1"&gt;'(^|/)dist/real\.mjs$'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'NOTHING in HEAD answers dist/real.mjs'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The build copy is gone. The vendored module is still reported. And look at the new reproduce line: it asks whether anything in HEAD answers the reference, which is the question the fix is built on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verification
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;4 regression tests in &lt;code&gt;test/checks.test.mjs&lt;/code&gt;, one per direction: quiet when the commit answers the path, still loud when nothing does, quiet on declared build output, still loud on vendored source under a path that only looks built.&lt;/li&gt;
&lt;li&gt;The fixture above, run against the pre-fix commit and against HEAD: 2 findings before, 1 after, then the one that survives is the real one.&lt;/li&gt;
&lt;li&gt;Full suite 108 tests passing. The tool's own mutation proof mutates all 27 mutable files of 40 tracked and reports no survivors.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;While I was in there, one more thing turned up that nothing was watching: the SARIF &lt;code&gt;helpUri&lt;/code&gt; for the mutation rule pointed at &lt;code&gt;/docs#mutation&lt;/code&gt; while the docs heading carried &lt;code&gt;id="mutation-check"&lt;/code&gt;, so every uploaded finding linked to nothing. There is a test now that resolves the &lt;code&gt;helpUri&lt;/code&gt; of every check the scanner can emit.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell my past self
&lt;/h2&gt;

&lt;p&gt;Write the check and the proof from different premises. Otherwise the proof is a spell-check on the check's own reasoning. Mine was, for 22 findings, with a green badge on each.&lt;/p&gt;

&lt;p&gt;Code: the fix is one commit, &lt;code&gt;efc5657&lt;/code&gt;, in &lt;a href="https://github.com/zkasuran/margyn" rel="noopener noreferrer"&gt;github.com/zkasuran/margyn&lt;/a&gt;. The scanner runs with no account and no config: &lt;code&gt;npx margyn-scan /path/to/repo&lt;/code&gt;. &lt;code&gt;--prove&lt;/code&gt; is the mode that got humbled here.&lt;/p&gt;

&lt;p&gt;AI assistance (Claude, Anthropic) was used while writing this post and while implementing the fix. The bug, the reduced fixture, the two rules and every number above were verified by me by running them: the outputs quoted are captured stdout from the pre-fix commit and from HEAD, not retyped.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>bugsmash</category>
      <category>testing</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Pup Card: what's your dog really thinking? (Gemini 3.5 Flash)</title>
      <dc:creator>Asuran</dc:creator>
      <pubDate>Fri, 14 Aug 2026 14:10:06 +0000</pubDate>
      <link>https://dev.to/zkasuran/pup-card-whats-your-dog-really-thinking-gemini-35-flash-3bhm</link>
      <guid>https://dev.to/zkasuran/pup-card-whats-your-dog-really-thinking-gemini-35-flash-3bhm</guid>
      <description>&lt;p&gt;This is a submission for &lt;a href="https://dev.to/devteam/join-our-dev-weekend-challenge-dog-days-edition-1000-in-prizes-across-five-winners-submissions-1g4i"&gt;Weekend Challenge: Dog Days Edition&lt;/a&gt;.&lt;/p&gt;

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

&lt;p&gt;Pup Card turns one dog photo into a shareable card. Drop in a photo or hit &lt;strong&gt;Surprise me&lt;/strong&gt; for a random dog. Gemini 3.5 Flash reads the picture and hands back:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the likely &lt;strong&gt;breed&lt;/strong&gt; with a confidence score, plus the mix if it is not purebred&lt;/li&gt;
&lt;li&gt;the dog's &lt;strong&gt;mood&lt;/strong&gt; and a plain read of its &lt;strong&gt;body language&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;a &lt;strong&gt;first-person caption&lt;/strong&gt;, as if the dog is talking&lt;/li&gt;
&lt;li&gt;two or three breed-appropriate &lt;strong&gt;care tips&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;a &lt;strong&gt;fun fact&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It all renders as a card you can download as a PNG and share. I wanted something that was genuinely fun to use on a Saturday and that leaned on the one thing a multimodal model is really good at: looking at an image and saying something specific and true about 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%2Fsb3m9rlz9su3c4lr6i8u.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsb3m9rlz9su3c4lr6i8u.gif" alt="Pup Card reading a random dog, start to finish" width="460" height="920"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Live demo: &lt;a href="https://pupcard.vercel.app" rel="noopener noreferrer"&gt;https://pupcard.vercel.app&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It needs no login and runs on your phone. If you do not have a dog photo on hand, the &lt;strong&gt;Surprise me&lt;/strong&gt; button pulls a random one so you can try it right now.&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%2Fxni6liyasl32dgm11qmg.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%2Fxni6liyasl32dgm11qmg.png" alt="A finished Pup Card" width="800" height="1512"&gt;&lt;/a&gt;&lt;/p&gt;

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


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/zkasuran" rel="noopener noreferrer"&gt;
        zkasuran
      &lt;/a&gt; / &lt;a href="https://github.com/zkasuran/pupcard" rel="noopener noreferrer"&gt;
        pupcard
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Snap a dog photo, get an AI Pup Card: breed, mood and what they're thinking. Powered by Gemini 3.5 Flash.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;🐶 Pup Card&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;Snap a dog photo and get back a Pup Card: the likely breed, a read on the dog's mood and
body language, a first-person line of what they are probably thinking and a few care
notes. One multimodal call to Google Gemini 3.5 Flash does the whole read.&lt;/p&gt;
&lt;p&gt;Built for the &lt;a href="https://dev.to/devteam" rel="nofollow"&gt;DEV Weekend Challenge: Dog Days&lt;/a&gt;, for the &lt;strong&gt;Best
use of Google AI&lt;/strong&gt; category.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Live demo: &lt;a href="https://pupcard.vercel.app" rel="nofollow noopener noreferrer"&gt;https://pupcard.vercel.app&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;a rel="noopener noreferrer" href="https://github.com/zkasuran/pupcard/docs/pup-card.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fzkasuran%2Fpupcard%2FHEAD%2Fdocs%2Fpup-card.png" alt="A Pup Card for a Bluetick Coonhound"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;What it does&lt;/h2&gt;
&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;Upload a dog photo (or hit &lt;strong&gt;Surprise me&lt;/strong&gt; to pull a random dog from &lt;a href="https://dog.ceo" rel="nofollow noopener noreferrer"&gt;dog.ceo&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;Gemini 3.5 Flash looks at the image and returns a structured read: breed guess with a
confidence score, likely mix, mood, body language, a playful first-person caption, 2-3
care tips and a fun fact.&lt;/li&gt;
&lt;li&gt;The result renders as a card you can download as a PNG and share.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;No dog photo on hand? The &lt;strong&gt;Surprise me&lt;/strong&gt; button means anyone can…&lt;/p&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/zkasuran/pupcard" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;The whole read is a single &lt;code&gt;generateContent&lt;/code&gt; call to &lt;code&gt;gemini-3.5-flash&lt;/code&gt;. The photo goes in as inline image data next to a prompt that asks for JSON in a fixed shape. A &lt;a href="https://zod.dev" rel="noopener noreferrer"&gt;zod&lt;/a&gt; schema validates the response so the front end always gets the same fields. If the model returns malformed JSON the server retries once with the parse error fed back. If the primary model is overloaded or out of quota it falls back to &lt;code&gt;gemini-2.5-flash&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The stack is deliberately small:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Gemini 3.5 Flash&lt;/strong&gt; through &lt;code&gt;@google/genai&lt;/code&gt; for the read&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TypeScript&lt;/strong&gt; everywhere, with &lt;strong&gt;Hono&lt;/strong&gt; for the local server&lt;/li&gt;
&lt;li&gt;plain &lt;strong&gt;Node serverless functions&lt;/strong&gt; on &lt;strong&gt;Vercel&lt;/strong&gt; for &lt;code&gt;/api/analyze&lt;/code&gt; and &lt;code&gt;/api/random-dog&lt;/code&gt;, so there is no long-lived server to run&lt;/li&gt;
&lt;li&gt;a &lt;strong&gt;vanilla&lt;/strong&gt; HTML, CSS and JS front end with a &lt;code&gt;&amp;lt;canvas&amp;gt;&lt;/code&gt; export for the downloadable card, so there is no build step and nothing shipped to the browser but three static files&lt;/li&gt;
&lt;li&gt;the free &lt;strong&gt;dog.ceo&lt;/strong&gt; API for the random dog&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two details I liked. Photos are downscaled in the browser before upload, which keeps the request tiny and the read fast. And the caption is where 3.5 Flash shows off: it picks up on the actual photo, the odd-colored eyes, the golden-hour light, the paw draped over a toy, instead of writing something generic.&lt;/p&gt;

&lt;p&gt;The Gemini key stays on the server. The public endpoint has no login, so it is guarded by a size cap and a simple per-IP rate limit instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Best Use of Google AI.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Gemini is not a feature bolted onto Pup Card, it is the product. One multimodal Gemini 3.5 Flash call does the breed identification, the mood and body-language read, the writing and the care tips, all returned as structured JSON that the card renders directly. There is no separate model, no rules engine and no template behind it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built over the challenge weekend. I used an AI coding assistant to help write it, then designed the prompt, reviewed the code and tested the whole thing end to end against real dog photos with live Gemini calls before shipping.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>weekendchallenge</category>
    </item>
    <item>
      <title>The reasoning tokens Sentry's Vercel AI traces dropped for Gemini</title>
      <dc:creator>Asuran</dc:creator>
      <pubDate>Fri, 14 Aug 2026 10:06:17 +0000</pubDate>
      <link>https://dev.to/zkasuran/the-reasoning-tokens-sentrys-vercel-ai-traces-dropped-for-gemini-3ib2</link>
      <guid>https://dev.to/zkasuran/the-reasoning-tokens-sentrys-vercel-ai-traces-dropped-for-gemini-3ib2</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/bugsmash"&gt;DEV's Summer Bug Smash: Clear the Lineup&lt;/a&gt; powered by &lt;a href="https://sentry.io/" rel="noopener noreferrer"&gt;Sentry&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug
&lt;/h2&gt;

&lt;p&gt;Sentry's JavaScript SDK auto-instruments the Vercel AI SDK, so every &lt;code&gt;generateText&lt;/code&gt; or &lt;code&gt;streamText&lt;/code&gt; call shows up as a &lt;code&gt;gen_ai&lt;/code&gt; span with token usage attached. When the model is a Gemini reasoning model, that usage was wrong: the span undercounted the output and the total because it silently dropped Gemini's reasoning tokens.&lt;/p&gt;

&lt;p&gt;Gemini reports its reasoning ("thoughts") tokens separately from the visible answer. The AI SDK's &lt;code&gt;outputTokens&lt;/code&gt; covers only the candidate answer and exposes the reasoning count through &lt;code&gt;providerMetadata.google.usageMetadata.thoughtsTokenCount&lt;/code&gt;. Sentry's &lt;code&gt;getProviderMetadataAttributes()&lt;/code&gt; handled OpenAI, Anthropic, Bedrock and DeepSeek metadata, but it never looked at the Google or Vertex block, so the reasoning tokens vanished from the span and the total was computed as input plus candidate-only output.&lt;/p&gt;

&lt;p&gt;On a real Gemini call this is not a rounding error. The model returned &lt;code&gt;usageMetadata { promptTokenCount: 14, candidatesTokenCount: 1, thoughtsTokenCount: 100, totalTokenCount: 115 }&lt;/code&gt;. Sentry recorded &lt;code&gt;output_tokens: 1&lt;/code&gt; and &lt;code&gt;total_tokens: 15&lt;/code&gt;, hiding 100 reasoning tokens. For a reasoning model the reasoning tokens are most of the cost, so the trace understated real usage by nearly 8x.&lt;/p&gt;

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

&lt;p&gt;The fix adds a Google and Vertex branch to &lt;code&gt;getProviderMetadataAttributes()&lt;/code&gt; in &lt;code&gt;packages/server-utils/src/ai/vercel-ai/index.ts&lt;/code&gt;. It reads &lt;code&gt;providerMetadata.{google|vertex}.usageMetadata&lt;/code&gt; and, when &lt;code&gt;thoughtsTokenCount&lt;/code&gt; is greater than zero, sets the output to &lt;code&gt;candidatesTokenCount + thoughtsTokenCount&lt;/code&gt;, sets the total from the real &lt;code&gt;totalTokenCount&lt;/code&gt; and records the reasoning breakdown under &lt;code&gt;gen_ai.usage.reasoning.output_tokens&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Deriving the output from the raw candidate and thoughts counts, rather than adding reasoning onto the SDK's existing value, is deliberate: it stays correct even if a future AI SDK version folds reasoning into &lt;code&gt;outputTokens&lt;/code&gt; itself, so it cannot double count. The branch is gated on &lt;code&gt;thoughtsTokenCount &amp;gt; 0&lt;/code&gt;, so a non-reasoning Gemini response is left exactly as it was. Both the OpenTelemetry span path and the &lt;code&gt;ai&lt;/code&gt; tracing-channel path go through this one helper, so both emit the corrected shape.&lt;/p&gt;

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

&lt;p&gt;I added a test file with four cases. A Gemini reasoning response now records &lt;code&gt;output_tokens: 101&lt;/code&gt;, &lt;code&gt;total_tokens: 115&lt;/code&gt; and &lt;code&gt;gen_ai.usage.reasoning.output_tokens: 100&lt;/code&gt;. The &lt;code&gt;vertex&lt;/code&gt; metadata variant is handled the same way. A non-reasoning response is unchanged. Run against the current source the three reasoning cases fail (&lt;code&gt;expected 1 to be 101&lt;/code&gt;) and the non-reasoning case passes, which pins the bug. With the fix all four pass.&lt;/p&gt;

&lt;p&gt;Green on the repo's own gates: the full &lt;code&gt;@sentry/server-utils&lt;/code&gt; vitest suite (377 passing across 40 files, my four added), &lt;code&gt;oxlint --type-aware&lt;/code&gt;, &lt;code&gt;oxfmt --check&lt;/code&gt; and &lt;code&gt;tsc&lt;/code&gt; on the source types all clean. I confirmed the fix against the AI SDK's own source: &lt;code&gt;convert-google-usage.ts&lt;/code&gt; maps output to candidates plus thoughts and reasoning to thoughts, which is exactly the accounting the fix uses.&lt;/p&gt;

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

&lt;p&gt;This is a fix to Sentry's own JavaScript SDK, in the Vercel AI instrumentation that feeds the AI monitoring product. Token usage on a &lt;code&gt;gen_ai&lt;/code&gt; span is what teams read to track model cost and behavior. For a reasoning model the reasoning tokens are the bulk of the spend, so an &lt;code&gt;output_tokens&lt;/code&gt; that omits them makes the trace understate cost in the direction that hurts most. After the fix the span carries the real output and total, so Sentry's usage numbers match what Gemini actually counted.&lt;/p&gt;

&lt;h2&gt;
  
  
  Best Use of Google AI
&lt;/h2&gt;

&lt;p&gt;I verified this end to end with a real Google Gemini reasoning call, because the whole bug only appears when a model emits &lt;code&gt;thoughtsTokenCount&lt;/code&gt;. A real &lt;code&gt;gemini-3.6-flash&lt;/code&gt; request returned &lt;code&gt;{ promptTokenCount: 14, candidatesTokenCount: 1, thoughtsTokenCount: 100, totalTokenCount: 115 }&lt;/code&gt;. Fed through the actual Sentry Vercel AI processor, the emitted span attributes were &lt;code&gt;output_tokens: 1, total_tokens: 15&lt;/code&gt; before the fix and &lt;code&gt;output_tokens: 101, total_tokens: 115, reasoning.output_tokens: 100&lt;/code&gt; after. Google Gemini produced the reasoning tokens. Sentry is where they reappear once the SDK reads the Google usage metadata correctly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;PR:&lt;/strong&gt; &lt;a href="https://github.com/getsentry/sentry-javascript/pull/23433" rel="noopener noreferrer"&gt;getsentry/sentry-javascript#23433&lt;/a&gt;, from branch &lt;code&gt;fix/vercel-ai-gemini-reasoning-tokens&lt;/code&gt;. Found by probing the integration with real Gemini traffic, not from a filed issue.&lt;/p&gt;

&lt;h2&gt;
  
  
  AI disclosure
&lt;/h2&gt;

&lt;p&gt;AI assistance (Claude, Anthropic) was used in developing this change. The design, review and verification were done by the author. Verified locally before submitting: the four new tests fail on the unpatched source and pass with the fix, the &lt;code&gt;@sentry/server-utils&lt;/code&gt; suite (377 passing), &lt;code&gt;oxlint --type-aware&lt;/code&gt;, &lt;code&gt;oxfmt --check&lt;/code&gt; and &lt;code&gt;tsc&lt;/code&gt; on the source types, plus a real Gemini reasoning run showing the span output and total tokens corrected.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>bugsmash</category>
      <category>javascript</category>
      <category>ai</category>
    </item>
    <item>
      <title>One tool call, counted twice: a Google GenAI streaming double-dip in Sentry's JS SDK</title>
      <dc:creator>Asuran</dc:creator>
      <pubDate>Fri, 14 Aug 2026 09:44:00 +0000</pubDate>
      <link>https://dev.to/zkasuran/one-tool-call-counted-twice-a-google-genai-streaming-double-dip-in-sentrys-js-sdk-4l4p</link>
      <guid>https://dev.to/zkasuran/one-tool-call-counted-twice-a-google-genai-streaming-double-dip-in-sentrys-js-sdk-4l4p</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/bugsmash"&gt;DEV's Summer Bug Smash: Clear the Lineup&lt;/a&gt; powered by &lt;a href="https://sentry.io/" rel="noopener noreferrer"&gt;Sentry&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug
&lt;/h2&gt;

&lt;p&gt;When you call &lt;code&gt;@google/genai&lt;/code&gt; in streaming mode and the model asks to run a tool, Sentry's JavaScript SDK records that tool call to the span twice. One tool call in, two entries out.&lt;/p&gt;

&lt;p&gt;The attribute that carries them is &lt;code&gt;gen_ai.response.tool_calls&lt;/code&gt;. It should hold one object per call. For a single streamed &lt;code&gt;controlLight&lt;/code&gt; call it held two. Worse, the two did not even agree on their shape. Here is a real capture, which I come back to at the end:&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="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"call_2079699"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"colorTemperature"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"warm"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"brightness"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"controlLight"&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;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"function"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"call_2079699"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"controlLight"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"arguments"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"colorTemperature"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"warm"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"brightness"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;30&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;Same id, same call, listed twice. One entry keys the parameters under &lt;code&gt;args&lt;/code&gt;, the other under &lt;code&gt;arguments&lt;/code&gt;. Anything reading this later sees two tool invocations where the model made one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Following the value
&lt;/h2&gt;

&lt;p&gt;The streaming instrumentation lives in &lt;code&gt;packages/server-utils/src/ai/google-genai/streaming.ts&lt;/code&gt;. Every chunk of the stream runs through &lt;code&gt;handleCandidateContent&lt;/code&gt;. That function wrote tool calls from two places:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleCandidateContent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;recordOutputs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;functionCalls&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;toolCalls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;functionCalls&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;          &lt;span class="c1"&gt;// push #1&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;candidate&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;candidates&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// ...finish reasons...&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;part&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;parts&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;recordOutputs&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;part&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;responseTexts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;part&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;part&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;functionCall&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;toolCalls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;                              &lt;span class="c1"&gt;// push #2&lt;/span&gt;
          &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;function&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;part&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;functionCall&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;part&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;functionCall&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;arguments&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;part&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;functionCall&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Push #1 spreads &lt;code&gt;chunk.functionCalls&lt;/code&gt; into the accumulator. Push #2 walks &lt;code&gt;candidate.content.parts&lt;/code&gt; and pushes every &lt;code&gt;functionCall&lt;/code&gt; it finds. They look like two different sources. They are not.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;chunk.functionCalls&lt;/code&gt; is a getter on the &lt;code&gt;@google/genai&lt;/code&gt; response object. Here is what it actually does inside the SDK:&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;get&lt;/span&gt; &lt;span class="nf"&gt;functionCalls&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;functionCalls&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;?.[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]?.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;parts&lt;/span&gt;
    &lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;part&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;part&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;functionCall&lt;/span&gt;&lt;span class="p"&gt;)&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;part&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;part&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;functionCall&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fc&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;fc&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;functionCalls&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 reads the exact same &lt;code&gt;candidates[].content.parts&lt;/code&gt; that push #2 iterates, filters for &lt;code&gt;functionCall&lt;/code&gt; and hands them back. So both pushes are pulling the identical tool call out of the identical parts array. The first keeps the SDK-native shape &lt;code&gt;{ id, name, args }&lt;/code&gt;. The second rebuilds it as &lt;code&gt;{ type, id, name, arguments }&lt;/code&gt;. Two views of one thing, both written down.&lt;/p&gt;

&lt;p&gt;The non-streaming path never had this problem. Its &lt;code&gt;addResponseAttributes&lt;/code&gt; reads tool calls once, straight from &lt;code&gt;response.functionCalls&lt;/code&gt;, then emits a single entry per call. So streaming and non-streaming disagreed on both the count and the key name for the very same response.&lt;/p&gt;

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

&lt;p&gt;There is a single source of truth here: the SDK accessor the non-streaming path already trusts. Take the tool calls only from &lt;code&gt;chunk.functionCalls&lt;/code&gt; and drop the second push:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleCandidateContent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;recordOutputs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// `chunk.functionCalls` is the SDK accessor over the candidate's function-call parts,&lt;/span&gt;
  &lt;span class="c1"&gt;// so it is the single source of truth for tool calls. Also reading `part.functionCall`&lt;/span&gt;
  &lt;span class="c1"&gt;// from those same parts would record every call twice, with two different shapes. This&lt;/span&gt;
  &lt;span class="c1"&gt;// mirrors the non-streaming path, which likewise takes tool calls from `response.functionCalls`.&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;functionCalls&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;toolCalls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;functionCalls&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;candidate&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;candidates&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// ...finish reasons...&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;part&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;parts&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;recordOutputs&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;part&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;responseTexts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;part&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I kept &lt;code&gt;chunk.functionCalls&lt;/code&gt; rather than the parts loop on purpose. Both code paths now share one source and emit the SDK-native shape, so streaming output matches non-streaming output byte for byte. That is also how the OpenAI and Anthropic integrations in this repo are built: their streaming paths reconstruct the exact tool-call shape their non-streaming paths produce. The deprecated &lt;code&gt;gen_ai.response.tool_calls&lt;/code&gt; example happens to show &lt;code&gt;{ name, arguments }&lt;/code&gt;, but no provider integration normalizes to that literally (Anthropic keeps &lt;code&gt;input&lt;/code&gt;, OpenAI nests under &lt;code&gt;function&lt;/code&gt;), so consistency within a provider was the property worth protecting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Proving it on real traffic
&lt;/h2&gt;

&lt;p&gt;I did not want to trust a mock for this. I made one real streaming call to &lt;code&gt;gemini-3.6-flash&lt;/code&gt; with a &lt;code&gt;controlLight&lt;/code&gt; tool, captured the actual chunks the model sent back (response id &lt;code&gt;IuF-auTiDpW2g8UPnNDKsAI&lt;/code&gt;), then replayed that identical response through the instrumentation twice: once against &lt;code&gt;develop&lt;/code&gt;, once with the fix.&lt;/p&gt;

&lt;p&gt;Before, &lt;code&gt;gen_ai.response.tool_calls&lt;/code&gt;:&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="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"call_2079699"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"colorTemperature"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"warm"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"brightness"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"controlLight"&lt;/span&gt;&lt;span class="p"&gt;},{&lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"function"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"call_2079699"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"controlLight"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"arguments"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"colorTemperature"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"warm"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"brightness"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;30&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;After:&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="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"call_2079699"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"colorTemperature"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"warm"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"brightness"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"controlLight"&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;One real call, one entry.&lt;/p&gt;

&lt;p&gt;A unit test covers the single-call case, several calls arriving across chunks and the &lt;code&gt;recordOutputs: false&lt;/code&gt; case, then checks that the non-streaming path still records one entry in the same shape. It fails on &lt;code&gt;develop&lt;/code&gt; with two entries and passes with the fix. Locally the full &lt;code&gt;@sentry/server-utils&lt;/code&gt; unit suite is green (377 passing), with &lt;code&gt;oxlint&lt;/code&gt;, &lt;code&gt;oxfmt --check&lt;/code&gt; and the TypeScript build all clean.&lt;/p&gt;

&lt;p&gt;The change is open as &lt;a href="https://github.com/getsentry/sentry-javascript/pull/23432" rel="noopener noreferrer"&gt;getsentry/sentry-javascript#23432&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;AI assistance (Claude, Anthropic) was used in developing this change. The design, review and verification were done by me. I verified it locally before submitting: the new and existing &lt;code&gt;@sentry/server-utils&lt;/code&gt; unit tests, &lt;code&gt;oxlint&lt;/code&gt;, &lt;code&gt;oxfmt --check&lt;/code&gt; and the TypeScript build all pass, plus the real gemini-3.6-flash run captured before and after.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>bugsmash</category>
      <category>javascript</category>
      <category>opensource</category>
    </item>
    <item>
      <title>The hidden svg Biome flagged anyway: a noSvgWithoutTitle false positive</title>
      <dc:creator>Asuran</dc:creator>
      <pubDate>Fri, 14 Aug 2026 08:13:58 +0000</pubDate>
      <link>https://dev.to/zkasuran/the-hidden-svg-biome-flagged-anyway-a-nosvgwithouttitle-false-positive-51jh</link>
      <guid>https://dev.to/zkasuran/the-hidden-svg-biome-flagged-anyway-a-nosvgwithouttitle-false-positive-51jh</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/bugsmash"&gt;DEV's Summer Bug Smash: Clear the Lineup&lt;/a&gt; powered by &lt;a href="https://sentry.io/" rel="noopener noreferrer"&gt;Sentry&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug
&lt;/h2&gt;

&lt;p&gt;A Biome user filed &lt;a href="https://github.com/biomejs/biome/issues/11317" rel="noopener noreferrer"&gt;issue #11317&lt;/a&gt;: the &lt;code&gt;noSvgWithoutTitle&lt;/code&gt; lint rule flagged &lt;code&gt;&amp;lt;svg aria-hidden&amp;gt;&amp;lt;rect /&amp;gt;&amp;lt;/svg&amp;gt;&lt;/code&gt;, even though that svg is hidden from the accessibility tree and does not need a title. Write it as &lt;code&gt;&amp;lt;svg aria-hidden="true"&amp;gt;&lt;/code&gt; or &lt;code&gt;&amp;lt;svg aria-hidden={true}&amp;gt;&lt;/code&gt; and the rule stays quiet. Write it with the JSX boolean shorthand &lt;code&gt;&amp;lt;svg aria-hidden&amp;gt;&lt;/code&gt; and the rule reports "title element cannot be empty". Same meaning, different result.&lt;/p&gt;

&lt;p&gt;The shorthand is not a niche style. In JSX, &lt;code&gt;aria-hidden&lt;/code&gt; with no value is exactly &lt;code&gt;aria-hidden={true}&lt;/code&gt;, the same way &lt;code&gt;disabled&lt;/code&gt; is &lt;code&gt;disabled={true}&lt;/code&gt; on an input. React users write it constantly, so the rule was punishing correct, accessible code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Following the value
&lt;/h2&gt;

&lt;p&gt;The rule lives in &lt;code&gt;crates/biome_js_analyze/src/lint/a11y/no_svg_without_title.rs&lt;/code&gt;. The guard that is supposed to let a hidden svg through looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;aria_hidden_attr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="nf"&gt;.find_attribute_by_name&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"aria-hidden"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attr_static_val&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;aria_hidden_attr&lt;/span&gt;&lt;span class="nf"&gt;.as_static_value&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;attr_text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;attr_static_val&lt;/span&gt;&lt;span class="nf"&gt;.text&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;attr_text&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;"true"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;None&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read it top down. Find the &lt;code&gt;aria-hidden&lt;/code&gt; attribute. Read its static value. If that value is the string &lt;code&gt;"true"&lt;/code&gt;, return &lt;code&gt;None&lt;/code&gt;, which means no diagnostic. Reasonable for &lt;code&gt;aria-hidden="true"&lt;/code&gt; and &lt;code&gt;aria-hidden={true}&lt;/code&gt;. The trouble is the second line, &lt;code&gt;as_static_value()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Here is what that helper does, in &lt;code&gt;crates/biome_js_syntax/src/jsx_ext.rs&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;as_static_value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;StaticValue&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="nf"&gt;.initializer&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="nf"&gt;.value&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.ok&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="nf"&gt;.as_static_value&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first thing it touches is &lt;code&gt;self.initializer()?&lt;/code&gt;. An initializer is the &lt;code&gt;= something&lt;/code&gt; part of an attribute. &lt;code&gt;aria-hidden="true"&lt;/code&gt; has one. &lt;code&gt;aria-hidden={true}&lt;/code&gt; has one. The bare shorthand &lt;code&gt;aria-hidden&lt;/code&gt; has none. For the shorthand, &lt;code&gt;initializer()&lt;/code&gt; returns &lt;code&gt;None&lt;/code&gt;, the &lt;code&gt;?&lt;/code&gt; short circuits and &lt;code&gt;as_static_value()&lt;/code&gt; returns &lt;code&gt;None&lt;/code&gt;. The &lt;code&gt;&amp;amp;&amp;amp; let Some(..)&lt;/code&gt; in the guard fails, the whole block is skipped and the svg falls through to the title checks and gets flagged.&lt;/p&gt;

&lt;p&gt;So the rule never actually decided the shorthand was not hidden. It just never got far enough to ask. The value was gone one line before the comparison that mattered.&lt;/p&gt;

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

&lt;p&gt;The shorthand carries its truth in its presence, not in a value. The moment we know the attribute exists but has no initializer, we can treat it as &lt;code&gt;true&lt;/code&gt; and stop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;aria_hidden_attr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="nf"&gt;.find_attribute_by_name&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"aria-hidden"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// In JSX the boolean shorthand `aria-hidden` (an attribute with no&lt;/span&gt;
    &lt;span class="c1"&gt;// initializer) is equivalent to `aria-hidden={true}`, so it hides&lt;/span&gt;
    &lt;span class="c1"&gt;// the svg from the accessibility tree and no title is required.&lt;/span&gt;
    &lt;span class="n"&gt;aria_hidden_attr&lt;/span&gt;&lt;span class="nf"&gt;.initializer&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attr_static_val&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;aria_hidden_attr&lt;/span&gt;&lt;span class="nf"&gt;.as_static_value&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;attr_static_val&lt;/span&gt;&lt;span class="nf"&gt;.text&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;"true"&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;None&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;aria_hidden_attr.initializer()?&lt;/code&gt; is the whole fix. If there is no initializer we are looking at the shorthand, so &lt;code&gt;?&lt;/code&gt; returns &lt;code&gt;None&lt;/code&gt; from the rule and no diagnostic is raised. If there is an initializer, the existing &lt;code&gt;"true"&lt;/code&gt; check runs exactly as before, so &lt;code&gt;aria-hidden="true"&lt;/code&gt;, &lt;code&gt;aria-hidden={true}&lt;/code&gt; and &lt;code&gt;aria-hidden={false}&lt;/code&gt; all keep the behavior they had. Clippy insists on the &lt;code&gt;?&lt;/code&gt; form here over an explicit &lt;code&gt;if ... .is_none() { return None; }&lt;/code&gt;. Biome's CI denies warnings, so the terse version is the one that compiles clean.&lt;/p&gt;

&lt;h2&gt;
  
  
  Watch it fail, then pass
&lt;/h2&gt;

&lt;p&gt;I added two valid cases to the rule's spec at &lt;code&gt;crates/biome_js_analyze/tests/specs/a11y/noSvgWithoutTitle/valid.jsx&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;svg&lt;/span&gt; &lt;span class="na"&gt;aria-hidden&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;rect&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;svg&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;svg&lt;/span&gt; &lt;span class="na"&gt;aria-hidden&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;rect&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;svg&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With the source reverted to the old guard but these cases in place, the spec test fails on exactly the shorthand:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;× Alternative text title element cannot be empty
  &amp;gt; 43 │  &amp;lt;svg aria-hidden&amp;gt;&amp;lt;rect /&amp;gt;&amp;lt;/svg&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Line 44, &lt;code&gt;&amp;lt;svg aria-hidden={true}&amp;gt;&lt;/code&gt;, does not fail, which matches the report: the value forms always worked, only the shorthand was broken. Put the one-line fix back and both cases pass while the &lt;code&gt;invalid.jsx&lt;/code&gt; spec still flags every svg that genuinely has no title.&lt;/p&gt;

&lt;p&gt;The rest of the gates are green too. &lt;code&gt;cargo run -p rules_check&lt;/code&gt; passes, so the new valid example I added to the rule's documentation is checked the same way. &lt;code&gt;cargo fmt --all --check&lt;/code&gt; is clean. &lt;code&gt;cargo clippy -p biome_js_analyze --all-features --all-targets -- --deny warnings&lt;/code&gt; is clean. The snapshot regenerates with &lt;code&gt;INSTA_UPDATE=always&lt;/code&gt; and produces no changes beyond the new input lines.&lt;/p&gt;

&lt;h2&gt;
  
  
  The change
&lt;/h2&gt;

&lt;p&gt;This is a fix inside Biome itself, in the linter. Issue &lt;a href="https://github.com/biomejs/biome/issues/11317" rel="noopener noreferrer"&gt;#11317&lt;/a&gt;, pull request &lt;a href="https://github.com/biomejs/biome/pull/11334" rel="noopener noreferrer"&gt;biomejs/biome#11334&lt;/a&gt;, branch &lt;a href="https://github.com/zkasuran/biome/tree/fix/no-svg-without-title-shorthand-aria-hidden" rel="noopener noreferrer"&gt;&lt;code&gt;fix/no-svg-without-title-shorthand-aria-hidden&lt;/code&gt;&lt;/a&gt; at commit &lt;code&gt;5d92567&lt;/code&gt;. One statement of source in &lt;code&gt;no_svg_without_title.rs&lt;/code&gt;, two spec cases, a documentation example and a changeset. A dead read of a value that was never there, fixed by asking the question one line earlier.&lt;/p&gt;

&lt;h2&gt;
  
  
  AI disclosure
&lt;/h2&gt;

&lt;p&gt;AI assistance (Claude, Anthropic) was used to trace the root cause, write the fix and the test cases, then run the checks. I own the change, reviewed it and verified it locally before submitting. Verified: the rule spec test passes with the fix and fails on the shorthand case without it; &lt;code&gt;cargo run -p rules_check&lt;/code&gt;, &lt;code&gt;cargo fmt --all --check&lt;/code&gt; and &lt;code&gt;cargo clippy -p biome_js_analyze --all-features --all-targets -- --deny warnings&lt;/code&gt; are clean; the snapshot regenerates with no changes.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>bugsmash</category>
      <category>rust</category>
      <category>opensource</category>
    </item>
    <item>
      <title>The fallback that never fired: empty SDK name and version in Sentry .NET console apps</title>
      <dc:creator>Asuran</dc:creator>
      <pubDate>Thu, 13 Aug 2026 01:33:31 +0000</pubDate>
      <link>https://dev.to/zkasuran/the-fallback-that-never-fired-empty-sdk-name-and-version-in-sentry-net-console-apps-5c4c</link>
      <guid>https://dev.to/zkasuran/the-fallback-that-never-fired-empty-sdk-name-and-version-in-sentry-net-console-apps-5c4c</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/bugsmash"&gt;DEV's Summer Bug Smash: Clear the Lineup&lt;/a&gt; powered by &lt;a href="https://sentry.io/" rel="noopener noreferrer"&gt;Sentry&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug that only showed up in console apps
&lt;/h2&gt;

&lt;p&gt;A Sentry .NET user filed &lt;a href="https://github.com/getsentry/sentry-dotnet/issues/5352" rel="noopener noreferrer"&gt;issue #5352&lt;/a&gt;: running the &lt;code&gt;Sentry.Samples.Console.Basic&lt;/code&gt; sample, every structured log went out with an empty &lt;code&gt;sentry.sdk.name&lt;/code&gt; and an empty &lt;code&gt;sentry.sdk.version&lt;/code&gt;. They also suspected trace metrics had the same problem. They were right.&lt;/p&gt;

&lt;p&gt;Those two attributes are not decoration. They tell Sentry which SDK produced a log or a metric. Lose them and a whole class of apps ships telemetry that cannot be attributed back to the .NET SDK. The strange part: the exact same logging code inside an ASP.NET Core app was fine. Only console apps dropped the fields. That split is the whole story, so this post walks the trace that explains it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Following the value
&lt;/h2&gt;

&lt;p&gt;Logs and metrics both end up in one method, &lt;code&gt;SetDefaultAttributes&lt;/code&gt; in &lt;code&gt;src/Sentry/Protocol/SentryAttributes.cs&lt;/code&gt;. Before the fix it read the SDK fields straight off the object it was handed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sdk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;       &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;SetAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"sentry.sdk.name"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sdk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Version&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="n"&gt;version&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;SetAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"sentry.sdk.version"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;version&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;Nothing wrong on its face. If &lt;code&gt;Name&lt;/code&gt; and &lt;code&gt;Version&lt;/code&gt; are set they get written. If they are null the attributes are quietly skipped. So the real question is what &lt;code&gt;sdk&lt;/code&gt; is on the console path.&lt;/p&gt;

&lt;p&gt;Two call sites feed this method:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/Sentry/SentryLog.cs:153&lt;/span&gt;
&lt;span class="n"&gt;sdk&lt;/span&gt; &lt;span class="p"&gt;??=&lt;/span&gt; &lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="n"&gt;Sdk&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="n"&gt;SdkVersion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Instance&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// src/Sentry/SentryMetric.Factory.cs:23&lt;/span&gt;
&lt;span class="n"&gt;metric&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Attributes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SetDefaultAttributes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="n"&gt;Sdk&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="n"&gt;SdkVersion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Instance&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both look defensive. Both say "use the scope's Sdk, otherwise fall back to &lt;code&gt;SdkVersion.Instance&lt;/code&gt;". &lt;code&gt;SdkVersion.Instance&lt;/code&gt; is the populated object, with &lt;code&gt;Name = "sentry.dotnet"&lt;/code&gt; and the assembly version. So why does the fallback never help?&lt;/p&gt;

&lt;p&gt;Here is the line that decides it, in &lt;code&gt;src/Sentry/Scope.cs:277&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;SdkVersion&lt;/span&gt; &lt;span class="n"&gt;Sdk&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// Name = null, Version = null&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Scope.Sdk&lt;/code&gt; is auto-initialized to &lt;code&gt;new SdkVersion()&lt;/code&gt;. That object exists, its &lt;code&gt;Name&lt;/code&gt; and &lt;code&gt;Version&lt;/code&gt; are just null. Because the property is never null, &lt;code&gt;scope?.Sdk ?? SdkVersion.Instance&lt;/code&gt; always resolves to &lt;code&gt;scope.Sdk&lt;/code&gt; and never reaches &lt;code&gt;SdkVersion.Instance&lt;/code&gt;. The &lt;code&gt;??&lt;/code&gt; only fires when &lt;code&gt;scope&lt;/code&gt; itself is null. The fallback everyone wrote was dead code.&lt;/p&gt;

&lt;p&gt;That empty object flows into &lt;code&gt;SetDefaultAttributes&lt;/code&gt;, both guards see null, so both attributes drop out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why ASP.NET Core escaped
&lt;/h2&gt;

&lt;p&gt;The framework integrations fill the scope's Sdk. ASP.NET Core does it in its middleware (&lt;code&gt;src/Sentry.AspNetCore/SentryMiddleware.cs:257-258&lt;/code&gt;), setting &lt;code&gt;scope.Sdk.Name&lt;/code&gt; and &lt;code&gt;scope.Sdk.Version&lt;/code&gt;. Once those are non-null the guards pass and the attributes appear. The core &lt;code&gt;Enricher&lt;/code&gt; populates the Sdk on events and transactions, never on the scope and never on the logs or metrics path. So a plain console app, with no integration touching &lt;code&gt;scope.Sdk&lt;/code&gt;, leaves it empty. That is the difference between the two app types, all the way down.&lt;/p&gt;

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

&lt;p&gt;One change, at the single point both logs and metrics share. Fall back per field to the populated instance when the incoming field is null:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Fall back to the populated SDK instance when the scope's Sdk was not filled in by a framework&lt;/span&gt;
&lt;span class="c1"&gt;// integration, e.g. in console apps, so logs and metrics still carry the SDK name and version (see #5352).&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;sdk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="n"&gt;SdkVersion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Instance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;SetAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"sentry.sdk.name"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;sdk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Version&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="n"&gt;SdkVersion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Instance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Version&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="n"&gt;version&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;SetAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"sentry.sdk.version"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;version&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;A few things make this the right shape. It fixes logs and metrics in one edit because both funnel through here, so the two call sites cannot drift apart. It does not override anyone: when ASP.NET Core has set &lt;code&gt;scope.Sdk.Name&lt;/code&gt;, that value is non-null so the &lt;code&gt;??&lt;/code&gt; short circuits and the integration still wins. &lt;code&gt;SdkVersion.Instance&lt;/code&gt; is the same object the envelope header already uses, so logs and metrics now agree with the envelope instead of contradicting it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test first, watch it fail, then pass
&lt;/h2&gt;

&lt;p&gt;Two new tests reproduce the console path. The log one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Fact&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;SetDefaultAttributes_EmptyScopeSdk_UsesSdkInstance&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;SentryOptions&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;log&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;SentryLog&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Timestamp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;TraceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SentryLogLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Info&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// A console app does not populate scope.Sdk, so its Name and Version stay null.&lt;/span&gt;
    &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SetDefaultAttributes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;Scope&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

    &lt;span class="n"&gt;SdkVersion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Instance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Version&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Should&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;NotBeNullOrWhiteSpace&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Attributes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ShouldContain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"sentry.sdk.name"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"sentry.dotnet"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Attributes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ShouldContain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"sentry.sdk.version"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SdkVersion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Instance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Version&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The metric one mirrors it with &lt;code&gt;new SdkVersion()&lt;/code&gt;, which is what the metric path receives in a console app. Two existing serialization tests were pinning the buggy payload (no SDK attributes), so they are corrected to include the name and version.&lt;/p&gt;

&lt;p&gt;Revert only the source file and keep the tests: the suite reports &lt;code&gt;Failed: 4, Passed: 2529&lt;/code&gt;. The two new tests fail on the missing &lt;code&gt;sentry.sdk.name&lt;/code&gt;, plus the two corrected serialization tests. Put the one-line fix back and it is &lt;code&gt;Failed: 0, Passed: 2533, Skipped: 5, Total: 2538&lt;/code&gt; on net10.0. &lt;code&gt;dotnet format --verify-no-changes&lt;/code&gt; is clean on the changed files. The pre-existing &lt;code&gt;Protocol_Default_VerifyAttributes&lt;/code&gt; tests never caught the bug because they pre-populate the Sdk, so they never touch the empty case.&lt;/p&gt;

&lt;h2&gt;
  
  
  The change
&lt;/h2&gt;

&lt;p&gt;This is a fix inside the Sentry .NET SDK itself, not an app that uses it. Issue &lt;a href="https://github.com/getsentry/sentry-dotnet/issues/5352" rel="noopener noreferrer"&gt;#5352&lt;/a&gt;, pull request &lt;a href="https://github.com/getsentry/sentry-dotnet/pull/5483" rel="noopener noreferrer"&gt;getsentry/sentry-dotnet#5483&lt;/a&gt;, branch &lt;a href="https://github.com/zkasuran/sentry-dotnet/tree/fix/sdkversion-empty-for-console" rel="noopener noreferrer"&gt;&lt;code&gt;fix/sdkversion-empty-for-console&lt;/code&gt;&lt;/a&gt; at commit &lt;code&gt;36a0d63&lt;/code&gt;. One line of source in &lt;code&gt;SentryAttributes.cs&lt;/code&gt; plus the regression tests. A dead &lt;code&gt;??&lt;/code&gt; fallback that everyone assumed was covering the empty case, sitting one property away from the fix.&lt;/p&gt;

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

&lt;p&gt;This entry is in two Bug Smash categories. Clear the Lineup covers the fix itself. Best Use of Sentry fits because the fix lives inside Sentry's own SDK. &lt;code&gt;sentry.sdk.name&lt;/code&gt; and &lt;code&gt;sentry.sdk.version&lt;/code&gt; are the attribution layer of the telemetry pipeline. They are how the platform knows a log or a metric came from &lt;code&gt;sentry.dotnet&lt;/code&gt; and which version produced it, which is what lets Sentry group data by SDK, spot version-specific regressions and route an issue to the right maintainers. When a console app drops them, its logs and metrics arrive unattributed. The ASP.NET Core path silently disagreeing with it made the gap easy to miss. Restoring the fields at the one method both paths share means every log and every metric now carries the same SDK identity the envelope header already sends, so the data a team relies on to debug production lines up with itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  AI disclosure
&lt;/h2&gt;

&lt;p&gt;AI assistance (Claude, Anthropic) was used to trace the root cause, write the fix and the tests, then run the test suite. I own the change, reviewed it and verified it locally before submitting. Verified: the full &lt;code&gt;Sentry.Tests&lt;/code&gt; suite on net10.0 (2533 passing, 0 failing); the bug reproduced by reverting only the source file (4 failing); &lt;code&gt;dotnet format --verify-no-changes&lt;/code&gt; clean on the changed files.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>bugsmash</category>
      <category>dotnet</category>
      <category>opensource</category>
    </item>
    <item>
      <title>The .git substring that broke but pr new for GitHub Pages repos</title>
      <dc:creator>Asuran</dc:creator>
      <pubDate>Thu, 13 Aug 2026 01:32:50 +0000</pubDate>
      <link>https://dev.to/zkasuran/the-git-substring-that-broke-but-pr-new-for-github-pages-repos-4gbd</link>
      <guid>https://dev.to/zkasuran/the-git-substring-that-broke-but-pr-new-for-github-pages-repos-4gbd</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/bugsmash"&gt;DEV's Summer Bug Smash: Clear the Lineup&lt;/a&gt; powered by &lt;a href="https://sentry.io/" rel="noopener noreferrer"&gt;Sentry&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug that only bites &lt;code&gt;.github.io&lt;/code&gt; repos
&lt;/h2&gt;

&lt;p&gt;Someone opened issue &lt;a href="https://github.com/gitbutlerapp/gitbutler/issues/15302" rel="noopener noreferrer"&gt;#15302&lt;/a&gt; on GitButler with a clean report: running &lt;code&gt;but pr new&lt;/code&gt; in their repo failed with HTTP 404 and the message "Failed to list open pull requests". Their remote was a GitHub Pages repo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;git@github.com:bburns-ds/bburns-test.github.io.git
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a completely ordinary clone URL. Nothing exotic. So why would GitButler be unable to list pull requests for a real repository the user clones every day?&lt;/p&gt;

&lt;h2&gt;
  
  
  Following the 404
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;but pr new&lt;/code&gt; has to turn a remote URL into an &lt;code&gt;(owner, repo)&lt;/code&gt; pair before it can call the GitHub API. I traced the path through the crates:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;but pr new&lt;/code&gt; calls &lt;code&gt;but_forge::derive_forge_repo_info(&amp;amp;remote_url)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;list_forge_reviews&lt;/code&gt; pulls &lt;code&gt;owner&lt;/code&gt; and &lt;code&gt;repo&lt;/code&gt; out of that and calls &lt;code&gt;but_github&lt;/code&gt;'s pull request list.&lt;/li&gt;
&lt;li&gt;That ends at &lt;code&gt;list_open_pulls&lt;/code&gt;, which issues &lt;code&gt;GET /repos/{owner}/{repo}/pulls&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So the 404 is GitHub telling us the request went to a repository that does not exist. The interesting question is what &lt;code&gt;derive_forge_repo_info&lt;/code&gt; produced for a &lt;code&gt;.github.io&lt;/code&gt; URL. I parsed the reporter's URL and got back &lt;code&gt;repo = "org"&lt;/code&gt;. Not &lt;code&gt;org.github.io&lt;/code&gt;. Just &lt;code&gt;org&lt;/code&gt;. The API call had become &lt;code&gt;GET /repos/org/org/pulls&lt;/code&gt;, which of course 404s.&lt;/p&gt;

&lt;h2&gt;
  
  
  The &lt;code&gt;.git&lt;/code&gt; substring trap
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;derive_forge_repo_info&lt;/code&gt; parses the URL with the &lt;code&gt;git-url-parse&lt;/code&gt; crate. Here is the exact code that splits the path, from &lt;code&gt;git-url-parse&lt;/code&gt; 0.6.0 (&lt;code&gt;src/types/provider/generic.rs&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&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="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="nf"&gt;.ends_with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;".git"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;separated_pair&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;is_not&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;take_until&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;".git"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="nf"&gt;.parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;separated_pair&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;is_not&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;is_not&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="nf"&gt;.parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the &lt;code&gt;.ends_with(".git")&lt;/code&gt; branch closely. It reads the repo name with &lt;code&gt;take_until(".git")&lt;/code&gt;. &lt;code&gt;take_until&lt;/code&gt; stops at the &lt;strong&gt;first&lt;/strong&gt; &lt;code&gt;.git&lt;/code&gt; substring, not the terminal suffix. Most repo names never contain &lt;code&gt;.git&lt;/code&gt;, so this is invisible almost all the time. But a GitHub Pages repo is named &lt;code&gt;something.github.io&lt;/code&gt;, where the &lt;code&gt;.github&lt;/code&gt; piece contains &lt;code&gt;.git&lt;/code&gt;. So the path &lt;code&gt;org/org.github.io.git&lt;/code&gt; gets cut at the &lt;code&gt;.git&lt;/code&gt; inside &lt;code&gt;.github&lt;/code&gt; and the repo name collapses to &lt;code&gt;org&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The trap only springs when two things line up: the URL ends in the &lt;code&gt;.git&lt;/code&gt; clone suffix AND the repo name contains &lt;code&gt;.git&lt;/code&gt; earlier. Canonical GitHub clone URLs always append &lt;code&gt;.git&lt;/code&gt;, so every Pages repo hits it. I confirmed the full parse matrix in a scratch crate against &lt;code&gt;git-url-parse&lt;/code&gt; 0.6.0:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;remote URL&lt;/th&gt;
&lt;th&gt;parsed repo&lt;/th&gt;
&lt;th&gt;should be&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;git@github.com:org/repo.git&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;repo&lt;/td&gt;
&lt;td&gt;repo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;git@github.com:org/org.github.io&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;org.github.io&lt;/td&gt;
&lt;td&gt;org.github.io&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;git@github.com:org/org.github.io.git&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;org&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;org.github.io&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;https://github.com/org/org.github.io.git&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;org&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;org.github.io&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Only the rows that carry both the suffix and the substring are wrong.&lt;/p&gt;

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

&lt;p&gt;The buggy line lives inside a published dependency, so I cannot edit it. But I can stop feeding it the input that trips it. Look at the &lt;code&gt;else&lt;/code&gt; branch above: for a URL that does NOT end in &lt;code&gt;.git&lt;/code&gt;, the parser uses &lt;code&gt;is_not("/")&lt;/code&gt;, which reads the whole repo name up to the next slash and keeps &lt;code&gt;org.github.io&lt;/code&gt; intact.&lt;/p&gt;

&lt;p&gt;So the fix is to strip a single trailing &lt;code&gt;.git&lt;/code&gt; at our boundary, before parsing, in &lt;code&gt;crates/but-forge/src/lib.rs&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;Before:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;derive_forge_repo_info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ForgeRepoInfo&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;git_url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;GitUrl&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.ok&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="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;After:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;derive_forge_repo_info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ForgeRepoInfo&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// git-url-parse 0.6.0's GenericProvider strips the git-suffix with&lt;/span&gt;
    &lt;span class="c1"&gt;// `take_until(".git")`, which stops at the FIRST ".git" substring rather&lt;/span&gt;
    &lt;span class="c1"&gt;// than the terminal suffix. So `org/org.github.io.git` yields repo="org"&lt;/span&gt;
    &lt;span class="c1"&gt;// and every URL built from it points at the wrong repository and 404s.&lt;/span&gt;
    &lt;span class="c1"&gt;// Strip a single trailing ".git" ourselves so the parser takes its correct&lt;/span&gt;
    &lt;span class="c1"&gt;// `is_not("/")` branch. Safe because a real repo name can't end in ".git".&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="nf"&gt;.strip_suffix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;".git"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.unwrap_or&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;git_url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;GitUrl&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.ok&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="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;&lt;code&gt;strip_suffix&lt;/code&gt; matters here. It removes exactly one trailing occurrence, which is the single &lt;code&gt;.git&lt;/code&gt; clone suffix GitHub appends. Using &lt;code&gt;replace(".git", "")&lt;/code&gt; would delete the &lt;code&gt;.git&lt;/code&gt; inside &lt;code&gt;.github&lt;/code&gt; and recreate the bug. It is safe to strip because GitHub does not allow a repo name to end in &lt;code&gt;.git&lt;/code&gt;, so a trailing &lt;code&gt;.git&lt;/code&gt; is always the clone suffix. SSH and HTTPS URLs are handled the same way because the strip runs on the raw string. The same &lt;code&gt;(owner, repo)&lt;/code&gt; also feeds the web base URL and the commit, PR and compare links, so those are fixed too.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test, failing then passing
&lt;/h2&gt;

&lt;p&gt;I added a table-driven regression test, &lt;code&gt;repo_name_containing_dotgit_is_parsed_correctly&lt;/code&gt;, that asserts forge, owner and repo for nine URL cases: SSH and HTTPS, each plain and with &lt;code&gt;.git&lt;/code&gt;, plus the &lt;code&gt;*.github.io&lt;/code&gt; family with and without the suffix.&lt;/p&gt;

&lt;p&gt;With the fix reverted and the test kept, the suite fails on exactly the reported case:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;---- tests::repo_name_containing_dotgit_is_parsed_correctly stdout ----
assertion `left == right` failed: repo for git@github.com:org/org.github.io.git
  left: "org"
 right: "org.github.io"

&lt;/span&gt;&lt;span class="gp"&gt;test result: FAILED. 103 passed;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;1 failed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With the one-line fix in place, everything is green:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;test tests::repo_name_containing_dotgit_is_parsed_correctly ... ok
&lt;/span&gt;&lt;span class="gp"&gt;test result: ok. 104 passed;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;0 failed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That before/after pair is the proof: the test catches the real defect and the single line is what resolves it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verification
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;cargo test -p but-forge&lt;/code&gt; with the fix: 104 passed, 0 failed.&lt;/li&gt;
&lt;li&gt;Same run with the fix removed: 103 passed, 1 failed on the target case.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;cargo clippy -p but-forge --all-targets&lt;/code&gt;: 0 warnings.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;cargo fmt -- --check&lt;/code&gt;: clean.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Pull request: &lt;a href="https://github.com/gitbutlerapp/gitbutler/pull/15311" rel="noopener noreferrer"&gt;gitbutlerapp/gitbutler#15311&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Issue: &lt;a href="https://github.com/gitbutlerapp/gitbutler/issues/15302" rel="noopener noreferrer"&gt;gitbutlerapp/gitbutler#15302&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Branch &lt;a href="https://github.com/zkasuran/gitbutler/tree/fix/pr-repo-name-containing-dotgit" rel="noopener noreferrer"&gt;&lt;code&gt;fix/pr-repo-name-containing-dotgit&lt;/code&gt;&lt;/a&gt; on my fork, commit &lt;a href="https://github.com/zkasuran/gitbutler/commit/c34cf75b3ec00181d7dd0623db74b500e6c0fb77" rel="noopener noreferrer"&gt;&lt;code&gt;c34cf75&lt;/code&gt;&lt;/a&gt;. One line changed in &lt;code&gt;crates/but-forge/src/lib.rs&lt;/code&gt; plus the regression test.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A one-line fix, but only after the parser's first &lt;code&gt;.git&lt;/code&gt; cut gave up its secret. The best bugs hide inside a string almost nobody thinks to name with a dot-git in the middle.&lt;/p&gt;

&lt;h2&gt;
  
  
  AI disclosure
&lt;/h2&gt;

&lt;p&gt;AI assistance (Claude, Anthropic) was used while working on this fix. I did the diagnosis, the design, the review and the verification. I own the change. What was verified locally before submitting: &lt;code&gt;cargo test -p but-forge&lt;/code&gt; (104 passed, plus the new test fails 1 of 104 with the fix reverted), &lt;code&gt;cargo clippy -p but-forge --all-targets&lt;/code&gt; (0 warnings) and &lt;code&gt;cargo fmt -- --check&lt;/code&gt; (clean).&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>bugsmash</category>
      <category>rust</category>
      <category>opensource</category>
    </item>
    <item>
      <title>A single failed flush was silently killing telemetry in the Sentry Python SDK</title>
      <dc:creator>Asuran</dc:creator>
      <pubDate>Wed, 12 Aug 2026 08:45:27 +0000</pubDate>
      <link>https://dev.to/zkasuran/a-single-failed-flush-was-silently-killing-telemetry-in-the-sentry-python-sdk-4bgo</link>
      <guid>https://dev.to/zkasuran/a-single-failed-flush-was-silently-killing-telemetry-in-the-sentry-python-sdk-4bgo</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/bugsmash"&gt;DEV's Summer Bug Smash: Clear the Lineup&lt;/a&gt; powered by &lt;a href="https://sentry.io/" rel="noopener noreferrer"&gt;Sentry&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;This entry is a real, tested bug fix in the &lt;a href="https://github.com/getsentry/sentry-python" rel="noopener noreferrer"&gt;Sentry Python SDK&lt;/a&gt; for issue &lt;a href="https://github.com/getsentry/sentry-python/issues/7138" rel="noopener noreferrer"&gt;#7138&lt;/a&gt;. The SDK batches logs, metrics and spans and ships them from a background daemon thread. If one flush ever raised, that thread died. From then on the process kept buffering telemetry that was never sent and eventually dropped. No error reached the application. The fix makes the flush loop tolerate a failed batch and keep running.&lt;/p&gt;

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

&lt;p&gt;Sentry's SDK does not send every log or span the moment you create it. It buffers them in a &lt;code&gt;Batcher&lt;/code&gt; and a background daemon thread drains the buffer on a timer. That thread runs a simple loop: wait, then flush.&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;_flush_loop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_active&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;flag&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_running&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_flush_event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;wait&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FLUSH_WAIT_TIME&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_flush_event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clear&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_flush&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="c1"&gt;# nothing catches an exception here
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;_flush()&lt;/code&gt; serializes the buffered items and hands them to the transport. If any part of that raised, the exception propagated straight out of &lt;code&gt;_flush_loop&lt;/code&gt;. Because this is the thread's entry point, the thread ended. Python does not restart it.&lt;/p&gt;

&lt;p&gt;The quiet part is what happens next. &lt;code&gt;add()&lt;/code&gt; decides whether to start the flusher by checking &lt;code&gt;_flusher_pid == os.getpid()&lt;/code&gt;. After the thread has died that check still passes, because the pid never changed, so &lt;code&gt;add()&lt;/code&gt; happily keeps appending to a buffer that nothing drains. Items pile up until the buffer hits its hard cap (&lt;code&gt;MAX_BEFORE_DROP&lt;/code&gt;, 1000 for logs). Every item after that is dropped through &lt;code&gt;_record_lost&lt;/code&gt;. So a single transient error inside one flush turns into permanent, silent telemetry loss for the rest of the process lifetime. On a long-lived web worker the whole point of running the SDK is gone, with nothing in the logs to say why.&lt;/p&gt;

&lt;p&gt;The issue was filed with a concrete trigger: a &lt;code&gt;RuntimeError: _core::PyFeatureContext is unsendable ...&lt;/code&gt; raised while serializing a value during a flush. But the class of the bug is broader than any one trigger. The batcher sits on the SDK's own hot path and must not let one bad item take down the sender.&lt;/p&gt;

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

&lt;p&gt;The fix follows the issue title exactly: tolerate exceptions in the flush loop. Each loop wraps its flush in &lt;code&gt;capture_internal_exceptions()&lt;/code&gt;, the SDK's own context manager for errors that are the SDK's own fault and should be logged rather than thrown at the user.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sentry_sdk.utils&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;capture_internal_exceptions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;format_timestamp&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_flush_loop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_active&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;flag&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_running&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_flush_event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;wait&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FLUSH_WAIT_TIME&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_flush_event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clear&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;capture_internal_exceptions&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
            &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_flush&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same guard goes on &lt;code&gt;SpanBatcher._flush_loop&lt;/code&gt;, which has two flush calls (the pending-buckets flush and the periodic full flush), so all three batchers are covered: logs and metrics through the base &lt;code&gt;Batcher&lt;/code&gt;, spans through the &lt;code&gt;SpanBatcher&lt;/code&gt; override. A failed batch is now swallowed and logged. The loop stays alive and the next flush delivers everything that queued up in the meantime.&lt;/p&gt;

&lt;p&gt;PR: &lt;a href="https://github.com/getsentry/sentry-python/pull/7186" rel="noopener noreferrer"&gt;getsentry/sentry-python#7186&lt;/a&gt;, from branch &lt;a href="https://github.com/zkasuran/sentry-python/tree/fix/tolerate-exceptions-in-flush-loop" rel="noopener noreferrer"&gt;&lt;code&gt;fix/tolerate-exceptions-in-flush-loop&lt;/code&gt;&lt;/a&gt; on my fork. sentry-python's bot auto-closes PRs opened before a maintainer agrees on the issue, so the change was staked on the issue thread first. Once a maintainer confirmed the direction there, the branch was rebased onto current master and the PR opened.&lt;/p&gt;

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

&lt;p&gt;The diff is small and on point: four files, +94 / -15, with the real source change being the two &lt;code&gt;with capture_internal_exceptions():&lt;/code&gt; guards. The rest is comments and tests.&lt;/p&gt;

&lt;p&gt;I added one regression test per loop, &lt;code&gt;test_flush_loop_swallows_flush_exception&lt;/code&gt;, in &lt;code&gt;tests/test_logs.py&lt;/code&gt; (base &lt;code&gt;Batcher&lt;/code&gt;) and &lt;code&gt;tests/tracing/test_span_batcher.py&lt;/code&gt; (&lt;code&gt;SpanBatcher&lt;/code&gt;). Each builds a batcher whose &lt;code&gt;_flush&lt;/code&gt; raises once and then stops the loop, calls &lt;code&gt;_flush_loop()&lt;/code&gt; directly on the main thread and asserts it returns instead of raising. Run against the unfixed source both fail with &lt;code&gt;RuntimeError: boom in flush&lt;/code&gt;, which is exactly the propagation that killed the real thread. With the fix both pass. They carry the repo's own &lt;code&gt;@pytest.mark.tests_internal_exceptions&lt;/code&gt; marker, the suite's opt-in for tests that exercise &lt;code&gt;capture_internal_exceptions&lt;/code&gt;, which is a nice confirmation that the fix routes through the SDK's standard internal-error handling rather than a bespoke &lt;code&gt;try/except&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Verification, all green: 73 passed across the logs, metrics and span-batcher suites, &lt;code&gt;ruff check&lt;/code&gt; and &lt;code&gt;ruff format --check&lt;/code&gt; clean on the changed files, &lt;code&gt;mypy sentry_sdk&lt;/code&gt; shows no new errors versus the base (its 28 pre-existing errors are all in an unrelated integration, none in the batcher files).&lt;/p&gt;

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

&lt;p&gt;This is a fix to Sentry's own delivery pipeline, the part every log, metric and span passes through on its way out. The failure mode it removes is the worst kind for an observability tool: the SDK stops reporting and never says so, so the first you know is a gap in your dashboards during an incident. Making the flush loop survive a single bad batch means the SDK degrades to "log the internal error and keep going" instead of "go silent forever," which is the behavior you want from the thing you rely on to tell you when everything else is broken.&lt;/p&gt;

&lt;h2&gt;
  
  
  AI disclosure
&lt;/h2&gt;

&lt;p&gt;AI assistance (Claude, Anthropic) was used in developing this change. The design, review and verification were done by the author. Verified locally before submitting: the two new regression tests fail on the unpatched source and pass with the fix, the logs, metrics and span-batcher suites pass (73 tests), &lt;code&gt;ruff check&lt;/code&gt; and &lt;code&gt;ruff format --check&lt;/code&gt; are clean on the changed files, &lt;code&gt;mypy sentry_sdk&lt;/code&gt; shows no new errors versus the base.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>bugsmash</category>
      <category>python</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Stop the event loop stall: a Sentry Python SDK fix for slow local-variable capture</title>
      <dc:creator>Asuran</dc:creator>
      <pubDate>Wed, 12 Aug 2026 06:11:42 +0000</pubDate>
      <link>https://dev.to/zkasuran/stop-the-event-loop-stall-a-sentry-python-sdk-fix-for-slow-local-variable-capture-4lm6</link>
      <guid>https://dev.to/zkasuran/stop-the-event-loop-stall-a-sentry-python-sdk-fix-for-slow-local-variable-capture-4lm6</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/bugsmash"&gt;DEV's Summer Bug Smash: Clear the Lineup&lt;/a&gt; powered by &lt;a href="https://sentry.io/" rel="noopener noreferrer"&gt;Sentry&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;This entry is a real, tested bug fix in the &lt;a href="https://github.com/getsentry/sentry-python" rel="noopener noreferrer"&gt;Sentry Python SDK&lt;/a&gt; for issue &lt;a href="https://github.com/getsentry/sentry-python/issues/6649" rel="noopener noreferrer"&gt;#6649&lt;/a&gt;. On FastAPI 0.137 and newer, logging a single exception could freeze an async service for seconds because Sentry was building a giant text representation of the framework's internal router objects while capturing stack-frame locals. The fix makes that capture bounded and fast without changing what data ends up in Sentry for normal cases.&lt;/p&gt;

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

&lt;p&gt;When Sentry captures an exception with &lt;code&gt;include_local_variables=True&lt;/code&gt; (the default), it serializes every stack frame's local variables. For any value that is not a plain container it falls back to Python's &lt;code&gt;repr()&lt;/code&gt;, builds the whole string, then truncates it. That is fine for small objects. It is not fine when the object's &lt;code&gt;repr()&lt;/code&gt; walks a large graph.&lt;/p&gt;

&lt;p&gt;FastAPI 0.137 stopped flattening routes on &lt;code&gt;include_router()&lt;/code&gt;. Each nested routing frame now holds a &lt;code&gt;fastapi.routing._IncludedRouter&lt;/code&gt;, a dataclass whose auto-generated &lt;code&gt;__repr__&lt;/code&gt; recurses through the entire router tree. The same object shows up as &lt;code&gt;self&lt;/code&gt;, &lt;code&gt;route&lt;/code&gt; and &lt;code&gt;included_router&lt;/code&gt; in every nested frame, so Sentry paid for that whole-app &lt;code&gt;repr()&lt;/code&gt; over and over. Measured locally at 800 routes, one &lt;code&gt;_IncludedRouter&lt;/code&gt; repr is 1,782,273 characters. In production at thousands of routes the reporter saw about 20 seconds per logged error, long enough to trip the gunicorn &lt;code&gt;UvicornWorker&lt;/code&gt; timeout and get the worker killed. On an async app this blocks the event loop the whole time.&lt;/p&gt;

&lt;p&gt;The cost is building the string, not storing it, so simply capping the output length does not help. Neither does &lt;code&gt;reprlib&lt;/code&gt; (it still calls the object's full &lt;code&gt;repr()&lt;/code&gt; first). The fix has to avoid materializing the graph in the first place.&lt;/p&gt;

&lt;p&gt;Here is the wall time of one &lt;code&gt;logger.error(..., exc_info=exc)&lt;/code&gt; with locals on, by route count, before and after:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;routes&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;200&lt;/td&gt;
&lt;td&gt;0.180s&lt;/td&gt;
&lt;td&gt;0.121s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;400&lt;/td&gt;
&lt;td&gt;0.385s&lt;/td&gt;
&lt;td&gt;0.120s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;800&lt;/td&gt;
&lt;td&gt;0.768s&lt;/td&gt;
&lt;td&gt;0.121s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1600&lt;/td&gt;
&lt;td&gt;1.553s&lt;/td&gt;
&lt;td&gt;0.131s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Before, the time grows linearly with the size of the app. After, it is flat, so it no longer scales with the router graph. The per-frame local serialization at 800 routes drops from 0.303s to 0.008s, about 38x.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;The fix, as a PR:&lt;/strong&gt; &lt;a href="https://github.com/getsentry/sentry-python/pull/7176" rel="noopener noreferrer"&gt;getsentry/sentry-python#7176&lt;/a&gt;, which a maintainer invited on &lt;a href="https://github.com/getsentry/sentry-python/issues/6649" rel="noopener noreferrer"&gt;#6649&lt;/a&gt;. Branch &lt;code&gt;fix/logging-locals-serialization-eventloop-stall&lt;/code&gt;, commit &lt;code&gt;73f8f54&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The core of the fix is a new &lt;code&gt;bounded_repr()&lt;/code&gt; in &lt;code&gt;sentry_sdk/utils.py&lt;/code&gt;. It renders dataclass fields and the standard container types itself and stops the moment the output reaches the budget, so it never calls the object's own recursive &lt;code&gt;__repr__&lt;/code&gt; on a large graph.&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;bounded_repr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_length&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;repr(value) that stops once the output would exceed max_length.

    Renders dataclass fields and standard containers itself and stops as
    soon as the output reaches max_length, returning a prefix of repr(value)
    followed by &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;. When the full repr fits, the result is byte-for-byte
    identical to repr(value). Leaf values (strings, numbers, objects with a
    custom __repr__) are always rendered in full, so string values are never
    shortened; only over-large container/dataclass graphs are cut.
    &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;max_length&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;safe_repr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;chunks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;emit&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="k"&gt;nonlocal&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;
        &lt;span class="n"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;depth&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;depth&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_MAX_BOUNDED_REPR_DEPTH&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&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;return&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;dataclasses&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_dataclass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&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;obj&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;getattr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
                &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;field&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;dataclasses&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;repr&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="nf"&gt;_render_items&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;type&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;__qualname__&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;depth&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;
        &lt;span class="n"&gt;obj_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;type&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;obj_type&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="nf"&gt;_render_items&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nf"&gt;safe_repr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt; &lt;span class="n"&gt;depth&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;obj_type&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="nf"&gt;_render_items&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;depth&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="c1"&gt;# tuple / set / frozenset handled the same way ...
&lt;/span&gt;        &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;safe_repr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_render_items&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;opening&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;closing&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;depth&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;opening&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;first&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;child&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;max_length&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="n"&gt;_BoundedReprLimit&lt;/span&gt;  &lt;span class="c1"&gt;# what we emitted is a real prefix of repr(value)
&lt;/span&gt;            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;first&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;first&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
            &lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;child&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;depth&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;closing&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;_BoundedReprLimit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&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;return&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The serializer then uses it from the same place it used &lt;code&gt;safe_repr&lt;/code&gt;, so &lt;code&gt;custom_repr&lt;/code&gt; still runs first, capped at &lt;code&gt;max_value_length&lt;/code&gt; when set and otherwise at a generous 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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_safe_repr_wrapper&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;repr_value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;custom_repr&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;repr_value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;custom_repr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;repr_value&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="nf"&gt;bounded_repr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_max_repr_length&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;safe_repr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;The change is small and focused: three files, +198 / -2. It keeps the captured data the same for normal cases and only bounds the pathological one.&lt;/p&gt;

&lt;p&gt;What it preserves: when a value's full &lt;code&gt;repr()&lt;/code&gt; fits within the budget the result is byte-for-byte identical to &lt;code&gt;repr()&lt;/code&gt;, verified over 3000+ randomized nested structures with zero mismatches. Leaf strings are always rendered in full, so the "keep long strings" behavior from a recent SDK change stays intact. There is no FastAPI-specific code, so the maintainer's concern about a special case clashing with &lt;code&gt;custom_repr&lt;/code&gt; does not apply.&lt;/p&gt;

&lt;p&gt;Three tests were added to &lt;code&gt;tests/test_serializer.py&lt;/code&gt;: one that a small object still serializes to its exact &lt;code&gt;repr()&lt;/code&gt;, one that a large object is not fully materialized (a sentinel placed after an oversized field is never reached, so reverting the fix trips it), one that with &lt;code&gt;max_value_length&lt;/code&gt; set the output matches the old build-then-truncate result. Verification, all green: 230 passed and 2 skipped across the serializer, utils and logging suites, &lt;code&gt;ruff check&lt;/code&gt; and &lt;code&gt;ruff format --check&lt;/code&gt; clean on the changed files, &lt;code&gt;mypy sentry_sdk&lt;/code&gt; shows no new errors versus the base.&lt;/p&gt;

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

&lt;p&gt;The best use of Sentry here is making Sentry itself better for every async Python service that uses it. Error capture should never be the thing that takes a service down. On modern FastAPI this bug could turn a normal 500 into a killed worker. This fix keeps Sentry's rich local-variable capture (which is genuinely useful for debugging) while making its cost bounded and predictable, so teams can leave &lt;code&gt;include_local_variables=True&lt;/code&gt; on in production without risking event-loop stalls. It also hardens the serializer against any object whose &lt;code&gt;repr()&lt;/code&gt; walks a large graph, not just FastAPI's, which is exactly the general limit the maintainers said they wanted.&lt;/p&gt;

&lt;h2&gt;
  
  
  AI disclosure
&lt;/h2&gt;

&lt;p&gt;AI assistance (Claude, Anthropic) was used in developing this change. The design, review and verification were done by the author. Verified locally before submitting: the new and existing serializer, utils and logging tests pass, &lt;code&gt;ruff check&lt;/code&gt; and &lt;code&gt;ruff format --check&lt;/code&gt; are clean on the changed files, &lt;code&gt;mypy sentry_sdk&lt;/code&gt; shows no new errors versus the base.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>bugsmash</category>
      <category>python</category>
      <category>performance</category>
    </item>
    <item>
      <title>Surfacing swallowed provider errors in the Sentry MCP test client</title>
      <dc:creator>Asuran</dc:creator>
      <pubDate>Wed, 12 Aug 2026 05:38:45 +0000</pubDate>
      <link>https://dev.to/zkasuran/surfacing-swallowed-provider-errors-in-the-sentry-mcp-test-client-4a6d</link>
      <guid>https://dev.to/zkasuran/surfacing-swallowed-provider-errors-in-the-sentry-mcp-test-client-4a6d</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/bugsmash"&gt;DEV's Summer Bug Smash: Clear the Lineup&lt;/a&gt; powered by &lt;a href="https://sentry.io/" rel="noopener noreferrer"&gt;Sentry&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;Sentry MCP is a Model Context Protocol server that exposes Sentry's error and performance data to AI assistants. It ships a small CLI test client (&lt;code&gt;packages/mcp-test-client&lt;/code&gt;) that drives the server through a real LLM so you can exercise the tools end to end. This fix lands in &lt;code&gt;runAgent&lt;/code&gt; inside that test client. It closes issue #500, which was filed by Sentry's founder.&lt;/p&gt;

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

&lt;p&gt;When the client hit a provider error (an invalid API key, a rate limit or a 5xx), it printed &lt;code&gt;(No response generated)&lt;/code&gt; and moved on. The real reason was lost. The cause is a detail of the Vercel AI SDK the repo pins (&lt;code&gt;ai@6&lt;/code&gt;): &lt;code&gt;streamText&lt;/code&gt; does not throw when the request fails. It ends &lt;code&gt;textStream&lt;/code&gt; with zero chunks and reports the failure only through an &lt;code&gt;onError&lt;/code&gt; callback the code never set. So the &lt;code&gt;for await&lt;/code&gt; loop finished empty, &lt;code&gt;runAgent&lt;/code&gt; never entered its &lt;code&gt;catch&lt;/code&gt;, the Sentry span kept its OK status and nothing was captured. The client then fell through to the misleading empty-response message. The failure was invisible to the user and to Sentry at the same time. I confirmed this against the installed SDK before writing anything: a probe showed &lt;code&gt;textStream&lt;/code&gt; yields zero chunks without throwing, while &lt;code&gt;onError&lt;/code&gt; fires with an &lt;code&gt;APICallError&lt;/code&gt; carrying &lt;code&gt;statusCode: 401&lt;/code&gt;. The issue guessed the error might sit on &lt;code&gt;result.errorPromise&lt;/code&gt;; on this SDK version that property does not exist, so the fix does not use it.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;The fix, merged:&lt;/strong&gt; &lt;a href="https://github.com/getsentry/sentry-mcp/pull/1243" rel="noopener noreferrer"&gt;getsentry/sentry-mcp#1243&lt;/a&gt; was merged into &lt;code&gt;getsentry/sentry-mcp&lt;/code&gt; by the maintainer who filed the issue, branch &lt;code&gt;fix/mcp-test-client-surface-provider-errors&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The client now captures what the SDK reports through &lt;code&gt;onError&lt;/code&gt;, then surfaces it once the stream drains so it flows through the existing &lt;code&gt;catch&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;streamError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;streamText&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;languageModel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;system&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SYSTEM_PROMPT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;userPrompt&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
  &lt;span class="nx"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;stopWhen&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;stepCountIs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;maxSteps&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;experimental_telemetry&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;isEnabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;onError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Provider failures arrive here rather than as a thrown error.&lt;/span&gt;
    &lt;span class="nx"&gt;streamError&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;onStepFinish&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="cm"&gt;/* ... */&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;await &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;chunk&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textStream&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="cm"&gt;/* ... stream chunks to the terminal ... */&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Surface the provider failure instead of the silent fallback. Throwing&lt;/span&gt;
&lt;span class="c1"&gt;// routes it through the catch, which marks the span errored and rethrows.&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;streamError&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isStreaming&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;logStreamEnd&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;isStreaming&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;describeProviderError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;streamError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;cause&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;streamError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The mapper turns the raw error into the message the issue asked for. It also unwraps a &lt;code&gt;RetryError&lt;/code&gt;, because retryable statuses (429 and 5xx) are retried by the SDK and then delivered wrapped, with the HTTP status on &lt;code&gt;lastError&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;describeProviderError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;label&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;provider&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;openrouter&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;OpenRouter API&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;OpenAI API&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;envVar&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="nx"&gt;provider&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;openrouter&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;OPENROUTER_API_KEY&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;OPENAI_API_KEY&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;apiError&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;findApiCallError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// unwraps RetryError / cause&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;apiError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;apiError&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; authentication failed. Please check your &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;envVar&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; environment variable.`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; rate limit exceeded. Please wait and try again.`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; service error. The service may be temporarily unavailable.`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; request failed&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;` (HTTP &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;)`&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;apiError&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nb"&gt;Error&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; request failed: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;The behavior change is small on purpose. A provider error now produces a clear, provider-aware message that names the environment variable to check. The legitimate empty-response path is untouched, so a genuine empty reply still reads &lt;code&gt;(No response generated)&lt;/code&gt;. I added nine tests. Integration tests through &lt;code&gt;runAgent&lt;/code&gt; prove a 401 stream surfaces the auth message, rejects and does not print the fallback. They also prove a real 429 (driven through the SDK's actual retry path, so a real &lt;code&gt;RetryError&lt;/code&gt;) surfaces the rate limit message. Unit tests cover the full mapping: 401, 403, 429, 5xx, the &lt;code&gt;RetryError&lt;/code&gt; unwrap, a generic 400 and a non-API error. To keep the fix honest I reverted only the source change and watched the new tests fail, then restored it and watched them pass. The package suite goes from 70 to 79 passing. Type-check, Biome lint and format plus ast-grep are all green on the changed package.&lt;/p&gt;

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

&lt;p&gt;The whole point of the fix is that Sentry can finally see the failure. &lt;code&gt;runAgent&lt;/code&gt; wraps its work in a Sentry span; before the fix that span stayed OK on a provider error and no event was captured. Now the thrown error runs through the &lt;code&gt;catch&lt;/code&gt;, which calls &lt;code&gt;span.setStatus({ code: 2 })&lt;/code&gt; and rethrows, so the top-level &lt;code&gt;Sentry.captureException&lt;/code&gt; in the CLI records it and the &lt;code&gt;cause&lt;/code&gt; carries the original &lt;code&gt;APICallError&lt;/code&gt; for full context. I proved this with a real send to a Sentry DSN: the harness stubs a 401 (no real model call is placed), runs the real &lt;code&gt;runAgent&lt;/code&gt; and logs through &lt;code&gt;beforeSend&lt;/code&gt; and &lt;code&gt;beforeSendTransaction&lt;/code&gt; exactly what leaves the process. Before the fix, the root span status transmitted is &lt;code&gt;ok&lt;/code&gt; and no exception event is sent. After the fix, a real exception event is captured and the root span status transmitted is &lt;code&gt;internal_error&lt;/code&gt;. The same before and after is also enforced offline by span-status assertions in the test suite, so the guarantee does not depend on the network.&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%2Funudc96qas5hctuluc5p.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%2Funudc96qas5hctuluc5p.png" alt="The swallowed provider error, now a captured Sentry issue with the span marked errored" width="800" height="1090"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The same failure, now a real Sentry issue (environment &lt;code&gt;bugsmash-issue-500-demo&lt;/code&gt;, level error). The stack trace shows the fix's own code: the &lt;code&gt;throw new Error(describeProviderError(...))&lt;/code&gt; that routes the error through the catch so Sentry records it. Before the fix this printed &lt;code&gt;(No response generated)&lt;/code&gt; and no event reached Sentry at all.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;AI assistance (Claude, Anthropic) was used in developing this change. The design, review and verification were done by the author. Verified locally before submitting: the &lt;code&gt;@sentry/mcp-test-client&lt;/code&gt; vitest suite (79 passing, up from 70), &lt;code&gt;tsc --noEmit&lt;/code&gt; for the package, Biome lint and format, ast-grep, plus a real Sentry send showing the error now reaches Sentry with the span marked errored.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>bugsmash</category>
      <category>typescript</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
