<?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: EladSer</title>
    <description>The latest articles on DEV Community by EladSer (@eladser).</description>
    <link>https://dev.to/eladser</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%2F3972395%2Ff4959b4c-d14d-4f29-adb5-e5fc59b61f25.png</url>
      <title>DEV Community: EladSer</title>
      <link>https://dev.to/eladser</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/eladser"/>
    <language>en</language>
    <item>
      <title>Don't mutate CommandText in an EF Core interceptor (a mistake I shipped)</title>
      <dc:creator>EladSer</dc:creator>
      <pubDate>Sat, 13 Jun 2026 07:58:25 +0000</pubDate>
      <link>https://dev.to/eladser/dont-mutate-commandtext-in-an-ef-core-interceptor-a-mistake-i-shipped-51o5</link>
      <guid>https://dev.to/eladser/dont-mutate-commandtext-in-an-ef-core-interceptor-a-mistake-i-shipped-51o5</guid>
      <description>&lt;p&gt;For about a year, my debugging tool crashed the instant anyone pointed it at SQLite. I had no idea, because I only ever ran it against SQL Server. Nobody opened an issue either, which tells you roughly how many people were using it. Here's the bug, because it's an easy one to ship and I doubt I'm alone.&lt;/p&gt;

&lt;p&gt;The tool records the SQL that EF Core runs (among other things) and shows it in a dashboard. To do that it uses a &lt;code&gt;DbCommandInterceptor&lt;/code&gt;, and I wanted each query's execution time. The way I got it was bad enough that I'll just show you:&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="k"&gt;override&lt;/span&gt; &lt;span class="n"&gt;InterceptionResult&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;DbDataReader&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;ReaderExecuting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;DbCommand&lt;/span&gt; &lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CommandEventData&lt;/span&gt; &lt;span class="n"&gt;eventData&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;InterceptionResult&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;DbDataReader&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;result&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;id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Guid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NewGuid&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CommandText&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="n"&gt;id&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="n"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UtcNow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Ticks&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="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&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;On the way in, glue a comment with an id and a timestamp onto the SQL. On the way out, in &lt;code&gt;ReaderExecuted&lt;/code&gt;, parse that comment back off the command text and subtract to get the elapsed time. A little correlation system, smuggled through the query string.&lt;/p&gt;

&lt;p&gt;Yeah.&lt;/p&gt;

&lt;p&gt;The first problem is that this changes the SQL you send to the database. Every query left with a &lt;code&gt;/* ... */&lt;/code&gt; tail on it. SQL Server shrugs and runs it, which is the whole reason I never noticed, but now the database's own query logging is full of my garbage and I'm writing to something that isn't mine to write to.&lt;/p&gt;

&lt;p&gt;The second problem is the one that actually broke. Rewriting &lt;code&gt;CommandText&lt;/code&gt; after the command has run, while its reader is still open, throws on SQLite:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;System.InvalidOperationException: An open reader is associated with this
command. Close it before changing the CommandText property.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;SQLite in local dev is completely normal. So instead of a dashboard, those people got an exception thrown from inside the tool that was supposed to be helping them debug. Great look.&lt;/p&gt;

&lt;p&gt;Now the part that stings. I built that whole id-in-a-comment contraption to carry one number from &lt;code&gt;ReaderExecuting&lt;/code&gt; to &lt;code&gt;ReaderExecuted&lt;/code&gt;. I never needed to. &lt;code&gt;ReaderExecuted&lt;/code&gt; already hands you the duration, and the command, and everything else:&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="k"&gt;override&lt;/span&gt; &lt;span class="n"&gt;DbDataReader&lt;/span&gt; &lt;span class="nf"&gt;ReaderExecuted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;DbCommand&lt;/span&gt; &lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CommandExecutedEventData&lt;/span&gt; &lt;span class="n"&gt;eventData&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DbDataReader&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;Record&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CommandText&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;eventData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Duration&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;result&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;CommandExecutedEventData.Duration&lt;/code&gt; is a &lt;code&gt;TimeSpan&lt;/code&gt; that EF measured for me. The command is right there too. There was nothing to correlate, because both halves of what I wanted live in the same callback. I'd written a solution to a problem the API had already solved, and that solution happened to corrupt SQL and crash SQLite on its way past.&lt;/p&gt;

&lt;p&gt;If you genuinely do need to carry state from executing to executed (something that's only available before the command runs, say), there's a stable handle for that too. &lt;code&gt;eventData.CommandId&lt;/code&gt; is the same &lt;code&gt;Guid&lt;/code&gt; across both calls. Key your own dictionary off it and leave the SQL alone:&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;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;ConcurrentDictionary&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Guid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;MyState&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_inflight&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="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="n"&gt;InterceptionResult&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;DbDataReader&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;ReaderExecuting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;DbCommand&lt;/span&gt; &lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CommandEventData&lt;/span&gt; &lt;span class="n"&gt;eventData&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;InterceptionResult&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;DbDataReader&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;_inflight&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;eventData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CommandId&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;CaptureWhateverYouNeed&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;result&lt;/span&gt;&lt;span class="p"&gt;;&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;override&lt;/span&gt; &lt;span class="n"&gt;DbDataReader&lt;/span&gt; &lt;span class="nf"&gt;ReaderExecuted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;DbCommand&lt;/span&gt; &lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CommandExecutedEventData&lt;/span&gt; &lt;span class="n"&gt;eventData&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DbDataReader&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;_inflight&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TryRemove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;eventData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CommandId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;out&lt;/span&gt; &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// use state + eventData.Duration here&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&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 command object is for reading. Whatever you want to stash, stash it on your side.&lt;/p&gt;

&lt;p&gt;I rebuilt the tool around the boring one-liner and shipped 2.0. MIT, .NET 8/9/10, two lines to wire up, then you open &lt;code&gt;/_debug&lt;/code&gt; and get your requests, the SQL with real parameter values, logs, exceptions, and an N+1 nag when one request fires the same query over and over. Demo gif and setup in the readme: &lt;a href="https://github.com/eladser/AspNetDebugDashboard" rel="noopener noreferrer"&gt;https://github.com/eladser/AspNetDebugDashboard&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you write interceptors, go look at yours and make sure they're not doing something clever to the command text. Mine looked fine for a year.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>opensource</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I built a local Network tab for LLM calls (with evals), in .NET</title>
      <dc:creator>EladSer</dc:creator>
      <pubDate>Sun, 07 Jun 2026 10:47:13 +0000</pubDate>
      <link>https://dev.to/eladser/i-built-a-local-network-tab-for-llm-calls-with-evals-in-net-4b2h</link>
      <guid>https://dev.to/eladser/i-built-a-local-network-tab-for-llm-calls-with-evals-in-net-4b2h</guid>
      <description>&lt;p&gt;When you build something on top of an LLM, you mostly fly blind. You send a prompt, you get an answer, and the interesting parts are invisible: the exact text that went to the model after your code stitched it together, what that one call cost, how long it took, whether it called a tool, and whether a prompt or model change quietly made the answers worse.&lt;/p&gt;

&lt;p&gt;In the browser you'd open the Network tab. For AI calls there wasn't a good local equivalent, especially in .NET. So I built one: &lt;strong&gt;Seerlens&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fynsc3k136kohnj5v88no.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.amazonaws.com%2Fuploads%2Farticles%2Fynsc3k136kohnj5v88no.gif" alt="Seerlens demo" width="799" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

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

&lt;p&gt;One command, and a local dashboard shows every LLM call your app makes, live:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet tool &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; Seerlens
seerlens   &lt;span class="c"&gt;# dashboard at http://localhost:5005&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then point your app at it. In .NET you wrap the &lt;code&gt;IChatClient&lt;/code&gt; you already use:&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;using&lt;/span&gt; &lt;span class="nn"&gt;Seerlens.Sdk&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="n"&gt;SeerlensTrace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Configure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"http://localhost:5005"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;IChatClient&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;baseClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseSeerlens&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every call through &lt;code&gt;client&lt;/code&gt; shows up: the prompt, the completion, tokens, cost in dollars, latency, and any tool calls. It's local-first, SQLite, no signup, no cloud.&lt;/p&gt;

&lt;p&gt;It's built on the OpenTelemetry GenAI conventions, so it isn't .NET-only (any OTLP app works, and there are small SDKs for Python and JS). But I lead with .NET on purpose.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why .NET
&lt;/h2&gt;

&lt;p&gt;The .NET AI stack grew up fast: Microsoft.Extensions.AI, Semantic Kernel, the Aspire dashboard. It traces calls well. What it doesn't do is judge whether the answer was any good, or turn tokens into a budget you can act on. The Python world has Langfuse, Phoenix, Promptfoo, Braintrust for that. .NET had basically nothing. That gap is the whole reason Seerlens exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I actually care about: evals
&lt;/h2&gt;

&lt;p&gt;Tracing gets you in the door. The thing that separates "called an API once" from "ran AI for real" is catching quality regressions. You write a small golden set, score answers against it, and watch the trend over time.&lt;/p&gt;

&lt;p&gt;You can gate CI on it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;seerlens &lt;span class="nb"&gt;eval &lt;/span&gt;support &lt;span class="nt"&gt;--min&lt;/span&gt; 0.8 &lt;span class="nt"&gt;--baseline&lt;/span&gt; .seerlens/support.base
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That exits non-zero if the score drops below a floor, or regresses too far from a saved baseline. So a model swap that quietly drops answer quality becomes a red check on the PR, not a surprise in production.&lt;/p&gt;

&lt;p&gt;There are three scorers: &lt;code&gt;keyword&lt;/code&gt; (offline), &lt;code&gt;llm-judge&lt;/code&gt; (grade against a rubric), and one I'm happy with, &lt;code&gt;agent&lt;/code&gt;: it gives the model a case's tools, lets it actually call them, and scores whether it reached for the right tools in the right order. "Right answer, wrong tool path" shows up as a lower score.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it's not
&lt;/h2&gt;

&lt;p&gt;Worth being upfront, because it shapes whether it fits you. It's single-user and local: no auth, no shared dashboard. SQLite is fine for the dev loop, not a production firehose. The agent scorer uses tools you declare with canned results, it doesn't execute your real tools. And cost is only as fresh as the price table (which you can override). For a team watching production traffic, a deployed platform is the right call. This is the local dev-loop tool.&lt;/p&gt;

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

&lt;p&gt;MIT licensed, on GitHub, NuGet, PyPI and npm.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Repo: &lt;a href="https://github.com/eladser/seerlens" rel="noopener noreferrer"&gt;https://github.com/eladser/seerlens&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;dotnet tool install -g Seerlens&lt;/code&gt; / &lt;code&gt;pip install seerlens&lt;/code&gt; / &lt;code&gt;npm install seerlens&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's a side project I've been building in the open, so I'd genuinely like feedback on where it falls short.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>ai</category>
      <category>opensource</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
