Common mistakes when logging, debugging, responding to incidents and writing postmortems Intermediate
Estimated reading time: 10 min read
This page collects the mistakes developers commonly make, in four groups: logs & errors, debugging tools, during response, postmortems. The code behavior in the Node.js pairs was tested on Node v24.17.0; the PostgreSQL pairs ran on PostgreSQL 17.
| Group | Mistakes |
|---|---|
| Logs & errors | Logs without a correlation ID · Logging errors via string concatenation / JSON.stringify · Rethrowing without cause |
| Debugging tools | Alerting on averages · --cpu-prof produces no file · Report leaks env · Heap snapshot on a live pod |
| PostgreSQL | EXPLAIN ANALYZE really deletes · pg_cancel_backend on an idle session |
| During response | Restart destroys evidence · Repeated guesswork hotfixes · Status update missing information |
| Postmortem | Blaming individuals · Action items without owner / deadline |
Logging and error handling
Section titled “Logging and error handling”Logs without a correlation ID
Common
async function checkout(orderId: string) { console.log('starting checkout'); await callPayment(orderId); console.log('payment call timed out');}// Two parallel requests → 4 interleaved log lines, no way to tell which line belongs to which requestPrefer
import { AsyncLocalStorage } from 'node:async_hooks';
const als = new AsyncLocalStorage<{ requestId: string }>();const log = (msg: string) => console.log(JSON.stringify({ requestId: als.getStore()?.requestId, msg }));
// middleware: each request runs in its own contextapp.use((req, _res, next) => als.run({ requestId: req.get('x-request-id') ?? crypto.randomUUID() }, next));
// {"requestId":"req-a","msg":"starting checkout"}// {"requestId":"req-b","msg":"starting checkout"}// {"requestId":"req-b","msg":"payment call timed out"}// {"requestId":"req-a","msg":"payment call timed out"}Why
- During an incident, logs from hundreds of requests interleave. Without an ID you cannot filter one request, nor connect its logs to another service.
AsyncLocalStoragekeeps context acrossawaitwithout passing a parameter through every function. Details: Correlation ID.
Logging errors via string concatenation or JSON.stringify
Common
} catch (err) { logger.error('checkout failed: ' + err); // "checkout failed: Error: boom" logger.error(JSON.stringify({ orderId, err })); // {"orderId":"o1","err":{}}}Prefer
} catch (err) { // pass the Error object itself to a logger with an error serializer (e.g. pino, key `err`) logger.error({ err, orderId }, 'checkout failed'); // or at minimum: console.error(err) — prints the full stack and [cause]}Why
- Concatenation calls
err.toString(): onlyError: messageremains, stack and cause are lost. JSON.stringify(err)yields{}becausemessageandstackare non-enumerable properties. The log has an error line but nothing to investigate.
Catching an error and throwing a new Error without a cause
Common
try { await db.insert(order);} catch (e) { throw new Error('Could not create order');}// e.cause === undefined, the stack has no trace of the DB errorPrefer
try { await db.insert(order);} catch (e) { throw new Error('Could not create order', { cause: e });}// Error: Could not create order ... {// [cause]: Error: connect ECONNREFUSED 10.0.3.7:5432 at query (...)// }Why
- The original error (
ECONNREFUSED, deadlock, timeout) is what reveals the cause. Wrapping withoutcausedestroys evidence right in the code. - The
Erroroptioncauseexists since Node 16.9;console.log/util.inspectprint the whole[cause]chain.
Debugging tools
Section titled “Debugging tools”Alerting on average event loop delay
Common
const h = monitorEventLoopDelay({ resolution: 10 });h.enable();setInterval(() => { metrics.gauge('eventloop_delay_ms', h.mean / 1e6); // alert when > 100 h.reset();}, 10_000);Prefer
setInterval(() => { metrics.gauge('eventloop_delay_p99_ms', h.percentile(99) / 1e6); metrics.gauge('eventloop_delay_max_ms', h.max / 1e6); // alert on max h.reset();}, 10_000);Why
- Tested: a single 400 ms block in a 10-second window gives
mean=10.8ms,max=400.3ms. An alert on the mean never fires. - For those 400 ms, every request in the process is stalled — exactly the kind of incident you need to catch.
Enabling --cpu-prof and waiting for the pod to be killed to get a profile
Common
node --cpu-prof --cpu-prof-dir=/tmp/prof app.js# Pod gets OOMKilled (SIGKILL) or kubectl delete pod (SIGTERM, app has no handler)ls /tmp/prof # emptyPrefer
// the app exits "normally" on SIGTERM → V8 gets to write the .cpuprofileprocess.on('SIGTERM', async () => { await new Promise((r) => server.close(r)); // graceful shutdown process.exit(0);});Why
- Node writes the CPU profile before the process exits. Tested:
kill -9andkill -TERMwithout a handler both leave no file; with a handler that callsprocess.exit, the file is there. - An OOMKilled pod cannot be saved — reproduce it in another environment. See also graceful shutdown.
Writing a diagnostic report and posting the whole file in the chat channel
Common
process.report.writeReport('incident.json');// the file has "environmentVariables": { "DATABASE_URL": "postgres://...", "STRIPE_KEY": ... }Prefer
process.report.excludeEnv = true; // or run node --report-exclude-envprocess.report.excludeNetwork = true;process.report.writeReport('incident.json');Why
- By default the report contains every environment variable (tested:
excludeEnvdefaults tofalse, and the report has anenvironmentVariableskey). - The incident channel has many people, bots and saved history — a secret leaked there is a second incident.
Taking a heap snapshot on a pod that is serving traffic
Common action
kubectl exec api-7f9c -- kill -USR2 1 # --heapsnapshot-signal=SIGUSR2# the event loop stalls during the snapshot, memory doubles → OOMKilled mid-incidentAction to take
# 1. take the pod out of the Service: remove the label the selector uses (the ReplicaSet creates a replacement)kubectl label pod api-7f9c app-# 2. only then snapshot; or reproduce the leak in stagingkubectl exec api-7f9c -- kill -USR2 1Why
- A heap snapshot blocks the event loop for the whole capture and needs roughly twice the heap in memory.
- A pod receiving traffic will time out en masse, or get OOM killed and lose the snapshot too.
PostgreSQL
Section titled “PostgreSQL”EXPLAIN ANALYZE on a DELETE in production
Common
EXPLAIN ANALYZE DELETE FROM orders WHERE status = 'expired';-- "just looking at the plan" → 1000 rows actually deletedPrefer
BEGIN;EXPLAIN (ANALYZE, BUFFERS) DELETE FROM orders WHERE status = 'expired';ROLLBACK;-- data intactWhy
ANALYZEactually executes the statement. Tested on PostgreSQL 17: unwrapped,count(*)drops to 0; wrapped inBEGIN/ROLLBACK, it stays at 1000.- Even with a rollback, the statement still takes locks and runs for its full duration — do not run it under peak load.
Using pg_cancel_backend on an idle in transaction session
Common
SELECT pg_cancel_backend(pid) FROM pg_stat_activityWHERE state = 'idle in transaction';-- returns true, but the session is still "idle in transaction", still holding the connection and locksPrefer
SELECT pg_terminate_backend(pid) FROM pg_stat_activityWHERE state = 'idle in transaction' AND xact_start < now() - interval '1 minute';-- session killed, transaction rolled back; then set idle_in_transaction_session_timeoutWhy
pg_cancel_backendonly cancels the running statement; an idle session has nothing to cancel. Tested: after the cancel,stateis stillidle in transaction.- The pool stays exhausted, on-call thinks it is handled — another ten minutes lost.
During response
Section titled “During response”Restart first, think later, save nothing
Common action
kubectl rollout restart deploy/api# p99 back to normal... 3 hours later it climbs again. Heap, connections, locks at failure time: all goneAction to take
# < 1 minute to preserve evidence, then mitigatekubectl exec api-7f9c -- kill -USR2 1 # node --report-on-signal --report-exclude-envpsql -c "\copy (SELECT * FROM pg_stat_activity) TO 'activity-1405.csv' CSV HEADER"kubectl get deploy/api -o jsonpath='{.spec.template.spec.containers[0].image}'kubectl rollout restart deploy/apiWhy
- A restart makes the symptoms go away temporarily and wipes the state you need to find the cause.
- The incident comes back, and you start investigating from zero.
Repeated guesswork hotfixes instead of rolling back or turning off a flag
Common sequence
14:10 deploy v2.31.1 "try fix timeout" → still failing14:25 deploy v2.31.2 "increase pool size" → still failing14:40 deploy v2.31.3 "revert pool, add logs" → a different error appearsSequence to aim for
14:10 turn off flag invoice-export (or roll back v2.31 → v2.30) → p99 drops14:15 SLI stable, cause confirmed to be in v2.3115:30 fix with review + tests, redeploy calmlyWhy
- Each hotfix is an untested change stacked on a failing system, with 10-15 minutes of waiting for the pipeline.
- With many consecutive changes you cannot tell which one worked. Rollbacks and flags are cheap operations with known outcomes.
Status update without impact or next update time
Common update
Checking, looks like the DB. Will update later.Update to write
[SEV2][14:35 UTC] Checkout slow — MITIGATINGImpact: ~30% of checkout requests > 2s since 14:05 UTC. No data loss.Done: rolled back api v2.31 → v2.30 at 14:31, p99 is dropping.Next update: 15:00 UTC. IC: @anWhy
- Without the impact, support, sales and management do not know whether to notify customers.
- Without the next update time, everyone keeps messaging the IC, pulling the IC away from coordinating.
Postmortem
Section titled “Postmortem”A postmortem that blames an individual
Common sentence
Root cause: An forgot to add an index on customer_id when merging PR #812.Sentence to write
Root cause: the query filtering by customer_id ran a seq scan on a 40M-row table.Contributing factors: review had no step to look at EXPLAIN for new queries;staging only has 10k rows, so the problem did not show.Why
- “An forgot” fixes nothing, and next time the people involved will stay quiet — the postmortem loses its best source of information.
- Describing system conditions is what leads to action items: staging data, review steps, lint.
Vague action items with no owner and no deadline
Common action items
- Improve monitoring.- Remind the team to be more careful when adding new queries.- Owner: Backend team.Action items to write
1. [detect] Alert event loop delay max > 200ms for 5 minutes → checkout on-call Owner: @binh Due: 2026-10-25 Ticket: OPS-4122. [prevent] CI runs EXPLAIN for new queries on a 1M-row dataset Owner: @an Due: 2026-11-08 Ticket: OPS-413Why
- “Be more careful” is not verifiable and does not change the system; “the team” is not an owner, so nobody does it.
- Per the SRE Workbook, action items without a tracking process are often forgotten and the incident recurs.
Self-check
Section titled “Self-check”- You rolled back but p99 did not drop. What does that say about the initial hypothesis, and what is the next step?
- Why did the diagnostic report in Demo 1 not point at
buildInvoiceReportSync? Which tool would? - Latency is up but CPU is low: which three places do you look at first?
- Rewrite the sentence “Because An forgot to add an index” in a blameless spirit and propose a prevent action item.
Back to: Incidents and postmortems overview.