Logging & Observability

TypeScript Real World Applications — Logging & Observability
TypeScript Real World Applications
Course 3 · Chapter 5 · Logging & Observability

📡 Logging & Observability

Once an app is running on a server you can't attach a debugger to, logs, metrics, and traces are the only window into what actually happened. This chapter types that window properly: a structured logger interface you can inject and mock (Chapter 1's DI pattern), log levels and redaction rules the compiler enforces (Chapter 4's public-vs-secret split), and a light-touch introduction to metrics and tracing.

console.log Doesn't Scale

console.log is fine for a script on your machine. In production, across dozens of instances, it falls apart — no structure to search on, no severity to filter by, no way to correlate lines from the same request:

Unstructured vs Structured Logging

console.log
console.log(`User ${userId} placed order ${orderId} for $${total}`); // A plain string. Good luck querying "all orders over $500 that failed."
Structured Log
logger.info("order.placed", { userId, orderId, total }); // { level: "info", msg: "order.placed", userId: "u1", orderId: "o1", total: 500 } // Every field is queryable in your log aggregator (Datadog, ELK, CloudWatch...)

A Typed Logger Interface

Just like Database and Mailer back in Chapter 1, a Logger should be an interface your classes depend on — not a hardcoded call to a specific library:

type LogLevel = "debug" | "info" | "warn" | "error"; interface LogFields { [key: string]: string | number | boolean | undefined; } interface Logger { debug(event: string, fields?: LogFields): void; info(event: string, fields?: LogFields): void; warn(event: string, fields?: LogFields): void; error(event: string, fields?: LogFields): void; } class OrderService { constructor(private logger: Logger, /* db, mailer, ... */) {} placeOrder(order: Order) { this.logger.info("order.placed", { orderId: order.id, total: order.total }); } }

Interface, Not Library

Business logic depends on Logger, not pino or winston directly — swap the implementation without touching a single call site.

Event Names, Not Sentences

"order.placed" is grep-able and stable across languages/refactors; a full sentence isn't.

Redacting Secrets Automatically

Chapter 4 split PublicConfig from Secrets at the type level. The same discipline applies to logging — a typed logger can refuse to accept fields it doesn't trust:

A Redacting Console Logger

const SENSITIVE_KEYS = new Set(["password", "token", "secret", "authorization"]); function redact(fields: LogFields): LogFields { const safe: LogFields = {}; for (const [key, value] of Object.entries(fields)) { safe[key] = SENSITIVE_KEYS.has(key.toLowerCase()) ? "[REDACTED]" : value; } return safe; } class ConsoleLogger implements Logger { info(event: string, fields: LogFields = {}): void { console.log(JSON.stringify({ level: "info", event, ...redact(fields) })); } // debug/warn/error follow the same pattern }

📊 Metrics Basics

Logs answer "what happened in this one request." Metrics answer "how is the system behaving overall" — cheap to store, fast to query, ideal for dashboards and alerts:

Counter

Only goes up: requests_total, orders_placed_total. Good for rates ("orders per minute").

Gauge

Goes up or down: active_connections, queue_depth. A snapshot of "right now."

Histogram

Distribution of values: request_duration_ms. Lets you ask "what's the p95 latency?"

Typed Metric Names

A literal-union type for metric names catches typos the same way LogLevel caught invalid levels.

type CounterName = "orders.placed" | "orders.failed" | "requests.total"; interface Metrics { increment(name: CounterName, tags?: Record<string, string>): void; gauge(name: string, value: number): void; timing(name: string, ms: number): void; } // metrics.increment("orders.plaeced"); // ❌ typo caught at compile time metrics.increment("orders.placed", { region: "eu-west-1" }); // ✅

🧵 Tracing Basics

A single user action can fan out across several services. Tracing ties every log line and span from that one action together with a shared identifier:

interface RequestContext { requestId: string; // generated once, per incoming request userId?: string; } // Every log call within the request includes the same requestId, // so a log aggregator can filter to "everything that happened during this one request." function logWithContext(logger: Logger, ctx: RequestContext, event: string, fields: LogFields = {}) { logger.info(event, { requestId: ctx.requestId, userId: ctx.userId, ...fields }); }

Correlation ID

One ID per request, passed through every log line and downstream call — the simplest form of tracing.

Spans

Full tracing tools (OpenTelemetry) go further: each unit of work is a timed "span," nested to show where time was actually spent.

💻 Coding Challenges

Challenge 1: Build a Typed Logger Interface

Define a Logger interface with debug/info/warn/error methods, then implement a ConsoleLogger that prints each call as a single JSON line including a level field.

Goal: Practice designing an interface that could later be swapped for a real logging library without touching any calling code.

→ Solution

Challenge 2: Redact Sensitive Fields

Write a redact(fields: LogFields): LogFields function that replaces any field whose key matches password, token, or secret (case-insensitive) with "[REDACTED]", then wire it into your logger from Challenge 1.

Goal: Practice enforcing a safety rule in code rather than relying on developers to remember it.

→ Solution

Challenge 3: Add Request Correlation

Write a logWithContext helper that takes a Logger, a RequestContext (with a requestId), an event name, and extra fields — and merges the requestId into every logged event automatically.

Goal: Practice the correlation-ID pattern that makes multi-line, multi-service logs traceable back to one request.

→ Solution

⚠️ Gotcha: Logging Too Much Is Its Own Outage

Structured logging makes it easy to log everything — which sounds safe until a log aggregator bill spikes, or a debug log at request-per-millisecond volume slows down the very service it's meant to help diagnose. Log events that matter for debugging or auditing, use debug level generously in development but sparingly in production, and always run new log fields through the redaction rule from Challenge 2 before they ship.

🎯 What's Next

With visibility into a running app established, the next chapter goes back to where that data usually lives: Database Integration — ORM patterns with TypeORM and Prisma, query builders, migrations, and writing genuinely type-safe queries.