Incident Drill: Debug a Failing ML Service from Logs and Metrics
Hands-on lab · IDE in your browser

Incident Drill: Debug a Failing ML Service from Logs and Metrics

Perch's listing-guard started holding almost every new listing at 14:00 and the review queue is drowning.

Time
45 min
Checked steps
5
Level
Intermediate
Setup
None
Read step 1

Hands-on labs require Pro · $29.99/mo · cancel anytime

The job

Perch runs a used-bike marketplace. Its listing-guard service scores every new listing and holds the risky ones for a human. At 14:00 the hold-rate jumped from a tenth to almost everything and the review queue overflowed. You have the request log and the running service. Find out what shipped, fix it, and leave a check so it cannot happen silently again.

5 steps, each checked when you finish it

A check runs your work at the end of every step. Hints and the full solution are there if you get stuck.

  1. 1

    Triage from the log

    At 14:00 Perch's review queue for new bike listings went from a trickle to a flood.

  2. 2

    Reproduce the failure

    A spike in a dashboard is not yet a bug.

  3. 3

    Localize the feature

    The service turns each raw listing into six model features and scores them.

  4. 4

    Find the cause and fix it

    You know the feature that broke: photos.

  5. 5

    Verify and add a guard

    The fix is in.

Step 1 as it appears in the lab

The lab’s own text. The hint and the solution stay inside the lab.

Step 1: Triage from the log

Triage from the log

At 14:00 Perch's review queue for new bike listings went from a trickle to a flood. The listing-guard service scores each new listing and either holds it for a human or approves it. Something made it hold almost everything. You have the request log and the running service. Start by reading the log: when did it break, and how badly.

incident.py gives you load_log(), which returns a DataFrame with one row per listing: its raw fields, the score and decision the service emitted, and a ts timestamp. Write two functions:

  • timeline(logs, freq="15min"): bucket the log by time and, per bucket, return n (how many listings) and hold_rate (the share held). Return a DataFrame indexed by bucket start. Drop empty buckets.
  • change_point(tl): the start time of the bucket where hold_rate rises the most from the bucket before it. That is your incident's clock.

Run it. You should see a flat morning near 10% and a wall of holds from 14:00 on.

incident.py, the file you edit79 lines
"""Your incident analysis. Perch's listing-guard started holding almost every new listing at 14:00, and the
review queue is drowning. Work from the request log and the live service to find why, prove it, fix it, and
add the check that would have paged you sooner."""
import json

import pandas as pd

import serving

DEPLOY_TS = "2026-03-18T14:00:00"   # when the review queue started filling


def load_log(path="requests.jsonl"):
    """The production request log: one row per listing with its raw fields, the score and decision the
    service emitted, and (added later, from review outcomes) the ground-truth is_bad."""
    return pd.DataFrame(json.loads(line) for line in open(path))


def raw_records(logs):
    """The log rows as plain dicts, the way they arrived on the queue: only the fields each row actually
    carried (the log unions columns across rows, so absent fields read as NaN and are dropped here)."""
    return [{k: v for k, v in row.items() if pd.notna(v)} for row in logs.to_dict("records")]


# ---------- Step 1: triage from the log ----------

def timeline(logs, freq="15min"):
    """Bucket the log by time and report volume and hold-rate per bucket. Returns a DataFrame indexed by
    bucket start with columns n and hold_rate."""
    # TODO (Step 1): to_datetime the ts, group by pd.Grouper(freq=freq), and per bucket
    # return n = len and hold_rate = mean of decision == 'hold'; drop empty buckets.
    raise NotImplementedError("Step 1: write timeline()")


def change_point(tl):
    """The start time of the bucket where hold_rate jumps up most from the bucket before it."""
    # TODO (Step 1): the index where hold_rate rises most from the previous bucket:
    # diff the hold_rate values, take argmax, add one for the shift.
    raise NotImplementedError("Step 1: write change_point()")


# ---------- Step 2: reproduce it ----------

def reproduce(records):
    """Run the current service over these raw records and return the list of decisions. If the current
    code reproduces the log, the fault is in the code path you can see, not a fluke of production."""
    raise NotImplementedError("reproduce() arrives in Step 2")


# ---------- Step 3: localize the feature ----------

def feature_report(before, after):
    """Featurize the before and after records through the current service and, per model feature, report
    its median before, its median after, and the share of after rows that collapsed onto one constant
    value. A feature that used to vary and is now a single value is the break. Returns a DataFrame indexed
    by feature."""
    raise NotImplementedError("feature_report() arrives in Step 3")


def culprit(report):
    """The feature that broke: among features now pinned to one constant (constant_share > 0.9), the one
    whose median moved most. Return its name."""
    raise NotImplementedError("culprit() arrives in Step 3")


# ---------- Step 5: verify and guard ----------

def outcome(records):
    """Run the current service over records that carry ground-truth is_bad, and report hold_rate and
    false_hold_rate: the share of legit (is_bad == 0) listings that were held. The incident's harm is a
    flood of false holds burying the real ones."""
    raise NotImplementedError("outcome() arrives in Step 5")


def silent_default_guard(records, feature, limit=0.5):
    """A release check to run before a deploy: featurize the batch and return False if `feature` collapses
    onto one constant for more than `limit` of it, the fingerprint of an input field that stopped
    arriving. True means the batch looks safe."""
    raise NotImplementedError("silent_default_guard() arrives in Step 5")
Provided for you:model.pyrequests.jsonlserving.pytraining.csvtry_it.py

Frequently asked questions

Why can a model fail in production without any code change?

Its inputs can change underneath it. An upstream team renaming or dropping a field, or switching units, leaves the serving code reading something that is no longer there. The model then scores a constant or a wrong value for every request while nothing in its own code looks broken.

What is training-serving skew?

It is any difference between how a feature is computed at training time and at serving time. Here the model was trained on real photo counts, but after a field rename the service fed it zero for every listing, so serving no longer matched training and the predictions collapsed.

How do you localize which feature broke?

Featurize traffic from before and after the incident through the current service and compare each feature's distribution. A feature that used to vary and is now pinned to a single constant for nearly every request is the one being fed wrongly.

Debugging a failing ML service

A model that was fine yesterday can fail today without its code changing, because the data feeding it changed. Debugging that means reading logs and metrics, reproducing the failure against the live service, and tracing it to the one feature that broke. In this lab you time an incident from a request log, reproduce it deterministically, localize the model feature that collapsed onto a constant, fix the field-mapping bug in the serving code, and add a release guard that catches a silently missing input before it ships.