--- name: esql description: "Query Elasticsearch/Kibana using ES|QL via the Kibana async search API. Requires an initial curl command from the user to extract session credentials. Triggers: /esql, 'query elastic', 'search logs', 'check elastic logs', 'run esql', 'elasticsearch query', 'check kibana'" user_invocable: true --- # ES|QL Query Skill Query Elasticsearch via the Kibana ES|QL async search API. Run ES|QL queries directly against Kibana clusters without the user needing to copy-paste results. ## Setup ### Staging / Production — `,es-web` CLI The `,es-web` CLI at `/Users/meain/.local/bin/utils/,es-web` handles Kibana ES|QL queries with automatic async polling, session management, and table output. **First-time setup** for a cluster: open Kibana's ES|QL tab (use the URL with `dataSource:(type:esql)` so an `esql_async` request fires on load), open DevTools → Network, filter by `esql_async`, right-click the request → **Copy as cURL**, then run: ```bash pbpaste | ,es-web init stg-us pbpaste | ,es-web init prd-eu ``` **Check configured clusters:** ```bash ,es-web list ``` Session cookies expire after a few hours. If you get a 401/session expired error, use `reinit` — it reopens Kibana and waits for a fresh curl on the clipboard itself, then tests & saves: ```bash ,es-web reinit prd-us ``` Run this with `dangerouslyDisableSandbox: true` since it needs to open the browser. It blocks until a valid curl shows up on the clipboard, so tell the user to copy one (DevTools → Network → filter `esql_async` → right-click → Copy as cURL) while it's waiting. ## How to query with `,es-web` ```bash # Simple: service + time window ,es-web -c stg-us -s earn -t 1h -n 20 # With additional WHERE clause ,es-web -c stg-us -s earn -t 1h -q 'message LIKE "*validation*"' # Custom fields ,es-web -c stg-us -s earn -t 1h -f '@timestamp, message, labels.error' # Raw ES|QL query ,es-web -c stg-us 'FROM logs-* | WHERE service.name == "earn" | STATS count = COUNT(*) BY labels.event_type | SORT count DESC | LIMIT 10' # JSON output for further processing ,es-web -c stg-us -s earn -t 30m --json # Debug: print generated query ,es-web -c stg-us -s earn -t 1h --debug ``` ### `,es-web` options - `-c` — Cluster alias (e.g. `stg-us`, `prd-eu`) - `-s` — `service.name` filter - `-t` — Time window (e.g. `30m`, `1h`, `2d`). Defaults to `4h` if omitted (a warning is printed to stderr) - `-n` — Max rows (default: 20) - `-q` — Additional WHERE clause - `-f` — Comma-separated fields for KEEP clause - `-i` — Index pattern (default: `logs-*`) - `--json` — Raw JSON output - `--debug` — Print generated query to stderr ### Important notes - `,es-web` swallows unknown-column errors — a `KEEP`/`STATS BY` on a **non-existent field returns an empty "No results"**, not an error. Before concluding "no data", confirm the field exists (e.g. `... | WHERE IS NOT NULL | STATS COUNT(*)`). Field names vary by dataset (e.g. `apm.app.earn` has no HTTP path field; `apm.app.nginx` uses `labels.path`). - `labels.status` is a **keyword** field — compare with strings, not integers (e.g., `labels.status >= "500"`) - When no `-f` is specified, default fields are: `@timestamp, service.name, message, labels.error, labels.error_message, labels.path, labels.status, trace.id` - For raw ES|QL queries, pass the full query as a positional argument — no default KEEP is added - LIKE queries on `message` across many services can be slow — scope with service and tight time windows ## EARN-specific reference For EARN log fields, structured labels, message strings, query patterns, and metrics, read: `.mdocs/reference/earn-kibana-esql-reference.md` (relative to the `control-plane-backend` repo root). That doc is authoritative for EARN. The quick summary of EARN-specific field names (these differ from other services): - `labels.workload_tenant_id` — workload tenant UUID (not `labels.workload_tenant.id`) - `labels.event_type` — EARN event type (not `labels.earn.event.type`) - `labels.organization_id` — org UUID in EARN context - `labels.operation` — EARN operation name (e.g. `get_workload_tenant_metadata`) - `labels.clog_labels` — structured log category (e.g. `[earn_event_dropped]`) - `log.level` — log level in EARN APM logs (not `level`) Note: EARN logs appear **twice** in `logs-*` — once as APM-parsed (`data_stream.dataset: apm.app.earn`, has structured fields) and once as raw filebeat (`data_stream.dataset: kubernetes.container_logs`, has JSON in message). Filter to `data_stream.dataset == "apm.app.earn"` to avoid double-counting in STATS. ## Known service names These are the `service.name` values for control-plane-backend services: | Service | `service.name` | |---|---| | Workload Tenants | `workload-tenants-svc` | | Subscriptions | `subscriptions-svc` | | User Management | `user-management` | | Routing | `routing` | | EARN | `earn` | | EARN Telemetry | `earn-telemetry` | | Usage Reports | `usage_reports` | | Onboarding | `onboarding` | | Scheduler | `scheduler` | The nginx ingress controller logs under `service.name == "nginx"`. ## Common log fields ### Standard fields - `@timestamp` — event timestamp (ISO 8601) - `service.name` — service identifier (see table above) - `service.environment` — `prod`, `prd`, `qa`, `dev` - `message` — log message text - `level` — log level (`info`, `warn`, `error`) - `trace.id` — distributed trace ID for correlating across services ### Label fields (keyword/string type) - `labels.status` — HTTP status code (string, not int) - `labels.path` — request path - `labels.method` — HTTP method - `labels.error` / `labels.error_message` — error details - `labels.organization_id` / `labels.org_id` — organization identifier - `labels.organization.id` — alternative org ID field - `labels.workload_tenant_id` — workload tenant identifier (EARN: same field, see EARN reference) - `labels.user.id` / `labels.principal.id` — user/principal identifiers - `labels.operation` — operation being performed - `labels.handler` — handler name - `labels.event_type` — EARN event type (use `labels.event_type`, not `labels.earn.event.type`) - `labels.earn.event.dedupe_key` — EARN dedup key - `labels.scheduler.event.id` — scheduler event ID - `labels.request.url` — outgoing request URL - `labels.http.router.request_id` — request ID ### Numeric label fields - `numeric_labels.*` — numeric values (latency, counts, etc.) ### Overview labels (for filtering structured log categories) These are set via `clog.Label()` and appear in a labels field: - `http_server_overview`, `dispatcher_overview`, `queue_dispatcher_overview` - `earn_event_delivery`, `events_processor` - `panic_recovery`, `panic_recovered` - `azure_sdk_logging`, `secrets_handling` - `deprecated_behavior_overview` ## Common query patterns ### List services in a time window ``` FROM logs-* | WHERE service.environment == "prod" AND @timestamp >= "2026-04-07T07:00:00.000Z" AND @timestamp <= "2026-04-07T08:00:00.000Z" | STATS count = COUNT(*) BY service.name | SORT count DESC | LIMIT 30 ``` ### Find 5xx errors from nginx ``` FROM logs-* | WHERE service.name == "nginx" AND service.environment == "prod" AND labels.status >= "500" | SORT @timestamp DESC | LIMIT 20 | KEEP @timestamp, labels.status, labels.path, labels.method, message ``` ### Find errors for a specific service ``` FROM logs-* | WHERE service.name == "earn" AND service.environment == "prod" AND level == "error" AND @timestamp >= "START" AND @timestamp <= "END" | SORT @timestamp DESC | LIMIT 30 | KEEP @timestamp, message, labels.error, labels.error_message, labels.operation, trace.id ``` ### Search by org/tenant ID across all services ``` FROM logs-* | WHERE service.environment == "prod" AND @timestamp >= "START" AND @timestamp <= "END" AND (labels.organization_id == "ORG_ID" OR labels.org_id == "ORG_ID" OR labels.organization.id == "ORG_ID") | SORT @timestamp DESC | LIMIT 20 | KEEP @timestamp, service.name, message, labels.error, trace.id ``` ### Search by workload tenant ID ``` FROM logs-* | WHERE service.environment == "prod" AND @timestamp >= "START" AND @timestamp <= "END" AND labels.workload_tenant_id == "TENANT_ID" | SORT @timestamp DESC | LIMIT 30 | KEEP @timestamp, service.name, message, labels.error, labels.operation ``` ### Trace a request across services ``` FROM logs-* | WHERE trace.id == "TRACE_ID" | SORT @timestamp ASC | LIMIT 100 | KEEP @timestamp, service.name, level, message, labels.error, labels.status, labels.path ``` ### Check latency metrics ``` FROM logs-* | WHERE service.name == "SERVICE" AND service.environment == "prod" AND message LIKE "*metric*latency*" | SORT @timestamp DESC | LIMIT 30 | KEEP @timestamp, message, numeric_labels.value_ms ``` ### Error rate by service (last hour) ``` FROM logs-* | WHERE service.environment == "prod" AND @timestamp >= "START" AND @timestamp <= "END" AND level == "error" | STATS error_count = COUNT(*) BY service.name | SORT error_count DESC | LIMIT 20 ``` ### Find panics ``` FROM logs-* | WHERE service.environment == "prod" AND @timestamp >= "START" AND @timestamp <= "END" AND (message LIKE "*panic*" OR labels.panic_recovered == "true" OR labels.panic_recovery == "true") | SORT @timestamp DESC | LIMIT 20 | KEEP @timestamp, service.name, message, labels.error, trace.id ``` ## Dev / Personal Environments — `,es` CLI The `,es` CLI at `/Users/meain/.local/bin/utils/,es` provides a simpler way to query Elasticsearch, but it **only works for the dev environment**. Do not use it for staging or production. ### Common queries Search by trace ID: ``` ,es -q 'trace.id:' -n 50 ``` Filter for errors: ``` ,es -q 'trace.id: AND event.type:error' -n 10 ``` Broader service-level search (no trace ID): ``` ,es -q 'service.name: AND (level:error OR event.type:error)' -n 20 ``` ### `,es` CLI options - `-q` — Lucene query string - `-n` — Number of results - `-f` — Comma-separated fields to display - `-s` — Sort field (e.g. `@timestamp:desc`) ## Choosing the right approach | Environment | Method | |---|---| | Dev / personal | Use `,es` CLI directly | | Staging / production | Use `,es-web -c ` (requires session cookie from user) | | No session available | Fall back to clipboard mode (see below) | **Cluster naming convention:** `-`, e.g. `stg-us`, `stg-eu`, `prd-us`, `prd-eu`, `prd-apj` (APJ, not apac). When no `,es-web` session is configured for the target cluster: 1. Look up the Kibana URL on the Confluence Speed Dial page (`confluence page 405504500`), open `/app/discover#/` with `open ` (with `dangerouslyDisableSandbox: true`), then ask the user to copy a curl from the network tab and run `! pbpaste | ,es-web init ` 2. If that's not possible, fall back to clipboard mode: - Copy the ES|QL query to the user's clipboard with `pbcopy` - Tell the user to open Kibana, run the query in Discover (ES|QL mode) - Have them copy the response JSON from the network tab ## Investigation workflow tips - **Start broad, narrow down.** First identify which services are involved, then drill into specific errors. - **nginx → upstream.** nginx logs show external-facing status codes; upstream service logs show root cause. - **Use trace.id** to correlate requests across services when available. - **Error details** live in `labels.error` and `labels.error_message`. - **Field types matter:** `numeric_labels.*` fields are numeric; `labels.*` fields are keywords (strings). - **Time windows:** Default is 4h when `-t` is omitted. If a query times out, retry with a narrower window (e.g., `1h` → `30m`). For very broad searches (e.g., no service filter), start at `30m` and expand only if needed. - **Overview labels** (like `http_server_overview`, `dispatcher_overview`) are useful for filtering to structured log categories without needing to match on message text.