Part II: Discovery Through Software Engineering and Vibe Coding
Chapter 21: AI for DevOps and Platform Engineering

21.2 Observability and Incident Analysis

"You cannot debug what you cannot observe. You cannot observe what you did not instrument. And you did not instrument the one service that is currently on fire."

An On-Call Engineer at 3 AM, Composing a Haiku About Regret

Prerequisites

This section builds on the CI/CD pipeline concepts from Section 21.1, which established the deployment lifecycle that observability monitors. You should also be familiar with the testing and verification patterns from Chapter 18, as test failures in production (the ones testing missed) are exactly what observability systems detect. The statistical concepts here connect to the anomaly detection methods covered more formally in Chapter 30.

The Big Picture

Observability is the ability to understand a system's internal state by examining its external outputs. Where testing verifies behavior before deployment, observability verifies behavior after deployment, in the wild, under real traffic, with real users. The four pillars of observability (metrics, logs, traces, profiles) provide complementary views of system behavior, and the Site Reliability Engineering (SRE) golden signals (latency, traffic, errors, saturation) distill those views into the four questions that matter most: "Is the service fast enough? Is it handling the expected load? Is it returning errors? Is it running out of capacity?" AI transforms observability from a reactive discipline (wait for alerts, then investigate) into a proactive one (detect anomalies before users notice, correlate signals across services, and generate root cause hypotheses automatically).

1. The Four Pillars of Observability

Your pager fires at 3 AM: checkout latency has tripled, but across forty-seven microservices, which one is responsible, and what changed? Answering that question under pressure demands four complementary signal types. Metrics are numeric measurements sampled at regular intervals: request count, error rate, CPU utilization, memory usage. They are cheap to collect, easy to aggregate, and excellent for alerting on known failure modes. Logs are timestamped text records of discrete events: a request arrived, a query executed, an error occurred. They provide narrative detail that metrics lack, but are expensive to store and search at scale. Traces follow a single request as it flows through multiple services, recording the timing and outcome of each hop. They reveal the causal chain of a distributed operation. Profiles capture the computational cost of individual functions: CPU time, memory allocations, lock contention. They answer "where is the time going?" at the code level.

Checkpoint

So far: observability rests on four complementary pillars: metrics (numeric aggregates), logs (event narratives), traces (cross-service request paths), and profiles (per-function cost breakdowns), each answering a different diagnostic question during an incident.

Observability is a property of a system's design, not a product you install. You can infer a system's internal states from its external outputs (telemetry) without deploying new code or attaching a debugger. This matters because modern distributed architectures produce failure modes that no one anticipated at design time. The only way to diagnose those failures quickly is to have rich, correlated telemetry already flowing. Each service emits metrics, logs, and traces through a shared collection pipeline (such as OpenTelemetry). A backend correlates these signals by request ID, timestamp, and service identity, so a single query can reveal the full story of any request. Use observability when you need to answer novel, ad hoc questions about production behavior; use traditional monitoring (static dashboards and threshold alerts) when you already know exactly which failure modes to watch for and need low-cost coverage.

Each pillar answers different questions during an incident:

Cross-pillar correlation gives observability its power. A latency spike (metric) traces to slow payment-service calls (trace) that log database timeouts (log) caused by a post-migration query plan regression (profile). AI processes all four signal types at once, outpacing human operators who must query each system separately under pressure. In short: A single signal tells you something is wrong; correlated signals tell you why. Figure 21.2.1 illustrates this end-to-end observability pipeline, from raw telemetry through correlated analysis to automated incident response.

Metrics Logs Traces Profiles Four Pillars OpenTelemetry Collection Anomaly Detection Root Cause Analysis Postmortem Generation Golden Signals: Latency, Traffic, Errors, Saturation
Figure 21.2.1: The observability pipeline. The four pillars (metrics, logs, traces, profiles) feed into an OpenTelemetry collection layer, which supplies the golden signals to anomaly detection. Detected anomalies trigger root cause analysis and, after resolution, structured postmortem generation.

Common Misconception

A frequent mistake is believing that more telemetry equals better observability, leading teams to instrument everything, store every log line at maximum verbosity, and record traces for 100% of requests. In practice, excessive telemetry creates noise that buries real signals, inflates storage costs, and slows down queries during the exact moments (incidents) when fast answers matter most. Observability comes from collecting the right signals with proper correlation (shared trace IDs, consistent labels, structured formats), not from collecting all signals indiscriminately.

Knowing what each pillar reveals individually is only the starting point; the next question is which measurements, drawn from all four pillars, matter most when a service is failing.

2. The SRE Golden Signals

Google's SRE framework distills service health into four golden signals. These are not arbitrary metrics; they are the minimal set needed to answer the question "is the service working for users?"

Latency measures the time to serve a request. Critically, it must be measured separately for successful and failed requests, because errors often complete quickly (a 500 response returns faster than a successful database query), masking degradation in the aggregate. The standard approach uses percentile distributions rather than averages, expressed as a Service Level Objective (SLO), where an SLO is a numeric target for the reliability a service promises to its users:

$$\text{SLO violation} \iff P_{99}(\text{latency}) > \tau_{99} \text{ over a rolling window } w$$

where \(\tau_{99}\) is the latency target at the 99th percentile and \(w\) is typically 5 or 10 minutes. Percentile-based SLOs capture the tail latency that averages hide.

Traffic measures demand: requests per second, messages per second, transactions per second. Traffic is the denominator in most other calculations. An error rate of 5% means something very different at 10 requests per second (one unhappy user per two seconds) versus 10,000 requests per second (500 errors per second).

Latency and traffic together define user experience: latency measures individual request pain, while traffic sets the scale at which that pain is felt.

Errors measure the rate of failed requests, including explicit failures (HTTP 5xx), implicit failures (HTTP 200 with wrong content), and policy violations (responses that are technically correct but too slow). The error budget model makes this quantitative:

$$\text{Error Budget Remaining} = \text{SLO Target} - \text{Observed Error Rate}$$

An SLO of 99.9% availability gives a monthly error budget of approximately 43.2 minutes of downtime (\(0.001 \times 30 \times 24 \times 60\)). (That is less than three quarters of an hour per month; each additional nine in uptime cuts the budget by 10x, so 99.99% leaves just 4.3 minutes.) When the budget is exhausted, the team should prioritize reliability over features.

Saturation measures how "full" a resource is: CPU at 85%, memory at 92%, disk at 78%, connection pool at 95%. Unlike the other signals, saturation is predictive: if two services are both trending upward in memory usage, the one at 90% utilization will run out of memory before the one at 50%, even if both are currently serving requests successfully. Linear extrapolation of saturation trends provides a simple "time to exhaustion" estimate:

$$t_{\text{exhaust}} = \frac{\text{Capacity} - \text{Current Usage}}{\text{Rate of Change}} = \frac{C - u(t)}{\frac{du}{dt}}$$
Key Insight: Observability Is Not Monitoring

Monitoring answers predefined questions: "Is CPU above 80%?" "Is the error rate above 1%?" You must know what to ask before building the dashboard. Observability answers unforeseen questions: "Why are requests from the Tokyo region 3x slower than Frankfurt, but only for users with more than 100 items in their cart, and only on Tuesdays?" The difference is the difference between verification (testing a known hypothesis) and discovery (finding an unknown pattern). Observability is the operational analog of the exploratory discovery methods in Chapter 25.

3. OpenTelemetry Instrumentation

OpenTelemetry (OTel) is a vendor-neutral framework that unifies the collection of metrics, logs, and traces under a single API. It provides auto-instrumentation for common frameworks (Flask, FastAPI, Django, requests, SQLAlchemy) and a manual instrumentation API for custom business logic. The key abstraction is the span, where a span is a named, timed operation that can contain attributes (key-value metadata), events (timestamped annotations), and links to other spans (forming a trace).

"""
OpenTelemetry instrumentation for a scientific discovery service.
Demonstrates metrics, traces, and structured logging with correlation.
"""
from opentelemetry import trace, metrics
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.trace.export import (
    BatchSpanProcessor, ConsoleSpanExporter
)
from opentelemetry.sdk.metrics.export import (
    PeriodicExportingMetricReader, ConsoleMetricExporter
)
from opentelemetry.semconv.trace import SpanAttributes
import logging
import time

# --- Setup: Configure trace and metrics providers ---

# Traces: each span records a timed operation with metadata
trace_provider = TracerProvider()
trace_provider.add_span_processor(
    BatchSpanProcessor(ConsoleSpanExporter())  # Replace with OTLP exporter
)
trace.set_tracer_provider(trace_provider)
tracer = trace.get_tracer("discovery.service")

# Metrics: counters, histograms, and gauges for aggregated measurements
metric_reader = PeriodicExportingMetricReader(
    ConsoleMetricExporter(), export_interval_millis=10000
)
meter_provider = MeterProvider(metric_readers=[metric_reader])
metrics.set_meter_provider(meter_provider)
meter = metrics.get_meter("discovery.service")

# Define the four golden signal metrics
request_counter = meter.create_counter(
    "discovery.requests.total",
    description="Total number of requests",
    unit="1"
)
error_counter = meter.create_counter(
    "discovery.errors.total",
    description="Total number of errors",
    unit="1"
)
latency_histogram = meter.create_histogram(
    "discovery.request.duration",
    description="Request duration in milliseconds",
    unit="ms"
)
active_requests_gauge = meter.create_up_down_counter(
    "discovery.requests.active",
    description="Number of currently active requests",
    unit="1"
)

# Structured logging with trace correlation
logger = logging.getLogger("discovery.service")


def process_experiment(experiment_id: str, parameters: dict) -> dict:
    """Process a scientific experiment with full observability.

    This function demonstrates how to instrument a business operation
    with traces (for request flow), metrics (for golden signals),
    and structured logs (for event details).
    """
    # Start a trace span for the entire operation
    with tracer.start_as_current_span(
        "process_experiment",
        attributes={
            "experiment.id": experiment_id,
            "experiment.type": parameters.get("type", "unknown"),
            "experiment.parameters_count": len(parameters),
        }
    ) as span:
        # Track active requests (saturation signal)
        active_requests_gauge.add(1, {"endpoint": "experiment"})
        start_time = time.time()

        try:
            # Step 1: Validate parameters (child span)
            with tracer.start_as_current_span("validate_parameters") as val_span:
                if not parameters:
                    val_span.set_attribute("validation.result", "failed")
                    raise ValueError("Empty parameters")
                val_span.set_attribute("validation.result", "passed")
                logger.info(
                    "Parameters validated",
                    extra={"experiment_id": experiment_id}
                )

            # Step 2: Run computation (child span with timing)
            with tracer.start_as_current_span("run_computation") as comp_span:
                # Simulate computation
                result = {
                    "experiment_id": experiment_id,
                    "status": "completed",
                    "metrics": {"accuracy": 0.94, "loss": 0.12}
                }
                comp_span.set_attribute(
                    "computation.accuracy", result["metrics"]["accuracy"]
                )
                comp_span.add_event("computation_completed", {
                    "accuracy": str(result["metrics"]["accuracy"])
                })

            # Step 3: Store results (child span)
            with tracer.start_as_current_span("store_results") as store_span:
                store_span.set_attribute("storage.backend", "postgresql")
                logger.info(
                    "Results stored",
                    extra={
                        "experiment_id": experiment_id,
                        "accuracy": result["metrics"]["accuracy"]
                    }
                )

            # Record success metrics
            duration_ms = (time.time() - start_time) * 1000
            request_counter.add(1, {"endpoint": "experiment", "status": "success"})
            latency_histogram.record(duration_ms, {"endpoint": "experiment"})

            span.set_attribute("experiment.status", "success")
            return result

        except Exception as e:
            # Record failure metrics
            duration_ms = (time.time() - start_time) * 1000
            error_counter.add(1, {
                "endpoint": "experiment",
                "error_type": type(e).__name__
            })
            latency_histogram.record(duration_ms, {"endpoint": "experiment"})

            span.set_attribute("experiment.status", "error")
            span.record_exception(e)
            logger.error(
                f"Experiment failed: {e}",
                extra={"experiment_id": experiment_id},
                exc_info=True
            )
            raise

        finally:
            active_requests_gauge.add(-1, {"endpoint": "experiment"})
OpenTelemetry instrumentation of a discovery service showing correlated parent-child trace spans, golden signal metric counters and histograms, and structured log entries keyed by experiment ID.
Library Shortcut: Auto-Instrumentation

The manual instrumentation above teaches the concepts, but OpenTelemetry's auto-instrumentation libraries handle most of this automatically. For a FastAPI service, install opentelemetry-instrumentation-fastapi and call FastAPIInstrumentor.instrument_app(app). This single line adds trace spans for every HTTP request, records latency histograms, tracks error rates, and propagates trace context across service boundaries. Auto-instrumentation packages exist for Flask, Django, SQLAlchemy, requests, httpx, Redis, PostgreSQL, and dozens more. The manual API is needed only for custom business logic (like the experiment-specific attributes above). In a typical service, auto-instrumentation typically covers roughly 80% of the observability needs in a few lines of code versus 60+ lines of manual instrumentation.

4. AI-Driven Anomaly Detection

Traditional alerting uses static thresholds: alert when error rate exceeds 1%, when latency exceeds 500ms, when CPU exceeds 80%. These thresholds are fragile. A service with natural daily traffic patterns (high during business hours, low at night) triggers false alarms at night (low traffic makes error rate volatile) and misses real problems during peak hours (a 20% latency increase from 200ms to 240ms is invisible to a 500ms threshold but significant to users).

When a payment service silently degrades during a traffic surge, a static 500ms latency threshold stays green while thousands of users experience timeouts at 480ms. Learned baselines catch these context-dependent failures because they adapt to what "normal" actually looks like at any given hour. AI-driven anomaly detection replaces static thresholds with learned baselines (a model of "normal" derived from historical data, against which new observations are compared). The simplest effective approach uses exponentially weighted moving average (EWMA) statistics, where each new observation is blended with the running estimate using an exponential decay that gives recent points more influence:

$$\hat{\mu}_t = \alpha \cdot x_t + (1 - \alpha) \cdot \hat{\mu}_{t-1}$$ $$\hat{\sigma}^2_t = \alpha \cdot (x_t - \hat{\mu}_t)^2 + (1 - \alpha) \cdot \hat{\sigma}^2_{t-1}$$

where \(\alpha \in (0, 1)\) is the smoothing parameter (smaller values give more weight to history), \(x_t\) is the current observation, \(\hat{\mu}_t\) is the estimated mean, and \(\hat{\sigma}^2_t\) is the estimated variance. An anomaly is flagged when the observation falls outside \(k\) standard deviations from the estimated mean:

$$\text{anomaly} \iff |x_t - \hat{\mu}_t| > k \cdot \hat{\sigma}_t$$

The parameter \(k\) controls the sensitivity: \(k = 2\) catches moderate anomalies (roughly 5% false positive rate for Gaussian data), \(k = 3\) catches severe anomalies (0.3% false positive rate). For operational metrics, \(k = 3\) is a common starting point, adjusted based on the cost of false positives versus missed detections.

Step-Through: EWMA Anomaly Detection

Trace through the exponentially weighted moving average detector with \(\alpha = 0.2\), \(k = 3\), and these five latency observations (in ms): 200, 210, 195, 205, 450.

Step 1 (\(x_1 = 200\)): First observation initializes the mean. \(\hat{\mu}_1 = 200\), \(\hat{\sigma}^2_1 = 0\). No anomaly check (warmup).
Step 2 (\(x_2 = 210\)): \(\hat{\mu}_2 = 0.2 \times 210 + 0.8 \times 200 = 202\). \(\hat{\sigma}^2_2 = 0.2 \times (210 - 202)^2 + 0.8 \times 0 = 12.8\), so \(\hat{\sigma}_2 = 3.58\). \(|210 - 202| = 8 < 3 \times 3.58 = 10.73\). Not anomalous.
Step 3 (\(x_3 = 195\)): \(\hat{\mu}_3 = 0.2 \times 195 + 0.8 \times 202 = 200.6\). \(\hat{\sigma}^2_3 = 0.2 \times (195 - 200.6)^2 + 0.8 \times 12.8 = 16.51\), so \(\hat{\sigma}_3 = 4.06\). \(|195 - 200.6| = 5.6 < 12.19\). Not anomalous.
Step 4 (\(x_4 = 205\)): \(\hat{\mu}_4 = 0.2 \times 205 + 0.8 \times 200.6 = 201.48\). \(\hat{\sigma}^2_4 = 0.2 \times (205 - 201.48)^2 + 0.8 \times 16.51 = 15.68\), so \(\hat{\sigma}_4 = 3.96\). \(|205 - 201.48| = 3.52 < 11.88\). Not anomalous.
Step 5 (\(x_5 = 450\)): \(\hat{\mu}_5 = 0.2 \times 450 + 0.8 \times 201.48 = 251.18\). \(\hat{\sigma}^2_5 = 0.2 \times (450 - 251.18)^2 + 0.8 \times 15.68 = 7929.98\), so \(\hat{\sigma}_5 = 89.05\). \(|450 - 251.18| = 198.82 > 3 \times 89.05 = 267.15\)? No: 198.82 < 267.15, so even this spike is not flagged, because the variance estimate bloated on the same step. This illustrates a known limitation: the EWMA updates mean and variance simultaneously, so a single extreme point inflates the variance enough to mask itself. Robust detectors address this by updating the variance with a lag or using the previous step's variance for the anomaly check.

Mental Model

Think of the exponentially weighted moving average as a weather forecaster who updates tomorrow's prediction by blending today's actual temperature with yesterday's forecast. If today is unexpectedly hot, the forecaster nudges the prediction upward but does not throw out the entire history. The smoothing parameter \(\alpha\) controls how much weight goes to today versus the accumulated past: a small \(\alpha\) (say 0.05) is a cautious forecaster who trusts the long trend, while a large \(\alpha\) (say 0.3) is a reactive forecaster who pivots quickly. An "anomaly" in this model is a day so far from the forecast that no reasonable weather pattern would produce it, like snow in July. The detector flags it not because the value is absolutely extreme, but because it is extreme relative to what the recent history predicts.

"""
Time series anomaly detection for operational metrics.
Implements exponentially weighted statistics with seasonal adjustment.
"""
import numpy as np
from dataclasses import dataclass, field
from collections import deque


@dataclass
class AnomalyDetector:
    """Detects anomalies in streaming metric data.

    Uses exponentially weighted moving average and variance with
    optional seasonal decomposition for metrics with periodic patterns
    (e.g., daily traffic cycles).
    """
    alpha: float = 0.1          # Smoothing parameter (0 < alpha < 1)
    threshold_sigma: float = 3.0  # Anomaly threshold in std deviations
    season_length: int = 0      # 0 = no seasonality; e.g., 288 for 5-min data with daily cycle
    warmup_points: int = 100    # Minimum points before generating alerts

    # Internal state
    _mean: float = 0.0
    _variance: float = 1.0
    _count: int = 0
    _seasonal_means: np.ndarray = field(default_factory=lambda: np.array([]))
    _seasonal_counts: np.ndarray = field(default_factory=lambda: np.array([]))

    def __post_init__(self):
        if self.season_length > 0:
            self._seasonal_means = np.zeros(self.season_length)
            self._seasonal_counts = np.zeros(self.season_length)

    def update(self, value: float, timestamp_index: int = 0) -> dict:
        """Process a new metric value and check for anomalies.

        Args:
            value: The metric value at the current time step.
            timestamp_index: Position in the seasonal cycle (e.g., minute of day).

        Returns:
            Dictionary with anomaly status, score, and context.
        """
        self._count += 1

        # Deseasonalize if seasonal model is active
        seasonal_component = 0.0
        if self.season_length > 0 and self._count > self.season_length:
            season_pos = timestamp_index % self.season_length
            seasonal_component = self._seasonal_means[season_pos]

        deseasonalized = value - seasonal_component

        # Update exponentially weighted statistics
        if self._count == 1:
            self._mean = deseasonalized
            self._variance = 0.0
        else:
            delta = deseasonalized - self._mean
            self._mean = self.alpha * deseasonalized + (1 - self.alpha) * self._mean
            self._variance = (
                self.alpha * delta ** 2
                + (1 - self.alpha) * self._variance
            )

        # Update seasonal model
        if self.season_length > 0:
            season_pos = timestamp_index % self.season_length
            n = self._seasonal_counts[season_pos]
            self._seasonal_means[season_pos] = (
                (n * self._seasonal_means[season_pos] + value) / (n + 1)
            )
            self._seasonal_counts[season_pos] = n + 1

        # Compute anomaly score
        std = max(np.sqrt(self._variance), 1e-10)  # Avoid division by zero
        z_score = abs(deseasonalized - self._mean) / std

        is_anomaly = (
            z_score > self.threshold_sigma
            and self._count > self.warmup_points
        )

        return {
            "is_anomaly": is_anomaly,
            "z_score": round(z_score, 2),
            "value": value,
            "expected": round(self._mean + seasonal_component, 2),
            "std": round(std, 4),
            "direction": "high" if deseasonalized > self._mean else "low",
            "confidence": min(self._count / self.warmup_points, 1.0)
        }


class MultiSignalDetector:
    """Correlates anomalies across multiple golden signals.

    A single metric anomaly might be noise. Correlated anomalies
    across latency, error rate, and traffic are almost certainly
    a real incident.
    """
    def __init__(self, signals: list[str], correlation_window: int = 5):
        """
        Args:
            signals: Names of the signals to monitor.
            correlation_window: Number of time steps within which
                anomalies are considered correlated.
        """
        self.detectors = {name: AnomalyDetector() for name in signals}
        self.correlation_window = correlation_window
        self.recent_anomalies: dict[str, deque] = {
            name: deque(maxlen=correlation_window) for name in signals
        }

    def update(self, signal_name: str, value: float, step: int) -> dict:
        """Update a single signal and check for correlated anomalies."""
        result = self.detectors[signal_name].update(value, step)
        self.recent_anomalies[signal_name].append(
            (step, result["is_anomaly"])
        )

        # Check for correlation: how many signals have recent anomalies?
        correlated_signals = []
        for name, history in self.recent_anomalies.items():
            if any(is_anom for _, is_anom in history):
                correlated_signals.append(name)

        result["correlated_signals"] = correlated_signals
        result["incident_likelihood"] = (
            "high" if len(correlated_signals) >= 3
            else "medium" if len(correlated_signals) >= 2
            else "low"
        )
        return result


# Example: monitoring a service
if __name__ == "__main__":
    detector = MultiSignalDetector(
        signals=["latency_p99", "error_rate", "traffic_rps", "cpu_percent"]
    )

    # Simulate normal traffic with an anomaly at step 150
    np.random.seed(42)
    for step in range(200):
        # Normal baseline
        latency = np.random.normal(200, 20)
        error_rate = np.random.normal(0.01, 0.002)
        traffic = np.random.normal(1000, 50)
        cpu = np.random.normal(60, 5)

        # Inject incident at step 150: latency spike + error surge
        if 150 <= step <= 160:
            latency += 300   # 200ms -> 500ms
            error_rate += 0.08  # 1% -> 9%

        for signal, value in [
            ("latency_p99", latency), ("error_rate", error_rate),
            ("traffic_rps", traffic), ("cpu_percent", cpu)
        ]:
            result = detector.update(signal, value, step)

        if result["incident_likelihood"] in ("medium", "high"):
            print(
                f"Step {step}: {result['incident_likelihood']} incident, "
                f"correlated: {result['correlated_signals']}"
            )
    # Output at step 150-160:
    #   Step 150: medium incident, correlated: ['latency_p99', 'error_rate']
    #   Step 151: high incident, correlated: ['latency_p99', 'error_rate', ...]
MultiSignalDetector correlating EWMA-based anomaly flags across four golden signals (latency, error rate, traffic, CPU) and rating incident likelihood by the number of simultaneously anomalous signals.
Practical Example: Detecting a Memory Leak Before OOM

A materials science simulation platform ran long-lived worker processes that occasionally developed memory leaks after processing certain molecular structures. Traditional monitoring used a static 90% memory threshold, which triggered alerts only minutes before out-of-memory (OOM) kills. By switching to the saturation extrapolation formula (\(t_{\text{exhaust}} = (C - u(t)) / (du/dt)\)), the team predicted OOM events 2-4 hours in advance. When the predicted time-to-exhaustion dropped below 30 minutes, an automated handler gracefully drained the worker, restarted it, and redistributed its jobs. OOM kills dropped from 15 per week to zero, and the team used the time-to-exhaustion metric as input to their capacity planning model from Chapter 6.

Detecting that something is anomalous, however, only tells you when the problem started; understanding what went wrong requires sifting through the narrative record that logs provide.

5. Log Clustering and Pattern Extraction

Production services generate millions of log lines per hour. During an incident, the relevant signal is buried in noise: routine health checks, request logs, debug output from unrelated services. AI-driven log analysis clusters similar log messages, identifies novel patterns, and extracts the critical events that explain the incident.

The simplest effective approach is log template extraction: parsing log messages to separate the fixed template ("Connection to {host}:{port} timed out after {duration}ms") from the variable parameters (host=db-primary, port=5432, duration=30000). Once templates are extracted, clustering reduces millions of log lines to dozens of distinct message types, and anomaly detection identifies templates whose frequency changed during the incident.

Real-World Application: Google's Monarch Monitoring System
Real-World Application: Google's Monarch Monitoring System
"""
Log clustering and anomaly detection for incident analysis.
Extracts templates from raw log messages, clusters by pattern,
and identifies frequency anomalies during incidents.
"""
import re
from collections import Counter, defaultdict
from dataclasses import dataclass


@dataclass
class LogEntry:
    """A parsed log entry."""
    timestamp: str
    level: str
    service: str
    message: str
    template: str = ""
    parameters: dict = None


def extract_template(message: str) -> tuple[str, dict]:
    """Extract a template from a log message by replacing variable parts.

    Replaces IP addresses, numbers, UUIDs, paths, and quoted strings
    with typed placeholders, leaving the structural skeleton.

    Returns:
        Tuple of (template_string, extracted_parameters).
    """
    params = {}
    template = message

    # Replace UUIDs
    uuid_pattern = r'[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'
    for i, match in enumerate(re.finditer(uuid_pattern, template, re.IGNORECASE)):
        params[f"uuid_{i}"] = match.group()
    template = re.sub(uuid_pattern, '{UUID}', template, flags=re.IGNORECASE)

    # Replace IP addresses
    ip_pattern = r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}'
    for i, match in enumerate(re.finditer(ip_pattern, template)):
        params[f"ip_{i}"] = match.group()
    template = re.sub(ip_pattern, '{IP}', template)

    # Replace quoted strings
    for i, match in enumerate(re.finditer(r'"([^"]*)"', template)):
        params[f"str_{i}"] = match.group(1)
    template = re.sub(r'"[^"]*"', '{STR}', template)

    # Replace numbers (after IPs to avoid double-replacing)
    for i, match in enumerate(re.finditer(r'\b\d+\.?\d*\b', template)):
        params[f"num_{i}"] = match.group()
    template = re.sub(r'\b\d+\.?\d*\b', '{NUM}', template)

    return template, params


class LogAnalyzer:
    """Analyzes log streams for incident investigation.

    Clusters logs by template, tracks template frequency over time,
    and identifies templates with anomalous frequency changes.
    """
    def __init__(self):
        self.template_counts: Counter = Counter()
        self.template_examples: dict[str, str] = {}
        self.window_counts: defaultdict = defaultdict(Counter)

    def ingest(self, entries: list[LogEntry], window_id: str = "current"):
        """Process a batch of log entries.

        Args:
            entries: List of log entries to process.
            window_id: Time window identifier for frequency comparison.
        """
        for entry in entries:
            template, params = extract_template(entry.message)
            entry.template = template
            entry.parameters = params

            self.template_counts[template] += 1
            self.window_counts[window_id][template] += 1

            if template not in self.template_examples:
                self.template_examples[template] = entry.message

    def find_anomalous_templates(
        self,
        baseline_window: str,
        incident_window: str,
        min_ratio: float = 3.0
    ) -> list[dict]:
        """Find templates whose frequency changed significantly.

        Compares template frequency between a baseline period and an
        incident period. Templates that appear much more (or less)
        frequently during the incident are likely related to the
        root cause.

        Args:
            baseline_window: Window ID for normal behavior.
            incident_window: Window ID for incident period.
            min_ratio: Minimum frequency ratio to flag as anomalous.

        Returns:
            List of anomalous templates with frequency ratios.
        """
        baseline = self.window_counts[baseline_window]
        incident = self.window_counts[incident_window]

        # Normalize by total count in each window
        baseline_total = max(sum(baseline.values()), 1)
        incident_total = max(sum(incident.values()), 1)

        anomalies = []
        all_templates = set(baseline.keys()) | set(incident.keys())

        for template in all_templates:
            base_rate = baseline.get(template, 0) / baseline_total
            inc_rate = incident.get(template, 0) / incident_total

            # Avoid division by zero; treat new templates as highly anomalous
            if base_rate < 1e-10:
                if inc_rate > 0:
                    anomalies.append({
                        "template": template,
                        "type": "new_during_incident",
                        "incident_rate": round(inc_rate, 6),
                        "example": self.template_examples.get(template, ""),
                        "incident_count": incident.get(template, 0)
                    })
                continue

            ratio = inc_rate / base_rate

            if ratio >= min_ratio or ratio <= 1.0 / min_ratio:
                anomalies.append({
                    "template": template,
                    "type": "frequency_spike" if ratio > 1 else "frequency_drop",
                    "ratio": round(ratio, 2),
                    "baseline_rate": round(base_rate, 6),
                    "incident_rate": round(inc_rate, 6),
                    "example": self.template_examples.get(template, ""),
                    "incident_count": incident.get(template, 0)
                })

        # Sort by severity (highest ratio first)
        anomalies.sort(
            key=lambda x: x.get("ratio", float("inf")), reverse=True
        )
        return anomalies
LogAnalyzer extracting structural templates from raw log messages via regex-based placeholder substitution, then comparing per-template frequencies between baseline and incident windows to surface the patterns most likely tied to the root cause.

Once anomalous log patterns have been surfaced, the next challenge is assembling those patterns alongside metrics, traces, and recent changes into a coherent explanation of why the incident occurred.

6. AI-Driven Root Cause Analysis

Root cause analysis (RCA) is the most cognitively demanding task in incident response. The on-call engineer must synthesize information from dashboards, log queries, trace views, deployment timelines, and recent changes, all under time pressure. AI assists by automating the evidence-gathering phase and generating structured hypotheses that the engineer can validate.

"""
AI-driven root cause analysis for production incidents.
Gathers evidence from multiple observability signals and generates
structured hypotheses using an LLM.
"""
from dataclasses import dataclass, field
from datetime import datetime
from anthropic import Anthropic


@dataclass
class IncidentContext:
    """Collected evidence for an incident."""
    incident_id: str
    start_time: datetime
    affected_services: list[str]
    golden_signals: dict[str, dict]    # service -> {latency, errors, traffic, saturation}
    anomalous_logs: list[dict]         # From LogAnalyzer.find_anomalous_templates
    recent_deployments: list[dict]     # {service, version, timestamp, changes}
    recent_config_changes: list[dict]  # {resource, change_type, timestamp}
    trace_analysis: dict = field(default_factory=dict)  # Slow span summary


def generate_root_cause_analysis(context: IncidentContext) -> dict:
    """Generate a structured root cause analysis from incident evidence.

    Synthesizes information from metrics, logs, traces, deployments,
    and config changes to produce ranked hypotheses with supporting
    evidence and recommended actions.

    Returns:
        Dictionary with hypotheses, evidence, and recommended actions.
    """
    client = Anthropic()

    # Build a structured evidence summary for the LLM
    evidence_text = f"""
INCIDENT: {context.incident_id}
START TIME: {context.start_time.isoformat()}
AFFECTED SERVICES: {', '.join(context.affected_services)}

GOLDEN SIGNALS:
"""
    for service, signals in context.golden_signals.items():
        evidence_text += f"\n  {service}:"
        for signal, value in signals.items():
            evidence_text += f"\n    {signal}: {value}"

    evidence_text += "\n\nANOMALOUS LOG PATTERNS:"
    for log in context.anomalous_logs[:10]:  # Top 10 anomalous patterns
        evidence_text += (
            f"\n  [{log['type']}] ratio={log.get('ratio', 'N/A')}: "
            f"{log['template']}"
            f"\n    Example: {log['example']}"
        )

    evidence_text += "\n\nRECENT DEPLOYMENTS:"
    for deploy in context.recent_deployments:
        evidence_text += (
            f"\n  {deploy['service']} v{deploy['version']} "
            f"at {deploy['timestamp']}: {deploy.get('changes', 'unknown')}"
        )

    evidence_text += "\n\nRECENT CONFIG CHANGES:"
    for change in context.recent_config_changes:
        evidence_text += (
            f"\n  {change['resource']} ({change['change_type']}) "
            f"at {change['timestamp']}"
        )

    prompt = f"""You are an expert SRE performing root cause analysis.
Given the following incident evidence, produce a structured analysis.

{evidence_text}

Produce your analysis as a structured report with these sections:

1. TIMELINE: Key events in chronological order
2. HYPOTHESES: Ranked list of possible root causes (most likely first),
   each with:
   - Description of the hypothesis
   - Supporting evidence (cite specific signals, logs, or changes)
   - Contradicting evidence (if any)
   - Confidence level (high/medium/low)
3. RECOMMENDED ACTIONS: Immediate mitigation steps, then investigation steps
4. PREVENTION: What changes would prevent recurrence

Be specific. Cite exact metric values, log templates, and timestamps."""

    response = client.messages.create(
        model="claude-sonnet-4-20250514",
        max_tokens=4096,
        messages=[{"role": "user", "content": prompt}]
    )

    return {
        "incident_id": context.incident_id,
        "analysis": response.content[0].text,
        "evidence_summary": {
            "services_affected": len(context.affected_services),
            "anomalous_log_patterns": len(context.anomalous_logs),
            "recent_deployments": len(context.recent_deployments),
            "recent_config_changes": len(context.recent_config_changes)
        }
    }
LLM-based root cause analysis that assembles golden signal snapshots, anomalous log templates, deployment history, and configuration changes into a structured prompt, producing ranked hypotheses with cited evidence and remediation steps.

Real-World Application: Google's Monarch Monitoring System

Google's Monarch system ingests billions of time series points per second across all of Google's production infrastructure, applying learned baselines rather than static thresholds to detect anomalies in golden signals. Monarch correlates metrics, logs, and traces through a unified query language, enabling SREs to ask ad hoc questions like "which RPCs to the Bigtable backend exceeded their latency SLO in the last hour, broken down by datacenter?" This correlation across pillars reportedly helped Google reduce mean time to detection (MTTD) for novel failure modes that no predefined alert would have caught.

Research Frontier: Autonomous Incident Response Agents

The RCA function above generates hypotheses for a human engineer to validate. Research from Microsoft (RCACopilot, Chen et al., International Conference on Software Engineering (ICSE) 2024) showed that large language model (LLM)-based copilots could match senior engineers on root cause classification for 72% of real cloud incidents. More recently, Microsoft Research's AIOpsLab (2024) introduced a standardized benchmark and orchestration framework for evaluating autonomous AIOps agents end to end, covering fault detection, localization, root cause analysis, and mitigation across reproducible cloud failure scenarios. These agent systems navigate observability dashboards, query log aggregation backends, inspect Kubernetes (the container orchestration platform that schedules and manages containerized services across a cluster) pod states, and even roll back deployments, all through tool-use interfaces similar to the MCP servers in Chapter 12. The key constraint remains trust: autonomous remediation in production requires strong guardrails, audit trails, and blast radius limits, connecting to the responsible AI practices discussed in Chapter 57.

Together, the preceding components form a complete incident analysis pipeline: anomaly detection (Section 4) identifies when something went wrong, log clustering (Section 5) surfaces what changed in the system's behavior, and root cause analysis (Section 6) synthesizes both signals into why the failure occurred. The final step is capturing those findings in a durable, shareable format so that the organization learns from each incident rather than repeating the same failures.

7. Structured Postmortem Generation

A postmortem (a structured, blameless after-action report focused on systemic causes rather than individual blame) is the discovery document of incident response: it records what happened, why it happened, and what the team will do to prevent recurrence. Writing a postmortem is mostly mechanical synthesis. The harder part is writing it promptly and thoroughly. Teams under pressure often skip postmortems or produce shallow ones. AI generates a first draft from the incident timeline, chat transcripts, and resolution steps. The team then reviews, corrects, and adds the human judgment that AI cannot provide.

"""
Structured postmortem generator from incident data.
Produces a blameless postmortem document following the SRE template.
"""
from dataclasses import dataclass
from datetime import datetime
from anthropic import Anthropic


@dataclass
class IncidentTimeline:
    """Chronological record of incident events."""
    events: list[dict]  # [{timestamp, actor, action, detail}]


@dataclass
class PostmortemInput:
    """All inputs needed to generate a postmortem."""
    incident_id: str
    severity: str             # "P1", "P2", "P3" (priority tiers where P1 is most severe)
    title: str
    start_time: datetime
    detection_time: datetime
    mitigation_time: datetime
    resolution_time: datetime
    timeline: IncidentTimeline
    root_cause_analysis: str  # From generate_root_cause_analysis
    impact: dict              # {users_affected, error_count, revenue_impact}
    responders: list[str]


def generate_postmortem(inputs: PostmortemInput) -> str:
    """Generate a blameless postmortem from incident data.

    Follows the SRE postmortem template: summary, impact, timeline,
    root cause, lessons learned, action items.
    """
    client = Anthropic()

    # Calculate key incident response time metrics:
    # TTD = Time to Detect (how long before anyone noticed)
    # TTM = Time to Mitigate (how long to stop user-facing impact)
    # TTR = Time to Resolve (how long to fully fix the underlying cause)
    ttd = (inputs.detection_time - inputs.start_time).total_seconds() / 60
    ttm = (inputs.mitigation_time - inputs.detection_time).total_seconds() / 60
    ttr = (inputs.resolution_time - inputs.detection_time).total_seconds() / 60

    timeline_text = "\n".join(
        f"  {e['timestamp']} [{e['actor']}] {e['action']}: {e['detail']}"
        for e in inputs.timeline.events
    )

    prompt = f"""Generate a blameless postmortem document for this incident.

INCIDENT: {inputs.incident_id} ({inputs.severity})
TITLE: {inputs.title}

KEY METRICS:
- Time to Detect (TTD): {ttd:.0f} minutes
- Time to Mitigate (TTM): {ttm:.0f} minutes
- Time to Resolve (TTR): {ttr:.0f} minutes

IMPACT:
- Users affected: {inputs.impact.get('users_affected', 'unknown')}
- Total errors: {inputs.impact.get('error_count', 'unknown')}

TIMELINE:
{timeline_text}

ROOT CAUSE ANALYSIS:
{inputs.root_cause_analysis}

RESPONDERS: {', '.join(inputs.responders)}

Generate a complete postmortem with these sections:
1. Executive Summary (2-3 sentences)
2. Impact (quantified, user-facing)
3. Timeline (table format with timestamps)
4. Root Cause (technical explanation, blameless)
5. What Went Well (detection, response, communication)
6. What Went Wrong (gaps in monitoring, process, tooling)
7. Where We Got Lucky (near-misses, things that could have been worse)
8. Action Items (each with owner, priority, due date)
9. Lessons Learned (what to teach the rest of the organization)

Rules:
- BLAMELESS: never name individuals as causes. Systems fail, not people.
- SPECIFIC: cite exact timestamps, metric values, and error messages.
- ACTIONABLE: every action item must be concrete, measurable, and assigned."""

    response = client.messages.create(
        model="claude-sonnet-4-20250514",
        max_tokens=4096,
        messages=[{"role": "user", "content": prompt}]
    )
    return response.content[0].text
Blameless postmortem generator that computes TTD, TTM, and TTR from incident timestamps and feeds them alongside the event timeline, root cause analysis, and impact data into a structured LLM prompt that produces a nine-section postmortem document.
Fun Note: The Postmortem Paradox

The best postmortems come from the worst incidents. A 30-minute P3 outage affecting 50 users rarely produces deep insights. A 4-hour P1 outage with cascading failures across six services, where the monitoring system itself went down, and the runbook was three years out of date, produces a postmortem so rich in lessons that it becomes required reading for every new hire. The paradox: you learn the most from the incidents you most wish had never happened.

Try It: Build a Golden Signal Anomaly Dashboard

1. Generate 24 hours of synthetic golden signal data using NumPy: create arrays for latency (normal around 200ms with a daily sine wave), error rate (baseline 0.5%), traffic (peak at midday, trough at 3 AM), and CPU utilization (correlated with traffic). Inject a simulated incident at hour 18: a 3x latency spike and 8% error rate lasting 20 minutes.
2. Implement the AnomalyDetector class from this section with alpha=0.1, threshold_sigma=3.0, and season_length=288 (5-minute intervals over 24 hours). Feed each signal through its own detector instance.
3. Build a MultiSignalDetector that tracks which signals are simultaneously anomalous within a 5-step correlation window. Log every time step where two or more signals flag together.
4. Visualize the results with Matplotlib: create a 4-row subplot (one per signal) showing the raw value, the detector's expected value, the confidence band (\(\hat{\mu} \pm k\hat{\sigma}\)), and red markers on flagged anomalies.
5. Experiment with tuning: try alpha=0.01 (slow adaptation) and alpha=0.3 (fast adaptation). Note how slow adaptation misses the end of the incident (it keeps flagging after recovery) and fast adaptation produces false positives during normal daily transitions. Find a value that correctly brackets the injected incident window.

Lab: Build a Log Cluster Investigator

Goal: Experience how log template extraction and frequency analysis surface incident-relevant messages from noisy production logs.
Tools: Python 3.10+, no external libraries required (uses only re, collections, and random).
Setup (5 min): Write a log generator that produces 10,000 log lines from 8 templates (health checks, request logs, cache hits, auth successes, DB queries, config reloads, retry warnings, timeout errors). Assign realistic frequencies: health checks at 40%, request logs at 30%, and the rest splitting the remainder. Timestamps should span a 2-hour window.
Incident injection (2 min): In the last 15 minutes of the window, increase timeout errors from 1% to 20% of all messages and introduce a new template that never appeared in the baseline: "Connection pool exhausted for host {IP}, active={NUM}".
Analysis (15 min): Implement the extract_template and LogAnalyzer classes from this section. Ingest the first 90 minutes as the baseline window and the last 15 minutes as the incident window. Call find_anomalous_templates with min_ratio=3.0.
What to vary: Try min_ratio values of 2.0, 3.0, and 5.0. Observe how lower ratios surface more templates (including borderline noise) while higher ratios may miss the timeout spike but still catch the completely new template.
What to observe: The analyzer should rank the new "connection pool exhausted" template first (infinite ratio, since it has zero baseline frequency) and the timeout error template second. Verify that routine templates (health checks, cache hits) do not appear in the anomaly list at any ratio setting above 2.0.

Exercises

Exercise 21.2.1: Golden Signal Dashboard (Conceptual)

Design a monitoring dashboard for a three-service architecture (API gateway, recommendation engine, database). For each service, specify which golden signals to display, what visualization type to use (line chart, heatmap, counter), what alert thresholds to set, and how to handle the cascading failure case where the database slowing down causes latency spikes in both upstream services. How would you distinguish "database is slow" from "recommendation engine is slow" using trace data?

Exercise 21.2.2: Anomaly Detector Tuning (Coding)

Implement the AnomalyDetector with seasonal adjustment for a metric that has a strong daily pattern (high during business hours, low at night). Generate synthetic data with a 24-hour cycle and inject three types of anomalies: a sudden spike, a gradual drift, and a missing data gap. Tune the alpha and threshold_sigma parameters to detect all three anomaly types with fewer than 5% false positives. Plot the detector's expected value, confidence bands, and flagged anomalies.

Exercise 21.2.3: Postmortem Quality Audit (Analysis)

Take the postmortem generator output for a synthetic incident and evaluate it against Google's postmortem criteria: Is it blameless? Are action items specific and assigned? Does it distinguish root cause from contributing factors? Does it identify what went well? Write a rubric with 10 criteria, score the generated postmortem, and identify which criteria the AI handles well and which require human revision.