Skip to content

Monitoring & Observability

Learning Objectives

  • Identify the key metrics to track for LLM applications (latency, throughput, quality, cost)
  • Implement comprehensive logging for debugging and audit trails
  • Set up distributed tracing for end-to-end request visibility
  • Configure alerting rules for proactive incident detection
  • Design dashboards that provide actionable insights for LLM operations

Introduction

LLM observability goes beyond traditional application monitoring. You need to track not just system health, but also model quality, token economics, and user satisfaction. A robust monitoring strategy enables rapid debugging, capacity planning, and continuous optimization.

The Observability Challenge

LLMs have unique monitoring challenges: non-deterministic outputs, variable latency based on input/output length, and quality that's hard to measure automatically. Your observability stack must account for these factors.

Monitoring Architecture

Key Metrics to Track

Metrics Implementation

python
# metrics.py - Comprehensive LLM metrics with Prometheus
from prometheus_client import (
    Counter, Histogram, Gauge, Summary,
    CollectorRegistry, generate_latest,
)
from functools import wraps
import time
from typing import Optional

# Create registry
registry = CollectorRegistry()

# Performance metrics
REQUEST_LATENCY = Histogram(
    "llm_request_latency_seconds",
    "End-to-end request latency",
    ["model", "endpoint"],
    buckets=[0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0],
    registry=registry,
)

TTFT_LATENCY = Histogram(
    "llm_ttft_seconds",
    "Time to first token",
    ["model"],
    buckets=[0.05, 0.1, 0.25, 0.5, 1.0, 2.0, 5.0],
    registry=registry,
)

TOKENS_PER_SECOND = Histogram(
    "llm_tokens_per_second",
    "Token generation rate",
    ["model"],
    buckets=[10, 25, 50, 100, 200, 500],
    registry=registry,
)

# Token metrics
INPUT_TOKENS = Counter(
    "llm_input_tokens_total",
    "Total input tokens processed",
    ["model", "endpoint"],
    registry=registry,
)

OUTPUT_TOKENS = Counter(
    "llm_output_tokens_total",
    "Total output tokens generated",
    ["model", "endpoint"],
    registry=registry,
)

# Cost metrics
REQUEST_COST = Histogram(
    "llm_request_cost_dollars",
    "Cost per request in dollars",
    ["model"],
    buckets=[0.001, 0.01, 0.05, 0.1, 0.5, 1.0, 5.0],
    registry=registry,
)

DAILY_COST = Gauge(
    "llm_daily_cost_dollars",
    "Running daily cost",
    ["model"],
    registry=registry,
)

# Resource metrics
GPU_UTILIZATION = Gauge(
    "llm_gpu_utilization_percent",
    "GPU utilization percentage",
    ["gpu_id"],
    registry=registry,
)

GPU_MEMORY_USED = Gauge(
    "llm_gpu_memory_used_bytes",
    "GPU memory used",
    ["gpu_id"],
    registry=registry,
)

GPU_MEMORY_TOTAL = Gauge(
    "llm_gpu_memory_total_bytes",
    "GPU memory total",
    ["gpu_id"],
    registry=registry,
)

# Cache metrics
CACHE_HITS = Counter(
    "llm_cache_hits_total",
    "Total cache hits",
    ["cache_type"],  # exact, semantic, kv
    registry=registry,
)

CACHE_MISSES = Counter(
    "llm_cache_misses_total",
    "Total cache misses",
    registry=registry,
)

# Error metrics
REQUEST_ERRORS = Counter(
    "llm_request_errors_total",
    "Total request errors",
    ["model", "error_type"],
    registry=registry,
)

# Queue metrics
QUEUE_SIZE = Gauge(
    "llm_queue_size",
    "Current queue size",
    ["priority"],
    registry=registry,
)

QUEUE_WAIT_TIME = Histogram(
    "llm_queue_wait_seconds",
    "Time spent waiting in queue",
    buckets=[0.01, 0.05, 0.1, 0.5, 1.0, 5.0],
    registry=registry,
)

class MetricsCollector:
    """Collect and report LLM metrics."""

    def __init__(self, model_name: str):
        self.model_name = model_name
        self.daily_costs = {}

    def record_request(
        self,
        endpoint: str,
        latency_seconds: float,
        ttft_seconds: float,
        input_tokens: int,
        output_tokens: int,
        tokens_per_second: float,
        cost_dollars: float,
        cache_hit: Optional[str] = None,
        error: Optional[str] = None,
    ):
        """Record metrics for a single request."""
        # Performance
        REQUEST_LATENCY.labels(
            model=self.model_name,
            endpoint=endpoint,
        ).observe(latency_seconds)

        TTFT_LATENCY.labels(model=self.model_name).observe(ttft_seconds)
        TOKENS_PER_SECOND.labels(model=self.model_name).observe(tokens_per_second)

        # Tokens
        INPUT_TOKENS.labels(
            model=self.model_name,
            endpoint=endpoint,
        ).inc(input_tokens)

        OUTPUT_TOKENS.labels(
            model=self.model_name,
            endpoint=endpoint,
        ).inc(output_tokens)

        # Cost
        REQUEST_COST.labels(model=self.model_name).observe(cost_dollars)
        DAILY_COST.labels(model=self.model_name).inc(cost_dollars)

        # Cache
        if cache_hit:
            CACHE_HITS.labels(cache_type=cache_hit).inc()
        else:
            CACHE_MISSES.inc()

        # Errors
        if error:
            REQUEST_ERRORS.labels(
                model=self.model_name,
                error_type=error,
            ).inc()

    def update_gpu_metrics(self):
        """Update GPU utilization metrics."""
        try:
            import pynvml
            pynvml.nvmlInit()

            device_count = pynvml.nvmlDeviceGetCount()
            for i in range(device_count):
                handle = pynvml.nvmlDeviceGetHandleByIndex(i)

                # Utilization
                util = pynvml.nvmlDeviceGetUtilizationRates(handle)
                GPU_UTILIZATION.labels(gpu_id=str(i)).set(util.gpu)

                # Memory
                mem_info = pynvml.nvmlDeviceGetMemoryInfo(handle)
                GPU_MEMORY_USED.labels(gpu_id=str(i)).set(mem_info.used)
                GPU_MEMORY_TOTAL.labels(gpu_id=str(i)).set(mem_info.total)

            pynvml.nvmlShutdown()
        except Exception as e:
            print(f"Failed to collect GPU metrics: {e}")

def metrics_middleware(metrics: MetricsCollector):
    """FastAPI middleware for automatic metrics collection."""
    def decorator(func):
        @wraps(func)
        async def wrapper(*args, **kwargs):
            start_time = time.time()
            first_token_time = None
            error = None

            try:
                result = await func(*args, **kwargs)
                return result
            except Exception as e:
                error = type(e).__name__
                raise
            finally:
                latency = time.time() - start_time
                # Record metrics (simplified - actual implementation would
                # need access to token counts from the response)

        return wrapper
    return decorator

Structured Logging

python
# logging_config.py - Structured logging for LLM applications
import structlog
import logging
import json
from datetime import datetime
from typing import Any, Dict, Optional
import uuid

def configure_logging(
    level: str = "INFO",
    json_format: bool = True,
    log_file: Optional[str] = None,
):
    """Configure structured logging."""
    # Configure structlog
    processors = [
        structlog.stdlib.filter_by_level,
        structlog.stdlib.add_logger_name,
        structlog.stdlib.add_log_level,
        structlog.stdlib.PositionalArgumentsFormatter(),
        structlog.processors.TimeStamper(fmt="iso"),
        structlog.processors.StackInfoRenderer(),
        structlog.processors.UnicodeDecoder(),
    ]

    if json_format:
        processors.append(structlog.processors.JSONRenderer())
    else:
        processors.append(structlog.dev.ConsoleRenderer())

    structlog.configure(
        processors=processors,
        wrapper_class=structlog.stdlib.BoundLogger,
        context_class=dict,
        logger_factory=structlog.stdlib.LoggerFactory(),
        cache_logger_on_first_use=True,
    )

    # Configure stdlib logging
    logging.basicConfig(
        format="%(message)s",
        level=getattr(logging, level.upper()),
    )

    if log_file:
        file_handler = logging.FileHandler(log_file)
        file_handler.setFormatter(logging.Formatter("%(message)s"))
        logging.getLogger().addHandler(file_handler)

class LLMLogger:
    """Specialized logger for LLM applications."""

    def __init__(self, service_name: str):
        self.logger = structlog.get_logger(service_name)
        self.service_name = service_name

    def log_request(
        self,
        request_id: str,
        prompt: str,
        model: str,
        user_id: Optional[str] = None,
        metadata: Optional[Dict] = None,
    ):
        """Log incoming request."""
        self.logger.info(
            "llm_request_received",
            request_id=request_id,
            model=model,
            user_id=user_id,
            prompt_length=len(prompt),
            prompt_preview=prompt[:100] + "..." if len(prompt) > 100 else prompt,
            **(metadata or {}),
        )

    def log_response(
        self,
        request_id: str,
        response: str,
        input_tokens: int,
        output_tokens: int,
        latency_ms: float,
        ttft_ms: float,
        cost: float,
        cache_hit: bool = False,
        cache_type: Optional[str] = None,
    ):
        """Log response with metrics."""
        self.logger.info(
            "llm_response_generated",
            request_id=request_id,
            response_length=len(response),
            response_preview=response[:100] + "..." if len(response) > 100 else response,
            input_tokens=input_tokens,
            output_tokens=output_tokens,
            total_tokens=input_tokens + output_tokens,
            latency_ms=round(latency_ms, 2),
            ttft_ms=round(ttft_ms, 2),
            tokens_per_second=round(output_tokens / (latency_ms / 1000), 2) if latency_ms > 0 else 0,
            cost_usd=round(cost, 6),
            cache_hit=cache_hit,
            cache_type=cache_type,
        )

    def log_error(
        self,
        request_id: str,
        error_type: str,
        error_message: str,
        stack_trace: Optional[str] = None,
    ):
        """Log error with context."""
        self.logger.error(
            "llm_request_error",
            request_id=request_id,
            error_type=error_type,
            error_message=error_message,
            stack_trace=stack_trace,
        )

    def log_quality_feedback(
        self,
        request_id: str,
        user_id: str,
        feedback_type: str,  # thumbs_up, thumbs_down, correction
        feedback_value: Any,
    ):
        """Log user feedback for quality monitoring."""
        self.logger.info(
            "llm_quality_feedback",
            request_id=request_id,
            user_id=user_id,
            feedback_type=feedback_type,
            feedback_value=feedback_value,
        )

    def log_cost_alert(
        self,
        alert_type: str,
        threshold: float,
        current_value: float,
        model: str,
    ):
        """Log cost alert."""
        self.logger.warning(
            "llm_cost_alert",
            alert_type=alert_type,
            threshold=threshold,
            current_value=current_value,
            model=model,
            overage_percent=round((current_value / threshold - 1) * 100, 1),
        )

# Example log output (JSON format):
"""
{
    "event": "llm_response_generated",
    "request_id": "req_abc123",
    "response_length": 1523,
    "response_preview": "To implement a binary search tree in Python...",
    "input_tokens": 245,
    "output_tokens": 512,
    "total_tokens": 757,
    "latency_ms": 2340.5,
    "ttft_ms": 187.3,
    "tokens_per_second": 218.7,
    "cost_usd": 0.00757,
    "cache_hit": false,
    "timestamp": "2024-01-15T10:23:45.123Z",
    "level": "info",
    "logger": "llm-service"
}
"""

Distributed Tracing

python
# tracing.py - OpenTelemetry tracing for LLM applications
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
from opentelemetry.sdk.resources import Resource
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.trace import Status, StatusCode
from contextlib import contextmanager
from typing import Optional, Dict, Any
import time

def configure_tracing(
    service_name: str,
    otlp_endpoint: str = "localhost:4317",
):
    """Configure OpenTelemetry tracing."""
    resource = Resource.create({
        "service.name": service_name,
        "service.version": "1.0.0",
    })

    provider = TracerProvider(resource=resource)
    processor = BatchSpanProcessor(
        OTLPSpanExporter(endpoint=otlp_endpoint)
    )
    provider.add_span_processor(processor)
    trace.set_tracer_provider(provider)

    return trace.get_tracer(service_name)

class LLMTracer:
    """Tracer for LLM request lifecycle."""

    def __init__(self, tracer: trace.Tracer):
        self.tracer = tracer

    @contextmanager
    def trace_request(
        self,
        request_id: str,
        model: str,
        operation: str = "llm_inference",
    ):
        """Trace an LLM request with all phases."""
        with self.tracer.start_as_current_span(operation) as span:
            span.set_attribute("request_id", request_id)
            span.set_attribute("model", model)

            try:
                yield span
            except Exception as e:
                span.set_status(Status(StatusCode.ERROR, str(e)))
                span.record_exception(e)
                raise

    @contextmanager
    def trace_cache_lookup(self, cache_type: str):
        """Trace cache lookup operation."""
        with self.tracer.start_as_current_span("cache_lookup") as span:
            span.set_attribute("cache_type", cache_type)
            start_time = time.time()

            try:
                yield span
            finally:
                span.set_attribute(
                    "duration_ms",
                    (time.time() - start_time) * 1000,
                )

    @contextmanager
    def trace_prefill(self, input_tokens: int):
        """Trace prefill phase."""
        with self.tracer.start_as_current_span("prefill") as span:
            span.set_attribute("input_tokens", input_tokens)
            start_time = time.time()

            try:
                yield span
            finally:
                duration = (time.time() - start_time) * 1000
                span.set_attribute("duration_ms", duration)
                span.set_attribute(
                    "tokens_per_ms",
                    input_tokens / duration if duration > 0 else 0,
                )

    @contextmanager
    def trace_decode(self):
        """Trace decode phase."""
        with self.tracer.start_as_current_span("decode") as span:
            start_time = time.time()
            output_tokens = 0

            class TokenCounter:
                def __init__(self):
                    self.count = 0

                def increment(self, n: int = 1):
                    self.count += n

            counter = TokenCounter()

            try:
                yield counter
            finally:
                duration = (time.time() - start_time) * 1000
                span.set_attribute("output_tokens", counter.count)
                span.set_attribute("duration_ms", duration)
                span.set_attribute(
                    "tokens_per_second",
                    counter.count / (duration / 1000) if duration > 0 else 0,
                )

    def add_request_attributes(
        self,
        span: trace.Span,
        input_tokens: int,
        output_tokens: int,
        ttft_ms: float,
        total_latency_ms: float,
        cost: float,
    ):
        """Add final request attributes to span."""
        span.set_attribute("input_tokens", input_tokens)
        span.set_attribute("output_tokens", output_tokens)
        span.set_attribute("total_tokens", input_tokens + output_tokens)
        span.set_attribute("ttft_ms", ttft_ms)
        span.set_attribute("total_latency_ms", total_latency_ms)
        span.set_attribute("cost_usd", cost)
        span.set_attribute(
            "tokens_per_second",
            output_tokens / (total_latency_ms / 1000) if total_latency_ms > 0 else 0,
        )

# Usage example
"""
async def generate(request: GenerateRequest):
    tracer = LLMTracer(get_tracer())

    with tracer.trace_request(request.id, request.model) as root_span:
        # Cache lookup
        with tracer.trace_cache_lookup("semantic") as cache_span:
            cached = semantic_cache.get(request.prompt)
            cache_span.set_attribute("cache_hit", cached is not None)

        if cached:
            return cached

        # Prefill
        with tracer.trace_prefill(len(request.prompt.split())) as prefill_span:
            kv_cache = model.prefill(request.prompt)

        # Decode
        with tracer.trace_decode() as counter:
            for token in model.generate(kv_cache):
                counter.increment()
                yield token

        # Add final attributes
        tracer.add_request_attributes(...)
"""

Alerting Configuration

python
# alerting.py - Alerting rules for LLM applications
from dataclasses import dataclass
from typing import List, Dict, Callable, Optional
from enum import Enum
import time

class AlertSeverity(Enum):
    INFO = "info"
    WARNING = "warning"
    CRITICAL = "critical"

@dataclass
class AlertRule:
    name: str
    description: str
    severity: AlertSeverity
    condition: str  # PromQL expression
    threshold: float
    for_duration: str  # e.g., "5m"
    labels: Dict[str, str]
    annotations: Dict[str, str]

# Standard LLM alerting rules
LLM_ALERT_RULES = [
    # Latency alerts
    AlertRule(
        name="LLMHighLatency",
        description="LLM request latency is above SLO",
        severity=AlertSeverity.WARNING,
        condition='histogram_quantile(0.95, rate(llm_request_latency_seconds_bucket[5m])) > 5',
        threshold=5.0,
        for_duration="5m",
        labels={"team": "ml-platform", "service": "llm"},
        annotations={
            "summary": "High latency detected",
            "description": "P95 latency is {{ $value }}s, threshold is 5s",
        },
    ),
    AlertRule(
        name="LLMCriticalLatency",
        description="LLM request latency is critically high",
        severity=AlertSeverity.CRITICAL,
        condition='histogram_quantile(0.99, rate(llm_request_latency_seconds_bucket[5m])) > 10',
        threshold=10.0,
        for_duration="2m",
        labels={"team": "ml-platform", "service": "llm"},
        annotations={
            "summary": "Critical latency - immediate attention required",
            "description": "P99 latency is {{ $value }}s",
        },
    ),

    # Error rate alerts
    AlertRule(
        name="LLMHighErrorRate",
        description="LLM error rate is elevated",
        severity=AlertSeverity.WARNING,
        condition='rate(llm_request_errors_total[5m]) / rate(llm_request_latency_seconds_count[5m]) > 0.01',
        threshold=0.01,
        for_duration="5m",
        labels={"team": "ml-platform", "service": "llm"},
        annotations={
            "summary": "Elevated error rate",
            "description": "Error rate is {{ $value | humanizePercentage }}",
        },
    ),
    AlertRule(
        name="LLMCriticalErrorRate",
        description="LLM error rate is critical",
        severity=AlertSeverity.CRITICAL,
        condition='rate(llm_request_errors_total[5m]) / rate(llm_request_latency_seconds_count[5m]) > 0.05',
        threshold=0.05,
        for_duration="2m",
        labels={"team": "ml-platform", "service": "llm"},
        annotations={
            "summary": "Critical error rate - immediate attention required",
            "description": "Error rate is {{ $value | humanizePercentage }}",
        },
    ),

    # GPU resource alerts
    AlertRule(
        name="LLMGPUMemoryHigh",
        description="GPU memory utilization is high",
        severity=AlertSeverity.WARNING,
        condition='llm_gpu_memory_used_bytes / llm_gpu_memory_total_bytes > 0.9',
        threshold=0.9,
        for_duration="5m",
        labels={"team": "ml-platform", "service": "llm"},
        annotations={
            "summary": "High GPU memory usage",
            "description": "GPU {{ $labels.gpu_id }} memory at {{ $value | humanizePercentage }}",
        },
    ),
    AlertRule(
        name="LLMGPUUtilizationLow",
        description="GPU utilization is low - potential waste",
        severity=AlertSeverity.INFO,
        condition='avg_over_time(llm_gpu_utilization_percent[30m]) < 20',
        threshold=20,
        for_duration="30m",
        labels={"team": "ml-platform", "service": "llm"},
        annotations={
            "summary": "Low GPU utilization",
            "description": "Consider scaling down or optimizing batching",
        },
    ),

    # Cost alerts
    AlertRule(
        name="LLMDailyCostHigh",
        description="Daily LLM cost exceeds budget",
        severity=AlertSeverity.WARNING,
        condition='llm_daily_cost_dollars > 1000',
        threshold=1000,
        for_duration="1m",
        labels={"team": "ml-platform", "service": "llm"},
        annotations={
            "summary": "Daily cost budget exceeded",
            "description": "Current daily cost: ${{ $value }}",
        },
    ),
    AlertRule(
        name="LLMCostSpike",
        description="Sudden spike in LLM costs",
        severity=AlertSeverity.WARNING,
        condition='rate(llm_daily_cost_dollars[1h]) > 100',
        threshold=100,
        for_duration="15m",
        labels={"team": "ml-platform", "service": "llm"},
        annotations={
            "summary": "Cost spike detected",
            "description": "Cost increasing at ${{ $value }}/hour",
        },
    ),

    # Cache alerts
    AlertRule(
        name="LLMCacheHitRateLow",
        description="Cache hit rate is below expected",
        severity=AlertSeverity.INFO,
        condition='rate(llm_cache_hits_total[1h]) / (rate(llm_cache_hits_total[1h]) + rate(llm_cache_misses_total[1h])) < 0.3',
        threshold=0.3,
        for_duration="1h",
        labels={"team": "ml-platform", "service": "llm"},
        annotations={
            "summary": "Low cache hit rate",
            "description": "Cache hit rate is {{ $value | humanizePercentage }}",
        },
    ),

    # Queue alerts
    AlertRule(
        name="LLMQueueBacklog",
        description="Request queue is backing up",
        severity=AlertSeverity.WARNING,
        condition='llm_queue_size > 100',
        threshold=100,
        for_duration="5m",
        labels={"team": "ml-platform", "service": "llm"},
        annotations={
            "summary": "Queue backlog detected",
            "description": "{{ $value }} requests in queue",
        },
    ),
]

def generate_prometheus_rules(rules: List[AlertRule]) -> str:
    """Generate Prometheus alerting rules YAML."""
    yaml_content = "groups:\n  - name: llm-alerts\n    rules:\n"

    for rule in rules:
        yaml_content += f"""      - alert: {rule.name}
        expr: {rule.condition}
        for: {rule.for_duration}
        labels:
          severity: {rule.severity.value}
"""
        for k, v in rule.labels.items():
            yaml_content += f"          {k}: {v}\n"

        yaml_content += "        annotations:\n"
        for k, v in rule.annotations.items():
            yaml_content += f'          {k}: "{v}"\n'

    return yaml_content

Dashboard Design

python
# dashboard.py - Grafana dashboard configuration
from typing import List, Dict, Any
import json

def create_llm_dashboard() -> Dict[str, Any]:
    """Create comprehensive LLM monitoring dashboard."""
    return {
        "title": "LLM Service Dashboard",
        "tags": ["llm", "ml-platform"],
        "timezone": "browser",
        "refresh": "30s",
        "panels": [
            # Row 1: Overview
            {
                "title": "Request Rate",
                "type": "stat",
                "gridPos": {"x": 0, "y": 0, "w": 6, "h": 4},
                "targets": [{
                    "expr": 'sum(rate(llm_request_latency_seconds_count[5m]))',
                    "legendFormat": "Requests/s",
                }],
            },
            {
                "title": "P95 Latency",
                "type": "stat",
                "gridPos": {"x": 6, "y": 0, "w": 6, "h": 4},
                "targets": [{
                    "expr": 'histogram_quantile(0.95, sum(rate(llm_request_latency_seconds_bucket[5m])) by (le))',
                    "legendFormat": "P95 Latency",
                }],
                "fieldConfig": {
                    "defaults": {
                        "unit": "s",
                        "thresholds": {
                            "mode": "absolute",
                            "steps": [
                                {"value": 0, "color": "green"},
                                {"value": 2, "color": "yellow"},
                                {"value": 5, "color": "red"},
                            ],
                        },
                    },
                },
            },
            {
                "title": "Error Rate",
                "type": "stat",
                "gridPos": {"x": 12, "y": 0, "w": 6, "h": 4},
                "targets": [{
                    "expr": 'sum(rate(llm_request_errors_total[5m])) / sum(rate(llm_request_latency_seconds_count[5m]))',
                    "legendFormat": "Error Rate",
                }],
                "fieldConfig": {
                    "defaults": {
                        "unit": "percentunit",
                        "thresholds": {
                            "mode": "absolute",
                            "steps": [
                                {"value": 0, "color": "green"},
                                {"value": 0.01, "color": "yellow"},
                                {"value": 0.05, "color": "red"},
                            ],
                        },
                    },
                },
            },
            {
                "title": "Daily Cost",
                "type": "stat",
                "gridPos": {"x": 18, "y": 0, "w": 6, "h": 4},
                "targets": [{
                    "expr": 'sum(llm_daily_cost_dollars)',
                    "legendFormat": "Daily Cost",
                }],
                "fieldConfig": {
                    "defaults": {
                        "unit": "currencyUSD",
                    },
                },
            },

            # Row 2: Latency breakdown
            {
                "title": "Latency Distribution",
                "type": "timeseries",
                "gridPos": {"x": 0, "y": 4, "w": 12, "h": 8},
                "targets": [
                    {
                        "expr": 'histogram_quantile(0.50, sum(rate(llm_request_latency_seconds_bucket[5m])) by (le))',
                        "legendFormat": "P50",
                    },
                    {
                        "expr": 'histogram_quantile(0.95, sum(rate(llm_request_latency_seconds_bucket[5m])) by (le))',
                        "legendFormat": "P95",
                    },
                    {
                        "expr": 'histogram_quantile(0.99, sum(rate(llm_request_latency_seconds_bucket[5m])) by (le))',
                        "legendFormat": "P99",
                    },
                ],
            },
            {
                "title": "TTFT Distribution",
                "type": "timeseries",
                "gridPos": {"x": 12, "y": 4, "w": 12, "h": 8},
                "targets": [
                    {
                        "expr": 'histogram_quantile(0.50, sum(rate(llm_ttft_seconds_bucket[5m])) by (le))',
                        "legendFormat": "P50",
                    },
                    {
                        "expr": 'histogram_quantile(0.95, sum(rate(llm_ttft_seconds_bucket[5m])) by (le))',
                        "legendFormat": "P95",
                    },
                ],
            },

            # Row 3: Token metrics
            {
                "title": "Token Throughput",
                "type": "timeseries",
                "gridPos": {"x": 0, "y": 12, "w": 12, "h": 8},
                "targets": [
                    {
                        "expr": 'sum(rate(llm_input_tokens_total[5m]))',
                        "legendFormat": "Input Tokens/s",
                    },
                    {
                        "expr": 'sum(rate(llm_output_tokens_total[5m]))',
                        "legendFormat": "Output Tokens/s",
                    },
                ],
            },
            {
                "title": "Tokens Per Second (Generation)",
                "type": "timeseries",
                "gridPos": {"x": 12, "y": 12, "w": 12, "h": 8},
                "targets": [{
                    "expr": 'histogram_quantile(0.50, sum(rate(llm_tokens_per_second_bucket[5m])) by (le))',
                    "legendFormat": "Median TPS",
                }],
            },

            # Row 4: Cache performance
            {
                "title": "Cache Hit Rate",
                "type": "timeseries",
                "gridPos": {"x": 0, "y": 20, "w": 12, "h": 8},
                "targets": [{
                    "expr": 'sum(rate(llm_cache_hits_total[5m])) / (sum(rate(llm_cache_hits_total[5m])) + sum(rate(llm_cache_misses_total[5m])))',
                    "legendFormat": "Hit Rate",
                }],
            },
            {
                "title": "Cache Hits by Type",
                "type": "piechart",
                "gridPos": {"x": 12, "y": 20, "w": 12, "h": 8},
                "targets": [{
                    "expr": 'sum(increase(llm_cache_hits_total[1h])) by (cache_type)',
                    "legendFormat": "{{cache_type}}",
                }],
            },

            # Row 5: GPU metrics
            {
                "title": "GPU Utilization",
                "type": "timeseries",
                "gridPos": {"x": 0, "y": 28, "w": 12, "h": 8},
                "targets": [{
                    "expr": 'llm_gpu_utilization_percent',
                    "legendFormat": "GPU {{gpu_id}}",
                }],
            },
            {
                "title": "GPU Memory Usage",
                "type": "timeseries",
                "gridPos": {"x": 12, "y": 28, "w": 12, "h": 8},
                "targets": [{
                    "expr": 'llm_gpu_memory_used_bytes / llm_gpu_memory_total_bytes',
                    "legendFormat": "GPU {{gpu_id}}",
                }],
                "fieldConfig": {
                    "defaults": {"unit": "percentunit"},
                },
            },
        ],
    }

Interview Q&A

Question 1: What metrics would you track for an LLM application, and why?

Strong Answer: I'd track metrics across four categories:

1. Performance Metrics:

  • TTFT (P50, P95, P99): Critical for user-perceived latency
  • Total latency: End-to-end request time
  • Tokens per second: Generation throughput
  • Queue wait time: Identifies capacity issues

2. Resource Metrics:

  • GPU utilization: Detect under/over-provisioning
  • GPU memory: Track KV-cache growth, prevent OOM
  • CPU/Network: Identify bottlenecks outside GPU

3. Business Metrics:

  • Cost per request: Track spending, detect anomalies
  • Token usage (input/output): Understand workload patterns
  • Cache hit rate: Measure optimization effectiveness
  • Error rate by type: Distinguish infrastructure vs model errors

4. Quality Metrics:

  • User feedback rate: Track thumbs up/down
  • Regeneration rate: Users requesting new responses
  • Response length distribution: Detect truncation or verbosity issues

Why these matter:

  • Performance directly impacts user experience
  • Resource metrics enable capacity planning
  • Business metrics ensure cost control
  • Quality metrics catch model degradation

I'd set up dashboards showing all four categories and alerts on critical thresholds (P95 latency >5s, error rate >1%, daily cost exceeding budget).

Question 2: How would you implement end-to-end tracing for an LLM request?

Strong Answer: I'd implement tracing using OpenTelemetry with spans for each phase of the request lifecycle:

Span Hierarchy:

llm_request (root span)
├── cache_lookup
│   ├── semantic_search
│   └── exact_match
├── preprocessing
│   └── tokenization
├── inference
│   ├── prefill
│   │   ├── embedding_lookup
│   │   └── attention_compute
│   └── decode
│       └── token_generation (repeated)
├── postprocessing
│   └── detokenization
└── response_streaming

Key attributes to capture:

  • request_id: Correlation ID
  • model: Model name/version
  • input_tokens, output_tokens: Token counts
  • ttft_ms: Time to first token
  • cache_hit: Boolean + type if hit
  • queue_wait_ms: Time in queue

Implementation approach:

  1. Use context propagation for trace continuity
  2. Add custom spans for LLM-specific phases
  3. Capture token-level timing in decode phase
  4. Include GPU metrics as span attributes
  5. Export to Jaeger/Tempo for visualization

Benefits:

  • Debug slow requests by identifying bottleneck phase
  • Compare latency across different cache scenarios
  • Track request flow across microservices
  • Generate SLI/SLO reports from trace data

Question 3: Design an alerting strategy for an LLM service with 99.9% availability SLO.

Strong Answer: For a 99.9% availability SLO, I'd implement a tiered alerting strategy:

SLO Definition:

  • 99.9% = 8.76 hours downtime/year = 43.8 minutes/month
  • Define "available" as: successful response within 10 seconds

Tier 1: Proactive (Prevent Incidents)

  • GPU memory >85%: Scale up or clear cache (Warning)
  • Queue depth >50: Scale up inference capacity (Warning)
  • Cache hit rate drop >20%: Investigate cache issues (Info)
  • Cost spike >50% hourly: Potential abuse or bug (Warning)

Tier 2: Reactive (Detect Incidents)

  • Error rate >0.1% for 2 minutes: Page on-call (Critical)
  • P95 latency >5s for 5 minutes: Page on-call (Critical)
  • P99 latency >10s for 2 minutes: Page on-call (Critical)
  • No requests for 1 minute: Page on-call (Critical)

Tier 3: SLO Burn Rate

  • Error budget burn rate >10x: Immediate page (Critical)
  • Error budget burn rate >2x: Warning, investigate (Warning)
  • Error budget at 50%: Review and plan (Info)

Alert routing:

  • Critical: PagerDuty -> On-call engineer
  • Warning: Slack channel + email
  • Info: Dashboard only

Runbooks for each alert:

  • Include diagnostic commands
  • Escalation procedures
  • Rollback instructions
  • Communication templates

Silencing rules:

  • During planned maintenance
  • After acknowledged and investigating
  • For known issues with open tickets

Summary

AspectToolsKey Metrics
MetricsPrometheus, GrafanaTTFT, TPS, error rate, cost
LoggingELK, LokiRequest/response, errors, feedback
TracingJaeger, TempoRequest lifecycle, latency breakdown
AlertingAlertManager, PagerDutySLO violations, resource limits
DashboardsGrafanaReal-time overview, trends

Sources