Production Debugging
Production bugs differ from local failures: traffic shape, data volume, and environment config hide issues your laptop never sees. Structured logs, distributed traces, and safe live profiling close that gap without risky restarts.
Recipe
Quick-reference recipe card - copy-paste ready.
import logging
import structlog
structlog.configure(
processors=[
structlog.contextvars.merge_contextvars,
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.JSONRenderer(),
],
wrapper_class=structlog.make_filtering_bound_logger(logging.INFO),
)
log = structlog.get_logger()
def handle_request(request_id: str, trace_id: str, tenant: str) -> None:
structlog.contextvars.bind_contextvars(
request_id=request_id, trace_id=trace_id, tenant=tenant
)
log.info("order_created", order_id="ord_123")# Live stack sample (no restart, needs ptrace or CAP_SYS_PTRACE)
py-spy top --pid $(pgrep -f "gunicorn.*api:app")
py-spy dump --pid <pid> --lines 50When to reach for this:
- Error rate spikes with no obvious deploy correlation
- Latency grows but CPU looks idle (GIL or I/O wait)
- Issue appears only for one tenant or region
- Local reproduction fails after matching Python version and env vars
Working Example
"""prod_debug_helpers.py - minimal toolkit for correlating prod evidence."""
from __future__ import annotations
import json
import os
import sys
import time
from contextlib import contextmanager
from dataclasses import dataclass, asdict
import structlog
structlog.configure(
processors=[
structlog.contextvars.merge_contextvars,
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.JSONRenderer(),
],
)
log = structlog.get_logger()
@dataclass(frozen=True)
class DeployContext:
git_sha: str
python_version: str
service: str
def deploy_context() -> DeployContext:
return DeployContext(
git_sha=os.getenv("GIT_SHA", "unknown"),
python_version=sys.version.split()[0],
service=os.getenv("SERVICE_NAME", "api"),
)
@contextmanager
def request_scope(request_id: str, trace_id: str, **extra: str):
structlog.contextvars.bind_contextvars(
request_id=request_id,
trace_id=trace_id,
**extra,
**asdict(deploy_context()),
)
start = time.perf_counter()
try:
yield
finally:
log.info("request_finished", duration_ms=(time.perf_counter() - start) * 1000)
def simulate_slow_path() -> None:
with request_scope("req-9f2", "trace-abc", tenant="acme"):
log.info("cache_miss", key="user:42")
time.sleep(0.05) # stands in for DB round-trip
log.warning("downstream_retry", attempt=2, service="billing")
if __name__ == "__main__":
simulate_slow_path()
print("Filter prod logs: trace_id=trace-abc AND level>=warning")What this demonstrates:
- JSON logs carry
request_id,trace_id, and deploy metadata on every line - Context managers bind scope once per request instead of threading kwargs everywhere
- Duration and downstream retries surface in the same log stream as business events
- Deploy SHA in logs ties incidents to release artifacts quickly
Deep Dive
How It Works
- Structured logging - Key-value fields index in Loki, CloudWatch, or Datadog; grep plain text at scale fails.
- Trace propagation - OpenTelemetry injects
trace_idinto logs when the SDK is configured with a logging bridge. - py-spy - Samples CPython stacks externally; safe on Gunicorn/Uvicorn workers under load.
- Read-only reproduction - Copy anonymized prod payloads and feature-flag state into staging before code changes.
- Evidence timeline - Align logs, metrics, and deploy annotations in one UTC window.
Production Signal Checklist
| Signal | Tool | What it reveals |
|---|---|---|
| Error spikes | Sentry / logs | Stack traces, release, user impact |
| Latency | APM p95/p99 | Slow spans, N+1 queries |
| CPU vs wait | py-spy top | GIL contention, hot loops |
| Memory | RSS graphs, tracemalloc | Leaks after deploy |
| Config drift | Env diff staging vs prod | Missing secrets, wrong pool size |
Python Notes
# Enable faulthandler on startup for native hangs
import faulthandler
faulthandler.enable()
# Temporary debug in one pod only (never commit)
import logging
logging.getLogger("sqlalchemy.engine").setLevel(logging.INFO)Gotchas
- Restarting pods before capturing logs - You lose in-memory state and stack evidence. Fix: Snapshot logs,
py-spy dump, and heap metrics first. - Missing correlation IDs - Thousands of identical errors are unsearchable. Fix: Require
request_id/trace_idin middleware before handlers run. - Debugging with DEBUG level globally - Log volume explodes and PII leaks. Fix: Raise level on one pod or use dynamic sampling.
- Assuming local == prod - Different
PYTHONHASHSEED, locale, or dependency pins change behavior. Fix: Match image digest and env in a scratch environment. - Blocking the event loop in async apps - Sync ORM calls stall all requests; CPU looks low. Fix: Check span timelines for long synchronous segments; move I/O off the loop.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| Remote debugger (debugpy) | Staging repro confirmed | Production traffic (unsafe, slow) |
| Replay from access logs | Idempotent read paths | Writes with side effects |
| Feature-flag bisect | Suspect new code path | Infrastructure-only outage |
| Heap dump (memray) | Memory growth over hours | CPU-bound hot loops |
FAQs
How do I debug without SSH access?
Use log platforms, APM, and kubectl exec/ecs exec only on a single canary pod. Prefer py-spy and env-print scripts checked into the repo over ad hoc edits.
Should I enable Python -X dev in production?
No. Dev mode adds expensive checks and warnings. Use staging with -X dev and keep prod on tuned logging and health probes.
What is the first query in an incident?
Filter errors by service and git_sha for the last two hours, then pivot on trace_id from the slowest successful request for contrast.
How do I trace a Celery task?
Pass trace_id in task kwargs and bind structlog contextvars in the task base class. Link worker logs to API logs via the same ID.
When is py-spy not enough?
When the process is blocked in native code without Python frames, or during startup before workers bind. Pair with eBPF or APM native profiling.
Can I use pprint in prod?
Only behind a feature flag and rate limit. Prefer structured fields so dashboards can aggregate.
How do I compare staging and prod configs?
Export redacted env keys (names only) and diff pool sizes, timeout values, and feature flags. Never paste secrets into tickets.
What about free-threaded Python 3.14?
GIL-related stalls diminish but I/O and lock contention remain. py-spy still helps; watch threading.Lock hot spots in metrics.
How long should I keep debug logging on?
Minutes to one pod. Revert immediately after capture; document findings in the incident channel.
Who should own prod debugging?
On-call engineer drives evidence; service owner interprets domain logic. Do not solo-debug SEV1 without an incident commander.
Related
- Incident Response Playbooks - first-15-minutes roles and severity
- Common Production Failure Modes - Python-specific outage patterns
- Rollback & Recovery Runbooks - stabilize under pressure
- Structured Logging - JSON log setup
- Distributed Tracing - trace propagation
Stack versions: This page was written for Python 3.14.0 (stable 3.14, maintenance 3.13), FastAPI 0.115+, Django 5.2, Flask 3.1, Pydantic 2, PyTorch 2.6+, pandas 2.2+, Polars 1.x, ruff 0.9+, and uv 0.6+.