Error Handling & Observability
Purpose
Review the observability posture of a backend service: whether errors are caught and propagated correctly, whether logs carry enough context to diagnose a production failure with no debugger attached, whether metrics exist to detect anomalies before users report them, whether distributed traces connect a request across service boundaries, and whether internal error details ever leak to end users. The deliverable is a severity-rated findings list with concrete fixes — the goal is a service that is observable from day one, not after the first un-diagnosable incident.
When to use
- A production incident could not be diagnosed because logs were missing, truncated, or contextless — and you want to prevent a repeat.
- Adding structured logging to a service that currently uses unstructured
console.log.
- Implementing distributed tracing (OpenTelemetry) across multiple services.
- Reviewing a new service before production so it ships observable.
- A service silently drops errors in async code and failures only surface as user complaints.
When not to use
- The service is a CLI tool or short-lived script — structured logging and tracing are overkill; stderr and exit codes suffice.
- The goal is debugging one known error right now — use runtime-diagnostics for live triage.
- You only need to add a single log line — this skill is for systematic review, not one-off additions.
Procedure
- Audit the logging framework and format. Confirm a structured logger (Pino/Winston/Bunyan, structlog/loguru, zap/slog, Logback JSON) emitting JSON with at least
timestamp, level, message, and context fields. Unstructured console.log("User " + id) cannot be queried or alerted on.
- Verify a correlation/request ID flows through every log line. One request should emit handler, service, and DB log lines sharing one
requestId/traceId. Confirm the ID is generated at the edge (or read from X-Request-ID/traceparent), stored in async context (AsyncLocalStorage, contextvars, context.Context), and attached to every logger call.
- Review error propagation — surfacing or disappearing? Flag empty catches (
catch (e) {} / log-only with no re-throw), fire-and-forget async (missing await), uninspected Promise.allSettled rejections, and background-job handlers that only log without retry/alert/DLQ.
- Confirm error classification and HTTP status mapping. The global handler must map validation→400, auth→401, forbidden→403, missing→404, conflict→409, rate-limit→429, internal→500. A catch-all 500 for validation errors is a UX failure; sending a raw stack trace is a security failure.
- Check internal details never reach the client in production. Flag
res.json({ error: err.message }), { stack: err.stack }, or raw DB errors in responses. In production the body must be a user-safe message plus a reference ID; full detail goes to logs.
- Review metric instrumentation. Confirm
http_request_duration_ms (histogram by method/route/status), http_requests_total (counter), error rate by status family, per-integration error rate, and queue lag where applicable. No metrics library present is a critical gap.
- Review trace instrumentation. In a multi-service architecture, confirm the W3C
traceparent header is propagated outbound and extracted inbound, and that DB calls, queue ops, and external calls are wrapped in spans.
- Verify alerting coverage. At minimum: sustained 5xx rate over threshold, p99 latency over SLA, growing dead-letter-queue depth, and health-check failures. Document any missing alert as a gap.
- Check log-level hygiene.
DEBUG lines logging full request/response bodies (PII, tokens) must not run in production; level is env-controlled (LOG_LEVEL=info), and high-throughput routes log at debug with sampling to avoid a flood.
- Verify the health endpoint reflects readiness. Confirm
/health (and any /ready probe) checks real dependencies (DB, cache) rather than returning 200 unconditionally, so the load balancer and alerts react to actual degradation.
- Check redaction at the logging boundary. Confirm a serializer or redaction list strips
password, authorization, tokens, and card data before any log line is written, so a future log.info({ req }) cannot leak secrets.
Concrete checks
- Structured JSON logger in use (not
console.log); every line has timestamp, level, message.
- Correlation/request ID generated at the edge and present on every log line via async context.
- No empty or swallowing catch blocks; all errors logged and propagated.
- No fire-and-forget async in critical paths; all async operations awaited.
Promise.allSettled results explicitly inspected for rejections.
- Global handler maps error types to correct status codes (400/401/403/404/409/429/500).
- Raw error messages, stack traces, and DB errors never appear in production response bodies.
- Error responses include a
requestId the user can quote in a support ticket.
- Key HTTP metrics instrumented: request duration (histogram), total count, error count.
traceparent propagated on all outbound calls; extracted on inbound.
- Background-job / consumer failures land in a DLQ with the error logged.
DEBUG disabled in production; log level controlled by env var.
- Alerts defined for sustained 5xx rate, p99 latency, and DLQ depth.
- Metric label cardinality is bounded (route templates, status families) — no raw IDs, emails, or URLs as labels.
- The error object is logged as a structured field, not interpolated into a message string.
- Log severity is mapped to level deliberately (errors at
error, query noise at debug).
- Trace spans cover the data and integration layers, not just the API edge.
- The health endpoint reflects real dependency status rather than returning 200 unconditionally.
- A single global error handler maps errors to status codes — not scattered ad-hoc
res.status(500) calls.
- A redaction list at the logger boundary strips
password, authorization, tokens, and card data before any line is written.
Commands
# Unstructured logging that cannot be queried/alerted
rg -n "console\.(log|error|warn)\(" src/ | wc -l
rg -n "console\.(log|error)\(" src/ | head -20
# Swallowed errors and fire-and-forget async
rg -nU "catch\s*\([^)]*\)\s*\{\s*(\}|//|console\.log)" src/
rg -nU "allSettled\([\s\S]{0,200}" src/ | rg -v "status|reason"
# Internal detail leaking to the client
rg -n "err\.message|err\.stack|error\.stack|\.stack\b" src/ | rg "res\.|reply\.|json\("
# Correlation ID plumbing present?
rg -n "AsyncLocalStorage|contextvars|requestId|traceId|X-Request-Id|traceparent" src/
# Metrics + tracing libraries present?
rg -n "prom-client|statsd|@opentelemetry|histogram|Counter\(" src/ | head
rg -n "traceparent|propagation|startSpan|tracer\." src/ | head
# Log level controlled by env, not hardcoded debug
rg -n "LOG_LEVEL|level:\s*['\"](debug|trace)" src/
# High-cardinality metric labels (kills the metrics backend)
rg -nU "(labels|tags)\([\s\S]{0,80}(userId|email|id|url|path)\b" src/
# Bodies/headers being logged (PII + token leak)
rg -n "log.*(req\.body|req\.headers|request\.body|password|authorization)" -i src/
# Sensitive fields making it into responses
rg -n "res\.(json|send)\(" src/ | rg -i "password|token|secret|stack|hash"
# Is there a single global error handler, or scattered ad-hoc 500s?
rg -n "app\.use\(.*err|errorHandler|@Catch\(|exceptionHandler|setErrorHandler" src/
rg -n "res\.status\(500\)" src/ | wc -l # many scattered 500s = no central mapping
# Redaction configured at the logger boundary?
rg -n "redact|censor|sanitize|paths:\s*\[|filterKeys" src/ | rg -i "password|authorization|token" \
|| echo "no logger redaction list found"
# Key HTTP metrics actually emitted (duration histogram, error counter)?
rg -n "http_request_duration|requestDuration|http_requests_total|observe\(|inc\(" src/ || echo "no HTTP metrics found"
Correct vs incorrect patterns
// WRONG: swallows the error and leaks internals to the client
try { await charge(order); }
catch (e) { console.log(e); res.status(500).json({ error: e.message, stack: e.stack }); }
// RIGHT: structured log with context, user-safe response with a reference ID
try { await charge(order); }
catch (e) {
log.error({ err: e, requestId: ctx.requestId, orderId: order.id }, 'charge failed');
res.status(502).json({ error: 'Payment provider unavailable', requestId: ctx.requestId });
}
Error class to HTTP status (the global handler's job)
| Error class |
Status |
Client body |
| ValidationError |
400 |
field-level messages |
| AuthenticationError |
401 |
"authentication required" |
| AuthorizationError |
403 |
"not permitted" |
| NotFoundError |
404 |
"resource not found" |
| ConflictError |
409 |
"already exists / version conflict" |
| RateLimitError |
429 |
"slow down" + Retry-After |
| Unhandled / unknown |
500 |
"something went wrong" + requestId |
Common issues & anti-patterns
- Correlation ID only in the handler. The ID is logged on handler entry/exit but the service and DB layers use a separate logger with no ID, making middle-layer errors un-correlatable in production.
console.error(err) instead of a structured call. Produces an unparseable multi-line string in the aggregator that cannot be searched or alerted on.
- Generic 500 for everything. A validation error returning 500 trips error-rate alerts and pages on-call for what is a client mistake.
- Stack traces in API responses. Leaks file paths, library versions, and code structure to attackers and clutters client logs.
- Unbounded metric label cardinality. Using
userId or a full URL as a label creates millions of time series and kills the metrics backend. Use route templates (/api/orders/:id), never raw values.
- Logging the whole error object as a string.
log.info(\failed: ${err}`) discards the stack and structure. Pass the error as a field ({ err }`) so the logger serializes it properly.
- One log level for everything. Logging every DB query at
info floods the aggregator and hides real signals; logging errors at info means alerts never fire. Map severity to level deliberately.
- Trace that stops at the gateway. Spans exist for the API edge but DB calls and outbound HTTP have none, so a slow request shows a gap with no detail. Instrument the data and integration layers.
- Alerting on raw error count. Paging on "any 5xx" pages on a single transient blip. Alert on a sustained rate over a window instead.
- Health check that never fails. A
/health that returns 200 unconditionally tells the load balancer the instance is fine while the DB is down. Reflect real dependencies.
Required output
Report must include: logging framework assessment (structured vs unstructured, fields present/missing); correlation-ID coverage (propagated / partial / missing with gaps); error-swallowing findings (empty catches, fire-and-forget, unhandled rejections by location); HTTP status-mapping assessment (types mapped to wrong codes); client error-exposure findings (response bodies leaking internals); metric coverage (present vs missing); trace propagation status (inbound extraction + outbound injection); alert coverage (existing + gaps); and a severity rating per finding.
Safety
- Do not suggest disabling error logging to cut log volume — fix verbosity at the source or use sampling.
- Do not add
console.log(req.body) for debugging and leave it in — it logs credentials and PII on every request.
- When quoting log output in the report, redact any actual user data, tokens, or secrets first.
- Recommend strengthening error classification, never collapsing distinct errors into a single opaque 500.
Completion criteria
Done means the logging framework, correlation-ID coverage, error-propagation paths, status mapping, client-exposure surface, metrics, traces, and alerts are each assessed from evidence, every gap has a file:line (or "absent") and a severity, and no sensitive value appears unredacted in the report.
1---2name: error-handling-observability3description: Use when you need to review error handling, logging, metrics, tracing, and user-safe diagnostics.4---56# Error Handling & Observability78## Purpose9Review the observability posture of a backend service: whether errors are caught and propagated correctly, whether logs carry enough context to diagnose a production failure with no debugger attached, whether metrics exist to detect anomalies before users report them, whether distributed traces connect a request across service boundaries, and whether internal error details ever leak to end users. The deliverable is a severity-rated findings list with concrete fixes — the goal is a service that is observable from day one, not after the first un-diagnosable incident.1011## When to use12- A production incident could not be diagnosed because logs were missing, truncated, or contextless — and you want to prevent a repeat.13- Adding structured logging to a service that currently uses unstructured `console.log`.14- Implementing distributed tracing (OpenTelemetry) across multiple services.15- Reviewing a new service before production so it ships observable.16- A service silently drops errors in async code and failures only surface as user complaints.1718## When not to use19- The service is a CLI tool or short-lived script — structured logging and tracing are overkill; stderr and exit codes suffice.20- The goal is debugging one known error right now — use runtime-diagnostics for live triage.21- You only need to add a single log line — this skill is for systematic review, not one-off additions.2223## Procedure241. **Audit the logging framework and format.** Confirm a structured logger (Pino/Winston/Bunyan, structlog/loguru, zap/slog, Logback JSON) emitting JSON with at least `timestamp`, `level`, `message`, and context fields. Unstructured `console.log("User " + id)` cannot be queried or alerted on.252. **Verify a correlation/request ID flows through every log line.** One request should emit handler, service, and DB log lines sharing one `requestId`/`traceId`. Confirm the ID is generated at the edge (or read from `X-Request-ID`/`traceparent`), stored in async context (`AsyncLocalStorage`, `contextvars`, `context.Context`), and attached to every logger call.263. **Review error propagation — surfacing or disappearing?** Flag empty catches (`catch (e) {}` / log-only with no re-throw), fire-and-forget async (missing `await`), uninspected `Promise.allSettled` rejections, and background-job handlers that only log without retry/alert/DLQ.274. **Confirm error classification and HTTP status mapping.** The global handler must map validation→400, auth→401, forbidden→403, missing→404, conflict→409, rate-limit→429, internal→500. A catch-all 500 for validation errors is a UX failure; sending a raw stack trace is a security failure.285. **Check internal details never reach the client in production.** Flag `res.json({ error: err.message })`, `{ stack: err.stack }`, or raw DB errors in responses. In production the body must be a user-safe message plus a reference ID; full detail goes to logs.296. **Review metric instrumentation.** Confirm `http_request_duration_ms` (histogram by method/route/status), `http_requests_total` (counter), error rate by status family, per-integration error rate, and queue lag where applicable. No metrics library present is a critical gap.307. **Review trace instrumentation.** In a multi-service architecture, confirm the W3C `traceparent` header is propagated outbound and extracted inbound, and that DB calls, queue ops, and external calls are wrapped in spans.318. **Verify alerting coverage.** At minimum: sustained 5xx rate over threshold, p99 latency over SLA, growing dead-letter-queue depth, and health-check failures. Document any missing alert as a gap.329. **Check log-level hygiene.** `DEBUG` lines logging full request/response bodies (PII, tokens) must not run in production; level is env-controlled (`LOG_LEVEL=info`), and high-throughput routes log at `debug` with sampling to avoid a flood.3310. **Verify the health endpoint reflects readiness.** Confirm `/health` (and any `/ready` probe) checks real dependencies (DB, cache) rather than returning 200 unconditionally, so the load balancer and alerts react to actual degradation.3411. **Check redaction at the logging boundary.** Confirm a serializer or redaction list strips `password`, `authorization`, tokens, and card data before any log line is written, so a future `log.info({ req })` cannot leak secrets.3536## Concrete checks37- Structured JSON logger in use (not `console.log`); every line has `timestamp`, `level`, `message`.38- Correlation/request ID generated at the edge and present on every log line via async context.39- No empty or swallowing catch blocks; all errors logged and propagated.40- No fire-and-forget async in critical paths; all async operations awaited.41- `Promise.allSettled` results explicitly inspected for rejections.42- Global handler maps error types to correct status codes (400/401/403/404/409/429/500).43- Raw error messages, stack traces, and DB errors never appear in production response bodies.44- Error responses include a `requestId` the user can quote in a support ticket.45- Key HTTP metrics instrumented: request duration (histogram), total count, error count.46- `traceparent` propagated on all outbound calls; extracted on inbound.47- Background-job / consumer failures land in a DLQ with the error logged.48- `DEBUG` disabled in production; log level controlled by env var.49- Alerts defined for sustained 5xx rate, p99 latency, and DLQ depth.50- Metric label cardinality is bounded (route templates, status families) — no raw IDs, emails, or URLs as labels.51- The error object is logged as a structured field, not interpolated into a message string.52- Log severity is mapped to level deliberately (errors at `error`, query noise at `debug`).53- Trace spans cover the data and integration layers, not just the API edge.54- The health endpoint reflects real dependency status rather than returning 200 unconditionally.55- A single global error handler maps errors to status codes — not scattered ad-hoc `res.status(500)` calls.56- A redaction list at the logger boundary strips `password`, `authorization`, tokens, and card data before any line is written.5758## Commands59```bash60# Unstructured logging that cannot be queried/alerted61rg -n "console\.(log|error|warn)\(" src/ | wc -l62rg -n "console\.(log|error)\(" src/ | head -206364# Swallowed errors and fire-and-forget async65rg -nU "catch\s*\([^)]*\)\s*\{\s*(\}|//|console\.log)" src/66rg -nU "allSettled\([\s\S]{0,200}" src/ | rg -v "status|reason"6768# Internal detail leaking to the client69rg -n "err\.message|err\.stack|error\.stack|\.stack\b" src/ | rg "res\.|reply\.|json\("7071# Correlation ID plumbing present?72rg -n "AsyncLocalStorage|contextvars|requestId|traceId|X-Request-Id|traceparent" src/7374# Metrics + tracing libraries present?75rg -n "prom-client|statsd|@opentelemetry|histogram|Counter\(" src/ | head76rg -n "traceparent|propagation|startSpan|tracer\." src/ | head7778# Log level controlled by env, not hardcoded debug79rg -n "LOG_LEVEL|level:\s*['\"](debug|trace)" src/80```81```bash82# High-cardinality metric labels (kills the metrics backend)83rg -nU "(labels|tags)\([\s\S]{0,80}(userId|email|id|url|path)\b" src/8485# Bodies/headers being logged (PII + token leak)86rg -n "log.*(req\.body|req\.headers|request\.body|password|authorization)" -i src/8788# Sensitive fields making it into responses89rg -n "res\.(json|send)\(" src/ | rg -i "password|token|secret|stack|hash"9091# Is there a single global error handler, or scattered ad-hoc 500s?92rg -n "app\.use\(.*err|errorHandler|@Catch\(|exceptionHandler|setErrorHandler" src/93rg -n "res\.status\(500\)" src/ | wc -l # many scattered 500s = no central mapping9495# Redaction configured at the logger boundary?96rg -n "redact|censor|sanitize|paths:\s*\[|filterKeys" src/ | rg -i "password|authorization|token" \97 || echo "no logger redaction list found"9899# Key HTTP metrics actually emitted (duration histogram, error counter)?100rg -n "http_request_duration|requestDuration|http_requests_total|observe\(|inc\(" src/ || echo "no HTTP metrics found"101```102103## Correct vs incorrect patterns104```ts105// WRONG: swallows the error and leaks internals to the client106try { await charge(order); }107catch (e) { console.log(e); res.status(500).json({ error: e.message, stack: e.stack }); }108109// RIGHT: structured log with context, user-safe response with a reference ID110try { await charge(order); }111catch (e) {112 log.error({ err: e, requestId: ctx.requestId, orderId: order.id }, 'charge failed');113 res.status(502).json({ error: 'Payment provider unavailable', requestId: ctx.requestId });114}115```116117## Error class to HTTP status (the global handler's job)118| Error class | Status | Client body |119|-------------|--------|-------------|120| ValidationError | 400 | field-level messages |121| AuthenticationError | 401 | "authentication required" |122| AuthorizationError | 403 | "not permitted" |123| NotFoundError | 404 | "resource not found" |124| ConflictError | 409 | "already exists / version conflict" |125| RateLimitError | 429 | "slow down" + Retry-After |126| Unhandled / unknown | 500 | "something went wrong" + requestId |127128## Common issues & anti-patterns129- **Correlation ID only in the handler.** The ID is logged on handler entry/exit but the service and DB layers use a separate logger with no ID, making middle-layer errors un-correlatable in production.130- **`console.error(err)` instead of a structured call.** Produces an unparseable multi-line string in the aggregator that cannot be searched or alerted on.131- **Generic 500 for everything.** A validation error returning 500 trips error-rate alerts and pages on-call for what is a client mistake.132- **Stack traces in API responses.** Leaks file paths, library versions, and code structure to attackers and clutters client logs.133- **Unbounded metric label cardinality.** Using `userId` or a full URL as a label creates millions of time series and kills the metrics backend. Use route templates (`/api/orders/:id`), never raw values.134- **Logging the whole error object as a string.** `log.info(\`failed: ${err}\`)` discards the stack and structure. Pass the error as a field (`{ err }`) so the logger serializes it properly.135- **One log level for everything.** Logging every DB query at `info` floods the aggregator and hides real signals; logging errors at `info` means alerts never fire. Map severity to level deliberately.136- **Trace that stops at the gateway.** Spans exist for the API edge but DB calls and outbound HTTP have none, so a slow request shows a gap with no detail. Instrument the data and integration layers.137- **Alerting on raw error count.** Paging on "any 5xx" pages on a single transient blip. Alert on a sustained rate over a window instead.138- **Health check that never fails.** A `/health` that returns 200 unconditionally tells the load balancer the instance is fine while the DB is down. Reflect real dependencies.139140## Required output141Report must include: **logging framework assessment** (structured vs unstructured, fields present/missing); **correlation-ID coverage** (propagated / partial / missing with gaps); **error-swallowing findings** (empty catches, fire-and-forget, unhandled rejections by location); **HTTP status-mapping assessment** (types mapped to wrong codes); **client error-exposure findings** (response bodies leaking internals); **metric coverage** (present vs missing); **trace propagation status** (inbound extraction + outbound injection); **alert coverage** (existing + gaps); and a **severity rating per finding**.142143## Safety144- Do not suggest disabling error logging to cut log volume — fix verbosity at the source or use sampling.145- Do not add `console.log(req.body)` for debugging and leave it in — it logs credentials and PII on every request.146- When quoting log output in the report, redact any actual user data, tokens, or secrets first.147- Recommend strengthening error classification, never collapsing distinct errors into a single opaque 500.148149## Completion criteria150Done means the logging framework, correlation-ID coverage, error-propagation paths, status mapping, client-exposure surface, metrics, traces, and alerts are each assessed from evidence, every gap has a file:line (or "absent") and a severity, and no sensitive value appears unredacted in the report.