It is 3:00 AM. Your pager goes off. The checkout service is experiencing high latency, but the CPU usage is flat. The logs are a chaotic stream of text, and you have no idea which database query is hanging the event loop.
If this scenario sounds familiar, your application lacks Observability.
In the landscape of 2025, deploying a Node.js application without a robust monitoring strategy is akin to flying a plane blindfolded. While “monitoring” tells you that the system is down, “observability” tells you why.
For mid-to-senior Node.js developers, simply installing a vendor agent isn’t enough. You need to understand the mechanics of telemetry, how the Event Loop behaves under load, and how to correlate logs, metrics, and traces across distributed systems.
In this deep dive, we will move beyond basic console.log. We will architect a production-grade observability stack using OpenTelemetry (OTel), explore custom APM solutions for business logic, and visualize it all.
1. Prerequisites and Environment #
Before we write code, let’s ensure our environment is ready for a modern observability stack. We will be using Node.js v22 (Active LTS in our 2025 context) and Docker to spin up our telemetry backend.
Requirements:
- Node.js: v20.x or v22.x
- npm: v10.x+
- Docker & Docker Compose: For running Prometheus, Jaeger, and Grafana locally.
- IDE: VS Code (recommended).
Project Setup #
Let’s initialize a new project. We will use ES Modules, which are the standard for modern Node development.
mkdir node-observability-deep-dive
cd node-observability-deep-dive
npm init -yUpdate your package.json to enable ES modules:
{
"name": "node-observability-deep-dive",
"type": "module",
"version": "1.0.0",
// ... rest of config
}We will need a robust set of dependencies. We aren’t just building a “Hello World”; we are building a simulation of a high-traffic microservice.
# Core Application
npm install express cors helmet
# Observability - OpenTelemetry
npm install @opentelemetry/api \
@opentelemetry/sdk-node \
@opentelemetry/auto-instrumentations-node \
@opentelemetry/exporter-prometheus \
@opentelemetry/exporter-trace-otlp-http
# Logging and Utils
npm install pino pino-http2. The Three Pillars of Observability #
Before implementation, we must align on the architecture. Observability isn’t a single tool; it’s a data strategy built on three pillars.
- Metrics: Aggregatable numerical data (e.g., “Requests per second,” “Event Loop Lag,” “Memory Usage”).
- Traces: The lifecycle of a request as it flows through your microservices (e.g., “How long did the DB query take within the
/checkoutrequest?”). - Logs: Discrete events containing context (e.g., “Payment failed for User ID 123”).
The Modern Telemetry Flow #
In the past, we sent logs to one tool and metrics to another. In 2025, we use the OpenTelemetry standard to unify collection.
3. Implementing OpenTelemetry (The Industry Standard) #
Vendor lock-in is the enemy of long-term maintainability. Instead of using a proprietary agent (like the old New Relic or AppDynamics agents), we use the OpenTelemetry SDK. This allows us to switch backends (e.g., from Jaeger to Datadog) without changing a single line of application code.
3.1 The Instrumentation Module #
Create a file named instrumentation.js. Crucial: This file must be imported before your application starts.
// instrumentation.js
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { PrometheusExporter } from '@opentelemetry/exporter-prometheus';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { Resource } from '@opentelemetry/resources';
import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions';
// 1. Define the service identity
const resource = new Resource({
[SemanticResourceAttributes.SERVICE_NAME]: 'node-devpro-service',
[SemanticResourceAttributes.SERVICE_VERSION]: '1.0.0',
});
// 2. Configure Metrics (Prometheus)
// We define a port where Prometheus will 'scrape' our data
const metricReader = new PrometheusExporter({
port: 9464, // Default scraping port
}, () => {
console.log('Prometheus scrape endpoint ready on port 9464');
});
// 3. Configure Tracing (Jaeger via OTLP)
const traceExporter = new OTLPTraceExporter({
url: 'http://localhost:4318/v1/traces', // Sending to local collector/Jaeger
});
// 4. Initialize the SDK
const sdk = new NodeSDK({
resource,
traceExporter,
metricReader,
instrumentations: [
// Automatically instrument Http, Express, Postgres, Redis, etc.
getNodeAutoInstrumentations({
// Reduce noise by disabling fs instrumentation if not needed
'@opentelemetry/instrumentation-fs': { enabled: false },
}),
],
});
// 5. Start the SDK
try {
sdk.start();
console.log('OpenTelemetry initialized');
} catch (error) {
console.error('Error initializing OpenTelemetry', error);
}
// Graceful shutdown
process.on('SIGTERM', () => {
sdk.shutdown()
.then(() => console.log('Tracing terminated'))
.catch((error) => console.log('Error terminating tracing', error))
.finally(() => process.exit(0));
});3.2 The Application Logic #
Now, let’s build an Express app that actually does something worth monitoring. We’ll simulate a “heavy” calculation to see how it affects the Event Loop.
Create app.js:
// app.js
import './instrumentation.js'; // MUST be the first import
import express from 'express';
import pino from 'pino-http';
import { trace, context } from '@opentelemetry/api';
const app = express();
const logger = pino();
app.use(logger);
// Simulation of a database call
const mockDbCall = async () => {
return new Promise((resolve) => setTimeout(resolve, Math.random() * 200));
};
// Simulation of CPU intensive task (The Event Loop blocker)
const fibonacci = (n) => {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
};
app.get('/checkout', async (req, res) => {
// Access the current active span to add custom attributes
const span = trace.getSpan(context.active());
try {
req.log.info('Checkout started');
// Custom Trace Attribute
span?.setAttribute('user.tier', 'premium');
await mockDbCall();
// Simulate complex logic
// WARNING: heavy computation blocks the event loop!
const result = fibonacci(35);
span?.setAttribute('checkout.value', result);
req.log.info({ result }, 'Checkout completed');
res.json({ status: 'success', orderId: crypto.randomUUID() });
} catch (err) {
span?.recordException(err);
span?.setStatus({ code: 1, message: err.message }); // 1 = Error
res.status(500).send('Checkout failed');
}
});
app.get('/health', (req, res) => res.send('OK'));
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Service listening on port ${PORT}`);
});3.3 Running the Local Stack #
To see the data, we need the backend infrastructure. Create a docker-compose.yaml file. This is the magic that brings observability to your localhost.
version: '3.8'
services:
# Jaeger for Tracing
jaeger:
image: jaegertracing/all-in-one:latest
ports:
- "16686:16686" # UI
- "4318:4318" # OTLP HTTP receiver
environment:
- COLLECTOR_OTLP_ENABLED=true
# Prometheus for Metrics
prometheus:
image: prom/prometheus:latest
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
ports:
- "9090:9090"
# Grafana for Dashboards
grafana:
image: grafana/grafana:latest
ports:
- "3001:3000"
environment:
- GF_SECURITY_ADMIN_PASSWORD=admin
depends_on:
- prometheus
- jaegerYou also need a simple prometheus.yml configuration to tell Prometheus to scrape your Node app:
global:
scrape_interval: 5s
scrape_configs:
- job_name: 'node_app'
static_configs:
- targets: ['host.docker.internal:9464'] # Access host from containerLaunch the stack:
docker-compose up -d
node app.jsNow, if you hit http://localhost:3000/checkout a few times, you can visit:
- Jaeger (http://localhost:16686): See the waterfall chart of your request.
- Prometheus (http://localhost:9090): Query
http_server_request_duration_seconds_bucket.
4. Building Custom APM Middleware #
While OTel handles standard HTTP and DB metrics perfectly, it often misses specific application health signals, like Event Loop Lag or specific business KPIs.
Let’s build a lightweight, custom APM middleware. This isn’t to replace OTel, but to augment it with data OTel might not capture by default in the way you need.
Why Monitor Event Loop Lag? #
Node.js is single-threaded. If your Event Loop lag increases, your server is blocked (likely by CPU-intensive code like our fibonacci function). Standard CPU usage metrics (provided by AWS/Azure) can be misleading because they show the system CPU, not the thread blockage.
custom-apm.js
#
import { performance } from 'perf_hooks';
// Singleton to hold our metrics
const metrics = {
eventLoopLag: 0,
requestsInFlight: 0,
};
// 1. Measure Event Loop Lag
// We schedule a timer for 10ms. If it executes in 100ms, lag is 90ms.
function measureLoopLag() {
const start = performance.now();
setTimeout(() => {
const lag = performance.now() - start - 10; // 10 is the expected delay
metrics.eventLoopLag = Math.max(0, lag);
measureLoopLag(); // Re-schedule
}, 10);
}
measureLoopLag();
// 2. Custom Middleware
export const customApmMiddleware = (req, res, next) => {
const start = performance.now();
metrics.requestsInFlight++;
// Hook into response finish
res.on('finish', () => {
metrics.requestsInFlight--;
const duration = performance.now() - start;
// Log if request is dangerously slow AND lag is high
if (duration > 500 && metrics.eventLoopLag > 20) {
console.warn(`[Performance Alert] Req: ${req.path}, Dur: ${duration.toFixed(2)}ms, Loop Lag: ${metrics.eventLoopLag.toFixed(2)}ms`);
}
});
next();
};
export const getApmMetrics = () => metrics;Integrate this into your app.js:
import { customApmMiddleware, getApmMetrics } from './custom-apm.js';
app.use(customApmMiddleware);
// Expose internal metrics for scraping
app.get('/metrics/custom', (req, res) => {
res.json(getApmMetrics());
});Now, when you hit the /checkout endpoint with the heavy Fibonacci calculation, your logs will immediately flag the Event Loop lag.
5. Structured Logging: The Glue #
Logs are useless if you can’t search them or correlate them with traces. In 2025, using console.log in production is a fireable offense in many top-tier tech teams.
We use Pino because it is the fastest logger for Node.js and outputs JSON by default.
Correlating Logs with Traces #
The “Holy Grail” of debugging is seeing a log error and immediately clicking a link to the trace. To do this, we must inject the OTel TraceID and SpanID into every log message.
Update your app.js logger configuration:
import { trace, context } from '@opentelemetry/api';
const logger = pino({
mixin() {
// This function runs for EVERY log
const span = trace.getSpan(context.active());
if (!span) return {};
const { traceId, spanId } = span.spanContext();
return { traceId, spanId };
}
});Result: Your logs now look like this:
{
"level": 30,
"time": 1735689600000,
"msg": "Checkout completed",
"result": 9227465,
"traceId": "5b8aa5a2d2c872e8321cf37308d69df2",
"spanId": "5fb397be34d26b51"
}When you ingest this into a tool like Datadog, Loki, or Elasticsearch, you can filter by traceId and see the logs exactly aligned with your waterfall charts.
6. Comparison: SaaS vs. Self-Hosted #
Should you build the stack above (Prometheus/Jaeger) for production, or pay a vendor? Here is a breakdown for decision-makers.
| Feature | SaaS (Datadog, New Relic) | Open Source (Prometheus, Jaeger, ELK) | Custom Node.js Solutions |
|---|---|---|---|
| Cost | High ($$$). Often based on data ingestion volume. | Low ($). Only infrastructure costs. | Medium. High engineering salary cost to maintain. |
| Setup Time | Instant. Install agent, see data. | High. Requires managing storage, retention, updates. | High. Requires constant code updates. |
| Data Ownership | Vendor owns data. Data is sampled (lost) to save cost. | You own 100% of data. No forced sampling. | You own it. |
| Customization | Limited to vendor features. | Infinite. | Infinite. |
| Maintenance | Zero. | High (Managing Prometheus storage is a skill itself). | High. |
My Recommendation for 2025: Start with OpenTelemetry in your code regardless of the backend.
- Small/Medium Teams: Pipe OTel data to a SaaS (e.g., Honeycomb or Datadog). The engineering time saved is worth the license cost.
- Large Enterprises: Pipe OTel data to a managed Prometheus/Grafana instance or a self-hosted stack to control data retention costs.
7. Performance Best Practices & Common Pitfalls #
Implementing observability introduces overhead. Here is how to keep it minimal.
1. Sampling is Mandatory #
In high-throughput Node.js services (1000+ RPS), tracing every request will degrade performance by 10-20% due to object allocation and serialization.
Solution: Use “Head Sampling” in your OTel configuration.
import { AlwaysOnSampler, ParentBasedSampler, TraceIdRatioBasedSampler } from '@opentelemetry/sdk-node';
const sdk = new NodeSDK({
// ... other config
// Sample 10% of traces, but always keep traces that have a parent
// (to ensure distributed traces don't break)
sampler: new ParentBasedSampler({
root: new TraceIdRatioBasedSampler(0.1),
}),
});2. High Cardinality Metrics #
Do not put user IDs, email addresses, or high-variance data into Metric Labels.
- ❌ Bad:
counter.add(1, { user_id: 'u-123' })-> Creates millions of metric series. Prometheus will crash. - ✅ Good:
counter.add(1, { user_type: 'premium' })-> Creates fixed number of series. - ✅ Good: Put User IDs in Traces or Logs, not Metrics.
3. Context Propagation #
If you use async/await heavily (which you should), OTel handles context propagation well. However, if you use the “Userland Queue” pattern (e.g., bull or p-queue), the trace context often breaks. You must manually extract the context before adding the job to the queue and re-inject it inside the worker.
Conclusion #
Observability in Node.js has matured significantly. We are no longer guessing why the event loop is blocked; we are measuring it.
By adopting OpenTelemetry, you future-proof your application. You decouple how you generate data from where you store it. By adding structured logging with Pino and correlation IDs, you turn debugging from a detective story into a simple lookup process.
Key Takeaways:
- Instrument Early: Don’t wait for the first outage.
- Use OpenTelemetry: It is the standard.
- Watch the Event Loop: It is the heartbeat of Node.js.
- Correlate Everything: Logs without Trace IDs are just noise.
Further Reading #
- OpenTelemetry JS Documentation
- Node.js Performance Hooks API
- The RED Method (Rate, Errors, Duration)
Ready to monitor? Clone the code, run docker-compose up, and start seeing what your code is actually doing.