
Kibana Anomaly Detection
- 1.4k installs
- 546 repo stars
- Updated July 22, 2026
- elastic/agent-skills
kibana-anomaly-detection is an agent skill that investigates, explains, troubleshoots, and manages Elastic ML anomaly detection jobs through Kibana Agent Builder MCP ad_* tools.
About
The kibana-anomaly-detection skill covers all Elastic ML anomaly detection work through Kibana Agent Builder MCP at the agent_builder/mcp endpoint. A mode selector routes questions to Investigate for RCA and blast radius, Explain for score drift and renormalization, Troubleshoot for datafeed stops and memory limits, or Manage for job and datafeed creation. It reads .ml-anomalies-*, .ml-config, .ml-notifications-*, and .ml-annotations-* via ES|QL and ad_* tools. Score bands treat record_score above 75 as critical and multi_bucket_impact at or above 3 as sustained shifts. Investigate mode follows a 14-step protocol ending in written RCA with root cause entity, affected jobs, and ad_rca_source_evidence confirmation. Troubleshoot fixes memory hard_limit before query_delay and uses stop-close-update-open-start lifecycle for config changes. Manage mode builds job plus datafeed JSON with smart defaults for bucket_span, query_delay, and detector functions.
- Four modes: Investigate, Explain, Troubleshoot, and Manage.
- ad_validate_ml_tool_permissions first on empty results.
- RCA protocol ranks multi-job entities with min_job_count=2.
- Score explanation compares initial_record_score vs record_score.
- Job creation workflow: create job, datafeed, open, start datafeed.
Kibana Anomaly Detection by the numbers
- 1,399 all-time installs (skills.sh)
- +162 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #292 of 1,039 Cloud & Infrastructure skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
kibana-anomaly-detection capabilities & compatibility
- Capabilities
- cross job rca with ad_rca_multi_job_entities · score drift and renormalization explanation · memory limit and query_delay troubleshooting · ml job and datafeed creation · es|ql queries on .ml anomalies * indices
- Use cases
- debugging · devops · data analysis
npx skills add https://github.com/elastic/agent-skills --skill kibana-anomaly-detectionAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1.4k |
|---|---|
| repo stars | ★ 546 |
| Last updated | July 22, 2026 |
| Repository | elastic/agent-skills ↗ |
What broke in my ML anomaly detection results, why is the score high or low, or how do I fix a stopped datafeed?
Investigate, explain, troubleshoot, or create Elastic ML anomaly detection jobs via Kibana Agent Builder MCP ad_* tools on .ml-anomalies-* indices.
Who is it for?
SREs and Elastic operators working with Kibana 8.x-9.x ML anomaly detection and Agent Builder MCP.
Skip if: Skip for legacy ECS kubernetes.* indices or APM-only service SLO analysis outside ML anomaly context.
When should I use this skill?
User asks what broke, why an anomaly score changed, datafeed stopped, memory limit hit, or to create an ML detector job.
What you get
A mode-appropriate RCA report, score explanation, troubleshooting fix, or deployed anomaly detection job with validated config.
- Kibana ML anomaly detection jobs
- anomaly alerts for production metrics
By the numbers
- 332 installs on skills.sh
- skills.sh rank 34
Files
Elastic ML Anomaly Detection
Single skill covering all anomaly detection work against Kibana Agent Builder MCP at {KIBANA_URL}/api/agent_builder/mcp. Use the Mode Selector below to pick the right approach for the user's question — modes share the same tool surface and concepts.
Platform
- Read path: ES|QL against
.ml-anomalies-*,.ml-config,.ml-notifications-*,.ml-annotations-* - Always-available:
platform.core.execute_esql(plus additional platform tools for search, index mapping, and
documentation — see scripts/agent_builder_constants.json)
- ML API spec (if available):
.kibana_ai_openapi_spec_elasticsearch— see
references/anomaly-detection-openapi-spec-discover.md for discovery pattern.
- Run `ad_validate_ml_tool_permissions` first when tools return empty/misleading results — missing privileges are
the most common cause of false negatives. Full permissions matrix: references/permissions-matrix.md.
Mode Selector
| User intent | Mode |
|---|---|
| "What broke?" / RCA / cross-job / blast radius / influencers / log categories | Investigate |
| "Why score high/low?" / renormalization / model bounds / forecasts | Explain |
| Missing docs / memory limit / datafeed stopped / CCS / lifecycle / calendars | Troubleshoot |
| Create a job / configure a datafeed / start analysis / retrieve results | Manage |
| Security framing (attack chains, MITRE, exfil) | Investigate + references/security-anomaly-expert.md |
| Observability/SRE framing (degradation, capacity, deployment regression) | Investigate + references/observability-anomaly-expert.md |
When a question spans modes: Investigate → Explain → Troubleshoot. Don't blend mode logic — finish one before moving on.
---
Score Quick Reference
record_scorebands: >75 critical · 50–75 warning · 25–50 minor · <25 informationalmulti_bucket_impact ≥ 3→ sustained shift (not a transient spike)initial_record_score >> record_score→ renormalization (model saw worse anomalies later)actual << typicalwithcount/low_count/low_mean→ absence/outage, not just low value- Low scores across many jobs > one high score — composite cross-job signal often beats single-detector severity
Full score definitions, renormalization mechanics, and anomaly_score_explanation components:references/score-reference.md.
Core concepts
Treat .ml-anomalies-* as three layers, accessed via result_type:
- `bucket` — bucket-level unusualness per
bucket_span.anomaly_scoreis the aggregate across all detectors. - `record` — finest-grained rows with
actualvstypical,probability,record_score,
anomaly_score_explanation.
- `influencer` — entity contributions ranked within a bucket (
influencer_score).
Read scores this way:
anomaly_score/record_score= current normalized values (move as the model sees new extremes).initial_anomaly_score/initial_record_score= immutable snapshots from detection time.- Compare
actualtotypical; useprobabilityfor raw likelihood. - Map entities via
partition_field_value/by_field_value/over_field_value. - Read
multi_bucket_impact(-5 to +5) to separate single-bucket spikes from sustained trends.
---
Mode: Investigate — RCA
When: "what broke?", "which entity caused this?", cross-job correlation, blast radius, attack/cascade chains.
Tool chain
| Phase | Tools |
|---|---|
| Discovery | ad_get_available_metadata, ad_get_jobs, ad_discover_related_jobs, ad_discover_jobs_by_datafeed_index |
| Timeline / scope | ad_query_anomaly_timeline |
| Cross-job / entities | ad_rca_cross_job_entity_match, ad_rca_multi_job_entities, ad_rca_entity_profile |
| Records / influencers | ad_query_anomaly_records, ad_query_influencers |
| RCA depth | ad_rca_detector_fingerprint, ad_rca_correlation, ad_rca_blast_radius, ad_rca_score_reassessment |
| Evidence / categories | ad_get_job_datafeed_config, ad_rca_source_evidence, ad_get_categories, ad_search_log_category_examples |
Protocol
Follow the 14-step sequence in references/protocols/investigation.md. High level: ad_get_available_metadata → pair ad_discover_jobs_by_datafeed_index with ad_discover_related_jobs → ad_query_anomaly_timeline → rank with ad_rca_multi_job_entities (min_job_count=2) → ad_rca_detector_fingerprint → drill with ad_query_anomaly_records + ad_query_influencers (low min_score=25) → profile with ad_rca_entity_profile → order with ad_rca_correlation → confirm with ad_rca_source_evidence. When by_field_name == "mlcategory", compare with ad_get_categories + paired ad_search_log_category_examples (baseline vs. anomaly window).
Finish with a written RCA: root cause entity · affected jobs · temporal progression · fault class (resource/network/application) · severity · recommended actions. Worked example: references/worked-example.md. Full ES|QL templates and parameters: references/investigate-anomaly-esql-tools.md.
Rules
1. Multi-job entities are prime suspects; single-job entities are usually victims. Use min_job_count=2. 2. Earliest anomaly timestamp wins — sort ad_rca_correlation by timestamp; first-appearing entity = origin. 3. `multi_bucket_impact ≥ 3` = sustained behavioral shift, weight higher than transient spikes. 4. Never close an RCA without `ad_rca_source_evidence` — raw source documents are ground truth. 5. Use low `min_score` (25 or lower) for influencer queries — high thresholds miss correlated entities.
---
Mode: Explain — Score / model behavior
When: "why is my score 30/90?", "score dropped overnight", "what is renormalization?", "why wasn't this detected?".
Score types
| Field | Scope | Meaning |
|---|---|---|
record_score | Single record | Normalized severity after renormalization. |
initial_record_score | Single record | Score at detection time. Gap vs record_score = renormalization drift. |
anomaly_score | Bucket | Aggregate severity across all detectors in a bucket. |
influencer_score | Entity × bucket | How anomalous a specific entity is in that bucket. |
anomaly_score_explanation components
| Component | Effect | What it means |
|---|---|---|
anomaly_length | ↑ score | More consecutive anomalous buckets |
single_bucket_impact | ↑ score | Lower probability → higher impact |
multi_bucket_impact | ↑ score | Sustained pattern contribution |
anomaly_characteristics_impact | ↑ score | Mean shift vs. variance change |
high_variance_penalty | ↓ score | Noisy data → wide bounds → anomaly less surprising |
incomplete_bucket_penalty | ↓ score | Bucket has less data than expected (ingest lag, sparse data) |
Why a score looks wrong
- Unexpectedly low:
high_variance_penalty, renormalization, <3 weeks training for weekly seasonality,
bucket_span too large, wrong detector function (mean vs high_mean), incomplete_bucket_penalty, suppression by custom_rules.
- Unexpectedly high: insufficient history (early training over-flags), high-cardinality split (too few points per
entity), use_null: true on a sparse field.
Tool chain
| Purpose | Tools |
|---|---|
| Records + explanation | ad_query_anomaly_records (exact job_id_pattern) |
| Renormalization drift | ad_rca_score_reassessment (score_drift = initial_record_score - record_score) |
| Model bounds (visual) | ad_get_model_plot — actual outside model_lower/model_upper = anomaly |
| Forecast overlap | ad_get_forecast_results |
| Influencer attribution | ad_query_influencers |
| Config & detector | ad_get_job_datafeed_config — bucket_span, function, custom_rules, use_null |
| Categorization | ad_get_categories |
| Model snapshots | ad_get_model_snapshots |
| Structured diagnostic | `ad_wf_troubleshoot_anomaly_score` (full decision tree) |
Decision tree (ad_wf_troubleshoot_anomaly_score)
1. ad_get_jobs — ≥3 weeks data for weekly seasonality? 2. ad_ts_model_memory_health — memory_status healthy? 3. ad_ts_delayed_data_annotations — no incomplete buckets? 4. ad_query_anomaly_records — compare record_score vs initial_record_score. 5. ad_get_job_datafeed_config — bucket_span, detector function, custom_rules, use_null. 6. ad_get_model_plot — wide bounds → high_variance_penalty. 7. ad_rca_score_reassessment — renormalization drift across history. 8. Explain anomaly_score_explanation factors.
Rules
1. Always show both `initial_record_score` and `record_score` — the gap is the renormalization story. 2. Explain renormalization before diagnosing config — score drift is the most common "score dropped" cause and needs no config change. 3. `actual << typical` with `count`/`low_count` is an absence anomaly — distinguish outages from value spikes. 4. `high_variance_penalty` and `incomplete_bucket_penalty` explain most "low score" surprises without remediation. 5. Weekly seasonality needs ≥3 weeks of training data — flag young jobs as the cause.
For detector function selection details, see references/anomaly-detection-functions.md.
---
Mode: Troubleshoot — Job ops
When: "missing documents", "datafeed stopped", "hard_limit", "results look wrong", lifecycle changes, calendars, CCS.
Common issues → fast paths
| Issue | Fast path | Full decision tree |
|---|---|---|
Missing docs / query_delay warning | ad_ts_delayed_data_annotations → ad_ts_bucket_event_gaps → ad_ts_ingest_latency_estimate → ad_update_datafeed_query_delay | ad_wf_troubleshoot_query_delay |
Memory soft_limit / hard_limit | ad_ts_model_memory_health → ad_wf_ts_field_cardinality → ad_estimate_memory_requirement → ad_update_model_memory_limit | ad_wf_troubleshoot_memory_limit |
| Datafeed not running / job state | ad_get_jobs (state) → ad_get_job_messages → ad_manage_datafeed | — |
CCS / remote_cluster: indices | ad_ts_ccs_diagnostics | — |
| Score sanity check | — | ad_wf_troubleshoot_anomaly_score |
hard_limit corrupts model state and causes downstream missing-doc false alarms (categorizer silently skips eventsfor unknown categories). Fix memory before fixing `query_delay`.
Memory concepts
| Field | Meaning |
|---|---|
model_bytes | Current memory used |
peak_model_bytes | High-water mark since job opened |
model_bytes_memory_limit | Configured model_memory_limit |
memory_status | ok / soft_limit (pruning) / hard_limit (critical) |
total_by_field_count > 100k | by_field cardinality too high — dominant driver |
total_partition_field_count > 10k | Partition explosion |
total_category_count > 10k | Too many distinct log patterns |
Prefer `ad_estimate_memory_requirement` (samples cardinality from source, calls Estimate Model Memory API) over heuristics like peak_model_bytes * 1.3 — the heuristic ignores pure influencer and categorization memory.
Datafeed & timing concepts
- `query_delay` — how far behind real time the datafeed queries. Too small → missing docs; too large → slower
alerts. Set to P95 ingest latency + buffer (default 60s–120s).
- `delayed_data_check_config` — how aggressively the datafeed checks for late data.
- `bucket_span` — analysis interval. Align with data granularity and detection window.
- `frequency` — defaults to
min(query_delay, bucket_span / 2).
Lifecycle for config changes (memory limit, query_delay)
1. Stop datafeed: ad_manage_datafeed (action=_stop) 2. Close job 3. Update config: ad_update_model_memory_limit, ad_update_datafeed_query_delay, ad_update_delayed_data_check_config 4. Open job: ad_open_job 5. Start datafeed: ad_manage_datafeed (action=_start)
Recover a corrupted period without resetting the whole model: ad_revert_model_snapshot.
Tool surface
| Category | Tools |
|---|---|
| Permissions / metadata | ad_validate_ml_tool_permissions, ad_get_available_metadata, ad_get_jobs |
| Job + datafeed state | ad_get_job_datafeed_config, ad_get_job_messages, ad_manage_datafeed, ad_preview_datafeed_with_latency |
| Timing / missing docs | ad_ts_delayed_data_annotations, ad_ts_bucket_event_gaps, ad_ts_ingest_latency_estimate, ad_update_datafeed_query_delay, ad_update_delayed_data_check_config, ad_wf_troubleshoot_query_delay |
| Memory | ad_ts_model_memory_health, ad_wf_ts_field_cardinality, ad_estimate_memory_requirement, ad_update_model_memory_limit, ad_wf_troubleshoot_memory_limit |
| Model / lifecycle | ad_get_model_snapshots, ad_revert_model_snapshot, ad_open_job, ad_create_job |
| CCS | ad_ts_ccs_diagnostics |
| Calendars | ad_get_calendar_events, ad_create_calendar_event |
Full parameter tables, ES|QL templates, and REST step lists: references/troubleshoot-anomaly-tool-reference.md.
Rules
1. `ad_validate_ml_tool_permissions` first — missing privileges produce misleading empty results. 2. Fix memory before `query_delay` — hard_limit corrupts state; query_delay fixes on a memory-limited job are wasted. 3. Stop the datafeed before updating it. Updating a running datafeed is rejected. 4. Close the job before updating memory limit. Sequence above. 5. *Prefer workflow tools (`ad_wf_) over manually chaining diagnostics** for complex decisions. 6. **ad_preview_datafeed_with_latency` before starting** — confirm the datafeed returns data after config changes.
---
Mode: Manage — Create / configure jobs
When: "set up a job", "create an ML detector", "monitor X over time", "detect rare/unusual/anomalous values".
4-step workflow
PUT _ml/anomaly_detectors/<job_id> # 1. Define job (ad_create_job)
PUT _ml/datafeeds/datafeed-<job_id> # 2. Define datafeed (ad_create_datafeed)
POST _ml/anomaly_detectors/<job_id>/_open # 3a. Open job (ad_open_job)
POST _ml/datafeeds/datafeed-<job_id>/_start # 3b. Start datafeed (ad_manage_datafeed action=_start)
GET _ml/anomaly_detectors/<job_id>/results/records # 4. Read resultsProcess
1. Build configs. Parse the user request into job + datafeed JSON with no null fields. 2. Apply smart defaults:
| Field | Default | Override when |
|---|---|---|
bucket_span | "15m" | User specifies a different span |
time_field | "@timestamp" | User names a different timestamp field |
index | "logs-*" | User specifies an index or pattern |
datafeed_query | {"match_all": {}} | User mentions filters, processes, or time windows |
influencers | by/over/partition fields from detectors | User adds extra influencer fields |
job_id | Generated from user description | User provides an explicit ID |
query_delay | "60s" | P95 ingest latency is higher |
3. Choose detector function from user intent — full table in references/anomaly-detection-functions.md:
- "high CPU" / "unusually large" →
high_meanorhigh_sum - "rare logins" / "unusual values" →
rare(variants below) - "too many requests" / "spike in count" →
high_count
rare variants:
- Infrequent globally →
rare by_field_name: X - Infrequent vs peers →
rare by_field_name: X over_field_name: Y - Infrequent per segment →
rare by_field_name: X partition_field_name: Y - Infrequent per segment vs peers →
rare by_field_name: X over_field_name: Y partition_field_name: Z
4. Validate. platform.core.get_index_mapping on the target index to verify field existence/types → ad_validate_job_spec. If errors, fix and re-validate (max 3 attempts).
5. Present and confirm. Show the complete job + datafeed bodies formatted as the exact API calls. Ask for approval once. If feedback, incorporate and re-present (up to 3 rounds).
6. Deploy. After confirmation: ad_create_job → ad_create_datafeed → ad_open_job → ad_manage_datafeed (action=_start). Report final job_id and datafeed_id.
For batch analysis on historical data, pass start and end to the datafeed start call.
Worked examples (rare-username, DNS exfil, large-downloads) with full JSON bodies and datafeed filters:
references/job-creation-recipes.md.
Rules
1. Create job before datafeed. Datafeed references job by ID. 2. Open job before starting datafeed. Start on a closed job is rejected. 3. `query_delay` = P95 ingest latency + buffer (60s–120s safe default). 4. Forecasts require non-population jobs — over_field_name jobs cannot be forecasted; warn before attempting. 5. `by_field_name` vs `over_field_name`: by compares entity to its own history; over compares to peer group in the same bucket. partition_field_name = fully independent sub-model with its own normalization. 6. `bucket_span` matches detection granularity — 15m for high-frequency, 1h for operational metrics, 1d for daily patterns. Larger smooths short spikes; smaller increases noise.
---
Registration (Kibana Agent Builder)
Requires Node.js 18+. Defaults to elastic/changeme when no credentials are supplied.
cd skills/kibana/kibana-anomaly-detection
# tools → workflows → skills
node scripts/kibana-agent-builder.mjs all register --kibana-url http://localhost:5601
# HTTPS with self-signed cert
node scripts/kibana-agent-builder.mjs all register --kibana-url https://localhost:5601 --insecureall register runs tools register, then workflows register, then skills register. Kibana allows at most five tool_ids per skill; the script fills them by scanning SKILL.md for tool mentions (in document order), then appends ids from references/kibana/tools/esql/*.json until the cap (workflow-only tools omitted by default). If you run skills register alone, run tools register first so those ids exist.
Workflow tool exclusions and prefixes live in scripts/agent_builder_constants.json.
MCP API key permissions:
- Kibana:
read_onechat,space_read - Index:
read,view_index_metadataon.ml-anomalies-*,.ml-annotations-*,.ml-notifications-*,.ml-config - For source evidence:
readon source data indices
---
Tool inventory
ES|QL tool specs live under references/kibana/tools/esql/*.json; workflow definitions under references/kibana/workflows/*.yaml. Each Mode section above lists the tools it uses. Full surface: references/tools.md (ES|QL) and references/workflow-tools.md (workflows).
Key system indices
| Index | Relevant content |
|---|---|
.ml-anomalies-* | record, bucket, influencer, model_plot, model_forecast, model_snapshot, category_definition, model_size_stats |
.ml-config | job/datafeed documents (visible even for never-run jobs) |
.ml-annotations-* | delayed data (event == "delayed_data") |
.ml-notifications-* | job messages (level: info/warning/error) |
---
Examples
RCA: "Something caused a spike in our error rate at 2pm — what broke?" → Investigate → ad_get_available_metadata → ad_query_anomaly_timeline → ad_rca_cross_job_entity_match → ad_rca_multi_job_entities → RCA report.
Score drop: "My anomaly score went from 90 to 55 — did the model change?" → Explain → ad_rca_score_reassessment for drift → explain renormalization if score_drift is large.
Memory limit: "Job status shows hard_limit and results look wrong." → Troubleshoot → ad_ts_model_memory_health → ad_wf_ts_field_cardinality → ad_estimate_memory_requirement → ad_update_model_memory_limit (lifecycle: stop datafeed → close → update → open → start).
New job: "Detect unusual error rates per host on nginx access logs." → Manage → high_count detector with by_field_name: "host.keyword" → validate → present → deploy.
Multi-mode: "We had an incident last night, scores were high but now low — is the job healthy?" → Investigate the incident → Explain the score drift → Troubleshoot if hard_limit or delayed data is suspected.
---
Guidelines
1. Pick a mode first. Don't blend RCA logic with score-explanation logic in one response. 2. `ad_validate_ml_tool_permissions` first on empty results — privileges are the most common false-negative cause. 3. Score bands are absolute thresholds: >75 critical, 50–75 warning, 25–50 minor, <25 informational. 4. Multi-job entities are prime suspects. Use min_job_count=2 in ad_rca_multi_job_entities. 5. Show `initial_record_score` alongside `record_score` — the gap tells the renormalization story. 6. Fix memory before `query_delay`. hard_limit invalidates downstream diagnostics. 7. Stop datafeed → close job → update config → open job → start datafeed for any config change to memory or query delay. 8. Confirm RCAs with `ad_rca_source_evidence`. Raw source documents are ground truth.
{
"name": "kibana-anomaly-detection-skills",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "kibana-anomaly-detection-skills",
"version": "0.1.0"
}
}
}
{
"name": "kibana-anomaly-detection-skills",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"tools:register": "node scripts/kibana-agent-builder.mjs tools register",
"workflows:register": "node scripts/kibana-agent-builder.mjs workflows register",
"skills:register": "node scripts/kibana-agent-builder.mjs skills register",
"all:register": "node scripts/kibana-agent-builder.mjs all register"
},
"dependencies": {}
}
Elastic ML Anomaly Detection Functions
Functions marked with * support high/low one-sided variants (e.g., high_count, low_mean) to detect anomalies in only one direction.
---
Count Functions
Analyze the occurrence rate of events or documents over time.
| Function | Description |
|---|---|
count \* | Number of documents in a bucket |
non_zero_count \* | Like count but ignores zero-count buckets — use for sparse data |
distinct_count \* | Cardinality (uniqueness) of values for a specific field |
count vs non_zero_count
| Scenario | Use |
|---|---|
| Events arrive in every bucket (e.g., web traffic, heartbeats) | count — zero buckets are meaningful (outage signal) |
| Events arrive intermittently (e.g., batch jobs, error logs) | non_zero_count — zeros are expected, not anomalous |
| Detecting a drop to zero as an outage | count with low_count variant |
| Detecting bursts in normally sparse traffic | non_zero_count with high_non_zero_count variant |
Example: An intrusion detection log index gets events only when triggered. Using count means the model learns that zero-event buckets are normal overnight — making it impossible to distinguish a genuine quiet night from a monitoring gap. Use non_zero_count so the model only learns from buckets that had events, and flags when event volumes spike unexpectedly.
distinct_count
Counts the number of unique values for a field within each bucket. Useful for detecting credential stuffing (unusually high distinct usernames), data exfiltration (high distinct destination IPs), or DGA activity (high distinct DNS query names).
---
Metric Functions
Operate on numerical fields within the data.
| Function | Description |
|---|---|
min / max | Minimum or maximum value in a bucket |
mean \* | Average value |
median \* | Median value |
sum / non_null_sum \* | Total sum of a field; non_null_sum is for sparse data |
varp \* | Variance / volatility of a metric |
metric | Shorthand that applies min, max, and mean simultaneously |
Choosing the right metric function
| Goal | Function |
|---|---|
| Detect average latency spike | mean or high_mean |
| Detect worst-case latency (tail) | max |
| Detect sustained volume drop | low_sum |
| Detect unusual volatility (erratic metric) | high_varp |
| Detect both high and low deviations | mean (bidirectional, default) |
| Detect only spikes, not drops | high_mean |
| Monitor noisy metrics with outliers | median (more robust than mean) |
sum vs non_null_sum
Use non_null_sum when the field is frequently absent from documents (sparse). Like non_zero_count, it skips empty buckets so the model learns only from active periods.
metric shorthand
Creates three detectors in one: min, max, and mean on the same field. Convenient for a quick initial setup, but produces three anomaly records per detection event. Prefer explicit functions once you know which direction matters.
---
Advanced & Specialized Functions
Handle complex analysis types such as rarity or geographic data.
| Function | Description |
|---|---|
rare | Identifies values that occur at low frequency compared to the dataset |
freq_rare | Finds population members that cause rare values to occur frequently |
info_content \* | Entropy of text strings — useful for detecting encrypted/obfuscated commands |
lat_long | Detects unusual geographic locations from latitude/longitude coordinates |
time_of_day | Detects behavioral changes relative to time of day |
time_of_week | Detects behavioral changes relative to day of week |
---
rare vs freq_rare
These two are often confused but answer different questions:
| Function | Question answered | Requires over_field |
|---|---|---|
rare | "Which values of the by_field are unusual globally?" | No |
freq_rare | "Which entities (over_field) frequently cause rare values?" | Yes |
`rare` example: Detect rare process.name values across all hosts. If svchost.exe with unusual arguments appears only once in 30 days, rare flags it. The focus is the rarity of the _value_.
`freq_rare` example: Detect which _users_ (over_field) frequently trigger rare process executions (by_field). Most users run rare processes occasionally, but a user running rare processes consistently is a lateral movement signal. The focus is the entity's behavior pattern.
Practical guidance:
- Use
rarefor hunting unknown unknowns — values that shouldn't exist at all. - Use
freq_rarefor insider threat and lateral movement scenarios — who is repeatedly doing unusual things. raregenerates high false-positive rates in noisy environments; use custom rules to suppress known-good rare values.freq_rarerequires anover_field(population) — without it, userare.
Types of rare analysis
Translate business goals to the correct rare detector configuration:
| Goal | Example | Detector config |
|---|---|---|
| Find infrequent values for a field | Detect hosts occurring infrequently | rare by_field_name: host |
| Find infrequent values compared to peers | Detect hosts visited by few users vs. other hosts; detect users visiting rare hosts | rare by_field_name: host over_field_name: user |
| Find infrequent values segmented by another field | Per location, detect infrequently seen hosts | rare by_field_name: host partition_field_name: location |
| Find infrequent values by another field compared to peers, segmented | Per location, detect hosts visited by few users vs. peers; detect users visiting rare hosts in a location | rare by_field_name: host over_field_name: user partition_field_name: location |
---
info_content
Measures the Shannon entropy of text strings. High entropy = high randomness = potentially encoded, encrypted, or machine-generated content.
| Entropy range | Interpretation | Example |
|---|---|---|
| Low (predictable) | Normal human-readable text | GET /api/users HTTP/1.1 |
| Medium | Mixed structured/variable content | Log messages with variable IDs |
| High (random) | Encoded, encrypted, or DGA-generated | aGVsbG8gd29ybGQ= (base64), zxq7v9abc.com (DGA) |
Use cases:
- DNS query names with
by_field_name: "dns.question.name"— DGA malware generates high-entropy domain names (e.g.,
xk9p2mnqabcdef.ru).
- User-agent strings — malware C2 frameworks often use randomized or encoded user agents.
- URL paths / query strings — webshell commands embedded in request parameters.
- Command-line arguments — base64-encoded PowerShell payloads.
`high_info_content` (one-sided): only alerts on unusually high entropy, which is almost always the right choice for security use cases.
---
time_of_day vs time_of_week
Both detect _when_ something happens relative to established patterns, not _how much_.
| Function | Granularity | Best for |
|---|---|---|
time_of_day | Hour/minute of day | Detecting off-hours access (3am login for a 9-5 user) |
time_of_week | Day of week | Detecting weekend/holiday activity (batch job on Sunday) |
`time_of_day` example: A database admin who always connects between 08:00–18:00 on weekdays. time_of_day learns this pattern. A connection at 02:30 scores anomalous — even if the volume of activity is normal.
`time_of_week` example: A data pipeline that runs Monday–Friday. time_of_week flags it running on Saturday. time_of_day would not catch this (the time of day, e.g., 08:00, may be normal).
Choosing between them:
- Use
time_of_daywhen the anomaly is "wrong hour of the day." - Use
time_of_weekwhen the anomaly is "wrong day of the week." - Use both together (two detectors) for comprehensive temporal coverage.
- Neither function cares about _count_ or _metric values_ — use
count/meandetectors in the same job for
volume-based detection.
---
One-Sided Variants
Functions marked with * support high_ and low_ prefixes:
| Variant | Detects |
|---|---|
high_<function> | Only values significantly above the expected range |
low_<function> | Only values significantly below the expected range |
<function> (no prefix) | Both directions (bidirectional) |
When to use one-sided:
high_mean(response_time)— only alert on latency spikes, not drops (a faster response is never a problem).low_count(login_events)— only alert on unusually low login volume (could indicate authentication system failure).high_distinct_count(destination.ip)— only alert on abnormally high unique destination IPs (exfiltration signal).
Bidirectional variants (mean, count) generate alerts for both directions, which can produce noise when only one direction is operationally relevant.
ML API Spec Discovery
The .kibana_ai_openapi_spec_elasticsearch index, if present, contains one document per API endpoint. Document shape:
{
"description": "Forecasts are not supported for jobs that perform population analysis; an\nerror occurs if you try to create a forecast for a job that has an\n`over_field_name` in its configuration. Forecasts predict future behavior\nbased on historical data.\n\n## Required authorization\n\n* Cluster privileges: `manage_ml`\n",
"endpoint": "POST /_ml/anomaly_detectors/{job_id}/_forecast",
"method": "post",
"operationId": "ml-forecast",
"path": "/_ml/anomaly_detectors/{job_id}/_forecast",
"path.keyword": "/_ml/anomaly_detectors/{job_id}/_forecast",
"summary": "Predict future behavior of a time series",
"tags": "ml anomaly"
}Use this to make informed API requests.
summary and description are semantic text fields — use MATCH() for natural-language lookup.
Step 1 — Confirm index exists
platform.core.list_indices → check for .kibana_ai_openapi_spec_elasticsearchor use ES|QL query
FROM .kibana_ai_openapi_spec_elasticsearchIf index missing or error, fall back to researching the official documentation: Elastic ML API docs and Elastic ML Anomaly API docs.
Step 2 — List all ML endpoints
FROM .kibana_ai_openapi_spec_elasticsearch
| WHERE tags == "ml"
| SORT endpoint ASC
| LIMIT 100Step 3 — Look up a specific endpoint by path
FROM .kibana_ai_openapi_spec_elasticsearch
| WHERE path LIKE "/_ml/calendars*"
| SORT endpoint ASCStep 4 — Semantic search when you know the intent, not the path
FROM .kibana_ai_openapi_spec_elasticsearch
| WHERE tags == "ml" AND MATCH(summary, "model memory limit")
| LIMIT 10FROM .kibana_ai_openapi_spec_elasticsearch
| WHERE tags == "ml" AND MATCH(description, "revert snapshot")
| KEEP method, endpoint, summary, description
| LIMIT 5When to use this:
- A workflow tool returns 400/404 and you suspect a wrong path or missing required field
- You want to discover optional parameters not covered by the current tool definition
- You're adding a new
ad_*workflow tool and need the exact endpoint and request body schema
Investigate mode — ES|QL tool reference
Supporting detail for the Investigate mode of the parent SKILL.md. Use these templates with Kibana Agent Builder ES|QL tools.
ES|QL Tools (15)
Discovery & Metadata
ad_get_available_metadata
Discover all jobs and their configured metadata. Call first when jobs are unknown.
FROM .ml-config
| WHERE job_type == "anomaly_detector"
| STATS job_count = COUNT(*),
job_ids = VALUES(job_id),
functions = VALUES(`analysis_config.detectors.function`),
fields = VALUES(`analysis_config.detectors.field_name`),
by_fields = VALUES(`analysis_config.detectors.by_field_name`),
over_fields = VALUES(`analysis_config.detectors.over_field_name`),
partition_fields = VALUES(`analysis_config.detectors.partition_field_name`),
influencers = VALUES(`analysis_config.influencers`),
bucket_spans = VALUES(`analysis_config.bucket_span`)_No parameters._
---
ad_get_jobs
List all jobs with full config: bucket_span, detector functions, field names, memory limit.
FROM .ml-config
| WHERE job_type == "anomaly_detector"
| KEEP job_id, `analysis_config.bucket_span`, `analysis_config.detectors.function`,
`analysis_config.detectors.field_name`, `analysis_config.detectors.partition_field_name`,
`analysis_config.detectors.by_field_name`, `analysis_config.detectors.over_field_name`,
`analysis_config.influencers`, `analysis_limits.model_memory_limit`, groups, description
| SORT job_id ASC | LIMIT 100_No parameters._
---
ad_discover_related_jobs
Find jobs sharing the same entity field name (partition/by/over). Call early even when job names differ completely.
| Parameter | Type | Description |
|---|---|---|
job_id | text | The job of interest |
FROM .ml-config
| WHERE job_type == "anomaly_detector"
| EVAL entity_field = COALESCE(`analysis_config.detectors.partition_field_name`,
`analysis_config.detectors.by_field_name`,
`analysis_config.detectors.over_field_name`)
| MV_EXPAND entity_field
| WHERE entity_field IS NOT NULL
| STATS job_count = COUNT_DISTINCT(job_id), jobs = VALUES(job_id),
influencers = VALUES(`analysis_config.influencers`) BY entity_field
| WHERE MV_CONTAINS(jobs, ?job_id)
| SORT job_count DESC | LIMIT 50---
Anomaly Records & Influencers
ad_query_anomaly_records
Primary anomaly search. Cross-job with * or single-job with exact ID.
| Parameter | Type | Description |
|---|---|---|
job_id_pattern | text | LIKE wildcards: * all, rcaeval-* group, exact ID for drill-down |
min_score | double | 50 significant · 25 broad · 75 critical |
start_time | text | ISO 8601 |
end_time | text | ISO 8601 |
FROM .ml-anomalies-*
| WHERE result_type == "record"
AND job_id LIKE ?job_id_pattern
AND record_score >= ?min_score
AND timestamp >= ?start_time AND timestamp <= ?end_time
| SORT record_score DESC | LIMIT 50
| KEEP job_id, timestamp, record_score, function, field_name, actual, typical,
by_field_name, by_field_value, over_field_name, over_field_value,
partition_field_name, partition_field_value,
multi_bucket_impact, initial_record_score, detector_index, probability---
ad_query_anomaly_timeline
Cross-job bucket timeline. Composite scores reveal coordinated events (5 jobs × 30 = composite 150).
| Parameter | Type | Description |
|---|---|---|
job_id_pattern | text | LIKE wildcards |
min_score | double | 25 for signal boosting, 50 standard |
start_time | text | ISO 8601 |
end_time | text | ISO 8601 |
FROM .ml-anomalies-*
| WHERE result_type == "bucket"
AND job_id LIKE ?job_id_pattern
AND anomaly_score >= ?min_score
AND timestamp >= ?start_time AND timestamp <= ?end_time
| STATS max_score = MAX(anomaly_score), job_count = COUNT_DISTINCT(job_id),
jobs = VALUES(job_id), composite_score = SUM(anomaly_score),
avg_score = AVG(anomaly_score) BY timestamp
| SORT timestamp---
ad_query_influencers
Most anomalous entities. job_count > 1 filter = cross-job shared influencers = strongest RCA signal.
| Parameter | Type | Description |
|---|---|---|
job_id_pattern | text | LIKE wildcards |
min_score | double | 25 for shared influencer discovery |
start_time | text | ISO 8601 |
end_time | text | ISO 8601 |
FROM .ml-anomalies-*
| WHERE result_type == "influencer"
AND job_id LIKE ?job_id_pattern
AND influencer_score >= ?min_score
AND timestamp >= ?start_time AND timestamp <= ?end_time
| STATS total_score = SUM(influencer_score), job_count = COUNT_DISTINCT(job_id),
jobs = VALUES(job_id), max_score = MAX(influencer_score)
BY influencer_field_name, influencer_field_value
| SORT total_score DESC | LIMIT 30---
RCA Tools
ad_rca_multi_job_entities
Strongest root cause signal. Entities anomalous in 2+ jobs simultaneously. Resource faults → multi-job; network faults → single-job.
| Parameter | Type | Description |
|---|---|---|
min_score | double | 25 broad · 50 significant |
min_job_count | double | Use 2 for cross-job RCA |
start_time | text | ISO 8601 |
end_time | text | ISO 8601 |
FROM .ml-anomalies-*
| WHERE result_type == "record"
AND record_score >= ?min_score
AND timestamp >= ?start_time AND timestamp <= ?end_time
| STATS job_count = COUNT_DISTINCT(job_id), jobs = VALUES(job_id),
max_score = MAX(record_score), total_records = COUNT(*),
functions = VALUES(function), fields = VALUES(field_name)
BY partition_field_value
| WHERE job_count >= ?min_job_count
| SORT job_count DESC, max_score DESC | LIMIT 20---
ad_rca_cross_job_entity_match
From an alert's entity value, find ALL jobs where it's anomalous. Returns first_anomaly per job for chronology reconstruction.
| Parameter | Type | Description |
|---|---|---|
entity_value | text | From alert's partition/by/over field value |
min_score | double | 10–25 for comprehensive search |
start_time | text | Wider than alert window (alert minus 6h) |
end_time | text | Alert plus 1–2h to catch delayed effects |
FROM .ml-anomalies-*
| WHERE result_type == "record"
AND record_score >= ?min_score
AND timestamp >= ?start_time AND timestamp <= ?end_time
AND (partition_field_value == ?entity_value
OR by_field_value == ?entity_value
OR over_field_value == ?entity_value)
| STATS max_score = MAX(record_score), anomaly_count = COUNT(*),
functions = VALUES(function), fields = VALUES(field_name),
first_anomaly = MIN(timestamp), last_anomaly = MAX(timestamp)
BY job_id
| SORT max_score DESC---
ad_rca_detector_fingerprint
Incident fingerprint — which system aspects are anomalous (CPU? Latency? Error rate?).
| Parameter | Type | Description |
|---|---|---|
job_id_pattern | text | LIKE wildcards |
min_score | double | Minimum record_score |
start_time | text | ISO 8601 |
end_time | text | ISO 8601 |
FROM .ml-anomalies-*
| WHERE result_type == "record"
AND job_id LIKE ?job_id_pattern
AND record_score >= ?min_score
AND timestamp >= ?start_time AND timestamp <= ?end_time
| STATS count = COUNT(*), max_score = MAX(record_score), avg_score = AVG(record_score)
BY job_id, function, field_name, detector_index
| SORT max_score DESC---
ad_rca_correlation
Temporally ordered anomalies for cascade analysis. Earliest anomaly for an entity → root cause direction.
| Parameter | Type | Description |
|---|---|---|
job_id_pattern | text | LIKE wildcards (scope to related group) |
min_score | double | Minimum record_score |
start_time | text | ISO 8601 |
end_time | text | ISO 8601 |
FROM .ml-anomalies-*
| WHERE result_type == "record"
AND job_id LIKE ?job_id_pattern
AND record_score >= ?min_score
AND timestamp >= ?start_time AND timestamp <= ?end_time
| SORT timestamp ASC
| KEEP job_id, timestamp, record_score, function, field_name,
by_field_name, by_field_value, partition_field_name, partition_field_value,
over_field_name, over_field_value, multi_bucket_impact
| LIMIT 100---
ad_rca_blast_radius
Scope of impact — how many partitions/jobs are affected by a specific anomalous value.
| Parameter | Type | Description |
|---|---|---|
anomalous_value | text | e.g. node-backdoor, payment-service |
min_score | double | 10–25 for weak-signal aggregation |
start_time | text | ISO 8601 |
end_time | text | ISO 8601 |
FROM .ml-anomalies-*
| WHERE result_type == "record"
AND record_score >= ?min_score
AND timestamp >= ?start_time AND timestamp <= ?end_time
AND (by_field_value == ?anomalous_value
OR over_field_value == ?anomalous_value
OR partition_field_value == ?anomalous_value)
| STATS affected_partitions = COUNT_DISTINCT(partition_field_value),
affected_jobs = COUNT_DISTINCT(job_id), total_anomalies = COUNT(*),
max_score = MAX(record_score),
time_span_hours = DATE_DIFF("hour", MIN(timestamp), MAX(timestamp))
BY by_field_value
| SORT affected_partitions DESC---
ad_rca_entity_profile
Complete anomaly dossier for a suspect entity across ALL jobs and field types.
| Parameter | Type | Description |
|---|---|---|
entity_value | text | e.g. server-01, payment-service |
start_time | text | ISO 8601 |
end_time | text | ISO 8601 |
FROM .ml-anomalies-*
| WHERE result_type == "record"
AND timestamp >= ?start_time AND timestamp <= ?end_time
AND (by_field_value == ?entity_value
OR over_field_value == ?entity_value
OR partition_field_value == ?entity_value)
| SORT timestamp ASC | LIMIT 100
| KEEP job_id, timestamp, record_score, function, field_name,
by_field_name, by_field_value, over_field_name, over_field_value,
partition_field_name, partition_field_value, actual, typical, multi_bucket_impact---
ad_rca_source_evidence
Raw source documents from the original data index. Get source index from ad_get_job_datafeed_config first.
| Parameter | Type | Description |
|---|---|---|
source_index | text | LIKE pattern from datafeed config (e.g. rcaeval-re1-ob, otel-flat-*) |
start_time | text | ISO 8601 |
end_time | text | ISO 8601 |
FROM * METADATA _index
| WHERE _index LIKE ?source_index
AND @timestamp >= ?start_time AND @timestamp <= ?end_time
| SORT @timestamp DESC | LIMIT 50---
Log Categorization (when by_field_name == "mlcategory")
ad_get_categories
Category definitions (terms, regex, examples) for jobs with categorization_field_name.
| Parameter | Type | Description |
|---|---|---|
job_id | text | The anomaly detection job ID |
FROM .ml-anomalies-*
| WHERE result_type == "category_definition" AND job_id == ?job_id
| SORT category_id ASC
| KEEP job_id, category_id, terms, regex, max_matching_length, examples
| LIMIT 100---
ad_search_log_category_examples
Raw log samples for two-window comparison (baseline vs anomaly window). Compare variable parts — IPs, hostnames, error codes — to find what changed.
| Parameter | Type | Description |
|---|---|---|
source_index | text | LIKE pattern from datafeed config |
start_time | text | ISO 8601 (baseline: 24h before anomaly) |
end_time | text | ISO 8601 |
FROM * METADATA _index
| WHERE _index LIKE ?source_index
AND @timestamp >= ?start_time AND @timestamp <= ?end_time
| SORT @timestamp DESC | LIMIT 50---
Job creation recipes
Worked examples for creating Elastic ML anomaly detection jobs and datafeeds via Agent Builder workflow tools.
For the high-level process, see the Manage section of the parent SKILL.md. For detector function selection, seeanomaly-detection-functions.md.
---
API call sequence
PUT _ml/anomaly_detectors/<job_id> # 1. Define job + detectors (ad_create_job)
PUT _ml/datafeeds/datafeed-<job_id> # 2. Define datafeed (source + query) (ad_create_datafeed)
POST _ml/anomaly_detectors/<job_id>/_open # 3a. Open job (ad_open_job)
POST _ml/datafeeds/datafeed-<job_id>/_start # 3b. Start datafeed (ad_manage_datafeed action=_start)
GET _ml/anomaly_detectors/<job_id>/results/records # 4. Read resultsTo stop: ad_manage_datafeed (action=_stop) → POST _ml/anomaly_detectors/<job_id>/_close.
For batch analysis on historical data, pass start and end to the datafeed start call:
POST _ml/datafeeds/datafeed-revenue_over_users_api/_start
{ "start": "2024-01-01T00:00:00Z", "end": "2024-03-01T00:00:00Z" }---
Key analysis_config fields
| Field | Description |
|---|---|
bucket_span | Analysis interval (e.g. 15m, 1h). Align with data granularity and detection window. |
detectors[].function | Analysis function (high_sum, rare, mean, etc). See anomaly-detection-functions.md. |
detectors[].field_name | Numeric field to analyze. |
detectors[].over_field_name | Population analysis — each entity compared to its peers in the same bucket. |
detectors[].by_field_name | Per-entity modeling — each entity compared to its own history. |
detectors[].partition_field_name | Fully independent sub-model per entity with its own score normalization. |
influencers | Fields to track as anomaly contributors (shown as influencer_score). |
data_description.time_field | Timestamp field for time series ordering (e.g. @timestamp, order_date). |
Key datafeed fields
| Field | Description |
|---|---|
indices | Array of index patterns containing source data. |
query | Elasticsearch DSL to filter source documents. Defaults to match_all. |
query_delay | How far behind real time the datafeed queries. Set to P95 ingest latency + buffer (default 60s–120s). Too low → missing docs. |
scroll_size | Documents fetched per scroll request. Default 1000. |
frequency | How often the datafeed polls. Defaults to min(query_delay, bucket_span / 2). |
---
Recipe 1 — rare detector: rare usernames
User query: "Create an ML job to detect rare usernames in login events across logs-\*"
Verify user.name (keyword) and @timestamp (date) exist via platform.core.get_index_mapping. Validate with ad_validate_job_spec. Then ad_create_job → ad_create_datafeed → ad_open_job → ad_manage_datafeed (action=_start).
Job body:
{
"description": "Detects rare values of user.name during login activity",
"analysis_config": {
"bucket_span": "15m",
"detectors": [
{ "function": "rare", "by_field_name": "user.name", "detector_description": "Rare user.name values" }
],
"influencers": ["user.name", "source.ip"]
},
"data_description": { "time_field": "@timestamp" }
}Datafeed body:
{ "job_id": "rare-login-usernames", "indices": ["logs-*"], "query": { "match_all": {} } }---
Recipe 2 — high_mean detector: DNS exfiltration with datafeed filter
User query: "detect hosts with unusually high DNS query volume per domain, only for external DNS traffic on port 53"
Verify dns.question.count (numeric), host.name (keyword), dns.question.name (keyword), @timestamp (date).
Job body:
{
"description": "Detects unusually high DNS query volume per host, partitioned by queried domain",
"analysis_config": {
"bucket_span": "15m",
"detectors": [
{
"function": "high_mean",
"field_name": "dns.question.count",
"over_field_name": "host.name",
"partition_field_name": "dns.question.name",
"detector_description": "High DNS query volume per host per domain"
}
],
"influencers": ["host.name", "dns.question.name", "source.ip"]
},
"data_description": { "time_field": "@timestamp" }
}Datafeed body:
{
"job_id": "high-dns-query-volume-per-host",
"indices": ["logs-*"],
"query": {
"bool": { "filter": [{ "term": { "network.transport": "udp" } }, { "term": { "destination.port": 53 } }] }
}
}---
Recipe 3 — high_sum detector: large downloads with time range
User query: "detect users downloading unusually large amounts of data for sshd and sftp processes in the last 30 days"
Verify destination.bytes (numeric), user.name (keyword), process.name (keyword), @timestamp (date).
Job body:
{
"description": "Detects unusually high total bytes downloaded per user for specific processes",
"analysis_config": {
"bucket_span": "1h",
"detectors": [
{
"function": "high_sum",
"field_name": "destination.bytes",
"by_field_name": "user.name",
"detector_description": "High total bytes downloaded per user"
}
],
"influencers": ["user.name", "process.name", "source.ip"]
},
"data_description": { "time_field": "@timestamp" }
}Datafeed body:
{
"job_id": "high-download-volume-per-user",
"indices": ["logs-*"],
"query": {
"bool": {
"filter": [
{ "terms": { "process.name": ["sshd", "sftp"] } },
{ "range": { "@timestamp": { "gte": "now-30d", "lte": "now" } } }
]
}
}
}---
Retrieving results
| Result type | Endpoint | Description |
|---|---|---|
| Buckets | GET _ml/anomaly_detectors/<job_id>/results/buckets | Aggregate anomaly score per time bucket |
| Records | GET _ml/anomaly_detectors/<job_id>/results/records | Individual anomaly records with actual, typical, record_score |
| Influencers | GET _ml/anomaly_detectors/<job_id>/results/influencers | Entity contribution scores |
| Forecast | POST _ml/anomaly_detectors/<job_id>/_forecast | Predict future values; specify duration (e.g. "10d") |
Forecasts are not supported for population analysis jobs (over_field_name set).{
"tools": [
"ad_get_available_metadata",
"ad_get_jobs",
"ad_discover_related_jobs",
"ad_rca_cross_job_entity_match",
"ad_query_anomaly_records",
"ad_query_anomaly_timeline",
"ad_query_influencers",
"ad_rca_entity_profile",
"ad_rca_detector_fingerprint",
"ad_rca_correlation",
"ad_rca_blast_radius",
"ad_rca_multi_job_entities",
"ad_rca_source_evidence",
"ad_discover_jobs_by_datafeed_index",
"ad_get_job_datafeed_config",
"ad_get_categories",
"ad_search_log_category_examples"
]
}
{
"tools": [
"ad_get_available_metadata",
"ad_get_jobs",
"ad_query_anomaly_records",
"ad_query_influencers",
"ad_rca_score_reassessment",
"ad_get_model_plot",
"ad_get_categories",
"ad_get_forecast_results",
"ad_wf_troubleshoot_anomaly_score",
"ad_get_job_datafeed_config"
]
}
{
"tools": [
"ad_get_available_metadata",
"ad_get_jobs",
"ad_get_job_messages",
"ad_get_model_snapshots",
"ad_ts_bucket_event_gaps",
"ad_ts_delayed_data_annotations",
"ad_ts_ingest_latency_estimate",
"ad_ts_model_memory_health",
"ad_wf_ts_field_cardinality",
"ad_get_job_datafeed_config",
"ad_manage_datafeed",
"ad_preview_datafeed_with_latency",
"ad_create_job",
"ad_revert_model_snapshot",
"ad_create_calendar_event",
"ad_get_calendar_events",
"ad_update_datafeed_query_delay",
"ad_update_delayed_data_check_config",
"ad_update_model_memory_limit",
"ad_estimate_memory_requirement",
"ad_validate_ml_tool_permissions",
"ad_ts_ccs_diagnostics",
"ad_wf_troubleshoot_query_delay",
"ad_wf_troubleshoot_memory_limit"
]
}
{
"name": "ad_discover_related_jobs",
"description": "Given a job of interest, find all other jobs that share the same entity field name (partition, by, or over field) by reading job configs from .ml-config. Returns the shared field name, the full list of sibling jobs in that group, their configured influencer fields, and the total sibling count. A job appears in its primary entity field group (partition > by > over). Call this early in investigation to find sibling jobs even when job names and prefixes differ completely. Complement with ad_discover_jobs_by_datafeed_index for source index overlap.",
"type": "esql",
"configuration": {
"query": "FROM .ml-config | WHERE job_type == \"anomaly_detector\" | EVAL entity_field = COALESCE(`analysis_config.detectors.partition_field_name`, `analysis_config.detectors.by_field_name`, `analysis_config.detectors.over_field_name`) | MV_EXPAND entity_field | WHERE entity_field IS NOT NULL | STATS job_count = COUNT_DISTINCT(job_id), jobs = VALUES(job_id), influencers = VALUES(`analysis_config.influencers`) BY entity_field | WHERE MV_CONTAINS(jobs, ?job_id) | SORT job_count DESC | LIMIT 50"
},
"parameters": {
"job_id": {
"type": "string",
"description": "The job ID of interest. Returns all other jobs that share the same entity split field (partition, by, or over field) as this job."
}
}
}
{
"name": "ad_get_available_metadata",
"description": "Discover all anomaly detection jobs and their configured metadata: job IDs, detector functions, monitored field names, entity split fields (by/over/partition), influencer field names, and bucket spans. Reads from .ml-config so all jobs are visible even if they have never produced an anomaly. Returns a single summary row. Call this FIRST before other tools to learn valid parameter values for job_id, function, field_name, and entity field filters.",
"type": "esql",
"configuration": {
"query": "FROM .ml-config | WHERE job_type == \"anomaly_detector\" | STATS job_count = COUNT(*), job_ids = VALUES(job_id), functions = VALUES(`analysis_config.detectors.function`), fields = VALUES(`analysis_config.detectors.field_name`), by_fields = VALUES(`analysis_config.detectors.by_field_name`), over_fields = VALUES(`analysis_config.detectors.over_field_name`), partition_fields = VALUES(`analysis_config.detectors.partition_field_name`), influencers = VALUES(`analysis_config.influencers`), bucket_spans = VALUES(`analysis_config.bucket_span`)"
},
"parameters": {}
}
{
"name": "ad_get_categories",
"description": "Get log categories for categorization jobs: category definitions, regex patterns, and examples. Useful for understanding what types of log messages the model has learned and which categories are generating anomalies.",
"type": "esql",
"configuration": {
"query": "FROM .ml-anomalies-* | WHERE result_type == \"category_definition\" AND job_id == ?job_id | SORT category_id ASC | KEEP job_id, category_id, terms, regex, max_matching_length, examples | LIMIT 100"
},
"parameters": {
"job_id": {
"type": "string",
"description": "The anomaly detection job ID"
}
}
}
{
"name": "ad_get_forecast_results",
"description": "Retrieve forecast predictions with upper/lower bounds for capacity planning. Queries model_forecast results from .ml-anomalies-*.",
"type": "esql",
"configuration": {
"query": "FROM .ml-anomalies-* | WHERE result_type == \"model_forecast\" AND job_id == ?job_id | SORT timestamp ASC | KEEP job_id, timestamp, forecast_prediction, forecast_upper, forecast_lower, bucket_span | LIMIT 200"
},
"parameters": {
"job_id": {
"type": "string",
"description": "The anomaly detection job ID"
}
}
}
{
"name": "ad_get_job_messages",
"description": "Retrieve all notifications for a job from the ML notifications index, including all severity levels (info, warning, error). Covers datafeed warnings, delayed data alerts, missing document warnings, query errors, timeout messages, CCS connectivity issues, memory limit warnings, and job lifecycle events. Returns all levels so the agent can filter by level in its reasoning. Use this for both general notification browsing and targeted datafeed warning investigation.",
"type": "esql",
"configuration": {
"query": "FROM .ml-notifications-* | WHERE job_id == ?job_id | SORT timestamp DESC | KEEP timestamp, level, message, node_name, job_id | LIMIT 50"
},
"parameters": {
"job_id": {
"type": "string",
"description": "The anomaly detection job ID"
}
}
}
{
"name": "ad_get_jobs",
"description": "List all anomaly detection jobs with their full configuration: bucket_span, detector functions, monitored field names, entity split fields (partition/by/over), influencers, memory limit, groups, and description. Reads from .ml-config so ALL jobs appear regardless of whether they have run or produced any anomaly results. Use this early in any investigation to understand what jobs exist and how they are configured.",
"type": "esql",
"configuration": {
"query": "FROM .ml-config | WHERE job_type == \"anomaly_detector\" | KEEP job_id, `analysis_config.bucket_span`, `analysis_config.detectors.function`, `analysis_config.detectors.field_name`, `analysis_config.detectors.partition_field_name`, `analysis_config.detectors.by_field_name`, `analysis_config.detectors.over_field_name`, `analysis_config.influencers`, `analysis_limits.model_memory_limit`, groups, description | SORT job_id ASC | LIMIT 100"
},
"parameters": {}
}
{
"name": "ad_get_model_plot",
"description": "Get model bounds (upper/lower/median) to explain why something was or was not flagged as anomalous. Shows the model's confidence interval at each time point. If actual value is within bounds, no anomaly; if outside, anomaly score depends on distance from bounds.",
"type": "esql",
"configuration": {
"query": "FROM .ml-anomalies-* | WHERE result_type == \"model_plot\" AND job_id == ?job_id AND timestamp >= ?start_time AND timestamp <= ?end_time | SORT timestamp ASC | KEEP job_id, timestamp, model_lower, model_upper, model_median, actual, partition_field_value, by_field_value | LIMIT 500"
},
"parameters": {
"job_id": {
"type": "string",
"description": "The anomaly detection job ID"
},
"start_time": {
"type": "string",
"description": "Start of time range in ISO 8601 format"
},
"end_time": {
"type": "string",
"description": "End of time range in ISO 8601 format"
}
}
}
{
"name": "ad_get_model_snapshots",
"description": "List available model snapshots for a job, including timestamp, description, and size. Used for model revert operations when data quality issues have corrupted the model.",
"type": "esql",
"configuration": {
"query": "FROM .ml-anomalies-* | WHERE result_type == \"model_snapshot\" AND job_id == ?job_id | SORT timestamp DESC | KEEP job_id, timestamp, description, snapshot_doc_count | LIMIT 20"
},
"parameters": {
"job_id": {
"type": "string",
"description": "The anomaly detection job ID"
}
}
}
{
"name": "ad_query_anomaly_records",
"description": "Search anomaly records by score threshold and time range. Use job_id_pattern='*' for cross-job search (default) or an exact job ID for single-job drill-down. Supports global cross-job search, job-scoped deep dive, absence detection (actual << typical), and value trend analysis. This is the primary tool for questions like 'find anomalies related to entity X' as well as per-job investigation.",
"type": "esql",
"configuration": {
"query": "FROM .ml-anomalies-* | WHERE result_type == \"record\" AND job_id LIKE ?job_id_pattern AND record_score >= ?min_score AND timestamp >= ?start_time AND timestamp <= ?end_time | SORT record_score DESC | LIMIT 50 | KEEP job_id, timestamp, record_score, function, field_name, actual, typical, by_field_name, by_field_value, over_field_name, over_field_value, partition_field_name, partition_field_value, multi_bucket_impact, initial_record_score, detector_index, probability"
},
"parameters": {
"job_id_pattern": {
"type": "string",
"description": "Job ID pattern using LIKE wildcards (* = multi-char). Use '*' for all jobs (cross-job search), or an exact job ID to scope to a single job (e.g. 'rcaeval-ob-cpu'). Use 'rcaeval-*' to scope to a prefix group."
},
"min_score": {
"type": "string",
"description": "Minimum record_score threshold (0-100). Use 50 for significant anomalies, 25 for broader search, 75 for critical only."
},
"start_time": {
"type": "string",
"description": "Start of time range in ISO 8601 format (e.g. 2024-01-01T00:00:00Z)"
},
"end_time": {
"type": "string",
"description": "End of time range in ISO 8601 format"
}
}
}
{
"name": "ad_query_anomaly_timeline",
"description": "Get a time-series summary of anomaly severity across all or selected jobs, bucketed by time. Use for building cross-job swimlanes, detecting anomaly storms (multiple jobs firing together), and computing composite signal scores. When multiple jobs have anomaly_score > threshold in the same bucket, it indicates a common external trigger. Scope to a job group with job_id_pattern (e.g. 'rcaeval-*') or use '*' for all jobs.",
"type": "esql",
"configuration": {
"query": "FROM .ml-anomalies-* | WHERE result_type == \"bucket\" AND job_id LIKE ?job_id_pattern AND anomaly_score >= ?min_score AND timestamp >= ?start_time AND timestamp <= ?end_time | STATS max_score = MAX(anomaly_score), job_count = COUNT_DISTINCT(job_id), jobs = VALUES(job_id), composite_score = SUM(anomaly_score), avg_score = AVG(anomaly_score) BY timestamp | SORT timestamp"
},
"parameters": {
"job_id_pattern": {
"type": "string",
"description": "Job ID pattern using LIKE wildcards. Use '*' for all jobs, or a prefix pattern to scope to a related group (e.g. 'rcaeval-*')."
},
"min_score": {
"type": "string",
"description": "Minimum bucket anomaly_score. Use 25 for signal boosting (catch coordinated low-severity events), 50 for standard, 75 for critical."
},
"start_time": {
"type": "string",
"description": "Start of time range in ISO 8601 format"
},
"end_time": {
"type": "string",
"description": "End of time range in ISO 8601 format"
}
}
}
{
"name": "ad_query_influencers",
"description": "Find the most unusual entities across all or selected jobs for a time range. Answers 'What entities are most anomalous right now?' and 'Which entities appear as influencers in MULTIPLE jobs simultaneously?' When require_multi_job semantics are needed, filter results where job_count > 1 to find shared influencers for cross-job RCA. Scope to a job group with job_id_pattern (e.g. 'rcaeval-*') or use '*' for all jobs.",
"type": "esql",
"configuration": {
"query": "FROM .ml-anomalies-* | WHERE result_type == \"influencer\" AND job_id LIKE ?job_id_pattern AND influencer_score >= ?min_score AND timestamp >= ?start_time AND timestamp <= ?end_time | STATS total_score = SUM(influencer_score), job_count = COUNT_DISTINCT(job_id), jobs = VALUES(job_id), max_score = MAX(influencer_score) BY influencer_field_name, influencer_field_value | SORT total_score DESC | LIMIT 30"
},
"parameters": {
"job_id_pattern": {
"type": "string",
"description": "Job ID pattern using LIKE wildcards. Use '*' for all jobs, or a prefix pattern to scope to a related group (e.g. 'rcaeval-*')."
},
"min_score": {
"type": "string",
"description": "Minimum influencer_score threshold. Use 25 for broad search (shared influencer discovery), 50 for significant entities."
},
"start_time": {
"type": "string",
"description": "Start of time range in ISO 8601 format"
},
"end_time": {
"type": "string",
"description": "End of time range in ISO 8601 format"
}
}
}
{
"name": "ad_rca_blast_radius",
"description": "Measure how widespread a threat or issue is by counting how many hosts, partitions, or entities are affected by a specific anomalous value. Given a value (e.g. process.name: node-backdoor), counts distinct affected partitions across all jobs. Also aggregates weak signals: entities with multiple low-score anomalies across different jobs that individually are below alerting thresholds but collectively indicate a real problem.",
"type": "esql",
"configuration": {
"query": "FROM .ml-anomalies-* | WHERE result_type == \"record\" AND record_score >= ?min_score AND timestamp >= ?start_time AND timestamp <= ?end_time AND (by_field_value == ?anomalous_value OR over_field_value == ?anomalous_value OR partition_field_value == ?anomalous_value) | STATS affected_partitions = COUNT_DISTINCT(partition_field_value), affected_jobs = COUNT_DISTINCT(job_id), total_anomalies = COUNT(*), max_score = MAX(record_score), time_span_hours = DATE_DIFF(\"hour\", MIN(timestamp), MAX(timestamp)) BY by_field_value | SORT affected_partitions DESC"
},
"parameters": {
"anomalous_value": {
"type": "string",
"description": "The specific anomalous value to measure blast radius for (e.g. 'node-backdoor', 'suspicious-script.ps1')"
},
"min_score": {
"type": "string",
"description": "Minimum record_score. Use low values (10-25) for weak signal aggregation."
},
"start_time": {
"type": "string",
"description": "Start of time range in ISO 8601 format"
},
"end_time": {
"type": "string",
"description": "End of time range in ISO 8601 format"
}
}
}
{
"name": "ad_rca_correlation",
"description": "Find temporally correlated anomalies across different jobs. Supports two modes: (1) co_occurrence — anomalies from different jobs in overlapping time windows regardless of shared influencers, essential for cross-domain correlation (K8s + APM + logs); (2) ordered_sequence — anomalies sorted by time to detect cascading failures (network -> app -> DB propagation). The agent examines temporal ordering and job types to infer causality. Use job_id_pattern to scope to a subset of jobs (e.g. 'rcaeval-ob-*') when many jobs exist.",
"type": "esql",
"configuration": {
"query": "FROM .ml-anomalies-* | WHERE result_type == \"record\" AND job_id LIKE ?job_id_pattern AND record_score >= ?min_score AND timestamp >= ?start_time AND timestamp <= ?end_time | SORT timestamp ASC | KEEP job_id, timestamp, record_score, function, field_name, by_field_name, by_field_value, partition_field_name, partition_field_value, over_field_name, over_field_value, multi_bucket_impact | LIMIT 100"
},
"parameters": {
"job_id_pattern": {
"type": "string",
"description": "Job ID pattern using LIKE wildcards (* = multi-char). Use '*' for all jobs, 'rcaeval-*' for all RCAEval jobs, 'rcaeval-ob-*' for Online Boutique only, 'nab-*' for NAB jobs, etc."
},
"min_score": {
"type": "string",
"description": "Minimum record_score threshold"
},
"start_time": {
"type": "string",
"description": "Start of time range in ISO 8601 format"
},
"end_time": {
"type": "string",
"description": "End of time range in ISO 8601 format"
}
}
}
{
"name": "ad_rca_cross_job_entity_match",
"description": "Starting from a specific entity value (e.g. a service name extracted from an alert), find ALL other jobs where that entity appears as anomalous — across partition, by, and over fields. Returns per-job summary with max score, anomaly count, detector functions, field names, and the first/last anomaly timestamps. Use first_anomaly to reconstruct chronology: the job that detected the entity earliest is closest to the root cause. Unlike ad_rca_multi_job_entities (which groups by partition_field_value only), this matches the entity value across ALL split field types.",
"type": "esql",
"configuration": {
"query": "FROM .ml-anomalies-* | WHERE result_type == \"record\" AND record_score >= ?min_score AND timestamp >= ?start_time AND timestamp <= ?end_time AND (partition_field_value == ?entity_value OR by_field_value == ?entity_value OR over_field_value == ?entity_value) | STATS max_score = MAX(record_score), anomaly_count = COUNT(*), functions = VALUES(function), fields = VALUES(field_name), first_anomaly = MIN(timestamp), last_anomaly = MAX(timestamp) BY job_id | SORT max_score DESC"
},
"parameters": {
"entity_value": {
"type": "string",
"description": "The entity value to search for across all jobs (e.g. 'frontend', 'server-01', 'payment-service'). Extract this from the alerting anomaly's partition_field_value, by_field_value, or over_field_value."
},
"min_score": {
"type": "string",
"description": "Minimum record_score threshold. Use 10-25 for comprehensive search (catches weak cascade signals), 50 for significant anomalies only."
},
"start_time": {
"type": "string",
"description": "Start of time range in ISO 8601 format. Use a wider window than the alert (e.g. alert_time minus 6 hours) to catch the full cascade."
},
"end_time": {
"type": "string",
"description": "End of time range in ISO 8601 format. Use alert_time plus 1-2 hours to catch delayed effects."
}
}
}
{
"name": "ad_rca_detector_fingerprint",
"description": "For a specific incident time window, produce a fingerprint showing exactly which detectors fired across all or selected jobs and what they monitor. Shows which aspects of the system are anomalous (CPU? Network? Error rate? Latency?). Group by job_id + function + field_name to understand the incident signature. Scope to a job group with job_id_pattern (e.g. 'rcaeval-*') or use '*' for all jobs.",
"type": "esql",
"configuration": {
"query": "FROM .ml-anomalies-* | WHERE result_type == \"record\" AND job_id LIKE ?job_id_pattern AND record_score >= ?min_score AND timestamp >= ?start_time AND timestamp <= ?end_time | STATS count = COUNT(*), max_score = MAX(record_score), avg_score = AVG(record_score) BY job_id, function, field_name, detector_index | SORT max_score DESC"
},
"parameters": {
"job_id_pattern": {
"type": "string",
"description": "Job ID pattern using LIKE wildcards. Use '*' for all jobs, or a prefix pattern to scope to a related group (e.g. 'rcaeval-*')."
},
"min_score": {
"type": "string",
"description": "Minimum record_score threshold"
},
"start_time": {
"type": "string",
"description": "Start of time range in ISO 8601 format"
},
"end_time": {
"type": "string",
"description": "End of time range in ISO 8601 format"
}
}
}
{
"name": "ad_rca_entity_profile",
"description": "Build a complete anomaly dossier for a suspect entity across ALL jobs. Given an entity value (e.g. host.name: server-01), shows every anomaly where it appeared as an influencer, by_field, partition_field, or over_field value. Use after identifying a suspect via ad_query_influencers to build full context.",
"type": "esql",
"configuration": {
"query": "FROM .ml-anomalies-* | WHERE result_type == \"record\" AND timestamp >= ?start_time AND timestamp <= ?end_time AND (by_field_value == ?entity_value OR over_field_value == ?entity_value OR partition_field_value == ?entity_value) | SORT timestamp ASC | LIMIT 100 | KEEP job_id, timestamp, record_score, function, field_name, by_field_name, by_field_value, over_field_name, over_field_value, partition_field_name, partition_field_value, actual, typical, multi_bucket_impact"
},
"parameters": {
"entity_value": {
"type": "string",
"description": "The entity value to profile (e.g. 'server-01', 'payment-service', 'user@example.com')"
},
"start_time": {
"type": "string",
"description": "Start of time range in ISO 8601 format"
},
"end_time": {
"type": "string",
"description": "End of time range in ISO 8601 format"
}
}
}
{
"name": "ad_rca_multi_job_entities",
"description": "Find entities that are anomalous in MULTIPLE jobs simultaneously — the strongest root cause signal. Returns entities ranked by the number of distinct jobs they appear in, with per-job max scores and detector functions. Entities in 2+ jobs are prime root cause candidates (e.g., a service with CPU AND latency anomalies); entities in only 1 job are likely secondary effects or victims. This is the key discriminator for RCA: resource faults produce multi-job anomalies, network faults produce single-job anomalies.",
"type": "esql",
"configuration": {
"query": "FROM .ml-anomalies-* | WHERE result_type == \"record\" AND record_score >= ?min_score AND timestamp >= ?start_time AND timestamp <= ?end_time | STATS job_count = COUNT_DISTINCT(job_id), jobs = VALUES(job_id), max_score = MAX(record_score), total_records = COUNT(*), functions = VALUES(function), fields = VALUES(field_name) BY partition_field_value | WHERE job_count >= ?min_job_count | SORT job_count DESC, max_score DESC | LIMIT 20"
},
"parameters": {
"min_score": {
"type": "string",
"description": "Minimum record_score threshold. Use 25 for broader search (catches weak cascade signals), 50 for significant anomalies."
},
"min_job_count": {
"type": "string",
"description": "Minimum number of distinct jobs the entity must appear in. Use 2 to find cross-job root cause candidates, 1 to include all entities."
},
"start_time": {
"type": "string",
"description": "Start of time range in ISO 8601 format"
},
"end_time": {
"type": "string",
"description": "End of time range in ISO 8601 format"
}
}
}
{
"name": "ad_rca_score_reassessment",
"description": "Find anomalies where the model has significantly changed its assessment over time due to renormalization. This tool defines score_drift = initial_record_score - record_score. When renormalization lowers the current score, initial_record_score stays higher → large positive drift (initial >> current). Large negative drift means the current score rose versus the initial snapshot (upward reconsideration). Records where scores stayed high indicate persistent anomalies the model never explained away. Scope to a specific job with job_id_pattern or use '*' for all jobs.",
"type": "esql",
"configuration": {
"query": "FROM .ml-anomalies-* | WHERE result_type == \"record\" AND job_id LIKE ?job_id_pattern AND timestamp >= ?start_time AND timestamp <= ?end_time | EVAL score_drift = initial_record_score - record_score | WHERE ABS(score_drift) >= ?min_drift | SORT ABS(score_drift) DESC | LIMIT 30 | KEEP job_id, timestamp, initial_record_score, record_score, score_drift, function, field_name, by_field_value, partition_field_value"
},
"parameters": {
"job_id_pattern": {
"type": "string",
"description": "Job ID pattern using LIKE wildcards. Use '*' for all jobs, or an exact job ID to scope to a single job (e.g. 'rcaeval-ob-cpu')."
},
"min_drift": {
"type": "string",
"description": "Minimum score point difference between initial and current score (default 20)"
},
"start_time": {
"type": "string",
"description": "Start of time range in ISO 8601 format"
},
"end_time": {
"type": "string",
"description": "End of time range in ISO 8601 format"
}
}
}
{
"name": "ad_rca_source_evidence",
"description": "After identifying an anomaly, retrieve raw source documents from the ORIGINAL data index for the anomaly's time window. This is the evidence drilldown — see the actual log lines, traces, or metrics that caused the statistical deviation. Without this, the agent can say 'something unusual happened' but cannot say 'here is what actually happened'.\n\nUsage: First find the source index from the job's datafeed configuration (via ad_get_job_datafeed_config). Pass the index name as source_index using LIKE wildcards (* for multi-char, ? for single-char). The tool returns raw documents with all original fields from that index.",
"type": "esql",
"configuration": {
"query": "FROM * METADATA _index | WHERE _index LIKE ?source_index AND @timestamp >= ?start_time AND @timestamp <= ?end_time | SORT @timestamp DESC | LIMIT 50"
},
"parameters": {
"source_index": {
"type": "string",
"description": "Source data index name or LIKE pattern (* = multi-char wildcard). Get this from the job's datafeed config. Examples: 'rcaeval-re1-ob', 'nab', 'otel-flat-*', 'smd'"
},
"start_time": {
"type": "string",
"description": "Start of time range in ISO 8601 format"
},
"end_time": {
"type": "string",
"description": "End of time range in ISO 8601 format"
}
}
}
{
"name": "ad_search_log_category_examples",
"description": "Search for log message examples in the source data for a specific time window. Used for two-window comparison in log categorization RCA: run once for a baseline window (e.g., 24h before anomaly) and once for the anomaly window. Compare the samples to identify what changed in the variable parts (IPs, hostnames, error codes, paths) that caused the category count anomaly. Returns raw log documents so you can inspect the categorization_field_name field specified in the job config.",
"type": "esql",
"configuration": {
"query": "FROM * METADATA _index | WHERE _index LIKE ?source_index AND @timestamp >= ?start_time AND @timestamp <= ?end_time | SORT @timestamp DESC | LIMIT 50"
},
"parameters": {
"source_index": {
"type": "string",
"description": "Source data index name or LIKE pattern from the job's datafeed config. Get this from ad_get_job_datafeed_config. Examples: 'logs-*', 'filebeat-*', 'otel-logs-*'"
},
"start_time": {
"type": "string",
"description": "Start of time range in ISO 8601 format. For baseline window, use a period before the anomaly (e.g., 24h before)."
},
"end_time": {
"type": "string",
"description": "End of time range in ISO 8601 format"
}
}
}
{
"name": "ad_ts_bucket_event_gaps",
"description": "Find buckets with zero or suspiciously low event counts for a specific job. These are the buckets actually affected by missing data. Compare event_count across buckets to identify time ranges where data was lost. Correlate with delayed data annotations and anomaly scores to confirm false positives from missing data.",
"type": "esql",
"configuration": {
"query": "FROM .ml-anomalies-* | WHERE result_type == \"bucket\" AND job_id == ?job_id AND timestamp >= ?start_time AND timestamp <= ?end_time | SORT timestamp ASC | KEEP timestamp, event_count, anomaly_score, bucket_span, is_interim | LIMIT 500"
},
"parameters": {
"job_id": {
"type": "string",
"description": "The anomaly detection job ID"
},
"start_time": {
"type": "string",
"description": "Start of time range in ISO 8601 format"
},
"end_time": {
"type": "string",
"description": "End of time range in ISO 8601 format"
}
}
}
{
"name": "ad_ts_delayed_data_annotations",
"description": "Retrieve all delayed data annotations for a job, showing exactly when and how many documents were missed. The annotation field contains text like 'Datafeed has missed 30 documents due to ingest latency...' \u2014 frequent annotations indicate chronic ingest latency. This is the starting point for any 'missing documents' investigation.",
"type": "esql",
"configuration": {
"query": "FROM .ml-annotations-* | WHERE job_id == ?job_id AND event == \"delayed_data\" | SORT timestamp DESC | KEEP job_id, timestamp, end_timestamp, annotation | LIMIT 100"
},
"parameters": {
"job_id": {
"type": "string",
"description": "The anomaly detection job ID"
}
}
}
{
"name": "ad_ts_ingest_latency_estimate",
"description": "Measure actual ingest latency by comparing event timestamps with ingestion timestamps in the SOURCE data. Determines whether the current query_delay is sufficient. If P95(event.ingested - @timestamp) > query_delay, data will be lost. Requires the source index to have an event.ingested or _ingest.timestamp field.\n\nUsage: Get the source index from the job's datafeed config (via ad_get_job_datafeed_config). Pass it as source_index using LIKE wildcards if needed.",
"type": "esql",
"configuration": {
"query": "FROM * METADATA _index | WHERE _index LIKE ?source_index AND @timestamp >= ?start_time AND @timestamp <= ?end_time AND event.ingested IS NOT NULL | EVAL latency_seconds = DATE_DIFF(\"second\", @timestamp, event.ingested) | STATS p50_latency = PERCENTILE(latency_seconds, 50), p95_latency = PERCENTILE(latency_seconds, 95), p99_latency = PERCENTILE(latency_seconds, 99), max_latency = MAX(latency_seconds), doc_count = COUNT(*) | LIMIT 1"
},
"parameters": {
"source_index": {
"type": "string",
"description": "Source data index name or LIKE pattern (* = multi-char wildcard). Get this from the job's datafeed config. Examples: 'rcaeval-re1-ob', 'nab', 'otel-flat-*', 'smd'"
},
"start_time": {
"type": "string",
"description": "Start of time range in ISO 8601 format"
},
"end_time": {
"type": "string",
"description": "End of time range in ISO 8601 format"
}
}
}
{
"name": "ad_ts_model_memory_health",
"description": "Get memory health and growth trend for a job. Returns model_size_stats records in reverse-chronological order. Use limit=1 for a fast current-state snapshot (hard_limit/soft_limit check); use limit=500 for full memory growth trend analysis (stable plateau / linear / exponential). Interpretation: hard_limit = CRITICAL (job blind to new entities), soft_limit = WARNING (aggressive pruning), model_bytes/model_bytes_memory_limit > 0.8 = APPROACHING LIMIT.",
"type": "esql",
"configuration": {
"query": "FROM .ml-anomalies-* | WHERE result_type == \"model_size_stats\" AND job_id == ?job_id | SORT timestamp DESC | KEEP job_id, timestamp, model_bytes, peak_model_bytes, model_bytes_memory_limit, model_bytes_exceeded, memory_status, total_by_field_count, total_over_field_count, total_partition_field_count, bucket_allocation_failures_count | LIMIT ?limit"
},
"parameters": {
"job_id": {
"type": "string",
"description": "The anomaly detection job ID"
},
"limit": {
"type": "string",
"description": "Number of historical records to return. Use 1 for current memory status (fast snapshot), 500 for full memory growth trend analysis."
}
}
}
name: ad_create_calendar_event
description: >
Add a scheduled event to a calendar to suppress false positives during known downtime, maintenance windows, or
holidays.
enabled: true
tags: ["anomaly-detection", "remediation"]
inputs:
- name: calendar_id
type: string
description: The calendar ID
- name: event_description
type: string
description: "Event description (e.g., 'Planned maintenance window')"
- name: start_time
type: string
description: Event start time in ISO 8601 format
- name: end_time
type: string
description: Event end time in ISO 8601 format
triggers:
- type: manual
steps:
- name: create_event
type: elasticsearch.request
with:
method: POST
path: /_ml/calendars/{{ inputs.calendar_id }}/events
body:
events:
- description: "{{ inputs.event_description }}"
start_time: "{{ inputs.start_time }}"
end_time: "{{ inputs.end_time }}"
- name: result
type: console
with:
message: |
Created event '{{ inputs.event_description }}' on calendar {{ inputs.calendar_id }}
from {{ inputs.start_time }} to {{ inputs.end_time }}.
name: ad_create_datafeed
description: >
Create or replace a datafeed for an anomaly detection job via PUT _ml/datafeeds/{datafeed_id}. Use after job creation
and before opening the job / starting the datafeed.
enabled: true
tags: ["anomaly-detection", "management"]
inputs:
- name: datafeed_id
type: string
description: Datafeed ID (typically datafeed-{job_id})
- name: datafeed_body
type: string
description: >
Full datafeed configuration as JSON text. Parsed with json_parse so the PUT body is a structured object for the ML
API, not a JSON-encoded string.
triggers:
- type: manual
steps:
- name: put_datafeed
type: elasticsearch.request
with:
method: PUT
path: /_ml/datafeeds/{{ inputs.datafeed_id }}
body: "${{ inputs.datafeed_body | json_parse }}"
- name: result
type: console
with:
message: |
Datafeed {{ inputs.datafeed_id }}:
{{ steps.put_datafeed.output | json:2 }}
name: ad_create_job
description: >
Create a new anomaly detection job from a configuration. Stretch goal — for advanced agent use cases where the agent
helps design and create jobs based on data exploration. job_body is JSON text; the step uses Liquid json_parse and typed
interpolation (${{ }}) so the PUT body is a structured JSON object for the ML API, not a JSON-encoded string.
enabled: true
tags: ["anomaly-detection", "management"]
inputs:
- name: job_id
type: string
description: The new job ID to create
- name: job_body
type: string
description: >
Full job configuration as JSON text (same shape as PUT /_ml/anomaly_detectors/{job_id}). Parsed with json_parse
before the request so the HTTP body is an object. Runners that support a native object input may still pass JSON
text here.
triggers:
- type: manual
steps:
- name: create_job
type: elasticsearch.request
with:
method: PUT
path: /_ml/anomaly_detectors/{{ inputs.job_id }}
body: "${{ inputs.job_body | json_parse }}"
- name: result
type: console
with:
message: |
Created job {{ inputs.job_id }}:
{{ steps.create_job.output | json:2 }}
name: ad_discover_jobs_by_datafeed_index
description: >
Given a job of interest, find all other jobs whose datafeed reads from overlapping source indices. Step 1 retrieves
the target job config (including datafeed_config.indices). Step 2 logs the target job summary. Step 3 iterates over
each index in that list and queries .ml-config for other datafeed documents containing the same index, logging matches
per index pattern. Jobs reading from the same indices monitor the same system and are strong candidates for cross-job
correlation.
enabled: true
tags: ["anomaly-detection", "rca", "discovery"]
inputs:
- name: job_id
type: string
description: >
The job ID of interest whose source indices you want to match against all other jobs.
triggers:
- type: manual
steps:
- name: get_target_job
type: elasticsearch.request
with:
method: GET
path: /_ml/anomaly_detectors/{{ inputs.job_id }}
- name: log_target
type: console
with:
message: |
TARGET JOB: {{ inputs.job_id }}
Source indices: {{ steps.get_target_job.output.jobs[0].datafeed_config.indices }}
Searching for jobs with overlapping source indices...
- name: find_related_jobs
type: foreach
foreach: "${{ steps.get_target_job.output.jobs[0].datafeed_config.indices }}"
steps:
- name: search_matching_datafeeds
type: elasticsearch.search
with:
index: .ml-config
size: 200
_source: ["job_id", "datafeed_id", "indices"]
query:
bool:
filter:
- term:
config_type: datafeed
- term:
indices: "{{ foreach.item }}"
must_not:
- term:
job_id: "{{ inputs.job_id }}"
- name: log_matches
type: console
with:
message: |
[{{ foreach.index | plus: 1 }}/{{ foreach.total }}] Index: {{ foreach.item }}
Matching jobs ({{ steps.search_matching_datafeeds.output.hits.total.value }}):
{{ steps.search_matching_datafeeds.output.hits.hits | json:2 }}
name: ad_estimate_memory_requirement
description: >
Compute a principled model_memory_limit estimate by automatically sampling cardinality from source data and calling
the Estimate Model Memory API. Dramatically better than guessing or peak_model_bytes * 1.3 because it uses the exact
same estimation algorithm Elasticsearch uses internally. Steps: (1) retrieve job+datafeed config, (2) identify fields
requiring cardinality estimates, (3) compute overall_cardinality via cardinality aggregations, (4) compute
max_bucket_cardinality via date_histogram + cardinality + max_bucket pipeline, (5) call Estimate Model Memory API, (6)
compare with current state, (7) produce recommendation.
enabled: true
tags: ["anomaly-detection", "troubleshooting", "memory"]
inputs:
- name: job_id
type: string
description: The anomaly detection job ID
triggers:
- type: manual
steps:
# Step 1: Retrieve job configuration
- name: get_job_config
type: elasticsearch.request
with:
method: GET
path: /_ml/anomaly_detectors/{{ inputs.job_id }}
- name: get_job_stats
type: elasticsearch.request
with:
method: GET
path: /_ml/anomaly_detectors/{{ inputs.job_id }}/_stats
# Step 2: Log what we found
# The agent (or manual reviewer) inspects the config to identify:
# - overall_fields: all partition/by/over field names from detectors
# - pure_influencers: influencer fields NOT used in any detector split
# - datafeed source indices and query
- name: log_config
type: console
with:
message: |
Job config retrieved for {{ inputs.job_id }}.
Analysis config: {{ steps.get_job_config.output | json:2 }}
Current stats: {{ steps.get_job_stats.output | json:2 }}
MANUAL STEP REQUIRED:
1. From analysis_config.detectors, extract all unique by_field_name,
over_field_name, partition_field_name values → these are "overall_fields"
2. From analysis_config.influencers, find fields NOT in any detector →
these are "pure_influencers"
3. From datafeed_config, get indices[] and query{}
4. Run cardinality aggregations on those indices for each field
5. Run date_histogram(bucket_span) + cardinality sub-agg + max_bucket
pipeline for each pure influencer
6. Call POST _ml/anomaly_detectors/_estimate_model_memory with the
analysis_config and computed cardinalities
7. Compare the estimate with current model_memory_limit and model_bytes
See tools/workflow/ad_estimate_memory_requirement.json for the full
step-by-step algorithm.
name: ad_get_calendar_events
description: >
Get scheduled events from calendars (maintenance windows, holidays). These suppress anomaly detection during known
downtime periods.
enabled: true
tags: ["anomaly-detection", "config"]
inputs:
- name: calendar_id
type: string
description: The calendar ID
triggers:
- type: manual
steps:
- name: get_events
type: elasticsearch.request
with:
method: GET
path: /_ml/calendars/{{ inputs.calendar_id }}/events
- name: result
type: console
with:
message: |
Calendar events for {{ inputs.calendar_id }}:
{{ steps.get_events.output | json:2 }}
name: ad_get_job_datafeed_config
description: >
Fetch complete job and datafeed configuration in one call: detectors, by/over/partition fields, bucket_span,
frequency, query_delay, delayed_data_check_config, source indices, and datafeed query. Essential for troubleshooting
and for ad_estimate_memory_requirement.
enabled: true
tags: ["anomaly-detection", "config"]
inputs:
- name: job_id
type: string
description: The anomaly detection job ID
triggers:
- type: manual
steps:
- name: get_job
type: elasticsearch.request
with:
method: GET
path: /_ml/anomaly_detectors/{{ inputs.job_id }}
- name: get_job_stats
type: elasticsearch.request
with:
method: GET
path: /_ml/anomaly_detectors/{{ inputs.job_id }}/_stats
- name: summary
type: console
with:
message: |
Job: {{ inputs.job_id }}
Config: {{ steps.get_job.output | json:2 }}
Stats: {{ steps.get_job_stats.output | json:2 }}
name: ad_get_log_categories
description: >
Retrieve ML log category details — terms, regex pattern, and example messages — for a specific category from a log
categorization job. Use when investigating anomalies where by_field_name == "mlcategory" to understand what type of
log message the category represents before comparing samples across time windows.
enabled: true
tags: ["anomaly-detection", "log-categorization"]
inputs:
- name: job_id
type: string
description: The categorization job ID
- name: category_id
type: string
description: "The category ID from the anomaly record's by_field_value"
triggers:
- type: manual
steps:
- name: get_categories
type: elasticsearch.request
with:
method: GET
path: /_ml/anomaly_detectors/{{ inputs.job_id }}/results/categories/{{ inputs.category_id }}
- name: result
type: console
with:
message: |
Log category {{ inputs.category_id }} for job {{ inputs.job_id }}:
{{ steps.get_categories.output | json:2 }}
name: ad_manage_datafeed
description: >
Start or stop a datafeed via POST _ml/datafeeds/{id}/{_start|_stop}. Used in remediation sequences (for example stop
before updating query_delay, then restart). For payload preview use ad_preview_datafeed_with_latency (GET
_ml/datafeeds/{id}/_preview); preview is not supported here because it requires GET, not POST.
enabled: true
tags: ["anomaly-detection", "remediation"]
inputs:
- name: datafeed_id
type: string
description: "The datafeed ID (typically 'datafeed-{job_id}')"
- name: action
type: string
description: "Action to perform: _start or _stop only"
triggers:
- type: manual
steps:
- name: manage_datafeed
type: elasticsearch.request
with:
method: POST
path: /_ml/datafeeds/{{ inputs.datafeed_id }}/{{ inputs.action }}
- name: result
type: console
with:
message: |
Datafeed {{ inputs.datafeed_id }} action {{ inputs.action }}: {{ steps.manage_datafeed.output | json:2 }}
name: ad_open_job
description: >
Open an anomaly detection job so it can receive data and run analysis (POST _ml/anomaly_detectors/{job_id}/_open).
enabled: true
tags: ["anomaly-detection", "management"]
inputs:
- name: job_id
type: string
description: The anomaly detection job ID
triggers:
- type: manual
steps:
- name: open_job
type: elasticsearch.request
with:
method: POST
path: /_ml/anomaly_detectors/{{ inputs.job_id }}/_open
- name: result
type: console
with:
message: |
Opened job {{ inputs.job_id }}:
{{ steps.open_job.output | json:2 }}
name: ad_preview_datafeed_with_latency
description: >
Preview a datafeed's source payload and measure effective latency before tuning query_delay. Shows what data the
datafeed would see at query time, helping identify fields available for latency measurement.
enabled: true
tags: ["anomaly-detection", "troubleshooting"]
inputs:
- name: datafeed_id
type: string
description: The datafeed ID to preview
triggers:
- type: manual
steps:
- name: preview_datafeed
type: elasticsearch.request
with:
method: GET
path: /_ml/datafeeds/{{ inputs.datafeed_id }}/_preview
- name: result
type: console
with:
message: |
Datafeed preview for {{ inputs.datafeed_id }}:
{{ steps.preview_datafeed.output | json:2 }}
name: ad_revert_model_snapshot
description: >
Revert a job's model to a previous snapshot to 'unlearn' bad data. Stops the datafeed, closes the job, reverts to the
snapshot, reopens, and restarts the datafeed from the snapshot timestamp.
enabled: true
tags: ["anomaly-detection", "remediation"]
inputs:
- name: job_id
type: string
description: The anomaly detection job ID
- name: snapshot_id
type: string
description: The snapshot ID to revert to
triggers:
- type: manual
steps:
- name: stop_datafeed
type: elasticsearch.request
with:
method: POST
path: /_ml/datafeeds/datafeed-{{ inputs.job_id }}/_stop
- name: close_job
type: elasticsearch.request
with:
method: POST
path: /_ml/anomaly_detectors/{{ inputs.job_id }}/_close
- name: revert_snapshot
type: elasticsearch.request
with:
method: POST
path: /_ml/anomaly_detectors/{{ inputs.job_id }}/model_snapshots/{{ inputs.snapshot_id }}/_revert
- name: open_job
type: elasticsearch.request
with:
method: POST
path: /_ml/anomaly_detectors/{{ inputs.job_id }}/_open
- name: start_datafeed
type: elasticsearch.request
with:
method: POST
path: /_ml/datafeeds/datafeed-{{ inputs.job_id }}/_start
- name: result
type: console
with:
message: |
Reverted {{ inputs.job_id }} to snapshot {{ inputs.snapshot_id }}.
Job reopened and datafeed restarted from snapshot timestamp.
name: ad_search_log_category_examples
description: >
Search a source log index for messages matching specific ML category terms within a time window. Returns concrete log
examples belonging to a category during a specific period. Call twice — once for the anomaly window, once for a
baseline period (e.g. 24h prior) — to compare log content and identify what changed in the variable parts (IPs,
hostnames, error codes) that may reveal the root cause.
enabled: true
tags: ["anomaly-detection", "log-categorization", "evidence"]
inputs:
- name: source_index
type: string
description: "The source log index (from ad_get_job_datafeed_config)"
default: "it_ops_logs"
- name: search_terms
type: string
description: "Category terms copied from ad_get_log_categories output"
- name: start_time
type: string
description: "Start of the time window (ISO 8601)"
- name: end_time
type: string
description: "End of the time window (ISO 8601)"
triggers:
- type: manual
steps:
- name: search_logs
type: elasticsearch.search
with:
index: "{{ inputs.source_index }}"
size: 20
sort: "@timestamp:desc"
query:
bool:
must:
- match:
message:
query: "{{ inputs.search_terms }}"
minimum_should_match: "70%"
filter:
- range:
"@timestamp":
gte: "{{ inputs.start_time }}"
lte: "{{ inputs.end_time }}"
- name: result
type: console
with:
message: |
Log examples matching category terms in {{ inputs.source_index }}
Time window: {{ inputs.start_time }} to {{ inputs.end_time }}
Results: {{ steps.search_logs.output.hits.hits | json:2 }}
name: ad_ts_ccs_diagnostics
description: >
Diagnose cross-cluster search (CCS) issues for datafeeds that query remote clusters. Checks remote cluster
connectivity, latency, and error rates. Helps identify per-cluster skew contributing to missing data.
enabled: true
tags: ["anomaly-detection", "troubleshooting", "ccs"]
triggers:
- type: manual
steps:
- name: check_remote_clusters
type: elasticsearch.request
with:
method: GET
path: /_remote/info
- name: check_cluster_health
type: elasticsearch.request
with:
method: GET
path: /_cluster/health
- name: result
type: console
with:
message: |
Remote clusters: {{ steps.check_remote_clusters.output | json:2 }}
Cluster health: {{ steps.check_cluster_health.output | json:2 }}
name: ad_update_datafeed_query_delay
description: >
Update the query_delay setting on a datafeed. The datafeed must be stopped first. Larger query_delay captures more
late-arriving data but delays anomaly alerts. Recommended: set to P95 ingest latency + buffer.
enabled: true
tags: ["anomaly-detection", "remediation"]
inputs:
- name: datafeed_id
type: string
description: The datafeed ID
- name: new_query_delay
type: string
description: "New query_delay value (e.g., '3m', '120s', '5m')"
triggers:
- type: manual
steps:
- name: stop_datafeed
type: elasticsearch.request
with:
method: POST
path: /_ml/datafeeds/{{ inputs.datafeed_id }}/_stop
- name: update_query_delay
type: elasticsearch.request
with:
method: POST
path: /_ml/datafeeds/{{ inputs.datafeed_id }}/_update
body:
query_delay: "{{ inputs.new_query_delay }}"
- name: start_datafeed
type: elasticsearch.request
with:
method: POST
path: /_ml/datafeeds/{{ inputs.datafeed_id }}/_start
- name: result
type: console
with:
message: |
Updated query_delay on {{ inputs.datafeed_id }} to {{ inputs.new_query_delay }}.
Datafeed restarted.
name: ad_update_delayed_data_check_config
description: >
Update the delayed_data_check_config on a job to control how aggressively delayed data is detected. POST nests under
analysis_config.delayed_data_check_config. A data.parseJson step builds JSON so enabled is a boolean (not a quoted
string) and check_window is omitted when the input is empty or whitespace-only after trim.
enabled: true
tags: ["anomaly-detection", "remediation"]
inputs:
- name: job_id
type: string
description: The anomaly detection job ID
- name: enabled
type: boolean
description: Whether to enable delayed data checks
- name: check_window
type: string
description: "Time window to check for delayed data (e.g., '2h'). Leave empty, omit, or whitespace-only to exclude check_window from the update body."
triggers:
- type: manual
steps:
- name: compose_payload
type: data.parseJson
source: |
{%- assign cw = inputs.check_window | default: "" | strip -%}
{%- if cw != "" -%}
{"analysis_config":{"delayed_data_check_config":{"enabled":{{ inputs.enabled }},"check_window":{{ cw | json }}}}
{%- else -%}
{"analysis_config":{"delayed_data_check_config":{"enabled":{{ inputs.enabled }}}}
{%- endif -%}
with: {}
- name: update_delayed_data
type: elasticsearch.request
with:
method: POST
path: /_ml/anomaly_detectors/{{ inputs.job_id }}/_update
body: "${{ steps.compose_payload.output }}"
- name: result
type: console
with:
message: |
Updated delayed_data_check_config on {{ inputs.job_id }}:
{{ steps.update_delayed_data.output | json:2 }}
name: ad_update_model_memory_limit
description: >
Remediation workflow to change analysis_limits.model_memory_limit on an anomaly detector (for example after
ad_estimate_memory_requirement). Stops the datafeed, closes the job, POSTs /_ml/anomaly_detectors/{job_id}/_update with
the new limit, opens the job, and starts the datafeed again. Expects the default datafeed id datafeed-{job_id}. You
cannot decrease model_memory_limit below current model_bytes — clone the job to shrink.
enabled: true
tags: ["anomaly-detection", "remediation"]
inputs:
- name: job_id
type: string
description: The anomaly detection job ID
- name: model_memory_limit
type: string
description: "New model_memory_limit value (e.g., '512mb', '1gb')"
triggers:
- type: manual
steps:
- name: stop_datafeed
type: elasticsearch.request
with:
method: POST
path: /_ml/datafeeds/datafeed-{{ inputs.job_id }}/_stop
- name: close_job
type: elasticsearch.request
with:
method: POST
path: /_ml/anomaly_detectors/{{ inputs.job_id }}/_close
- name: update_model_memory_limit
type: elasticsearch.request
with:
method: POST
path: /_ml/anomaly_detectors/{{ inputs.job_id }}/_update
body:
analysis_limits:
model_memory_limit: "{{ inputs.model_memory_limit }}"
- name: open_job
type: elasticsearch.request
with:
method: POST
path: /_ml/anomaly_detectors/{{ inputs.job_id }}/_open
- name: start_datafeed
type: elasticsearch.request
with:
method: POST
path: /_ml/datafeeds/datafeed-{{ inputs.job_id }}/_start
- name: result
type: console
with:
message: |
Updated model_memory_limit on {{ inputs.job_id }} to {{ inputs.model_memory_limit }}.
Job reopened and datafeed restarted.
name: ad_validate_job_spec
description: >
Validate an anomaly detection job configuration before creation. POSTs to the ML validate endpoint with the same JSON
document shape as PUT job creation. Requires manage_ml (cluster).
enabled: true
tags: ["anomaly-detection", "management"]
inputs:
- name: job_body
type: string
description: >
Full job configuration as JSON text (same shape as job creation). Parsed with json_parse so the POST body is a
structured object for _validate, not a JSON string value.
triggers:
- type: manual
steps:
- name: validate_job
type: elasticsearch.request
with:
method: POST
path: /_ml/anomaly_detectors/_validate
body: "${{ inputs.job_body | json_parse }}"
- name: result
type: console
with:
message: |
Validation result:
{{ steps.validate_job.output | json:2 }}
name: ad_validate_ml_tool_permissions
description: >
Preflight check for core ML result and config indices via _has_privileges. Verifies read + view_index_metadata on
.ml-anomalies-*, .ml-config, .ml-annotations-*, and .ml-notifications-*. Does not check privileges on job-specific
source data indices — validate those separately before ad_rca_source_evidence, datafeed preview, or ad_wf_ts_field_cardinality.
enabled: true
tags: ["anomaly-detection", "diagnostics"]
triggers:
- type: manual
steps:
- name: check_security
type: elasticsearch.request
with:
method: GET
path: /_security/_authenticate
- name: check_ml_privileges
type: elasticsearch.request
with:
method: POST
path: /_security/user/_has_privileges
body:
index:
- names: [".ml-anomalies-*"]
privileges: ["read", "view_index_metadata"]
- names: [".ml-config"]
privileges: ["read", "view_index_metadata"]
- names: [".ml-annotations-*"]
privileges: ["read", "view_index_metadata"]
- names: [".ml-notifications-*"]
privileges: ["read", "view_index_metadata"]
- name: result
type: console
with:
message: |
User: {{ steps.check_security.output.username }}
Roles: {{ steps.check_security.output.roles | json }}
ML index privileges: {{ steps.check_ml_privileges.output | json:2 }}
name: ad_wf_troubleshoot_anomaly_score
description: >
Stored workflow for troubleshooting unexpectedly high or low anomaly scores. Implements a branching decision tree: (0)
gate checks (sufficient data, memory status, delayed data, UI aggregation), (1) UI display vs real score
(renormalization), (2) job configuration analysis (bucket_span, detector function, partition/influencer
fields, custom rules),
(3) model learning and data characteristics (insufficient history, high variance
penalty, model adaptation),
(4) score factor education (anomaly_score_explanation breakdown). Trigger: 'Why is my score low?', 'Expected anomaly
not detected', 'Score too high/low'.
enabled: true
tags: ["anomaly-detection", "troubleshooting", "scores"]
inputs:
- name: job_id
type: string
description: The anomaly detection job ID
- name: record_timestamp
type: string
description: "Optional: ISO 8601 timestamp of the specific anomaly record to investigate"
triggers:
- type: manual
steps:
# Gate check 0a: Has the job processed enough data?
- name: get_job_stats
type: elasticsearch.request
with:
method: GET
path: /_ml/anomaly_detectors/{{ inputs.job_id }}/_stats
- name: gate_check
type: console
with:
message: |
GATE CHECKS for {{ inputs.job_id }}:
Job stats: {{ steps.get_job_stats.output | json:2 }}
CHECK 1 - Sufficient data:
Model needs >= 3 weeks for weekly seasonality, >= 2 full cycles.
Check data_counts.processed_record_count and earliest_record_timestamp.
CHECK 2 - Memory status:
If memory_status is soft_limit or hard_limit, the model is degraded.
Fix memory first before investigating scores.
CHECK 3 - Delayed data:
If the job has delayed data warnings, bucket scores may be based on
incomplete data. Check .ml-annotations-* for delayed data annotations.
# Step 1: Compare record_score vs initial_record_score
- name: get_anomaly_records
type: elasticsearch.search
with:
index: .ml-anomalies-*
size: 10
sort: "record_score:desc"
query:
bool:
filter:
- term:
job_id: "{{ inputs.job_id }}"
- term:
result_type: record
- name: score_comparison
type: console
with:
message: |
SCORE ANALYSIS for {{ inputs.job_id }}:
Top records: {{ steps.get_anomaly_records.output.hits.hits | json:2 }}
Compare initial_record_score vs record_score:
- If initial >> current: renormalization lowered the score after more
extreme anomalies appeared later. This is EXPECTED behavior.
- initial_record_score is the score at detection time.
- record_score is the current (renormalized) score.
# Step 2: Get job configuration for analysis
- name: get_job_config
type: elasticsearch.request
with:
method: GET
path: /_ml/anomaly_detectors/{{ inputs.job_id }}
- name: config_analysis
type: console
with:
message: |
JOB CONFIGURATION ANALYSIS:
Config: {{ steps.get_job_config.output | json:2 }}
Check these factors:
1. bucket_span: Too large → dilutes anomalies. Too small → noisy.
2. Detector function: mean vs high_mean vs low_mean affects directionality.
3. Partition fields: High cardinality partitions split the model thin.
4. custom_rules: May be suppressing valid anomalies.
5. use_null: If false (default), missing entities produce no anomalies.
# Step 3-4: Score factor explanation
- name: score_education
type: console
with:
message: |
ANOMALY SCORE FACTORS (from anomaly_score_explanation):
1. anomaly_length: How many consecutive buckets are anomalous.
Longer sequences → higher scores.
2. single_bucket_impact: How extreme this single bucket is.
Driven by probability (lower p → higher impact).
3. multi_bucket_impact: Positive (0-5) when anomaly spans multiple
buckets. Values >= 3 suggest genuine behavioral shift.
4. anomaly_characteristics_impact: Nature of the anomaly (mean shift
vs variance change).
5. high_variance_penalty: REDUCES score when the model's confidence
bounds are wide. Common early in model training or with noisy data.
Wide bounds → model is uncertain → anomalies appear less surprising.
6. incomplete_bucket_penalty: REDUCES score when the bucket doesn't
have the expected amount of data (e.g., due to delayed data).
To see these factors, query the specific record from .ml-anomalies-*
and inspect the anomaly_score_explanation field.
name: ad_wf_ts_field_cardinality
description: >
Estimate cardinality of a split field (by_field, over_field, partition_field) in SOURCE data via ES|QL
POST /_query. The column for COUNT_DISTINCT is interpolated into the query text (not a ? parameter - those bind only
literals). Pass split_field_esql as a valid ES|QL column reference (for example service.keyword or `host.name.keyword`)
from ad_get_job_datafeed_config. Compare distinct_count with total_*_count from ad_ts_model_memory_health; if source
cardinality is much larger, entities may be dropped. For CCS, run per cluster and sum. Prefer ad_estimate_memory_requirement
for full sizing; this workflow answers how many distinct values the field has in the window.
enabled: true
tags: ["anomaly-detection", "diagnostics"]
inputs:
- name: source_index
type: string
description: >
Source index name or LIKE pattern (* wildcard). From the job datafeed config (same as ES|QL FROM * METADATA _index filter).
- name: split_field_esql
type: string
description: >
Exact ES|QL column expression for COUNT_DISTINCT (not quoted as a string). Examples: service.keyword, `host.name.keyword`.
Must match a field on matched documents; use only values taken from job analysis config to avoid query injection.
- name: start_time
type: string
description: Start of time range in ISO 8601 format
- name: end_time
type: string
description: End of time range in ISO 8601 format
triggers:
- type: manual
steps:
- name: cardinality_esql
type: elasticsearch.request
with:
method: POST
path: /_query
body:
query: "FROM * METADATA _index | WHERE _index LIKE ?source_index AND @timestamp >= ?start_time AND @timestamp <= ?end_time | STATS distinct_count = COUNT_DISTINCT({{ inputs.split_field_esql }}) | LIMIT 1"
params:
source_index: "{{ inputs.source_index }}"
start_time: "{{ inputs.start_time }}"
end_time: "{{ inputs.end_time }}"
- name: result
type: console
with:
message: |
Split-field cardinality ({{ inputs.split_field_esql }}) on indices matching {{ inputs.source_index }} ({{ inputs.start_time }}-{{ inputs.end_time }}):
{{ steps.cardinality_esql.output | json:2 }}
{
"default_agent_id": "elastic-ai-agent",
"default_agent_enable_elastic_capabilities": true,
"workflow_tool_exclusions": [
"ad_get_job_datafeed_config",
"ad_ts_ccs_diagnostics",
"ad_get_calendar_events",
"ad_create_calendar_event",
"ad_discover_jobs_by_datafeed_index"
],
"fallback_tools": {
"ad_get_index_mappings": "platform.core.get_index_mapping"
},
"workflow_prefixes": [
"ad_wf_",
"ad_create_",
"ad_manage_",
"ad_open_",
"ad_update_",
"ad_revert_",
"ad_preview_",
"ad_validate_",
"ad_estimate_"
],
"builtin_tools": [
"platform.core.search",
"platform.core.list_indices",
"platform.core.get_index_mapping",
"platform.core.execute_esql",
"platform.core.generate_esql",
"platform.core.product_documentation"
]
}
Related skills
FAQ
What modes does kibana-anomaly-detection support?
Investigate for RCA, Explain for scores, Troubleshoot for datafeed and memory issues, and Manage for job creation.
What should I run first on empty ML results?
ad_validate_ml_tool_permissions because missing privileges are the most common false-negative cause.
How do I change model memory or query_delay?
Stop datafeed, close job, update config, open job, then start datafeed in that order.