RECIPE · 08

Anomaly detection

Goal: you have a timestamped metric — CPU, latency, request rate, error count — and you want to know when it went wrong without hand-tuning a fixed red-line. A CPU box that idles at 20% is fine at 20% and alarming at 96%, but the "alarming" number depends on the box. You want the engine to learn the normal band and flag the minute it broke out.

XERJ ships an on-demand anomaly detector (_ml/anomaly_detectors, Elasticsearch wire-shape). You point it at an index, tell it the time field, the metric (mean(cpu)), and a bucket span. When you score it, XERJ buckets the data by time, computes the metric per bucket, builds a moving baseline from prior normal buckets, and scores each bucket by how many standard deviations it sits from that baseline. A spike lands many sigmas out and gets flagged; ordinary jitter does not.

Honest scope. This is on-demand batch scoring: you POST _score and get the full timeline back in one response. There is no continuous datafeed or background job yet — nothing runs on a timer, and there is no alerting. To monitor live, call _score on a schedule (cron, your alertmanager, a /loop) over a recent time window and act on anomaly_count. The model is a transparent moving-window z-score, not a seasonal/multivariate ML model — it's excellent for "this metric jumped relative to its recent self," which covers the large majority of ops spikes.

The scenario

cpu_metrics collects four host readings per minute. For fifteen minutes CPU sits around 20%. At minute 12 a runaway process pins a box and the per-minute mean jumps to 96%, then recovers. We want XERJ to flag exactly that minute.

1. Map the index

The time field must be a date; the metric must be numeric so the detector can average it.

curl -XPUT localhost:9200/cpu_metrics -H 'Content-Type: application/json' -d '{
  "mappings": { "properties": {
    "@timestamp": { "type": "date" },
    "host":       { "type": "keyword" },
    "cpu":        { "type": "double" }
  }}
}'

2. Bulk-ingest the series (spike injected at minute 12)

Standard _bulk NDJSON — 16 minutes × 4 hosts. Normal minutes read ~20%; minute 12 reads ~96%. (Full generator in the script below.)

{"index":{"_index":"cpu_metrics"}}
{"@timestamp":"2026-07-06T00:00:00Z","host":"web-0","cpu":19}
...
{"index":{"_index":"cpu_metrics"}}
{"@timestamp":"2026-07-06T00:12:00Z","host":"web-1","cpu":96}   <- the spike
...

3. Create the detector

function: mean over the cpu field, one-minute buckets. anomaly_threshold is the z-score gate (defaults to 3.0 — a bucket is flagged when it sits more than 3σ from the baseline).

curl -XPUT localhost:9200/_ml/anomaly_detectors/cpu-spike \
  -H 'Content-Type: application/json' -d '{
    "source_index": "cpu_metrics",
    "time_field":   "@timestamp",
    "function":     "mean",
    "field":        "cpu",
    "bucket_span":  "1m",
    "description":  "CPU mean per minute"
  }'

Response (the config, ES-shaped):

{
  "job_id": "cpu-spike",
  "job_type": "anomaly_detector",
  "analysis_config": {
    "bucket_span": "1m",
    "detectors": [{ "function": "mean", "field_name": "cpu" }]
  },
  "data_description": { "time_field": "@timestamp" },
  "source_index": ["cpu_metrics"],
  "anomaly_threshold": 3.0
}

Supported function values: count (no field needed — buckets by doc count, great for request/error rate), mean/avg, min, max, sum.

4. Score it

curl -XPOST localhost:9200/_ml/anomaly_detectors/cpu-spike/_score

XERJ returns the whole timeline plus a ranked anomalies list. Rendered per bucket:

minute (UTC)           actual  expected  z_score   score  flag
00:00:00                 20.0         -        -     0.0        <- warm-up
00:01:00                 21.0         -        -     0.0        <- warm-up
00:02:00                 19.0         -        -     0.0        <- warm-up
00:03:00                 20.0         -        -     0.0        <- warm-up
00:04:00                 22.0      20.0      2.8    48.0
00:05:00                 18.0      20.4     -2.4    41.9
00:06:00                 21.0      20.0      0.8    16.4
00:07:00                 20.0      20.1     -0.1     2.6
00:08:00                 19.0      20.1     -1.0    20.0
00:09:00                 21.0      20.0      0.9    18.1
00:10:00                 20.0      20.1     -0.1     2.0
00:11:00                 22.0      20.1      1.8    33.5
00:12:00                 96.0      20.2     65.1   100.0   ***  <- SPIKE
00:13:00                 20.0      20.2     -0.2     4.8
00:14:00                 21.0      20.2      0.7    14.7
00:15:00                 19.0      20.3     -1.2    23.7

== anomalies (1 of 16 buckets) ==
  2026-07-06T00:12:00.000Z  actual=96.0 expected=20.2  z=65.1  score=100.0

The spike record in full:

{
  "actual": 96.0,
  "expected": 20.25,
  "std_dev": 1.1636866703140785,
  "z_score": 65.09484205018445,
  "anomaly_score": 100.0,
  "is_anomaly": true,
  "timestamp_iso": "2026-07-06T00:12:00.000Z"
}

How the scoring works (and how to read the numbers)

Narrow or re-tune a run without recreating the job

_score accepts two per-run overrides in the POST body:

# Same data, stricter gate (10σ) — the 65σ spike still stands alone.
curl -XPOST localhost:9200/_ml/anomaly_detectors/cpu-spike/_score \
  -H 'Content-Type: application/json' -d '{"anomaly_threshold": 10}'
# -> "anomaly_threshold": 10.0, "anomaly_count": 1

Count instead of mean (rate spikes)

For "too many errors this minute" you don't need a metric field — use function: count, drop field, and point the detector at your log index. Each bucket's actual becomes the doc count, scored the same way. Same recipe, same response shape.

List detectors

curl localhost:9200/_cat/ml/anomaly_detectors
# cpu-spike opened cpu_metrics mean 1m

GET /_ml/anomaly_detectors/{id} and DELETE /_ml/anomaly_detectors/{id} round out lifecycle management.

Reproduce it yourself

Start XERJ on its default port (9200) and run the example — no keys, no external services, stdlib Python only:

# 1. Start a throwaway XERJ (ES-compat wire on :9200 by default)
xerj --insecure --data-dir ./data

# 2. In another shell, run the demo (honors $XERJ_URL, default http://localhost:9200)
python3 docs/examples/anomaly-detection/anomaly_detection.py

Point it at a non-default host/port with XERJ_URL=http://host:port (the legacy XERJ=... variable still works as an alias). The data is generated in-script with a fixed spike, so every run prints the same numbers:

== detector created ==
{ "job_id": "cpu-spike", "function": "mean", "bucket_span": "1m", "anomaly_threshold": 3.0 }

00:12:00                 96.0      20.2     65.1   100.0   ***   <- the spike

== anomalies (1 of 16 buckets) ==
  2026-07-06T00:12:00.000Z  actual=96.0 expected=20.2  z=65.1  score=100.0

OK: spike at 00:12 flagged (score 100.0); 11 normal buckets, none flagged (top normal score 48.0).

== spike record (full precision) ==
{ "actual": 96.0, "expected": 20.25, "std_dev": 1.1636866703140785,
  "z_score": 65.09484205018445, "anomaly_score": 100.0, "is_anomaly": true }

== stricter re-score (anomaly_threshold=10) ==
  anomaly_threshold=10.0  anomaly_count=1

== _cat/ml/anomaly_detectors ==
  cpu-spike opened cpu_metrics mean 1m

The script maps the index, ingests the series with the injected spike, creates the detector, scores it, prints the table above, and asserts the result: exactly one anomaly, at 00:12, score 100.0, mean 96.0, 65σ out — and no normal bucket flagged (the spike's score is 100.0 vs. a top normal of 48.0, clean separation). It exits non-zero if any of that regresses, so it doubles as a smoke test.

Full example script on GitHub: docs/examples/anomaly-detection/anomaly_detection.py.

Endpoint cheat-sheet

MethodPathPurpose
PUT/_ml/anomaly_detectors/{id}create/replace a detector
GET/_ml/anomaly_detectors/{id}fetch one detector's config
GET/_ml/anomaly_detectorslist all detectors
DELETE/_ml/anomaly_detectors/{id}remove a detector
POST/_ml/anomaly_detectors/{id}/_scorescore now; body may set query, anomaly_threshold
GET/_cat/ml/anomaly_detectorsone line per detector

Config fields: source_index (required), time_field (default @timestamp), function (count/mean/min/max/sum, default count), field (required for everything but count), bucket_span (default 1h), anomaly_threshold (z-gate, default 3.0), description. ES-style analysis_config.detectors[] and data_description.time_field are also accepted for drop-in configs.