<?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: Hunter</title>
    <description>The latest articles on DEV Community by Hunter (@hunter-debug00).</description>
    <link>https://dev.to/hunter-debug00</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%2F4209353%2F9b2d584d-0d40-4d16-9b3f-dacbb1949c72.png</url>
      <title>DEV Community: Hunter</title>
      <link>https://dev.to/hunter-debug00</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hunter-debug00"/>
    <language>en</language>
    <item>
      <title>Diagnosing BullMQ Jobs with OpenTelemetry and SigNoz</title>
      <dc:creator>Hunter</dc:creator>
      <pubDate>Tue, 27 Oct 2026 09:39:50 +0000</pubDate>
      <link>https://dev.to/hunter-debug00/diagnosing-bullmq-jobs-with-opentelemetry-and-signoz-3gma</link>
      <guid>https://dev.to/hunter-debug00/diagnosing-bullmq-jobs-with-opentelemetry-and-signoz-3gma</guid>
      <description>&lt;p&gt;Imagine a user clicks "Generate Invoice" in your application. Your&lt;br&gt;
Express API validates the request, pushes a job into Redis, and responds&lt;br&gt;
in under 10 milliseconds with &lt;code&gt;202 Accepted&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In reality, the user waits two full seconds for the invoice to finish,&lt;br&gt;
or longer during traffic spikes. When users report the delay, you check&lt;br&gt;
your observability dashboard expecting to find a slow database query or&lt;br&gt;
a bogged-down worker. Instead, every chart looks healthy. The API&lt;br&gt;
responds in a few milliseconds, and the worker reports that every job&lt;br&gt;
finishes in 100 milliseconds.&lt;/p&gt;

&lt;p&gt;If the API takes 6 milliseconds and the worker takes 100 milliseconds,&lt;br&gt;
where did the other 1,900 milliseconds go?&lt;/p&gt;

&lt;p&gt;Most of that time passed before the worker ever started the job. When a&lt;br&gt;
burst of jobs arrives and worker concurrency is saturated, jobs sit in&lt;br&gt;
Redis waiting for an available worker slot. Retries and exponential&lt;br&gt;
backoff delays add another layer of waiting. Standard job duration&lt;br&gt;
metrics only start measuring when a worker picks up a job, which leaves&lt;br&gt;
pre-execution waiting invisible.&lt;/p&gt;

&lt;p&gt;In this guide, we will instrument a Node.js and TypeScript BullMQ&lt;br&gt;
pipeline with OpenTelemetry and SigNoz. We will inspect what generic&lt;br&gt;
Node.js auto-instrumentation misses, enable BullMQ's &lt;code&gt;bullmq-otel&lt;/code&gt;&lt;br&gt;
integration to trace retries across services, build a bounded&lt;br&gt;
&lt;code&gt;job_age_at_attempt_ms&lt;/code&gt; histogram alongside a&lt;br&gt;
&lt;code&gt;bullmq.job.age_at_attempt_ms&lt;/code&gt; span attribute, and run a 10-trial&lt;br&gt;
concurrency benchmark to separate slow worker code from queue waiting&lt;br&gt;
time.&lt;/p&gt;

&lt;p&gt;The complete source code, 50-test suite, OTLP export smoke test, and&lt;br&gt;
benchmark CSV artifacts are available in the &lt;a href="https://github.com/Hunter-debug00/otel-bullmq-signoz-tracing" rel="noopener noreferrer"&gt;GitHub&lt;br&gt;
repository&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  In this article
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;What we are measuring&lt;/li&gt;
&lt;li&gt;Architecture and prerequisites&lt;/li&gt;
&lt;li&gt;Implementation&lt;/li&gt;
&lt;li&gt;Diagnostic scenarios and traces&lt;/li&gt;
&lt;li&gt;Metrics and backlog analysis&lt;/li&gt;
&lt;li&gt;Concurrency benchmark&lt;/li&gt;
&lt;li&gt;Operational limits&lt;/li&gt;
&lt;li&gt;References&lt;/li&gt;
&lt;/ul&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/Hunter-debug00" rel="noopener noreferrer"&gt;
        Hunter-debug00
      &lt;/a&gt; / &lt;a href="https://github.com/Hunter-debug00/otel-bullmq-signoz-tracing" rel="noopener noreferrer"&gt;
        otel-bullmq-signoz-tracing
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Why a 100 ms BullMQ job still feels slow: measure enqueue-to-attempt age vs. worker runtime using OpenTelemetry, distributed traces, histograms, and a reproducible concurrency benchmark.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;BullMQ Distributed Tracing &amp;amp; Job Age Telemetry with SigNoz&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;In asynchronous queues, a worker pool can appear healthy while jobs sit waiting in Redis for seconds. The standard &lt;code&gt;bullmq.job.duration&lt;/code&gt; metric only starts when a worker begins executing the job handler, so it completely misses queue waiting time.&lt;/p&gt;
&lt;p&gt;This repository demonstrates how to instrument BullMQ with OpenTelemetry and export signals to SigNoz over OTLP/HTTP. It adds a custom histogram, &lt;code&gt;job_age_at_attempt_ms&lt;/code&gt;, to measure the elapsed time from job enqueue to the start of each attempt. Two services (&lt;code&gt;order-api&lt;/code&gt; and &lt;code&gt;order-worker&lt;/code&gt;) propagate distributed W3C trace contexts so producer HTTP requests and worker job executions appear in a single trace waterfall.&lt;/p&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;What the Benchmark Shows&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;The included experiment runs 20 jobs of 100 ms simulated work across two conditions: baseline concurrency ($c=5$) and constrained concurrency ($c=1$). Each condition ran 5 trials on a 2-CPU Linux host using…&lt;/p&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/Hunter-debug00/otel-bullmq-signoz-tracing" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;h2&gt;
  
  
  What we are measuring (and why these tools fit together)
&lt;/h2&gt;

&lt;p&gt;To separate slow worker execution from pre-execution waiting, we need&lt;br&gt;
two distinct measurements:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Worker execution duration (&lt;code&gt;bullmq.job.duration&lt;/code&gt;):&lt;/strong&gt; how long the
worker handler spends executing an attempt once picked up.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Enqueue-to-attempt age (&lt;code&gt;job_age_at_attempt_ms&lt;/code&gt;):&lt;/strong&gt; elapsed
wall-clock time from when the job was originally created
(&lt;code&gt;job.timestamp&lt;/code&gt;) until the current processing attempt starts
(&lt;code&gt;job.processedOn&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a first attempt on a job that is eligible to run immediately,&lt;br&gt;
enqueue-to-attempt age reflects initial queue wait time. It is not pure&lt;br&gt;
Redis queue wait in every scenario. If a job is scheduled with an&lt;br&gt;
intentional delay, that delay is part of the elapsed time. If a job is&lt;br&gt;
on a retry attempt, its age measures cumulative elapsed time since&lt;br&gt;
initial creation, including earlier attempts, prior queue waits, and&lt;br&gt;
retry backoff.&lt;/p&gt;

&lt;p&gt;Three open-source tools form our diagnostic stack:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;BullMQ:&lt;/strong&gt; offloads background work such as PDF generation or
webhook delivery into Redis with built-in retries, exponential
backoff, and concurrency controls.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;OpenTelemetry:&lt;/strong&gt; provides a vendor-neutral SDK and protocol (OTLP)
so traces and histograms are exported from one standard
instrumentation layer.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;SigNoz:&lt;/strong&gt; ingests OTLP traces and histograms into a unified
backend, letting us correlate histogram quantiles with distributed
trace waterfalls in the same interface.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Architecture and runtime specifications
&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%2Fttmfe4876u1ur90wpa67.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%2Fttmfe4876u1ur90wpa67.png" alt="architecture diagram showing the Express API, Redis, BullMQ workers, and SigNoz OTLP exports" width="800" height="173"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;small&gt;&lt;em&gt;Figure 1. The Express API enqueues jobs in Redis, BullMQ workers process them, and OpenTelemetry exports traces and metrics to SigNoz over OTLP/HTTP.&lt;/em&gt;&lt;/small&gt;&lt;/p&gt;

&lt;p&gt;The application is intentionally compact so the measurement problem is&lt;br&gt;
easy to reproduce. It runs four components:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;&lt;code&gt;order-api&lt;/code&gt; (&lt;code&gt;src/app.ts&lt;/code&gt;, &lt;code&gt;src/server.ts&lt;/code&gt;):&lt;/strong&gt; validates
&lt;code&gt;POST /jobs&lt;/code&gt; requests, enqueues job batches atomically via
&lt;code&gt;addBulk&lt;/code&gt;, and polls queue state counts every five seconds.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Redis (&lt;code&gt;redis:7.4-alpine&lt;/code&gt;):&lt;/strong&gt; stores the BullMQ queue and
serialized W3C trace context.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;&lt;code&gt;order-worker&lt;/code&gt; (&lt;code&gt;src/worker.ts&lt;/code&gt;, &lt;code&gt;src/jobs/processor.ts&lt;/code&gt;):&lt;/strong&gt;
processes jobs across four core scenarios: &lt;code&gt;success&lt;/code&gt;, &lt;code&gt;slow-worker&lt;/code&gt;,
&lt;code&gt;transient-retry&lt;/code&gt;, and &lt;code&gt;exhausted-failure&lt;/code&gt;. The scenario runner also
dispatches a 20-job backlog burst.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;SigNoz:&lt;/strong&gt; receives OTLP/HTTP traces at &lt;code&gt;/v1/traces&lt;/code&gt; and metrics at
&lt;code&gt;/v1/metrics&lt;/code&gt; on port &lt;code&gt;4318&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  Tested environment and prerequisites
&lt;/h3&gt;

&lt;p&gt;The benchmark data and telemetry captures should be interpreted&lt;br&gt;
alongside the locked dependency versions in&lt;br&gt;
&lt;a href="https://github.com/Hunter-debug00/otel-bullmq-signoz-tracing/blob/main/results/sample-manifest.json" rel="noopener noreferrer"&gt;&lt;code&gt;results/sample-manifest.json&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Tested version&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Node.js&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;24.21.0&lt;/code&gt; (&lt;code&gt;.nvmrc&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Benchmark tested on a 2-vCPU Linux environment with 8 GB RAM.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BullMQ and Redis client&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;bullmq 6.3.15&lt;/code&gt;, &lt;code&gt;bullmq-otel 2.0.1&lt;/code&gt;, &lt;code&gt;ioredis 6.0.0&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Installed reproducibly with &lt;code&gt;npm ci&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenTelemetry SDK&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@opentelemetry/sdk-node 0.223.0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Includes &lt;code&gt;@opentelemetry/resources 2.12.0&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Redis container&lt;/td&gt;
&lt;td&gt;&lt;code&gt;redis:7.4-alpine&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Started with Docker Compose on port &lt;code&gt;6379&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SigNoz backend&lt;/td&gt;
&lt;td&gt;SigNoz Cloud or local SigNoz&lt;/td&gt;
&lt;td&gt;A self-hosted installation needs sufficient Docker memory; use SigNoz Cloud on smaller environments.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;h2&gt;
  
  
  Implementation
&lt;/h2&gt;
&lt;h3&gt;
  
  
  Step 1: Clone the repository and start the services
&lt;/h3&gt;

&lt;p&gt;Clone the repository and move into the project directory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/Hunter-debug00/otel-bullmq-signoz-tracing.git
&lt;span class="nb"&gt;cd &lt;/span&gt;otel-bullmq-signoz-tracing
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Select Node.js &lt;code&gt;24.21.0&lt;/code&gt; and install the locked dependencies from&lt;br&gt;
&lt;code&gt;package-lock.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nvm use
npm ci
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Copy the environment template and start Redis:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cp&lt;/span&gt; .env.example .env
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt; redis
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; &lt;code&gt;docker compose up -d redis&lt;/code&gt; starts Redis only; it does not&lt;br&gt;
deploy a full local SigNoz stack. Run a separate &lt;a href="https://signoz.io/docs/install/docker/" rel="noopener noreferrer"&gt;local SigNoz&lt;br&gt;
installation&lt;/a&gt; or configure&lt;br&gt;
SigNoz Cloud.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Open &lt;code&gt;.env&lt;/code&gt; to verify the OTLP endpoint settings. By default, it points&lt;br&gt;
to a local SigNoz receiver on port &lt;code&gt;4318&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PORT=3000
HOST=127.0.0.1
REDIS_URL=redis://localhost:6379
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
OTEL_EXPORTER_OTLP_HEADERS=
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The application's exporters append &lt;code&gt;/v1/traces&lt;/code&gt; and &lt;code&gt;/v1/metrics&lt;/code&gt; to&lt;br&gt;
&lt;code&gt;OTEL_EXPORTER_OTLP_ENDPOINT&lt;/code&gt;, so do not append &lt;code&gt;/v1/traces&lt;/code&gt; in &lt;code&gt;.env&lt;/code&gt;.&lt;br&gt;
For SigNoz Cloud, use your regional ingestion URL and the&lt;br&gt;
&lt;code&gt;signoz-ingestion-key&lt;/code&gt; header (see the &lt;a href="https://signoz.io/docs/ingestion/signoz-cloud/overview/" rel="noopener noreferrer"&gt;SigNoz Cloud ingestion&lt;br&gt;
guide&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;OTEL_EXPORTER_OTLP_ENDPOINT=https://ingest.&amp;lt;region&amp;gt;.signoz.cloud:443
OTEL_EXPORTER_OTLP_HEADERS=signoz-ingestion-key=&amp;lt;your-signoz-ingestion-key&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep the real SigNoz ingestion key in your local &lt;code&gt;.env&lt;/code&gt; file; do not commit it or include it in screenshots.&lt;/p&gt;

&lt;p&gt;Start the API in one terminal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm run start:api
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Start the worker in a second terminal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm run start:worker
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The npm scripts set &lt;code&gt;OTEL_SERVICE_NAME&lt;/code&gt; (&lt;code&gt;order-api&lt;/code&gt; and &lt;code&gt;order-worker&lt;/code&gt;)&lt;br&gt;
and configure worker concurrency. The explicit script setting takes&lt;br&gt;
precedence over &lt;code&gt;.env&lt;/code&gt; where applicable.&lt;/p&gt;
&lt;h3&gt;
  
  
  Step 2: The trap of relying on generic auto-instrumentation alone
&lt;/h3&gt;

&lt;p&gt;A common mistake when adding OpenTelemetry to a Node.js service is&lt;br&gt;
enabling only &lt;code&gt;@opentelemetry/auto-instrumentations-node&lt;/code&gt;. The&lt;br&gt;
historical baseline captures below were recorded before &lt;code&gt;bullmq-otel&lt;/code&gt;&lt;br&gt;
and job-level metrics were wired into the worker.&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%2Fwz57lhbqg1dw5gvwmblb.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%2Fwz57lhbqg1dw5gvwmblb.png" alt="baseline SigNoz service table showing low-level worker operations before BullMQ job instrumentation" width="800" height="427"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;small&gt;&lt;em&gt;Figure 2. In the baseline run before job-level instrumentation, the service table reports fast low-level operations (34.65 ms P99 on &lt;code&gt;order-worker&lt;/code&gt;) and misses the BullMQ job lifecycle.&lt;/em&gt;&lt;/small&gt;&lt;/p&gt;

&lt;p&gt;Opening a worker trace from that baseline run shows what generic&lt;br&gt;
auto-instrumentation actually captures:&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%2Fg3fv4iy6yquk56wf7f3c.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%2Fg3fv4iy6yquk56wf7f3c.png" alt="baseline worker trace showing Redis evalsha and moveStalledJobsToWait spans without a BullMQ job-attempt span" width="800" height="428"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;small&gt;&lt;em&gt;Figure 3. Baseline trace showing a 1.21 ms Redis &lt;code&gt;evalsha&lt;/code&gt; span and &lt;code&gt;moveStalledJobsToWait&lt;/code&gt; spans, with no parent HTTP link or job-attempt span.&lt;/em&gt;&lt;/small&gt;&lt;/p&gt;

&lt;p&gt;Generic auto-instrumentation wraps low-level HTTP, Express, and ioredis&lt;br&gt;
calls. It sees the fast Redis Lua scripts that poll the queue, but it&lt;br&gt;
has no concept of BullMQ job attempts, retry backoffs, or enqueue&lt;br&gt;
timestamps.&lt;/p&gt;
&lt;h3&gt;
  
  
  Step 3: Initialize OpenTelemetry and enable &lt;code&gt;bullmq-otel&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;To keep unit tests fast and free of import-time side effects, the&lt;br&gt;
repository separates telemetry SDK construction from process startup:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;code&gt;src/instrumentation.ts&lt;/code&gt; constructs the OpenTelemetry &lt;code&gt;NodeSDK&lt;/code&gt; and
OTLP HTTP exporters without starting them on import.&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;src/bootstrap.ts&lt;/code&gt; preloads telemetry through
&lt;code&gt;--import ./src/bootstrap.ts&lt;/code&gt; before application modules load.&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;src/app.ts&lt;/code&gt; defines the Express application, payload validation,
and error classification so route tests can import &lt;code&gt;createApp()&lt;/code&gt;
without binding a network port.&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;src/server.ts&lt;/code&gt; starts the HTTP server, polls
&lt;code&gt;recordJobCountsMetric()&lt;/code&gt;, and handles graceful shutdown.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The SDK factory in &lt;code&gt;src/instrumentation.ts&lt;/code&gt; normalizes the OTLP base URL&lt;br&gt;
and wires both exporters. The following excerpt reflects the current&lt;br&gt;
repository's service-name fallback and exporter configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;serviceName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;serviceName&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;OTEL_SERVICE_NAME&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bullmq-diagnostics&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;baseUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;normalizeOtlpBaseUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;otlpEndpoint&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;OTEL_EXPORTER_OTLP_ENDPOINT&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;headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;otlpHeaders&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nf"&gt;parseOtlpHeaders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;OTEL_EXPORTER_OTLP_HEADERS&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;timeoutMillis&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;timeoutMillis&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;OTEL_EXPORTER_OTLP_TIMEOUT&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;OTEL_EXPORTER_OTLP_TIMEOUT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5000&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;traceExporter&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;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="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;baseUrl&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/v1/traces`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;timeoutMillis&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;metricExporter&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;OTLPMetricExporter&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="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;baseUrl&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/v1/metrics`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;timeoutMillis&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;One configuration detail in &lt;code&gt;createTelemetrySdk&lt;/code&gt; avoids a subtle trap.&lt;br&gt;
In &lt;code&gt;@opentelemetry/sdk-node 0.223.0&lt;/code&gt;, supplying a &lt;code&gt;spanProcessors&lt;/code&gt; array&lt;br&gt;
takes precedence over the top-level &lt;code&gt;traceExporter&lt;/code&gt; option. If you&lt;br&gt;
configure an &lt;code&gt;InMemorySpanExporter&lt;/code&gt; in &lt;code&gt;spanProcessors&lt;/code&gt; and leave it in&lt;br&gt;
the production configuration, metrics can still reach SigNoz through&lt;br&gt;
&lt;code&gt;metricReader&lt;/code&gt; while traces are captured only in memory. Production&lt;br&gt;
startup omits this test-only override; &lt;code&gt;tests/otlp-export.smoke.test.ts&lt;/code&gt;&lt;br&gt;
verifies real OTLP delivery to an ephemeral HTTP server.&lt;/p&gt;

&lt;p&gt;The queue module wires &lt;code&gt;BullMQOtel&lt;/code&gt; into both the producer queue and the&lt;br&gt;
worker. When &lt;code&gt;order-api&lt;/code&gt; enqueues jobs with &lt;code&gt;addBulk()&lt;/code&gt;, &lt;code&gt;bullmq-otel&lt;/code&gt;&lt;br&gt;
injects the active W3C trace context into job metadata in Redis. When&lt;br&gt;
&lt;code&gt;order-worker&lt;/code&gt; processes the job, it extracts that context so worker&lt;br&gt;
attempts appear in the originating &lt;code&gt;POST /jobs&lt;/code&gt; trace.&lt;/p&gt;
&lt;h3&gt;
  
  
  Step 4: Run the diagnostic scenarios and inspect the traces
&lt;/h3&gt;

&lt;p&gt;The scenario runner sends four core scenarios, waits for retry and&lt;br&gt;
slow-worker work to finish, and then dispatches a backlog burst:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Behavior&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;success&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Standard 50 ms job completing on attempt 1 in the scenario runner.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;slow-worker&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Simulated handler work of approximately 2,000 ms.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;transient-retry&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Fails on attempt 1, waits through exponential backoff (1,500 ms base delay), and succeeds on attempt 2.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;exhausted-failure&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Fails all three configured attempts across exponential backoff delays.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backlog burst&lt;/td&gt;
&lt;td&gt;Enqueues 20 jobs with 100 ms of simulated work each to expose queue backlog.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;With &lt;code&gt;order-api&lt;/code&gt; and &lt;code&gt;order-worker&lt;/code&gt; running, trigger the scenarios from&lt;br&gt;
a third terminal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm run scenarios
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  1. What a slow worker looks like (&lt;code&gt;slow-worker&lt;/code&gt;)
&lt;/h4&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%2Faz69yvx9zw5u92w7b1vb.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%2Faz69yvx9zw5u92w7b1vb.png" alt="slow-worker trace comparing a 2-second worker span with 6 milliseconds of enqueue-to-attempt age" width="800" height="426"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;small&gt;&lt;em&gt;Figure 4. The selected worker span (&lt;code&gt;execute_scenario.slow-worker&lt;/code&gt;) lasts 2.00 seconds while &lt;code&gt;bullmq.job.age_at_attempt_ms&lt;/code&gt; is 6 ms. In this single diagnostic capture, the job started almost immediately and the delay was inside the worker handler.&lt;/em&gt;&lt;/small&gt;&lt;/p&gt;

&lt;p&gt;Because the job waited only 6 ms before processing began, scaling worker&lt;br&gt;
concurrency would not speed up this operation; inspect the code or&lt;br&gt;
downstream service inside the worker. This capture represents a single&lt;br&gt;
trace observation, not a statistical estimate of production P99 latency.&lt;/p&gt;
&lt;h4&gt;
  
  
  2. What a transient retry looks like (&lt;code&gt;transient-retry&lt;/code&gt;)
&lt;/h4&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%2Flllrwmjtax1z1ma6ng6p.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%2Flllrwmjtax1z1ma6ng6p.png" alt="transient-retry trace showing the HTTP request, failed first attempt, backoff, and successful second attempt" width="800" height="428"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;small&gt;&lt;em&gt;Figure 5. The HTTP request returns &lt;code&gt;202 Accepted&lt;/code&gt; quickly. The asynchronous trace continues through a failed first attempt, a retry delay, and a 50.38 ms second attempt across a 1.67-second trace horizon.&lt;/em&gt;&lt;/small&gt;&lt;/p&gt;

&lt;p&gt;A common misreading is assuming the Express HTTP handler stayed open for&lt;br&gt;
1.67 seconds. In fact, the root HTTP span ends as soon as &lt;code&gt;202 Accepted&lt;/code&gt;&lt;br&gt;
is returned. The trace can still span the later worker activity because&lt;br&gt;
the BullMQ consumer spans are represented as children in the trace.&lt;/p&gt;
&lt;h4&gt;
  
  
  3. What an exhausted failure looks like (&lt;code&gt;exhausted-failure&lt;/code&gt;)
&lt;/h4&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%2Flxohd72e1hrtd314oszy.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%2Flxohd72e1hrtd314oszy.png" alt="exhausted-failure trace showing three failed attempts and retry backoff" width="800" height="426"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;small&gt;&lt;em&gt;Figure 6. All three configured attempts fail (one initial attempt and two retries). The final attempt (&lt;code&gt;bullmq.job.attempt_number: 3&lt;/code&gt;) lasts 49.89 ms, while the full trace spans 3.27 seconds including earlier attempts and backoff.&lt;/em&gt;&lt;/small&gt;&lt;/p&gt;

&lt;p&gt;Selecting the third attempt shows the exception, a 49.89 ms handler&lt;br&gt;
duration, and a cumulative &lt;code&gt;bullmq.job.age_at_attempt_ms&lt;/code&gt; of 3,209 ms.&lt;/p&gt;
&lt;h3&gt;
  
  
  Step 5: Build the &lt;code&gt;job_age_at_attempt_ms&lt;/code&gt; histogram and span attribute
&lt;/h3&gt;

&lt;p&gt;To measure enqueue-to-attempt age in both the metrics pipeline and&lt;br&gt;
individual traces, the code records two distinct telemetry signals:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;OpenTelemetry histogram metric (&lt;code&gt;job_age_at_attempt_ms&lt;/code&gt;):&lt;/strong&gt;
defined in &lt;code&gt;src/telemetry/job-age.ts&lt;/code&gt; and exported to SigNoz Metrics
Explorer.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;OpenTelemetry span attribute (&lt;code&gt;bullmq.job.age_at_attempt_ms&lt;/code&gt;):&lt;/strong&gt;
explicitly attached to active trace spans in &lt;code&gt;src/jobs/processor.ts&lt;/code&gt;
using &lt;code&gt;setAttributes()&lt;/code&gt;. Recording the histogram does not
automatically create the trace attribute; the worker processor wires
both using the value returned by &lt;code&gt;recordJobAgeAtAttempt()&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The metric uses explicit bucket boundaries:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;JOB_AGE_BUCKET_BOUNDARIES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;150&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;250&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;750&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10000&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 calculation helper rejects invalid or non-positive timestamps and&lt;br&gt;
returns &lt;code&gt;null&lt;/code&gt; rather than manufacturing an age value:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;calculateEnqueueToAttemptAgeMs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;enqueueTimestamp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;processedOnTimestamp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;enqueueTimestamp&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;number&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
    &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isFinite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;enqueueTimestamp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
    &lt;span class="nx"&gt;enqueueTimestamp&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&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="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;processedOnTimestamp&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
    &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;processedOnTimestamp&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;number&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
    &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isFinite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;processedOnTimestamp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
    &lt;span class="nx"&gt;processedOnTimestamp&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&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="kc"&gt;null&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;diff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;processedOnTimestamp&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;enqueueTimestamp&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;diff&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;warnRateLimited&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;negative_clock_skew&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="s2"&gt;`Negative clock skew detected: processedOn (&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;processedOnTimestamp&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;) &amp;lt; enqueueTimestamp (&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;enqueueTimestamp&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;). Skipping metric recording.`&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&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;diff&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;recordJobAgeAtAttempt()&lt;/code&gt; separately classifies invalid enqueue&lt;br&gt;
timestamps and missing or invalid processing timestamps before calling&lt;br&gt;
the helper. A genuine negative delta is classified as&lt;br&gt;
&lt;code&gt;negative_clock_skew&lt;/code&gt;; invalid observations are skipped rather than&lt;br&gt;
clamped to zero. Warnings are rate-limited to once per reason per&lt;br&gt;
minute.&lt;/p&gt;
&lt;h4&gt;
  
  
  Key design safeguards and limitations
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Clock-skew directionality:&lt;/strong&gt; &lt;code&gt;job.timestamp&lt;/code&gt; is stamped by the
producer host and &lt;code&gt;job.processedOn&lt;/code&gt; by the worker host. A worker
clock that lags behind the producer can produce a negative delta,
which is dropped. If the worker clock is ahead, the age can be
artificially high while still positive. Synchronized host clocks
remain essential.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Bounded metric cardinality:&lt;/strong&gt; high-cardinality identifiers such as
&lt;code&gt;job.id&lt;/code&gt; and &lt;code&gt;runId&lt;/code&gt; stay on trace spans. The histogram uses
&lt;code&gt;bullmq.queue.name&lt;/code&gt;, &lt;code&gt;bullmq.job.attempt_type&lt;/code&gt; (&lt;code&gt;first_attempt&lt;/code&gt; or
&lt;code&gt;retry_attempt&lt;/code&gt;), and allowlisted &lt;code&gt;bullmq.job.name&lt;/code&gt;. If queue names
are dynamically generated per tenant, omit &lt;code&gt;bullmq.queue.name&lt;/code&gt; from
metric attributes.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Explicit bucket boundaries:&lt;/strong&gt; OpenTelemetry's default histogram
buckets jump from 100 to 250 ms. In the tested workload, 100 ms
timers completed around 100.3 to 101.6 ms, which places observations
above the 100 ms boundary. The custom boundaries provide finer
resolution for sub-10-second ages.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  Step 6: Compare both histograms and inspect the backlog in SigNoz
&lt;/h3&gt;
&lt;h4&gt;
  
  
  Visual query builder
&lt;/h4&gt;

&lt;p&gt;Open &lt;strong&gt;Metrics → Explorer&lt;/strong&gt; in SigNoz to compare enqueue-to-attempt age&lt;br&gt;
with active worker duration. Select &lt;code&gt;job_age_at_attempt_ms&lt;/code&gt; for Query A&lt;br&gt;
and &lt;code&gt;bullmq.job.duration&lt;/code&gt; for Query B, choose P95, and group by&lt;br&gt;
&lt;code&gt;bullmq.queue.name&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fo8pux6kfr73plvi42jw9.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%2Fo8pux6kfr73plvi42jw9.png" alt="SigNoz visual query builder with enqueue-to-attempt age and worker duration queries grouped by queue" width="800" height="385"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;small&gt;&lt;em&gt;Figure 7. Query A (&lt;code&gt;job_age_at_attempt_ms&lt;/code&gt;) and Query B (&lt;code&gt;bullmq.job.duration&lt;/code&gt;) configured for P95 and grouped by queue name in the visual query builder.&lt;/em&gt;&lt;/small&gt;&lt;/p&gt;
&lt;h4&gt;
  
  
  Raw PromQL in a dashboard panel
&lt;/h4&gt;

&lt;p&gt;For raw PromQL, open &lt;strong&gt;Dashboards&lt;/strong&gt;, create or edit a panel, and select&lt;br&gt;
the PromQL editor. SigNoz uses quoted metric and label names for dotted&lt;br&gt;
names.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;P95 enqueue-to-attempt age per queue:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;histogram_quantile(
  0.95,
  sum by (le, "bullmq.queue.name") (
    rate({"job_age_at_attempt_ms.bucket"}[5m])
  )
)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;P95 active worker execution duration per queue:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;histogram_quantile(
  0.95,
  sum by (le, "bullmq.queue.name") (
    rate({"bullmq.job.duration.bucket"}[5m])
  )
)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Omitting &lt;code&gt;"bullmq.queue.name"&lt;/code&gt; from &lt;code&gt;sum by (...)&lt;/code&gt; aggregates buckets&lt;br&gt;
across all queues into one combined series.&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%2Fzo11ox60qdpmiph4h4vt.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%2Fzo11ox60qdpmiph4h4vt.png" alt="SigNoz P95 comparison showing enqueue-to-attempt age and worker execution duration" width="" height=""&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;small&gt;&lt;em&gt;Figure 8. In the interactive diagnostic run, P95 enqueue-to-attempt age reaches 2.93 seconds while P95 worker execution duration is 248.5 ms. These observations come from the mixed scenario run, separate from the controlled benchmark.&lt;/em&gt;&lt;/small&gt;&lt;/p&gt;

&lt;p&gt;SigNoz estimates histogram percentiles via bucket interpolation across&lt;br&gt;
aggregated bucket counts. This differs from exact nearest-rank&lt;br&gt;
percentiles calculated over discrete per-job observations.&lt;/p&gt;
&lt;h4&gt;
  
  
  Inspecting a single backlog job in Trace Explorer
&lt;/h4&gt;

&lt;p&gt;To jump from a metric spike to the underlying traces, open &lt;strong&gt;Traces&lt;br&gt;
Explorer&lt;/strong&gt; and filter by the span attribute:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;service.name = 'order-worker' AND bullmq.job.age_at_attempt_ms &amp;gt; 500
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Opening the 20-job burst trace captured with a single worker slot&lt;br&gt;
(&lt;code&gt;npm run start:worker:constrained&lt;/code&gt;) reveals the backlog staircase.&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%2Fn1aw36rx8weepennw1q7.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%2Fn1aw36rx8weepennw1q7.png" alt="single-worker backlog trace showing the twentieth job waiting before execution" width="800" height="422"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;small&gt;&lt;em&gt;Figure 9. In this interactive burst trace (2.06-second horizon), the selected twentieth job executes in 100.5 ms after an enqueue-to-attempt age of 1,957 ms.&lt;/em&gt;&lt;/small&gt;&lt;/p&gt;

&lt;p&gt;The selected span finishes in 100.5 ms, while its&lt;br&gt;
&lt;code&gt;bullmq.job.age_at_attempt_ms&lt;/code&gt; attribute is 1,957 ms. Because the single&lt;br&gt;
worker processes jobs sequentially, the twentieth job waits behind the&lt;br&gt;
first 19 jobs before starting.&lt;/p&gt;
&lt;h3&gt;
  
  
  Step 7: Reproduce the 10-trial controlled concurrency benchmark
&lt;/h3&gt;

&lt;p&gt;To evaluate concurrency saturation in an isolated experiment without&lt;br&gt;
retry noise or histogram bucket interpolation, run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm run experiment
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The script runs five trials at concurrency &lt;code&gt;5&lt;/code&gt; and five at concurrency&lt;br&gt;
&lt;code&gt;1&lt;/code&gt;, alternating trial order so neither condition always runs on a cold&lt;br&gt;
process. Each trial creates a dedicated queue, pauses the worker,&lt;br&gt;
enqueues 20 jobs with 100 ms simulated work in one &lt;code&gt;addBulk()&lt;/code&gt; call,&lt;br&gt;
resumes processing for a synchronized burst, validates that returned&lt;br&gt;
ages are finite and non-negative, and cleans up Redis resources.&lt;/p&gt;

&lt;p&gt;The run writes three artifacts to &lt;code&gt;results/&lt;/code&gt; and overwrites the&lt;br&gt;
committed samples:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;code&gt;results/sample-trial-summary.csv&lt;/code&gt;: per-trial P50 and P95 summaries
across all 10 trials.&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;results/sample-job-observations.csv&lt;/code&gt;: all 200 individual job
observations.&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;results/sample-manifest.json&lt;/code&gt;: runtime specifications, locked
package versions, workload settings, and the nearest-rank percentile
formula.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want to restore the committed sample dataset after running the&lt;br&gt;
experiment, use &lt;code&gt;git checkout results/&lt;/code&gt;.&lt;/p&gt;
&lt;h4&gt;
  
  
  Benchmark results
&lt;/h4&gt;

&lt;p&gt;The table reports mean batch-enqueue latency across the five trials and&lt;br&gt;
the median of the five per-trial nearest-rank percentiles (rather than&lt;br&gt;
pooling all 100 observations together):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Measurement&lt;/th&gt;
&lt;th&gt;Concurrency 5&lt;/th&gt;
&lt;th&gt;Concurrency 1&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Mean enqueue-batch latency (20 jobs)&lt;/td&gt;
&lt;td&gt;6.53 ms&lt;/td&gt;
&lt;td&gt;3.93 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Processor elapsed P50 (median of 5 trials)&lt;/td&gt;
&lt;td&gt;100.6 ms&lt;/td&gt;
&lt;td&gt;100.3 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Processor elapsed P95 (median of 5 trials)&lt;/td&gt;
&lt;td&gt;101.6 ms&lt;/td&gt;
&lt;td&gt;100.9 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Enqueue-to-attempt age P50 (median of 5 trials)&lt;/td&gt;
&lt;td&gt;118 ms&lt;/td&gt;
&lt;td&gt;923 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Enqueue-to-attempt age P95 (median of 5 trials)&lt;/td&gt;
&lt;td&gt;328 ms&lt;/td&gt;
&lt;td&gt;1,844 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;At concurrency 5, 20 jobs of 100 ms complete in four waves of five. At&lt;br&gt;
concurrency 1, the twentieth job waits behind the first 19&lt;br&gt;
jobs (approximately 1.9 seconds under idealized sequential processing), while the full batch takes approximately 2 seconds to&lt;br&gt;
drain. The median per-trial P95 processor elapsed time stays near 101 ms&lt;br&gt;
in both conditions, while the median per-trial P95 enqueue-to-attempt&lt;br&gt;
age rises from 328 ms to 1,844 ms (about 5.6 times higher in this&lt;br&gt;
workload).&lt;/p&gt;

&lt;p&gt;The benchmark's &lt;code&gt;processor_elapsed_ms&lt;/code&gt; values come from a local&lt;br&gt;
&lt;code&gt;performance.now()&lt;/code&gt; timer inside &lt;code&gt;processDiagnosticJob()&lt;/code&gt;. They are not&lt;br&gt;
calculated from the exported &lt;code&gt;bullmq.job.duration&lt;/code&gt; histogram, which is&lt;br&gt;
the separate metric examined in SigNoz.&lt;/p&gt;

&lt;p&gt;This 5.6× ratio is a measurement of this specific 20-job, 100 ms burst&lt;br&gt;
workload on a single worker process, not a general constant that will&lt;br&gt;
hold across arbitrary production traffic. Production queue latency&lt;br&gt;
varies with arrival rate, job duration variance, scheduling delays,&lt;br&gt;
retries, and worker fleet size. What the experiment shows is that&lt;br&gt;
plotting &lt;code&gt;job_age_at_attempt_ms&lt;/code&gt; alongside &lt;code&gt;bullmq.job.duration&lt;/code&gt; helps&lt;br&gt;
separate queue waiting from handler execution time.&lt;/p&gt;

&lt;p&gt;&lt;small&gt;&lt;em&gt;Screenshot chronology: the screenshots are representative captures from separate diagnostic runs. Baseline captures predate job-level instrumentation, while interactive trace and Metrics Explorer captures are separate from the automated 10-trial CSV benchmark.&lt;/em&gt;&lt;/small&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Operational limits and production diagnostic guide
&lt;/h2&gt;

&lt;p&gt;Keep three operational details in mind when running this instrumentation&lt;br&gt;
in production:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Idle or dead workers emit no job-age samples:&lt;/strong&gt; because
&lt;code&gt;job_age_at_attempt_ms&lt;/code&gt; is recorded only when a worker starts an
attempt, a queue whose workers have all crashed will stop emitting
histogram observations. Pair alerts on job age with an alert on the
&lt;code&gt;bullmq.queue.jobs&lt;/code&gt; waiting gauge.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Collector timeouts on shutdown:&lt;/strong&gt; when the OTLP collector is
unreachable, the API and worker can continue processing jobs while
telemetry export fails. Exporter requests time out according to
&lt;code&gt;OTEL_EXPORTER_OTLP_TIMEOUT&lt;/code&gt; (default 5,000 ms), and
&lt;code&gt;sdk.shutdown()&lt;/code&gt; waits for in-flight requests. Configure container
termination grace periods accordingly.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;API hardening:&lt;/strong&gt; the demo API enforces a 16 KB payload limit
(&lt;code&gt;413 Payload Too Large&lt;/code&gt;), rejects malformed input
(&lt;code&gt;400 Bad Request&lt;/code&gt;), and maps Redis connection errors to
&lt;code&gt;503 Service Unavailable&lt;/code&gt;. It includes no authentication or rate
limiting and should not be exposed directly to the public internet.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Use this diagnostic matrix when triaging latency in SigNoz:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;code&gt;bullmq.job.duration&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;job_age_at_attempt_ms&lt;/code&gt; (first attempt)&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;job_age_at_attempt_ms&lt;/code&gt; (retry attempt)&lt;/th&gt;
&lt;th&gt;Likely cause and next step&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Slow worker handler or downstream call:&lt;/strong&gt; jobs start immediately, but active execution is slow. Inspect child spans inside the worker trace.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Queue backlog or scheduled delay:&lt;/strong&gt; handler execution is fast, but jobs wait before starting. Check waiting counts and increase concurrency or worker instances if appropriate.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Retry and backoff accumulation:&lt;/strong&gt; failures and backoff inflate age on retry attempts. Filter Trace Explorer by &lt;code&gt;service.name = 'order-worker' AND has_error = true&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Run the TypeScript check, 50-test suite, and build locally with Redis&lt;br&gt;
running:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm run typecheck
npm &lt;span class="nb"&gt;test
&lt;/span&gt;npm run build
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;a href="https://github.com/Hunter-debug00/otel-bullmq-signoz-tracing/actions" rel="noopener noreferrer"&gt;GitHub Actions&lt;br&gt;
workflow&lt;/a&gt;&lt;br&gt;
provides the latest CI result.&lt;/p&gt;

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

&lt;p&gt;You have instrumented a BullMQ pipeline with OpenTelemetry to compare enqueue-to-attempt age with active worker execution time. The controlled benchmark shows why both measurements matter: under this 20-job workload, median per-trial P95 enqueue-to-attempt age rose from 328 ms at concurrency 5 to 1,844 ms at concurrency 1, while processor elapsed P95 stayed near 101 ms.&lt;/p&gt;

&lt;p&gt;For a production rollout, start in staging, keep producer and worker clocks synchronized, and pair job-age observations with queue-waiting and worker-health alerts. Then use the traces to decide whether the bottleneck is handler execution, queue backlog, or retry backoff before changing concurrency. The SigNoz query and ingestion references below provide the next steps for adapting the dashboards to your environment.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;a href="https://github.com/Hunter-debug00/otel-bullmq-signoz-tracing" rel="noopener noreferrer"&gt;Source code, tests, and benchmark
artifacts&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://docs.bullmq.io/guide/telemetry/getting-started" rel="noopener noreferrer"&gt;BullMQ OpenTelemetry telemetry
guide&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://docs.bullmq.io/guide/telemetry/metrics" rel="noopener noreferrer"&gt;BullMQ telemetry metrics
reference&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://opentelemetry.io/docs/languages/js/" rel="noopener noreferrer"&gt;OpenTelemetry JavaScript
documentation&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://opentelemetry.io/docs/specs/otel/trace/api/" rel="noopener noreferrer"&gt;OpenTelemetry Tracing API
specification&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://opentelemetry.io/docs/specs/otel/metrics/sdk/" rel="noopener noreferrer"&gt;OpenTelemetry Metrics SDK
specification&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://signoz.io/docs/userguide/query-builder/" rel="noopener noreferrer"&gt;SigNoz: querying metrics and histogram
quantiles&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://signoz.io/docs/userguide/write-a-prom-query-with-new-format/" rel="noopener noreferrer"&gt;SigNoz: PromQL syntax for dotted metric
names&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://signoz.io/docs/ingestion/signoz-cloud/overview/" rel="noopener noreferrer"&gt;SigNoz Cloud ingestion
guide&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>node</category>
      <category>typescript</category>
      <category>opentelemetry</category>
      <category>bullmq</category>
    </item>
  </channel>
</rss>
