Monitoring › Implementation
Language AI HandbookAll chapters Monitoring › ImplementationOne section · Sections in this chapter7 / 15 ↓
Reading tools Definitions & inline links
Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.
Article links
Make inline references clickable
Let's build a practical monitoring system for an LLM API service. We will instrument the application with metrics and logging, generate realistic simulated traffic, and demonstrate the kinds of visualizations you would use in a real deployment.
First, install the required packages:
# uv pip install prometheus-client opentelemetry-api opentelemetry-sdk opentelemetry-exporter-otlp
# uv pip install opentelemetry-instrumentation-fastapi fastapi uvicorn
# uv pip install numpy matplotlibCore Metrics Instrumentation
We will define a metrics registry using the prometheus_client library. This library exposes metrics in the Prometheus text format, which can be scraped by a Prometheus server or pushed to any compatible backend. For this demonstration, we implement a simplified version of the Prometheus data types so the code runs without a running Prometheus instance.
import numpy as np
# Simulate the prometheus_client API for demonstration
class Counter:
def __init__(self, name, description, labelnames=()):
self.name = name
self._value = 0
self._labels = {}
def labels(self, **kwargs):
key = tuple(sorted(kwargs.items()))
if key not in self._labels:
self._labels[key] = {"value": 0}
return _LabeledCounter(self._labels[key])
def inc(self, amount=1):
self._value += amount
class _LabeledCounter:
def __init__(self, store):
self._store = store
def inc(self, amount=1):
self._store["value"] += amount
class Histogram:
def __init__(self, name, description, labelnames=(), buckets=None):
self.name = name
self._observations = []
def labels(self, **kwargs):
return self
def observe(self, value):
self._observations.append(value)
@property
def mean(self):
return np.mean(self._observations) if self._observations else 0
@property
def p50(self):
return (
np.percentile(self._observations, 50) if self._observations else 0
)
@property
def p95(self):
return (
np.percentile(self._observations, 95) if self._observations else 0
)
@property
def p99(self):
return (
np.percentile(self._observations, 99) if self._observations else 0
)
class Gauge:
def __init__(self, name, description, labelnames=()):
self.name = name
self._value = 0
def set(self, value):
self._value = value
def inc(self, amount=1):
self._value += amount
def dec(self, amount=1):
self._value -= amount
# Define the metrics registry
REQUEST_COUNTER = Counter(
"llm_requests_total",
"Total number of LLM requests",
labelnames=["model", "status"],
)
REQUEST_LATENCY = Histogram(
"llm_request_duration_seconds",
"LLM request latency in seconds",
labelnames=["model"],
buckets=[0.1, 0.5, 1.0, 2.0, 5.0, 10.0, 30.0, 60.0],
)
TTFT_HISTOGRAM = Histogram(
"llm_time_to_first_token_seconds",
"Time to first token in seconds",
labelnames=["model"],
buckets=[0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0],
)
TOKEN_COUNTER = Counter(
"llm_tokens_total", "Total tokens processed", labelnames=["model", "type"]
)
QUALITY_SCORE = Histogram(
"llm_response_quality_score",
"Automated quality score for LLM responses (0-1)",
labelnames=["model"],
buckets=[0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0],
)
GPU_MEMORY_GAUGE = Gauge("llm_gpu_memory_bytes", "GPU memory currently in use")Each metric type serves a different purpose. Counters accumulate monotonically and are typically used for totals that only go up (requests, tokens, errors). Histograms record the distribution of a value across configurable buckets, enabling percentile calculations at query time. Gauges represent a point-in-time value that can go up or down, like current GPU memory usage or active connection count.
Simulating Request Traffic
With our metrics defined, we simulate a realistic request workload. The simulation is designed to include a mix of normal traffic and degraded traffic, so we can test whether our alerting correctly detects the quality drop.
import datetime
import random
from dataclasses import dataclass
@dataclass
class RequestMetadata:
request_id: str
model: str
input_tokens: int
output_tokens: int
latency_ms: float
ttft_ms: float
quality_score: float
status: str
timestamp: str
def simulate_request(
model: str, quality_degraded: bool = False
) -> RequestMetadata:
"""Simulate a single LLM API request with realistic characteristics."""
request_id = f"req_{random.randint(100000, 999999)}"
# Input token distribution: most requests are short, some are long
input_tokens = int(np.random.lognormal(mean=5.5, sigma=1.0))
input_tokens = min(input_tokens, 8192)
# Output tokens proportional to input with some noise
output_tokens = int(input_tokens * random.uniform(0.3, 2.0))
output_tokens = min(output_tokens, 4096)
# Latency scales with total token count
total_tokens = input_tokens + output_tokens
base_latency = 0.5 + (total_tokens / 1000) * 2.0
latency_ms = base_latency * 1000 * np.random.lognormal(0, 0.3)
# Time to first token is faster (prefill phase)
ttft_ms = latency_ms * random.uniform(0.1, 0.3)
# Quality score: high normally, degraded if drift is simulated
if quality_degraded:
quality_score = random.betavariate(2, 5) # Right-skewed toward 0
else:
quality_score = random.betavariate(8, 2) # Right-skewed toward 1
# 2% error rate
status = "error" if random.random() < 0.02 else "success"
timestamp = datetime.datetime.utcnow().isoformat() + "Z"
return RequestMetadata(
request_id=request_id,
model=model,
input_tokens=input_tokens,
output_tokens=output_tokens,
latency_ms=latency_ms,
ttft_ms=ttft_ms,
quality_score=quality_score,
status=status,
timestamp=timestamp,
)
def record_request_metrics(req: RequestMetadata):
"""Push request metrics to the Prometheus registry."""
REQUEST_COUNTER.labels(model=req.model, status=req.status).inc()
REQUEST_LATENCY.labels(model=req.model).observe(req.latency_ms / 1000)
TTFT_HISTOGRAM.labels(model=req.model).observe(req.ttft_ms / 1000)
TOKEN_COUNTER.labels(model=req.model, type="input").inc(req.input_tokens)
TOKEN_COUNTER.labels(model=req.model, type="output").inc(req.output_tokens)
if req.status == "success":
QUALITY_SCORE.labels(model=req.model).observe(req.quality_score)
# Generate a week of simulated traffic
np.random.seed(42)
n_requests = 5000
requests_normal = [
simulate_request("gpt-4o", quality_degraded=False)
for _ in range(int(n_requests * 0.7))
]
requests_degraded = [
simulate_request("gpt-4o", quality_degraded=True)
for _ in range(int(n_requests * 0.3))
]
# Preserve arrival order for the drift dashboard, then shuffle a copy for
# aggregate distributions where temporal order is irrelevant.
timeline_requests = requests_normal + requests_degraded
all_requests = timeline_requests.copy()
random.shuffle(all_requests)
# Record all requests to metrics
for req in all_requests:
record_request_metrics(req)
# Simulate GPU memory varying over time
gpu_samples = np.random.normal(18.5, 2.0, 1000) # GB
gpu_samples = np.clip(gpu_samples, 0, 24)The simulation uses log-normal distributions for token counts and latency because these match the empirical distributions of real production traffic well. Real users mostly send short requests, but a fat tail of long-context requests exists and drives a disproportionate share of total compute. Quality scores are sampled from a beta distribution, which is bounded between 0 and 1, making it a natural fit for scores interpreted as probabilities or fractions.
Total requests simulated: 5,000 Successful: 4,905 (98.1%) Errors: 95 (1.9%) Average input tokens: 403 Average output tokens: 450 Quality scores: Normal traffic: 0.798 Degraded traffic: 0.287 Latency percentiles: p50: 1550 ms p95: 6426 ms p99: 12247 ms
The output shows exactly the kind of data a monitoring system would surface. The error rate is within normal range, but the gap between normal and degraded quality scores (roughly 0.8 versus 0.3) is the signal we want our alerting to catch. Notice that the infrastructure metrics (error rate, latency) look completely healthy even while 30% of the traffic is producing poor quality outputs, illustrating why semantic monitoring is essential.
Building Dashboards
Now let's visualize the key metrics as they would appear in a Grafana-style dashboard. The first visualization shows the request latency distribution, which is one of the most informative single charts for understanding system performance.

The right-skewed shape is typical of LLM inference. Most requests complete quickly because they have short inputs and short outputs. The long tail comes from requests with thousands of input tokens or that trigger multi-step chain-of-thought generation. When you plot only the mean, these outliers inflate it without revealing the cause of the higher value. Percentile monitoring makes the shape of the distribution visible.

The log scale reveals the true spread. On a linear scale, the long tail compresses the majority of the distribution into a thin strip near zero, hiding its structure. On a log scale, you can see that the peak of the input distribution is around a few hundred tokens, but requests extend out to 8,000 tokens. This directly translates to cost variance: a request at the 99th percentile of the input distribution might cost 20-30 times more than one at the median.

This chart captures what concept drift looks like in practice. The individual scores are noisy, but the rolling mean reveals the trend clearly. An alert tied to the rolling mean crossing the threshold is sensitive enough to catch sustained degradation while ignoring isolated bad responses.

GPU memory is one of the most important infrastructure metrics for LLM services. Unlike CPU or RAM, GPU memory is a hard limit: when it is exhausted, the inference process crashes rather than slowing down. Setting the alert threshold well below the physical limit (90% rather than 100%) gives operators a warning window to investigate before the system becomes unstable. In practice, this matters especially at traffic peaks, where sudden bursts of long-context requests can push memory usage from comfortable to critical in seconds.
Alerting Rules
Metrics and dashboards help operators understand normal operation and investigate known problems. Alerts are the proactive layer: they tell you when something breaks before users file support tickets.
Effective alerting has two failure modes. Too few alerts means real problems go undetected. Too many alerts means operators get desensitized and start ignoring them, which is equivalent to no alerts at all. The goal is high-signal, low-noise alerts tied directly to user-visible impact.
A practical alert rule specification follows the format used by Prometheus and most modern alerting systems:
# Alert rule definitions (YAML-style config representation)
ALERT_RULES = [
{
"name": "HighErrorRate",
"expr": "rate(llm_requests_total{status='error'}[5m]) / rate(llm_requests_total[5m]) > 0.05",
"for": "2m",
"severity": "critical",
"summary": "Error rate exceeded 5% over the last 5 minutes",
"runbook": "Check model backend health, review recent deployments",
},
{
"name": "HighP99Latency",
"expr": "histogram_quantile(0.99, rate(llm_request_duration_seconds_bucket[5m])) > 30",
"for": "5m",
"severity": "warning",
"summary": "p99 latency exceeded 30 seconds",
"runbook": "Check for requests with very long contexts, review GPU utilization",
},
{
"name": "LowQualityScore",
"expr": "histogram_quantile(0.5, rate(llm_response_quality_score_bucket[30m])) < 0.6",
"for": "10m",
"severity": "warning",
"summary": "Median quality score dropped below 0.6 over the last 30 minutes",
"runbook": "Review recent model outputs, check for prompt injection or adversarial inputs",
},
{
"name": "HighGPUMemoryPressure",
"expr": "llm_gpu_memory_bytes / (24 * 1e9) > 0.90",
"for": "3m",
"severity": "warning",
"summary": "GPU memory utilization exceeded 90%",
"runbook": "Review context length distribution, consider enabling memory-efficient attention",
},
{
"name": "ModelBackendDown",
"expr": "up{job='llm-inference'} == 0",
"for": "1m",
"severity": "critical",
"summary": "LLM inference backend is unreachable",
"runbook": "Check inference server process, review system logs",
},
]
for rule in ALERT_RULES:
print(f"Alert: {rule['name']}")
print(f" Severity: {rule['severity']}")
print(f" Condition: {rule['expr'][:60]}...")
print(f" Summary: {rule['summary']}")
print()Alert: HighErrorRate
Severity: critical
Condition: rate(llm_requests_total{status='error'}[5m]) / rate(llm_requ...
Summary: Error rate exceeded 5% over the last 5 minutes
Alert: HighP99Latency
Severity: warning
Condition: histogram_quantile(0.99, rate(llm_request_duration_seconds_b...
Summary: p99 latency exceeded 30 seconds
Alert: LowQualityScore
Severity: warning
Condition: histogram_quantile(0.5, rate(llm_response_quality_score_buck...
Summary: Median quality score dropped below 0.6 over the last 30 minutes
Alert: HighGPUMemoryPressure
Severity: warning
Condition: llm_gpu_memory_bytes / (24 * 1e9) > 0.90...
Summary: GPU memory utilization exceeded 90%
Alert: ModelBackendDown
Severity: critical
Condition: up{job='llm-inference'} == 0...
Summary: LLM inference backend is unreachableEach alert rule includes a for clause that requires the condition to be true for a sustained period before firing. This prevents transient spikes from generating noise. The runbook field links each alert to documented response procedures, transforming an alarm from a puzzle into an action item. An alert that fires at 3 AM is far more useful if it comes with a clear description of what to check and what to do, rather than just a metric value and a name.
The for duration deserves careful tuning. For a hard outage (model backend unreachable), even a one-minute window is too slow: users are already experiencing errors. For a quality degradation signal, a ten-minute window is appropriate because quality scores fluctuate and a sustained drop is much more meaningful than a momentary dip. Match the for duration to the expected dynamics of the underlying condition.
Simulating Alert Detection
Let's verify that our quality metric alert would correctly fire on the degraded traffic scenario we simulated:
def evaluate_quality_alert(requests_window, threshold=0.6):
"""Evaluate whether the quality alert condition is met."""
successful = [r for r in requests_window if r.status == "success"]
if not successful:
return False, None
scores = [r.quality_score for r in successful]
median_score = float(np.median(scores))
return median_score < threshold, median_score
# Evaluate alert on normal traffic window
alert_fired_normal, score_normal = evaluate_quality_alert(requests_normal[:500])
# Evaluate alert on degraded traffic window
alert_fired_degraded, score_degraded = evaluate_quality_alert(
requests_degraded[:500]
)Quality Alert Evaluation: Normal traffic window: Median quality score: 0.815 Alert fired: False Degraded traffic window: Median quality score: 0.263 Alert fired: True Status: ALERT FIRING
The alert correctly remains silent during normal operation and fires during the degraded period. This demonstrates that the threshold is calibrated to capture quality issues without generating false positives on healthy traffic. In a real system, you would tune this threshold by analyzing historical quality score distributions and picking a value below the natural variance of normal operation but above the range seen during known degradation incidents.
Structured Log Emission
The final piece is logging. Here we implement a simple structured logger that would integrate with a log aggregation system like Loki or Elasticsearch:
class LLMRequestLogger:
"""Emits structured JSON logs for LLM requests."""
def __init__(self, service_name: str, sample_rate: float = 0.05):
self.service_name = service_name
self.sample_rate = sample_rate
self._log_buffer = []
def log_request(self, req: RequestMetadata, include_text: bool = False):
"""Log a completed request."""
# Always log metadata; only log text for sampled fraction
should_log_text = random.random() < self.sample_rate
entry = {
"service": self.service_name,
"request_id": req.request_id,
"timestamp": req.timestamp,
"model": req.model,
"status": req.status,
"latency_ms": round(req.latency_ms, 2),
"ttft_ms": round(req.ttft_ms, 2),
"input_tokens": req.input_tokens,
"output_tokens": req.output_tokens,
"quality_score": round(req.quality_score, 4),
"text_sampled": should_log_text,
}
self._log_buffer.append(entry)
return entry
logger = LLMRequestLogger(service_name="language-api", sample_rate=0.05)
# Log a sample of requests
for req in all_requests[:20]:
logger.log_request(req)
# Show a few representative log entries
text_sampled_count = sum(1 for e in logger._log_buffer if e["text_sampled"])Sample log entries:
------------------------------------------------------------
{
"service": "language-api",
"request_id": "req_897003",
"timestamp": "2026-08-15T02:44:39.339861Z",
"model": "gpt-4o",
"status": "success",
"latency_ms": 1014.35,
"ttft_ms": 274.43,
"input_tokens": 35,
"output_tokens": 39,
"quality_score": 0.6873,
"text_sampled": false
}
{
"service": "language-api",
"request_id": "req_376185",
"timestamp": "2026-08-15T02:44:39.325797Z",
"model": "gpt-4o",
"status": "success",
"latency_ms": 9846.49,
"ttft_ms": 2896.58,
"input_tokens": 1143,
"output_tokens": 1304,
"quality_score": 0.9424,
"text_sampled": false
}
{
"service": "language-api",
"request_id": "req_488069",
"timestamp": "2026-08-15T02:44:39.340554Z",
"model": "gpt-4o",
"status": "success",
"latency_ms": 1703.85,
"ttft_ms": 216.62,
"input_tokens": 278,
"output_tokens": 176,
"quality_score": 0.2353,
"text_sampled": false
}
Log buffer: 20 entries
Text-sampled entries (5% rate): 1Each log entry is a self-contained JSON object with all fields needed for debugging, cost analysis, and quality auditing. The text_sampled flag indicates whether the full prompt and response text were captured, enabling targeted retrieval of sampled records during investigations. Notice that even without the full text, a single log entry tells you a great deal: which model handled the request, how long it took, how many tokens it consumed, and what the automated quality score was.