| |

Node.js 69 🟢 Application Performance Metrics Exporting with Prometheus

Prometheus is a pull-based monitoring system. Unlike agents that push metrics to a central server, Prometheus periodically requests a /metrics endpoint on your application, parses the response, and stores the values as time-series data. This means your Node.js application’s job is not to send metrics anywhere—it is to expose them in a format Prometheus understands, on an endpoint Prometheus can reach. The prom-client library is the standard implementation for this, and the patterns around it determine whether your metrics are useful or just noise.

The library provides four metric types that map to different kinds of measurements. Counters only increase (total requests, total errors). Gauges go up and down (current memory usage, active connections). Histograms sample observations into buckets (request durations, response sizes). Summaries calculate quantiles over a sliding window (p50/p95/p99 latency). Choosing the wrong type produces data that cannot answer the questions you will actually ask during an incident .

This chapter covers the prom-client setup, the default metrics that come free, the custom metrics that matter for an HTTP service, the Express middleware that collects them, the /metrics endpoint, and the Prometheus scrape configuration that completes the loop.

Key point: Your Node.js application exposes metrics in Prometheus text format on a /metrics endpoint; Prometheus scrapes that endpoint on an interval, and the prom-client library provides the counters, gauges, histograms, and summaries that produce the metrics, with collectDefaultMetrics() adding Node.js runtime metrics automatically.


Why the pull model and the four metric types matter

The pull model’s advantage. Prometheus decides when to scrape, not the application. If Prometheus is down, the application does not buffer metrics or fail to send them—it simply stops being scraped, and the gap is visible in the time-series data. If the application is down, the scrape fails, and Prometheus records the target as down. This separation means the application does not need to know Prometheus’s address, does not need retry logic, and does not block request handling to send metrics. The /metrics endpoint is a plain HTTP handler that returns the current values .

Counter semantics. A counter is monotonic. It only increases. http_requests_total is a counter: it starts at zero, increments on every request, and resets to zero only when the process restarts. Prometheus handles the restart by detecting the reset and adjusting its rate calculations. A counter that goes down is a bug, and a counter used for a value that fluctuates (like active connections) will produce meaningless rates .

Histogram and summary semantics. Both measure distributions, but differently. A histogram counts observations into predefined buckets and exposes three series: _bucket (cumulative counts per bucket), _sum (total of all observations), and _count (number of observations). A summary calculates quantiles directly and exposes them as _sum, _count, and {quantile="0.95"} series. Histograms are aggregatable across instances; summaries are not. For HTTP request durations, the histogram is the standard choice because it allows calculating p95 across all instances, not just per-instance .

The default metrics problem. collectDefaultMetrics() adds Node.js runtime metrics: CPU usage, memory (heap used, heap total, RSS, external), event loop lag, and garbage collection statistics . These are essential for diagnosing whether a performance problem is in your code or in the runtime. A high request latency with low CPU and low event loop lag points to an external dependency. A high latency with high event loop lag points to blocking code. Without the defaults, you cannot make that distinction.


a. The prom-client setup and default metrics

The library is installed as a single dependency:

npm install prom-client

The core object is a Registry, which holds every metric. collectDefaultMetrics({ register }) adds the Node.js runtime metrics to the registry. Every custom metric must also be registered before it can be exposed .

const client = require('prom-client');

const register = new client.Registry();
client.collectDefaultMetrics({ register });

The default metrics include process_cpu_user_seconds_total, process_cpu_system_seconds_total, nodejs_heap_size_total_bytes, nodejs_heap_size_used_bytes, nodejs_external_memory_bytes, nodejs_eventloop_lag_seconds, and several garbage collection metrics. These are the metrics that answer “is the runtime healthy?” before you look at application-level metrics .

The registry also has a contentType property (text/plain; version=0.0.4) and a metrics() method that returns the current values as a string in the Prometheus exposition format. The /metrics endpoint uses both:

app.get('/metrics', async (req, res) => {
  res.set('Content-Type', register.contentType);
  res.end(await register.metrics());
});

This is the entire contract between the application and Prometheus. Prometheus requests the endpoint, receives the text, parses it, and stores it. The application does not need to know when the scrape happens or how often .


b. Custom metrics for an HTTP service

The default metrics cover the runtime. They do not cover the application’s behavior: how many requests it handles, how long they take, and how many fail. Those require custom metrics.

The two most valuable custom metrics for an HTTP service are a request counter and a request duration histogram. The counter tracks throughput and error rate. The histogram tracks latency distribution, which is more useful than an average because averages hide outliers .

const httpRequestDuration = new client.Histogram({
  name: 'http_request_duration_seconds',
  help: 'Duration of HTTP requests in seconds',
  labelNames: ['method', 'route', 'status'],
  buckets: [0.01, 0.05, 0.1, 0.5, 1, 5],
  registers: [register],
});

const httpRequestTotal = new client.Counter({
  name: 'http_requests_total',
  help: 'Total number of HTTP requests',
  labelNames: ['method', 'route', 'status'],
  registers: [register],
});

The buckets array defines the histogram boundaries. A request that takes 0.07 seconds falls into the 0.1 bucket (and all larger buckets). The bucket boundaries should reflect your service level objectives: if you care about requests slower than 500ms, there should be a bucket at 0.5. The default buckets are spread across milliseconds to seconds, but they are often too coarse for a specific service .

The labelNames define the dimensions. method, route, and status are the standard labels because they allow queries like “error rate by route” and “p95 latency for GET /orders.” Labels should be low cardinality. A label with unique values per request—user ID, request ID, full URL with query parameters—will create a separate time series for every value, and Prometheus’s memory usage will grow without bound .

The middleware that records these metrics wraps the request lifecycle:

app.use((req, res, next) => {
  const start = process.hrtime.bigint();
  res.on('finish', () => {
    const duration = Number(process.hrtime.bigint() - start) / 1e9;
    const labels = {
      method: req.method,
      route: req.route?.path || req.path,
      status: res.statusCode,
    };
    httpRequestDuration.observe(labels, duration);
    httpRequestTotal.inc(labels);
  });
  next();
});

The res.on('finish') handler fires when the response has been sent, which is the correct point to record the duration. The req.route?.path returns the Express route pattern (/orders/:id), not the actual path (/orders/123). This is critical for cardinality: using req.path would create a separate label value for every order ID, and the metric would become useless .

For business metrics that are not HTTP-related—signups, orders created, payments processed—the same counter or gauge pattern applies. A signups_total counter increments when a user registers, and it answers “how many signups per second” when queried with rate(signups_total[5m]) .


c. The Prometheus scrape configuration and the Kubernetes variation

Prometheus needs to know where to find the /metrics endpoint. In a static configuration, the scrape_configs section lists the targets:

global:
  scrape_interval: 15s

scrape_configs:
  - job_name: 'node-backend'
    static_configs:
      - targets: ['backend:3001']
    metrics_path: '/metrics'

The scrape_interval controls how often Prometheus pulls. Fifteen seconds is the standard starting point: frequent enough to see trends, infrequent enough not to overload the application . The metrics_path is optional if the endpoint is /metrics, which is the default.

In Kubernetes, static targets do not scale. The Prometheus Operator uses ServiceMonitor resources instead. A ServiceMonitor selects a Service by label, specifies the port name, the path, and the interval, and the Operator configures Prometheus to scrape the matching endpoints .

apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
  name: nodejs-app
  namespace: monitoring
  labels:
    release: prometheus-stack
spec:
  selector:
    matchLabels:
      app: nodejs-app
  namespaceSelector:
    matchNames: [default]
  endpoints:
    - port: http
      path: /metrics
      interval: 15s

The selector.matchLabels must match the Service’s labels, not the Pod’s. The endpoints.port must match the named port in the Service definition (http in this example). If the Service defines the port as port: 3000 without a name, the ServiceMonitor cannot reference it . This is a common source of silent scraping failures: the ServiceMonitor exists, the Service exists, but the port name does not match, and Prometheus never scrapes the application.


Complete Example Session

The following session builds an Express application with default metrics, custom HTTP metrics, a business metric, and the /metrics endpoint, then configures Prometheus to scrape it.

// ============================================
// PART 1: SETUP AND REGISTRY
// ============================================
const express = require('express');
const client = require('prom-client');

const app = express();
const register = new client.Registry();
client.collectDefaultMetrics({ register });

// ============================================
// PART 2: CUSTOM METRICS
// ============================================
const httpRequestDuration = new client.Histogram({
  name: 'http_request_duration_seconds',
  help: 'Duration of HTTP requests in seconds',
  labelNames: ['method', 'route', 'status'],
  buckets: [0.01, 0.05, 0.1, 0.5, 1, 5],
  registers: [register],
});

const httpRequestTotal = new client.Counter({
  name: 'http_requests_total',
  help: 'Total number of HTTP requests',
  labelNames: ['method', 'route', 'status'],
  registers: [register],
});

const signupsTotal = new client.Counter({
  name: 'signups_total',
  help: 'Total number of user signups',
  registers: [register],
});

// ============================================
// PART 3: MIDDLEWARE
// ============================================
app.use((req, res, next) => {
  const start = process.hrtime.bigint();
  res.on('finish', () => {
    const duration = Number(process.hrtime.bigint() - start) / 1e9;
    const labels = {
      method: req.method,
      route: req.route?.path || req.path,
      status: res.statusCode,
    };
    httpRequestDuration.observe(labels, duration);
    httpRequestTotal.inc(labels);
  });
  next();
});

// ============================================
// PART 4: ROUTES
// ============================================
app.get('/', (req, res) => {
  res.send('Hello');
});

app.get('/orders/:id', (req, res) => {
  res.json({ id: req.params.id });
});

app.post('/signup', (req, res) => {
  signupsTotal.inc();
  res.status(201).json({ status: 'created' });
});

// ============================================
// PART 5: METRICS ENDPOINT
// ============================================
app.get('/metrics', async (req, res) => {
  res.set('Content-Type', register.contentType);
  res.end(await register.metrics());
});

// ============================================
// PART 6: START
// ============================================
app.listen(3000, () => console.log('App on :3000'));
# ============================================
# PART 7: PROMETHEUS CONFIG
# ============================================
# prometheus.yml
global:
  scrape_interval: 15s

scrape_configs:
  - job_name: 'node-app'
    static_configs:
      - targets: ['app:3000']
    metrics_path: '/metrics'
# ============================================
# PART 8: EXAMPLE OUTPUT
# ============================================
# GET /metrics returns (abridged):
# HELP http_requests_total Total number of HTTP requests
# TYPE http_requests_total counter
http_requests_total{method="GET",route="/",status="200"} 42
http_requests_total{method="GET",route="/orders/:id",status="200"} 17
http_requests_total{method="POST",route="/signup",status="201"} 3

# HELP http_request_duration_seconds Duration of HTTP requests in seconds
# TYPE http_request_duration_seconds histogram
http_request_duration_seconds_bucket{method="GET",route="/",status="200",le="0.01"} 38
http_request_duration_seconds_bucket{method="GET",route="/",status="200",le="0.05"} 42
http_request_duration_seconds_bucket{method="GET",route="/",status="200",le="+Inf"} 42
http_request_duration_seconds_sum{method="GET",route="/",status="200"} 0.34
http_request_duration_seconds_count{method="GET",route="/",status="200"} 42

# HELP signups_total Total number of user signups
# TYPE signups_total counter
signups_total 3

The eight parts cover the registry and default metrics, the custom metrics, the middleware that records them, the routes that trigger them, the /metrics endpoint, the Prometheus scrape configuration, and the example output. What the session demonstrates is that the counter and histogram are recorded once in the middleware, and every route automatically contributes to them without per-route instrumentation.


Quick Reference

Metric Types

TypeBehaviorExample Use
CounterOnly increaseshttp_requests_total
GaugeUp and downactive_connections
HistogramBucket countshttp_request_duration_seconds
SummaryQuantilesdb_query_duration_seconds

Default Metrics

MetricMeaning
process_cpu_user_seconds_totalUser CPU time
nodejs_heap_size_used_bytesHeap used
nodejs_eventloop_lag_secondsEvent loop lag
nodejs_gc_duration_secondsGC pause time

Setup Commands

CommandPurpose
npm install prom-clientInstall library
collectDefaultMetrics({ register })Add runtime metrics
register.metrics()Get text output
register.contentTypeSet response header

Label Cardinality Rules

Safe LabelUnsafe Label
methodrequest_id
route (/orders/:id)path (/orders/123)
status (200, 404)user_id

Prometheus Scrape Config

FieldValue
scrape_interval15s
metrics_path/metrics
targets['app:3000']

Best Practices

✅ Do This:

collectDefaultMetrics({ register });                       // ✅ runtime metrics
route: req.route?.path || req.path                         // ✅ route pattern
buckets: [0.01, 0.05, 0.1, 0.5, 1, 5]                     // ✅ meaningful boundaries
res.on('finish', () => { /* record */ });                  // ✅ record after response

❌ Don’t Do This:

labelNames: ['user_id', 'request_id']                      // ❌ cardinality explosion
route: req.path                                            // ❌ unique path per request
// no /metrics endpoint                                    // ❌ Prometheus cannot scrape
// counter used for memory usage                          // ❌ counter only increases

Common Pitfalls

PitfallWhy It HappensFix
Cardinality explosionHigh-cardinality labelsUse route patterns, not full paths
Metrics endpoint 404Route not registeredAdd app.get('/metrics')
Prometheus target downWrong target addressCheck hostname and port
Histogram no dataNo observations yetCustom metrics appear after first use
ServiceMonitor not scrapingPort name mismatchUse named port in Service

Real-World Examples

1. Default metrics setup

const register = new client.Registry();
client.collectDefaultMetrics({ register });

2. Request counter

new client.Counter({ name: 'http_requests_total', help: '...', labelNames: ['method', 'route', 'status'], registers: [register] });

3. Duration histogram

new client.Histogram({ name: 'http_request_duration_seconds', help: '...', buckets: [0.1, 0.5, 1], registers: [register] });

4. Metrics endpoint

app.get('/metrics', async (req, res) => { res.set('Content-Type', register.contentType); res.end(await register.metrics()); });

5. Prometheus scrape config

scrape_configs:
  - job_name: 'node-app'
    static_configs:
      - targets: ['app:3000']

6. Business metric

signupsTotal.inc();

7. Gauge for active connections

activeConnections.set(value);

8. Histogram observe

httpRequestDuration.observe({ method, route, status }, duration);

9. ServiceMonitor (Kubernetes)

endpoints:
  - port: http
    path: /metrics

10. Query error rate

sum(rate(http_requests_total{status=~"5.."}[5m])) / sum(rate(http_requests_total[5m]))

Visual

The Pull Model

┌──────────────────────────────────────────────┐
│  NODE.JS APP                                 │
│  ┌────────────────────────────────────────┐  │
│  │ Registry                               │  │
│  │ - default metrics                      │  │
│  │ - http_requests_total                  │  │
│  │ - http_request_duration_seconds        │  │
│  └────────────────┬───────────────────────┘  │
│                   │                          │
│                   ▼                          │
│  GET /metrics → text/plain exposition        │
└───────────────────┼──────────────────────────┘
                    │
                    │ Prometheus scrapes every 15s
                    │
                    ▼
┌──────────────────────────────────────────────┐
│  PROMETHEUS                                  │
│  - parses text format                        │
│  - stores as time series                     │
│  - evaluates alert rules                     │
└──────────────────────────────────────────────┘

Metric Type Decision

┌──────────────────────────────────────────────┐
│  Does the value only increase?               │
│  ├─ YES → Counter (requests, errors)         │
│  └─ NO → Does it go up and down?             │
│          ├─ YES → Gauge (memory, connections)│
│          └─ NO → Distribution?               │
│                  ├─ YES → Histogram (latency)│
│                  └─ NO → Summary (quantiles) │
└──────────────────────────────────────────────┘

Middleware Recording Flow

┌──────────────────────────────────────────────┐
│  Request arrives                             │
│         │                                    │
│         ▼                                    │
│  start = hrtime.bigint()                     │
│         │                                    │
│         ▼                                    │
│  next() — route handler runs                 │
│         │                                    │
│         ▼                                    │
│  res.on('finish') fires                      │
│         │                                    │
│         ▼                                    │
│  duration = now - start                      │
│         │                                    │
│         ▼                                    │
│  counter.inc(labels)                         │
│  histogram.observe(labels, duration)         │
└──────────────────────────────────────────────┘

Label Cardinality

┌──────────────────────────────────────────────┐
│  SAFE: route = "/orders/:id"                 │
│  → one time series per route pattern         │
│                                              │
│  UNSAFE: path = "/orders/123"                │
│  → one time series per order ID              │
│  → millions of series → Prometheus OOM       │
└──────────────────────────────────────────────┘

Summary

ItemValue
Libraryprom-client
Metric endpoint/metrics
Default metricscollectDefaultMetrics()
CounterMonotonic increase
GaugeUp and down
HistogramBucket distribution
SummaryQuantiles
Safe labelsmethod, route, status
Unsafe labelsuser_id, request_id, path
Scrape interval15s

Key takeaways:

  • Prometheus pulls; the application exposes. The /metrics endpoint returns the current metric values in text format, and Prometheus scrapes it on an interval. The application does not need to know Prometheus exists .
  • prom-client provides the four metric types. Counters for totals, gauges for fluctuating values, histograms for distributions, and summaries for quantiles. Choosing the wrong type produces data that cannot answer the questions you will ask .
  • collectDefaultMetrics() adds runtime metrics. CPU, memory, event loop lag, and garbage collection are essential for distinguishing application problems from runtime problems .
  • The request middleware records the counter and histogram once. Every route contributes to http_requests_total and http_request_duration_seconds without per-route code .
  • Labels must be low-cardinality. Use req.route.path (the pattern) not req.path (the actual URL). A label with a unique value per request will create a separate time series for every value and exhaust Prometheus’s memory .
  • The histogram buckets should reflect your SLOs. If you care about requests slower than 500ms, include a bucket at 0.5. The default buckets are often too coarse .
  • In Kubernetes, use a ServiceMonitor. The Prometheus Operator discovers services by label and scrapes the named port. A mismatch between the Service port name and the ServiceMonitor port reference is a silent failure .

Remember: Prometheus monitoring is a contract between the application and the monitoring system. The application promises to expose its metrics on /metrics in the text format. Prometheus promises to scrape on a schedule and store the values. The prom-client library makes the first half easy: default metrics for the runtime, custom metrics for the application, and a middleware that records them without per-route code. The discipline is in the labels—keep them low-cardinality, use route patterns, and never put a unique identifier in a label. The metrics that matter are the ones that answer the questions you will ask at 3 AM: How many requests? How long did they take? How many failed? The four golden signals—latency, traffic, errors, saturation—are the starting point, and prom-client gives you the primitives to measure them.



Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!