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_quantileassumes 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
forclause, 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
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.
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 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 setsdrop_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 historicalmax_queue_depthfield 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, andquantile()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:
{
"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
python3 monitor.py --write
python3 monitor.py --checkThe 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) | Arrivals | Answered | 503 | Timeout | Error rate | p50 (ms) | p95 (ms) | p95 from buckets (ms) | Max sampled system depth | Output tokens | Example cost (USD) |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 100 | 100 | 0 | 0 | 0 | 356.632 | 499.069 | 617.5 | 0 | 2001 | 0.002420 |
| 60 | 100 | 100 | 0 | 0 | 0 | 354.734 | 499.069 | 618.1 | 0 | 1994 | 0.002417 |
| 120 | 100 | 100 | 0 | 0 | 0 | 356.632 | 499.069 | 618.1 | 0 | 2005 | 0.002425 |
| 180 | 100 | 100 | 0 | 0 | 0 | 356.632 | 499.069 | 618.1 | 0 | 2015 | 0.002428 |
| 240 | 220 | 24 | 37 | 151 | 0.8868 | 1770.068 | 2741.472 | 4352 | 21 | 3309 | 0.003969 |
| 300 | 220 | 0 | 56 | 164 | 1 | no value | no value | no value | 21 | 3315 | 0.004010 |
| 360 | 100 | 79 | 0 | 29 | 0.2685 | 367.957 | 1975.635 | 1936 | 20 | 2422 | 0.002914 |
| 420 | 100 | 18 | 0 | 77 | 0.8105 | 1705.284 | 2920.638 | 4544 | 17 | 1638 | 0.002000 |
| 480 | 100 | 0 | 14 | 87 | 1 | no value | no value | no value | 21 | 1620 | 0.001989 |
| 540 | 100 | 54 | 6 | 44 | 0.4808 | 370.66 | 2567.89 | 2816 | 21 | 2353 | 0.002805 |
| 600 | 100 | 100 | 0 | 0 | 0 | 361.471 | 499.069 | 618.1 | 0 | 2031 | 0.002435 |
| Window | Rule | From | To | Value |
|---|---|---|---|---|
| 4 | HighErrorRate | inactive | pending | 0.8868 |
| 4 | SlowAnswers | inactive | firing | 4352 |
| 4 | QueueBacklog | inactive | firing | 21 |
| 4 | TokenSpend | inactive | firing | 3309 |
| 5 | HighErrorRate | pending | firing | 1 |
| 5 | SlowAnswers | firing | inactive | no value |
| 6 | TokenSpend | firing | inactive | 2422 |
| 7 | SlowAnswers | inactive | firing | 4544 |
| 8 | SlowAnswers | firing | inactive | no value |
| 9 | SlowAnswers | inactive | firing | 2816 |
| 10 | HighErrorRate | firing | inactive | 0 |
| 10 | SlowAnswers | firing | inactive | 618.1 |
| 10 | QueueBacklog | firing | inactive | 0 |
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
| Configuration | Answered | 503 | Timeout | Model calls | Wasted model time (s) |
|---|---|---|---|---|---|
| A: queue of 20; the worker runs every queued call | 675 | 113 | 552 | 1227 | 263.4 |
| B: queue of 6; the worker skips calls whose client has given up | 1063 | 104 | 173 | 1201 | 104.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) | Arrivals | Answered | 503 | Timeout | Error rate | p50 (ms) | p95 (ms) | p95 from buckets (ms) | Max sampled system depth | Output tokens | Example cost (USD) |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 100 | 100 | 0 | 0 | 0 | 356.632 | 499.069 | 617.5 | 0 | 2001 | 0.002420 |
| 60 | 100 | 100 | 0 | 0 | 0 | 354.734 | 499.069 | 618.1 | 0 | 1994 | 0.002417 |
| 120 | 100 | 100 | 0 | 0 | 0 | 356.632 | 499.069 | 618.1 | 0 | 2005 | 0.002425 |
| 180 | 100 | 100 | 0 | 0 | 0 | 356.632 | 499.069 | 618.1 | 0 | 2015 | 0.002428 |
| 240 | 220 | 164 | 50 | 0 | 0.2336 | 2332.894 | 2886.108 | 4595.2 | 7 | 3332 | 0.004000 |
| 300 | 220 | 166 | 54 | 0 | 0.2455 | 2335.738 | 2883.308 | 4601.8 | 7 | 3297 | 0.004008 |
| 360 | 100 | 106 | 0 | 0 | 0 | 370.66 | 2622.002 | 3181.7 | 6 | 2180 | 0.002598 |
| 420 | 100 | 22 | 0 | 73 | 0.7684 | 1942.808 | 2953.23 | 4768 | 6 | 1656 | 0.002012 |
| 480 | 100 | 6 | 0 | 94 | 0.94 | 2932.004 | 2992.472 | 4992 | 6 | 1698 | 0.001979 |
| 540 | 100 | 99 | 0 | 6 | 0.0571 | 367.896 | 1954.106 | 1717.3 | 6 | 2090 | 0.002516 |
| 600 | 100 | 100 | 0 | 0 | 0 | 361.471 | 499.069 | 618.1 | 0 | 2031 | 0.002435 |
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.
python3 -m unittest -v test_monitor.pyThe 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.jsondoes.
References
Official documentation checked October 20, 2026:
- OpenTelemetry GenAI semantic conventions: client inference metrics —
gen_ai.client.inference.durationand its recommended bucket boundaries; token usage metrics. Development status. - Prometheus: histogram_quantile — linear interpolation inside a bucket and the highest bucket.
- Prometheus: alerting rules — the
forclause, pending and firing. - Ollama: chat API —
prompt_eval_count,eval_countand the durations recorded incalls.json. - Python: bisect and math.fsum