Monitoring › Implementation

Michael BrenndoerferFebruary 13, 202615 min read
Language AI HandbookAll chapters
Part 65 of 67Production Systems
Chapter 497Monitoring › Implementation
Reading section 7 of 15 · Implementation · 15 minSection 7 of 15 · 15 min

Book contents

Section 7 of 15 · 15 min read

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:

In[3]:
Code
# uv pip install prometheus-client opentelemetry-api opentelemetry-sdk opentelemetry-exporter-otlp
# uv pip install opentelemetry-instrumentation-fastapi fastapi uvicorn
# uv pip install numpy matplotlib

Core 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.

In[4]:
Code
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.

In[5]:
Code
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.

Out[6]:
Console
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.

Out[7]:
Visualization
Histogram of simulated LLM request latency from zero to fifteen thousand milliseconds, with dashed p50, p95, and p99 markers.
Simulated request latency is strongly right-skewed: the median is about 1.6 seconds, while the 95th and 99th percentiles extend much farther into the tail. Percentile markers expose slow requests that a mean alone would hide.

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.

Out[8]:
Visualization
Overlapping histograms of simulated input and output token counts on a logarithmic axis from ten to ten thousand tokens.
Simulated input and output token counts overlap on a logarithmic axis, revealing both the dense body of ordinary requests and the long tail of expensive requests. The tail matters directly for per-request compute cost and memory demand.

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.

Out[9]:
Visualization
Scatter plot of simulated quality scores over time. A shaded region marks degraded traffic after about request 3,430, where the rolling mean drops from about 0.8 toward 0.3 and crosses a 0.6 threshold.
A simulated traffic shift begins after 70 percent of requests. The 100-request rolling mean falls from roughly 0.8 to below the 0.6 alert threshold, making sustained quality degradation visible despite noisy individual scores.

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.

Out[10]:
Visualization
Line chart of GPU memory usage in GB over time with horizontal alert threshold and capacity lines.
Simulated GPU memory usage over 1,000 time samples, centered around 18.5 GB of a 24 GB device capacity. The 22 GB alert threshold provides a roughly 2 GB safety margin before out-of-memory errors begin, giving operators time to investigate before the situation becomes critical.

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:

In[11]:
Code
# 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()
Out[12]:
Console
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 unreachable

Each 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:

In[13]:
Code
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]
)
Out[14]:
Console
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:

In[15]:
Code
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"])
Out[16]:
Console
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): 1

Each 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.