At 2:47 AM last December, our on-call channel lit up with a 0.4% bump in 5xx errors. The overall error rate looked harmless on the dashboard, but that tiny percentage translated to 80,000 failed requests per minute for a single video customer in Southeast Asia. Traditional alerts didn't catch the blast radius until their support team called us. That's the moment I stopped trusting global error rates and started mining the logs for patterns instead.
Why Global Error Rates Hide the Real Failures
Most CDN monitoring still relies on threshold-based error detection: if 5xx > 1% for five minutes, page someone. It works for total outages, but it completely misses the fragmented, low-volume failures that make up 90% of what your customers actually feel. I've seen a PoP in Frankfurt serve 503s to only iOS users requesting HLS segments with a specific query string, while the global error rate stayed flat at 0.2%. Without error pattern mining, those slices never surface until a customer reports them.
The problem is dimensionality. Every access log line carries 30+ fields — PoP ID, cache status, origin response time, TLS version, user-agent family, request path, edge status code, origin status code — and failures cluster in combinations of those fields, not in any single one. If you're just aggregating by status code, you're collapsing the structure that points to the cause. We've had far better results treating CDN Real-Time Log Streaming Architecture: From Edge Events to Analytics in Seconds as the prerequisite: you can't mine patterns if your logs arrive 15 minutes late or sampled down to 1%. You need high-cardinality, near-real-time streams to make root cause analysis possible at all. For the HTTP semantics themselves, the HTTP Working Group Specifications are still the best reference for distinguishing what an edge 502 versus a 504 really means.
# Normalized CDN access log -> structured features for mining
# Don't mine raw URLs; mine templates and feature combinations
{
"timestamp": "2024-12-10T02:47:12Z",
"pop": "sin1",
"edge_status": 502,
"origin_status": 0,
"cache_status": "MISS",
"origin_tTFB_ms": 0,
"x_cache_key": "/video/1080p/seg_*.ts?token=*",
"tls_version": "TLSv1.3",
"ua_family": "iOS",
"path_template": "/video/{res}/seg_{num}.ts"
}
# Template extraction keeps cardinality manageable
# Example: Drain algorithm for path_template generation
# Input: /video/1080p/seg_12345.ts?token=abc123
# Output: /video/{res}/seg_{num}.ts?token=*
Building a Pattern Miner That Finds Signal, Not Noise
Early on, we tried simple frequent itemset mining on error logs and drowned in noise. An Apriori run over a five-minute window with 40 million requests returned 12,000 "patterns" — most were just common combinations like `POP=iad1 AND cache_status=HIT` that happened to co-occur with errors. The trick isn't finding frequent patterns, it's finding discriminative ones. We now contrast error logs against a baseline of successful requests from the same window. If a combination appears 500 times in errors and 50 times in successes, that's interesting. If it appears 500 times in both, it's just traffic.
Our current pipeline runs every 60 seconds and looks like this: first, we template high-cardinality fields (URL paths, user-agents, cache keys) to keep the feature space under control. Second, we run a streaming FP-Growth variant limited to itemsets that contain at least one error-specific feature (like `edge_status=502` or `origin_status=0`). Third, we score each pattern by lift and Fisher's exact test, not just raw support. Lift tells you how much more likely the pattern is in errors versus successes, which is what you need for automated log diagnosis. I learned this the hard way after we spent a week chasing a "pattern" that was just our busiest PoP doing what it always does.
Sampling matters here more than you'd think. You can't store 100% of logs forever, but naive random sampling will delete the exact tail patterns you're hunting. We've had good luck with stratified sampling that preserves all 4xx/5xx and a weighted reserve of cache MISSes, as described in CDN Log Sampling Strategies: Reducing Storage Costs Without Losing Signal. And if your collection tier drops events under load, mining is pointless — you'll be diagnosing the gaps in your pipeline. That collection reliability is why CDN Log Aggregation Architecture: Collecting Billions of Events Without Dropping Data has to be solid before you add any intelligence on top.
# PySpark sketch: discriminative pattern mining for CDN errors
from pyspark.ml.fpm import FPGrowth
# errors and baseline are DataFrames of itemsets per request
# e.g., ["pop=sin1", "edge=502", "cache=MISS", "path=/video/{res}/*"]
fpg = FPGrowth(itemsCol="items", minSupport=0.001, minConfidence=0.6)
error_model = fpg.fit(errors)
# Score patterns by lift vs baseline frequency
# lift = P(pattern|error) / P(pattern|baseline)
patterns = error_model.freqItemsets.join(baseline_freq, on="items") \
.withColumn("lift", col("freq_error") / col("freq_baseline")) \
.filter((col("lift") > 5.0) & (col("p_value") < 0.001)) \
.orderBy(col("lift").desc())
# Emit top-k patterns to root cause classifier
From Mined Patterns to Root Cause Diagnosis
A pattern alone isn't a root cause. `pop=sin1 AND edge_status=502 AND origin_status=0 AND origin_tTFB=0` is a pattern; "origin TCP connection timeout due to security group change in ap-southeast-1" is a diagnosis. The last mile is a rule layer that maps pattern signatures to known failure modes, enriched with origin health and config change feeds. We maintain a library of about 40 signatures we’ve curated over the years. New patterns that don't match anything get flagged for human review and, if confirmed, become new rules. It's not fully unsupervised — you don't want the system inventing causes.
This is where response header semantics and caching behavior really help narrow things down. For instance, distinguishing a genuine origin 502 from an edge-generated 502 due to a collapsed forwarding timeout requires looking at `origin_status`, `cache_status`, and `origin_tTFB` together. The web.dev Articles on caching and `stale-while-revalidate` saved us once when we misclassified a whole class of 503s that were actually expected during revalidation. Pairing pattern features with that kind of domain knowledge is what turns error detection into actual root cause analysis.
We track how well this works not by precision alone, but by time-to-diagnosis and operator load. Before automated mining, our median time to isolate the affected PoP + path + origin combo was around 18 minutes. Now it’s under two. More importantly, we’ve cut the number of pages that turn out to be false clusters by more than half.
| Error Pattern Signature (Mined Itemset) | Typical Volume Share During Incident | Diagnosis Accuracy (Precision) | Median Time to Root Cause |
|---|---|---|---|
| edge=502, origin_status=0, origin_tTFB=0ms, pop=sin1/sin2 | 0.35% global, 94% of sin1 errors | 98.1% | 68 seconds |
| edge=503, cache_status=MISS, origin=429, path=/api/* | 0.12% global, 81% of API 5xx | 92.4% | 95 seconds |
| edge=504, origin_tTFB > 5000ms, cache=MISS, origin_tier=primary | 0.08% global, 73% of timeouts | 89.7% | 121 seconds |
| edge=403, tls_version=TLSv1.0/1.1, ua_family=LegacyBot | 0.45% global, 99% of TLS errors | 99.3% | 42 seconds |
| edge=502, cache_status=HIT, age > 86400s | 0.02% global, 61% of stale-cache 502s | 85.2% | 140 seconds |
Putting the Miner Into Production Without Getting Buried in Alerts
It’s one thing to mine patterns in a Jupyter notebook, it’s another to run it every 5 minutes on a firehose of edge logs without waking everyone up at 3 AM. We’ve learned the hard way that you don’t want to run your miner on raw access logs directly. You’ll want a lightweight pre-aggregator sitting close to your log shipper — Vector, Fluent Bit, whatever you’re using — that buckets requests into 60-second windows and emits only the failed requests with a compact feature vector: PoP, cache status, origin IP, ASN, status code, `x-edge-result`, and request path prefix. On a typical mid-size property doing ~40M requests per hour, that filtering alone cuts your input volume by 98.5% if your baseline error rate is 1.5%, and it keeps the miner’s memory footprint under 4GB.
The trick that actually made ours reliable was adding a two-stage threshold. First, we only mine windows where the error rate for that PoP or property deviates by more than 3-sigma from its 7-day rolling baseline. No anomaly, no mining. Second, inside those anomalous windows we mine with a minimum support of 0.015 and confidence of 0.85, but we also require a lift > 3.5. That kills the ubiquitous patterns like `{cache_status=MISS}` that show up everywhere and aren’t diagnostic. If you’re trying to correlate these error bursts with user behavior, you’ll get a lot more context by joining your error patterns against behavioral data — see Clickstream Analysis at CDN Edge: Understanding User Navigation Patterns for how we structure that join without exploding cardinality.
Once you’ve got a pattern, don’t just log it — score it and route it. We push every mined pattern through a determinism score: `support(pattern -> error) / support(pattern)`. Anything above 0.92 gets auto-ticketed with the runbook attached. Anything between 0.75 and 0.92 goes to Slack with context. Below that, we just store it for weekly review. This cut our actionable alerts from ~120 per day to about 9, and 7 of those 9 were true positives requiring intervention last quarter. Here’s the stripped-down core of our streaming miner:
# streaming pattern miner - runs every 60s on anomalous windows
from collections import Counter
import pandas as pd
from mlxtend.frequent_patterns import fpgrowth, association_rules
# df is filtered to failed requests in an anomalous 60s window
# features are discretized: e.g., path_prefix, pop, cache_status, origin_dc
basket = pd.get_dummies(df[["pop", "cache_status", "origin_dc", "path_prefix", "edge_result"]])
# mine frequent itemsets - support is relative to *failed* requests in window
frequent_itemsets = fpgrowth(basket, min_support=0.015, use_colnames=True, max_len=4)
# generate rules where consequent is {is_error=1} is implicit, so we use lift/confidence
# lift > 3.5 means pattern is 3.5x more likely to co-occur with errors than random
rules = association_rules(frequent_itemsets, metric="confidence", min_threshold=0.85)
actionable = rules[rules['lift'] > 3.5].sort_values("lift", ascending=False)
# deterministic failure: pattern almost always = error
for _, row in actionable.iterrows():
determinism = row['confidence'] # P(error | pattern)
if determinism > 0.92:
trigger_runbook(row['antecedents'], determinism, row['lift'])
Advanced Patterns and Troubleshooting When Status Codes Lie
If you’ve been doing this long enough, you know the nastiest outages don’t show up as 5xx. They show up as 200s that are actually failures — 0-byte bodies, truncated JSON, or stale cache hits that should have been revalidated. Our miner caught a 14-hour incident where `status=200, cache_status=HIT, age > 86400, content_length < 500` correlated 100% with checkout API failures. Origin was returning 200 with an empty body due to a broken serializer, and the edge was happily caching it. Global error rate? Still 0.4%. But pattern support for that signature was 0.022 with 0.98 confidence within `pop=iad, lhr, and sin` only. You’ll never find that with status code dashboards.
Another pattern we now hunt for explicitly is the retry storm. You’ll see it as `{pop=sea, edge_result=origin_error, retry_count=1, status=502}` spiking, but the real tell is the *absence* of a pattern. When origin concurrency limits are hit, the log pattern fragments — you get dozens of low-support patterns across many paths instead of one clean one. If your miner suddenly returns 30+ patterns each with support 0.008-0.012 and the same origin IP, don’t chase each path. That’s your cue to check origin connection queue depth and thread pool saturation. We’ve also found it invaluable to overlay error patterns onto conversion paths; a 0.8% error rate on `/api/cart/add` looks minor until you realize it breaks a journey that drives 34% of revenue. That’s where User Journey Analytics from CDN Logs: Mapping the Path to Conversion and Conversion Attribution from CDN Edge: First-Party Tracking Without Client-Side Scripts become critical for prioritizing which patterns you fix first.
Finally, watch out for clock skew and poisoned baselines. If you deploy a new PoP and your baseline model still thinks its traffic should be zero, every request looks anomalous for the first 48 hours. We freeze baseline learning for 72 hours after any PoP or origin change and use a static threshold (error rate > 1.2% absolute) instead. And if your miner starts flagging `{user_agent=healthchecker}` every morning at 08:00 UTC, it’s not an error — it’s your synthetic monitor hitting a non-existent path. Exclude synthetic ASNs and known probe UAs before mining, or you’ll spend a week debugging your own monitoring.
Frequently Asked Questions
How much log volume do we need before pattern mining is useful?
You don't need petabytes. We've gotten stable patterns from as little as 5 minutes of logs (~200k requests) during an incident, as long as you have at least 500-800 failed requests in the window. Below ~300 errors, support thresholds become noisy and you'll get spurious patterns with lift that looks great but doesn't reproduce. For steady-state baselining, give it 14 days of edge logs to learn per-PoP and per-path seasonality.
Should we mine on status codes or edge result fields?
Mine on both, but prioritize edge result fields like `x-cache`, `x-edge-result`, `origin_status`, and `cache_lock`. Status codes alone are too lossy. A 502 from `origin_unreachable` vs. `origin_timeout` vs. `ssl_handshake_failure` are three completely different runbooks, but they'll all look like "502 rate up" on a dashboard. Include the edge result in your feature vector and let the miner separate them for you.
Why does our miner flag patterns that aren't actually errors?
Almost always it's low lift or confounding traffic. A pattern like `{path=/static/*.woff2, pop=nrt}` might have 0.9 confidence for 200s, not errors, if you accidentally mined successful requests too. Or you've got a high-cardinality field like full URL or `x-request-id` in your itemset — that will create millions of unique, low-support patterns that look significant. Stick to discretized, low-cardinality features (path prefix, PoP, ASN group, status class) and enforce lift > 3.0.
Can we use this same approach for origin-side logs?
Absolutely, and you should. We run a second miner on origin ALB/nginx logs with features like upstream response time bucket, worker PID, and DB shard. When we correlate an edge pattern `{edge_result=origin_timeout, pop=fra}` with an origin pattern `{upstream_time>2s, db_shard=03}`, we've got our root cause in under 2 minutes instead of 40. Just keep the miners separate — don't try to join edge and origin logs row-for-row in real time, you'll add 8-12 seconds of join latency and miss your SLO.