top of page

Build Debugging Tools Into Code — Debugging Systematically, Part 10

Shawn West
Jul 22
3 min read

Updated: Jul 28

Debugging Systematically · Part 10

The best time to make a bug easy to debug is before it happens — while you're writing the code, not at 3 a.m. while it's on fire. Code that's built to be observed (structured logs, metrics, traces, a kill switch) turns a multi-hour production mystery into a five-minute lookup. This walks through building those debugging affordances in from the start, so future-you can see what the system is actually doing.

Future-you needs to debug this code at 3am. Help future-you. Build observability and control into the code from the start.

Step 1: Structured Logging (10 min)

Don't:

logger.info(f"User {user_id} did {action}")

Do:

logger.info("user_action", extra={"user_id": user_id, "action": action})

JSON output:

{"event": "user_action", "user_id": 42, "action": "login", "timestamp": "..."}

Searchable, filterable, aggregatable. Plain text logs lose at scale.

Step 2: Useful Log Levels (5 min)

  • DEBUG: verbose; off in production

  • INFO: notable events (request received, job completed)

  • WARN: something unexpected but handled (retried, fell back)

  • ERROR: something failed

  • CRITICAL: the world is on fire

Log levels let you control verbosity without code changes.

Step 3: Log Context (10 min)

Add per-request context:

# Middleware
def correlation_id_middleware(request, call_next):
    trace_id = request.headers.get("X-Trace-Id", str(uuid4()))
    with logger.contextualize(trace_id=trace_id):
        return call_next(request)

Now every log inside that request automatically includes trace_id. No threading through every function.

Step 4: Metrics (15 min)

Counters, gauges, histograms:

from prometheus_client import Counter, Histogram

requests_total = Counter("requests_total", "Total requests", ["method", "endpoint"])
request_duration = Histogram("request_duration_seconds", "Request duration")

@app.middleware("http")
async def metrics_middleware(request, call_next):
    start = time.time()
    response = await call_next(request)
    requests_total.labels(method=request.method, endpoint=request.url.path).inc()
    request_duration.observe(time.time() - start)
    return response

Dashboards. Alerts. Trends.

Step 5: Traces (10 min)

Per-span timing across functions:

from opentelemetry import trace
tracer = trace.get_tracer(__name__)

def process_order(order_id):
    with tracer.start_as_current_span("process_order") as span:
        span.set_attribute("order.id", order_id)
        validate(order_id)
        charge(order_id)
        ship(order_id)

Visible in your tracing tool. Tells you where time goes.

Step 6: Feature Flags (15 min)

Decouple deploy from release:

if feature_flags.is_enabled("new_checkout_flow", user=user):
    return new_checkout()
else:
    return old_checkout()

Roll out gradually:

  • 1% of users

  • Specific cohort

  • Internal team only

  • Everyone

If it breaks, flip the flag. No deploy needed.

Tools: LaunchDarkly, Unleash, ConfigCat, or homegrown.

Step 7: Kill Switches (10 min)

For dangerous features, a master off:

if feature_flags.is_enabled("disable_new_charge_flow"):
    return legacy_charge_flow()

When the new flow misbehaves: flip; investigate. No deploy required.

Critical for high-stakes systems.

Step 8: Health and Readiness Endpoints (10 min)

@app.get("/healthz")
def liveness():
    return {"status": "ok"}

@app.get("/readyz")
def readiness():
    return {"status": "ok", "db": db.is_connected(), "cache": cache.is_connected()}
  • Liveness: am I alive? (restart if not)

  • Readiness: am I ready for traffic? (LB skips if not)

Critical for Kubernetes, autoscaling, deploys.

Step 9: Debug Endpoints (10 min)

For ad-hoc inspection:

@app.get("/debug/config")
def show_config():
    return get_safe_config()  # don't expose secrets!

@app.get("/debug/metrics")
def show_metrics():
    return get_metrics_snapshot()

@app.post("/debug/log-level")
def set_log_level(level: str):
    logging.getLogger().setLevel(level)

Auth-protected. Only for engineers. But: invaluable at 3am.

Step 10: Document the Tools (10 min)

A runbook:

# Debugging My Service

## Logs
Search Datadog: service:my-service trace_id:<id>

## Traces
Jaeger UI: https://...

## Feature flags
LaunchDarkly: https://...

## Common operations
- Disable a feature: flip flag X
- Drain a node: hit /debug/drain
- Force GC: hit /debug/gc

## Common errors
- "DB connection failed" → check security group rules
- "OOM" → restart, then profile

Without docs, debugging tools sit unused.

What You Just Did

Built-in debugging: structured logs, levels, context, metrics, traces, feature flags, kill switches, health checks, debug endpoints, runbooks. Code designed for the bad day.

Common Failure Modes

No structured logs. Production debugging by grep.

No metrics. "Is it slow?" → no idea.

No feature flags. Every change is a deploy + risk.

Health check that's too shallow. Reports healthy when broken.

Debug endpoints unauthenticated. Attackers love them.

Continue the Debugging Systematically path

Part of the Debugging Systematically learning path.

bottom of page