aiml
SearchMy learning
Menu

Lesson 15 of 21Module 4

Build LLM monitoring for latency, cost and errors

Add latency, token, cost, error, and queue monitoring

Aggregate simulated model-service requests into latency, token, cost, error and queue metrics. Test alert transitions and histogram estimates.

Prerequisites

Build end-to-end request tracing with source provenance: this build reuses its recorded model calls. Queues, 503 replies and latency percentiles.

By the end, you can

  • Count every request as an answer, a timeout or a 503 in the window where its client saw it end.
  • Estimate a percentile from histogram buckets and say how far it can be from the exact value.
  • Write alert rules that wait for a condition to hold, and test the window in which a rule has no data.
Runtime
Python 3.11+
Dependencies
Python standard library only

Lab code, tests and fixtures (ZIP, 15 KB)Quiz check your understanding

Progress is saved in this browser.

Problem

When you need this. Use this when operating a model service in production and you need windowed latency percentiles, token and cost accounting, queue depth tracking and tested alert rules.

The tracing build explains one request. An operator needs a different answer first: is the service healthy right now? A trace cannot say whether one request in a hundred is slow or ninety are, whether errors are rising, whether the queue is backing up, or what the last minute cost.

We will add monitoring to a model service with one worker and one queue. Its contract:

  • Input: one record per request: when it arrived, how it ended, its latency and wait, its tokens and its model time.
  • Output: one row of metrics per 60-second window: arrivals, answers, 503s, timeouts, the error rate, latency percentiles, maximum sampled system depth, tokens, cost and the model time spent on calls nobody waited for.
  • Alerts: rules that read each window and change state between inactive, pending and firing.

The traffic is a simulation, so every count is exact and repeatable. The token counts and baseline model time come from recorded calls: each simulated request takes its tokens and model time from one of the 48 calls to gemma4:e2b recorded in the tracing build, in turn. The success check: the rules must fire in the windows of a burst and of a slow-model phase, stay quiet in steady traffic, and the tests must pin how each metric counts a request, including the window in which a latency rule has nothing to measure.

Concepts

Metric
A number computed over many requests. A counter only grows: requests, errors, tokens. A gauge is a reading at a moment. This lab records system depth when a request arrives: waiting requests plus any running request. A histogram counts values into buckets so that percentiles can be estimated later without keeping every value.
Window
The time span one row of metrics covers, here 60 seconds. Each request is counted once, in the window where its client saw it end: when the answer arrived, when the client’s timeout passed, or when the 503 came back.
Error rate
Requests that ended with a 503 or a timeout, divided by all requests that ended in the window. Leaving the 503s out of the denominator, or the numerator, would hide an overloaded service.
Bucket estimate
A percentile computed from histogram counts. Prometheus’s histogram_quantile assumes values are spread evenly inside a bucket and interpolates linearly; a quantile in the last, unbounded bucket returns the highest finite bound. For a finite bucket, its width bounds the discrepancy between values inside that bucket. The unbounded bucket supplies no finite error bound.
Alert rule
A condition on a window’s metric with a threshold and a hold time. Following Prometheus’s for clause, a rule is pending while the condition has held for fewer windows than its hold time, firing once it has held long enough, and inactive as soon as a window does not meet it.

The latency histogram uses the bucket bounds that the OpenTelemetry GenAI semantic conventions recommend for gen_ai.client.inference.duration, from 0.01 to 81.92 seconds, each twice the last. The conventions, like the span conventions in the tracing build, are marked Development. Token counts are the model’s own prompt_eval_count and eval_count from Ollama.

Cost here is tokens times a price. The local model has no per-token price, so prices.json holds example prices, 0.1 and 0.4 US dollars per million input and output tokens, only to show the calculation. They are not any provider’s prices.

One more effect matters. When a client gives up after its timeout of 3000 ms, the worker does not know. Unless it checks, it still runs the call: the model time and tokens are spent, and nobody receives the answer. This build counts that wasted model time separately.

Architecture

monitor.py · simulate() and windows() · every request ends one way, in one window
traffic.json5 phases · timeout 3000 mscalls.json48 recorded calls of gemma4:e2bsimulate(config)one worker, one bounded queue, an integer clockarrival timesrequestarrivesqueuefull?yes503queue full;no model timenoqueuelimit 20 in A6 in Bworkerone callat a timetokens, model_msanswerin time?yesanswerlatency recordednotimeoutclient stops waiting;A runs every queued call,B skips calls already expired503timeoutanswerwindows()counts each request in the 60-second window where its client saw it end;tokens, cost and model time where the worker finished the callprices.json0.1 / 0.4 USD per 1M, examplepricesone row per windowmonitoring.jsonruns A and B: windows, alert changesevaluate() reads each row with alerts.json: the next figuretraffic.json5 phasescalls.json48 callssimulate()arrivalsqueuefull?yes503queue full,no modelnoqueuelimit 20 (A)6 (B)in orderworkerone call at a timetokens,model_msanswerin time?yesanswernotimeoutclient stops waiting; A runsevery call, B skips expiredeach endingwindows()one 60-second window per row,where the client saw the endmonitoring.jsonprices.jsonexamplepricesrows
Request endings feed time-window metrics. Alert evaluation advances through those windows and keeps pending, firing and inactive states separate. A missing latency value is not a fast response; this local simulation does not run a model service.

Start at Request arrives. Queue full? ends the request at once with a 503 when the queue already holds its limit. Otherwise it waits for the one worker. Answer in time? ends it as a timeout when the client’s deadline passes first, or with an answer otherwise. Each ending is handed to the 60-second window in which the client saw it end, where it adds to the outcome counts and, for an answered request, the latency histogram. System depth is sampled at arrival; tokens and model time are added when the worker finishes a call, including a call whose client already gave up.

monitor.py · evaluate() · one alert rule, window by window
monitoring.jsonone row per windowalerts.json4 rules: metric, threshold, forrowrulesevaluate(windows)for every window, for every rulevalue ≥threshold?no, or no valueinactivecount reset to 0yescount + 1held forN windows?yesfiringheld long enoughnopendingcount below for_windowsnext windowmonitoring.jsonalerts.json4 rulesrowrulesevaluate()value ≥threshold?noinactivecount 0yes,count + 1held forN windows?nopendingbelow Nyesfiringcount ≥ Nnext window
Request endings feed time-window metrics. Alert evaluation advances through those windows and keeps pending, firing and inactive states separate. A missing latency value is not a fast response; this local simulation does not run a model service.

Each rule starts inactive. A window that meets its condition makes it pending. When the condition has held for N windows, the rule is firing. A window that does not meet the condition, marked No, or no value, returns the rule to inactive and resets the count; the cobalt line reads the next window.

Working code

Runtime: Python 3.11 or later. Dependencies: Python standard library only. No model or network is used; the recorded calls come from the tracing build. Download monitor.py, calls.json, traffic.json, alerts.json and prices.json into one directory.

monitor.pySource · pythonDownload
"""Monitor a simulated support service: latency, tokens, cost, errors and queue depth, with alert rules.

    python3 monitor.py            # print one line per window and every alert change
    python3 monitor.py --check    # also compare with the saved monitoring.json
    python3 monitor.py --write    # save a fresh run as monitoring.json

Standard library only. Nothing is timed: the clock is simulated milliseconds. Requests
arrive as traffic.json describes and wait in one queue for one model worker. Each
request's tokens and model time come from a call recorded from gemma4:e2b in the
tracing build (calls.json), taken in turn; traffic.json can slow the model for a
phase. A client gives up after its timeout, but the worker still finishes the call.

Every request is reported in the 60-second window in which the client saw it end:
its reply, its timeout, or its 503. Latency histograms use the OpenTelemetry GenAI
duration buckets, and the p95 is estimated from them the way Prometheus's
histogram_quantile does; the exact p95 is reported beside it.
"""
import argparse
import bisect
import json
import math
import sys
from collections import deque
from pathlib import Path

HERE = Path(__file__).resolve().parent
TRAFFIC = json.loads((HERE / "traffic.json").read_text(encoding="utf-8"))
CALLS = json.loads((HERE / "calls.json").read_text(encoding="utf-8"))["calls"]
RULES = json.loads((HERE / "alerts.json").read_text(encoding="utf-8"))["rules"]
PRICES = json.loads((HERE / "prices.json").read_text(encoding="utf-8"))
# ExplicitBucketBoundaries for gen_ai.client.inference.duration, in seconds.
BUCKETS_S = (0.01, 0.02, 0.04, 0.08, 0.16, 0.32, 0.64, 1.28, 2.56, 5.12, 10.24, 20.48, 40.96, 81.92)


class Histogram:
    """Counts per explicit bucket; bucket i holds values v with BUCKETS[i-1] < v <= BUCKETS[i]."""

    def __init__(self, bounds):
        self.bounds, self.counts = tuple(bounds), [0] * (len(bounds) + 1)  # the last bucket is +Inf

    def record(self, value):
        self.counts[bisect.bisect_left(self.bounds, value)] += 1

    def quantile(self, q):
        """Estimate a quantile by linear interpolation inside its bucket, as histogram_quantile does.

        The lowest bucket starts at 0. A quantile in the +Inf bucket returns the highest finite bound.
        """
        total = sum(self.counts)
        if total == 0:
            return None
        rank, seen = q * total, 0
        for i, count in enumerate(self.counts):
            if count and seen + count >= rank:
                if i == len(self.bounds):
                    return self.bounds[-1]
                lower = self.bounds[i - 1] if i else 0.0
                return lower + (self.bounds[i] - lower) * (rank - seen) / count
            seen += count
        return self.bounds[-1]


def nearest_rank(values, p):
    ordered = sorted(values)
    return ordered[-(-p * len(ordered) // 100) - 1] if ordered else None


def arrivals():
    """Evenly spaced arrivals per phase, each with the model slowdown that applies to it."""
    out, start = [], 0
    for phase in TRAFFIC["phases"]:
        length_ms = phase["seconds"] * 1000
        count = phase["requests_per_minute"] * phase["seconds"] // 60
        out += [(start + length_ms * i // count, phase["model_slowdown"]) for i in range(count)]
        start += length_ms
    return out, start


def simulate(config):
    """One worker, one bounded queue. Return one row per request, in arrival order."""
    limit, timeout, drop = config["queue_limit"], TRAFFIC["client_timeout_ms"], config["drop_expired"]
    plan, _ = arrivals()
    rows, waiting, free_at = [], deque(), 0
    for i, (at, slowdown) in enumerate(plan):
        call = CALLS[i % len(CALLS)]
        # Start every waiting request the worker can begin before this arrival.
        while waiting and max(free_at, waiting[0]["arrival_ms"]) <= at:
            free_at = start(waiting.popleft(), free_at, timeout, drop)
        depth = len(waiting) + (1 if free_at > at else 0)  # requests in the system, including the one running
        row = {"n": i, "arrival_ms": at, "input_tokens": call["input_tokens"],
               "output_tokens": call["output_tokens"], "model_ms": round(call["model_ms"] * slowdown, 3),
               "depth_at_arrival": depth}
        if len(waiting) >= limit:
            row.update(status="503", end_ms=at, latency_ms=0, wait_ms=0, ran=False)
        else:
            waiting.append(row)
        rows.append(row)
    while waiting:
        free_at = start(waiting.popleft(), free_at, timeout, drop)
    return rows


def start(row, free_at, timeout, drop_expired):
    """Run one request on the worker. The client stops waiting at its timeout.

    With drop_expired, the worker skips a request whose client gave up while it waited;
    without it, the worker spends the model call anyway.
    """
    begin = max(free_at, row["arrival_ms"])
    deadline = row["arrival_ms"] + timeout
    if drop_expired and begin >= deadline:
        row.update(status="timeout", end_ms=deadline, latency_ms=timeout, wait_ms=timeout, ran=False)
        return free_at
    finish = begin + row["model_ms"]
    row.update(wait_ms=begin - row["arrival_ms"], ran=True)
    if finish <= deadline:
        row.update(status="ok", end_ms=finish, latency_ms=round(finish - row["arrival_ms"], 3))
    else:
        row.update(status="timeout", end_ms=deadline, latency_ms=timeout)
    return finish


def cost_usd(input_tokens, output_tokens):
    """Example prices from prices.json; the local model itself has no per-token price."""
    return round(math.fsum([input_tokens * PRICES["input_usd_per_million"],
                            output_tokens * PRICES["output_usd_per_million"]]) / 1e6, 6)


def windows(rows, duration_ms):
    size = TRAFFIC["window_seconds"] * 1000
    out = []
    for w in range(math.ceil(duration_ms / size)):
        lo, hi = w * size, (w + 1) * size
        done = [r for r in rows if lo <= r["end_ms"] < hi]
        answered = [r for r in done if r["status"] == "ok"]
        histogram = Histogram(BUCKETS_S)
        for r in answered:
            histogram.record(r["latency_ms"] / 1000)
        errors = {status: sum(r["status"] == status for r in done) for status in ("503", "timeout")}
        # Tokens and model time are counted for every call the worker ran that ended here,
        # including calls whose client had already given up.
        ran = [r for r in rows if r["ran"] and lo <= r["arrival_ms"] + r["wait_ms"] + r["model_ms"] < hi]
        estimate = histogram.quantile(0.95)
        out.append({
            "window": w, "start_s": lo // 1000,
            "arrivals": sum(lo <= r["arrival_ms"] < hi for r in rows),
            "ended": len(done), "ok": len(answered), "errors": errors,
            "error_rate": round(sum(errors.values()) / len(done), 4) if done else 0.0,
            "latency_ms": {"p50": nearest_rank([r["latency_ms"] for r in answered], 50),
                           "p95": nearest_rank([r["latency_ms"] for r in answered], 95),
                           "p95_from_buckets": None if estimate is None else round(estimate * 1000, 1),
                           "bucket_counts": histogram.counts},
            "max_queue_depth": max((r["depth_at_arrival"] for r in rows if lo <= r["arrival_ms"] < hi), default=0),
            "input_tokens": sum(r["input_tokens"] for r in ran),
            "output_tokens": sum(r["output_tokens"] for r in ran),
            "cost_usd": cost_usd(sum(r["input_tokens"] for r in ran), sum(r["output_tokens"] for r in ran)),
            "model_ms": round(math.fsum(r["model_ms"] for r in ran), 3),
            "wasted_model_ms": round(math.fsum(r["model_ms"] for r in ran if r["status"] == "timeout"), 3),
        })
    return out


def metric(window, name):
    """Read a rule's metric from one window, such as latency_ms.p95_from_buckets."""
    value = window
    for part in name.split("."):
        value = value[part]
    return value


def evaluate(windows_, rules=RULES):
    """Alert states per window, like Prometheus: pending while the condition holds for fewer
    than `for_windows` evaluations, firing after that, inactive as soon as it stops holding."""
    timeline, changes = [], []
    held = {rule["name"]: 0 for rule in rules}
    state = {rule["name"]: "inactive" for rule in rules}
    for window in windows_:
        row = {"window": window["window"]}
        for rule in rules:
            value = metric(window, rule["metric"])
            met = value is not None and value >= rule["threshold"]
            held[rule["name"]] = held[rule["name"]] + 1 if met else 0
            new = "inactive" if not met else "firing" if held[rule["name"]] >= rule["for_windows"] else "pending"
            if new != state[rule["name"]]:
                changes.append({"window": window["window"], "rule": rule["name"], "from": state[rule["name"]],
                                "to": new, "value": value})
            state[rule["name"]] = row[rule["name"]] = new
        timeline.append(row)
    return timeline, changes


def run(config):
    rows = simulate(config)
    _, duration = arrivals()
    per_window = windows(rows, duration)
    timeline, changes = evaluate(per_window)
    totals = {status: sum(r["status"] == status for r in rows) for status in ("ok", "503", "timeout")}
    return {"name": config["name"], "label": config["label"], "requests": len(rows), "totals": totals,
            "model_calls": sum(r["ran"] for r in rows),
            "wasted_model_ms": round(math.fsum(r["model_ms"] for r in rows if r["ran"] and r["status"] == "timeout"), 3),
            "windows": per_window, "alerts": timeline, "alert_changes": changes}


def results():
    return {"traffic": "traffic.json", "calls": "calls.json", "runs": [run(c) for c in TRAFFIC["configs"]]}


def record(data):
    return json.dumps(data, indent=2) + "\n"


def main(argv=None):
    parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    parser.add_argument("--check", action="store_true", help="compare with the saved monitoring.json")
    parser.add_argument("--write", action="store_true", help="save a fresh run as monitoring.json")
    options = parser.parse_args(argv)
    fresh = results()
    for one in fresh["runs"]:
        report(one)
    if options.write:
        (HERE / "monitoring.json").write_text(record(fresh), encoding="utf-8")
        print("wrote monitoring.json")
    if options.check:
        if (HERE / "monitoring.json").read_text(encoding="utf-8") != record(fresh):
            sys.exit("monitoring.json differs from a fresh run")
        print("monitoring.json matches")


def report(fresh):
    print(f"{fresh['name']}: {fresh['label']}: {fresh['totals']}, model calls {fresh['model_calls']}, "
          f"wasted model ms {fresh['wasted_model_ms']}")
    for w in fresh["windows"]:
        print(f"{w['start_s']:>4}s arrivals {w['arrivals']:>3} ok {w['ok']:>3} 503 {w['errors']['503']:>3} "
              f"timeout {w['errors']['timeout']:>3} err {w['error_rate']:.3f} p95 {w['latency_ms']['p95']} "
              f"(buckets {w['latency_ms']['p95_from_buckets']}) depth {w['max_queue_depth']:>2} "
              f"out-tokens {w['output_tokens']:>5} ${w['cost_usd']:.6f}")
    for change in fresh["alert_changes"]:
        print(f"window {change['window']}: {change['rule']} {change['from']} -> {change['to']} ({change['value']})")


if __name__ == "__main__":
    main()

Read the important decisions in order:

  • simulate() runs one worker and one bounded queue on an integer clock. A request that finds the queue full ends with a 503 and costs no model time.
  • start() gives each request its client deadline. The client sees a timeout at the deadline; the worker still finishes the call unless the configuration sets drop_expired.
  • windows() counts a request in the window of its client-side end, and counts tokens, cost and model time in the window where the worker finished the call. Its historical max_queue_depth field is sampled system depth, including the running request; it is not the number waiting alone.
  • Histogram.record() puts a value equal to a bound into that bound’s bucket, and quantile() interpolates inside the bucket, starting the lowest bucket at 0.
  • evaluate() keeps a count of consecutive windows that meet each rule and turns it into a state.

The rules, with the reason for each threshold:

alerts.jsonSource · jsonDownload
{
  "about": "Alert rules for this build. Thresholds were chosen for teaching; a real service derives them from its own targets.",
  "rules": [
    {"name": "HighErrorRate", "metric": "error_rate", "threshold": 0.05, "for_windows": 2,
     "why": "More than 5% of requests ended in a 503 or a timeout for two windows in a row."},
    {"name": "SlowAnswers", "metric": "latency_ms.p95_from_buckets", "threshold": 2000, "for_windows": 1,
     "why": "The estimated 95th-percentile latency of answered requests reached 2 seconds."},
    {"name": "QueueBacklog", "metric": "max_queue_depth", "threshold": 15, "for_windows": 1,
     "why": "An arriving request found 15 or more requests ahead of it."},
    {"name": "TokenSpend", "metric": "output_tokens", "threshold": 3000, "for_windows": 1,
     "why": "The model generated 3,000 or more output tokens in one window."}
  ]
}

Run it

Terminal commandsTerminal · bash
python3 monitor.py --write
python3 monitor.py --check

The traffic in traffic.json has 5 phases: steady (240 s, 100 requests per minute), burst (120 s, 220 requests per minute), steady again (60 s, 100 requests per minute), slow model (120 s, 100 requests per minute, model calls 2 times slower), recovered (120 s, 100 requests per minute). The recorded calls take 365.3 ms of model time on average, so one worker can finish about 164 calls a minute. The burst asks for more than that, and so does steady traffic once the model is slower.

Configuration A, queue of 20; the worker runs every queued call:

Window start (s)ArrivalsAnswered503TimeoutError ratep50 (ms)p95 (ms)p95 from buckets (ms)Max sampled system depthOutput tokensExample cost (USD)
0100100000356.632499.069617.5020010.002420
60100100000354.734499.069618.1019940.002417
120100100000356.632499.069618.1020050.002425
180100100000356.632499.069618.1020150.002428
24022024371510.88681770.0682741.47243522133090.003969
3002200561641no valueno valueno value2133150.004010
360100790290.2685367.9571975.63519362024220.002914
420100180770.81051705.2842920.63845441716380.002000
480100014871no valueno valueno value2116200.001989
540100546440.4808370.662567.8928162123530.002805
600100100000361.471499.069618.1020310.002435
WindowRuleFromToValue
4HighErrorRateinactivepending0.8868
4SlowAnswersinactivefiring4352
4QueueBackloginactivefiring21
4TokenSpendinactivefiring3309
5HighErrorRatependingfiring1
5SlowAnswersfiringinactiveno value
6TokenSpendfiringinactive2422
7SlowAnswersinactivefiring4544
8SlowAnswersfiringinactiveno value
9SlowAnswersinactivefiring2816
10HighErrorRatefiringinactive0
10SlowAnswersfiringinactive618.1
10QueueBacklogfiringinactive0

Read the steady windows first. The exact p95 is 499.069 ms, and the estimate from the buckets is 617.5 ms. The p95 falls in the bucket from 320 to 640 ms, and linear interpolation inside a bucket that wide is off by 118.4 ms. A dashboard built on bucket estimates shows the bucket, not the value; in the slower windows the gap grows with the bucket width.

Now the burst, which starts at 240 s. The queue holds up to 20 requests, which is about 7.3 seconds of work at the average model time, longer than the client timeout of 3 s. A request at the back of a full queue cannot be answered in time, so the queue turns arrivals into timeouts instead of answers. In the windows that start at 300 s and 480 s, one in the burst and one in the slow-model phase, no request is answered at all. Over the whole run, configuration A ends 552 requests as timeouts and 113 as 503s, and it spends 263.4 seconds of model time on calls whose client had already given up. TokenSpend fires in the burst although few answers reached anyone: the tokens were spent all the same.

The alert changes show one trap. In window 5, SlowAnswers goes from firing to inactive. The service was not faster: no request was answered in that window, so the p95 has no value, and a rule with no value is not met. HighErrorRate was firing in the same window, which is why every latency rule needs an error-rate rule beside it.

Check your understanding

Try it: configuration B changes two things. Its queue holds 6 requests instead of 20, and the worker skips a call whose client has already given up. Before you open the comparison, predict whether B answers more or fewer requests than A, whether it returns more or fewer 503s, and how its wasted model time compares.

Compare your result
ConfigurationAnswered503TimeoutModel callsWasted model time (s)
A: queue of 20; the worker runs every queued call6751135521227263.4
B: queue of 6; the worker skips calls whose client has given up10631041731201104.7

B answers 1063 requests instead of 675, with 104 503s instead of 113. A shorter queue rejects at once the requests that a long queue would have kept until their clients gave up, so the requests it keeps can be answered in time. Skipping expired calls cuts the wasted model time from 263.4 to 104.7 seconds. B still wastes some: in the slow-model phase, a call can start before its deadline and finish after it. Its windows:

Window start (s)ArrivalsAnswered503TimeoutError ratep50 (ms)p95 (ms)p95 from buckets (ms)Max sampled system depthOutput tokensExample cost (USD)
0100100000356.632499.069617.5020010.002420
60100100000354.734499.069618.1019940.002417
120100100000356.632499.069618.1020050.002425
180100100000356.632499.069618.1020150.002428
2402201645000.23362332.8942886.1084595.2733320.004000
3002201665400.24552335.7382883.3084601.8732970.004008
360100106000370.662622.0023181.7621800.002598
420100220730.76841942.8082953.234768616560.002012
48010060940.942932.0042992.4724992616980.001979
54010099060.0571367.8961954.1061717.3620900.002516
600100100000361.471499.069618.1020310.002435
During a burst, the SlowAnswers alert resolves while the service is answering nothing at all. Why?

Choose one answer, then check your reasoning.

Read the explanation

An alert on a statistic of successful requests goes quiet when nothing succeeds. Pair latency rules with an error-rate rule, or treat a missing value as a reason to alert.

Failure cases

Each behavior below has a named test in test_monitor.py.

Failure case Behavior Test
Value exactly on a bucket bound It is counted in the bucket that the bound closes, as le buckets do test_a_value_on_a_bucket_bound_is_counted_in_that_bucket
Percentile from buckets Linear interpolation inside the bucket, from 0 for the lowest; the highest finite bound for the unbounded bucket; no value for an empty histogram test_bucket_quantile_interpolates_linearly_inside_its_bucket
Queue already full The request ends at once with a 503, zero latency and no model time test_a_full_queue_answers_503_at_once
Client gives up while the call runs The request ends as a timeout at its deadline; its model time and tokens are still counted test_a_client_timeout_caps_latency_but_the_worker_still_runs_the_call
Request ends in a later window It is counted where its client saw it end, not where it arrived test_a_request_is_counted_in_the_window_where_its_client_saw_it_end
503s missing from the error rate 503s and timeouts both count, over every request that ended in the window test_error_rate_counts_503s_and_timeouts_over_every_ended_request
One bad window The rule stays pending and returns to inactive; it fires only after its hold time test_one_bad_window_stays_pending_and_never_fires
Metric with no value The rule is not met and resolves, as SlowAnswers does in the recorded run test_missing_data_counts_as_not_met_so_the_alert_resolves

The other tests check nearest-rank percentiles, the cost formula, that every request ends in exactly one window, that calls.json matches the tracing build’s recording, and that a fresh run reproduces monitoring.json byte for byte. We removed each guard from the source, one at a time; every removal made its named test fail.

Terminal commandsTerminal · bash
python3 -m unittest -v test_monitor.py

The run ends with Ran 17 tests and OK on Python 3.11 and 3.15.

Deployment

No service was deployed. The deployment unit is a script that reads request records and writes window metrics and alert states.

In a running service, the same metrics come from instruments rather than from a list of records: a counter for requests by outcome, a histogram for latency with the bucket bounds above, a gauge for queue depth and counters for tokens, exported with the OpenTelemetry metrics SDK or scraped by Prometheus. Alert rules then run in the monitoring system, with a for duration matching for_windows. Before trusting a rule, replay a recorded incident through it, as monitor.py does, and check the windows where it should and should not fire, including a window with no answered request.

Scaling

No capacity was measured for the monitoring code. The costs in this code:

Cost in this code What changes with more traffic Next change
Every request record stays in memory until the windows are computed Memory grows with traffic Update counters and histogram buckets as each request ends, and keep only the current window
Exact percentiles sort every latency in a window Sorting grows with requests per window Keep the histogram only, and report bucket estimates with their bucket bounds
One set of metrics for the whole service No breakdown by tenant, model or route Add labels such as the tenant, and keep their number small: every label value multiplies the series stored

Key takeaways

  • Count every request once, by how and when its client saw it end. Answers, timeouts and 503s are different endings, and the error rate needs all of them.
  • A percentile estimated from buckets is only as precise as the bucket it falls in. Report the method beside the number.
  • A queue longer than the client can wait turns load into timeouts and wasted model time. Size it from the timeout, and skip calls nobody waits for.
  • Alert rules need hold times, and a latency rule needs an error-rate rule beside it: with nothing answered, a latency percentile has no value.

Best practices

  • Replay a recorded incident through every new alert rule and check when it goes pending, fires and resolves.
  • Count tokens and model time for calls whose client gave up; they are part of the cost.
  • Keep the rules, thresholds and their reasons in one versioned file, as alerts.json does.

References

Official documentation checked October 20, 2026:

Written by Arjun Prabhulal.

Module 4

Control access, guard output and inspect requests