Lecture 30: Reliability and Observability: Log Levels¶
Tests prove your application works before you ship it. This lecture is about everything that keeps it working — and tells you why it broke — after it's live and real users depend on it: reliability patterns, deployment pipelines, and the discipline of logging.
In This Lecture¶
- Apply reliability practices: retries, circuit breakers, health checks, and graceful degradation
- Understand a CI/CD pipeline's stages and deployment strategies, including rollback
- Distinguish logs, metrics, and traces as complementary pillars of observability
- Use log levels (TRACE through FATAL) correctly and apply structured logging with correlation IDs
- Make sound decisions about log aggregation, retention, sensitive data, and signal vs. noise
Reliability Practices¶
Reliability is the probability that a system keeps doing what it's supposed to do, even when parts of it fail. Distributed systems fail constantly and in small ways — a network blip, a slow downstream service, a database connection pool exhausted for a few seconds — and reliable systems are built to absorb those failures rather than cascade them into full outages.
Retries¶
A retry re-attempts a failed operation, on the assumption that many failures (a dropped connection, a momentary timeout) are transient and will succeed on a second try.
async function fetchWithRetry(url, { retries = 3, baseDelayMs = 200 } = {}) {
for (let attempt = 0; attempt <= retries; attempt++) {
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} catch (err) {
if (attempt === retries) throw err;
const delay = baseDelayMs * 2 ** attempt; // exponential backoff
await new Promise((r) => setTimeout(r, delay + Math.random() * 100));
}
}
}
Naive retries can make outages worse
If every client retries immediately and simultaneously after a failure, you create a retry storm that hammers an already-struggling service and prevents it from recovering. Always use exponential backoff (waiting longer between each retry) and jitter (a small random delay) to spread retries out, and only retry operations that are safe to repeat (idempotent) — never blindly retry "charge this credit card."
Circuit Breakers¶
A circuit breaker wraps a call to a dependency and tracks its failure rate. If failures cross a threshold, the breaker "trips" (opens) and stops calling the failing dependency for a cooldown period, immediately failing fast (or falling back) instead — protecting both the caller from wasting time on a call that likely won't succeed, and the struggling dependency from added load while it recovers.
stateDiagram-v2
[*] --> Closed
Closed --> Open: failure rate exceeds threshold
Open --> HalfOpen: after cooldown period
HalfOpen --> Closed: trial request succeeds
HalfOpen --> Open: trial request fails
- Closed — requests pass through normally; failures are counted.
- Open — requests fail immediately (or return a fallback) without calling the dependency at all.
- Half-open — after a cooldown, a small number of trial requests are let through; if they succeed, the breaker closes again, otherwise it reopens.
Health Checks¶
A health check is an endpoint (commonly GET /health or /healthz) that reports
whether an application instance is able to serve traffic. Load balancers and orchestrators
(like Kubernetes) poll it to decide whether to route traffic to an instance, restart it, or
remove it from rotation.
app.get("/health", async (req, res) => {
const dbOk = await checkDatabaseConnection();
const cacheOk = await checkRedisConnection();
const healthy = dbOk && cacheOk;
res.status(healthy ? 200 : 503).json({
status: healthy ? "ok" : "degraded",
checks: { database: dbOk, cache: cacheOk },
uptimeSeconds: process.uptime(),
});
});
Liveness vs. readiness
Production systems often split this into two checks: a liveness check ("is the process alive at all, or should it be restarted?") and a readiness check ("is this instance ready to receive traffic right now?"). An instance can be alive but not ready — for example, still warming up a cache on startup.
Graceful Degradation¶
Graceful degradation means that when a non-critical dependency fails, the system keeps
serving its core functionality with reduced features, rather than failing entirely. If a
recommendation service is down, an e-commerce site should still let users browse and check
out — just without the "Recommended for you" section — instead of returning a 500 for
the whole page.
flowchart TB
Req["Incoming Request"] --> Core{"Core service<br/>(catalog, cart)"}
Core -->|healthy| Render["Render full page"]
Core -->|down| Fail["Return error — no degradation possible"]
Render --> Rec{"Recommendations<br/>service"}
Rec -->|healthy| Full["Show recommendations"]
Rec -->|down/timeout| Partial["Hide recommendations,<br/>show rest of page"]
CI/CD Pipelines and Deployment Strategies¶
A CI/CD pipeline automates moving code from a commit to running in production: build, test, and deploy, ideally with a fast, safe path back out if something goes wrong.
flowchart LR
Commit["Commit / PR"] --> Build["Build<br/>(compile, bundle, Docker image)"]
Build --> Test["Test<br/>(unit, integration, E2E)"]
Test --> Stage["Deploy to Staging"]
Stage --> Approve{"Automated checks<br/>+ approval"}
Approve -->|pass| Prod["Deploy to Production"]
Approve -->|fail| Reject["Block merge / deploy"]
Prod --> Monitor["Monitor error rate,<br/>latency, health checks"]
Monitor -->|regression detected| Rollback["Rollback to<br/>previous version"]
Monitor -->|healthy| Done["Deployment complete"]
Several deployment strategies limit the blast radius of a bad release:
- Rolling deployment — instances are updated a few at a time, so the application never fully goes down, but old and new versions run simultaneously for a while.
- Blue-green deployment — a full second environment ("green") is deployed alongside the live one ("blue"); traffic is switched over atomically once green is verified healthy, making rollback as simple as switching traffic back.
- Canary deployment — the new version is rolled out to a small percentage of traffic first; if error rates and latency stay healthy, the rollout gradually increases to 100%.
Rollback — reverting to the last known-good version — should be fast, automatic where possible (triggered by monitoring, covered in Lecture 31), and rehearsed. A deployment strategy is only as good as how quickly you can undo it.
# Simplified deploy stage with automatic rollback trigger
deploy:
needs: [build, test]
steps:
- run: ./deploy.sh --strategy=canary --percent=10
- run: ./watch-metrics.sh --duration=10m --max-error-rate=1%
- run: ./deploy.sh --strategy=canary --percent=100
if: success()
- run: ./rollback.sh
if: failure()
Logs, Metrics, and Traces¶
Once code is running in production, observability is your ability to understand its internal state from the outside — from the signals it emits. Three complementary signal types (often called the "three pillars of observability") work together:
flowchart LR
subgraph Logs["Logs"]
L["Discrete, timestamped events<br/>'What exactly happened?'"]
end
subgraph Metrics["Metrics"]
M["Numeric measurements over time<br/>'How much / how often / how fast?'"]
end
subgraph Traces["Traces"]
T["A request's path across services<br/>'Where did the time go?'"]
end
Logs --- Metrics --- Traces
- Logs are discrete, timestamped records of individual events — "user 42 logged in," "payment failed with error X." They answer "what exactly happened, and why?" and are the most detailed but most expensive to store and search at volume.
- Metrics are numeric measurements aggregated over time — request count, average latency, memory usage. They're cheap to store, easy to graph and alert on, and answer "how much / how often / how fast?" — but not "why." (Metrics are the subject of Lecture 31.)
- Traces follow a single request as it moves across multiple services, recording how long each step took. They answer "where did the time go, across this whole call chain?" and are essential once you have more than one service in the request path.
This lecture focuses on logs; Lecture 31 builds on metrics and traces for monitoring.
Log Levels¶
A log level (or severity) tags each log entry with how important or urgent it is, letting you filter noise from signal and configure verbosity differently per environment.
| Level | Meaning | Example |
|---|---|---|
| TRACE | Extremely fine-grained detail, function entry/exit, variable values | Entering calculateTax() with amount=100 |
| DEBUG | Diagnostic detail useful while developing or investigating a bug | Cache miss for key user:42, fetching from DB |
| INFO | Normal but noteworthy business events | User 42 completed checkout, order #8831 |
| WARN | Something unexpected happened but the system recovered or degraded gracefully | Recommendation service timed out, showing fallback |
| ERROR | An operation failed and needs attention, but the process is still running | Failed to send confirmation email to user 42 |
| FATAL | An unrecoverable error; the process cannot continue safely | Could not bind to port 3000, shutting down |
logger.trace("Entering applyDiscount", { price, code });
logger.debug("Cache miss", { key: `user:${userId}` });
logger.info("Order placed", { orderId, userId, total });
logger.warn("Payment provider slow, retrying", { attempt: 2 });
logger.error("Payment failed", { orderId, error: err.message });
logger.fatal("Database connection pool exhausted, exiting");
Set the level per environment, not per log statement
You don't delete TRACE/DEBUG statements before shipping — you configure a minimum
log level per environment. Production typically runs at INFO or WARN (so logs
stay affordable and readable); when investigating an incident, you temporarily lower
the level to DEBUG to get more detail, then raise it back.
Structured Logging and Correlation IDs¶
Structured logging emits each log entry as a machine-parseable object (usually JSON) with consistent fields, rather than a free-text sentence. This makes logs searchable and filterable by field in a log aggregation system, instead of relying on fragile text matching.
// Unstructured — hard to search reliably
console.log(`User ${userId} failed to log in from ${ip} at ${new Date()}`);
// Structured — queryable by any field
logger.warn("login_failed", {
userId,
ip,
reason: "invalid_password",
timestamp: new Date().toISOString(),
requestId,
});
A correlation ID (or request ID / trace ID) is a unique identifier generated when a request enters the system and passed along through every log statement, every internal service call, and every response header for that request. It lets you pull every log line related to one request or one user action across an entire distributed system, even one made of several microservices.
app.use((req, res, next) => {
req.requestId = req.headers["x-request-id"] || crypto.randomUUID();
res.setHeader("x-request-id", req.requestId);
next();
});
app.post("/api/orders", (req, res) => {
logger.info("order_creation_started", { requestId: req.requestId, userId: req.user.id });
// ... every downstream call and log statement propagates req.requestId
});
sequenceDiagram
participant Client
participant Gateway
participant Orders as Orders Service
participant Payments as Payments Service
Client->>Gateway: POST /checkout (no request ID)
Gateway->>Gateway: generate requestId=abc123
Gateway->>Orders: POST /orders (x-request-id: abc123)
Orders->>Orders: log "order_created" requestId=abc123
Orders->>Payments: POST /charge (x-request-id: abc123)
Payments->>Payments: log "charge_failed" requestId=abc123
Payments-->>Orders: 402 Payment Required
Orders-->>Gateway: 402
Gateway-->>Client: 402
Without abc123 tying every log line together, debugging this failure across three
services means guessing which lines belong to the same request from timestamps alone.
Log Aggregation, Retention, and Sensitive Data¶
In a system with more than one instance (which is nearly every production system), logs written to local disk are nearly useless — the instance that logged the error might be gone by the time you look. Log aggregation ships every instance's logs to a central system (e.g., the ELK/Elastic stack, Datadog, Grafana Loki, CloudWatch Logs) where they can be searched, filtered by correlation ID, and alerted on together.
flowchart LR
A1["App Instance 1"] --> Agg["Log Aggregator<br/>(Elasticsearch / Loki / CloudWatch)"]
A2["App Instance 2"] --> Agg
A3["App Instance 3"] --> Agg
Agg --> Dash["Search UI / Dashboards"]
Agg --> Alert["Alerting rules"]
Retention policies decide how long logs are kept before deletion — a balance between debugging/compliance needs and storage cost. A common pattern is tiered retention: recent logs (7–30 days) kept in fast, searchable storage; older logs archived to cheap cold storage (or deleted) after a defined period, sometimes driven by legal/compliance requirements rather than technical ones.
Never log sensitive data
Passwords, full credit card numbers, authentication tokens, and other secrets must never appear in logs — a log aggregation system is a much larger attack surface than a single database, often accessible to a wider set of engineers, and log retention can outlive the reason the data was collected. Mask or omit sensitive fields at the point of logging:
Personally identifiable information (PII) more broadly should be logged only when necessary and handled per your data protection obligations (e.g., GDPR).Signal vs. Noise: Deciding What to Log¶
Logging everything is as unhelpful as logging nothing — a flood of low-value log lines buries the ones that matter and costs real money at aggregation-system scale. A few guidelines:
- Log state transitions and decisions ("order moved to
shipped"), not every line of execution. - Log all errors and warnings with enough context (correlation ID, relevant IDs, the actual error) to diagnose without reproducing the bug.
- Avoid logging inside tight loops or very high-frequency code paths at
INFOor above — useDEBUG/TRACE, or aggregate into a metric instead (Lecture 31). - Prefer one structured log line per meaningful event over scattering several
console.logcalls across a function.
Ask: would this log line help me at 3 a.m.?
A good heuristic when deciding whether (and at what level) to log something: imagine being paged at 3 a.m. because this code path is failing in production. Would this log line help you figure out why, quickly, without redeploying with more logging first? If not, it's either noise or missing the context (like a correlation ID) that would make it useful.
Try It Yourself¶
- Add a health check endpoint to an existing Express project that checks a real
dependency (a database connection, or a mocked external API) and returns
200when healthy and503with details when not. - Take a small Express route handler and rewrite its
console.logcalls as structured log statements with appropriate levels (INFOfor the happy path,WARNfor a recovered failure,ERRORfor an unrecovered one), including a request ID on every line.
Key Takeaways¶
- Retries (with exponential backoff and jitter), circuit breakers, health checks, and graceful degradation are the core patterns for surviving partial failures without a full outage.
- A CI/CD pipeline automates build, test, and deploy; strategies like rolling, blue-green, and canary deployments limit the blast radius of a bad release, and fast rollback is essential.
- Logs, metrics, and traces are complementary observability signals — logs for "what happened," metrics for "how much/how often," traces for "where did the time go."
- Log levels (TRACE, DEBUG, INFO, WARN, ERROR, FATAL) let you control verbosity per environment without changing code.
- Structured logging with correlation IDs makes it possible to reconstruct one request's full path across a distributed system.
- Log aggregation centralizes logs from many instances; retention policies balance debugging needs against cost and compliance.
- Never log secrets or unnecessary PII, and be deliberate about signal vs. noise — log what would actually help you debug an incident.