Backend cheatsheetNestJS observabilityKnow before the pager does
NestJS
Observed
Your service already knows when it is slow, failing or backed up. This guide wires a NestJS app from nothing to metrics, logs, traces, dashboards and alerts, and shows which signal answers which question.
Key terms in plain words
New to watching a live app? Start here. Every word below shows up later on this page, and each one comes with an everyday comparison. Underlined words in the modules link back to these cards.
25 terms in 5 groups. Hover an underlined word anywhere on the page for a quick reminder, or click it to jump here.
The three signals
Running an app is like running an airport's control tower. You need the numbers on the boards, the logbook, and a way to follow one bag through the whole building.
- Metrics
- Think of it as the numbers on the departure board: flights on time, delayed, cancelled
- Numbers measured over time, such as requests per second, errors per second and how long requests take. Cheap to keep for months, and the first place to look when something feels wrong.
- Logging
- Think of it as the logbook where staff write down each thing that happens
- Writing one line of text for each event, such as "payment failed for game 42". Logs answer why one particular thing went wrong.
- Tracing
- Think of it as following one bag's barcode through every belt and scanner on its way to the plane
- Recording the path of a single request through your app and the services it calls, with how long each step took. It shows where the time went. Busy apps often keep only a sample of traces.
- Request id
- Think of it as the baggage tag number that follows one bag through every belt
- A short unique code given to each request as it arrives. Every log line and trace for that request carries it, so you can pull them all up together.
- Pod
- Think of it as one of several identical terminals running side by side
- One running copy of your app. Busy apps run several at once and share the traffic between them, so numbers are often shown per pod to spot one that misbehaves.
Counting things
How numbers are collected, the three kinds of number, and the one mistake that can sink the whole system.
- Prometheus and scraping
- Think of it as an inspector who walks round every few seconds and writes down every dial
- Prometheus is a program that stores metrics. Every few seconds it visits your app's
/metricsaddress and reads the current numbers, which is called scraping. - Counter
- Think of it as a turnstile clicker that only ever goes up
- A metric that only increases, such as requests served or jobs failed. You usually look at how fast it rises rather than its total.
- Gauge
- Think of it as the "passengers in the lounge right now" sign
- A metric that goes up and down, such as connected users or jobs waiting. It tells you the current level.
- Histogram
- Think of it as sorting passengers by how long they waited: under a minute, under five, under ten
- A metric that drops each measurement into a range, called a bucket, and counts how many land in each. From those counts you can work out percentiles.
- Percentile (p95)
- Think of it as "95 in every 100 passengers waited less than this"
- A way to describe speed that ignores lucky averages. A p95 of 300 ms means 95 out of 100 requests finished within 300 milliseconds and the slowest 5 took longer.
- Label
- Think of it as the sticker that sorts numbers into piles by gate, airline or status
- A name and value attached to a metric so you can split it up, such as
route="/games/:id"orstatus="500". - Time series and cardinality
- Think of it as the number of separate piles those stickers create
- Every different mix of label values is stored as its own time series. Cardinality is how many there are. A label with unlimited values, like a user id, multiplies them until Prometheus runs out of memory.
Logs and traces
Keeping the logbook tidy and safe, and tracking each request step by step.
- Structured log
- Think of it as a logbook with printed columns instead of free handwriting
- A log line written as JSON, a text format of named fields, rather than a plain sentence. Tools can then search and filter by any field, such as every line for one request id.
- For example
{"level":"info","reqId":"8c47","msg":"game created"} - Redaction
- Think of it as blacking out passport numbers before the logbook is filed
- Removing secrets such as passwords and login tokens from logs before they are written, so they never end up stored where many people can read them.
- OpenTelemetry
- Think of it as a standard tracking system every airport can plug its scanners into
- An open standard and set of libraries for collecting traces. Its auto instrumentation records HTTP calls and database queries for you, and OTLP is the format it uses to send them on.
- Span
- Think of it as one leg of the bag's journey, with a start and finish time
- A single timed step inside a trace, such as one database query. A trace is a tree of spans, and the widest one is usually where the time went.
Work outside a request
Measuring what happens around each request, and the jobs and connections that never pass through one.
- Interceptor
- Think of it as a host who meets you before the gate and sees you off after you land
- NestJS code that wraps every route handler: part runs before, part after. That makes it the one place to start a timer and stop it for every request.
- Queue
- Think of it as the belt of bags waiting to be loaded
- A waiting list of work to be done later, in the background, so a request can answer quickly. A queue that keeps growing means the work is arriving faster than it is done.
- Job
- Think of it as one bag on that belt
- A single piece of background work in a queue, such as sending an email. Each job is waiting, active, delayed, done or failed.
- Worker
- Think of it as a baggage handler who takes the next bag off the belt
- A process that pulls jobs from a queue and carries them out. Too few workers and the waiting count climbs.
- Socket
- Think of it as an open radio channel between the tower and a plane
- A connection that stays open so the server and the browser can send each other messages at any time. Counting open sockets per pod shows where the load sits.
Watching and waking up
Turning the numbers into screens people look at and alarms that wake the right person.
- Dashboard
- Think of it as the wall of screens in the control tower
- A page of live charts built from your metrics, logs and traces. Grafana is the tool this page uses to draw them.
- PromQL
- Think of it as the question you type into the tower's screen
- The query language for Prometheus. You write a short formula, such as errors divided by all requests over five minutes, and it returns the numbers for a chart or an alert.
- Alert
- Think of it as the alarm that goes off in the tower
- A rule that watches a number and notifies someone when it crosses a line for long enough. Alertmanager groups alerts and sends them to chat or to a pager.
- Runbook
- Think of it as the laminated emergency card hanging next to the alarm
- A short written guide for one alert: what it means, what to check first and how to fix it. Linking it from the alert saves minutes at 3am.
Three signals
Metrics say something is wrong, traces say where, logs say why. Emit all three and join them with one id.
Metrics with prom-client
One registry, the Node.js default metrics, an HTTP histogram and a /metrics route for Prometheus to scrape.
Percentiles computed in the app. Cannot be summed across pods.
See the example@Injectable()
export class MetricsService {
readonly registry = new Registry();
readonly httpDuration = new Histogram({
name: 'http_server_duration_seconds',
help: 'HTTP request duration in seconds',
labelNames: ['method', 'route', 'status'] as const,
buckets: [0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5],
registers: [this.registry],
});
constructor() {
collectDefaultMetrics({ register: this.registry }); // CPU, memory, GC, event loop lag
}
}
Why it matters: buckets placed around your real latencies make histogram_quantile give an honest p95.
@Injectable()
export class HttpMetricsInterceptor implements NestInterceptor {
constructor(private readonly metrics: MetricsService) {}
intercept(ctx: ExecutionContext, next: CallHandler) {
if (ctx.getType() !== 'http') return next.handle();
const http = ctx.switchToHttp();
const req = http.getRequest();
const route = req.route?.path ?? 'unmatched'; // the template, never the raw URL
const end = this.metrics.httpDuration.startTimer({ method: req.method, route });
return next.handle().pipe(tap({
next: () => end({ status: http.getResponse().statusCode }),
error: (err) => end({ status: err?.getStatus?.() ?? 500 }),
}));
}
}
Why it matters: one global interceptor times every controller with three small labels.
Picture it: one request lands in the histogram
183 msobserve(0.183)→.1.25.51+InfEvery bucket at or above the value counts it. That is why buckets are cumulative.Labels and cardinality
Every unique mix of label values is its own time series. Keep labels to small, fixed sets.
A histogram with 8 buckets writes 11 series per label mix: one per bucket, +Inf, plus sum and count. Multiply by routes, methods, statuses and pods and it grows fast. Add a label like userId and it grows without limit. Run this to see the numbers.
const perHistogram = 8 + 1 + 2; // buckets, +Inf, sum and count
const base = { routes: 40, methods: 3, statuses: 6, pods: 4 };
const count = (labels) => Object.values(labels).reduce((a, b) => a * b, perHistogram);
console.log('route, method, status, pod:', count(base).toLocaleString('en-US'));
console.log('plus a raw URL instead of the route:', count({ ...base, routes: 25000 }).toLocaleString('en-US'));
console.log('plus a userId label:', count({ ...base, users: 50000 }).toLocaleString('en-US'));
$ node series-count.js route, method, status, pod: 31,680 plus a raw URL instead of the route: 19,800,000 plus a userId label: 1,584,000,000
Why it matters: one careless label turns about 32 thousand series into millions or billions, and Prometheus runs out of memory.
Structured logs
JSON lines with a request id on every entry, secrets redacted, and Nest’s own logger routed through the same pipe.
LoggerModule.forRoot({
pinoHttp: {
level: process.env.LOG_LEVEL ?? 'info',
genReqId: (req, res) => {
const id = req.headers['x-request-id'] ?? randomUUID();
res.setHeader('x-request-id', id);
return id;
},
redact: ['req.headers.authorization', 'req.headers.cookie', '*.password'],
customProps: () => ({ service: 'game-api' }),
transport: process.env.NODE_ENV !== 'production' ? { target: 'pino-pretty' } : undefined,
},
}),
Why it matters: one id ties every line of a request together, and tokens never reach the log store.
const app = await NestFactory.create(AppModule, { bufferLogs: true });
app.useLogger(app.get(Logger));
Why it matters: startup messages wait for Pino, so nothing prints in two formats.
Traces with OpenTelemetry
Start the SDK before Nest loads, let auto instrumentation cover HTTP, PostgreSQL and Redis, and name the steps that are yours.
Picture it: one slow request as a waterfall
The widest bar is where the time went. Here it is one query, not the code around it.
const sdk = new NodeSDK({
resource: resourceFromAttributes({ [ATTR_SERVICE_NAME]: 'game-api' }),
traceExporter: new OTLPTraceExporter(), // reads OTEL_EXPORTER_OTLP_ENDPOINT
instrumentations: [getNodeAutoInstrumentations({
'@opentelemetry/instrumentation-fs': { enabled: false },
})],
});
sdk.start(); // must run before express, pg or ioredis are imported
process.on('SIGTERM', () => sdk.shutdown().finally(() => process.exit(0)));
Why it matters: HTTP, pg, ioredis and Nest handlers get spans with no code changes, and Pino lines gain trace_id.
async score(move: MoveDto) {
return tracer.startActiveSpan('score-move', async (span) => {
span.setAttributes({ 'game.id': move.gameId, 'word.length': move.word.length });
try {
return await this.computeScore(move);
} catch (err) {
span.recordException(err);
span.setStatus({ code: SpanStatusCode.ERROR });
throw err;
} finally {
span.end();
}
});
}
Why it matters: the game id rides on the span, where one value costs one attribute instead of a new series.
Queues and sockets
HTTP metrics stop at the controller. Background jobs and WebSocket connections need their own numbers.
new Gauge({
name: 'bullmq_jobs',
help: 'Jobs by queue and state',
labelNames: ['queue', 'state'] as const,
registers: [registry],
async collect() { // runs only when Prometheus scrapes
const counts = await powerUps.getJobCounts('waiting', 'active', 'delayed', 'failed');
for (const [state, n] of Object.entries(counts)) this.set({ queue: powerUps.name, state }, n);
},
});
worker.on('completed', (job) =>
jobDuration.observe({ queue: job.queueName, name: job.name }, (job.finishedOn! - job.processedOn!) / 1000));
Why it matters: a rising waiting count is the earliest sign workers cannot keep up.
handleConnection() { this.metrics.wsClients.inc({ namespace: '/game' }); }
handleDisconnect() { this.metrics.wsClients.dec({ namespace: '/game' }); }
Why it matters: connected clients per pod shows uneven load, and a sudden drop often means a bad deploy.
Dashboards and alerts
Five PromQL queries cover most dashboards. Page on what users feel, and give each alert time to be sure.
| Panel | PromQL |
|---|---|
| Rate | sum by (route) (rate(http_server_duration_seconds_count[5m])) |
| Errors | sum(rate(http_server_duration_seconds_count{status=~"5.."}[5m])) / sum(rate(http_server_duration_seconds_count[5m])) |
| p95 | histogram_quantile(0.95, sum by (le, route) (rate(http_server_duration_seconds_bucket[5m]))) |
| Event loop | max by (pod) (nodejs_eventloop_lag_p99_seconds) |
| Backlog | sum by (queue) (bullmq_jobs{state="waiting"}) |
groups:
- name: game-api
rules:
- alert: HighErrorRate
expr: sum(rate(http_server_duration_seconds_count{status=~"5.."}[5m])) / sum(rate(http_server_duration_seconds_count[5m])) > 0.02
for: 10m
labels:
severity: page
annotations:
summary: "5xx above 2% for 10 minutes"
runbook: "https://wiki.internal/runbooks/game-api-errors"
Why it matters: a ratio means the same at 3am and at peak, and the runbook link is the first step of the fix.
- Condition trueexpr crosses 2%
- Pendingheld for 10m
- Firingsent to Alertmanager
- Routedgrouped, deduplicated
- Pagedwith the runbook
Which one do I need?
Start from the question you need answered, then reach for the signal that answers it cheapest.
| I want to know | Look at | Tool |
|---|---|---|
| Is the API failing more than usual? | Error ratio | Prometheus |
| Are users waiting too long? | p95 by route | Prometheus |
| Which step made this request slow? | The trace waterfall | Tempo |
| Why did this one move fail? | Log lines for its request id | Loki |
| Are workers keeping up? | Waiting jobs and job duration | Prometheus |
| Is one pod overloaded? | Sockets and event loop lag by pod | Prometheus |
| Should someone wake up? | A symptom alert with for and a runbook | Alertmanager |