<?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: Arjun Chaudhary</title>
    <description>The latest articles on DEV Community by Arjun Chaudhary (@oxarjun).</description>
    <link>https://dev.to/oxarjun</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%2F4048299%2Fe26cb960-0bab-4fd9-b040-48daebd9c786.png</url>
      <title>DEV Community: Arjun Chaudhary</title>
      <link>https://dev.to/oxarjun</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/oxarjun"/>
    <language>en</language>
    <item>
      <title>I Built an Observability Proxy for MCP Agents — Here's What I Actually Learned</title>
      <dc:creator>Arjun Chaudhary</dc:creator>
      <pubDate>Sun, 26 Jul 2026 18:42:46 +0000</pubDate>
      <link>https://dev.to/oxarjun/i-built-an-observability-proxy-for-mcp-agents-heres-what-i-actually-learned-3j2</link>
      <guid>https://dev.to/oxarjun/i-built-an-observability-proxy-for-mcp-agents-heres-what-i-actually-learned-3j2</guid>
      <description>&lt;p&gt;I've been using AI agents with MCP tools for a few months. At some point I noticed something uncomfortable: I had no idea what was happening inside them.&lt;/p&gt;

&lt;p&gt;An agent calls a tool. Did it succeed? How long did it take? Did the agent call the same tool 6 times because it couldn't parse the response? I genuinely could not tell. The only output was either a final answer or a vague failure. Everything in between was invisible.&lt;/p&gt;

&lt;p&gt;That invisibility felt like a real problem — not a theoretical one. So I decided to build something that fixes it, using OpenTelemetry and SigNoz. This is what I built, what I got wrong the first three times, and what finally worked.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Actual Problem (With Specifics)
&lt;/h2&gt;

&lt;p&gt;MCP uses a simple JSON-RPC 2.0 protocol over stdio. An agent sends &lt;code&gt;{"method": "tools/call", "params": {"name": "search", "arguments": {...}}}&lt;/code&gt; and a server responds. That's it. No request IDs in logs, no timing, no error rate, no session concept.&lt;/p&gt;

&lt;p&gt;Three failure modes kept appearing in my testing:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Loops.&lt;/strong&gt; An agent called &lt;code&gt;search&lt;/code&gt; with the same query 5 times in a row. I only noticed because the demo slowed down and I happened to be watching. There was no alert, no log entry, nothing. The agent was stuck in a self-reinforcing loop and the MCP layer had no opinion about it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hung calls.&lt;/strong&gt; &lt;code&gt;slow_tool&lt;/code&gt; takes about 20 seconds. The agent just waited. No timeout event. No trace. You'd only know something was wrong if you stared at the terminal long enough.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Silent drift.&lt;/strong&gt; The fake MCP server I wrote for testing can add a new tool at runtime (I called it &lt;code&gt;bonus_tool&lt;/code&gt;). Once it appeared in &lt;code&gt;tools/list&lt;/code&gt;, the agent started trying to use it. But there was no record of when the server's tool list changed — it just... happened.&lt;/p&gt;

&lt;p&gt;These aren't contrived edge cases. Loops happen when LLM output formats change slightly. Hung calls happen when downstream APIs have degraded performance. Drift happens whenever a server does a zero-downtime update. They're real.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Architecture: One Command, No Code Changes
&lt;/h2&gt;

&lt;p&gt;The core idea: don't ask people to add an SDK to their agent or their server. Just intercept the stdio stream.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;AI Agent
  │ stdin/stdout (JSON-RPC 2.0)
  ▼
MCP Observer (proxy)   ──────▶  SigNoz (via OTLP gRPC)
  │ passthrough
  ▼
MCP Server (unchanged)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You run your MCP server through the proxy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Before&lt;/span&gt;
node my-mcp-server.js

&lt;span class="c"&gt;# After&lt;/span&gt;
node mcp-observer.js &lt;span class="nt"&gt;--&lt;/span&gt; node my-mcp-server.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The proxy spawns the server as a child process, wires up bidirectional stdio, and taps every newline-delimited JSON message. It's a transparent middleman — every message passes through unmodified, but the observer sees all of it.&lt;/p&gt;

&lt;p&gt;For each &lt;code&gt;tools/call&lt;/code&gt; request, I start an OTel span:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;span&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tracer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startSpan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`mcp.tool.call:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;toolName&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="na"&gt;attributes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mcp.tool.name&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;toolName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mcp.session.id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;session&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mcp.tool.args_hash&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="c1"&gt;// never log raw args&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mcp.call.id&lt;/span&gt;&lt;span class="dl"&gt;'&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;callId&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 span closes when the response arrives. Duration is captured automatically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The thing I almost got wrong:&lt;/strong&gt; I almost logged raw &lt;code&gt;arguments&lt;/code&gt; as a span attribute. That's a bad idea — tool arguments often contain user queries, file paths, or credentials. SHA-256 hashing the arguments lets you correlate "this is the same call repeated 4 times" without exposing what was actually in them.&lt;/p&gt;




&lt;h2&gt;
  
  
  Building the Three Detectors
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Loop Detector
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;session&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="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;toolName&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;argsHash&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;calls&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;callHistory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&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="nx"&gt;calls&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="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;recent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;calls&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;t&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;WINDOW_MS&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;callHistory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;recent&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;recent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;THRESHOLD&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;emitAnomaly&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mcp.anomaly.loop_detected&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="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 threshold I landed on: 3 identical calls within 60 seconds. Too tight and you get false positives. Too loose and you're notifying after the damage is done. 3/60 caught every real loop in testing without any false positives.&lt;/p&gt;

&lt;h3&gt;
  
  
  Hung-Call Detector
&lt;/h3&gt;

&lt;p&gt;The trick here is that you want to fire &lt;em&gt;while the call is still in flight&lt;/em&gt; — not after it resolves. This rules out measuring duration at span close time. Instead, I set a &lt;code&gt;setTimeout&lt;/code&gt; when the call starts and cancel it if a response arrives before it fires:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;timer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;emitAnomaly&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mcp.anomaly.hung_call&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tool.name&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;toolName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;elapsed_ms&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;THRESHOLD_MS&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="nx"&gt;THRESHOLD_MS&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// On response: clearTimeout(timer)&lt;/span&gt;
&lt;span class="c1"&gt;// Then also emit a 'hung_call_resolved' with final duration&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;hung_call_resolved&lt;/code&gt; event turned out to be important. It tells you not just that a call was slow but whether it eventually succeeded. A call that hung for 18s and then returned successfully is very different from one that hung and then errored. Both fire the initial alert, but the resolution event differentiates them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Drift Detector
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;lastToolList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;session&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="o"&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;next&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;response&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="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;name&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;added&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;next&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;n&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;removed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;prev&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;p&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&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;added&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;removed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;emitAnomaly&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mcp.anomaly.capability_drift&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;added&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;removed&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nx"&gt;lastToolList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;session&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="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This one is simple but took me a moment to figure out the right comparison point. You want to diff per-session, not globally — different sessions can legitimately connect to different server versions. If you diff globally, you get noise.&lt;/p&gt;




&lt;h2&gt;
  
  
  Adding Gemini AI Diagnosis — and the Mistake I Almost Made
&lt;/h2&gt;

&lt;p&gt;Once the detectors were working, I added Gemini AI to generate plain-English root cause summaries. The first implementation I wrote awaited the Gemini call before emitting the anomaly log. That was wrong.&lt;/p&gt;

&lt;p&gt;Gemini sometimes takes 500–800ms. The proxy is in the hot path of every tool call response. If I await the AI call, I'm adding latency to the agent for every anomaly. The agent doesn't care why it was slow — it cares about getting the response.&lt;/p&gt;

&lt;p&gt;The fix: fire and forget.&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="c1"&gt;// This runs, then immediately moves on — does NOT await&lt;/span&gt;
&lt;span class="nf"&gt;generateRootCause&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;anomalyType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;summary&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;// Emits a separate log event with the AI diagnosis&lt;/span&gt;
  &lt;span class="nf"&gt;emitAnomaly&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;anomalyType&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.ai_diagnosis`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;root_cause_ai_summary&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;diagnosis_source&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gemini&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The anomaly log fires immediately. The AI diagnosis arrives 500ms later as a separate correlated log event. Both carry the same &lt;code&gt;trace_id&lt;/code&gt; and &lt;code&gt;span_id&lt;/code&gt;, so they appear together in SigNoz when you filter by trace.&lt;/p&gt;

&lt;p&gt;Here's the actual Gemini output I got for the hung call (pulled directly from SigNoz Logs Explorer — the &lt;code&gt;root_cause_ai_summary&lt;/code&gt; attribute):&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"Session 893fffd4 experienced a hung call on slow_tool after exceeding the 15,000ms threshold, following a rapid sequence of eight successful lightweight operations. This sudden latency spike strongly indicates an unhandled upstream timeout, database lock, or deadlock. Check downstream server logs for call ID 9 to see where execution stalled, and consider implementing a circuit breaker."&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's genuinely useful. It has the session ID, call ID, and a concrete suggestion. That's what I wanted — not "error occurred", but "here's what probably happened and what to do about it."&lt;/p&gt;




&lt;h2&gt;
  
  
  SigNoz: What I Actually Used
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Traces Explorer.&lt;/strong&gt; Filtering &lt;code&gt;service.name = mcp-observer&lt;/code&gt; shows every tool call span. Clicking into &lt;code&gt;mcp.tool.call:slow_tool&lt;/code&gt; shows the 15-second duration immediately. The span attributes panel shows the session ID and argument hash.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Logs Explorer.&lt;/strong&gt; The filter &lt;code&gt;body LIKE 'mcp.anomaly.%'&lt;/code&gt; shows all anomaly events. Each row is clickable — the sidebar reveals the full structured attributes including &lt;code&gt;root_cause_summary&lt;/code&gt; and &lt;code&gt;root_cause_ai_summary&lt;/code&gt;. Clicking the trace link in the sidebar jumps to the exact span that triggered the anomaly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One thing that caught me:&lt;/strong&gt; the &lt;code&gt;body&lt;/code&gt; field on log records is just the event name (e.g., &lt;code&gt;mcp.anomaly.hung_call&lt;/code&gt;). The full diagnostic text lives in &lt;code&gt;attributes_string&lt;/code&gt; → &lt;code&gt;root_cause_ai_summary&lt;/code&gt;. I spent longer than I'd like to admit looking for the text in the wrong field.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trace ↔ Log correlation.&lt;/strong&gt; This took the most wiring. To get &lt;code&gt;trace_id&lt;/code&gt; and &lt;code&gt;span_id&lt;/code&gt; to appear as top-level columns on log records (which SigNoz uses for the "jump to trace" button), you need to emit the log inside the span's active context:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setSpan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;active&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nx"&gt;span&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;anomalyLogger&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="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;eventName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;attributes&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;If you emit outside the context, the log record gets no trace link. This is not obvious from the OTel docs and I only found it by inspecting the log records in SigNoz and noticing &lt;code&gt;trace_id&lt;/code&gt; was empty.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'd Tell Someone Starting This
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Start with one detector.&lt;/strong&gt; I tried to build all three at once and tangled the session state. The loop detector alone is a complete, testable unit. Get that working end-to-end into SigNoz, then add the others.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SHA-256 your arguments.&lt;/strong&gt; Don't log raw tool arguments. You'll regret it when you realize they contain user data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Non-blocking AI enrichment from the start.&lt;/strong&gt; Don't architect yourself into a corner where you have to refactor later. Fire-and-forget is the right pattern for AI calls in any hot path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check your actual span names before building dashboards.&lt;/strong&gt; I spent time building dashboard panels that filtered for &lt;code&gt;name = 'execute_tool'&lt;/code&gt; — a span name I made up. My proxy actually emits &lt;code&gt;mcp.tool.call:search&lt;/code&gt;, &lt;code&gt;mcp.tool.call:slow_tool&lt;/code&gt;, etc. Running this in SigNoz Logs told me the truth immediately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SELECT DISTINCT name FROM signoz_traces
WHERE serviceName = 'mcp-observer'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Always verify what you're actually emitting before you build anything on top of it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;MCP is new enough that its observability story is essentially blank. That's either a problem or an opportunity, depending on how you look at it. I chose opportunity.&lt;/p&gt;

&lt;p&gt;The proxy works. Every anomaly fires, every AI diagnosis lands in SigNoz, and the trace-log correlation makes debugging a session a matter of clicking rather than grepping.&lt;/p&gt;

&lt;p&gt;If you're building with MCP tools and you want to see what your agents are actually doing, the code is at &lt;a href="https://github.com" rel="noopener noreferrer"&gt;https://github.com/Arjun-3105/mcp-observer&lt;/a&gt;. &lt;code&gt;npm run demo&lt;/code&gt; gets you all three anomaly types firing in about 90 seconds.&lt;/p&gt;

&lt;p&gt;One command. No code changes to your agent or server. Full visibility.&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%2Ftbu08j5h4kpp0fwsgss2.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%2Ftbu08j5h4kpp0fwsgss2.png" alt=" " width="800" height="439"&gt;&lt;/a&gt;&lt;/p&gt;

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

</description>
      <category>signoz</category>
      <category>mcp</category>
      <category>ai</category>
      <category>opentelemetry</category>
    </item>
  </channel>
</rss>
