<?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: Tarun Prajapati</title>
    <description>The latest articles on DEV Community by Tarun Prajapati (@tarun_prajapati_b2b3f4b39).</description>
    <link>https://dev.to/tarun_prajapati_b2b3f4b39</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%2F3397249%2F614ccbbc-a321-4956-b0d7-d74b8528d2d4.png</url>
      <title>DEV Community: Tarun Prajapati</title>
      <link>https://dev.to/tarun_prajapati_b2b3f4b39</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/tarun_prajapati_b2b3f4b39"/>
    <language>en</language>
    <item>
      <title>I Couldn't See What My AI App Was Doing — So I Added OpenTelemetry and SigNoz in One Afternoon</title>
      <dc:creator>Tarun Prajapati</dc:creator>
      <pubDate>Sat, 18 Jul 2026 16:42:25 +0000</pubDate>
      <link>https://dev.to/tarun_prajapati_b2b3f4b39/i-couldnt-see-what-my-ai-app-was-doing-so-i-added-opentelemetry-and-signoz-in-one-afternoon-3ihc</link>
      <guid>https://dev.to/tarun_prajapati_b2b3f4b39/i-couldnt-see-what-my-ai-app-was-doing-so-i-added-opentelemetry-and-signoz-in-one-afternoon-3ihc</guid>
      <description>&lt;p&gt;Nebva is an AI content-generation SaaS I'm building on Node.js, TypeScript, MongoDB, and Redis, with Gemini doing the actual generation work. Until recently, the only telemetry it had was a homemade buffer that wrote events into MongoDB and hoped I'd go query them later. When a request was slow, or a generation got blocked, I had no real way to find out why — just logs and guesses. Here's what changed in one afternoon.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before You Start
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Node.js/Express backend (swap for your own stack)&lt;/li&gt;
&lt;li&gt;MongoDB + Redis running&lt;/li&gt;
&lt;li&gt;A Gemini API key (or whichever LLM you're calling)&lt;/li&gt;
&lt;li&gt;SigNoz running locally (Docker Compose or Foundry)&lt;/li&gt;
&lt;li&gt;No prior OpenTelemetry experience required&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I Was Flying Blind On
&lt;/h2&gt;

&lt;p&gt;Nebva's only telemetry used to be &lt;code&gt;TelemetryProxyProvider.ts&lt;/code&gt; — a manual buffer that wrote events straight into MongoDB. It worked, in the sense that events got written. What it couldn't do was connect anything: an LLM call, the storage write that followed it, and the notification that went out after — three separate entries with no shared ID between them. If a request felt slow, I had no way to tell whether the delay was Gemini, MongoDB, or my own code. There was no dashboard and no alerting, just documents sitting in a collection I'd query by hand when something broke.&lt;/p&gt;

&lt;p&gt;A single content-generation request in Nebva actually moves through several distinct stages — pulling signal, generating an embedding, calling Gemini for the actual content, writing the result to storage. None of that structure was visible anywhere. It was just "the request," one opaque block of time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The One File That Changed Everything
&lt;/h2&gt;

&lt;p&gt;Auto-instrumentation is the fastest win in the whole migration, and it lives entirely in one file: &lt;code&gt;instrumentation.ts&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// instrumentation.ts — REPLACE the body below with your actual file&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;NodeSDK&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="s1"&gt;@opentelemetry/sdk-node&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;getNodeAutoInstrumentations&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="s1"&gt;@opentelemetry/auto-instrumentations-node&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;OTLPTraceExporter&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="s1"&gt;@opentelemetry/exporter-trace-otlp-http&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;Resource&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="s1"&gt;@opentelemetry/resources&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;SemanticResourceAttributes&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="s1"&gt;@opentelemetry/semantic-conventions&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;sdk&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;NodeSDK&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;resource&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;Resource&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;SemanticResourceAttributes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SERVICE_NAME&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;nebva-backend&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="na"&gt;traceExporter&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;OTLPTraceExporter&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;url&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;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="na"&gt;instrumentations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;getNodeAutoInstrumentations&lt;/span&gt;&lt;span class="p"&gt;()],&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;sdk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;⚠️ This file has to load before anything else in your app — before your routes, before your business logic. OpenTelemetry's own docs are explicit about this: import it after your other modules and the instrumentations silently do nothing. &lt;em&gt;[FILL IN: if you actually hit this, describe what you saw instead of this line]&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's it for infrastructure. Every Express route, every Mongoose query, every outgoing HTTP call, and every Redis operation now shows up as a span, without touching a single line of business code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping My AI Provider Without Touching It
&lt;/h2&gt;

&lt;p&gt;Auto-instrumentation covers infrastructure. It has no idea what "generating a blog post" means, or that a guardrail just blocked an output — those are Nebva-specific concepts, and there's no way for a generic instrumentation library to invent attributes for things it doesn't know about.&lt;/p&gt;

&lt;p&gt;That's what &lt;code&gt;TelemetryProxyProvider&lt;/code&gt; is for. It implements the same &lt;code&gt;IAIProvider&lt;/code&gt; interface every other part of the codebase already calls, so nothing upstream had to change. Underneath, every call gets wrapped in a span tagged with OpenTelemetry's GenAI attributes — &lt;code&gt;gen_ai.usage.total_tokens&lt;/code&gt;, &lt;code&gt;gen_ai.usage.output_tokens&lt;/code&gt;, model name, and so on.&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;// TelemetryProxyProvider.ts — REPLACE with your actual implementation&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TelemetryProxyProvider&lt;/span&gt; &lt;span class="k"&gt;implements&lt;/span&gt; &lt;span class="nx"&gt;IAIProvider&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;inner&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;IAIProvider&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;GenerationInput&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;tracer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startActiveSpan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gen_ai.generate&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;span&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;span&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gen_ai.request.model&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;model&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;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;inner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&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="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gen_ai.usage.total_tokens&lt;/span&gt;&lt;span class="dl"&gt;'&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="nx"&gt;totalTokens&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="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gen_ai.usage.output_tokens&lt;/span&gt;&lt;span class="dl"&gt;'&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="nx"&gt;outputTokens&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&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="nf"&gt;end&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 part I actually think is worth stealing: I used the same pattern for Nebva's content-safety checks. Every time a guardrail blocks a generation — a soft refusal, a hate-speech match, whatever the reason — that's now also a span attribute: &lt;code&gt;gen_ai.guardrail.triggered&lt;/code&gt; and &lt;code&gt;gen_ai.guardrail.reason&lt;/code&gt;, tagged against the user who triggered it. It didn't need a separate system. Same Decorator, same spans, one more attribute.&lt;/p&gt;

&lt;h2&gt;
  
  
  What It Actually Looks Like in SigNoz
&lt;/h2&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%2Ft2kk96tvv03g05b2qly9.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%2Ft2kk96tvv03g05b2qly9.png" alt=" " width="799" height="478"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Once real traffic runs through it, the Services page breaks the whole pipeline into stages, each with its own P50/P95/P99:&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%2Fy949ux1yxw17cupqzsd1.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%2Fy949ux1yxw17cupqzsd1.png" alt=" " width="799" height="445"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I'd assumed the pipeline was roughly evenly slow across the board. It isn't — image generation alone is the outlier, and errors cluster entirely on the write path.&lt;/p&gt;

&lt;p&gt;The guardrail attributes turned into a dashboard with no extra plumbing — same span data, a different query:&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%2Fc4vwj0z0i4a4j6rz7lbw.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%2Fc4vwj0z0i4a4j6rz7lbw.png" alt=" " width="800" height="755"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Two Things I'd Tell My Past Self
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Auto-instrumentation gets you infrastructure for free. It will never know your business.&lt;/strong&gt; Express, Mongoose, HTTP, Redis — all free. "This generation got blocked for a safety reason" is not a concept OpenTelemetry has ever heard of, and it's worth tagging that from day one instead of bolting it on later.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Watch your async queues. Nebva uses BullMQ for background jobs. By default, BullMQ jobs show up as completely disconnected traces in SigNoz. Auto-instrumentation doesn't automatically propagate the OTel context across the Redis queue boundary unless you explicitly inject the trace context into the job payload and extract it on the worker side. If you don't do this, your trace waterfall breaks the moment a job hits the queue.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;One file for infrastructure, one Decorator for business logic, and a MongoDB buffer I could finally delete. If you're running any LLM behind an API and flying blind on what it's actually doing, this is the smallest version of the fix. &lt;/p&gt;

&lt;p&gt;If I had more time, the next things I'd add: alerting on guardrail-trigger spikes per user, a cost dashboard broken out by generation type instead of just totals, and tracing through the queue side of the pipeline to see whether async jobs stay connected to the request that started them.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>opentelemenetry</category>
      <category>signoz</category>
      <category>node</category>
    </item>
  </channel>
</rss>
