Debugging Production Incidents: A Systematic Framework
It's 2:47 a.m. Checkout latency is climbing, error rates are spiking in one region, and three people are typing over each other in the incident channel. Nobody knows if this started five minutes ago or fifty. This is where most debugging of production issues actually happens, and it's exactly where ad hoc debugging fails.
Debugging production issues is different from debugging in development. You can't attach a debugger, you can't reproduce at will, and every minute of diagnosis has a customer-facing cost. What you need instead is a framework: a fixed sequence of decisions that works when you're tired, stressed and looking at a system someone else built. This article gives you that framework: stabilize first, check what changed, measure with USE and RED, bisect by layer, then decide between rollback and fix-forward.
This is a companion piece to our broader root cause analysis guide. That article covers the full RCA lifecycle including the postmortem. This one covers the part where the pager is still going off.
01First
Stabilize
Reduce customer impact with coarse, reversible levers.
02Cheapest check
What changed
Deploys, config, flags, data and scheduled work near onset.
03Hypotheses
USE and RED
Saturated resource or sick service: metrics pick the direction.
04Search
Bisect layers
Test a midpoint and cut the search space in half.
05Decide
Roll back or fix
A rubric, not a debate, with the timeline as evidence.
Why ad hoc debugging breaks at 3 a.m.
Smart engineers still lose hours in incidents. The reasons are structural, not personal:
Diagnosis feels like progress. Reading logs and forming theories is satisfying work, so responders do it first, while customers keep hitting errors that a rollback could have stopped ten minutes ago.
Logs are searched before there's a hypothesis. Grepping a noisy service for "error" returns thousands of lines, most of them normal. Without a question to answer, there's no way to tell which line matters.
Memory rewrites the timeline. Under stress, "the deploy went out, then latency spiked" and "latency spiked, then we deployed the fix" blur together. Decisions made on a misremembered order go wrong.
The system model lives in a few heads. Which service calls which, and where the connection pool limits are, is knowledge the senior engineer has and the 3 a.m. responder may not.
A framework doesn't make anyone smarter. It removes these four failure modes by fixing the order of operations.
First principles: stabilize before you understand
The single most common mistake in production incident debugging is treating diagnosis as the first job. It isn't. Your first job is to reduce customer impact. Understanding the root cause is the second job, and it can usually wait.
Split every incident into two explicit tracks:
- Mitigate. Restore service using coarse, reversible actions: roll back the last deploy, shift traffic away from a bad zone, scale out, enable a degraded mode, shed noncritical load. These actions don't require understanding the root cause. They require knowing which lever is safe to pull.
- Diagnose. Figure out what actually broke. This track can run in parallel, at lower urgency, and often continues after the customer impact is gone.
If you have more than one responder, assign the tracks to different people. If you're alone, do the mitigation pass first and time-box it: spend the first ten minutes asking "what can I do right now to make this stop hurting" rather than "why is this happening." Teams that adopt a mitigate-first posture typically cut incident duration significantly (often by 30 to 50 percent on change-induced incidents) simply because rollback is faster than diagnosis. If your mean time to recovery is dominated by diagnosis time, that's a process smell; see our guide on how to reduce MTTR for the structural fixes.
The second first-principle habit: write the timeline as you go. Open a scratch doc or a thread and log timestamps for everything: when the alert fired, when symptoms started (not the same thing), what you checked, what you changed, what happened after. Two reasons. It stops you from re-checking the same dashboard three times at 3 a.m. And the timeline is the raw material for the postmortem; a timeline reconstructed from memory two days later is fiction. One line per event is enough:
02:41 p99 checkout latency crosses 2s (alert fired 02:47)
02:52 rolled back checkout to rev 141, no effect
02:58 noticed payments-db CPU pinned since 02:39
03:04 killed long-running analytics query, latency recovering
Note the ordering trap this example exposes: the rollback did nothing because the deploy wasn't the cause. You only see that clearly because the timestamps are written down.
The change-first heuristic
Before any deep diagnosis, do one cheap thing: check what changed. The majority of production incidents follow a change; industry postmortem datasets and vendor incident analyses consistently attribute somewhere between half and three-quarters of incidents to a recent change. "Change" means more than deploys:
- Code deploys. The obvious one.
- Config changes. Often shipped through a separate, less-scrutinized pipeline.
- Feature flags. A flag flip is a deploy that skips CI.
- Data shape. A new customer with 100x the usual cardinality, a batch import, a row that breaks an assumption.
- Dependency behavior. A third-party API changing latency, rate limits or response format without telling you.
- Scheduled work. Certificate rotations, cron jobs, autoscaling events, infrastructure maintenance.
The check itself takes under a minute:
kubectl rollout history deployment/checkout -n production
git log --oneline --since="6 hours ago" -- deploy/ config/
If a change lands within a few minutes of symptom onset, treat it as the prime suspect until proven otherwise. Correlation isn't causation, but at 3 a.m. it's an excellent place to start, and reverting a correlated change is cheap to test.
The heuristic only works if changes are visible. If deploys, flag flips and config pushes don't appear on the same timeline as your metrics, you won't make the connection under pressure. The fix is deploy annotations: have your CD pipeline post an event to your metrics system on every change:
curl -s -X POST "$GRAFANA_URL/api/annotations" \
-H "Authorization: Bearer $GRAFANA_TOKEN" \
-H "Content-Type: application/json" \
-d '{"tags":["deploy","checkout"],"text":"checkout v142 deployed"}'
Once every dashboard shows vertical lines for changes, "did this start after the deploy?" stops being a research task and becomes something you answer by looking.
Measure before you guess: USE and RED
If no change explains the symptoms, or mitigation is underway and you need a direction, resist the urge to SSH into a random box and start reading logs. Logs are where you confirm hypotheses, not where you generate them. Generate hypotheses from metrics, using two complementary checklists.
01USE, per resource
Ran out of something
Utilization, saturation and errors on CPU, memory, disk, network and pools.
02RED, per service
Something misbehaves
Rate, errors and duration (p99, not the mean) for each service.
The USE method: per resource
Brendan Gregg's USE method says: for every resource (CPU, memory, disk, network, connection pools, thread pools), check Utilization, Saturation and Errors. It catches the class of incidents where nothing changed in your code but the system ran out of something: capacity exhaustion, noisy neighbors, leaks that finally crossed a threshold.
Utilization tells you how busy the resource is. Saturation tells you how much work is queued because the resource can't keep up, and saturation, not utilization, is what correlates with latency. A node at 90 percent CPU with an empty run queue is fine. A node at 70 percent with a deep run queue isn't.
A practical PromQL check for CPU saturation across nodes, using pressure stall information:
rate(node_pressure_cpu_waiting_seconds_total[5m]) > 0.1
This fires when processes are collectively spending more than 10 percent of their time waiting for CPU, which is to say queueing. If you don't export pressure metrics, comparing load average to core count on the box works as a fallback:
uptime && nproc
Walk the USE checklist across every resource in the suspect path. It takes five minutes, and it's exhaustive in a way intuition isn't.
The RED method: per service
The RED method is the service-level complement: for every service, check Rate (requests per second), Errors (failed requests per second) and Duration (latency distribution, not averages). It catches the incidents USE misses: a downstream dependency returning errors fast, a bad code path that's slow without consuming resources, a traffic spike from one client.
A p99 duration check for a service instrumented with Prometheus histograms:
histogram_quantile(0.99,
sum by (le) (
rate(http_request_duration_seconds_bucket{job="checkout"}[5m])
)
)
Look at p99, not the mean. Production incidents almost always live in the tail first. By the time the mean moves, your worst-affected users have been suffering for a while.
Use the two methods together as a decision fork. If RED shows a sick service but USE shows healthy resources underneath it, the problem is in the code or in a dependency. If USE shows a saturated resource, the problem is capacity or a workload change. Either way, you now have a direction instead of a guess.
Bisect by layer
Distributed systems debugging is a search problem, and the fastest search is binary. A request in a typical mid-market SaaS stack crosses six layers:
client → edge/CDN/LB → service → dependency services → data store → infrastructure
01Layer 1
Client
curl timing splits DNS, connect, TLS and first byte.
02Layer 2
Edge and LB
Are the targets behind the load balancer healthy?
03Layer 3
Service
Orchestrator events: OOM kills, crash loops, failed probes.
04Layer 4
Dependencies
Test from inside the caller, not from your laptop.
05Layer 5
Data store
Long queries, lock waits and connection pile-ups.
06Layer 6
Infrastructure
Node conditions before blaming anything above them.
Don't walk this list top to bottom. Pick a midpoint, test it, and cut the search space in half. If the service looks healthy from its own metrics but clients see errors, the problem is above the service: edge, LB, DNS, TLS. If the service itself is reporting errors, the problem is at the service or below. Each test should eliminate roughly half of the remaining layers.
Concrete probes for each cut:
Client to edge. Reproduce the request with a timing breakdown to see which phase is slow (DNS, TCP connect, TLS or server processing):
curl -o /dev/null -s -w 'dns=%{time_namelookup} tcp=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total}\n' https://api.example.com/healthz
If time to first byte is fine but total is slow, the response body transfer is the problem. If TLS handshake time jumped, look at the edge, not the service.
Edge to service. Check whether the load balancer even considers your backends healthy:
aws elbv2 describe-target-health \
--target-group-arn "$TG_ARN" \
--query 'TargetHealthDescriptions[?TargetHealth.State!=`healthy`]'
A surprising number of "the service is down" incidents are actually "the health check is failing, so the LB removed every target." The service processes are fine.
Service layer. Ask the orchestrator what's been happening, in time order:
kubectl get events --sort-by=.lastTimestamp -n production | tail -20
OOM kills, failed scheduling, crash loops and failed probes all show up here before they show up anywhere else.
Service to dependency. Test the dependency from inside the caller's network context, because your laptop's view of the dependency proves nothing about the pod's view:
kubectl exec deploy/checkout -n production -- \
curl -s -o /dev/null -w '%{http_code} %{time_total}\n' http://payments:8080/healthz
Data store. Look at what the database is actually doing right now (long-running queries, lock waits, connection pile-ups):
SELECT pid, now() - query_start AS duration, wait_event_type, state, left(query, 80)
FROM pg_stat_activity
WHERE state <> 'idle'
ORDER BY duration DESC
LIMIT 10;
Infrastructure. Check node conditions before blaming anything above them:
kubectl describe nodes | grep -A5 Conditions
The prerequisite for effective bisection is knowing what the layers are: which services sit in the request path and what they depend on. If your team can't draw that diagram from memory during an incident, that's a gap worth closing before the next one. We cover how in dependency mapping for root cause analysis.
One incident, through the framework
Here's the timeline from earlier, walked step by step.
Stabilize and check changes. The p99 alert fires at 02:47. The rollout history shows checkout rev 142 went out the previous evening, the only recent change on the path. It's the cheapest suspect, so it gets rolled back at 02:52. Latency doesn't move. That's useful: one hypothesis is dead, and the timeline says so.
Name the remaining readings. Without a change to blame, there are a few possibilities. Checkout's own code path is slow under some new input. A dependency (payments, or its database) is slow. Or a shared resource is saturated. RED on checkout shows rate flat, errors up slightly and p99 way up: it's sick, but not overloaded. USE on checkout's pods shows healthy CPU and memory. Sick service on healthy resources points at a dependency.
Bisect downward. The kubectl exec probe from a checkout pod to payments:8080/healthz takes seconds instead of milliseconds. The problem is at payments or below. Payments' pods look healthy too, so the next cut is the data store. pg_stat_activity on payments-db shows an analytics query that's been running since 02:39, holding locks, with a queue of payment queries waiting behind it. The CPU graph confirms it: pinned since 02:39, two minutes before the first latency symptom.
Decide. There's nothing to roll back, since the cause wasn't a deploy. The mitigation is to kill the query (03:04), and the fix-forward is to move analytics to a replica. The postmortem writes itself from the timeline, including the rollback that didn't help.
Roll back or fix forward
You found a suspect change. Now the decision that actually determines incident duration: revert it, or fix it in place. Use a rubric, not a debate.
| Signal | Roll back | Fix forward |
|---|---|---|
| Change is recent and correlated with onset | Yes | No |
| Rollback path is tested and takes minutes | Yes | No |
| Change included a schema migration or data backfill | No | Usually yes |
| Other changes have shipped on top of the suspect | Depends on isolation | Often yes |
| Cause is external (provider outage, upstream API) | Nothing to roll back | Mitigate around it |
| Fix is a one-line, well-understood patch with fast CI | No | Maybe |
| It's 3 a.m. and you're not sure | Yes | No |
Default to rollback when the change is recent, suspect and cheap to revert. Rollback is a mitigation you can execute in one command without understanding the bug:
kubectl rollout undo deployment/checkout -n production
Fix forward when rollback is genuinely riskier than the bug: a migration already rewrote data in a shape the old code can't read; the suspect change is entangled with later changes you'd also revert; or the cause is external and there's nothing of yours to revert. Be honest about the cost. Fixing forward means writing code under pressure, reviewing it under pressure, and shipping it through a pipeline that takes however long it takes, all while the incident continues.
Two failure modes to avoid. Ego-driven fix-forward: "I can patch this in five minutes" is how five-minute incidents become ninety-minute incidents. And rollback theater: rolling back a change the timestamps already exonerated, which is another reason the written timeline matters.
One structural note: if rollback is scary on your team, that's the actual incident. A rollback path you haven't exercised recently isn't a rollback path; it's a hope. Test it in daylight.
What frameworks can and can't do
A framework reduces panic. It gives you a next step when your mind is blank, keeps three responders from duplicating work, and stops you from spending forty minutes reading logs for a hypothesis you never formed. Those are real gains.
What it doesn't do is replace system knowledge. USE and RED tell you where to look. Knowing that the checkout service holds a connection pool of exactly 20 and falls over at 21 is knowledge no checklist contains. The engineers who resolve incidents fastest carry an accurate mental model of the system, and the framework keeps that model pointed in a productive direction under stress.
The framework's most durable output isn't the fix. It's the timeline: a defensible, timestamped record of what was observed, what was tried and what worked. That record turns an incident into a postmortem worth reading, and a postmortem worth reading is what stops the same incident from happening twice.
Why the framework beats ad hoc debugging
| Ad hoc debugging | Framework by hand | Framework run by CloudThinker | |
|---|---|---|---|
| First ten minutes | Reading logs for a theory | Mitigate, then check changes | Change correlation done before you open a laptop |
| Hypotheses | Whatever the loudest person suspects | From USE and RED | From USE and RED across every service in the path |
| Search | Top to bottom, or random | Bisect by layer | Bisect across a live dependency graph |
| Timeline | Rebuilt from memory | Written as you go | Reconstructed from telemetry automatically |
| System knowledge | In a few heads | In a few heads | In the topology graph, shared by everyone |
The deeper difference is where the working memory lives. A framework run by a person depends on that person's stamina and knowledge of the system at 3 a.m. The same framework run against telemetry and a live topology doesn't get tired, and it doesn't forget which services sit in the path.
Where CloudThinker fits
Everything above is executable by a human with a terminal. It's also what CloudThinker Resolve runs when an alert fires: reconstructing the timeline from your telemetry, correlating symptom onset against deploys, flag flips and config changes, and bisecting layer by layer across a live topology graph of your services and dependencies. You get the completed investigation instead of running it yourself at 3 a.m.
Mitigation is where an agent has to be careful, so it goes through Auto Mode, so you decide what runs on its own and what waits for approval. The investigation (metrics, logs, events, queries like the ones above) runs freely. Every write, whether a rollback, a scale-out or killing a query, is proposed with its evidence. Every proposal and action lands in the audit trail alongside the timeline.
Getting started
Ask in plain language, the same way you'd brief a teammate joining the incident:
"Checkout p99 has been climbing since about 02:40. Check what changed in the last six hours, run USE on the pods and nodes in the checkout path and RED on checkout and its dependencies, and tell me which layer the problem is in. Propose only, change nothing."
"Reconstruct the timeline for last night's checkout incident from alerts, deploys and the actions taken in the channel, and draft a postmortem from it."
Related reading
Try it free at app.cloudthinker.io. The free tier includes 100 premium credits, no card required.
