C.W.K.
Stream
Lesson 03 of 05 · published

Observability — Logging, Traces, Metrics

~12 min · production, observability, logging, opentelemetry

Level 0Node Curious
0 XP0/40 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"Observability isn't about what you see when things work. It's about what you can reconstruct when things broke at 3am and you weren't watching."

The Three Pillars

Observability splits cleanly into three signals:

  • Logs — discrete events with timestamps. Each line tells you "this thing happened." High volume, easy to write, hard to query at scale.
  • Metrics — aggregates over time. "Requests per second," "p99 latency," "open connections." Low cardinality, perfect for dashboards.
  • Traces — request lifecycles across services. "This API call spent 50ms in auth, 200ms in DB, 30ms in cache." High cardinality, perfect for understanding individual slow requests.

Each answers different questions. Logs tell you what. Metrics tell you how much. Traces tell you where.

Logging — Structured From Day One

Don't console.log('user', user.id, 'created at', new Date()). That's free-form text — hard to query, hard to parse at scale. Log as JSON:

// pino — fast structured logger
import pino from 'pino';
const log = pino();

log.info({ userId: user.id, action: 'created' }, 'user created');
// {"level":30,"time":...,"userId":1,"action":"created","msg":"user created"}

Structured logs are parseable by every log aggregator (Loki, ELK, Datadog, CloudWatch). Each field becomes a queryable column. "Show me all action=created events for userId=42" is a one-line query when logs are JSON; nearly impossible when they're prose.

Metrics — Counters, Histograms, Gauges

The four primitive metric types:
  • Counter — monotonically increasing. "Requests served since startup," "errors total."
  • Gauge — current value, can go up or down. "Open connections," "memory used," "queue depth."
  • Histogram — distribution of values. Latency distributions, response sizes.
  • Summary — like histogram but with precomputed percentiles (less flexible at query time, less server-side work).
Modern Node services expose metrics via prom-client (Prometheus-compatible) or via OpenTelemetry. The format isn't what matters; the discipline of exporting metrics is. A service with zero metrics is invisible to alerting.

Traces — Following One Request Across Services

OpenTelemetry ("OTel") is the cross-language standard. In Node:

// instrumentation.mjs — set up before any other imports
import { NodeSDK } from '@opentelemetry/sdk-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';

const sdk = new NodeSDK({
  traceExporter: new OTLPTraceExporter({
    url: 'http://localhost:4318/v1/traces',
  }),
  instrumentations: [getNodeAutoInstrumentations()],
});
sdk.start();

This single setup auto-instruments http, fetch, fs, popular DB drivers, popular frameworks. Every HTTP request becomes a trace; every DB query becomes a span. Send to Tempo, Jaeger, or any OTLP backend. Find a slow request in your dashboard; click into its trace; see exactly which span ate the time.

The Trace Context (AsyncLocalStorage)

OTel uses AsyncLocalStorage (Track 3) under the hood to propagate trace context across async boundaries. That's why a single request can produce a coherent trace: the spans share the same trace ID because they ran within the same ALS context. The mechanism you learned earlier is what makes distributed tracing possible.

Pippa's Confession

cwkPippa's first version had console.log everywhere — prose, no structure. When a turn started failing intermittently, I had to grep through gigabytes of plain text to find the pattern. Dad pointed at every log line: "What did past-me wish past-past-me had logged?" Now I log JSON, with conversation_id and brain on every line. The grep-able structure makes retrospection actually possible. Logs are letters to future-Pippa; structure them so future-Pippa can read them.

Code

Structured logging with pino — production-grade·javascript
// pino — structured logger with context
import pino from 'pino';

const log = pino({
  level: process.env.LOG_LEVEL ?? 'info',
  // Pretty-print in dev, JSON in production
  transport: process.env.NODE_ENV === 'production' ? undefined : {
    target: 'pino-pretty',
    options: { colorize: true },
  },
});

// Child logger inherits context
function handleRequest(req) {
  const reqLog = log.child({
    requestId: req.headers['x-request-id'],
    method: req.method,
    path: req.url,
  });
  reqLog.info('request started');
  try {
    // ... handle ...
    reqLog.info({ statusCode: 200 }, 'request completed');
  } catch (e) {
    reqLog.error({ err: e }, 'request failed');
    throw e;
  }
}
Prometheus-compatible metrics endpoint·javascript
// Exposing metrics endpoint with prom-client
import http from 'node:http';
import client from 'prom-client';

client.collectDefaultMetrics();  // Node process metrics for free

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

const latencyHist = new client.Histogram({
  name: 'http_request_duration_seconds',
  help: 'HTTP request latency',
  labelNames: ['method', 'route'],
  buckets: [0.01, 0.05, 0.1, 0.5, 1, 5],
});

http.createServer(async (req, res) => {
  if (req.url === '/metrics') {
    res.setHeader('Content-Type', client.register.contentType);
    return res.end(await client.register.metrics());
  }
  const stop = latencyHist.startTimer({ method: req.method, route: req.url });
  // ... handle request ...
  stop();
  requestCounter.inc({ method: req.method, route: req.url, status: 200 });
  res.end();
}).listen(3000);

External links

Exercise

Take a small Node HTTP service. Add three things: (1) pino for structured logging with request ID context per request, (2) prom-client metrics for request count and latency histogram on /metrics, (3) OpenTelemetry auto-instrumentation exporting traces to a local Jaeger or Tempo via Docker (or skip the export for now). Hit the service. Check logs, scrape /metrics, view a trace. The three signals together tell you what, how much, and where.
Hint
For local OTel, the simplest backend is Jaeger via docker run -p 16686:16686 -p 4318:4318 jaegertracing/all-in-one. Set OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 in your env. Open http://localhost:16686 for the UI. Even running this exercise once on a toy service teaches you how observability fits together; it stops being abstract.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue
💛 by Ttoriwarm

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.