<?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: Thirumalaiboobathi B</title>
    <description>The latest articles on DEV Community by Thirumalaiboobathi B (@thirumalaiboobathi_b_2b20).</description>
    <link>https://dev.to/thirumalaiboobathi_b_2b20</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%2F3955723%2Fa912b054-6413-4173-9ace-4a317b3afe46.jpg</url>
      <title>DEV Community: Thirumalaiboobathi B</title>
      <link>https://dev.to/thirumalaiboobathi_b_2b20</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/thirumalaiboobathi_b_2b20"/>
    <language>en</language>
    <item>
      <title>The Retry Loop That's Tripling Your Bedrock Bill</title>
      <dc:creator>Thirumalaiboobathi B</dc:creator>
      <pubDate>Mon, 03 Aug 2026 09:34:33 +0000</pubDate>
      <link>https://dev.to/thirumalaiboobathi_b_2b20/the-retry-loop-thats-tripling-your-bedrock-bill-e25</link>
      <guid>https://dev.to/thirumalaiboobathi_b_2b20/the-retry-loop-thats-tripling-your-bedrock-bill-e25</guid>
      <description>&lt;h2&gt;
  
  
  Six green spans, one broken tool
&lt;/h2&gt;

&lt;p&gt;A few weeks after shipping cost attribution, I was staring at a trace that looked healthy and made no sense.&lt;/p&gt;

&lt;p&gt;Six calls to the same tool inside one session. All HTTP 200. All span status OK. Total duration about four seconds. Nothing in the trace suggested anything was wrong.&lt;/p&gt;

&lt;p&gt;The tool had failed all six times. Same error, every call. The agent just couldn't tell.&lt;/p&gt;

&lt;p&gt;I only found it because the cost attribution I'd built in v0.5.0 showed six InvokeModel charges for one logical operation. The traces said everything succeeded. The dollars said otherwise.&lt;/p&gt;

&lt;p&gt;That gap is what v0.6.1 closes.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why the agent keeps retrying
&lt;/h2&gt;

&lt;p&gt;MCP has two error channels, and only one of them behaves the way you'd expect.&lt;/p&gt;

&lt;p&gt;Protocol errors — unknown method, malformed request — come back as JSON-RPC error objects. Those propagate normally.&lt;/p&gt;

&lt;p&gt;Tool errors don't. When a tool executes and fails, the server returns a &lt;strong&gt;successful&lt;/strong&gt; JSON-RPC response with an &lt;code&gt;isError&lt;/code&gt; flag inside the result:&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="err"&gt;HTTP/&lt;/span&gt;&lt;span class="mf"&gt;1.1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;OK&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;span class="nl"&gt;"jsonrpc"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"result"&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;span class="nl"&gt;"isError"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"content"&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;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"connection refused: 10.0.0.5:5432"&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;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;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;Nothing throws. Nothing rejects. Generic instrumentation reads the transport status, sees 200, and marks the span OK.&lt;/p&gt;

&lt;p&gt;Now think about what the agent sees. It gets that error text back as a &lt;strong&gt;normal tool result&lt;/strong&gt;. From the model's point of view nothing failed — it just received content that reads like a complaint about its input. So it does the sensible thing: rephrases the arguments and tries again.&lt;/p&gt;

&lt;p&gt;The tool fails identically. The agent tries again.&lt;/p&gt;

&lt;p&gt;Each retry resends the accumulated context to Bedrock, and the context has grown by the previous failed result. So the retries get progressively more expensive:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Attempt&lt;/th&gt;
&lt;th&gt;Input tokens&lt;/th&gt;
&lt;th&gt;Output tokens&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;8,000&lt;/td&gt;
&lt;td&gt;300&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;8,400&lt;/td&gt;
&lt;td&gt;300&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;8,800&lt;/td&gt;
&lt;td&gt;300&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;9,200&lt;/td&gt;
&lt;td&gt;300&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;9,600&lt;/td&gt;
&lt;td&gt;300&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;10,000&lt;/td&gt;
&lt;td&gt;300&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Illustrative token growth for one retry loop — actual figures depend entirely on your context size and the agent's retry behaviour.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Six billed InvokeModel calls producing nothing, and not a single error span to point at.&lt;/p&gt;




&lt;h2&gt;
  
  
  What thrash detection emits
&lt;/h2&gt;

&lt;p&gt;v0.6.1 watches for the same tool failing with the &lt;strong&gt;same failure fingerprint&lt;/strong&gt; repeatedly inside one session. When that crosses a threshold, it emits one event carrying the whole loop instead of leaving it scattered across N indistinguishable spans.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Span event: &lt;code&gt;mcp.loop.detected&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="err"&gt;mcp.loop.length&lt;/span&gt;              &lt;span class="err"&gt;6&lt;/span&gt;
&lt;span class="err"&gt;mcp.loop.wasted_tokens_in&lt;/span&gt;    &lt;span class="err"&gt;54000&lt;/span&gt;
&lt;span class="err"&gt;mcp.loop.wasted_tokens_out&lt;/span&gt;   &lt;span class="err"&gt;1800&lt;/span&gt;
&lt;span class="err"&gt;mcp.loop.wasted_cost_usd&lt;/span&gt;     &lt;span class="err"&gt;0.19&lt;/span&gt;
&lt;span class="err"&gt;mcp.loop.duration_ms&lt;/span&gt;         &lt;span class="err"&gt;4310&lt;/span&gt;
&lt;span class="err"&gt;mcp.loop.first_span_id&lt;/span&gt;       &lt;span class="err"&gt;7b2e...&lt;/span&gt;
&lt;span class="err"&gt;mcp.loop.first_trace_id&lt;/span&gt;      &lt;span class="err"&gt;c81a...&lt;/span&gt;
&lt;span class="err"&gt;mcp.loop.session_id&lt;/span&gt;          &lt;span class="err"&gt;sess-4471&lt;/span&gt;
&lt;span class="err"&gt;mcp.failure.fingerprint&lt;/span&gt;      &lt;span class="err"&gt;a3f8c21d94b06e77&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;first_span_id&lt;/code&gt; is the one that saves you time at 2 AM. It points back to the span where the loop started, which is where the actual root cause lives — the other five are just echoes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Five metrics&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="err"&gt;mcp.tool.loop.detected&lt;/span&gt;          &lt;span class="err"&gt;Counter&lt;/span&gt;
&lt;span class="err"&gt;mcp.tool.loop.length&lt;/span&gt;            &lt;span class="err"&gt;Histogram&lt;/span&gt;
&lt;span class="err"&gt;mcp.tool.loop.wasted_tokens&lt;/span&gt;     &lt;span class="err"&gt;Histogram&lt;/span&gt;   &lt;span class="err"&gt;tokens&lt;/span&gt;
&lt;span class="err"&gt;mcp.tool.loop.wasted_cost_usd&lt;/span&gt;   &lt;span class="err"&gt;Histogram&lt;/span&gt;   &lt;span class="err"&gt;USD&lt;/span&gt;
&lt;span class="err"&gt;mcp.tool.loop.duration&lt;/span&gt;          &lt;span class="err"&gt;Histogram&lt;/span&gt;   &lt;span class="err"&gt;ms&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same division of labour as v0.5.0: the counters give you alerting and trend lines, the span event gives you the drill-down when an alert fires.&lt;/p&gt;




&lt;h2&gt;
  
  
  The fingerprint is what makes grouping work
&lt;/h2&gt;

&lt;p&gt;Detection keys off the failure fingerprinting from v0.4.0, and this is the part that makes it useful rather than noisy.&lt;/p&gt;

&lt;p&gt;Raw error strings don't group. The same broken connection produces a different message every time — different IP, different request ID, different path. So the fingerprint normalises the error through a pipeline that strips UUIDs, paths, numbers, and hex strings, then hashes the result and truncates to 16 hex characters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;connection refused: 10.0.0.5:5432   →  a3f8c21d94b06e77
connection refused: 10.0.0.7:5432   →  a3f8c21d94b06e77
timeout after 30000ms on req_88a1   →  6d10b4e7c2f3a915
timeout after 30000ms on req_91c4   →  6d10b4e7c2f3a915
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same root cause, same fingerprint, regardless of the incidental detail. Six failures with six different messages collapse into one detected loop — which is the correct reading, because it &lt;em&gt;is&lt;/em&gt; one problem.&lt;/p&gt;

&lt;p&gt;This is also why v0.6.1 couldn't have shipped before v0.4.0 and v0.5.0. The loop event needs the fingerprint to know the failures are the same, and the cost attribution to say what the loop cost. Neither feature could produce this alone.&lt;/p&gt;




&lt;h2&gt;
  
  
  Instrumenting a Bedrock-backed MCP server
&lt;/h2&gt;

&lt;p&gt;No new setup. If you're already running the library, thrash detection is on by default:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;instrumentMcpServer&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;opentel-mcp&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;server&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;instrumentMcpServer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mcpServer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;serviceName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;mcp-server&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any tool that fails three times with the same fingerprint inside 60 seconds gets flagged automatically.&lt;/p&gt;

&lt;p&gt;Here's a Bedrock tool that will thrash if the downstream table goes missing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;BedrockRuntimeClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;InvokeModelCommand&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@aws-sdk/client-bedrock-runtime&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;instrumentMcpServer&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;opentel-mcp&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;bedrock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;BedrockRuntimeClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;region&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;us-east-1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;lookup_customer&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;query&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="k"&gt;try&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;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;query&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;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;bedrock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;InvokeModelCommand&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;modelId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;amazon.nova-pro-v1:0&lt;/span&gt;&lt;span class="dl"&gt;"&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;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;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="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;summarize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rows&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="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="nx"&gt;JSON&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextDecoder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&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;body&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&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="s2"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&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;output&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="nx"&gt;content&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;text&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
      &lt;span class="na"&gt;_meta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;:&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;usage&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;amazon.nova-pro-v1:0&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="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// This is the shape that goes unnoticed: HTTP 200, isError in the payload&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;isError&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;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&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="s2"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`query failed: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;err&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="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nf"&gt;instrumentMcpServer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;serviceName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;customer-tools&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rename the underlying table and the agent will retry this four to six times before giving up, charging Bedrock for each attempt. With v0.6.1 you get one &lt;code&gt;mcp.loop.detected&lt;/code&gt; event with the total.&lt;/p&gt;




&lt;h2&gt;
  
  
  Configuration
&lt;/h2&gt;

&lt;p&gt;Every field is overridable in code or by environment variable, which makes it tunable per environment without a redeploy:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Env var&lt;/th&gt;
&lt;th&gt;Default&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;enabled&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;OTEL_MCP_THRASH_ENABLED&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;true&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Disables detection entirely&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;threshold&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;OTEL_MCP_THRASH_THRESHOLD&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;3&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Same-fingerprint failures before triggering&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;windowMs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;OTEL_MCP_THRASH_WINDOW_MS&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;60000&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Rolling window failures must fall inside&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;maxTrackedKeys&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;OTEL_MCP_THRASH_MAX_TRACKED_KEYS&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1000&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;LRU cap on the tracking store&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;entryTtlMs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;OTEL_MCP_THRASH_ENTRY_TTL_MS&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;900000&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;How long an idle key survives&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;reEmitAfter&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;OTEL_MCP_THRASH_RE_EMIT_AFTER&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;3&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Re-emit every N failures past threshold&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;assumeSingleSession&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;OTEL_MCP_THRASH_ASSUME_SINGLE_SESSION&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;false&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Opt-in fallback for undetectable transports&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Invalid environment values fall back to the default silently. They never throw.&lt;/p&gt;




&lt;h2&gt;
  
  
  Four design decisions worth explaining
&lt;/h2&gt;

&lt;h3&gt;
  
  
  High-cardinality attributes never touch metric labels
&lt;/h3&gt;

&lt;p&gt;Fingerprints are unbounded — every new bug is a new fingerprint, permanently. Session IDs are worse. Put either on a metric dimension and you create a new time series per bug and per session, forever. In CloudWatch that gets expensive fast; in any backend it eventually falls over.&lt;/p&gt;

&lt;p&gt;So every metric carries only &lt;code&gt;gen_ai.tool.name&lt;/code&gt;. The fingerprint and session ID live on the span event, where high cardinality is safe and you get the full detail anyway when you drill in. The allowlist is enforced in code, not by convention, because "remember not to add attributes here" is not a strategy that survives contact with a future version of yourself.&lt;/p&gt;

&lt;h3&gt;
  
  
  The tracking store is bounded, and there's no timer
&lt;/h3&gt;

&lt;p&gt;Loop detection has to remember recent failures, which means it holds state. MCP servers on stdio transport run for the lifetime of the process — sometimes weeks. An unbounded map here would be a slow memory leak that only shows up in the longest-running deployments, which are exactly the ones you least want to debug.&lt;/p&gt;

&lt;p&gt;So it's an LRU with a TTL: capped keys, lazy expiry on read, and an amortised sweep on write. Deliberately no &lt;code&gt;setInterval&lt;/code&gt; — a live timer keeps the Node event loop alive and stops the server exiting cleanly, which is its own subtle bug.&lt;/p&gt;

&lt;h3&gt;
  
  
  Sessions are never merged on a guess
&lt;/h3&gt;

&lt;p&gt;This is the setting most likely to get misconfigured, so it's worth being explicit.&lt;/p&gt;

&lt;p&gt;Loop detection needs a session boundary. Merge two clients' failures into one bucket and you get a phantom loop: three unrelated clients each failing once looks identical to one client failing three times.&lt;/p&gt;

&lt;p&gt;Session-oriented transports provide a real session ID. Stdio doesn't — there's exactly one connection for the process lifetime instead. The resolution order: a real session ID always wins and permanently marks the server as session-aware. Once a server has been observed handing one out, a later call without one is skipped rather than merged. A generated fallback is used only when the transport is structurally confirmed single-connection, or when you explicitly opt in via &lt;code&gt;assumeSingleSession&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Otherwise detection is skipped silently rather than guessing. Skipping produces missing data; guessing produces wrong data. Wrong is worse.&lt;/p&gt;

&lt;h3&gt;
  
  
  It observes. It does not intervene.
&lt;/h3&gt;

&lt;p&gt;Same principle as the budget flags in v0.5.0. Detecting a loop sets attributes and emits metrics. It does not cancel the request, break the connection, or refuse the next call.&lt;/p&gt;

&lt;p&gt;An instrumentation library that can interrupt agent execution is a library that can take down production in a way nobody predicted. Loop-breaking belongs in the agent framework or an AI gateway, where it's a deliberate part of the request path and can be disabled without redeploying your MCP server.&lt;/p&gt;




&lt;h2&gt;
  
  
  Checking it without a collector
&lt;/h2&gt;

&lt;p&gt;If you just want to know whether anything is thrashing right now, there's an in-process accessor that involves no OpenTelemetry at all:&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="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getThrashSummary&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

&lt;span class="c1"&gt;// {&lt;/span&gt;
&lt;span class="c1"&gt;//   activeLoops: 1,&lt;/span&gt;
&lt;span class="c1"&gt;//   totalLoopsDetected: 4,&lt;/span&gt;
&lt;span class="c1"&gt;//   totalWastedCostUsd: 0.09,&lt;/span&gt;
&lt;span class="c1"&gt;//   totalWastedTokensIn: 3600,&lt;/span&gt;
&lt;span class="c1"&gt;//   totalWastedTokensOut: 900,&lt;/span&gt;
&lt;span class="c1"&gt;//   topOffenders: [&lt;/span&gt;
&lt;span class="c1"&gt;//     { toolName: 'lookup_customer', fingerprint: 'a3f4c8e2b1d09f77',&lt;/span&gt;
&lt;span class="c1"&gt;//       loops: 3, wastedCostUsd: 0.03 }&lt;/span&gt;
&lt;span class="c1"&gt;//   ]&lt;/span&gt;
&lt;span class="c1"&gt;// }&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing is sent anywhere. It's safe to call from a health check handler, and it works before you've stood up any backend.&lt;/p&gt;

&lt;p&gt;One caveat worth stating: &lt;code&gt;activeLoops&lt;/code&gt; and &lt;code&gt;topOffenders&lt;/code&gt; reflect only what's currently in the bounded store, so an evicted or expired loop won't appear even though it really happened. The cumulative totals survive both and answer "how much has this process wasted since it started." Read the totals for accounting, not the offender list.&lt;/p&gt;




&lt;h2&gt;
  
  
  Common questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Does this add latency?&lt;/strong&gt; Detection is a hash-map lookup and a counter increment on the failure path only. Successful calls do a single clear operation. No network calls, no async work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What if the same tool legitimately fails for different reasons?&lt;/strong&gt; Different reasons produce different fingerprints, so they don't group and no loop fires. That's the intended behaviour — a tool failing three distinct ways is a different problem from a tool failing the same way three times.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does it work without fingerprinting enabled?&lt;/strong&gt; No. Detection keys off the fingerprint, so with &lt;code&gt;fingerprinting: false&lt;/code&gt; it silently never fires. Both are on by default.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What about schema validation errors?&lt;/strong&gt; Those travel the JSON-RPC error channel rather than arriving as &lt;code&gt;isError: true&lt;/code&gt;, so they're a separate detection path and aren't covered here. Worth knowing, since "the agent is calling the tool wrong" is arguably the failure mode most likely to loop. It's on the list.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is the loop event emitted once or repeatedly?&lt;/strong&gt; Once at the threshold, then again every &lt;code&gt;reEmitAfter&lt;/code&gt; failures past it — so a 12-call loop emits at 3, 6, 9, and 12 rather than nine times. The wasted-cost figures are delta-accounted so re-emission doesn't double-count.&lt;/p&gt;




&lt;h2&gt;
  
  
  The gap I haven't closed
&lt;/h2&gt;

&lt;p&gt;Worth stating plainly, because it's the most interesting unsolved problem in the library right now.&lt;/p&gt;

&lt;p&gt;If the observation path itself is broken — no provider registered, collector unreachable, exporter silently dropping — then a tool failure and a clean run produce identical output. Zero. "Nothing failed" and "nothing was observed" collapse into the same state.&lt;/p&gt;

&lt;p&gt;This came out of a review from an external reader on the v0.3.0 release post, and he was right. A liveness signal can't travel over the channel whose liveness is in question, so this has to surface on the in-process path rather than through spans.&lt;/p&gt;

&lt;p&gt;The proposed contract is three states rather than two: &lt;code&gt;OBSERVED_CLEAN&lt;/code&gt;, &lt;code&gt;OBSERVED_FAILING&lt;/code&gt;, &lt;code&gt;OBSERVATION_UNAVAILABLE&lt;/code&gt;. It's currently in the repo as a skipped specification test with the reasoning documented, rather than quietly ignored. Detecting an unbound observation path without coupling to unstable SDK internals is the part I don't have an answer for yet.&lt;/p&gt;

&lt;p&gt;Anyone who has chased a collector that looked perfectly healthy while binding no ports will recognise the shape.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Splitting protocol-level failures from execution failures in the fingerprint, so a schema error and an upstream outage read differently&lt;/li&gt;
&lt;li&gt;Tool schema drift detection — hashing &lt;code&gt;tools/list&lt;/code&gt; schemas and flagging silent changes&lt;/li&gt;
&lt;li&gt;The observation liveness contract above&lt;/li&gt;
&lt;li&gt;Cost-aware sampling, so expensive traces survive sampling decisions that cheap ones don't&lt;/li&gt;
&lt;/ul&gt;




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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;opentel-mcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;npm: &lt;a href="https://www.npmjs.com/package/opentel-mcp" rel="noopener noreferrer"&gt;https://www.npmjs.com/package/opentel-mcp&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/Thirumalaiboobathi/opentel-mcp" rel="noopener noreferrer"&gt;https://github.com/Thirumalaiboobathi/opentel-mcp&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Docs: &lt;a href="https://opentel-mcp-site.pages.dev/" rel="noopener noreferrer"&gt;https://opentel-mcp-site.pages.dev/&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're running MCP servers on AWS, the thing worth checking today is whether your tool failures are actually reaching your traces. Mine weren't — and I'd like to know what your retry patterns look like, because the edge cases people report are what shape the next release.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>mcp</category>
      <category>opensource</category>
      <category>aws</category>
    </item>
    <item>
      <title>Track Bedrock Token Cost Per MCP Tool Call in OpenTelemetry</title>
      <dc:creator>Thirumalaiboobathi B</dc:creator>
      <pubDate>Wed, 29 Jul 2026 10:41:33 +0000</pubDate>
      <link>https://dev.to/thirumalaiboobathi_b_2b20/track-bedrock-token-cost-per-mcp-tool-call-in-opentelemetry-4khm</link>
      <guid>https://dev.to/thirumalaiboobathi_b_2b20/track-bedrock-token-cost-per-mcp-tool-call-in-opentelemetry-4khm</guid>
      <description>&lt;p&gt;Your MCP traces already tell you what was slow and what broke. Here's how to make them tell you what it cost — before the Amazon Bedrock bill shows up.&lt;/p&gt;

&lt;p&gt;The bill that arrives three weeks late&lt;/p&gt;

&lt;p&gt;Last month I was load-testing an MCP server that fronts a handful of Amazon Bedrock calls. Latency looked fine. Error rate looked fine. Every span was green.&lt;/p&gt;

&lt;p&gt;Then the Cost Explorer chart came in and one tool was responsible for most of the model spend.&lt;/p&gt;

&lt;p&gt;I went back to the traces to figure out which one. The traces had no idea. They had duration, status code, tool name, and nothing about tokens or dollars. The information I needed had already been thrown away three weeks earlier.&lt;/p&gt;

&lt;p&gt;That gap is what this post is about, and what I shipped in opentel-mcp v0.5.0 to close it.&lt;/p&gt;

&lt;p&gt;Why standard MCP instrumentation misses cost entirely&lt;/p&gt;

&lt;p&gt;A typical MCP server sits in the middle of a chain like this:&lt;/p&gt;

&lt;p&gt;User → MCP Client → MCP Server → Tool → Amazon Bedrock / Anthropic / OpenAI / Gemini&lt;/p&gt;

&lt;p&gt;Standard OpenTelemetry instrumentation wraps the MCP server layer and records:&lt;/p&gt;

&lt;p&gt;request latency&lt;br&gt;
error status&lt;br&gt;
success rate&lt;/p&gt;

&lt;p&gt;All useful. But the model call happens inside the tool, one layer down, and its usage payload gets consumed and discarded when the tool builds its response. By the time the span closes, the token counts are gone.&lt;/p&gt;

&lt;p&gt;So the questions you actually want answered at 2 AM stay unanswered:&lt;/p&gt;

&lt;p&gt;Which tool is burning the most tokens?&lt;br&gt;
Which workflow costs the most per invocation?&lt;br&gt;
Which model is driving the spend?&lt;br&gt;
Which user session blew past its budget?&lt;/p&gt;

&lt;p&gt;Those answers exist only in the provider's billing dashboard, aggregated by day, with no trace ID attached. You can see that you spent money. You can't see which request spent it.&lt;/p&gt;

&lt;p&gt;What cost attribution in a span looks like&lt;/p&gt;

&lt;p&gt;In v0.5.0, every MCP tool span carries cost and token data as first-class attributes alongside latency and errors.&lt;/p&gt;

&lt;p&gt;Token usage&lt;/p&gt;

&lt;p&gt;&lt;code&gt;mcp.tool.tokens.input&lt;br&gt;
mcp.tool.tokens.output&lt;br&gt;
mcp.tool.tokens.total&lt;br&gt;
&lt;/code&gt;&lt;br&gt;
Cost&lt;/p&gt;

&lt;p&gt;`mcp.tool.cost.usd&lt;/p&gt;

&lt;p&gt;Model attribution&lt;/p&gt;

&lt;p&gt;mcp.tool.model&lt;br&gt;
gen_ai.response.model&lt;br&gt;
`&lt;br&gt;
That second one matters more than it looks. gen_ai.response.model is part of the OpenTelemetry GenAI semantic conventions, so if you already have a GenAI dashboard built in Grafana, SigNoz, or Amazon Managed Grafana, MCP tool spans show up in it without you touching a single panel. Emitting both is a small duplication that saves every downstream user a dashboard rebuild.&lt;/p&gt;

&lt;p&gt;Budget signals&lt;/p&gt;

&lt;p&gt;&lt;code&gt;mcp.tool.cost.budget_exceeded&lt;br&gt;
mcp.tool.cost.budget_scope&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Two new metrics counters&lt;br&gt;
&lt;code&gt;&lt;br&gt;
mcp.tool.tokens.total&lt;br&gt;
mcp.tool.cost.total&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;The counters give you the aggregate view for alerting and trend lines. The span attributes give you the drill-down when an alert fires. You need both — the counter tells you spend tripled, the span tells you which tool did it.&lt;/p&gt;

&lt;p&gt;Instrumenting a Bedrock-backed MCP server&lt;/p&gt;

&lt;p&gt;Setup is one line. If you already have the OpenTelemetry Node SDK configured, this is the entire change:&lt;/p&gt;

&lt;p&gt;javascript&lt;br&gt;
`import { instrumentMcpServer } from "opentel-mcp";&lt;/p&gt;

&lt;p&gt;instrumentMcpServer(server);`&lt;/p&gt;

&lt;p&gt;From that point on, every tool call emits trace context, token usage, estimated cost, model attribution, and budget signals.&lt;/p&gt;

&lt;p&gt;Here's a Bedrock tool being instrumented end to end:&lt;/p&gt;

&lt;p&gt;javascript&lt;br&gt;
`import { BedrockRuntimeClient, InvokeModelCommand } from "@aws-sdk/client-bedrock-runtime";&lt;br&gt;
import { instrumentMcpServer } from "opentel-mcp";&lt;/p&gt;

&lt;p&gt;const bedrock = new BedrockRuntimeClient({ region: "us-east-1" });&lt;/p&gt;

&lt;p&gt;server.tool("summarize_report", async ({ text }) =&amp;gt; {&lt;br&gt;
  const response = await bedrock.send(new InvokeModelCommand({&lt;br&gt;
    modelId: "amazon.nova-pro-v1:0",&lt;br&gt;
    body: JSON.stringify({&lt;br&gt;
      messages: [{ role: "user", content: [{ text }] }]&lt;br&gt;
    })&lt;br&gt;
  }));&lt;/p&gt;

&lt;p&gt;const result = JSON.parse(new TextDecoder().decode(response.body));&lt;/p&gt;

&lt;p&gt;return {&lt;br&gt;
    content: [{ type: "text", text: result.output.message.content[0].text }],&lt;br&gt;
    // usage flows through to the span&lt;br&gt;
    _meta: { usage: result.usage, model: "amazon.nova-pro-v1:0" }&lt;br&gt;
  };&lt;br&gt;
});&lt;/p&gt;

&lt;p&gt;instrumentMcpServer(server);&lt;br&gt;
`&lt;br&gt;
The resulting span:&lt;/p&gt;

&lt;p&gt;Span: mcp.tool.summarize_report&lt;br&gt;
├─ duration: 1,842ms&lt;br&gt;
├─ mcp.tool.model: amazon.nova-pro-v1:0&lt;br&gt;
├─ mcp.tool.tokens.input: 4,210&lt;br&gt;
├─ mcp.tool.tokens.output: 388&lt;br&gt;
├─ mcp.tool.tokens.total: 4,598&lt;br&gt;
└─ mcp.tool.cost.usd: 0.00363&lt;/p&gt;

&lt;p&gt;One tool call. One line in the trace. Latency, model, tokens, and dollars in the same place — which means you can now sort your traces by cost the same way you sort them by duration.&lt;/p&gt;

&lt;p&gt;Which models are priced out of the box&lt;/p&gt;

&lt;p&gt;v0.5.0 ships pricing for 19 models across five providers:&lt;/p&gt;

&lt;p&gt;Provider    Coverage&lt;br&gt;
Amazon Bedrock  Nova family&lt;br&gt;
Anthropic   Claude family&lt;br&gt;
OpenAI  GPT family&lt;br&gt;
Google  Gemini family&lt;br&gt;
DeepSeek    DeepSeek models&lt;/p&gt;

&lt;p&gt;If you're on a supported model, there's no pricing configuration to write.&lt;/p&gt;

&lt;p&gt;If you're running a fine-tuned model, a self-hosted model, or you have negotiated enterprise rates, extend the defaults:&lt;/p&gt;

&lt;p&gt;javascript&lt;br&gt;
`import { instrumentMcpServer, DEFAULT_PRICING } from "opentel-mcp";&lt;/p&gt;

&lt;p&gt;instrumentMcpServer(server, {&lt;br&gt;
  pricing: {&lt;br&gt;
    ...DEFAULT_PRICING,&lt;br&gt;
    "internal-model": {&lt;br&gt;
      input: 0.002,   // USD per 1K input tokens&lt;br&gt;
      output: 0.008   // USD per 1K output tokens&lt;br&gt;
    }&lt;br&gt;
  },&lt;br&gt;
  budgets: {&lt;br&gt;
    toolUsd: 0.10,&lt;br&gt;
    sessionUsd: 1.00&lt;br&gt;
  }&lt;br&gt;
});`&lt;/p&gt;

&lt;p&gt;Every provider returns usage in a different shape, so the default usage extractor is fully replaceable if your tool results don't match the common formats.&lt;/p&gt;

&lt;p&gt;Four design decisions worth explaining&lt;/p&gt;

&lt;p&gt;It observes. It does not enforce.&lt;/p&gt;

&lt;p&gt;When budgets.toolUsd is exceeded, the library sets mcp.tool.cost.budget_exceeded on the span and lets the request through. It does not throw, block, or retry.&lt;/p&gt;

&lt;p&gt;This one gets pushback, so here's the reasoning: an instrumentation library that can reject requests is a library that can take down production. Enforcement belongs in an AI gateway or proxy that sits in the request path deliberately and can be turned off without redeploying your server. Instrumentation should be safe to leave on forever.&lt;/p&gt;

&lt;p&gt;It never throws.&lt;/p&gt;

&lt;p&gt;Unknown model, malformed usage payload, missing session ID, garbage in the response — none of these produce an exception. The library records whatever it can resolve and moves on. Observability tooling that becomes a source of incidents defeats its own purpose.&lt;/p&gt;

&lt;p&gt;Budget tracking is in-memory.&lt;/p&gt;

&lt;p&gt;Session budgets are tracked per process and are not distributed across instances. Behind a load balancer, each instance tracks its own view.&lt;/p&gt;

&lt;p&gt;That's a real limitation and I'd rather state it plainly than have you discover it in production. The alternative was a Redis dependency, and keeping this library dependency-free was worth more to me than distributed budget accuracy. If you need cluster-wide enforcement, aggregate the mcp.tool.cost.total counter in your backend instead — that's what the counter is there for.&lt;/p&gt;

&lt;p&gt;Extraction is pluggable.&lt;/p&gt;

&lt;p&gt;Rather than trying to internally support every provider's response schema forever, the usage extractor is a public API you can swap out entirely.&lt;/p&gt;

&lt;p&gt;Why this belongs in traces and not a billing dashboard&lt;/p&gt;

&lt;p&gt;AI FinOps is becoming its own discipline, and most of the tooling around it operates on daily aggregates — spend by account, spend by service, spend by tag.&lt;/p&gt;

&lt;p&gt;Aggregates are fine for finance. They're close to useless for engineering. When your model spend jumps, "which day" doesn't help you. "Which tool, called by which workflow, on which model, in which session" is the thing that lets you actually fix something.&lt;/p&gt;

&lt;p&gt;Putting cost in the same span as latency and errors means you stop treating them as separate investigations. A slow tool and an expensive tool are often the same tool, and you can now see that in one query.&lt;/p&gt;

&lt;p&gt;Common questions&lt;/p&gt;

&lt;p&gt;Does this add latency to my tool calls? Cost calculation is a table lookup and arithmetic on data already present in the response. There's no network call and no external service.&lt;/p&gt;

&lt;p&gt;What if my model isn't in the pricing table? The span still records tokens and model name. Cost is omitted rather than guessed, and no exception is raised.&lt;/p&gt;

&lt;p&gt;Does it work with existing OpenTelemetry GenAI dashboards? Yes. The library emits gen_ai.response.model alongside mcp.tool.model specifically so existing dashboards pick up MCP spans with no changes.&lt;/p&gt;

&lt;p&gt;Can I use it with an MCP server not backed by Bedrock? Yes. Anthropic, OpenAI, Gemini, and DeepSeek pricing ship by default, and custom pricing covers anything else.&lt;/p&gt;

&lt;p&gt;Does it block requests when a budget is exceeded? No. It records mcp.tool.cost.budget_exceeded on the span and continues.&lt;/p&gt;

&lt;p&gt;What's next&lt;/p&gt;

&lt;p&gt;Directions I'm considering for future releases:&lt;/p&gt;

&lt;p&gt;cost-aware sampling, so expensive traces survive sampling decisions that cheap ones don't&lt;br&gt;
cross-server trace correlation for multi-hop MCP topologies&lt;br&gt;
predictive budget alerts via OpenTelemetry Events&lt;br&gt;
richer AI FinOps dashboard templates&lt;/p&gt;

&lt;p&gt;The goal isn't to become an AI gateway. It's to make sure that when you open a trace, the cost information is already sitting there.&lt;/p&gt;

&lt;p&gt;Try it&lt;br&gt;
bash&lt;br&gt;
&lt;code&gt;npm install opentel-mcp&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;GitHub: &lt;a href="https://github.com/Thirumalaiboobathi/opentel-mcp" rel="noopener noreferrer"&gt;https://github.com/Thirumalaiboobathi/opentel-mcp&lt;/a&gt; — MIT licensed, issues and PRs welcome. A star helps more people find it.&lt;/p&gt;

&lt;p&gt;npm: &lt;a href="https://www.npmjs.com/package/opentel-mcp" rel="noopener noreferrer"&gt;https://www.npmjs.com/package/opentel-mcp&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;CHANGELOG: &lt;a href="https://github.com/Thirumalaiboobathi/opentel-mcp/blob/main/CHANGELOG.md" rel="noopener noreferrer"&gt;https://github.com/Thirumalaiboobathi/opentel-mcp/blob/main/CHANGELOG.md&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every AI provider returns usage in its own shape, and the edge cases people report are what shape the next release. If your tool results don't parse cleanly, drop a comment with the payload shape or open an issue — that's the fastest way to get your provider supported.&lt;/p&gt;

&lt;p&gt;I build open-source observability tooling for the MCP ecosystem. Next in this series: why CallToolResult.isError silently disappears from your traces, and what I found when I went looking for it on the Python side.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>observability</category>
      <category>aws</category>
      <category>ai</category>
    </item>
    <item>
      <title>Catching Silent Failures in MCP Servers with OpenTelemetry and SigNoz</title>
      <dc:creator>Thirumalaiboobathi B</dc:creator>
      <pubDate>Fri, 24 Jul 2026 08:16:48 +0000</pubDate>
      <link>https://dev.to/thirumalaiboobathi_b_2b20/catching-silent-failures-in-mcp-servers-with-opentelemetry-and-signoz-3hc5</link>
      <guid>https://dev.to/thirumalaiboobathi_b_2b20/catching-silent-failures-in-mcp-servers-with-opentelemetry-and-signoz-3hc5</guid>
      <description>&lt;h2&gt;
  
  
  The Problem: MCP Tools Can Lie
&lt;/h2&gt;

&lt;p&gt;The Model Context Protocol (MCP) has become the connective tissue between LLM agents and the tools they use. But MCP has a subtle observability gap that most instrumentation misses entirely.&lt;/p&gt;

&lt;p&gt;Here's the failure mode: an MCP tool call can return &lt;code&gt;isError: true&lt;/code&gt; &lt;strong&gt;inside a perfectly successful HTTP 200 / JSON-RPC response&lt;/strong&gt;. From the transport layer's point of view, nothing went wrong. The HTTP status is 200. The JSON-RPC envelope reports success. The trace span, in most instrumentations, is marked OK.&lt;/p&gt;

&lt;p&gt;But the tool itself failed. The agent got a bad answer back. And nobody knows.&lt;/p&gt;

&lt;p&gt;This is the class of bug that quietly erodes trust in AI agents: silent, non-retryable, invisible in every dashboard, discovered only when a downstream user complains.&lt;/p&gt;

&lt;p&gt;That's the problem &lt;code&gt;opentel-mcp&lt;/code&gt; was built to solve — and building the SigNoz observability story around it for this hackathon is what turned a library into a full end-to-end demonstration.&lt;/p&gt;

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

&lt;p&gt;Three things came together during the hackathon week:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;opentel-mcp&lt;/code&gt; v0.3.0&lt;/strong&gt; — an open-source npm library that adds OpenTelemetry instrumentation to MCP servers (Node.js / TypeScript), with silent-failure detection at its core.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A "MCP Tool Health" dashboard in SigNoz&lt;/strong&gt; — five Query Builder panels that surface the failures other instrumentation misses.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A real ingester bug found, reported, and worked around&lt;/strong&gt; — because you don't get real observability without a real backend.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Below is what each of those looked like in practice.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 1: The Library — &lt;code&gt;opentel-mcp&lt;/code&gt; v0.3.0
&lt;/h2&gt;

&lt;p&gt;The core detection logic is small but load-bearing. When a &lt;code&gt;CallToolResult&lt;/code&gt; comes back, the library inspects the payload itself — not just the transport response — and if &lt;code&gt;isError === true&lt;/code&gt;, the span is marked:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;status: ERROR&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;error.type: tool_error&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That single check is the differentiator. Every other Node MCP instrumentation library I benchmarked treats a 200 as a success, full stop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;v0.3.0 added metrics on top of the traces:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;mcp.tool.calls&lt;/code&gt; — total call counter&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;mcp.tool.errors&lt;/code&gt; — transport-level and JSON-RPC-level errors&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;mcp.tool.silent_failures&lt;/code&gt; — the &lt;code&gt;isError&lt;/code&gt;-in-200 case, isolated&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;mcp.tool.duration&lt;/code&gt; — histogram, tagged with &lt;code&gt;outcome=success|error|silent_failure&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Critically, both traces and metrics are driven by the &lt;strong&gt;same&lt;/strong&gt; detection logic. They can never disagree about what failed — which is a class of observability bug in its own right, and one I explicitly designed out.&lt;/p&gt;

&lt;p&gt;Attributes follow the OpenTelemetry MCP semantic conventions (&lt;code&gt;gen_ai.tool.name&lt;/code&gt;, &lt;code&gt;error.type&lt;/code&gt;), so nothing here is proprietary — it lands cleanly in any OTel backend.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A story from the v0.3.0 release that felt too fitting to leave out:&lt;/strong&gt; while verifying my own README examples against a real OTLP collector before publishing, I discovered that one example was silently sending traces to a no-op tracer. A silent failure, in the documentation, of a library built to catch silent failures. Fixed, re-verified, shipped. Every example in the README is now tested copy-paste-as-written.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 2: Wiring It Into SigNoz — and Finding a Real Bug
&lt;/h2&gt;

&lt;p&gt;The plan was simple: run SigNoz self-hosted, point &lt;code&gt;opentel-mcp&lt;/code&gt; at the OTLP receiver on &lt;code&gt;4318&lt;/code&gt;, watch traces land. In practice, &lt;code&gt;4318&lt;/code&gt; never bound.&lt;/p&gt;

&lt;p&gt;The ingester logs said "Everything is ready." &lt;code&gt;curl http://127.0.0.1:4318/v1/traces&lt;/code&gt; refused connections. Something was off.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Root cause:&lt;/strong&gt; the ingester service in the SigNoz &lt;code&gt;compose.yaml&lt;/code&gt; was starting the collector with &lt;code&gt;--manager-config=/etc/opamp-config.yaml&lt;/code&gt;, putting it under OpAMP remote management pointed at &lt;code&gt;ws://signoz-signoz-0:4320/v1/opamp&lt;/code&gt;. The OpAMP handshake was failing most of the time ("Server returned an error response"), and on the occasional successful handshake, the OpAMP-supplied default configuration was &lt;strong&gt;clobbering the local &lt;code&gt;otlp&lt;/code&gt; receiver definitions&lt;/strong&gt;. So &lt;code&gt;4317&lt;/code&gt; and &lt;code&gt;4318&lt;/code&gt; never came up, despite the reassuring startup logs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; dropped &lt;code&gt;--manager-config&lt;/code&gt; and &lt;code&gt;--copy-path&lt;/code&gt; from the ingester command so it runs off the static &lt;code&gt;ingester/ingester.yaml&lt;/code&gt;, which already correctly defines the OTLP receivers on &lt;code&gt;grpc:4317&lt;/code&gt; and &lt;code&gt;http:4318&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;After &lt;code&gt;docker compose up -d --force-recreate ingester&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Logs showed &lt;code&gt;Starting HTTP server on [::]:4318&lt;/code&gt; — no restart loop&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;curl -X POST http://127.0.0.1:4318/v1/traces&lt;/code&gt; returned &lt;code&gt;200 {"partialSuccess":{}}&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I reported this to Nagesh from the SigNoz team with diagnostics — happy to see the library's whole reason for existing (surface hidden failures) apply to its own backend on day one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 3: Verifying the Detection End-to-End
&lt;/h2&gt;

&lt;p&gt;Before touching the dashboard, I wanted proof that the silent-failure detection actually reached storage — not just stderr, not just the trace exporter, but the row that SigNoz's UI would eventually read.&lt;/p&gt;

&lt;p&gt;I ran the existing &lt;code&gt;examples/signoz-demo/server.js&lt;/code&gt; demo, which exposes two tools:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;echo&lt;/code&gt; — always succeeds&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;fetch_weather&lt;/code&gt; — always returns &lt;code&gt;isError: true&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then I queried ClickHouse directly:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;trace_id&lt;/th&gt;
&lt;th&gt;name&lt;/th&gt;
&lt;th&gt;status&lt;/th&gt;
&lt;th&gt;has_error&lt;/th&gt;
&lt;th&gt;error.type&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2780…&lt;/td&gt;
&lt;td&gt;&lt;code&gt;tools/call fetch_weather&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Error&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;true&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;tool_error&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;d4fa…&lt;/td&gt;
&lt;td&gt;&lt;code&gt;tools/call echo&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Ok&lt;/td&gt;
&lt;td&gt;false&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Exactly the outcome the library is supposed to produce: the silent failure was caught, labeled, and stored with the semantic-convention attribute set — not the successful-looking span the transport layer would have reported on its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 4: The Dashboard — MCP Tool Health
&lt;/h2&gt;

&lt;p&gt;With traces landing correctly, the dashboard came together as five panels, all built with SigNoz Query Builder (no default templates):&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Tool call volume by tool&lt;/strong&gt; — &lt;code&gt;count()&lt;/code&gt; grouped by &lt;code&gt;gen_ai.tool.name&lt;/code&gt;, filtered to &lt;code&gt;mcp.method.name = 'tools/call'&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tool call latency (p95) by tool&lt;/strong&gt; — &lt;code&gt;p95(duration_nano)&lt;/code&gt; with the same grouping&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Error rate: transport-level vs silent failures&lt;/strong&gt; — two series in one panel, one for &lt;code&gt;has_error = true AND error.type != 'tool_error'&lt;/code&gt;, one for &lt;code&gt;error.type = 'tool_error'&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Silent failures by tool&lt;/strong&gt; — dedicated panel, filtered to &lt;code&gt;error.type = 'tool_error'&lt;/code&gt; only&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Silent failure rate&lt;/strong&gt; — a stat panel with the formula &lt;code&gt;(silent_failures / total_calls) * 100&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;To ground the query schema in reality rather than guesswork, I cross-referenced it against the SigNoz source I had checked out — &lt;code&gt;pkg/types/dashboardtypes/dashboard.go&lt;/code&gt;, &lt;code&gt;pkg/telemetrytraces/const.go&lt;/code&gt;, &lt;code&gt;pkg/types/telemetrytypes/field_context.go&lt;/code&gt;, &lt;code&gt;grammar/FilterQuery.g4&lt;/code&gt;, and a real shipped fixture at &lt;code&gt;pkg/sqlmigration/088_migrate_ii_dashboards/redis/overview.json&lt;/code&gt;. This version's dashboard schema was new enough that I didn't want to trust general knowledge.&lt;/p&gt;

&lt;p&gt;I then validated the dashboard JSON against the live SigNoz API: &lt;code&gt;POST /api/v1/dashboards&lt;/code&gt; returned a clean 401 unauthenticated — not a 400 schema error. That confirmed the JSON was routing and parsing correctly, only gated on login. The final import through the UI worked on the first try, and all five panels rendered with real data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The result: a silent failure rate of ~43% under mixed traffic&lt;/strong&gt; — visible, quantified, and alertable. On any other Node MCP instrumentation, the same traffic would report ~0% errors.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Demonstrates
&lt;/h2&gt;

&lt;p&gt;Pulling the whole thing together, this project is about three ideas working in the same direction:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A missing detection&lt;/strong&gt;, plugged: MCP silent failures now show up as real errors in traces and metrics, with the semantic-convention attributes any OTel backend expects.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A real observability story on SigNoz&lt;/strong&gt;, from ingestion through storage to a Query Builder dashboard — including a real ingester bug found and reported along the way.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Traces and metrics that can't lie to each other&lt;/strong&gt;, because they're driven by the same detection logic — closing a subtle class of observability bug that shows up in most dual-signal setups.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Formalize the structural fingerprinting approach as ADR 005 in the library&lt;/li&gt;
&lt;li&gt;Publish a Grafana-compatible export of the dashboard&lt;/li&gt;
&lt;li&gt;Ship an alerting recipe for silent-failure rate thresholds in SigNoz&lt;/li&gt;
&lt;li&gt;Continue collaborating with the SigNoz team on the OpAMP fallback fix upstream&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Built by Team Agent 4318 for the Agents of SigNoz hackathon (July 20–26, 2026).&lt;/em&gt;&lt;/p&gt;

</description>
      <category>agents</category>
      <category>api</category>
      <category>llm</category>
      <category>mcp</category>
    </item>
    <item>
      <title>Everything Was Green. Nothing Was Listening. — Self-Hosting SigNoz for MCP Tool-Error Traces</title>
      <dc:creator>Thirumalaiboobathi B</dc:creator>
      <pubDate>Fri, 17 Jul 2026 13:17:34 +0000</pubDate>
      <link>https://dev.to/thirumalaiboobathi_b_2b20/everything-was-green-nothing-was-listening-self-hosting-signoz-for-mcp-tool-error-traces-148p</link>
      <guid>https://dev.to/thirumalaiboobathi_b_2b20/everything-was-green-nothing-was-listening-self-hosting-signoz-for-mcp-tool-error-traces-148p</guid>
      <description>&lt;p&gt;Everything was green. &lt;code&gt;signoz-signoz-0&lt;/code&gt; was healthy. The UI answered on &lt;code&gt;localhost:8080&lt;/code&gt; with a clean HTTP 200. The collector logs said, in so many words, &lt;em&gt;everything is ready&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Nothing was listening on 4318.&lt;/p&gt;

&lt;p&gt;This is the story of two days spent self-hosting SigNoz to validate a library I'd published a week earlier, and what I found instead. Spoiler: the library works. The traces are shaped correctly. I still don't have a screenshot of them in the SigNoz UI, and I'd rather tell you why than pretend otherwise.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing I was trying to prove
&lt;/h2&gt;

&lt;p&gt;I maintain &lt;a href="https://www.npmjs.com/package/opentel-mcp" rel="noopener noreferrer"&gt;&lt;code&gt;opentel-mcp&lt;/code&gt;&lt;/a&gt;, a one-line OpenTelemetry instrumentation library for Model Context Protocol servers. Its one genuinely uncommon behaviour: MCP's &lt;code&gt;CallToolResult&lt;/code&gt; can come back with &lt;code&gt;isError: true&lt;/code&gt; while the JSON-RPC call itself succeeds. The transport is fine. The tool failed. Most instrumentation sees a 200-equivalent and moves on.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;opentel-mcp&lt;/code&gt; catches that case and marks the span &lt;code&gt;status: ERROR&lt;/code&gt; with &lt;code&gt;error.type: tool_error&lt;/code&gt;. As far as I can tell it's the only Node library doing this, though I'd be happy to be corrected.&lt;/p&gt;

&lt;p&gt;That's the claim. A claim like that deserves a real backend behind it, not just spans printed to stderr. SigNoz is OpenTelemetry-native and self-hostable, so it seemed like the honest test: point the exporter at a real ingester, open the trace explorer, and see whether the error actually surfaces the way I say it does.&lt;/p&gt;

&lt;h2&gt;
  
  
  The demo
&lt;/h2&gt;

&lt;p&gt;Small on purpose. One &lt;code&gt;McpServer&lt;/code&gt;, two tools:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;echo&lt;/code&gt; — returns cleanly. Should produce an OK span.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;fetch_weather&lt;/code&gt; — always returns &lt;code&gt;isError: true&lt;/code&gt;. Should produce the ERROR span with &lt;code&gt;error.type: tool_error&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Instrumented in one call:&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;instrumentMcpServer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;setupNodeSdk&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;exporterUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http://localhost:4318/v1/traces&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One note that matters more than it looks: &lt;code&gt;opentel-mcp&lt;/code&gt; only ships &lt;code&gt;@opentelemetry/exporter-trace-otlp-http&lt;/code&gt;. There's no gRPC exporter. So &lt;code&gt;exporterUrl&lt;/code&gt; has to be the OTLP/HTTP URL &lt;strong&gt;with the full path&lt;/strong&gt; — &lt;code&gt;http://localhost:4318/v1/traces&lt;/code&gt; — not the bare 4317 gRPC port. Every backend guide lists both ports side by side and it's an easy thing to get wrong at 1am.&lt;/p&gt;

&lt;h2&gt;
  
  
  Self-hosting SigNoz: four things nobody told me
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. &lt;code&gt;deploy/docker/docker-compose.yaml&lt;/code&gt; doesn't exist anymore
&lt;/h3&gt;

&lt;p&gt;Most of the guides you'll find (including the one in my own head) say clone the repo, &lt;code&gt;cd deploy/docker&lt;/code&gt;, &lt;code&gt;docker compose up -d&lt;/code&gt;. That path is gone. SigNoz has moved to Foundry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://signoz.io/foundry.sh | bash
&lt;span class="c"&gt;# write a casting.yaml&lt;/span&gt;
foundryctl cast &lt;span class="nt"&gt;-f&lt;/span&gt; casting.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not worse, just different, and different in a way that silently invalidates every blog post written before it. Which is a nice bit of irony for a post like this one.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. My machine was under spec and Docker didn't say so
&lt;/h3&gt;

&lt;p&gt;7.28GB total RAM. Docker Desktop's WSL2 VM had helped itself to 3.47GB of it. SigNoz's documented minimum is 4GB.&lt;/p&gt;

&lt;p&gt;What that looks like in practice is not an error message. It looks like &lt;code&gt;signoz-signoz-0&lt;/code&gt; dying partway through a migration with &lt;code&gt;unexpected EOF&lt;/code&gt;. I bumped the VM to 5GB via &lt;code&gt;.wslconfig&lt;/code&gt; and &lt;code&gt;wsl --shutdown&lt;/code&gt;, which left Windows about 2.3GB to think about its life choices. Tight, but it got the container up.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. The crash left a lock behind, and the lock outlived the container
&lt;/h3&gt;

&lt;p&gt;Here's the fun part. That first crashed migration wrote a row into &lt;code&gt;migration_lock&lt;/code&gt;. Postgres data lives on a volume, so the lock survived every restart afterwards and blocked each new boot. The container wasn't failing for the original reason anymore — it was failing because of the corpse of the original reason.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;exec &lt;/span&gt;signoz-metastore-postgres-0 &lt;span class="se"&gt;\&lt;/span&gt;
  psql &lt;span class="nt"&gt;-U&lt;/span&gt; signoz &lt;span class="nt"&gt;-d&lt;/span&gt; signoz &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"DELETE FROM migration_lock;"&lt;/span&gt;
docker restart signoz-signoz-0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I had to do this twice. Restarting Docker Desktop doesn't clear it. The volume persists, so the lock persists.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Two self-inflicted wounds worth naming
&lt;/h3&gt;

&lt;p&gt;I ran &lt;code&gt;npm install&lt;/code&gt; from inside the example directory instead of the repo root. That gave the demo its own second copy of &lt;code&gt;@modelcontextprotocol/sdk&lt;/code&gt; and &lt;code&gt;@opentelemetry/api&lt;/code&gt;. Spans stopped printing. Not an error — just silence, which is the worst possible failure mode when you're debugging an observability tool. Classic dual-package hazard. Fixed by registering the example as a proper workspace member so everything shares one copy.&lt;/p&gt;

&lt;p&gt;Then: &lt;code&gt;BatchSpanProcessor&lt;/code&gt;'s flush timer is &lt;code&gt;unref()&lt;/code&gt;'d. For a short-lived script like this demo, the process can exit before the batched export ever fires, dropping every span silently. stderr output is unaffected because it's synchronous — so it &lt;em&gt;looks&lt;/em&gt; fine locally while nothing reaches your backend. Fixed by flushing on stdin close.&lt;/p&gt;

&lt;p&gt;Both of those are on me, not on SigNoz. Both are exactly the kind of thing that would have had me blaming the backend for an hour.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part where it works
&lt;/h2&gt;

&lt;p&gt;With all that cleared, the demo produces exactly what it should:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{
  name: 'tools/call echo',
  attributes: {
    'mcp.method.name': 'tools/call',
    'gen_ai.operation.name': 'execute_tool',
    'gen_ai.tool.name': 'echo',
    'mcp.tool.argument_count': 1,
    'jsonrpc.request.id': '1'
  },
  status: { code: 1 }
}
{
  name: 'tools/call fetch_weather',
  attributes: {
    'mcp.method.name': 'tools/call',
    'gen_ai.operation.name': 'execute_tool',
    'gen_ai.tool.name': 'fetch_weather',
    'mcp.tool.argument_count': 1,
    'jsonrpc.request.id': '2',
    'error.type': 'tool_error'
  },
  status: { code: 2 }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;status: { code: 1 }&lt;/code&gt; is OK. &lt;code&gt;status: { code: 2 }&lt;/code&gt; is ERROR, carrying &lt;code&gt;error.type: 'tool_error'&lt;/code&gt;. The tool failure got caught. The claim holds.&lt;/p&gt;

&lt;h2&gt;
  
  
  The wall
&lt;/h2&gt;

&lt;p&gt;Spans generated correctly. Exporter configured correctly. Backend healthy, UI up, collector satisfied with itself.&lt;/p&gt;

&lt;p&gt;Export times out.&lt;/p&gt;

&lt;p&gt;Before blaming anything I checked whether the port was actually open, from inside the network:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;--network&lt;/span&gt; signoz-network curlimages/curl:latest &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-X&lt;/span&gt; POST http://signoz-ingester-1:4318/v1/traces &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"resourceSpans":[]}'&lt;/span&gt;
&lt;span class="c"&gt;# HTTP 000 — connection refused&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And then, because container networking lies to you often enough to warrant it, straight from the process table inside the ingester:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;exec &lt;/span&gt;signoz-ingester-1 sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"cat /proc/net/tcp"&lt;/span&gt;
&lt;span class="c"&gt;# no listener on 4318&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No listener. Not a firewall, not a DNS thing, not my exporter. The OTLP receiver simply was never bound.&lt;/p&gt;

&lt;p&gt;The on-disk &lt;code&gt;ingester.yaml&lt;/code&gt; is correct — the receiver is right there in the config file. But the ingester runs with &lt;code&gt;--manager-config=/etc/opamp-config.yaml&lt;/code&gt;, which means it doesn't use that file as its source of truth. It fetches its &lt;em&gt;effective&lt;/em&gt; config remotely via OpAMP from the &lt;code&gt;signoz&lt;/code&gt; service. That handshake kept erroring, and on eventually connecting it appears to apply a fallback default config with no &lt;code&gt;otlp&lt;/code&gt; receiver in it.&lt;/p&gt;

&lt;p&gt;So the failure mode is: correct config on disk, ignored; broken config delivered over the wire; every health check green; nothing listening. Observability platform, unobservable.&lt;/p&gt;

&lt;p&gt;My leading hypothesis is version skew between two independently-tagged &lt;code&gt;:latest&lt;/code&gt; images — the ingester and the OpAMP server disagreeing about config schema, with the ingester falling back rather than failing loudly. I want to be clear about my confidence here: I'm certain about the symptom, and I've verified it three ways. The root cause is my best reading of the evidence, not a confirmed diagnosis. If someone from SigNoz reads this and tells me I cast a bad &lt;code&gt;casting.yaml&lt;/code&gt;, that's a completely plausible outcome and I'd like to know.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I shipped anyway
&lt;/h2&gt;

&lt;p&gt;When the flush hit an unreachable ingester, my demo died with an uncaught exception and a stack trace out of &lt;code&gt;http-transport-utils.js&lt;/code&gt;. Ugly, and it points the finger at the wrong place — someone hitting that would reasonably assume the library broke.&lt;/p&gt;

&lt;p&gt;So I wrapped the shutdown path. Now it exits 0 with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;opentel-mcp: failed to flush spans to SigNoz: socket hang up
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One line. Correct blame. A backend being unreachable is a normal condition, not a crash, and a library that instruments error handling should probably model decent error handling.&lt;/p&gt;

&lt;p&gt;That fix is the one lasting artifact of two days of infrastructure debugging, and honestly it's a better outcome than the screenshot I came for.&lt;/p&gt;

&lt;h2&gt;
  
  
  The feature I liked most, which I never got to use
&lt;/h2&gt;

&lt;p&gt;An awkward heading for a post that ends without a trace explorer screenshot. But my honest answer is the thing that caused the failure: &lt;strong&gt;OpAMP-managed remote configuration&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Think about what it's for. Fleets of collectors, centrally configured, no redeploy to change what you're collecting. That's a genuinely good idea and it's why the on-disk config was being ignored — the system was working exactly as designed, just with the wrong config on the other end of the wire. Static-config observability stacks don't have this failure mode, but they also don't let you retune a hundred collectors from one place.&lt;/p&gt;

&lt;p&gt;The lesson I'm taking is narrower and more useful than "SigNoz is broken," because I don't think it is: &lt;strong&gt;when your config is delivered rather than mounted, "the config file is correct" stops being evidence of anything.&lt;/strong&gt; Check the listener, not the YAML.&lt;/p&gt;

&lt;h2&gt;
  
  
  Repro, if you want to try
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://signoz.io/foundry.sh | bash
foundryctl cast &lt;span class="nt"&gt;-f&lt;/span&gt; casting.yaml

&lt;span class="c"&gt;# if signoz-signoz-0 crash-loops:&lt;/span&gt;
docker &lt;span class="nb"&gt;exec &lt;/span&gt;signoz-metastore-postgres-0 &lt;span class="se"&gt;\&lt;/span&gt;
  psql &lt;span class="nt"&gt;-U&lt;/span&gt; signoz &lt;span class="nt"&gt;-d&lt;/span&gt; signoz &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"DELETE FROM migration_lock;"&lt;/span&gt;
docker restart signoz-signoz-0

&lt;span class="c"&gt;# from opentel-mcp repo root, NOT the example dir:&lt;/span&gt;
npm &lt;span class="nb"&gt;install
cd &lt;/span&gt;examples/signoz-demo &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'...'&lt;/span&gt; | node server.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And before you trust the trace explorer's emptiness to mean anything about your own code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;exec &lt;/span&gt;signoz-ingester-1 sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"cat /proc/net/tcp"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Full friction notes are in &lt;a href="https://github.com/Thirumalaiboobathi/opentel-mcp/blob/main/examples/signoz-demo/README.md" rel="noopener noreferrer"&gt;&lt;code&gt;examples/signoz-demo/README.md&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;If you've self-hosted SigNoz via Foundry recently and your ingester &lt;em&gt;did&lt;/em&gt; bind its OTLP receiver, I'd genuinely like to hear what your &lt;code&gt;casting.yaml&lt;/code&gt; looked like — there's a decent chance the answer is embarrassing and mine.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;opentel-mcp&lt;/code&gt; is on &lt;a href="https://www.npmjs.com/package/opentel-mcp" rel="noopener noreferrer"&gt;npm&lt;/a&gt; and &lt;a href="https://github.com/Thirumalaiboobathi/opentel-mcp" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>opentelemetry</category>
      <category>observability</category>
      <category>node</category>
      <category>devops</category>
    </item>
    <item>
      <title>Building AgriNova AI Assistant – My First Step into GenAI + Cloud</title>
      <dc:creator>Thirumalaiboobathi B</dc:creator>
      <pubDate>Thu, 28 May 2026 04:57:19 +0000</pubDate>
      <link>https://dev.to/thirumalaiboobathi_b_2b20/building-agrinova-ai-assistant-my-first-step-into-genai-cloud-2a1e</link>
      <guid>https://dev.to/thirumalaiboobathi_b_2b20/building-agrinova-ai-assistant-my-first-step-into-genai-cloud-2a1e</guid>
      <description>&lt;p&gt;Recently, I built AgriNova AI Assistant🌾🤖 — an AI-powered agriculture assistant designed to support farmers and agriculture-related guidance using Generative AI.&lt;/p&gt;

&lt;p&gt;This project became a major learning milestone for me because it combined:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;☁️ Cloud&lt;/li&gt;
&lt;li&gt;🤖 Generative AI&lt;/li&gt;
&lt;li&gt;⚙️ DevOps concepts&lt;/li&gt;
&lt;li&gt;🧠 AI agents&lt;/li&gt;
&lt;li&gt;🌐 Full Stack integration&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;💡 What AgriNova AI Can Do&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Provide agriculture-related guidance&lt;/li&gt;
&lt;li&gt;Answer farming queries using AI&lt;/li&gt;
&lt;li&gt;Generate smart responses using LLMs&lt;/li&gt;
&lt;li&gt;Work as an AI assistant/chat system&lt;/li&gt;
&lt;li&gt;Explore privacy-focused AI workflows&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;📚 What I Learned&lt;/p&gt;

&lt;p&gt;This project helped me understand:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;How AI assistants actually work&lt;/li&gt;
&lt;li&gt;Basics of prompt engineering&lt;/li&gt;
&lt;li&gt;Integrating AI models into applications&lt;/li&gt;
&lt;li&gt;Running local LLMs with Ollama&lt;/li&gt;
&lt;li&gt;Connecting cloud + AI workflows&lt;/li&gt;
&lt;li&gt;Importance of scalable architecture&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Biggest Takeaway&lt;/p&gt;

&lt;p&gt;Earlier, I used to spend more time only consuming content and scrolling.&lt;/p&gt;

&lt;p&gt;Now I’m trying to build consistently, learn publicly, and improve through real projects.&lt;/p&gt;

&lt;p&gt;This is just the beginning of my AI + Cloud journey ✨&lt;/p&gt;

&lt;p&gt;Big thanks to the AWS and developer communities that motivated me to start building and sharing publicly 🙌&lt;/p&gt;

</description>
      <category>ai</category>
      <category>aws</category>
      <category>genai</category>
      <category>awsnova</category>
    </item>
  </channel>
</rss>
