12.2 Observability, Logging & LLMOps¶
Deploying a language model is not the finish line — it is the starting gun. In traditional software, you ship code, watch error rates, and deploy a hotfix. With LLMs the failure modes are subtler: the model might still answer every request with HTTP 200 while silently hallucinating, gradually drifting off-topic, accumulating prompt-injection vulnerabilities, or costing ten times what you budgeted. Observability — the practice of understanding a system’s internal state from its external outputs — is the discipline that closes this loop.
This chapter covers the full LLMOps observability stack: how to trace individual requests, what metrics to collect and alert on, how to log prompts and responses safely, how to run evaluations inside production traffic, how to detect quality drift, and how to wire all of this into a continuous improvement flywheel. The tools we examine (Langfuse, Phoenix/Arize, Weights & Biases, Prometheus, OpenTelemetry) are illustrative; the concepts are portable.
For the upstream serving architecture that this observability layer wraps, see Designing an LLM Serving System. For offline evaluation techniques such as LLM-as-a-judge, see LLM-as-a-Judge & Automated Evaluation.
The Three Pillars: Traces, Metrics, Logs¶
The canonical observability model from distributed systems gives us three signals. All three are necessary for LLMs; none alone is sufficient.
Traces capture the causal chain of a single request. For a RAG pipeline, a trace spans the user query, the embedding call, the vector-database retrieval, the prompt construction, the LLM call, and finally the response. Each step is a span with a start time, duration, input, and output. Traces answer “what happened to request X?”
Metrics are aggregated numerical time-series: token counts, latency percentiles, error rates, cost. Metrics answer “how is the system behaving overall right now?”
Logs are structured records of individual events. For LLMs the most important logs are the verbatim prompt and response strings, because the model’s behavior is expressed in natural language that no metric can fully capture. Logs answer “what exactly did the model say?”
Distributed Tracing with OpenTelemetry¶
OpenTelemetry (OTel) is the vendor-neutral standard for generating and propagating traces, metrics, and logs. The Python SDK makes it straightforward to instrument an LLM service.
# trace_setup.py — one-time bootstrap for an LLM microservice
# Requirements: opentelemetry-sdk opentelemetry-exporter-otlp-proto-grpc
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
def setup_tracing(service_name: str, otlp_endpoint: str = "http://localhost:4317") -> trace.Tracer:
"""Configure an OTel TracerProvider that exports to any OTLP-compatible backend.
Compatible backends include: Langfuse (via proxy), Jaeger, Grafana Tempo,
Google Cloud Trace, Honeycomb, and Datadog.
"""
provider = TracerProvider()
exporter = OTLPSpanExporter(endpoint=otlp_endpoint, insecure=True)
# BatchSpanProcessor buffers spans and sends them asynchronously
# to avoid adding latency to the critical path.
provider.add_span_processor(BatchSpanProcessor(exporter))
trace.set_tracer_provider(provider)
return trace.get_tracer(service_name)
TRACER = setup_tracing("llm-chat-service")
# rag_pipeline.py — a fully-instrumented RAG request handler
import time
import hashlib
from opentelemetry import trace
from trace_setup import TRACER
def handle_request(
user_id: str,
query: str,
retriever,
llm_client,
max_tokens: int = 512,
) -> dict:
"""
Process one user query through a RAG pipeline, emitting OTel spans for
every stage. The root span holds the full end-to-end latency.
"""
# Root span for the entire request.
with TRACER.start_as_current_span("rag_request") as root_span:
# Attach stable, low-cardinality attributes for filtering in dashboards.
root_span.set_attribute("user.id", user_id)
root_span.set_attribute("query.length_chars", len(query))
root_span.set_attribute("query.hash", hashlib.md5(query.encode()).hexdigest()[:8])
# ── Stage 1: Retrieval ────────────────────────────────────────────────
with TRACER.start_as_current_span("retrieval") as ret_span:
t0 = time.perf_counter()
docs = retriever.retrieve(query, top_k=5)
ret_span.set_attribute("retrieval.latency_ms", (time.perf_counter() - t0) * 1000)
ret_span.set_attribute("retrieval.num_docs", len(docs))
ret_span.set_attribute("retrieval.top_score", docs[0]["score"] if docs else 0.0)
# ── Stage 2: Prompt construction ─────────────────────────────────────
with TRACER.start_as_current_span("prompt_build") as pb_span:
context_text = "\n\n".join(d["text"] for d in docs)
system_prompt = "You are a helpful assistant. Use the provided context."
user_message = f"Context:\n{context_text}\n\nQuestion: {query}"
pb_span.set_attribute("prompt.system_chars", len(system_prompt))
pb_span.set_attribute("prompt.user_chars", len(user_message))
# ── Stage 3: LLM inference ───────────────────────────────────────────
with TRACER.start_as_current_span("llm_inference") as llm_span:
t0 = time.perf_counter()
response = llm_client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_message},
],
max_tokens=max_tokens,
)
elapsed_ms = (time.perf_counter() - t0) * 1000
usage = response.usage
# LLM-specific span attributes are the most valuable for cost analysis.
# Use the OpenTelemetry GenAI semantic-convention names (gen_ai.*) rather
# than ad-hoc keys, so any OTel-native backend can build dashboards without
# per-app mapping rules. (The conventions are still marked "Development",
# so pin the SDK version and expect occasional renames.)
llm_span.set_attribute("gen_ai.operation.name", "chat")
llm_span.set_attribute("gen_ai.system", "openai") # newer revisions: gen_ai.provider.name
llm_span.set_attribute("gen_ai.request.model", "gpt-4o-mini")
llm_span.set_attribute("gen_ai.request.max_tokens", max_tokens)
llm_span.set_attribute("gen_ai.response.model", response.model)
llm_span.set_attribute("gen_ai.usage.input_tokens", usage.prompt_tokens)
llm_span.set_attribute("gen_ai.usage.output_tokens", usage.completion_tokens)
# finish_reasons is an ARRAY in the spec (n>1 sampling returns several).
llm_span.set_attribute("gen_ai.response.finish_reasons",
[c.finish_reason for c in response.choices])
llm_span.set_attribute("llm.latency_ms", elapsed_ms) # app-local extra
answer = response.choices[0].message.content
root_span.set_attribute("response.length_chars", len(answer))
return {"answer": answer, "docs": docs}
The key design principle here: put semantic attributes on every span so you can slice metrics by model, user cohort, or prompt template later. Avoid putting raw prompt strings in span attributes (size limits + PII risk) — instead log them separately and link by trace ID. The GenAI conventions do define a way to carry message content, but it is opt-in precisely because of that risk; most SDKs gate it behind an environment flag such as OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT.
In practice you rarely hand-write every span. Three open-source auto-instrumentation families monkey-patch the client libraries for you and emit conforming spans: opentelemetry-instrumentation-* packages from OTel itself (contrib, plus a openai-v2 GenAI instrumentation), Arize’s OpenInference instrumentors (openinference-instrumentation-openai, -langchain, -llama-index, -vllm), and Traceloop’s OpenLLMetry (traceloop-sdk). All three export plain OTLP, so they land in whatever backend you already run:
# auto_instrument.py — zero-code-change tracing for OpenAI + LangChain calls
# Requirements: openinference-instrumentation-openai openinference-instrumentation-langchain
from openinference.instrumentation.openai import OpenAIInstrumentor
from openinference.instrumentation.langchain import LangChainInstrumentor
from trace_setup import setup_tracing
setup_tracing("llm-chat-service") # installs the global TracerProvider first
OpenAIInstrumentor().instrument() # every .chat.completions.create() now emits a span
LangChainInstrumentor().instrument() # chains/agents emit a nested span tree
Write manual spans only for the parts your code owns — retrieval, prompt assembly, business logic — and let the instrumentors cover the library calls.
Token, Latency & Cost Metrics¶
Metrics are aggregates. We want to observe their distributions over time, not just averages. Use Prometheus-style histograms (or their equivalents) to track percentiles.
The four core LLM metrics¶
| Metric | Unit | Why It Matters |
|---|---|---|
| Time to First Token (TTFT) | milliseconds | User-perceived responsiveness for streaming |
| Time per Output Token (TPOT) | ms / token | Streaming smoothness; bottlenecks in decode |
| Total request latency | milliseconds | End-to-end SLA |
| Token cost | USD / 1M tokens | Unit economics; budget guardrails |
TTFT is dominated by prefill and queue wait. TPOT is dominated by memory bandwidth during autoregressive decode. See The Anatomy of LLM Inference: Prefill, Decode & The KV Cache for the underlying mechanics, and Inference Economics: Latency, Throughput & Cost for the cost model in full detail.
The OTel GenAI conventions name these too — gen_ai.client.operation.duration and gen_ai.client.token.usage on the caller side, gen_ai.server.time_to_first_token and gen_ai.server.time_per_output_token on the inference-server side. If you emit metrics through the OTel SDK you get those names for free; the hand-rolled Prometheus metrics below are what you write when Prometheus is already your system of record. Either way, keep one naming scheme across services or your dashboards will silently double-count.
# metrics.py — Prometheus metrics for an LLM serving endpoint
# Requirements: prometheus_client
from prometheus_client import Counter, Histogram, Gauge, start_http_server
# ── Counters (monotonically increasing) ──────────────────────────────────────
REQUESTS_TOTAL = Counter(
"llm_requests_total",
"Total LLM requests",
["model", "status"], # labels allow slicing by model and outcome
)
TOKENS_TOTAL = Counter(
"llm_tokens_total",
"Total tokens processed",
["model", "direction"], # direction = 'prompt' or 'completion'
)
# ── Histograms (track distributions, not just averages) ───────────────────────
TTFT_HISTOGRAM = Histogram(
"llm_time_to_first_token_seconds",
"Time to first token in seconds",
["model"],
# Buckets tuned for typical LLM latencies: 50ms → 10s
buckets=[0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0],
)
LATENCY_HISTOGRAM = Histogram(
"llm_request_latency_seconds",
"End-to-end request latency",
["model"],
buckets=[0.1, 0.5, 1.0, 2.0, 5.0, 10.0, 30.0, 60.0],
)
COST_HISTOGRAM = Histogram(
"llm_request_cost_usd",
"Estimated cost per request in USD",
["model"],
buckets=[0.0001, 0.001, 0.01, 0.05, 0.10, 0.50, 1.0],
)
# ── Gauges (current snapshot) ─────────────────────────────────────────────────
QUEUE_DEPTH = Gauge("llm_request_queue_depth", "Current pending request count")
# Pricing table (USD per 1M tokens); update as providers change rates
PRICING = {
"gpt-4o-mini": {"prompt": 0.15, "completion": 0.60},
"gpt-4o": {"prompt": 5.00, "completion": 15.0},
}
def record_llm_call(model: str, prompt_tokens: int, completion_tokens: int,
latency_s: float, ttft_s: float, error: bool = False):
"""Call this after every LLM API response to update all metrics."""
status = "error" if error else "ok"
REQUESTS_TOTAL.labels(model=model, status=status).inc()
TOKENS_TOTAL.labels(model=model, direction="prompt").inc(prompt_tokens)
TOKENS_TOTAL.labels(model=model, direction="completion").inc(completion_tokens)
TTFT_HISTOGRAM.labels(model=model).observe(ttft_s)
LATENCY_HISTOGRAM.labels(model=model).observe(latency_s)
if model in PRICING:
cost = (prompt_tokens / 1e6 * PRICING[model]["prompt"]
+ completion_tokens / 1e6 * PRICING[model]["completion"])
COST_HISTOGRAM.labels(model=model).observe(cost)
if __name__ == "__main__":
start_http_server(9090) # Prometheus scrapes :9090/metrics
Alerting thresholds (example rules)¶
# prometheus_alerts.yml
groups:
- name: llm_service
rules:
# Alert if median TTFT exceeds 2 seconds for 5 minutes
- alert: LLMHighTTFT
expr: histogram_quantile(0.50, rate(llm_time_to_first_token_seconds_bucket[5m])) > 2.0
for: 5m
labels:
severity: warning
annotations:
summary: "LLM median TTFT is {{ $value | humanizeDuration }}"
# Alert if error rate exceeds 1% over 5 minutes
- alert: LLMHighErrorRate
expr: >
rate(llm_requests_total{status="error"}[5m])
/ rate(llm_requests_total[5m]) > 0.01
for: 5m
labels:
severity: critical
# Alert if hourly token cost is trending toward budget overage
- alert: LLMCostBudgetWarning
expr: sum(rate(llm_tokens_total{direction="completion"}[1h])) * 3600 * 0.60 / 1e6 > 100
for: 10m
labels:
severity: warning
annotations:
summary: "Projected hourly completion cost exceeds $100"
Worked example: Cost and budget math
Suppose your application sends an average of 800 prompt tokens and receives 300 completion tokens per request, using gpt-4o-mini (USD 0.15/M prompt, USD 0.60/M completion).
Cost per request:
At 10 requests per second, that is:
If you switch to a model that is 40% cheaper on completion tokens (USD 0.36/M), and you shorten the average prompt by 200 tokens via better context management, daily cost becomes:
That is USD 171.07/day — a 34% reduction. Tracking token distributions per-template makes these opportunities visible; without metrics you are flying blind.
Cost when you host the model yourself¶
The PRICING table above only exists because a vendor published a price list. When you serve your own weights — the situation for the model you build in Evaluation & Serving: Honest Benchmarks, int4 Quantization, and Running on a Laptop — there is no per-token price; there is a rented GPU that costs the same whether it is busy or idle. The unit cost is therefore derived from throughput:
where \(C_{\text{gpu}}\) is the hourly instance price and \(T\) is sustained output tokens/second at your target latency SLO. For example, at an illustrative USD 2.00/hour for a single GPU and a measured \(T = 900\) tokens/s under continuous batching, that is \(2.00 / (900 \times 3600) \times 10^6 \approx \$0.62\) per 1M output tokens — and it halves if better batching doubles \(T\). This is why self-hosted cost dashboards must plot the derived cost alongside utilization: an idle GPU has infinite cost per token. Instrument it directly from the serving metrics rather than a static table:
# selfhost_cost.py — derive USD/1M tokens from live throughput
GPU_HOURLY_USD = 2.00 # your actual instance price
NUM_GPUS = 1
def usd_per_million_tokens(tokens_per_second: float) -> float:
"""Amortize GPU rent over measured throughput. Returns inf when idle."""
if tokens_per_second <= 0:
return float("inf")
cost_per_second = GPU_HOURLY_USD * NUM_GPUS / 3600.0
return cost_per_second / tokens_per_second * 1e6
In PromQL against a vLLM server, tokens_per_second is just rate(vllm:generation_tokens_total[5m]), so the whole cost panel is one expression. See Inference Economics: Latency, Throughput & Cost for the full utilization-vs-latency tradeoff behind \(T\).
Prompt & Response Logging¶
Raw prompt and response logging is the highest-fidelity signal available, but also the most sensitive. The discipline is: log what you need, protect what you log, and delete what you no longer need.
What to log¶
# log_schema.py — Pydantic model for a structured LLM log record
from pydantic import BaseModel, Field
from typing import Optional, List
from datetime import datetime
import uuid
class LLMLogRecord(BaseModel):
# Identity
record_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
trace_id: str # OTel trace ID — links to distributed trace
session_id: str # Groups turns in a multi-turn conversation
user_id_hashed: str # SHA-256 of user ID — never store raw PII here
# Request context
timestamp_utc: datetime
model: str
prompt_version: str # e.g. "chat-v3.2" — MUST be versioned for regression analysis
# Content — store in encrypted, access-controlled storage (not plaintext RDBMS)
system_prompt: Optional[str] = None # May contain IP; log if needed for debugging
user_message: str
assistant_reply: str
# Usage
prompt_tokens: int
completion_tokens: int
latency_ms: float
finish_reason: str # "stop", "length", "content_filter", etc.
# Evaluation signals (filled in asynchronously)
thumbs_up: Optional[bool] = None # User feedback if collected
auto_eval_score: Optional[float] = None # From async LLM-as-judge pipeline
safety_flagged: Optional[bool] = None # From guardrail layer
class Config:
json_encoders = {datetime: lambda v: v.isoformat()}
Privacy and data-handling rules¶
- Hash or pseudonymize user IDs before logging. Use a keyed HMAC so you can re-identify for debugging under a formal process, but the logs are not linkable without the key.
- Classify prompts by sensitivity tier. A coding assistant’s prompts are low sensitivity; a healthcare assistant’s prompts may contain PHI and must never leave a HIPAA-compliant boundary.
- Define retention policies. Seven days for debugging, 90 days for eval/training, one year for compliance. Automate deletion.
- Separate storage from compute. Write logs to an append-only, encrypted object store (S3 + SSE-KMS, GCS) and query with Athena or BigQuery. Do not log to a relational database that is also serving production traffic.
Common pitfall: logging to stdout in production
Structured logs emitted to stdout get mixed with framework noise, may be truncated at 64 KB by your log collector, and — critically — are often streamed to a central logging system that security teams have not reviewed for PII. Always write LLM content logs to a dedicated, permission-controlled sink. Use the trace ID to correlate with the main log stream without duplicating sensitive data there.
Evaluation in Production¶
Offline evaluation with held-out benchmarks tells you how a model performs on a fixed distribution. Production evaluation tells you how it performs on your actual users, who are always stranger than your benchmark dataset. The two must both be part of your release process.
Sampling strategy¶
You cannot judge every response with an LLM-as-judge (it is expensive and adds latency). Instead, sample strategically:
# production_eval_sampler.py
import random
from dataclasses import dataclass
from typing import Optional
@dataclass
class SamplingPolicy:
base_rate: float # Fraction of all traffic to evaluate (e.g. 0.05 = 5%)
failure_rate: float # Fraction of error/flagged requests to evaluate (e.g. 1.0 = 100%)
new_prompt_rate: float # Fraction of requests using a new prompt template to evaluate
low_score_rate: float # Fraction of requests below a score threshold to evaluate
def should_evaluate(record: dict, policy: SamplingPolicy) -> bool:
"""Return True if this record should be sent to the async eval pipeline."""
# Always evaluate failures and safety flags
if record.get("finish_reason") in ("content_filter", "error"):
return random.random() < policy.failure_rate
# Evaluate new prompt versions at higher rate to catch regressions early
if record.get("is_new_prompt_version", False):
return random.random() < policy.new_prompt_rate
# Evaluate previously low-scoring records (detected drift)
if record.get("auto_eval_score", 1.0) < 0.5:
return random.random() < policy.low_score_rate
# Baseline random sample for steady-state tracking
return random.random() < policy.base_rate
POLICY = SamplingPolicy(
base_rate=0.05,
failure_rate=1.00,
new_prompt_rate=0.30,
low_score_rate=0.50,
)
Async LLM-as-judge evaluation pipeline¶
# async_eval_worker.py
# Runs as a separate process / Cloud Function; reads from an eval queue
import asyncio
import json
from openai import AsyncOpenAI
client = AsyncOpenAI()
JUDGE_SYSTEM = """You are an expert evaluator. Score the assistant reply on:
1. Faithfulness (0-2): Does the reply contradict the provided context?
2. Relevance (0-2): Does the reply address the user's question?
3. Fluency (0-1): Is the reply grammatically correct?
Output ONLY valid JSON: {"faithfulness": X, "relevance": X, "fluency": X, "reason": "..."}"""
async def judge_single(record: dict) -> dict:
"""Call the judge model and parse the score, with a 30-second timeout."""
prompt = (
f"Context provided to assistant:\n{record.get('context', 'none')}\n\n"
f"User question: {record['user_message']}\n\n"
f"Assistant reply: {record['assistant_reply']}"
)
response = await asyncio.wait_for(
client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": JUDGE_SYSTEM},
{"role": "user", "content": prompt},
],
temperature=0, # Zero temperature for reproducibility
max_tokens=256,
),
timeout=30.0,
)
raw = response.choices[0].message.content
scores = json.loads(raw)
scores["composite"] = (scores["faithfulness"] + scores["relevance"] + scores["fluency"]) / 5.0
scores["record_id"] = record["record_id"]
return scores
async def eval_batch(records: list[dict]) -> list[dict]:
"""Evaluate a batch of records concurrently (up to 20 in parallel)."""
semaphore = asyncio.Semaphore(20)
async def bounded(r):
async with semaphore:
try:
return await judge_single(r)
except Exception as e:
return {"record_id": r["record_id"], "error": str(e), "composite": None}
return await asyncio.gather(*[bounded(r) for r in records])
For a deep dive into LLM-as-judge methodology and bias mitigation, see LLM-as-a-Judge & Automated Evaluation.
Drift Detection¶
A model that performed well at launch can degrade silently as user behaviour evolves, upstream data sources change, or the base model provider silently updates their serving stack. There are three types of drift to monitor.
Input drift (covariate shift)¶
Track statistics of the prompt distribution over time. If users start asking qualitatively different questions, your eval scores from an earlier period are no longer representative.
The Population Stability Index (PSI) measures how much a distribution \(P\) (current) has shifted from a reference \(Q\) (baseline). By convention: PSI < 0.1 is stable, 0.1–0.25 is moderate shift, > 0.25 is significant. Apply it to binned token count distributions, embedding dimensions, or topic proportions.
# drift_detection.py — PSI on prompt embedding distributions
import numpy as np
from sklearn.decomposition import PCA
def compute_psi(reference: np.ndarray, current: np.ndarray, n_bins: int = 10) -> float:
"""
Compute Population Stability Index on 1-D arrays.
Typical usage: call this on the first principal component of prompt embeddings,
comparing a rolling 24-hour window against the previous 7-day baseline.
"""
# Use reference distribution to define bin edges (important: same bins for both)
min_val = min(reference.min(), current.min())
max_val = max(reference.max(), current.max())
bins = np.linspace(min_val, max_val, n_bins + 1)
ref_counts, _ = np.histogram(reference, bins=bins)
cur_counts, _ = np.histogram(current, bins=bins)
# Add small epsilon to avoid division by zero or log(0)
eps = 1e-6
ref_pct = (ref_counts + eps) / (ref_counts.sum() + eps * n_bins)
cur_pct = (cur_counts + eps) / (cur_counts.sum() + eps * n_bins)
psi = np.sum((cur_pct - ref_pct) * np.log(cur_pct / ref_pct))
return float(psi)
def monitor_embedding_drift(
reference_embeddings: np.ndarray, # shape: (N_ref, D)
current_embeddings: np.ndarray, # shape: (N_cur, D)
n_components: int = 5,
) -> dict[str, float]:
"""Project embeddings to principal components, then compute PSI on each."""
pca = PCA(n_components=n_components).fit(reference_embeddings)
ref_proj = pca.transform(reference_embeddings)
cur_proj = pca.transform(current_embeddings)
results = {}
for i in range(n_components):
results[f"psi_pc{i}"] = compute_psi(ref_proj[:, i], cur_proj[:, i])
results["psi_max"] = max(results.values())
return results
Output quality drift¶
Track your eval composite score as a rolling mean. Use a Page-Cusum or CUSUM change-point algorithm to detect a sustained downward shift that is not noise:
where \(\mu_0\) is the in-control mean quality score, \(k\) is the allowance parameter (typically half the smallest shift to detect), and an alert fires when \(S_n > h\) (a decision threshold, commonly set by ARL — average run length — analysis).
Provider / model version drift¶
A model provider can silently change model behavior behind the same API endpoint (e.g. “gpt-4o-2024-05-13” vs a later snapshot). Pin model versions explicitly in your API calls. Add a canary that runs a fixed probe set once per hour and alerts if the judge score drops more than a threshold.
A/B Testing and Canary Releases¶
LLMOps borrows traffic-splitting patterns from traditional MLOps but with LLM-specific wrinkles: there is no single scalar prediction to compare; quality is a distribution; and changes compound over multi-turn conversations.
Traffic splitting¶
# ab_router.py — stateless traffic-split router for LLM variants
import hashlib
from typing import Callable
# Variant config: (weight, handler_function)
# Weights must sum to 1.0.
VARIANTS = {
"control": (0.80, "handle_with_gpt4o_mini"),
"treatment_A": (0.10, "handle_with_claude3_haiku"),
"treatment_B": (0.10, "handle_with_gpt4o_mini_v2_prompt"),
}
def route_request(session_id: str) -> str:
"""
Route a request to a variant using a deterministic hash.
Using a hash of session_id (not a random draw) ensures that the SAME
user always gets the SAME variant across multiple turns — critical for
multi-turn conversation quality evaluation.
"""
# MD5 is fine here; we need bucketing, not cryptographic security.
bucket = int(hashlib.md5(session_id.encode()).hexdigest(), 16) % 1000
cumulative = 0
for variant_name, (weight, _handler) in VARIANTS.items():
cumulative += int(weight * 1000)
if bucket < cumulative:
return variant_name
return "control" # fallback
Statistical significance for quality metrics¶
Comparing eval scores across two variants with a t-test requires careful sample size planning. Use a two-sample t-test on per-session composite scores:
A typical LLM quality score (0–1 range) has standard deviation on the order of 0.15–0.25. To detect a 5-percentage-point improvement (\(\delta = 0.05\)) at 80% power and \(\alpha = 0.05\), you need roughly:
At 1,000 sessions/day with 10% traffic in the treatment arm, that is 100 sessions/day per variant — plan for 2–3 days before making a decision.
Interview Corner
Q: You deploy a new prompt template and want to know if it improves response quality. How do you set up and interpret the A/B test?
A: Route a small fraction of traffic (say 10%) to the new template, using session-level hashing for consistency within multi-turn conversations. Collect per-session composite quality scores from your async LLM-as-judge pipeline. After reaching the pre-determined sample size (calculated from expected effect size, variance, and desired power), run a two-sample t-test or Mann-Whitney U test (the latter is more robust to non-normal score distributions). Guard against multiple comparisons if you test several metrics — use Bonferroni correction or pre-register a primary metric. Also track secondary guardrails: latency, cost, error rate. Report lift as both absolute (e.g. +3.2 pp) and relative (+5.1%) with confidence intervals. Only ship if you see statistically significant improvement on the primary metric AND no degradation on guardrails.
Langfuse and the LLMOps Ecosystem¶
Langfuse is an open-source LLM observability platform that provides a purpose-built UI for traces, evals, prompt versioning, and datasets. Since its v3 Python SDK it is built on top of OpenTelemetry: the decorator below creates ordinary OTel spans, so Langfuse can also ingest spans from any other OTel exporter and you are not locked in.
# langfuse_integration.py — SDK-level Langfuse tracing (v3 Python SDK)
# Requirements: langfuse openai
from langfuse import observe, get_client
# Drop-in wrapper: identical surface to `openai`, but every call is traced.
from langfuse.openai import OpenAI
# Langfuse reads LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY, LANGFUSE_HOST from env
langfuse = get_client()
client = OpenAI()
@observe(name="rag_pipeline") # Creates a trace named "rag_pipeline"
def run_rag(query: str, user_id: str) -> str:
# Update the current trace with metadata (user, session, tags)
langfuse.update_current_trace(
user_id=user_id,
tags=["production", "rag-v2"],
)
# Retrieval step — appears as a child span
docs = retrieve_documents(query)
# LLM call — the wrapped client records model, token usage, and cost automatically
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": build_prompt(query, docs)}],
)
return response.choices[0].message.content
@observe(name="retrieve_documents") # Child span for retrieval
def retrieve_documents(query: str) -> list:
# ... vector DB call ...
return []
def build_prompt(query, docs):
return f"Context: {docs}\n\nQuestion: {query}"
Beyond Langfuse, the ecosystem includes:
- Arize Phoenix: Open-source, strong embedding visualization and drift monitoring; excellent for RAG systems.
- Weights & Biases Weave: Integrates naturally if you are already using W&B for training; supports traces and evals.
- Helicone: Proxy-based approach — zero code changes; good for quick cost/latency dashboards.
- Datadog LLM Observability: Managed, enterprise-grade; integrates with existing Datadog alerts.
- OpenLIT: OpenTelemetry-native SDK with native GPU metric collection, useful when running self-hosted inference.
Observing a self-hosted server¶
If you serve your own weights, the engine already emits most of what this chapter asks for and you should not re-implement it. vLLM exposes a Prometheus endpoint at /metrics on the API server, including vllm:num_requests_running, vllm:num_requests_waiting (the queue-depth gauge), vllm:kv_cache_usage_perc, vllm:time_to_first_token_seconds, vllm:time_per_output_token_seconds, vllm:e2e_request_latency_seconds, and the vllm:prompt_tokens_total / vllm:generation_tokens_total counters. SGLang exposes an equivalent set once launched with --enable-metrics. Both also speak OTLP for traces, so the same collector receives engine spans and your application spans:
# Self-hosted serving with metrics + tracing turned on.
vllm serve ./stack-100m \
--port 8000 \
--otlp-traces-endpoint http://localhost:4317 # engine spans -> your OTel collector
# Prometheus scrape target is then http://localhost:8000/metrics
# SGLang equivalent
python -m sglang.launch_server --model-path ./stack-100m \
--port 30000 --enable-metrics
# prometheus.yml — scrape a self-hosted engine alongside your app
scrape_configs:
- job_name: vllm
metrics_path: /metrics
static_configs:
- targets: ["localhost:8000"]
- job_name: llm-app
static_configs:
- targets: ["localhost:9090"]
vLLM ships an example Prometheus + Grafana stack in its examples/ directory, so a usable dashboard is a docker compose up away rather than a build project. See vLLM: Architecture, PagedAttention & Internals for what the underlying gauges mean, and Evaluation & Serving: Honest Benchmarks, int4 Quantization, and Running on a Laptop for wiring this to the Stack-100M model you build in Part XIV.
Aside: observability during training, not just serving
The same discipline applies one layer up. A pretraining run is a long-lived job whose health is visible only through telemetry: loss, gradient norm, learning rate, tokens/second, MFU, and activation/gradient statistics. The open-source tools there are Weights & Biases, MLflow, TensorBoard, and Aim — logged from the training loop rather than scraped. The failure modes rhyme with production ones (a silent loss spike is the training-time analogue of a silent hallucination), and the fix is the same: log enough to reconstruct what happened without re-running. See The Pretraining Run: A Complete Single-GPU Training Loop for the concrete logging set used in the capstone run.
The LLMOps Lifecycle and Continuous Improvement¶
LLMOps is the practice of operating LLM-powered systems with the same rigor applied to software services. The lifecycle has five phases that cycle continuously.
Phase 1: Ship with a canary¶
Route 5–10% of traffic to the new version. Monitor error rate, latency P95, and eval composite score. Use automated rollback if any guardrail metric degrades beyond a threshold within the first hour.
Phase 2: Observe continuously¶
The metrics and trace pipeline described above should be always-on. Build dashboards at three granularities: real-time (1-minute granularity, for incident response), daily (for business-level KPIs), and weekly (for trend and drift analysis).
Phase 3: Evaluate a curated sample¶
Maintain a golden dataset of 200–500 representative requests with human-authored reference answers. Re-run this dataset against every new model version or prompt change before any canary. Supplement with production samples filtered by the sampling policy above. Track score over time in your experiment tracking tool.
Phase 4: Improve — the data flywheel¶
Production logs are a gold mine. Low-rated responses (thumbs-down or low judge score) become negative examples. High-rated responses become positive examples. If your platform supports it (see Data Flywheels & Continuous Improvement), route these curated examples back into your fine-tuning pipeline.
Prompt improvements are often the fastest wins. Langfuse’s prompt management lets you maintain versioned system prompts, A/B test them against production traffic, and roll back instantly — without a code deployment.
Phase 5: Retrain or fine-tune¶
After accumulating sufficient labeled production data, fine-tune the model. Track the fine-tuning run in your experiment tracking tool (W&B, MLflow) with all hyperparameters and dataset hashes. Run your golden-dataset eval before and after, and require a statistically significant improvement on the primary metric to promote the new weights.
For the mechanics of efficient fine-tuning, see PEFT I: LoRA, QLoRA, DoRA & The Adapter Family. For the full data-flywheel architecture, see Data Flywheels & Continuous Improvement.
What to Monitor and Alert On¶
Consolidate your alerting policy into four layers, from raw infrastructure to business impact:
| Layer | Metric / Signal | Alert Threshold (example) |
|---|---|---|
| Infrastructure | GPU utilization, memory, host errors | GPU util < 40% for 10 min (underloaded) or > 95% (saturated) |
| Serving | Request queue depth, TTFT P95, error rate | TTFT P95 > 3s for 5 min; error rate > 1% |
| Quality | Rolling eval composite score, safety flag rate | Score drop > 5 pp vs 7-day baseline; safety flags > 0.5% |
| Cost | Hourly USD spend, cost per DAU | Projected daily cost > 120% of budget |
For multi-turn agentic pipelines (see The Agentic Loop: ReAct, Plan-Execute & Reflection), add: - Tool call success rate: fraction of tool calls that return valid results - Turn count distribution: unusually high turn counts signal loops or confused planning - Context utilization: fraction of context window used (approaching the limit is a risk signal)
For RAG systems (see Retrieval-Augmented Generation Architectures), add: - Retrieval relevance score: low scores mean the retriever is not finding useful context - No-hit rate: fraction of queries where no document score exceeds a minimum threshold
Practitioner tip: use composite burn-rate alerts, not fixed thresholds
Fixed thresholds (alert if score < 0.7) generate alarm fatigue when traffic is noisy. Instead, compute a burn rate: if your error budget allows 1% of responses to be low-quality, alert when the 1-hour burn rate exceeds 2x the budget rate. This is the SRE error-budget model applied to LLM quality, and it dramatically reduces false positives while catching genuine degradations faster.
State of the Art & Resources (2026)
LLMOps observability has rapidly matured from ad-hoc logging into a principled discipline: OpenTelemetry GenAI semantic conventions — now developed in a dedicated semantic-conventions-genai repository and still marked “Development” status — provide a maturing vendor-neutral standard for LLM spans and metrics, while purpose-built platforms (Langfuse, Arize Phoenix, MLflow Tracing) offer production-grade trace storage, eval pipelines, and drift monitoring. The key challenge through 2026 remains scaling these practices to multi-agent systems with complex, nested execution graphs.
Foundational work
- Sculley et al., Hidden Technical Debt in Machine Learning Systems (NeurIPS 2015) — the canonical argument for why monitoring and observability are non-negotiable in any ML system.
- Shankar et al., Who Validates the Validators? (2024) — identifies “criteria drift” in LLM-as-judge pipelines and proposes a mixed-initiative approach to aligning automated evaluators with human preferences.
Recent advances (2023–2026)
- OpenTelemetry GenAI Semantic Conventions — Spans — the CNCF-backed standard for LLM span attributes (model, token counts, finish reason); the spec relocated from the main OpenTelemetry docs site into this dedicated repository and remains in “Development” status pending stabilization.
- OpenTelemetry GenAI Semantic Conventions — Metrics — companion spec covering client-side metrics including time-to-first-token and throughput, enabling consistent dashboards across providers; also now maintained in the repository above.
- Jain, An Introduction to Observability for LLM-based Applications using OpenTelemetry (2024) — official OpenTelemetry blog walkthrough for instrumenting LLM apps with OTel and exporting to Prometheus and Jaeger.
Open-source & tools
- langfuse/langfuse — open-source LLM engineering platform (YC W23) providing OTel-native tracing, prompt versioning, evals, and datasets; the most widely adopted self-hostable LLMOps stack.
- Arize-ai/phoenix — open-source AI observability platform with strong RAG evaluation, embedding drift visualization, and support for 40+ frameworks via OpenInference traces.
- openlit/openlit — OpenTelemetry-native observability SDK that instruments 50+ LLM providers plus NVIDIA GPU metrics with one line of code; good for self-hosted inference.
- wandb/weave — Weights & Biases toolkit for GenAI tracing and evaluation; integrates naturally with W&B experiment tracking for teams already in that ecosystem.
- MLflow Tracing for LLM and Agent Observability — MLflow 3.0 adds fully OTel-compatible tracing for 50+ GenAI libraries, linking traces to code, data, and prompts; 100% open source and self-hosted.
Go deeper
- Google SRE Workbook — Alerting on SLOs — the multi-window, multi-burn-rate alerting model that LLMOps quality alerting adapts; essential reading for setting alert thresholds that minimize alarm fatigue.
- Langfuse blog — OpenTelemetry for LLM Observability (2024) — practitioner analysis of OTel adoption in LLMOps, including the Langfuse OTel Collector and why the industry is converging on OTel as the standard transport layer.
Further Reading¶
- Sculley et al., “Hidden Technical Debt in Machine Learning Systems” (NIPS 2015) — the foundational paper on ML system complexity and why monitoring is non-negotiable.
- Shankar et al., “Who Validates the Validators? Aligning LLM-Assisted Evaluation of LLM Outputs with Human Preferences” (2024) — on the reliability and calibration of LLM-as-judge pipelines.
- Langfuse (github.com/langfuse/langfuse) — open-source LLM observability; read the architecture docs for a practical reference implementation.
- Arize Phoenix (github.com/Arize-ai/phoenix) — OTel-native tracing and embedding drift for LLMs and RAG.
- OpenInference (github.com/Arize-ai/openinference) and OpenLLMetry (github.com/traceloop/openllmetry) — the two main open-source auto-instrumentation suites for LLM/agent frameworks; both emit OTLP, so they work with any backend.
- Google SRE Book, Chapter 5: Eliminating Toil (sre.google/sre-book) — the error-budget and burn-rate alerting model that LLMOps adapts.
- Klaise et al., “Alibi Detect: Algorithms for Outlier, Adversarial and Drift Detection” (JMLR 2022) — statistical toolkit for production drift detection including CUSUM and PSI.
- OpenTelemetry GenAI Semantic Conventions (github.com/open-telemetry/semantic-conventions-genai) — the emerging standard for LLM span attributes, maintained by the OTel community; relocated in 2025–2026 from the main OpenTelemetry docs site into this dedicated repository and still marked “Development” status.
Key Takeaways
- LLM observability requires all three pillars: distributed traces (per-request causal chains), metrics (aggregated time-series for TTFT, tokens, cost), and logs (verbatim prompts and responses for qualitative debugging).
- Use OpenTelemetry as the vendor-neutral instrumentation layer, with the GenAI semantic-convention attribute names (
gen_ai.request.model,gen_ai.usage.input_tokens, …) rather than ad-hoc keys; export to any backend (Langfuse, Phoenix, Jaeger, Grafana Tempo, Datadog). Auto-instrumentors (OpenInference, OpenLLMetry,opentelemetry-instrumentation-*) cover the library calls; hand-write spans only for your own logic. - Track TTFT, TPOT, total latency, prompt/completion token counts, and estimated cost per request as your baseline metric set. Use Prometheus histograms, not averages. Self-hosted serving has no price list — derive USD/1M tokens from GPU hourly cost divided by measured throughput, and scrape vLLM’s or SGLang’s native
/metricsinstead of re-implementing it. - Prompt/response logs are uniquely valuable and uniquely sensitive: hash user IDs, classify by data sensitivity, enforce retention policies, and store in access-controlled object storage — never in plaintext RDBMS.
- Run eval-in-production with strategic sampling (100% of failures, 5–30% of new-template traffic, ~5% of baseline); use an async LLM-as-judge pipeline so eval does not add request latency.
- Detect three kinds of drift: input distribution shift (PSI on prompt embeddings), output quality drift (CUSUM on eval scores), and provider model drift (hourly canary probes).
- A/B test prompt and model changes with session-level hash routing for multi-turn consistency; pre-calculate required sample sizes before launch.
- The LLMOps lifecycle is a closed loop: Ship → Observe → Evaluate → Improve → Retrain. Production logs feed the data flywheel that makes the next version better.
- Prefer burn-rate alerts over fixed thresholds to reduce alarm fatigue while maintaining sensitivity to genuine degradation.
Exercises¶
1. (Conceptual — the three pillars.) A teammate proposes cutting costs by dropping verbatim prompt/response logs and keeping only metrics (token counts, latency, error rate) and traces (per-stage spans with semantic attributes). Give two concrete failure modes from this chapter that this setup would fail to catch, and explain why. Separately, the chapter says to put semantic attributes on spans but to keep raw prompt strings out of span attributes — give the two reasons it cites.
Solution
Two failure modes that metrics + traces alone miss:
- Silent hallucination / off-topic drift. The introduction stresses that a model can “answer every request with HTTP 200 while silently hallucinating.” Metrics would show a healthy
status="ok"counter, low error rate, and normal token counts; traces would show every span completing successfully. Nothing numeric reveals that the content is wrong. Only the verbatim response log — the pillar that answers “what exactly did the model say?” — surfaces it, typically via the async LLM-as-judge pipeline that readsassistant_reply. - Prompt-template regressions / prompt-injection. Because logs carry the
prompt_versionand the actualuser_message/assistant_reply, they are what lets you do regression analysis after a template change or spot an injection attack in the wild. A latency histogram or a span duration cannot show that a new template started leaking the system prompt.
In short, the chapter’s framing is that all three pillars are necessary and none is sufficient; the log pillar is the only one that captures behavior “expressed in natural language that no metric can fully capture.”
Why keep raw prompt strings off span attributes: (1) size limits — span attributes are meant for stable, low-cardinality values and large strings blow past backend limits; (2) PII risk — traces fan out to observability backends that may not be reviewed for sensitive data. The chapter’s guidance is to log prompts separately in an access-controlled sink and link them to the span by trace ID.
2. (Quantitative — cost math.) Your service averages 1,200 prompt tokens and 400 completion tokens per request on gpt-4o-mini (USD 0.15 per 1M prompt tokens, USD 0.60 per 1M completion tokens). (a) Compute the cost per request. (b) At a steady 5 requests/second, compute the projected daily cost. © A context-management change trims the average prompt to 700 tokens with completions unchanged. What is the new daily cost, and what percentage reduction is that?
Solution
(a) Cost per request, using \(c = \frac{p}{10^6}\times 0.15 + \frac{k}{10^6}\times 0.60\) with \(p=1200\), \(k=400\):
(b) Requests per day \(= 5 \times 3600 \times 24 = 432{,}000\). Daily cost:
© New per-request cost with \(p=700\):
New daily cost \(= 0.000345 \times 432{,}000 = \$149.04\). Reduction:
The prompt is the only thing that changed, and prompt tokens are the cheaper direction here, so a 42% cut in prompt tokens buys only about an 18% cost cut — exactly the kind of per-template insight the chapter says token distributions make visible.
3. (Quantitative — PSI drift.) You bin prompt-length into three buckets. The 7-day baseline proportions are \(Q = [0.40,\ 0.35,\ 0.25]\) and the current 24-hour window gives \(P = [0.25,\ 0.35,\ 0.40]\). Using the chapter’s definition \(\text{PSI} = \sum_i (P_i - Q_i)\ln\frac{P_i}{Q_i}\), compute the PSI and classify the shift using the chapter’s thresholds.
Solution
Term by term (natural logs):
- Bin 1: \((0.25 - 0.40)\ln\frac{0.25}{0.40} = (-0.15)\ln(0.625) = (-0.15)(-0.4700) = 0.07050\)
- Bin 2: \((0.35 - 0.35)\ln\frac{0.35}{0.35} = 0 \times \ln(1) = 0\)
- Bin 3: \((0.40 - 0.25)\ln\frac{0.40}{0.25} = (0.15)\ln(1.6) = (0.15)(0.4700) = 0.07050\)
Against the chapter’s convention (PSI < 0.1 stable, 0.1–0.25 moderate, > 0.25 significant), \(0.141\) falls in the moderate shift band. Note the symmetric mass swap from bin 1 to bin 3 produces two equal positive contributions — PSI is a sum of non-negative terms, so opposite-direction movements do not cancel.
4. (Quantitative — A/B sample size.) You want to detect a \(\delta = 0.04\) (4-percentage-point) improvement in the per-session composite quality score, which has standard deviation \(\sigma = 0.20\). Using the chapter’s formula at 80% power (\(z_\beta = 0.84\)) and \(\alpha = 0.05\) two-sided (\(z_{\alpha/2} = 1.96\)), compute the required sessions per variant. If you run 1,000 sessions/day total and put 10% of traffic in the treatment arm, how many days until you can decide?
Solution
Sample size per variant, using \(n \approx \dfrac{2\sigma^2 (z_{\alpha/2}+z_\beta)^2}{\delta^2}\):
The treatment arm receives 10% of 1,000 sessions/day \(= 100\) sessions/day. Time to reach 392:
(The control arm at 80% traffic reaches 392 far sooner, so the treatment arm is the binding constraint.) Smaller effect sizes scale as \(1/\delta^2\): halving the target effect to \(\delta = 0.02\) would quadruple \(n\) to about 1,568 per variant, roughly 16 days in the treatment arm — which is why the chapter insists on pre-calculating sample size before launch.
5. (Implementation — output-quality drift detector.) The chapter gives an upper CUSUM, \(S_n = \max(0,\ S_{n-1} + (x_n - \mu_0 - k))\), which accumulates upward deviations. Output-quality drift is a sustained drop, so implement a lower CUSUM detector class that fires when the composite score falls. It should take \(\mu_0\) (in-control mean), \(k\) (allowance), and \(h\) (decision threshold), accept one score at a time via update(x), and return True on the update that first crosses \(h\). Then trace it by hand on \(\mu_0 = 0.80\), \(k = 0.02\), \(h = 0.10\) with the stream \([0.80,\ 0.78,\ 0.75,\ 0.74,\ 0.73]\).
Solution
To detect a downward shift, flip the sign of the deviation: accumulate \((\mu_0 - k - x_n)\), which grows when \(x_n\) sits below \(\mu_0 - k\).
# quality_cusum.py -- lower CUSUM change-point detector for eval scores
class QualityCUSUM:
"""Detect a sustained DROP in the rolling composite quality score.
mu0 : in-control (baseline) mean score
k : allowance, typically half the smallest shift you want to detect
h : decision threshold (set via ARL analysis)
"""
def __init__(self, mu0: float, k: float, h: float):
self.mu0 = mu0
self.k = k
self.h = h
self.s = 0.0
self.alerted = False
def update(self, x: float) -> bool:
# Lower CUSUM: accumulate how far below (mu0 - k) each score falls.
self.s = max(0.0, self.s + (self.mu0 - self.k - x))
fired = self.s > self.h and not self.alerted
if fired:
self.alerted = True # latch so we alert once per excursion
return fired
def reset(self):
self.s = 0.0
self.alerted = False
Hand trace with \(\mu_0 = 0.80\), \(k = 0.02\), so each step adds \((0.78 - x_n)\), clamped at 0:
| \(n\) | \(x_n\) | \(0.78 - x_n\) | \(S_n = \max(0, S_{n-1} + \cdot)\) | \(S_n > h\)? |
|---|---|---|---|---|
| 1 | 0.80 | \(-0.02\) | \(\max(0,\ 0 - 0.02) = 0\) | no |
| 2 | 0.78 | \(0.00\) | \(\max(0,\ 0 + 0.00) = 0\) | no |
| 3 | 0.75 | \(+0.03\) | \(\max(0,\ 0 + 0.03) = 0.03\) | no |
| 4 | 0.74 | \(+0.04\) | \(\max(0,\ 0.03 + 0.04) = 0.07\) | no |
| 5 | 0.73 | \(+0.05\) | \(\max(0,\ 0.07 + 0.05) = 0.12\) | yes |
The detector fires on the 5th score, when \(S_5 = 0.12 > h = 0.10\). A single low reading (e.g. one noisy \(0.75\)) never crosses \(h\) on its own; the alert requires a sustained run below \(\mu_0 - k\), which is exactly the noise-vs-signal separation the chapter wants from a change-point algorithm rather than a fixed threshold.
6. (Implementation — burn-rate quality alert.) Implement the practitioner tip’s burn-rate alert to replace a fixed “score < 0.7” rule. Given an error budget that allows a fraction budget (e.g. 0.01 = 1%) of responses to be low-quality, and the counts from a rolling window, compute the burn rate and alert when it exceeds a multiplier (e.g. 2x). Then apply it to a 1-hour window with 5,000 responses of which 130 were flagged low-quality, at budget = 0.01, multiplier = 2.0.
Solution
The burn rate is the observed bad fraction divided by the budgeted bad fraction; a burn rate of 1 means you are consuming budget exactly as fast as allowed, and >1 means faster.
# burn_rate_alert.py -- SRE-style quality burn-rate alert
def quality_burn_rate(low_quality: int, total: int, budget: float) -> float:
"""Return the burn rate: observed bad fraction / budgeted bad fraction.
low_quality : count of low-quality responses in the window
total : total responses in the window
budget : allowed fraction of low-quality responses (e.g. 0.01)
"""
if total == 0:
return 0.0
observed_fraction = low_quality / total
return observed_fraction / budget
def should_alert(low_quality: int, total: int,
budget: float = 0.01, multiplier: float = 2.0) -> bool:
"""Fire when the window is burning budget faster than `multiplier`x."""
return quality_burn_rate(low_quality, total, budget) > multiplier
Applying it to the window:
Since \(2.6 > 2.0\), should_alert(...) returns True — the service is burning its quality budget at 2.6x the sustainable rate. The advantage over a fixed threshold (which the chapter warns causes alarm fatigue): the same absolute count of bad responses raises no alarm when traffic is high and the fraction stays under budget, but triggers quickly when the rate of budget consumption spikes, catching genuine degradations faster while suppressing noise-driven false positives.