Skill: davidgeorgehope/elasticsearch-skill (version 0.1.0)

--- name: elasticsearch description: > Interact with Elasticsearch and Kibana via REST API using curl. Use when querying, indexing, managing indices, checking cluster health, writing aggregations, deploying dashboards, or troubleshooting Elasticsearch. Requires cluster URL and API key. Covers: search (Query DSL), CRUD operations, index management, mappings, aggregations, cluster health, ILM, ES|QL, Kibana API (dashboards, data views, saved objects), OpenTelemetry data patterns, and common troubleshooting patterns. --- # Elasticsearch All Elasticsearch interaction is via REST API using `curl`. No SDK or client library required. ## Authentication Every request needs the cluster URL and an API key: ```bash # Set these for your session (or export in .env / shell profile) ES_URL="https://your-cluster.es.cloud.elastic.co:443" ES_API_KEY="your-base64-api-key" # All requests follow this pattern: curl -s "${ES_URL%/}/<endpoint>" \ -H "Authorization: ApiKey $(printenv ES_API_KEY)" \ -H "Content-Type: application/json" \ -d '<json-body>' ``` **API key format:** Base64-encoded `id:api_key` string. Pass as-is in the `Authorization: ApiKey` header. If the user provides a URL and key, export them as `ES_URL` and `ES_API_KEY` before running commands. **Important — variable expansion in curl:** - Always use `$(printenv ES_API_KEY)` instead of `$ES_API_KEY` in curl headers. The `$ES_API_KEY` variable may not expand correctly in the shell, resulting in empty `Authorization` headers and 401 errors. - Always use `${ES_URL%/}` to strip any trailing slash from the URL, preventing double-slash path issues (e.g., `//_cluster/health`). ## Quick Health Check ```bash # Cluster health (green/yellow/red) — NOT available on serverless curl -s "${ES_URL%/}/_cluster/health" -H "Authorization: ApiKey $(printenv ES_API_KEY)" | jq . # Node stats summary — NOT available on serverless curl -s "${ES_URL%/}/_cat/nodes?v&h=name,heap.percent,ram.percent,cpu,load_1m,disk.used_percent" \ -H "Authorization: ApiKey $(printenv ES_API_KEY)" # Index overview (works on both serverless and traditional) curl -s "${ES_URL%/}/_cat/indices?v&s=store.size:desc&h=index,health,status,docs.count,store.size" \ -H "Authorization: ApiKey $(printenv ES_API_KEY)" ``` **Serverless Elasticsearch:** If you get `api_not_available_exception` errors, the cluster is running in serverless mode. The following APIs are **not available** in serverless: - `_cluster/health`, `_cluster/settings`, `_cluster/allocation/explain`, `_cluster/pending_tasks` - `_cat/nodes`, `_cat/shards` - `_nodes/hot_threads`, `_nodes/stats` - ILM APIs (`_ilm/*`) Use `_cat/indices` and `_search` APIs as the starting point instead — these work everywhere. ## Search (Query DSL) ```bash # Simple match query curl -s "${ES_URL%/}/my-index/_search" \ -H "Authorization: ApiKey $(printenv ES_API_KEY)" \ -H "Content-Type: application/json" \ -d '{ "query": { "match": { "message": "error timeout" } }, "size": 10 }' | jq . # Bool query (must + filter + must_not) curl -s "${ES_URL%/}/my-index/_search" \ -H "Authorization: ApiKey $(printenv ES_API_KEY)" \ -H "Content-Type: application/json" \ -d '{ "query": { "bool": { "must": [ { "match": { "message": "error" } } ], "filter": [ { "range": { "@timestamp": { "gte": "now-1h" } } } ], "must_not": [ { "term": { "level": "debug" } } ] } }, "size": 20, "sort": [ { "@timestamp": { "order": "desc" } } ] }' | jq . ``` For full Query DSL reference (term, terms, range, wildcard, regexp, nested, exists, multi_match, etc.), see [references/query-dsl.md](references/query-dsl.md). ## Index & Document Operations ```bash # Create index with mappings curl -s -X PUT "${ES_URL%/}/my-index" \ -H "Authorization: ApiKey $(printenv ES_API_KEY)" \ -H "Content-Type: application/json" \ -d '{ "settings": { "number_of_shards": 1, "number_of_replicas": 1 }, "mappings": { "properties": { "message": { "type": "text" }, "@timestamp": { "type": "date" }, "level": { "type": "keyword" }, "count": { "type": "integer" } } } }' # Index a document (auto-generate ID) curl -s -X POST "${ES_URL%/}/my-index/_doc" \ -H "Authorization: ApiKey $(printenv ES_API_KEY)" \ -H "Content-Type: application/json" \ -d '{ "message": "hello world", "@timestamp": "2026-01-31T12:00:00Z", "level": "info" }' # Index with specific ID curl -s -X PUT "${ES_URL%/}/my-index/_doc/doc-123" \ -H "Authorization: ApiKey $(printenv ES_API_KEY)" \ -H "Content-Type: application/json" \ -d '{ "message": "specific doc", "level": "warn" }' # Get document curl -s "${ES_URL%/}/my-index/_doc/doc-123" -H "Authorization: ApiKey $(printenv ES_API_KEY)" | jq . # Update document (partial) curl -s -X POST "${ES_URL%/}/my-index/_update/doc-123" \ -H "Authorization: ApiKey $(printenv ES_API_KEY)" \ -H "Content-Type: application/json" \ -d '{ "doc": { "level": "error" } }' # Delete document curl -s -X DELETE "${ES_URL%/}/my-index/_doc/doc-123" \ -H "Authorization: ApiKey $(printenv ES_API_KEY)" # Bulk operations (newline-delimited JSON) curl -s -X POST "${ES_URL%/}/_bulk" \ -H "Authorization: ApiKey $(printenv ES_API_KEY)" \ -H "Content-Type: application/x-ndjson" \ --data-binary @- << 'EOF' {"index":{"_index":"my-index"}} {"message":"bulk doc 1","level":"info","@timestamp":"2026-01-31T12:00:00Z"} {"index":{"_index":"my-index"}} {"message":"bulk doc 2","level":"warn","@timestamp":"2026-01-31T12:01:00Z"} EOF ``` ## Aggregations ```bash # Terms aggregation (top values) curl -s "${ES_URL%/}/my-index/_search?size=0" \ -H "Authorization: ApiKey $(printenv ES_API_KEY)" \ -H "Content-Type: application/json" \ -d '{ "aggs": { "levels": { "terms": { "field": "level", "size": 10 } } } }' | jq '.aggregations' # Date histogram + nested metric curl -s "${ES_URL%/}/my-index/_search?size=0" \ -H "Authorization: ApiKey $(printenv ES_API_KEY)" \ -H "Content-Type: application/json" \ -d '{ "query": { "range": { "@timestamp": { "gte": "now-24h" } } }, "aggs": { "over_time": { "date_histogram": { "field": "@timestamp", "fixed_interval": "1h" }, "aggs": { "avg_count": { "avg": { "field": "count" } } } } } }' | jq '.aggregations' ``` For more aggregation types (cardinality, percentiles, composite, filters, significant_terms, etc.), see [references/aggregations.md](references/aggregations.md). ## Mappings & Index Management ```bash # Get mapping curl -s "${ES_URL%/}/my-index/_mapping" -H "Authorization: ApiKey $(printenv ES_API_KEY)" | jq . # Add field to existing mapping (mappings are additive — you can't change existing field types) curl -s -X PUT "${ES_URL%/}/my-index/_mapping" \ -H "Authorization: ApiKey $(printenv ES_API_KEY)" \ -H "Content-Type: application/json" \ -d '{ "properties": { "new_field": { "type": "keyword" } } }' # Reindex (change mappings, rename index, etc.) curl -s -X POST "${ES_URL%/}/_reindex" \ -H "Authorization: ApiKey $(printenv ES_API_KEY)" \ -H "Content-Type: application/json" \ -d '{ "source": { "index": "old-index" }, "dest": { "index": "new-index" } }' # Delete index curl -s -X DELETE "${ES_URL%/}/my-index" -H "Authorization: ApiKey $(printenv ES_API_KEY)" # Index aliases curl -s -X POST "${ES_URL%/}/_aliases" \ -H "Authorization: ApiKey $(printenv ES_API_KEY)" \ -H "Content-Type: application/json" \ -d '{ "actions": [ { "add": { "index": "my-index-v2", "alias": "my-index" } }, { "remove": { "index": "my-index-v1", "alias": "my-index" } } ] }' # Index templates (for time-series / rollover patterns) curl -s -X PUT "${ES_URL%/}/_index_template/my-template" \ -H "Authorization: ApiKey $(printenv ES_API_KEY)" \ -H "Content-Type: application/json" \ -d '{ "index_patterns": ["logs-*"], "template": { "settings": { "number_of_shards": 1 }, "mappings": { "properties": { "message": { "type": "text" }, "@timestamp": { "type": "date" } } } } }' ``` ## Cluster & Troubleshooting > **Note:** Most APIs in this section are **not available on serverless** Elasticsearch. They only work on self-managed or traditional Elastic Cloud deployments. ```bash # Allocation explanation (why is a shard unassigned?) — NOT serverless curl -s "${ES_URL%/}/_cluster/allocation/explain" \ -H "Authorization: ApiKey $(printenv ES_API_KEY)" \ -H "Content-Type: application/json" \ -d '{ "index": "my-index", "shard": 0, "primary": true }' | jq . # Pending tasks curl -s "${ES_URL%/}/_cluster/pending_tasks" -H "Authorization: ApiKey $(printenv ES_API_KEY)" | jq . # Hot threads (performance debugging) curl -s "${ES_URL%/}/_nodes/hot_threads" -H "Authorization: ApiKey $(printenv ES_API_KEY)" # Shard allocation curl -s "${ES_URL%/}/_cat/shards?v&s=store:desc&h=index,shard,prirep,state,docs,store,node" \ -H "Authorization: ApiKey $(printenv ES_API_KEY)" # Task management (long-running operations) curl -s "${ES_URL%/}/_tasks?actions=*search&detailed" -H "Authorization: ApiKey $(printenv ES_API_KEY)" | jq . # Cluster settings (persistent + transient) curl -s "${ES_URL%/}/_cluster/settings?include_defaults=false" \ -H "Authorization: ApiKey $(printenv ES_API_KEY)" | jq . ``` For Kibana API operations (dashboards, data views, saved objects, alerting rules), see [references/kibana-api.md](references/kibana-api.md). ## Data Streams & ILM > **Note:** ILM APIs (`_ilm/*`) are **not available on serverless**. Data stream listing works on both. ```bash # List data streams curl -s "${ES_URL%/}/_data_stream" -H "Authorization: ApiKey $(printenv ES_API_KEY)" | jq . # Create ILM policy curl -s -X PUT "${ES_URL%/}/_ilm/policy/my-policy" \ -H "Authorization: ApiKey $(printenv ES_API_KEY)" \ -H "Content-Type: application/json" \ -d '{ "policy": { "phases": { "hot": { "actions": { "rollover": { "max_age": "7d", "max_size": "50gb" } } }, "warm": { "min_age": "30d", "actions": { "shrink": { "number_of_shards": 1 } } }, "delete": { "min_age": "90d", "actions": { "delete": {} } } } } }' # Check ILM status for an index curl -s "${ES_URL%/}/my-index/_ilm/explain" -H "Authorization: ApiKey $(printenv ES_API_KEY)" | jq . ``` ## ES|QL (Elasticsearch Query Language) For Elasticsearch 8.11+, ES|QL offers a pipe-based query syntax: ```bash curl -s -X POST "${ES_URL%/}/_query" \ -H "Authorization: ApiKey $(printenv ES_API_KEY)" \ -H "Content-Type: application/json" \ -d '{ "query": "FROM logs-* | WHERE level == \"error\" | STATS count = COUNT(*) BY service.name | SORT count DESC | LIMIT 10" }' | jq . ``` For querying OpenTelemetry data (OTEL logs, traces, metrics, correlation patterns), see [references/otel-data.md](references/otel-data.md). ## Ingest Pipelines ```bash # Create pipeline curl -s -X PUT "${ES_URL%/}/_ingest/pipeline/my-pipeline" \ -H "Authorization: ApiKey $(printenv ES_API_KEY)" \ -H "Content-Type: application/json" \ -d '{ "processors": [ { "grok": { "field": "message", "patterns": ["%{TIMESTAMP_ISO8601:timestamp} %{LOGLEVEL:level} %{GREEDYDATA:msg}"] } }, { "date": { "field": "timestamp", "formats": ["ISO8601"] } }, { "remove": { "field": "timestamp" } } ] }' # Test pipeline curl -s -X POST "${ES_URL%/}/_ingest/pipeline/my-pipeline/_simulate" \ -H "Authorization: ApiKey $(printenv ES_API_KEY)" \ -H "Content-Type: application/json" \ -d '{ "docs": [ { "_source": { "message": "2026-01-31T12:00:00Z ERROR something broke" } } ] }' | jq . ``` ## Tips - **Always use `jq`** to format JSON output — Elasticsearch responses are verbose. - **`?size=0`** on search requests when you only want aggregations (skip hits). - **`_cat` APIs** (`_cat/indices`, `_cat/shards`, `_cat/nodes`) give human-readable tabular output — add `?v` for headers, `?format=json` for JSON. - **Scroll/PIT for large exports** — don't use `from`/`size` beyond 10,000 hits. Use search_after + PIT instead. - **Field types matter** — `keyword` for exact match/aggs, `text` for full-text search. Check mappings before querying. - **Date math in index names** — `logs-{now/d}` resolves to today's date. Useful for time-based indices.

path: README.md
content: # Elasticsearch Skill for Claude Code

A markdown-based skill that teaches Claude Code how to interact with Elasticsearch clusters via REST API. No SDK, no client library, no MCP server — just `curl` and knowledge.

## What's in it

| File | What it covers |
|------|---------------|
| `SKILL.md` | Auth, search, CRUD, bulk ops, index management, cluster health, ILM, ES\|QL, ingest pipelines |
| `references/query-dsl.md` | Full Query DSL — bool, match, term, range, nested, geo, wildcards, runtime fields, search_after |
| `references/aggregations.md` | Metric, bucket, and pipeline aggregations — plus SRE patterns (error rates, top-N leaderboards) |

## Installation

Claude Code loads skills from local directories, not from GitHub URLs. Clone this repo, then copy the files to one of the supported skill locations.

### Option A: Personal skill (available in all projects)

```bash
git clone https://github.com/davidgeorgehope/elasticsearch-skill.git
mkdir -p ~/.claude/skills/elasticsearch
cp elasticsearch-skill/SKILL.md ~/.claude/skills/elasticsearch/
cp -r elasticsearch-skill/references ~/.claude/skills/elasticsearch/
```

### Option B: Project skill (available in one repo only)

From within your project's root directory:

```bash
git clone https://github.com/davidgeorgehope/elasticsearch-skill.git /tmp/elasticsearch-skill
mkdir -p .claude/skills/elasticsearch
cp /tmp/elasticsearch-skill/SKILL.md .claude/skills/elasticsearch/
cp -r /tmp/elasticsearch-skill/references .claude/skills/elasticsearch/
```

Then commit the `.claude/skills/elasticsearch/` directory to your repo so your team gets it too.

### Verify

Restart Claude Code, then check the skill is loaded:

```
/skills
```

You should see `elasticsearch` in the list.

> **Note:** Adding a GitHub URL to the `skills` array in `~/.claude/settings.json` does **not** work. Skills must be local files — there is no remote fetching.

## Configuration

Set your cluster credentials as environment variables before starting Claude Code:

```bash
export ES_URL="https://your-cluster.es.cloud.elastic.co:443"
export ES_API_KEY="your-base64-api-key"
```

Optionally, for Kibana API access:

```bash
export KIBANA_URL="https://your-cluster.kb.cloud.elastic.co:443"
```

No server to run, no dependencies to install, no Docker container to spin up.

## Why a Skill and Not MCP?

This is a deliberate choice, not a limitation.

### The token tax

MCP servers expose tools via JSON schemas that get injected into the LLM's context window on every request. Anthropic's own testing found that a modest five-server setup with 58 tools consumed **~55,000 tokens before the conversation even started**. That's your context window being eaten alive by tool definitions you might not even use.

A skill loads its main instructions only when triggered, and deeper reference files only when the LLM actually needs them. Progressive disclosure by design — not by workaround.

### The full circle

The industry trajectory looks something like this:

1. We had OpenAPI specs for describing APIs
2. LLMs couldn't reliably call them, so MCP was created as an intermediary
3. Models got better at calling APIs directly
4. Now people are generating OpenAPI specs *from* MCP servers
5. Meanwhile, Anthropic's own proposed fix is: give the agent a code execution tool and let it write TypeScript that calls APIs directly

We went full circle. MCP solved "LLMs can't reliably call APIs" right before that problem got solved by better models.

### What a skill actually is

A skill is a markdown file that tells the LLM:

- Here's how authentication works
- Here's the curl pattern for every operation
- Here's a reference for the query language (load it when you need it)
- Here are common patterns and gotchas

The LLM reads this, then writes and executes the actual API calls. No protocol translation layer, no tool schema overhead, no server process to maintain.

As [Mario Zechner put it](https://marioslab.io/posts/codex/primitives-not-frameworks/): *"Skills achieve the same progressive disclosure Anthropic describes, but without needing MCP as the underlying protocol… I could replace their MCPs with markdown files that explain how to correctly call their APIs or use their CLIs."*

### The practical argument

An MCP server for Elasticsearch would mean:

- A Node.js/Python process to run and maintain
- Tool schemas consuming context tokens on every turn
- Version compatibility issues between the MCP server and your ES cluster
- Another dependency in your stack

This skill is:

- **Three markdown files** (~26KB total)
- Zero dependencies
- Zero processes
- Works with any Elasticsearch version (it's just REST)
- Loaded into context only when needed
- The LLM writes the actual curl commands, adapting to your specific cluster

### When MCP *does* make sense

MCP isn't pointless — it shines when you need:

- **Stateful connections** (database sessions, websockets)
- **Binary protocols** that can't be called via curl
- **Complex auth flows** (OAuth dance, token refresh)
- **Non-HTTP tools** (local file systems, hardware interfaces)

Elasticsearch is a REST API with a single auth header. It doesn't need a protocol translation layer. It needs a good explanation.

## License

MIT


path: references/aggregations-api.md
content: # Aggregations API Reference

Aggregations run inside `_search` requests under the `aggs` key. Combine with `"size": 0` to skip hits and only return aggregation results.

## Metric Aggregations

```bash
es "my-index/_search" -d '{
  "size": 0,
  "aggs": {
    "avg_duration": {"avg": {"field": "duration_ms"}},
    "max_duration": {"max": {"field": "duration_ms"}},
    "p95_duration": {"percentiles": {"field": "duration_ms", "percents": [50, 95, 99]}},
    "unique_users": {"cardinality": {"field": "user.id"}},
    "total_bytes": {"sum": {"field": "response.bytes"}},
    "duration_stats": {"stats": {"field": "duration_ms"}}
  }
}'
```

## Bucket Aggregations

**Terms** (group by field value):
```json
{
  "size": 0,
  "aggs": {
    "by_status": {
      "terms": {"field": "status.keyword", "size": 20},
      "aggs": {
        "avg_duration": {"avg": {"field": "duration_ms"}}
      }
    }
  }
}
```

**Date histogram** (time buckets):
```json
{
  "size": 0,
  "aggs": {
    "over_time": {
      "date_histogram": {
        "field": "@timestamp",
        "fixed_interval": "1h"
      },
      "aggs": {
        "error_count": {
          "filter": {"term": {"level": "error"}},
          "aggs": {"count": {"value_count": {"field": "_id"}}}
        }
      }
    }
  }
}
```

**calendar_interval** options: `minute`, `hour`, `day`, `week`, `month`, `quarter`, `year`
**fixed_interval** options: `30s`, `1m`, `5m`, `1h`, `1d`, etc.

**Range** (custom buckets):
```json
{
  "size": 0,
  "aggs": {
    "latency_ranges": {
      "range": {
        "field": "duration_ms",
        "ranges": [
          {"to": 100, "key": "fast"},
          {"from": 100, "to": 500, "key": "normal"},
          {"from": 500, "key": "slow"}
        ]
      }
    }
  }
}
```

**Histogram** (fixed-width numeric buckets):
```json
{
  "size": 0,
  "aggs": {
    "response_sizes": {
      "histogram": {"field": "response.bytes", "interval": 1000}
    }
  }
}
```

**Filter / Filters** (named query buckets):
```json
{
  "size": 0,
  "aggs": {
    "levels": {
      "filters": {
        "filters": {
          "errors": {"term": {"level": "error"}},
          "warnings": {"term": {"level": "warn"}},
          "info": {"term": {"level": "info"}}
        }
      }
    }
  }
}
```

## Nested Aggregations

Aggregations can nest — buckets contain sub-aggregations:

```json
{
  "size": 0,
  "aggs": {
    "by_service": {
      "terms": {"field": "service.name", "size": 10},
      "aggs": {
        "over_time": {
          "date_histogram": {"field": "@timestamp", "fixed_interval": "1h"},
          "aggs": {
            "p99": {"percentiles": {"field": "duration_ms", "percents": [99]}}
          }
        }
      }
    }
  }
}
```

## Pipeline Aggregations

Compute on other aggregation results:

**Derivative** (rate of change):
```json
{
  "aggs": {
    "per_hour": {
      "date_histogram": {"field": "@timestamp", "fixed_interval": "1h"},
      "aggs": {
        "total": {"sum": {"field": "bytes"}},
        "bytes_rate": {"derivative": {"buckets_path": "total"}}
      }
    }
  }
}
```

**Moving average**:
```json
{
  "aggs": {
    "per_day": {
      "date_histogram": {"field": "@timestamp", "calendar_interval": "day"},
      "aggs": {
        "daily_errors": {"value_count": {"field": "_id"}},
        "smoothed": {
          "moving_avg": {"buckets_path": "daily_errors", "window": 7}
        }
      }
    }
  }
}
```

**Bucket sort** (top-N buckets):
```json
{
  "aggs": {
    "by_service": {
      "terms": {"field": "service.name", "size": 100},
      "aggs": {
        "error_rate": {
          "avg": {"script": {"source": "doc['level'].value == 'error' ? 1 : 0"}}
        },
        "worst_first": {
          "bucket_sort": {"sort": [{"error_rate": {"order": "desc"}}], "size": 5}
        }
      }
    }
  }
}
```

## Composite Aggregation

Efficient pagination over all buckets (for exports):

```bash
es "my-index/_search" -d '{
  "size": 0,
  "aggs": {
    "my_composite": {
      "composite": {
        "size": 1000,
        "sources": [
          {"service": {"terms": {"field": "service.name"}}},
          {"hour": {"date_histogram": {"field": "@timestamp", "fixed_interval": "1h"}}}
        ]
      }
    }
  }
}'
# Use "after" from response to paginate: "after": {"service": "api", "hour": 1706745600000}
```


path: references/aggregations.md
content: # Aggregations Reference

Full reference for Elasticsearch aggregation types. All examples are JSON bodies for `POST /<index>/_search?size=0`.

## Table of Contents

- [Metric Aggregations](#metric-aggregations)
- [Bucket Aggregations](#bucket-aggregations)
- [Pipeline Aggregations](#pipeline-aggregations)
- [Patterns](#patterns)

## Metric Aggregations

### Basic metrics

```json
{
  "aggs": {
    "avg_response":  { "avg":  { "field": "response_time" } },
    "max_response":  { "max":  { "field": "response_time" } },
    "min_response":  { "min":  { "field": "response_time" } },
    "sum_bytes":     { "sum":  { "field": "bytes" } },
    "total_docs":    { "value_count": { "field": "_id" } }
  }
}
```

### stats / extended_stats

All basic metrics in one shot:

```json
{ "aggs": { "response_stats": { "stats": { "field": "response_time" } } } }
{ "aggs": { "response_ext": { "extended_stats": { "field": "response_time" } } } }
```

extended_stats adds: std_deviation, variance, std_deviation_bounds

### cardinality (approximate distinct count)

```json
{ "aggs": { "unique_users": { "cardinality": { "field": "user_id" } } } }
```

### percentiles / percentile_ranks

```json
{ "aggs": { "latency_pcts": { "percentiles": { "field": "response_time", "percents": [50, 90, 95, 99] } } } }
{ "aggs": { "under_200ms": { "percentile_ranks": { "field": "response_time", "values": [200, 500, 1000] } } } }
```

### top_hits (get sample documents per bucket)

```json
{
  "aggs": {
    "by_service": {
      "terms": { "field": "service.name" },
      "aggs": {
        "latest": {
          "top_hits": { "size": 3, "sort": [ { "@timestamp": "desc" } ], "_source": ["message", "level"] }
        }
      }
    }
  }
}
```

### weighted_avg

```json
{
  "aggs": {
    "weighted_score": {
      "weighted_avg": {
        "value": { "field": "score" },
        "weight": { "field": "importance" }
      }
    }
  }
}
```

## Bucket Aggregations

### terms (top N values)

```json
{
  "aggs": {
    "top_services": {
      "terms": { "field": "service.name", "size": 20, "order": { "_count": "desc" } }
    }
  }
}
```

Use `"missing": "unknown"` to include docs where field is absent.

### date_histogram

```json
{
  "aggs": {
    "over_time": {
      "date_histogram": {
        "field": "@timestamp",
        "fixed_interval": "1h",
        "min_doc_count": 0,
        "extended_bounds": { "min": "now-24h", "max": "now" }
      }
    }
  }
}
```

Intervals: `fixed_interval` (e.g., `30s`, `5m`, `1h`, `1d`) or `calendar_interval` (`minute`, `hour`, `day`, `week`, `month`, `year`).

### histogram (numeric buckets)

```json
{ "aggs": { "response_dist": { "histogram": { "field": "response_time", "interval": 100 } } } }
```

### range / date_range

```json
{
  "aggs": {
    "latency_buckets": {
      "range": {
        "field": "response_time",
        "ranges": [
          { "key": "fast",   "to": 100 },
          { "key": "medium", "from": 100, "to": 500 },
          { "key": "slow",   "from": 500 }
        ]
      }
    }
  }
}
```

### filters (named buckets from queries)

```json
{
  "aggs": {
    "status": {
      "filters": {
        "filters": {
          "errors": { "term": { "level": "error" } },
          "warnings": { "term": { "level": "warn" } },
          "info": { "term": { "level": "info" } }
        }
      }
    }
  }
}
```

### significant_terms (unusual terms vs background)

```json
{
  "query": { "term": { "level": "error" } },
  "aggs": {
    "unusual_words": { "significant_terms": { "field": "message.keyword", "size": 10 } }
  }
}
```

### composite (paginated aggregation — for large cardinality)

```json
{
  "aggs": {
    "paginated": {
      "composite": {
        "size": 100,
        "sources": [
          { "service": { "terms": { "field": "service.name" } } },
          { "level":   { "terms": { "field": "level" } } }
        ]
      }
    }
  }
}
```

Paginate by passing `"after": { "service": "last-value", "level": "last-value" }` from previous response.

### multi_terms (group by multiple fields)

```json
{
  "aggs": {
    "service_level": {
      "multi_terms": {
        "terms": [
          { "field": "service.name" },
          { "field": "level" }
        ],
        "size": 50
      }
    }
  }
}
```

### nested (aggregate on nested objects)

```json
{
  "aggs": {
    "comments": {
      "nested": { "path": "comments" },
      "aggs": {
        "top_authors": { "terms": { "field": "comments.author.keyword" } }
      }
    }
  }
}
```

### sampler / diversified_sampler

Limit analysis to top N scoring docs (performance optimization):

```json
{
  "aggs": {
    "sample": {
      "sampler": { "shard_size": 200 },
      "aggs": {
        "keywords": { "significant_terms": { "field": "message.keyword" } }
      }
    }
  }
}
```

## Pipeline Aggregations

Operate on output of other aggregations.

### moving_avg / moving_fn

```json
{
  "aggs": {
    "over_time": {
      "date_histogram": { "field": "@timestamp", "fixed_interval": "1h" },
      "aggs": {
        "avg_response": { "avg": { "field": "response_time" } },
        "smoothed": {
          "moving_fn": { "buckets_path": "avg_response", "window": 5, "script": "MovingFunctions.unweightedAvg(values)" }
        }
      }
    }
  }
}
```

### derivative (rate of change)

```json
{
  "aggs": {
    "over_time": {
      "date_histogram": { "field": "@timestamp", "fixed_interval": "1h" },
      "aggs": {
        "total": { "sum": { "field": "count" } },
        "rate_of_change": { "derivative": { "buckets_path": "total" } }
      }
    }
  }
}
```

### cumulative_sum

```json
{
  "aggs": {
    "over_time": {
      "date_histogram": { "field": "@timestamp", "fixed_interval": "1d" },
      "aggs": {
        "daily_count": { "value_count": { "field": "_id" } },
        "running_total": { "cumulative_sum": { "buckets_path": "daily_count" } }
      }
    }
  }
}
```

### bucket_script (compute across sibling metrics)

```json
{
  "aggs": {
    "by_service": {
      "terms": { "field": "service.name" },
      "aggs": {
        "errors":  { "filter": { "term": { "level": "error" } } },
        "total":   { "value_count": { "field": "_id" } },
        "error_rate": {
          "bucket_script": {
            "buckets_path": { "err": "errors._count", "tot": "total" },
            "script": "params.err / params.tot * 100"
          }
        }
      }
    }
  }
}
```

### bucket_sort (sort/limit aggregation buckets)

```json
{
  "aggs": {
    "by_service": {
      "terms": { "field": "service.name", "size": 100 },
      "aggs": {
        "avg_latency": { "avg": { "field": "response_time" } },
        "sort_by_latency": {
          "bucket_sort": { "sort": [ { "avg_latency": { "order": "desc" } } ], "size": 10 }
        }
      }
    }
  }
}
```

### bucket_selector (filter buckets by condition)

```json
{
  "aggs": {
    "by_service": {
      "terms": { "field": "service.name" },
      "aggs": {
        "avg_latency": { "avg": { "field": "response_time" } },
        "slow_only": {
          "bucket_selector": {
            "buckets_path": { "lat": "avg_latency" },
            "script": "params.lat > 500"
          }
        }
      }
    }
  }
}
```

## Patterns

### Error rate over time (SRE dashboard)

```json
{
  "size": 0,
  "query": { "range": { "@timestamp": { "gte": "now-24h" } } },
  "aggs": {
    "over_time": {
      "date_histogram": { "field": "@timestamp", "fixed_interval": "15m" },
      "aggs": {
        "total":  { "value_count": { "field": "_id" } },
        "errors": { "filter": { "term": { "level": "error" } } },
        "error_rate": {
          "bucket_script": {
            "buckets_path": { "err": "errors._count", "tot": "total" },
            "script": "params.tot > 0 ? (params.err / params.tot * 100) : 0"
          }
        }
      }
    }
  }
}
```

### Top N with details (leaderboard)

```json
{
  "size": 0,
  "aggs": {
    "top_endpoints": {
      "terms": { "field": "url.path.keyword", "size": 10 },
      "aggs": {
        "avg_latency": { "avg": { "field": "response_time" } },
        "p99_latency": { "percentiles": { "field": "response_time", "percents": [99] } },
        "error_count": { "filter": { "range": { "http.response.status_code": { "gte": 500 } } } }
      }
    }
  }
}
```


path: references/cluster-api.md
content: # Cluster & Node API Reference

## Cluster Health

```bash
# Quick health check
es "_cluster/health" | jq '{status, number_of_nodes, active_shards, unassigned_shards}'

# Per-index health
es "_cluster/health?level=indices" | jq '.indices | to_entries[] | select(.value.status != "green") | {index: .key, status: .value.status}'
```

Status meanings:
- **green**: All primary and replica shards assigned
- **yellow**: All primaries assigned, some replicas missing (common on single-node)
- **red**: Some primary shards unassigned — data may be unavailable

## Cluster Stats

```bash
es "_cluster/stats" | jq '{
  nodes: .nodes.count.total,
  indices: .indices.count,
  docs: .indices.docs.count,
  store_size: .indices.store.size_in_bytes,
  memory: .nodes.jvm.mem.heap_used_in_bytes
}'
```

## Cluster Settings

```bash
# Get all settings (persistent + transient)
es "_cluster/settings?include_defaults=true&flat_settings=true"

# Update persistent setting
es "_cluster/settings" -XPUT -d '{
  "persistent": {
    "cluster.routing.allocation.enable": "all"
  }
}'
```

## Node Stats

```bash
# All node stats
es "_nodes/stats" | jq '.nodes | to_entries[] | {name: .value.name, heap_pct: .value.jvm.mem.heap_used_percent, cpu: .value.os.cpu.percent, disk: .value.fs.total}'

# Specific metrics
es "_nodes/stats/jvm,os,fs"

# Hot threads (debugging slow nodes)
es "_nodes/hot_threads"
```

## Node Info

```bash
es "_nodes" | jq '.nodes | to_entries[] | {name: .value.name, roles: .value.roles, version: .value.version, os: .value.os.pretty_name}'
```

## Shard Allocation

```bash
# List shards with allocation
es "_cat/shards?v&s=store:desc&h=index,shard,prirep,state,store,node"

# Why a shard is unassigned
es "_cluster/allocation/explain" -XPOST -d '{
  "index": "my-index",
  "shard": 0,
  "primary": true
}'

# Reroute a shard manually
es "_cluster/reroute" -XPOST -d '{
  "commands": [
    {"allocate_replica": {"index": "my-index", "shard": 0, "node": "node-2"}}
  ]
}'
```

## Pending Tasks

```bash
es "_cluster/pending_tasks"
es "_cat/pending_tasks?v"
```

## Task Management

```bash
# List running tasks
es "_tasks?detailed=true&actions=*reindex*"

# Cancel a task
es "_tasks/node_id:task_id/_cancel" -XPOST
```

## Cat APIs

Quick cluster overview commands (append `?v` for headers, `?format=json` for JSON):

```bash
es "_cat/health?v"                    # Cluster health one-liner
es "_cat/nodes?v&h=name,heap.percent,ram.percent,cpu,load_1m,disk.used_percent,node.role"
es "_cat/indices?v&s=store.size:desc" # Indices sorted by size
es "_cat/shards?v&s=store:desc"       # Shards sorted by size
es "_cat/segments?v"                  # Segment info
es "_cat/recovery?v&active_only"      # Active recoveries
es "_cat/thread_pool?v&h=node_name,name,active,queue,rejected"  # Thread pools
es "_cat/fielddata?v"                 # Fielddata memory usage
```


path: references/document-api.md
content: # Document API Reference

## Get Document

```bash
# By ID
es "my-index/_doc/abc123" | jq '._source'

# Specific fields only
es "my-index/_doc/abc123?_source_includes=timestamp,message"
```

## Index (Create/Replace) Document

```bash
# With specific ID (PUT = create or replace)
es "my-index/_doc/abc123" -XPUT -d '{
  "timestamp": "2026-02-01T12:00:00Z",
  "message": "Hello from Nox",
  "level": "info"
}'

# Auto-generate ID (POST)
es "my-index/_doc" -XPOST -d '{
  "timestamp": "2026-02-01T12:00:00Z",
  "message": "Auto-ID document"
}'
```

## Update Document

**Partial update** (merge fields):
```bash
es "my-index/_update/abc123" -XPOST -d '{
  "doc": {
    "level": "warn",
    "updated_at": "2026-02-01T13:00:00Z"
  }
}'
```

**Script update** (computed values):
```bash
es "my-index/_update/abc123" -XPOST -d '{
  "script": {
    "source": "ctx._source.retry_count += 1",
    "lang": "painless"
  }
}'
```

**Upsert** (create if missing, update if exists):
```bash
es "my-index/_update/abc123" -XPOST -d '{
  "doc": {"counter": 1},
  "upsert": {"counter": 1, "created": "2026-02-01T12:00:00Z"}
}'
```

## Delete Document

```bash
es "my-index/_doc/abc123" -XDELETE
```

## Delete by Query

```bash
es "my-index/_delete_by_query" -XPOST -d '{
  "query": {
    "range": {"@timestamp": {"lt": "now-90d"}}
  }
}'
```

## Update by Query

```bash
es "my-index/_update_by_query" -XPOST -d '{
  "query": {"term": {"status": "pending"}},
  "script": {
    "source": "ctx._source.status = \"expired\"",
    "lang": "painless"
  }
}'
```

## Multi-Get

```bash
es "_mget" -XPOST -d '{
  "docs": [
    {"_index": "my-index", "_id": "abc123"},
    {"_index": "my-index", "_id": "def456"}
  ]
}'
```

## Bulk API

The most efficient way to index many documents. Uses NDJSON format (newline-delimited JSON).

```bash
es "_bulk" -XPOST -H "Content-Type: application/x-ndjson" -d '
{"index": {"_index": "my-index", "_id": "1"}}
{"timestamp": "2026-02-01T12:00:00Z", "message": "first"}
{"index": {"_index": "my-index", "_id": "2"}}
{"timestamp": "2026-02-01T12:01:00Z", "message": "second"}
{"delete": {"_index": "my-index", "_id": "old-doc"}}
'
```

**Bulk from file**:
```bash
es "_bulk" -XPOST -H "Content-Type: application/x-ndjson" --data-binary @bulk-data.ndjson
```

**Performance tips**:
- Optimal bulk size: 5-15 MB per request
- Use `_bulk` instead of individual requests for >10 documents
- Check response for `errors: true` and inspect individual item statuses


path: references/index-api.md
content: # Index Management API Reference

## Create Index

```bash
es "my-new-index" -XPUT -d '{
  "settings": {
    "number_of_shards": 1,
    "number_of_replicas": 1
  },
  "mappings": {
    "properties": {
      "timestamp": {"type": "date"},
      "message": {"type": "text"},
      "level": {"type": "keyword"},
      "duration_ms": {"type": "float"}
    }
  }
}'
```

## Delete Index

```bash
es "my-old-index" -XDELETE
```

**With wildcard** (dangerous — requires `action.destructive_requires_name: false`):
```bash
es "temp-*" -XDELETE
```

## Index Settings

**Get settings**:
```bash
es "my-index/_settings" | jq '.[]|.settings.index'
```

**Update settings** (dynamic only):
```bash
es "my-index/_settings" -XPUT -d '{
  "index": {
    "number_of_replicas": 2,
    "refresh_interval": "30s"
  }
}'
```

Common dynamic settings: `number_of_replicas`, `refresh_interval`, `max_result_window`, `routing.allocation.require.*`

## List Indices

```bash
# Human-readable, sorted by size
es "_cat/indices?v&s=store.size:desc&h=index,health,status,docs.count,store.size"

# JSON format
es "_cat/indices?format=json&s=store.size:desc" | jq '.[] | {index: .index, docs: .["docs.count"], size: .["store.size"]}'

# Filter by pattern
es "_cat/indices/logs-*?v&s=store.size:desc"
```

## Open / Close

Closed indices consume no resources but can't be searched.

```bash
es "my-index/_close" -XPOST
es "my-index/_open" -XPOST
```

## Reindex

Copy data between indices (useful for mapping changes):

```bash
es "_reindex" -XPOST -d '{
  "source": {"index": "old-index"},
  "dest": {"index": "new-index"}
}'
```

**With query filter**:
```bash
es "_reindex" -XPOST -d '{
  "source": {
    "index": "logs-*",
    "query": {"range": {"@timestamp": {"gte": "2026-01-01"}}}
  },
  "dest": {"index": "logs-2026"}
}'
```

**Remote reindex** (cross-cluster):
```bash
es "_reindex" -XPOST -d '{
  "source": {
    "remote": {"host": "https://other-cluster:9200"},
    "index": "source-index"
  },
  "dest": {"index": "dest-index"}
}'
```

## Aliases

```bash
# List aliases
es "_cat/aliases?v"

# Create alias
es "_aliases" -XPOST -d '{
  "actions": [
    {"add": {"index": "logs-2026.02", "alias": "logs-current"}}
  ]
}'

# Swap alias atomically
es "_aliases" -XPOST -d '{
  "actions": [
    {"remove": {"index": "logs-2026.01", "alias": "logs-current"}},
    {"add": {"index": "logs-2026.02", "alias": "logs-current"}}
  ]
}'
```

## Index Lifecycle Management (ILM)

```bash
# Get ILM policy
es "_ilm/policy/my-policy"

# Get ILM status for index
es "my-index/_ilm/explain"

# Retry failed ILM step
es "my-index/_ilm/retry" -XPOST
```

## Shrink / Split

```bash
# Shrink (reduce shard count — index must be read-only first)
es "my-index/_settings" -XPUT -d '{"index.blocks.write": true}'
es [REDACTED_SECRET] -XPOST -d '{"settings": {"index.number_of_shards": 1}}'

# Split (increase shard count)
es "my-index/_split/my-index-split" -XPOST -d '{"settings": {"index.number_of_shards": 4}}'
```

## Rollover

```bash
es "logs-current/_rollover" -XPOST -d '{
  "conditions": {
    "max_age": "7d",
    "max_size": "50gb",
    "max_docs": 10000000
  }
}'
```

## Index Stats

```bash
# All stats
es "my-index/_stats" | jq '.indices["my-index"].total'

# Specific metrics
es "my-index/_stats/docs,store,indexing,search"
```


path: references/kibana-api.md
content: # Kibana API Reference

Kibana has its own REST API, separate from Elasticsearch. Use `$KIBANA_URL` (not `$ES_URL`).

## Authentication

```bash
# Set Kibana URL (typically .kb. domain, vs .es. for Elasticsearch)
KIBANA_URL="https://your-deployment.kb.us-east-1.aws.elastic.cloud"

# All Kibana mutations require kbn-xsrf header
curl -s "$KIBANA_URL/<endpoint>" \
  -H "Authorization: ApiKey $ES_API_KEY" \
  -H "kbn-xsrf: true" \
  -H "Content-Type: application/json" \
  -d '<json-body>'
```

**Key differences from ES API:**
- `kbn-xsrf: true` header required on all POST/PUT/DELETE requests
- Same API key works for both ES and Kibana
- URL pattern: replace `.es.` with `.kb.` from your ES URL

## Health Check

```bash
curl -s "$KIBANA_URL/api/status" \
  -H "Authorization: ApiKey $ES_API_KEY" | jq '{name: .name, status: .status.overall.level, version: .version.number}'
```

## Spaces

Kibana spaces scope dashboards, saved objects, and data views. Prefix API paths with `/s/{spaceId}` for non-default spaces:

```bash
# Default space — no prefix
curl -s "$KIBANA_URL/api/data_views" -H "Authorization: ApiKey $ES_API_KEY"

# Custom space
curl -s "$KIBANA_URL/s/my-space/api/data_views" -H "Authorization: ApiKey $ES_API_KEY"
```

## Data Views

Data views (formerly index patterns) tell Kibana which indices to query.

```bash
# List data views
curl -s "$KIBANA_URL/api/data_views" \
  -H "Authorization: ApiKey $ES_API_KEY" | jq '.data_view[] | {id, title}'

# Create data view
curl -s -X POST "$KIBANA_URL/api/data_views/data_view" \
  -H "Authorization: ApiKey $ES_API_KEY" \
  -H "kbn-xsrf: true" \
  -H "Content-Type: application/json" \
  -d '{
    "data_view": {
      "id": "my-logs-view",
      "title": "logs-*",
      "timeFieldName": "@timestamp"
    },
    "override": true
  }'

# Delete data view
curl -s -X DELETE "$KIBANA_URL/api/data_views/data_view/my-logs-view" \
  -H "Authorization: ApiKey $ES_API_KEY" \
  -H "kbn-xsrf: true"
```

**`override: true`** — overwrites if a data view with that ID already exists. Useful for idempotent scripts.

## Saved Objects

Saved objects are Kibana's storage model — dashboards, visualizations, data views, searches, etc.

```bash
# Get a saved object — NOT available on serverless
curl -s "$KIBANA_URL/api/saved_objects/dashboard/my-dashboard-id" \
  -H "Authorization: ApiKey $ES_API_KEY" | jq .

# Find saved objects by type — NOT available on serverless
curl -s "$KIBANA_URL/api/saved_objects/_find?type=dashboard&per_page=100" \
  -H "Authorization: ApiKey $ES_API_KEY" | jq '.saved_objects[] | {id, title: .attributes.title}'

# Bulk create (with overwrite) — NOT available on serverless
curl -s -X POST "$KIBANA_URL/api/saved_objects/_bulk_create?overwrite=true" \
  -H "Authorization: ApiKey $ES_API_KEY" \
  -H "kbn-xsrf: true" \
  -H "Content-Type: application/json" \
  -d '[
    {
      "type": "dashboard",
      "id": "my-dashboard",
      "attributes": { "title": "My Dashboard" }
    }
  ]'

# Delete saved object — NOT available on serverless
curl -s -X DELETE "$KIBANA_URL/api/saved_objects/dashboard/my-dashboard-id" \
  -H "Authorization: ApiKey $ES_API_KEY" \
  -H "kbn-xsrf: true"
```

Saved object types: `dashboard`, `visualization`, `lens`, `search`, `index-pattern`, `map`, `tag`.

**Serverless Kibana:** On Elastic Cloud Serverless, only `_import` and `_export` are available for saved objects. The `_find`, `_bulk_create`, individual GET, and DELETE endpoints all return `400 Bad Request`. Use `_import?overwrite=true` for create/update operations.

## Dashboard Import / Export

```bash
# Export dashboard (includes dependencies)
curl -s -X POST "$KIBANA_URL/api/saved_objects/_export" \
  -H "Authorization: ApiKey $ES_API_KEY" \
  -H "kbn-xsrf: true" \
  -H "Content-Type: application/json" \
  -d '{
    "objects": [{"type": "dashboard", "id": "my-dashboard-id"}],
    "includeReferencesDeep": true
  }' > dashboard-export.ndjson

# Import dashboard (NDJSON format, overwrite existing)
curl -s -X POST "$KIBANA_URL/api/saved_objects/_import?overwrite=true" \
  -H "Authorization: ApiKey $ES_API_KEY" \
  -H "kbn-xsrf: true" \
  --form file=@dashboard-export.ndjson
```

**Import format** is newline-delimited JSON (NDJSON) — one saved object per line.

## Programmatic Dashboard Creation

For Elastic Cloud Serverless and portability, use **by-value** dashboards where visualizations are embedded inline (no separate saved objects needed).

### Dashboard Structure

```json
{
  "type": "dashboard",
  "id": "my-dashboard",
  "coreMigrationVersion": "8.8.0",
  "typeMigrationVersion": "10.3.0",
  "attributes": {
    "title": "My Dashboard",
    "description": "Dashboard description",
    "timeRestore": true,
    "timeTo": "now",
    "timeFrom": "now-24h",
    "refreshInterval": {"pause": false, "value": 30000},
    "panelsJSON": "[...panels array as JSON string...]",
    "optionsJSON": "{\"useMargins\":true,\"syncColors\":false}"
  },
  "references": []
}
```

**Critical:** `coreMigrationVersion` and `typeMigrationVersion` are **required** for serverless Kibana imports. Without them, the import returns a 500 Internal Server Error. Include them on all saved objects in NDJSON payloads.

### Panel Format (By-Value)

Each panel in `panelsJSON` has grid position and an embedded visualization:

```json
{
  "type": "lens",
  "gridData": {"x": 0, "y": 0, "w": 12, "h": 8, "i": "panel-1"},
  "panelIndex": "panel-1",
  "embeddableConfig": {
    "attributes": {
      "visualizationType": "lnsMetric",
      "title": "Total Events",
      "state": {
        "filters": [],
        "query": {"query": "", "language": "kuery"},
        "datasourceStates": {
          "formBased": {
            "layers": {
              "layer1": {
                "columns": {
                  "col1": {
                    "operationType": "count",
                    "label": "Count",
                    "dataType": "number",
                    "isBucketed": false,
                    "sourceField": "___records___"
                  }
                },
                "columnOrder": ["col1"]
              }
            }
          }
        },
        "visualization": {
          "layerId": "layer1",
          "layerType": "data",
          "metricAccessor": "col1"
        }
      },
      "references": [
        {"type": "index-pattern", "id": "my-data-view", "name": "indexpattern-datasource-layer-layer1"}
      ]
    }
  }
}
```

Grid uses a 48-column layout. Common panel sizes: metric `w:12 h:7`, chart `w:24 h:14`, table `w:48 h:12`.

**Layer properties:** Each layer in `datasourceStates.formBased.layers` should include:
- `"indexPatternId": "data-view-id"` — links the layer to its data view
- `"incompleteColumns": {}` — required empty object
- `"columns"` and `"columnOrder"` — the column definitions

### Lens Visualization Types

| Type | `visualizationType` | Use Case |
|------|---------------------|----------|
| Metric | `lnsMetric` | Single number KPI |
| XY Chart | `lnsXY` | Line, bar, area charts |
| Pie / Donut | `lnsPie` | Proportion breakdown |
| Data Table | `lnsDatatable` | Tabular drill-down |

### Column Operation Types

| Operation | Description | `isBucketed` | `sourceField` | `scale` |
|-----------|-------------|--------------|---------------|---------|
| `count` | Document count | false | `"___records___"` (required!) | `"ratio"` |
| `unique_count` | Cardinality | false | field name | `"ratio"` |
| `sum`, `avg`, `min`, `max` | Metric aggregations | false | field name | `"ratio"` (see TSDB caveat) |
| `terms` | Top values grouping | true | field name | `"ordinal"` |
| `date_histogram` | Time buckets | true | field name | `"interval"` |
| `filters` | Custom filter groups | true | — | `"ordinal"` |

**Critical:**
- `count` columns **must** include `"sourceField": "___records___"`. Without it, Kibana throws `aggValueCount requires the "field" argument`.
- All columns **should** include `"scale"` for XY charts. Without it, XY charts may silently render empty despite correct configuration. Use `"interval"` for date_histogram, `"ordinal"` for terms/filters, `"ratio"` for metrics.
- **TSDB gauge metrics** (e.g., `metrics.system.memory.utilization`, `metrics.system.cpu.utilization`) with `time_series_metric: gauge` in their mapping **cannot** be aggregated via `avg`/`sum`/`min`/`max` in programmatic Lens panels — the chart renders blank with no error. Use `count`-based panels for TSDB data instead, or view these metrics through Kibana's built-in Infrastructure/Metrics Explorer UI.

### Column with Filter

```json
{
  "col1": {
    "operationType": "count",
    "label": "Error Count",
    "dataType": "number",
    "isBucketed": false,
    "filter": {
      "query": "attributes.event.category: \"user.error\"",
      "language": "kuery"
    }
  }
}
```

### XY Chart Visualization Config

**Critical:** XY charts require a `layers` array wrapper, `preferredSeriesType`, `legend`, and `fittingFunction` at the top level. Without the `layers` array, charts render empty with no error. Without `legend`/`fittingFunction`, charts may also silently fail to render.

```json
{
  "visualization": {
    "preferredSeriesType": "area_stacked",
    "legend": {"isVisible": true, "position": "right"},
    "fittingFunction": "None",
    "layers": [
      {
        "layerId": "layer1",
        "layerType": "data",
        "seriesType": "area_stacked",
        "xAccessor": "col-date",
        "accessors": ["col-count"],
        "splitAccessor": "col-category"
      }
    ]
  }
}
```

Series types: `line`, `bar`, `bar_stacked`, `area`, `area_stacked`, `bar_horizontal`, `bar_horizontal_stacked`.

## Control Groups (Dashboard Filters)

Add interactive filter controls to dashboards:

```json
{
  "controlGroupInput": {
    "controlStyle": "oneLine",
    "chainingSystem": "HIERARCHICAL",
    "panelsJSON": {
      "panel-filter-1": {
        "order": 0,
        "width": "medium",
        "type": "optionsListControl",
        "explicitInput": {
          "id": "panel-filter-1",
          "fieldName": "attributes.user.id",
          "title": "User",
          "dataViewId": "my-data-view"
        }
      }
    }
  }
}
```

## Deploy Dashboard (Full Example)

```bash
# Generate NDJSON with data view + dashboard
# NOTE: coreMigrationVersion + typeMigrationVersion are required for serverless
cat > /tmp/dashboard.ndjson << 'EOF'
{"type":"index-pattern","id":"my-logs","coreMigrationVersion":"8.8.0","attributes":{"title":"logs-*","timeFieldName":"@timestamp"}}
{"type":"dashboard","id":"my-dashboard","coreMigrationVersion":"8.8.0","typeMigrationVersion":"10.3.0","attributes":{"title":"My Dashboard","timeRestore":true,"timeFrom":"now-24h","timeTo":"now","panelsJSON":"[{\"type\":\"lens\",\"gridData\":{\"x\":0,\"y\":0,\"w\":48,\"h\":8,\"i\":\"p1\"},\"panelIndex\":\"p1\",\"embeddableConfig\":{\"attributes\":{\"visualizationType\":\"lnsMetric\",\"title\":\"Total Docs\",\"state\":{\"filters\":[],\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"datasourceStates\":{\"formBased\":{\"layers\":{\"l1\":{\"columns\":{\"c1\":{\"operationType\":\"count\",\"label\":\"Count\",\"dataType\":\"number\",\"isBucketed\":false}},\"columnOrder\":[\"c1\"]}}}},\"visualization\":{\"layerId\":\"l1\",\"layerType\":\"data\",\"metricAccessor\":\"c1\"}},\"references\":[{\"type\":\"index-pattern\",\"id\":\"my-logs\",\"name\":\"indexpattern-datasource-layer-l1\"}]}}}]"},"references":[]}
EOF

# Import to Kibana
curl -s -X POST "$KIBANA_URL/api/saved_objects/_import?overwrite=true" \
  -H "Authorization: ApiKey $(printenv ES_API_KEY)" \
  -H "kbn-xsrf: true" \
  --form file=@/tmp/dashboard.ndjson | jq .
```

For complex dashboards with many panels, use a Python script to generate the NDJSON rather than hand-crafting the JSON strings. See the `panelsJSON` nesting: it's a JSON string containing an array of panel objects, each with deeply nested Lens state. Errors in this structure cause silent 500 errors on import.

## Alerting Rules

```bash
# List rules
curl -s "$KIBANA_URL/api/alerting/rules/_find?per_page=100" \
  -H "Authorization: ApiKey $ES_API_KEY" | jq '.data[] | {id, name: .name, enabled: .enabled}'

# Disable a rule
curl -s -X POST "$KIBANA_URL/api/alerting/rule/{rule_id}/_disable" \
  -H "Authorization: ApiKey $ES_API_KEY" \
  -H "kbn-xsrf: true"

# Enable a rule
curl -s -X POST "$KIBANA_URL/api/alerting/rule/{rule_id}/_enable" \
  -H "Authorization: ApiKey $ES_API_KEY" \
  -H "kbn-xsrf: true"
```

## Tips

- **`kbn-xsrf: true`** is required on every write operation — Kibana returns 400 without it.
- **By-value dashboards** embed visualizations inline — no separate saved object IDs to manage. Required for Elastic Cloud Serverless.
- **NDJSON import** is the most reliable way to deploy dashboards programmatically.
- **Data view IDs** must match between visualization references and actual data views — mismatches cause "field not found" errors.
- **Space prefix** `/s/{spaceId}/api` — omit for the default space.
- **Derive Kibana URL from ES URL** — swap `.es.` for `.kb.` in the hostname.


path: references/mapping-api.md
content: # Mapping & Template API Reference

## Get Mapping

```bash
# Full mapping
es "my-index/_mapping" | jq '.[]|.mappings.properties'

# Specific field
es "my-index/_mapping/field/message" | jq .
```

## Put Mapping (Add Fields)

Mappings can only be extended, not changed for existing fields. To change a field type, reindex.

```bash
es "my-index/_mapping" -XPUT -d '{
  "properties": {
    "new_field": {"type": "keyword"},
    "location": {"type": "geo_point"}
  }
}'
```

## Common Field Types

| Type | Use for | Notes |
|------|---------|-------|
| `text` | Full-text search | Analyzed, not aggregatable |
| `keyword` | Exact values, filtering, aggregations | Not analyzed, max 256 chars default |
| `long`/`integer`/`short`/`byte` | Whole numbers | Pick smallest that fits |
| `float`/`double`/`half_float`/`scaled_float` | Decimals | `scaled_float` for money |
| `date` | Timestamps | Supports multiple formats |
| `boolean` | true/false | |
| `ip` | IPv4/IPv6 addresses | Supports CIDR queries |
| `geo_point` | Lat/lon coordinates | For geo queries |
| `nested` | Arrays of objects | Preserves object boundaries |
| `object` | JSON objects | Flattened by default |
| `flattened` | Arbitrary JSON | Single field, keyword-like queries |

## Multi-fields

Map a single field multiple ways:

```json
{
  "properties": {
    "title": {
      "type": "text",
      "fields": {
        "keyword": {"type": "keyword"},
        "autocomplete": {
          "type": "text",
          "analyzer": "autocomplete_analyzer"
        }
      }
    }
  }
}
```

Search with `title` (full-text), aggregate with `title.keyword` (exact), suggest with `title.autocomplete`.

## Dynamic Mapping

Control how new fields are handled:

```json
{
  "mappings": {
    "dynamic": "strict",
    "properties": {
      "known_field": {"type": "text"}
    }
  }
}
```

Values: `true` (auto-map, default), `runtime` (map as runtime fields), `false` (ignore in mapping, still stored), `strict` (reject unknown fields).

## Dynamic Templates

Auto-map fields by pattern:

```json
{
  "mappings": {
    "dynamic_templates": [
      {
        "strings_as_keywords": {
          "match_mapping_type": "string",
          "mapping": {"type": "keyword"}
        }
      },
      {
        "labels_as_keywords": {
          "path_match": "labels.*",
          "mapping": {"type": "keyword"}
        }
      }
    ]
  }
}
```

## Index Templates

Apply settings/mappings to new indices matching a pattern:

```bash
# Create index template
es "_index_template/logs-template" -XPUT -d '{
  "index_patterns": ["logs-*"],
  "priority": 100,
  "template": {
    "settings": {
      "number_of_shards": 1,
      "number_of_replicas": 1
    },
    "mappings": {
      "properties": {
        "@timestamp": {"type": "date"},
        "message": {"type": "text"},
        "level": {"type": "keyword"}
      }
    }
  }
}'

# List templates
es "_index_template" | jq '.index_templates[].name'

# Get specific template
es "_index_template/logs-template"

# Delete template
es "_index_template/logs-template" -XDELETE
```

## Component Templates

Reusable template building blocks:

```bash
es "_component_template/base-mappings" -XPUT -d '{
  "template": {
    "mappings": {
      "properties": {
        "@timestamp": {"type": "date"},
        "host.name": {"type": "keyword"}
      }
    }
  }
}'

# Use in index template
es "_index_template/my-template" -XPUT -d '{
  "index_patterns": ["my-*"],
  "composed_of": ["base-mappings"],
  "priority": 200
}'
```

## Runtime Fields

Define fields at query time (no reindex needed):

```bash
es "my-index/_search" -d '{
  "runtime_mappings": {
    "day_of_week": {
      "type": "keyword",
      "script": "emit(doc[\"@timestamp\"].value.dayOfWeekEnum.getDisplayName(TextStyle.FULL, Locale.ROOT))"
    }
  },
  "query": {"term": {"day_of_week": "Monday"}}
}'
```


path: references/otel-data.md
content: # OpenTelemetry Data in Elasticsearch

OTEL data ingested via the managed OTLP endpoint lands in standard index patterns. This reference covers querying OTEL logs, traces, and metrics.

## Index Patterns

| Signal | Default Index | Contains |
|--------|---------------|----------|
| Logs | `logs-generic.otel-default` | Application logs, custom events |
| Traces | `traces-generic.otel-default` | Spans and transactions |
| Metrics | `metrics-generic.otel-default` | Numeric measurements |

These indices are created automatically when data arrives via the OTLP endpoint.

## Field Structure

OTEL data maps to Elastic fields with a consistent structure:

```
@timestamp                          # Event timestamp
resource.attributes.service.name    # Service that emitted the signal
resource.attributes.*               # Resource-level attributes (deployment, host, etc.)
attributes.*                        # Signal-specific attributes
```

### Log Fields

```
body                                # Log message / event name
severityNumber                      # Numeric severity (1-24)
severityText                        # INFO, WARN, ERROR, etc.
attributes.*                        # Custom attributes set on the log
resource.attributes.service.name    # Originating service
traceId                             # Trace correlation (if emitted within a span)
spanId                              # Span correlation (if emitted within a span)
```

### Trace Fields (APM)

In Elastic APM, OTEL spans become:
- **Transaction** = root span (no parent) — the entry point
- **Span** = child span (has a parent) — operations within a transaction

```
trace.id                            # Links all spans in a distributed trace
span.id                             # This span's unique ID
parent.id                           # Parent span ID (empty for root/transaction)
span.name                           # Operation name
span.duration.us                    # Duration in microseconds
span.type                           # Span type (custom, db, http, etc.)
service.name                        # Service that emitted the span
```

## Querying OTEL Logs

### ES|QL

```bash
# All logs from a service
curl -s -X POST "$ES_URL/_query" \
  -H "Authorization: ApiKey $ES_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "query": "FROM logs-generic.otel-default | WHERE resource.attributes.service.name == \"my-app\" | SORT @timestamp DESC | LIMIT 50"
  }' | jq .

# Events by category
curl -s -X POST "$ES_URL/_query" \
  -H "Authorization: ApiKey $ES_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "query": "FROM logs-generic.otel-default | WHERE attributes.event.category == \"user.interaction\" | STATS count = COUNT(*) BY attributes.event.action | SORT count DESC"
  }' | jq .

# Errors in the last hour
curl -s -X POST "$ES_URL/_query" \
  -H "Authorization: ApiKey $ES_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "query": "FROM logs-generic.otel-default | WHERE severityText == \"ERROR\" AND @timestamp > NOW() - 1 hour | SORT @timestamp DESC | LIMIT 20"
  }' | jq .
```

### Query DSL

```bash
# Filter logs by custom attribute
curl -s "$ES_URL/logs-generic.otel-default/_search" \
  -H "Authorization: ApiKey $ES_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "query": {
      "bool": {
        "filter": [
          {"term": {"resource.attributes.service.name": "my-app"}},
          {"range": {"@timestamp": {"gte": "now-1h"}}}
        ]
      }
    },
    "sort": [{"@timestamp": {"order": "desc"}}],
    "size": 20
  }' | jq '.hits.hits[]._source'

# Full-text search on log body
curl -s "$ES_URL/logs-generic.otel-default/_search" \
  -H "Authorization: ApiKey $ES_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "query": {
      "bool": {
        "must": [{"match": {"body": "error timeout"}}],
        "filter": [{"range": {"@timestamp": {"gte": "now-24h"}}}]
      }
    },
    "size": 20
  }' | jq '.hits.hits[]._source'
```

## Querying OTEL Traces

### ES|QL

```bash
# Slowest transactions for a service
curl -s -X POST "$ES_URL/_query" \
  -H "Authorization: ApiKey $ES_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "query": "FROM traces-generic.otel-default | WHERE service.name == \"my-app\" AND parent.id IS NULL | SORT span.duration.us DESC | LIMIT 10"
  }' | jq .

# All spans in a trace (distributed trace view)
curl -s -X POST "$ES_URL/_query" \
  -H "Authorization: ApiKey $ES_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "query": "FROM traces-generic.otel-default | WHERE trace.id == \"abc123def456\" | SORT @timestamp ASC"
  }' | jq .

# Error rate by service
curl -s -X POST "$ES_URL/_query" \
  -H "Authorization: ApiKey $ES_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "query": "FROM traces-generic.otel-default | WHERE parent.id IS NULL | STATS total = COUNT(*), errors = COUNT_IF(event.outcome == \"failure\") BY service.name | EVAL error_rate = errors / total | SORT error_rate DESC"
  }' | jq .
```

### Query DSL

```bash
# Find transactions (root spans) for a service
curl -s "$ES_URL/traces-generic.otel-default/_search" \
  -H "Authorization: ApiKey $ES_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "query": {
      "bool": {
        "must": [{"term": {"service.name": "my-app"}}],
        "must_not": [{"exists": {"field": "parent.id"}}],
        "filter": [{"range": {"@timestamp": {"gte": "now-1h"}}}]
      }
    },
    "sort": [{"span.duration.us": {"order": "desc"}}],
    "size": 10
  }' | jq '.hits.hits[] | {name: ._source.span.name, duration_ms: (._source.span.duration.us / 1000), trace_id: ._source.trace.id}'
```

## Trace-Log Correlation

Logs emitted within an active trace context automatically include `traceId` and `spanId`. Use these to correlate:

```bash
# Step 1: Find a slow trace
curl -s -X POST "$ES_URL/_query" \
  -H "Authorization: ApiKey $ES_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "query": "FROM traces-generic.otel-default | WHERE service.name == \"my-app\" AND span.duration.us > 5000000 | KEEP trace.id, span.name, span.duration.us | LIMIT 5"
  }' | jq .

# Step 2: Get all logs for that trace
curl -s -X POST "$ES_URL/_query" \
  -H "Authorization: ApiKey $ES_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "query": "FROM logs-generic.otel-default | WHERE traceId == \"abc123def456\" | SORT @timestamp ASC"
  }' | jq .

# Step 3: Get all spans for the same trace
curl -s -X POST "$ES_URL/_query" \
  -H "Authorization: ApiKey $ES_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "query": "FROM traces-generic.otel-default | WHERE trace.id == \"abc123def456\" | SORT @timestamp ASC | KEEP span.name, span.duration.us, parent.id"
  }' | jq .
```

## Aggregations on OTEL Data

```bash
# Log volume by service over time
curl -s "$ES_URL/logs-generic.otel-default/_search?size=0" \
  -H "Authorization: ApiKey $ES_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "query": {"range": {"@timestamp": {"gte": "now-24h"}}},
    "aggs": {
      "over_time": {
        "date_histogram": {"field": "@timestamp", "fixed_interval": "1h"},
        "aggs": {
          "by_service": {
            "terms": {"field": "resource.attributes.service.name", "size": 10}
          }
        }
      }
    }
  }' | jq '.aggregations'

# Average span duration by operation
curl -s "$ES_URL/traces-generic.otel-default/_search?size=0" \
  -H "Authorization: ApiKey $ES_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "query": {
      "bool": {
        "filter": [
          {"term": {"service.name": "my-app"}},
          {"range": {"@timestamp": {"gte": "now-1h"}}}
        ]
      }
    },
    "aggs": {
      "by_operation": {
        "terms": {"field": "span.name", "size": 20},
        "aggs": {
          "avg_duration_ms": {"avg": {"field": "span.duration.us"}},
          "p99_duration": {"percentiles": {"field": "span.duration.us", "percents": [99]}}
        }
      }
    }
  }' | jq '.aggregations'

# Unique users per service (custom attribute)
curl -s "$ES_URL/logs-generic.otel-default/_search?size=0" \
  -H "Authorization: ApiKey $ES_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "aggs": {
      "by_service": {
        "terms": {"field": "resource.attributes.service.name", "size": 10},
        "aggs": {
          "unique_users": {"cardinality": {"field": "attributes.user.id"}}
        }
      }
    }
  }' | jq '.aggregations'
```

## KQL Patterns for Discover

Common KQL queries for the Kibana Discover tab:

```
# All events from a service
resource.attributes.service.name: "my-app"

# Error logs
severityText: "ERROR"

# Custom attribute filtering
attributes.user.id: "david"

# Exists check (has frustration data)
attributes.frustration.type: *

# Combine filters
resource.attributes.service.name: "my-app" AND severityText: "ERROR" AND @timestamp >= now-1h
```

## OTLP Endpoint Setup

Elastic Cloud provides a managed OTLP endpoint (no APM Server needed):

```bash
# Endpoint (from Kibana: Add data → Applications → OpenTelemetry)
OTEL_EXPORTER_OTLP_ENDPOINT="https://your-otlp-endpoint.elastic.cloud:443"

# Authentication
OTEL_EXPORTER_OTLP_HEADERS="Authorization=ApiKey YOUR_BASE64_KEY"

# Service identity
OTEL_RESOURCE_ATTRIBUTES="service.name=my-app,deployment.environment=production"
```

The OTLP endpoint accepts:
- `POST /v1/logs` — log signals
- `POST /v1/traces` — trace signals
- `POST /v1/metrics` — metric signals

## Tips

- **OTEL attributes are nested** — query `attributes.my.field`, not `my.field`. Check mappings if unsure.
- **`resource.attributes`** vs **`attributes`** — resource attributes describe the source (service, host), signal attributes describe the event.
- **Trace correlation** — `traceId` on logs links to `trace.id` on traces. Note the different field names.
- **Transactions vs spans** — root spans (no `parent.id`) become Elastic APM transactions. Filter with `parent.id IS NULL` or `NOT exists parent.id`.
- **Duration is in microseconds** — `span.duration.us`. Divide by 1000 for milliseconds.
- **Data streams** — OTEL indices are data streams. Use `_data_stream/logs-generic.otel-default` to check health.
- **Field type conflicts** — if the same attribute has different types across services, Elasticsearch may reject documents. Use consistent types.


path: references/query-dsl.md
content: # Query DSL Reference

Full reference for Elasticsearch Query DSL query types. All examples assume `ES_URL` and `ES_API_KEY` are set.

## Table of Contents

- [Full-Text Queries](#full-text-queries)
- [Term-Level Queries](#term-level-queries)
- [Compound Queries](#compound-queries)
- [Nested & Join Queries](#nested--join-queries)
- [Geo Queries](#geo-queries)
- [Special Queries](#special-queries)
- [Search Features](#search-features)

## Full-Text Queries

### match

Analyzes the query string and finds documents with matching terms:

```json
{ "query": { "match": { "message": { "query": "error timeout", "operator": "and" } } } }
```

- Default operator is `or` (any term matches)
- Use `"operator": "and"` to require all terms
- `"fuzziness": "AUTO"` enables typo tolerance

### multi_match

Search across multiple fields:

```json
{
  "query": {
    "multi_match": {
      "query": "connection refused",
      "fields": ["message", "error.message", "log.original"],
      "type": "best_fields"
    }
  }
}
```

Types: `best_fields` (default), `most_fields`, `cross_fields`, `phrase`, `phrase_prefix`

### match_phrase

Matches exact phrase in order:

```json
{ "query": { "match_phrase": { "message": "connection refused" } } }
```

### match_phrase_prefix

Like match_phrase but last term is a prefix (autocomplete):

```json
{ "query": { "match_phrase_prefix": { "message": "connect" } } }
```

### query_string

Lucene syntax — powerful but can throw parse errors on bad input:

```json
{ "query": { "query_string": { "query": "message:error AND level:critical", "default_field": "message" } } }
```

### simple_query_string

Safer version — never throws parse errors, ignores invalid syntax:

```json
{ "query": { "simple_query_string": { "query": "error + timeout -debug", "fields": ["message"] } } }
```

Operators: `+` (AND), `|` (OR), `-` (NOT), `"..."` (phrase), `*` (prefix), `(...)` (precedence)

## Term-Level Queries

These operate on exact values (no analysis). Use on `keyword`, `numeric`, `date`, `boolean` fields.

### term

Exact match on a single value:

```json
{ "query": { "term": { "level": { "value": "error" } } } }
```

⚠️ Don't use `term` on `text` fields — they're analyzed, so exact match won't work as expected.

### terms

Match any of several values (like SQL `IN`):

```json
{ "query": { "terms": { "level": ["error", "critical", "fatal"] } } }
```

### range

Numeric/date ranges:

```json
{ "query": { "range": { "@timestamp": { "gte": "now-1h", "lt": "now" } } } }
{ "query": { "range": { "response_time": { "gte": 500, "lte": 5000 } } } }
```

Operators: `gt`, `gte`, `lt`, `lte`. Date math: `now`, `now-1d`, `now/d` (round to day).

### exists

Field exists and has a non-null value:

```json
{ "query": { "exists": { "field": "error.message" } } }
```

### wildcard

Wildcard pattern matching (`*` = any chars, `?` = single char):

```json
{ "query": { "wildcard": { "hostname": { "value": "web-prod-*" } } } }
```

⚠️ Leading wildcards (`*error`) are expensive. Avoid in production.

### regexp

Regular expression match:

```json
{ "query": { "regexp": { "path": { "value": "/api/v[0-9]+/users.*" } } } }
```

### prefix

Matches documents where field starts with value:

```json
{ "query": { "prefix": { "hostname": { "value": "web-" } } } }
```

### fuzzy

Typo-tolerant matching:

```json
{ "query": { "fuzzy": { "message": { "value": "eror", "fuzziness": "AUTO" } } } }
```

### ids

Match by document IDs:

```json
{ "query": { "ids": { "values": ["doc-1", "doc-2", "doc-3"] } } }
```

## Compound Queries

### bool

Combine multiple queries with boolean logic:

```json
{
  "query": {
    "bool": {
      "must":     [ { "match": { "message": "error" } } ],
      "filter":   [ { "range": { "@timestamp": { "gte": "now-1h" } } } ],
      "should":   [ { "term": { "level": "critical" } } ],
      "must_not": [ { "term": { "env": "dev" } } ],
      "minimum_should_match": 1
    }
  }
}
```

- `must` — required, contributes to score
- `filter` — required, does NOT contribute to score (faster, cacheable)
- `should` — optional boost (or required if no must/filter)
- `must_not` — excluded, does NOT contribute to score

**Best practice:** Put exact filters in `filter`, free-text in `must`.

### boosting

Demote results matching a negative query without excluding them:

```json
{
  "query": {
    "boosting": {
      "positive": { "match": { "message": "error" } },
      "negative": { "term": { "level": "debug" } },
      "negative_boost": 0.2
    }
  }
}
```

### constant_score

Wrap a filter to return a fixed score:

```json
{ "query": { "constant_score": { "filter": { "term": { "level": "error" } }, "boost": 1.0 } } }
```

### function_score

Custom scoring functions (decay, random, field_value_factor):

```json
{
  "query": {
    "function_score": {
      "query": { "match_all": {} },
      "functions": [
        { "field_value_factor": { "field": "popularity", "modifier": "log1p" } }
      ]
    }
  }
}
```

## Nested & Join Queries

### nested

Query nested objects (requires `nested` field type in mapping):

```json
{
  "query": {
    "nested": {
      "path": "comments",
      "query": {
        "bool": {
          "must": [
            { "match": { "comments.author": "david" } },
            { "range": { "comments.date": { "gte": "now-7d" } } }
          ]
        }
      }
    }
  }
}
```

### has_child / has_parent

Join queries for parent-child relationships:

```json
{ "query": { "has_child": { "type": "answer", "query": { "match": { "body": "elasticsearch" } } } } }
```

## Geo Queries

### geo_bounding_box

```json
{
  "query": {
    "geo_bounding_box": {
      "location": {
        "top_left": { "lat": 43.1, "lon": -79.0 },
        "bottom_right": { "lat": 42.8, "lon": -78.7 }
      }
    }
  }
}
```

### geo_distance

```json
{ "query": { "geo_distance": { "distance": "10km", "location": { "lat": 42.886, "lon": -78.878 } } } }
```

## Special Queries

### match_all / match_none

```json
{ "query": { "match_all": {} } }
{ "query": { "match_none": {} } }
```

### script

Custom scoring/filtering via Painless scripts:

```json
{
  "query": {
    "script": {
      "script": {
        "source": "doc['response_time'].value > params.threshold",
        "params": { "threshold": 1000 }
      }
    }
  }
}
```

## Search Features

### Sorting

```json
{ "sort": [ { "@timestamp": "desc" }, { "_score": "desc" } ] }
```

### Source filtering

```json
{ "_source": ["message", "level", "@timestamp"] }
{ "_source": { "includes": ["error.*"], "excludes": ["error.stack"] } }
```

### Highlighting

```json
{ "highlight": { "fields": { "message": { "pre_tags": [">>"], "post_tags": ["<<"] } } } }
```

### search_after (pagination beyond 10k)

```json
{
  "size": 100,
  "sort": [ { "@timestamp": "desc" }, { "_id": "asc" } ],
  "search_after": ["2026-01-31T12:00:00.000Z", "abc123"]
}
```

### collapse (deduplicate by field)

```json
{ "collapse": { "field": "hostname" }, "sort": [ { "@timestamp": "desc" } ] }
```

### runtime fields (query-time computed fields)

```json
{
  "runtime_mappings": {
    "duration_ms": {
      "type": "double",
      "script": "emit(doc['end_time'].value.toInstant().toEpochMilli() - doc['start_time'].value.toInstant().toEpochMilli())"
    }
  },
  "query": { "range": { "duration_ms": { "gte": 1000 } } }
}
```


path: references/search-api.md
content: # Search API Reference

## Query DSL

The primary search endpoint. Supports GET and POST with a JSON body.

```bash
# Basic search
es "my-index/_search" -d '{
  "query": {
    "bool": {
      "must": [
        {"match": {"message": "error"}}
      ],
      "filter": [
        {"range": {"@timestamp": {"gte": "now-1h"}}}
      ]
    }
  },
  "size": 20,
  "sort": [{"@timestamp": {"order": "desc"}}]
}'
```

### Common Query Types

**match** — full-text search (analyzed):
```json
{"query": {"match": {"message": "connection timeout"}}}
```

**term** — exact value match (not analyzed, use for keywords/enums):
```json
{"query": {"term": {"status.keyword": "error"}}}
```

**bool** — combine queries:
```json
{"query": {"bool": {
  "must": [...],      // AND — contributes to score
  "filter": [...],    // AND — no scoring, faster
  "should": [...],    // OR — at least one if no must
  "must_not": [...]   // NOT — excludes
}}}
```

**range** — numeric/date ranges:
```json
{"query": {"range": {"@timestamp": {"gte": "now-24h", "lte": "now"}}}}
```

**wildcard** — pattern matching (use sparingly, slow):
```json
{"query": {"wildcard": {"host.keyword": "prod-web-*"}}}
```

**exists** — field exists:
```json
{"query": {"exists": {"field": "error.stack_trace"}}}
```

**multi_match** — search across multiple fields:
```json
{"query": {"multi_match": {"query": "timeout", "fields": ["message", "error.message", "log.message"]}}}
```

**query_string** — Lucene syntax (powerful, user-facing):
```json
{"query": {"query_string": {"query": "status:error AND service:api-gateway"}}}
```

### Sorting

```json
{
  "sort": [
    {"@timestamp": {"order": "desc"}},
    {"_score": {"order": "desc"}}
  ]
}
```

### Highlighting

```json
{
  "query": {"match": {"message": "error"}},
  "highlight": {
    "fields": {"message": {}},
    "pre_tags": [">>"],
    "post_tags": ["<<"]
  }
}
```

### Pagination

**Basic** (up to 10,000 hits):
```json
{"from": 0, "size": 20}
```

**search_after** (for deep pagination):
```json
{
  "size": 20,
  "sort": [{"@timestamp": "desc"}, {"_id": "asc"}],
  "search_after": ["2026-01-31T10:00:00.000Z", "doc-id-123"]
}
```

**Point-in-time + search_after** (consistent pagination):
```bash
# Open PIT
PIT=$(es "_pit" -d '{"index":"my-index","keep_alive":"5m"}' | jq -r '.id')

# Search with PIT
es "_search" -d "{\"pit\":{\"id\":\"$PIT\",\"keep_alive\":\"5m\"},\"size\":100,\"sort\":[{\"@timestamp\":\"desc\"}]}"
```

### Count

```bash
es "my-index/_count" -d '{"query": {"range": {"@timestamp": {"gte": "now-1h"}}}}'
```

## ES|QL (Elasticsearch Query Language)

Pipe-based query language. Simpler than DSL for analytics.

```bash
es "_query" -d '{
  "query": "FROM logs-* | WHERE @timestamp > NOW() - 1 hour AND level == \"error\" | STATS count = COUNT(*) BY service.name | SORT count DESC | LIMIT 10"
}'
```

### ES|QL Patterns

```sql
-- Top error-producing services in the last hour
FROM logs-*
| WHERE @timestamp > NOW() - 1 hour AND level == "error"
| STATS error_count = COUNT(*) BY service.name
| SORT error_count DESC
| LIMIT 10

-- P99 latency by endpoint
FROM traces-*
| WHERE @timestamp > NOW() - 24 hours
| STATS p99 = PERCENTILE(duration, 99), avg = AVG(duration) BY url.path
| WHERE p99 > 1000
| SORT p99 DESC

-- Unique users per day
FROM logs-*
| WHERE @timestamp > NOW() - 7 days
| EVAL day = DATE_TRUNC(1 day, @timestamp)
| STATS unique_users = COUNT_DISTINCT(user.id) BY day
| SORT day
```

## EQL (Event Query Language)

Sequence-based queries for event correlation:

```bash
es "logs-*/_eql/search" -d '{
  "query": "sequence by host.name [process where event.type == \"start\" and process.name == \"cmd.exe\"] [file where event.type == \"creation\"]",
  "size": 10
}'
```


Security scan verdicts — ClawScan: suspicious | static analysis:  | VirusTotal: suspicious | SkillSpector: suspicious (CRITICAL)