1. Courses
  2. System Design
  3. Running Software in Production
  4. Stop 1

40 minIntermediate

Observability: logs, metrics and traces

When a store manager says "the app is slow today", you need to answer three questions fast: is it really slow, for whom, and where does the time go. Logs, metrics and traces each answer one of them. This lesson wires all three into a NestJS API and adds an alert that wakes someone only when users are hurting.

By the end you can

  • Say which question logs, metrics and traces each answer, and pick the right one
  • Write structured JSON logs with nestjs-pino and one request id across every line
  • Expose RED metrics with a prom-client histogram and read them in Prometheus
  • Add OpenTelemetry tracing and read a trace waterfall
  • Write an alert rule tied to your SLO, and avoid alert fatigue

The everyday picture

A store has three ways to know what happened today. The daily sales report gives numbers: 1,240 bills, average basket $16, sales down 30% after 6 pm. It is small and cheap, and it tells you something is wrong, but not why. The receipt trail has one paper for every sale: time, cashier, items, payment method. It has every detail, but there are thousands, so you only read the few you need. The CCTV lets you follow one customer from the door to the counter and see where they waited: 2 minutes picking rice, 9 minutes in the queue at counter 3, 4 minutes waiting for a mobile wallet payment to confirm.

Software has the same three tools. Metrics are the sales report. Logs are the receipts. Traces are the CCTV that follows one request. A good team uses all three together: the report says "something is wrong since 6 pm", the CCTV shows "people wait at payment", and the receipts show "wallet timeouts on these bills".

Monitoring and observability

answers questions you thought of in advance: is the API up, is the disk full, is the error rate above 2%. is about questions you did not plan for: "why are goods received note (GRN) submits slow only for stores in one city, only since Tuesday?" You can answer that without shipping new code only if the system already sends out rich data. Monitoring is part of observability. You need both.

The data comes as three . They differ in what they hold and what they cost:

SignalWhat one item isAnswersCost
MetricsA number over time, such as requests per secondIs something wrong? Since when? How much?Low and fixed. Does not grow with traffic
LogsOne event with details, such as "GRN 8812 saved, 42 lines"What exactly happened to this request or this user?Grows with traffic. Usually the biggest bill
TracesOne request's path, split into timed stepsWhere did the time go? Which service failed?Medium. Usually you keep a sample

Quick check

Store managers say "the app has been slow since lunch". Which signal tells you fastest whether that is true, and since when?

Metrics Is something wrong? How bad, since when? numbers, cheap 5xx rate 2% Logs What happened to this one request? detail, costs disk req=7f3c INFO grn saved req=7f3c ERROR sms fail req=9a1d INFO login ok filter by request id Traces Where did the time go? sampled requests request 1.8 s db payment 1.6 s db
Three signals, three questions. A metric shows the 5xx rate crossing 2%. Logs, filtered by one request id, show what happened inside that request. A trace shows the request as timed steps, so the slow one stands out.

Logs: structured, with one request id

A log line like Saved GRN for store is useless at 3 a.m. Which GRN? Which store? Which request? A writes one JSON object per line, with named fields: {"level":"info","msg":"GRN saved","grnId":8812,"siteId":27,"reqId":"7f3c…"}. A log tool can then filter and count by any field, the way you would query a table.

The most important field is the . In a typical setup (see Load balancers), Nginx adds an X-Request-Id header to every request. The API puts that id on every log line it writes for the request, sends it on to any service it calls, and returns it in the response. One search for that id then shows the whole story, across all API copies.

Rules that keep logs useful and safe:

  • Use levels. error means someone may need to act. warn is unusual but handled. info is a normal business event. debug is off in production. If every 404 is an error, nobody reads errors.
  • Write to stdout. The app prints JSON lines. Docker or the platform collects them and ships them to a log store such as Grafana Loki or OpenSearch. The app never writes log files or talks to the log store itself.
  • Never log secrets or personal data. No passwords, tokens, OTPs or full card numbers. Customer phone numbers only if you truly need them, and masked. Use the logger's redact option so a mistake in one place cannot leak.
  • Log events, not loops. One line per GRN saved, not one per GRN line. Logs are often the largest observability cost, and they grow with traffic.

Metrics: RED for services, USE for resources

A is a number tracked over time. There are three basic kinds. A only goes up (total requests). A goes up and down (jobs waiting in a queue). A counts values into buckets: how many requests finished under 50 ms, under 100 ms, under 300 ms, and so on.

Two short checklists tell you which metrics to have. For every service that answers requests, use : Rate (requests per second), Errors (share that fail), Duration (latency as a histogram). For every resource, such as CPU, a database connection pool, a disk or a queue, use . Utilization is how busy it is, such as 70% CPU. Saturation is how much work is waiting, such as 40 requests queued for a database connection. Errors are failures. Saturation is the one people forget, and it is often the first warning. A pool at 100% use with nobody waiting is fine. A pool with 200 requests waiting is an outage starting.

Why a histogram for latency, and not the computed inside each server? Because you cannot combine percentiles. If three API copies report p95 values of 120, 140 and 900 ms, the average of those (387 ms) is not the p95 of all requests. Histogram buckets are plain counts, so Prometheus can add the buckets from all copies and then compute one true p95. Choose bucket edges around your (see Measuring speed and reliability: p99, SLOs and nines). If the target is "95% under 300 ms", put a bucket edge at exactly 0.3 seconds, and the share of good requests is one division.

Each different set of creates a separate time series. route="/v1/grns/:grnId" and status_code="200" are fine: a few dozen routes times a few status codes. A label with the order id, user id or full URL creates millions of series and can bring Prometheus down. This is called a explosion. Put ids in logs and traces, never in metric labels.

Prometheus collects metrics by : every 15 seconds it calls GET /metrics on each API copy and stores the numbers. Grafana draws dashboards from it.

Quick check

The database connection pool is 100% in use and 200 requests are waiting for a connection. Which USE number is the alarm here?

Traces: one request, step by step

A is the journey of one request. It is made of . Each span is one step with a start time, a duration, a name and a parent. The first span covers the whole request. Each database query, Redis call or outgoing HTTP call becomes a child span. Drawn on a timeline, a trace is a waterfall, and the slow step is the longest bar.

trace_id 4bf92f35 same id on every span 0 0.5 s 1.0 s 1.5 s POST /v1/orders 1,840 ms pg SELECT stock 34 ms pg UPDATE stock 32 ms HTTP POST payment 1,632 ms pg INSERT order 32 ms redis PUBLISH 3 ms 89% is the payment call DB total: about 100 ms
One trace for POST /v1/orders. The top span is the whole request; the five spans below are its children. Without the trace, the team might add database indexes. The waterfall shows that 1,632 of the 1,840 ms is spent waiting for the payment API.

When the API calls another of your services, the trace must follow. The caller adds a traceparent header (the W3C Trace Context standard) with the trace id and its own span id. The next service reads it and makes its spans children of that span. This passing-along is called , and the instrumentation libraries do it for you.

OpenTelemetry (OTel) is the open standard and set of libraries for producing traces, metrics and logs. Its Node.js patches http, Express, NestJS, pg, ioredis and pino when the process starts, so you get spans without touching your services. It sends data in the OTLP format to any backend: Jaeger or Grafana Tempo if you host it yourself, or a managed service. Because the format is standard, you can change the backend later without changing app code.

Storing every trace of a busy API is expensive. keeps a share, such as 10%. The simple kind decides at the start of the request. Keeping every failed or slow trace needs tail sampling in an OpenTelemetry Collector, which waits until the trace ends before deciding. Start with head sampling at 10 to 100% while traffic is small.

The best part comes when the signals link. With the pino instrumentation, every log line written during a traced request gets trace_id and span_id fields automatically. From a slow bar in the waterfall you jump to its logs, and from an error log you jump to its trace.

Quick check

A trace shows a 1.8-second checkout request, and 1.6 seconds of it is one child span for the call to the payment provider. What do you look at next?

Alerts that mean something

An alert interrupts a person, maybe at 3 a.m. So every alert must mean "users are hurting now, or soon will be, and a human can do something". Alert on symptoms users feel, tied to your SLO: error rate, latency, freshness of synced sales. Do not page on causes like CPU at 85%. High CPU with happy users is a good use of a server you pay for. Put causes on dashboards, where they help you find the reason once a symptom alert fires.

Too many alerts cause : people mute the channel, and then miss the real one. Rules that prevent it:

  • Two levels only. Page (phone, now) for user-facing problems. Ticket (look tomorrow) for slow trends such as a disk that will be full in 10 days.
  • Wait before firing. A for: 5m clause means the condition must stay true for 5 minutes, so one bad scrape does not wake anyone.
  • Guard low traffic. At 2 a.m., 1 failure out of 3 requests is a 33% error rate. Require a minimum request rate before paging.
  • Every alert has a runbook. A link says what it means and what to check first.
  • Review alerts monthly. Delete any alert that fired and needed no action.

A more advanced form alerts on the 's : page when the budget is being used up 10 or more times faster than the allowed pace. The Google SRE workbook describes it well. A plain threshold with for: is a good start.

Quick check

At 2 a.m. the API gets 3 requests a minute, and 1 of them fails. Which rule avoids waking someone for that?

Build it

Add JSON logs with a request id, RED metrics, OpenTelemetry traces and a Prometheus alert to a NestJS 10 API, then run Prometheus, Grafana and Jaeger next to it. The steps need Node and Docker on your machine. The two ideas at the core, computing a percentile and deciding when an alert should page, are exercises you can run in the browser in the Practice section below.

  1. Install the packages

    nestjs-pino 4.x supports NestJS 8 to 11. In 2026 prom-client was republished as @prometheus-io/client (Node 22 or later). The parts used below (Histogram, register, collectDefaultMetrics) work the same in both; read its changelog before switching.

    Terminal
    npm i nestjs-pino pino-http pino prom-client
    npm i @opentelemetry/sdk-node @opentelemetry/auto-instrumentations-node \
          @opentelemetry/exporter-trace-otlp-http @prisma/instrumentation
  2. Log JSON with one request id per request

    genReqId reuses the id Nginx sent, or makes a new one, and returns it to the client. redact replaces secrets with [Redacted] wherever they appear. Health checks and scrapes are not logged, because they would be most of the volume.

    TypeScript
    // src/app.module.ts
    import { randomUUID } from 'node:crypto';
    import { Module } from '@nestjs/common';
    import { LoggerModule } from 'nestjs-pino';
    import { stdTimeFunctions } from 'pino';
    
    @Module({
      imports: [
        LoggerModule.forRoot({
          pinoHttp: {
            level: process.env.LOG_LEVEL ?? 'info',
            timestamp: stdTimeFunctions.isoTime,          // "2026-10-06T20:59:16.471Z"
            genReqId: (req, res) => {
              const incoming = req.headers['x-request-id'];
              const id = typeof incoming === 'string' && incoming.length <= 64 ? incoming : randomUUID();
              res.setHeader('X-Request-Id', id);           // the app can show it on errors
              return id;
            },
            customProps: () => ({ service: 'ops-api', env: process.env.NODE_ENV ?? 'dev' }),
            customLogLevel: (_req, res, err) =>
              err || res.statusCode >= 500 ? 'error' : res.statusCode >= 400 ? 'warn' : 'info',
            serializers: {                                 // keep request logs short
              req: (req) => ({ id: req.id, method: req.method, url: req.url }),
              res: (res) => ({ statusCode: res.statusCode }),
            },
            redact: ['req.headers.authorization', 'req.headers.cookie', '*.password', '*.otp'],
            autoLogging: { ignore: (req) => req.url === '/metrics' || !!req.url?.startsWith('/health') },
          },
        }),
      ],
    })
    export class AppModule {}

    Make Nest's own logger go through pino, so startup messages are JSON too:

    TypeScript
    // src/main.ts
    import { NestFactory } from '@nestjs/core';
    import { Logger } from 'nestjs-pino';
    import { AppModule } from './app.module';
    
    async function bootstrap() {
      const app = await NestFactory.create(AppModule, { bufferLogs: true });
      app.useLogger(app.get(Logger));
      app.enableShutdownHooks();
      await app.listen(3000);
    }
    void bootstrap();
  3. Log business events with fields

    Keep using Nest's normal Logger. Pass an object first and the message second. The request id is added for you, even deep inside a service, because nestjs-pino tracks the current request with Node's AsyncLocalStorage.

    TypeScript
    // src/grn/grn.service.ts
    import { Injectable, Logger } from '@nestjs/common';
    
    @Injectable()
    export class GrnService {
      private readonly logger = new Logger(GrnService.name);
    
      async submit(grnId: number, siteId: number, lines: number) {
        // ... save the GRN ...
        this.logger.log({ grnId, siteId, lines }, 'GRN submitted');
      }
    }

    Output, one JSON object per line. The trace_id fields appear once tracing is on (step 5):

    Output
    {"level":30,"time":"2026-10-06T20:59:16.471Z","req":{"id":"7f3c9a2e-1b4d-4c8e-9a51-2f6d0b8e4c11","method":"POST","url":"/v1/grns/8812/submit"},"service":"ops-api","env":"dev","trace_id":"f2e2fc693b8b609819e923a12da4981f","span_id":"5394a3a85723a81b","context":"GrnService","grnId":8812,"siteId":27,"lines":42,"msg":"GRN submitted"}
    {"level":50,"time":"2026-10-06T20:59:16.477Z","req":{"id":"7f3c9a2e-1b4d-4c8e-9a51-2f6d0b8e4c11","method":"POST","url":"/v1/grns/8812/submit"},"service":"ops-api","env":"dev","trace_id":"f2e2fc693b8b609819e923a12da4981f","res":{"statusCode":503},"responseTime":8,"msg":"request errored"}

    Pino writes the level as a number: 30 is info, 40 warn, 50 error. In development, pipe the output through npx pino-pretty to read it as text.

  4. Expose RED metrics with a histogram

    One histogram gives all three RED numbers: its _count is the rate, filtering _count by status gives errors, and its buckets give duration. Label by the route pattern, never the real URL, and use unmatched for 404s so scanners hitting random paths cannot create new series.

    TypeScript
    // src/metrics/metrics.ts
    import { Controller, Get, Header, Injectable, NestMiddleware } from '@nestjs/common';
    import type { NextFunction, Request, Response } from 'express';
    import { collectDefaultMetrics, Histogram, register } from 'prom-client';
    
    collectDefaultMetrics();   // process CPU, memory, event-loop lag, GC
    
    export const httpDuration = new Histogram({
      name: 'http_request_duration_seconds',
      help: 'HTTP request duration in seconds',
      labelNames: ['method', 'route', 'status_code'] as const,
      buckets: [0.025, 0.05, 0.1, 0.2, 0.3, 0.5, 1, 2.5, 5],   // 0.3 = our SLO edge
    });
    
    @Injectable()
    export class MetricsMiddleware implements NestMiddleware {
      use(req: Request, res: Response, next: NextFunction) {
        const end = httpDuration.startTimer();
        res.once('finish', () => {
          end({
            method: req.method,
            route: req.route?.path ?? 'unmatched',     // '/v1/grns/:grnId/submit'
            status_code: String(res.statusCode),
          });
        });
        next();
      }
    }
    
    @Controller('metrics')
    export class MetricsController {
      @Get()
      @Header('Content-Type', register.contentType)
      scrape(): Promise<string> {
        return register.metrics();
      }
    }

    Register the controller, and apply the middleware to every route by having AppModule implement NestModule:

    TypeScript
    // in AppModule: controllers: [MetricsController], and
    export class AppModule implements NestModule {
      configure(consumer: MiddlewareConsumer) {
        consumer.apply(MetricsMiddleware).forRoutes('*');
      }
    }
    Terminal
    curl -s localhost:3000/metrics | grep 'grnId/submit",status_code="200"'
    Output
    http_request_duration_seconds_bucket{le="0.1",method="POST",route="/v1/grns/:grnId/submit",status_code="200"} 1810
    http_request_duration_seconds_bucket{le="0.3",method="POST",route="/v1/grns/:grnId/submit",status_code="200"} 1967
    http_request_duration_seconds_bucket{le="+Inf",method="POST",route="/v1/grns/:grnId/submit",status_code="200"} 2000
    http_request_duration_seconds_sum{method="POST",route="/v1/grns/:grnId/submit",status_code="200"} 183.4
    http_request_duration_seconds_count{method="POST",route="/v1/grns/:grnId/submit",status_code="200"} 2000
    # 1967 of 2000 under 0.3 s: 98.35% good on latency

    Do not put /metrics on the public internet. Block it in Nginx, or serve it on a separate internal port.

  5. Turn on OpenTelemetry tracing before the app loads

    This file must run first. Prisma does not use the pg driver by default, so it needs its own instrumentation. On Prisma 6.1 and later that is all; on Prisma 5, also add previewFeatures = ["tracing"] to the generator block.

    TypeScript
    // src/tracing.ts
    import { NodeSDK } from '@opentelemetry/sdk-node';
    import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
    import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
    import { PrismaInstrumentation } from '@prisma/instrumentation';
    
    const sdk = new NodeSDK({
      serviceName: process.env.OTEL_SERVICE_NAME ?? 'ops-api',
      traceExporter: new OTLPTraceExporter(),    // reads OTEL_EXPORTER_OTLP_ENDPOINT
      instrumentations: [
        getNodeAutoInstrumentations(),           // http, express, nestjs, pg, ioredis, pino...
        new PrismaInstrumentation(),             // one span per Prisma query
      ],
    });
    sdk.start();
    
    process.on('SIGTERM', () => {               // send the last spans before exit
      sdk.shutdown().finally(() => process.exit(0));
    });
    Terminal
    # package.json "start:prod": load tracing first with -r
    node -r ./dist/tracing.js dist/main.js
    
    # settings, read by the SDK from the environment
    OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger:4318     # /v1/traces is added for you
    OTEL_TRACES_SAMPLER=parentbased_traceidratio
    OTEL_TRACES_SAMPLER_ARG=0.2                        # keep 20% of new traces

    parentbased means: if the caller already decided to sample this trace, follow that decision, so a trace is never half-recorded across services.

  6. Run Prometheus, Grafana and Jaeger next to the API

    Add these services to the Docker Compose file that runs your API (the one from Scaling up vs scaling out works). The Jaeger image name and tag come from the Jaeger getting-started page; check it for the current version.

    YAML
    # docker-compose.yml (added to api, nginx, redis, db)
    services:
      api:
        command: ["node", "-r", "./dist/tracing.js", "dist/main.js"]
        environment:
          OTEL_EXPORTER_OTLP_ENDPOINT: http://jaeger:4318
          OTEL_SERVICE_NAME: ops-api
      prometheus:
        image: prom/prometheus:v3.5.0
        volumes:
          - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
          - ./alerts:/etc/prometheus/alerts:ro
        ports: ["127.0.0.1:9090:9090"]          # UI on this machine only
      grafana:
        image: grafana/grafana:12.0.0
        ports: ["127.0.0.1:3001:3000"]          # add Prometheus at http://prometheus:9090
      jaeger:
        image: cr.jaegertracing.io/jaegertracing/jaeger:2.21.0
        ports: ["127.0.0.1:16686:16686"]        # trace UI; OTLP 4318 stays internal
    YAML
    # prometheus.yml
    global:
      scrape_interval: 15s
      evaluation_interval: 15s
    rule_files:
      - /etc/prometheus/alerts/*.yml
    scrape_configs:
      - job_name: ops-api
        metrics_path: /metrics
        dns_sd_configs:              # "api" resolves to every copy of the API
          - names: ['api']
            type: A
            port: 3000
  7. Write the dashboard queries

    These PromQL queries give the RED row of a service dashboard, plus two USE numbers for the Node process. rate(...[5m]) means "per second, averaged over the last 5 minutes". Buckets are summed across copies before the percentile is computed.

    Output
    # Rate: requests per second, per route
    sum by (route) (rate(http_request_duration_seconds_count{job="ops-api"}[5m]))
    
    # Errors: share of requests that ended in 5xx
    sum(rate(http_request_duration_seconds_count{job="ops-api",status_code=~"5.."}[5m]))
      / sum(rate(http_request_duration_seconds_count{job="ops-api"}[5m]))
    
    # Duration: p95 per route, across all copies
    histogram_quantile(0.95,
      sum by (le, route) (rate(http_request_duration_seconds_bucket{job="ops-api"}[5m])))
    
    # SLI: share of requests under 300 ms
    sum(rate(http_request_duration_seconds_bucket{job="ops-api",le="0.3"}[5m]))
      / sum(rate(http_request_duration_seconds_count{job="ops-api"}[5m]))
    
    # USE for the process: CPU cores used, and event-loop lag (saturation)
    rate(process_cpu_seconds_total{job="ops-api"}[5m])
    nodejs_eventloop_lag_p99_seconds{job="ops-api"}
  8. Alert when more than 2% of requests fail for 5 minutes

    The and line is the low-traffic guard: no page unless there is at least one request every 2 seconds.

    YAML
    # alerts/ops-api.yml
    groups:
      - name: ops-api
        rules:
          - alert: OpsApiHigh5xxRate
            expr: |
              (
                sum(rate(http_request_duration_seconds_count{job="ops-api", status_code=~"5.."}[5m]))
                /
                sum(rate(http_request_duration_seconds_count{job="ops-api"}[5m]))
              ) > 0.02
              and sum(rate(http_request_duration_seconds_count{job="ops-api"}[5m])) > 0.5
            for: 5m                    # must stay true for 5 minutes
            labels:
              severity: page           # Alertmanager sends "page" to the on-call phone
            annotations:
              summary: "ops-api: {{ $value | humanizePercentage }} of requests failing"
              runbook_url: "https://wiki.example.com/runbooks/ops-api-5xx"
    
          - alert: OpsApiSlowP95
            expr: |
              histogram_quantile(0.95,
                sum by (le) (rate(http_request_duration_seconds_bucket{job="ops-api"}[5m]))
              ) > 0.3
            for: 15m
            labels:
              severity: ticket         # look at it in working hours
            annotations:
              summary: "ops-api p95 is {{ $value | humanizeDuration }} (SLO 300 ms)"

    Check the file before you load it. promtool ships with Prometheus; promtool test rules can also replay fake series against your rules, which is how you prove the low-traffic guard works.

    Terminal
    docker compose exec prometheus promtool check rules /etc/prometheus/alerts/ops-api.yml
    Output
    Checking /etc/prometheus/alerts/ops-api.yml
      SUCCESS: 2 rules found

    Prometheus only decides that an alert is firing. A separate Alertmanager service groups alerts and sends them to Slack, email, SMS or a phone app; add it when the rules are stable.

NeedSelf-hosted optionWhat it is good at
Metrics and alertsPrometheus + Alertmanager, Grafana for dashboardsCheap, standard, PromQL everywhere
LogsGrafana Loki, or OpenSearchLoki indexes only labels, so it is cheap; OpenSearch has full-text search
TracesJaeger or Grafana Tempo, fed by OpenTelemetryWaterfalls, service maps
Errors and crashesSentry (also offered as a hosted service)Grouped stack traces, release tracking, React Native crash reports

Bring it to your team

Ask these before a feature goes live, not after the first outage.

  • If this endpoint gets slow tomorrow, which dashboard shows it, and which trace shows why?
  • Do every log line and every error screen carry the request id, so support can find one store's failed request?
  • What are the RED numbers for this service, and the USE numbers for the database pool and the queue?
  • Which alert fires if users are hurting, and does it link to a runbook?
  • What personal data could end up in these logs, and where is it redacted?
  • How long do we keep logs and traces, and what does that cost per month at festival-peak traffic?

Red flags

  • "We will find out from the stores" as the plan for detecting outages.
  • Alerts on CPU, memory or every error log line, with nobody acting on most of them.
  • User ids, order ids or full URLs as metric labels.
  • Averages on the latency dashboard and no percentiles.
  • Logs that contain tokens, passwords or OTPs.

Practice

Compute a percentile

Write percentile(values, p) using the nearest-rank method: sort a copy of the latencies from fastest to slowest, compute rank = ceil(p × n / 100), where n is the number of values, and return the value at that rank (counting from 1). Return null for an empty list, and do not change the array you were given. The starter returns the average, which is the number this lesson warns you not to put on a latency dashboard.

JavaScript exerciseRuns in your browser with tests
function percentile(values, p) {
  // The average hides the slow requests. Replace it with nearest-rank.
  return values.reduce((sum, v) => sum + v, 0) / values.length;
}
Ctrl + Enter runs
Show a solution
JavaScript
function percentile(values, p) {
  if (values.length === 0) return null;
  const sorted = [...values].sort((a, b) => a - b);   // copy, then sort as numbers
  const rank = Math.ceil((p * sorted.length) / 100);   // nearest rank, 1-based
  return sorted[Math.max(rank, 1) - 1];
}

Two traps: sort() without a compare function sorts as text, so 2400 would come before 300; and sort() changes the array in place, which is why the solution copies it first.

Pass the exercise to finish this task.

Decide when an alert should page

Write shouldPage(minutes, options), a simplified version of the lesson's alert rule. minutes is a list of one-minute samples, oldest first, each { requests, errors }. options has maxErrorRate (default 0.02), minRps (default 0.5 requests per second) and forMinutes (default 5). Return true only when each of the last forMinutes minutes has an error rate strictly above maxErrorRate and at least minRps requests per second. With fewer samples than forMinutes, return false.

JavaScript exerciseRuns in your browser with tests
function shouldPage(minutes, { maxErrorRate = 0.02, minRps = 0.5, forMinutes = 5 } = {}) {
  // Pages on one bad minute, even at 2 a.m. with almost no traffic.
  const last = minutes[minutes.length - 1];
  return last.errors / last.requests > maxErrorRate;
}
Ctrl + Enter runs
Show a solution
JavaScript
function shouldPage(minutes, { maxErrorRate = 0.02, minRps = 0.5, forMinutes = 5 } = {}) {
  if (minutes.length < forMinutes) return false;          // not held long enough yet
  return minutes.slice(-forMinutes).every((m) =>
    m.requests / 60 >= minRps &&                           // the low-traffic guard
    m.errors / m.requests > maxErrorRate);                  // the symptom
}
Pass the exercise to finish this task.

Check yourself

Three API copies report p95 latencies of 120, 140 and 900 ms. How do you get the true p95 across all requests?

Which of these should page the on-call engineer at 3 a.m.?

A developer adds a user_id label to the request histogram "to find slow users". What happens?

Flashcards

Open my review

Key takeaways

  • Metrics tell you something is wrong, traces show where the time goes, logs show what happened to one request.
  • Log JSON to stdout, with one request id on every line and secrets redacted.
  • Track RED for services and USE for resources. Use histograms for latency and keep labels low-cardinality.
  • Load OpenTelemetry before the app, and link traces and logs by trace_id.
  • Page only on user-facing symptoms tied to the SLO, held for a few minutes, with a runbook.

Sources

Episode transcriptSales report, receipts and CCTV · about 13 minutes · tap any line to play from there
  1. NadiaThe app is slow today. Imran, how many times have you heard a store manager say that?
  2. ImranEvery week, Nadia. And my answer is usually, slow how? For whom? Since when? And nobody knows.
  3. NadiaThose are exactly the three questions. Is it really slow, for whom, and where does the time go. Today's lesson gives you a tool for each one.
  4. NadiaAnd if this is your first episode, welcome. This one opens the Running Software in Production course, and you can start right here. I'm a senior engineer at Mangrove Market, a grocery chain with about three hundred and twenty stores, and Imran is the product manager for our Store app.
  5. ImranThe app our store staff use on handheld scanners. So when it's slow, I hear about it first.
  6. NadiaAnd the picture is something you already have at every store. Three ways to know what happened today. First, the daily sales report. One thousand two hundred and forty bills, average basket sixteen dollars, sales down thirty percent after six pm.
  7. ImranSmall and cheap. It tells me something's wrong, but not why.
  8. NadiaSecond, the receipt trail. One paper per sale, with time, cashier, items, payment method. Every detail, but thousands of them, so you only read the few you need.
  9. ImranAnd third?
  10. NadiaThe CCTV. You follow one customer from the door to the counter. Two minutes picking rice, nine minutes in the queue at counter three, four minutes waiting for a mobile wallet payment to confirm.
  11. ImranSo in software, metrics are the sales report, logs are the receipts, and traces are the CCTV.
  12. NadiaExactly. The report says something's wrong since six, the CCTV shows people waiting at payment, and the receipts show wallet timeouts on these bills.
  13. ImranIs this the same as monitoring? We have monitoring already.
  14. NadiaMonitoring answers questions you thought of in advance. Is the API up? Is the error rate above two percent? Observability is about questions nobody planned for.
  15. ImranLike, why are GRN submits slow only for stores in one city, and only since Tuesday?
  16. NadiaThat's the lesson's example. You can only answer it without shipping new code if the system already sends out rich data. You need both, and monitoring is part of observability.
  17. NadiaIf you can see the screen, the first diagram shows the three signals stacked one above the other. Metrics at the top, with an error rate line crossing two percent. Logs in the middle, filtered by one request ID. A trace at the bottom, where one long bar stands out.
  18. ImranAnd they cost different amounts?
  19. NadiaVery different. Metrics are low and fixed, and don't grow with traffic. Logs grow with traffic and are usually the biggest bill. Traces are in between, because you usually keep only a sample.
  20. ImranLet's start with logs. We have plenty. Saved GRN for store, that kind of thing.
  21. NadiaAnd at three in the morning, that line is useless. Which GRN? Which store? Which request? A structured log writes one JSON object per line with named fields: the GRN ID, the site ID, the request ID.
  22. ImranSo a log tool can filter and count by any field, like querying a table.
  23. NadiaYes. And the most important field is the request ID. Nginx already adds one to every request. The API puts it on every log line for that request, passes it on to any service it calls, and returns it in the response.
  24. NadiaOne search for that ID shows the whole story, across every API copy. A few rules keep logs useful. Use levels properly. If every four oh four is an error, nobody reads errors.
  25. NadiaWrite to standard output, and let Docker ship the lines to a log store like Grafana Loki. Never log passwords, tokens or OTPs. And log events, not loops. One line per GRN saved, not one per GRN line.
  26. ImranNow metrics. Which ones should we actually have?
  27. NadiaTwo short checklists. For every service that answers requests, RED. Rate, requests per second. Errors, the share that fail. Duration, latency as a histogram.
  28. NadiaFor every resource, like CPU, a connection pool, a disk or a queue, USE. Utilization, how busy it is. Saturation, how much work is waiting. Errors.
  29. ImranSaturation is the one I'd never think of.
  30. NadiaIt's the one people forget, and often the first warning. A pool at a hundred percent use with nobody waiting is fine. A pool with two hundred requests waiting is an outage starting.
  31. ImranWhy a histogram for latency? Each server could just report its own p ninety-five, and we average them.
  32. NadiaThat's the trap. You can't combine percentiles. Three copies report p ninety-five of a hundred and twenty, a hundred and forty and nine hundred milliseconds. The average is about three hundred and eighty-seven. That isn't the p ninety-five of all requests.
  33. ImranBecause each copy served a different number of requests.
  34. NadiaRight. A histogram just counts requests into buckets: under fifty milliseconds, under a hundred, under three hundred. Counts add up, so Prometheus sums the buckets from every copy, then computes one true p ninety-five.
  35. NadiaAnd put a bucket edge exactly at your SLO. If the target is ninety-five percent under three hundred milliseconds, an edge at point three seconds makes the share of good requests one division.
  36. ImranThen I'd add a label for the user ID, so I can find slow users.
  37. NadiaPlease don't. Every unique label combination is its own time series. Ten buckets, twenty routes and five thousand users is about a million series, and Prometheus struggles. Route and status code are fine. IDs go in logs and traces.
  38. ImranThat's called a cardinality explosion, isn't it?
  39. NadiaYes. Then Prometheus collects the numbers by scraping. Every fifteen seconds it calls the metrics endpoint on each API copy, and Grafana draws dashboards from it.
  40. ImranNow traces, the CCTV.
  41. NadiaA trace is one request's journey, made of spans. Each span is one timed step: the whole request on top, then each database query, Redis call or outgoing HTTP call as a child.
  42. NadiaThe second diagram is a trace waterfall for one order request. The top bar is the whole request, one thousand eight hundred and forty milliseconds. Below it, five shorter bars. If you're listening only, picture four tiny bars and one huge one.
  43. ImranAnd the huge one is the database, I'd bet.
  44. NadiaThat's what the team would have guessed, and they'd have added indexes. But the database total is about a hundred milliseconds. The huge bar is the outgoing payment call, one thousand six hundred and thirty-two milliseconds. Eighty-nine percent of the time.
  45. ImranSo without the trace, we'd tune the wrong thing for a week.
  46. NadiaWhen the API calls another of your services, the caller sends a traceparent header, a W3C standard, so the next service adds its spans to the same trace. The libraries do that for you.
  47. NadiaThe libraries are OpenTelemetry, the open standard for traces, metrics and logs. Its Node auto-instrumentation patches http, NestJS, pg, ioredis and pino at startup, so you get spans without changing your services.
  48. ImranDo we keep every trace?
  49. NadiaThat gets expensive, so you sample, say ten percent. Start with ten to a hundred percent while traffic is small. And the best part: every log line written during a traced request gets the trace ID. From a slow bar you jump straight to its logs.
  50. ImranLast piece, alerts. Our current rule is basically alert on everything.
  51. NadiaAn alert interrupts a person, maybe at three in the morning. So it must mean users are hurting now, and a human can do something. Alert on symptoms users feel, tied to your SLO: error rate, latency, freshness of synced sales.
  52. ImranSo not CPU at eighty-five percent.
  53. NadiaHigh CPU with happy users is a good use of a server you pay for. Causes go on dashboards. Otherwise you get alert fatigue: people mute the channel, then miss the real one.
  54. NadiaSo, two levels only: page now, or ticket for tomorrow. Wait five minutes before firing. Guard low traffic, because at two am, one failure out of three requests is a thirty-three percent error rate. Give every alert a runbook. And review alerts monthly.
  55. ImranLet me tell a story we've lived at Mangrove. Store managers say GRN submit sometimes takes ten seconds.
  56. NadiaWithout observability, the team argues about the database. With it, the dashboard shows p ninety-five for GRN submit jumped from four hundred milliseconds to six seconds on Tuesday, for all stores.
  57. NadiaOne slow trace shows five and a half seconds inside an HTTP call that notifies the supplier system, done inline before the API replies. Logs with the same trace ID show two retries per call. The fix is to move that call to a queue.
  58. ImranAnd I like the product idea in the lesson. When a request fails, the app shows the last eight characters of the request ID as an error reference.
  59. NadiaAma reads that code to the help desk, and the engineer finds the exact request in seconds.
  60. NadiaOne common mistake. OpenTelemetry patches libraries when they're first loaded. If the app loads NestJS before the tracing setup runs, you get no spans and no error message. So load the tracing file first, with node dash r.
  61. NadiaThat leads into the Build-it, in words. First, nestjs-pino for JSON logs. It reuses the request ID Nginx sent, or makes one, returns it in a header, and redacts authorization headers, passwords and OTPs.
  62. ImranAnd services just use Nest's normal logger?
  63. NadiaYes, with an object of fields first and the message second. The request ID is added for you, even deep inside a service. Then one prom-client histogram gives all three RED numbers, labelled by route pattern, method and status code.
  64. NadiaThen the tracing file, loaded first, with auto-instrumentation plus Prisma's own instrumentation. Then Prometheus, Grafana and Jaeger in the Compose file, next to the API.
  65. ImranAnd the alert?
  66. NadiaPage when more than two percent of requests fail for five minutes, but only with at least one request every two seconds. A second rule opens a ticket if p ninety-five stays above three hundred milliseconds for fifteen minutes. Check both with promtool before loading them.
  67. ImranMy questions before a feature goes live. If this endpoint gets slow tomorrow, which dashboard shows it, and which trace shows why?
  68. NadiaGood. And does every error screen show the request ID, so support can find one store's failed request?
  69. ImranWhich alert fires if users are hurting, and does it link to a runbook? And what personal data could end up in these logs?
  70. NadiaFor practice, the lesson has two small functions to write in the page. One computes a percentile from a list of latencies. The other decides whether an alert should page, with the five minute hold and the low traffic guard.
  71. NadiaA question to think about. Which of these should wake the on-call engineer at three am? CPU above eighty percent for a minute, any error log line, or more than two percent of requests failing for five minutes with real traffic? Take a second.
  72. ImranThe last one. It's a symptom users feel, it lasted, and there's real traffic.
  73. NadiaExactly. High CPU with fast responses isn't a problem, and one error log is normal noise.
  74. ImranMy takeaways. One: metrics tell me something's wrong, traces show where the time goes, logs show what happened to one request. Two: JSON logs with one request ID on every line. Three: RED for services, USE for resources, histograms for latency.
  75. NadiaAnd one correction to keep: page only on user-facing symptoms, held a few minutes, with a runbook. Next time, the ID card at the staff door, and the bug where someone changes one number in a URL.
Finished reading?Mark it complete to fill in its stop on your course line.

Use it at work

Runbook

Symptoms, first checks with commands, fixes and when to escalate, for one alert.

Open in the DevOps & TechOps Lab
Next stopSecurity essentials
Was this lesson helpful?
Questions and discussion

Loading the discussion…