
Observability Service Health
- 2 installs
- 31 repo stars
- Updated May 28, 2026
- elastic/cursor-plugins
This is a copy of observability-service-health by elastic - installs and ranking accrue to the original listing.
Helps with ai & agent building tasks.
About
observability-service-health is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- observability-service-health
- AI & Agent Building
- AI-coding skill
Observability Service Health by the numbers
- 2 all-time installs (skills.sh)
- Data as of Jul 24, 2026 (Skillselion catalog sync)
npx skills add https://github.com/elastic/cursor-plugins --skill observability-service-healthAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2 |
|---|---|
| repo stars | ★ 31 |
| Last updated | May 28, 2026 |
| Repository | elastic/cursor-plugins ↗ |
What it does
Helps with ai & agent building tasks.
Files
APM Service Health
Assess APM service health using Observability APIs, ES|QL against APM indices, Elasticsearch APIs, and (for correlation and APM-specific logic) the Kibana repo. Use SLOs, firing alerts, ML anomalies, throughput, latency (avg/p95/p99), error rate, and dependency health.
Where to look
- Observability APIs (Observability APIs): Use the
SLOs API (Stack | Serverless) to get SLO definitions, status, burn rate, and error budget. Use the Alerting API (Stack | Serverless) to list and manage alerting rules and their alerts for the service. Use APM annotations API to create or search annotations when needed.
- ES|QL and Elasticsearch: Query
traces*apm*,traces*otel*andmetrics*apm*,metrics*otel*with ES|QL (see
Using ES|QL for APM metrics) for throughput, latency, error rate, and dependency-style aggregations. Use Elasticsearch APIs (e.g. POST _query for ES|QL, or Query DSL) as documented in the Elasticsearch repo for indices and search.
- APM Correlations: Run the apm-correlations script to get attributes that correlate with high-latency or failed
transactions for a given service. It tries the Kibana internal APM correlations API first, then falls back to Elasticsearch significant_terms on traces*apm*,traces*otel*. See APM Correlations script.
- Infrastructure: Correlate via resource attributes (e.g.
k8s.pod.name,container.id,host.name) in
traces; query infrastructure or metrics indices with ES|QL/Elasticsearch for CPU and memory. OOM and CPU throttling directly impact APM health.
- Logs: Use ES|QL or Elasticsearch search on log indices filtered by
service.nameortrace.idto explain
behavior and root cause.
- Observability Labs: Observability Labs and
APM tag for patterns and troubleshooting.
Health criteria
Synthesize health from all of the following when available:
| Signal | What to check |
|---|---|
| SLOs | Burn rate, status (healthy/degrading/violated), error budget. |
| Firing alerts | Open or recently fired alerts for the service or dependencies. |
| ML anomalies | Anomaly jobs; score and severity for latency, throughput, or error rate. |
| Throughput | Request rate; compare to baseline or previous period. |
| Latency | Avg, p95, p99; compare to SLO targets or history. |
| Error rate | Failed/total requests; spikes or sustained elevation. |
| Dependency health | Downstream latency, error rate, availability (ES\ |
| Infrastructure | CPU usage, memory; OOM and CPU throttling on pods/containers/hosts. |
| Logs | App logs filtered by service or trace ID for context and root cause. |
Treat a service as unhealthy if SLOs are violated, critical alerts are firing, or ML anomalies indicate severe degradation. Correlate with infrastructure (OOM, CPU throttling), dependencies, and logs (service/trace context) to explain _why_ and suggest next steps.
Using ES|QL for APM metrics
When querying APM data from Elasticsearch (traces*apm*,traces*otel*, metrics*apm*,metrics*otel*), use ES|QL by default where available.
- Availability: ES|QL is available in Elasticsearch 8.11+ (technical preview; GA in 8.14). It is **always
available** in Elastic Observability Serverless Complete tier.
- Scoping to a service: Always filter by
service.name(andservice.environmentwhen relevant). Combine with a
time range on @timestamp:
WHERE service.name == "my-service-name" AND service.environment == "production"
AND @timestamp >= "2025-03-01T00:00:00Z" AND @timestamp <= "2025-03-01T23:59:59Z"- Example patterns: Throughput, latency, and error rate over time: see Kibana
trace_charts_definition.ts
(getThroughputChart, getLatencyChart, getErrorRateChart). Use from(index) → where(...) → stats(...) / evaluate(...) with BUCKET(@timestamp, ...) and WHERE service.name == "<service_name>".
- Performance: Add
LIMIT nto cap rows and token usage. Prefer coarserBUCKET(@timestamp, ...)(e.g. 1 hour)
when only trends are needed; finer buckets increase work and result size.
APM Correlations script
When only a subpopulation of transactions has high latency or failures, run the apm-correlations script to list attributes that correlate with those transactions (e.g. host, service version, pod, region). The script tries the Kibana internal APM correlations API first; if unavailable (e.g. 404), it falls back to Elasticsearch significant_terms on traces*apm*,traces*otel*.
# Latency correlations (attributes over-represented in slow transactions)
node skills/observability/service-health/scripts/apm-correlations.js latency-correlations --service-name <name> [--start <iso>] [--end <iso>] [--last-minutes 60] [--transaction-type <t>] [--transaction-name <n>] [--space <id>] [--json]
# Failed transaction correlations
node skills/observability/service-health/scripts/apm-correlations.js failed-correlations --service-name <name> [--start <iso>] [--end <iso>] [--last-minutes 60] [--transaction-type <t>] [--transaction-name <n>] [--space <id>] [--json]
# Test Kibana connection
node skills/observability/service-health/scripts/apm-correlations.js test [--space <id>]Environment: KIBANA_URL and KIBANA_API_KEY (or KIBANA_USERNAME/KIBANA_PASSWORD) for Kibana; for fallback, ELASTICSEARCH_URL and ELASTICSEARCH_API_KEY. Use the same time range as the investigation.
Workflow
Service health progress:
- [ ] Step 1: Identify the service (and time range)
- [ ] Step 2: Check SLOs and firing alerts
- [ ] Step 3: Check ML anomalies (if configured)
- [ ] Step 4: Review throughput, latency (avg/p95/p99), error rate
- [ ] Step 5: Assess dependency health (ES|QL/APIs / Kibana repo)
- [ ] Step 6: Correlate with infrastructure and logs
- [ ] Step 7: Summarize health and recommend actionsStep 1: Identify the service
Confirm service name and time range. Resolve the service from the request; if multiple are in scope, target the most relevant. Use ES|QL on traces*apm*,traces*otel* or metrics*apm*,metrics*otel* (e.g. WHERE service.name == "<name>") or Kibana repo APM routes to obtain service-level data. If the user has not provided the time range, assume last hour.
Step 2: Check SLOs and firing alerts
SLOs: Call the SLOs API to get SLO definitions and status for the service (latency, availability), healthy/degrading/violated, burn rate, error budget. Alerts: For active APM alerts, call /api/alerting/rules/_find?search=apm&search_fields=tags&per_page=100&filter=alert.attributes.executionStatus.status:active. When checking one service, include both rules where params.serviceName matches the service and rules where params.serviceName is absent (all-services rules). Do not query .alerts* indices for active-state checks. Correlate with SLO violations or metric changes.
Step 3: Check ML anomalies
If ML anomaly detection is used, query ML job results or anomaly records (via Elasticsearch ML APIs or indices) for the service and time range. Note high-severity anomalies (latency, throughput, error rate); use anomaly time windows to narrow Steps 4–5.
Step 4: Review throughput, latency, and error rate
Use ES|QL against traces*apm*,traces*otel* or metrics*apm*,metrics*otel* for the service and time range to get throughput (e.g. req/min), latency (avg, p95, p99), error rate (failed/total or 5xx/total). Example: FROM traces*apm*,traces*otel* | WHERE service.name == "<service_name>" AND @timestamp >= ... AND @timestamp <= ... | STATS .... Compare to prior period or SLO targets. See Using ES|QL for APM metrics.
Step 5: Assess dependency health
Obtain dependency and service-map data via ES|QL on traces*apm*,traces*otel*/metrics*apm*,metrics*otel* (e.g. downstream service/span aggregations) or via APM route handlers in the Kibana repo that expose dependency/service-map data. For the service and time range, note downstream latency and error rate; flag slow or failing dependencies as likely causes.
Step 6: Correlate with infrastructure and logs
- APM Correlations (when only a subpopulation is affected): Run
node skills/observability/service-health/scripts/apm-correlations.js latency-correlations|failed-correlations --service-name <name> [--start ...] [--end ...] to get correlated attributes. Filter by those attributes and fetch trace samples or errors to confirm root cause. See APM Correlations script.
- Infrastructure: Use resource attributes from traces (e.g.
k8s.pod.name,container.id,host.name) and
query infrastructure/metrics indices with ES|QL or Elasticsearch for CPU and memory. OOM and CPU throttling directly impact APM health; correlate their time windows with APM degradation.
- Logs: Use ES|QL or Elasticsearch on log indices with
service.name == "<service_name>"or
trace.id == "<trace_id>" to explain behavior and root cause (exceptions, timeouts, restarts).
Step 7: Summarize and recommend
State health (healthy / degraded / unhealthy) with reasons; list concrete next steps.
Examples
Example: ES|QL for a specific service
Scope with WHERE service.name == "<service_name>" and time range. Throughput and error rate (1-hour buckets; LIMIT caps rows and tokens):
FROM traces*apm*,traces*otel*
| WHERE service.name == "api-gateway"
AND @timestamp >= "2025-03-01T00:00:00Z" AND @timestamp <= "2025-03-01T23:59:59Z"
| STATS request_count = COUNT(*), failures = COUNT(*) WHERE event.outcome == "failure" BY BUCKET(@timestamp, 1 hour)
| EVAL error_rate = failures / request_count
| SORT @timestamp
| LIMIT 500Latency percentiles and exact field names: see Kibana trace_charts_definition.ts.
Example: "Is service X healthy?"
1. Resolve service X and time range. Call SLOs API and Alerting API; run ES|QL on traces*apm*,traces*otel*/metrics*apm*,metrics*otel* for throughput, latency, error rate; query dependency/service-map data (ES|QL or Kibana repo). 2. Evaluate SLO status (violated/degrading?), firing rules, ML anomalies, and dependency health. 3. Answer: Healthy / Degraded / Unhealthy with reasons and next steps (e.g. Observability Labs).
Example: "Why is service Y slow?"
1. Service Y and slowness time range. Call SLOs API and Alerting API; run ES|QL for Y and dependencies; query ML anomaly results. 2. Compare latency (avg/p95/p99) to prior period via ES|QL; from dependency data identify high-latency or failing deps. 3. Summarize (e.g. p99 up; dependency Z elevated) and recommend (investigate Z; Observability Labs for latency).
Example: Correlate service to infrastructure (OpenTelemetry)
Use resource attributes on spans/traces to get the runtimes (pods, containers, hosts) for the service. Then check CPU and memory for those resources in the same time window as the APM issue:
- From the service’s traces or metrics, read resource attributes such as
k8s.pod.name,k8s.namespace.name,
container.id, or host.name.
- Run ES|QL or Elasticsearch search on infrastructure/metrics indices filtered by those resource values and the
incident time range. Check CPU usage and memory consumption (e.g. system.cpu.total.norm.pct); look for OOMKilled events, CPU throttling, or sustained high CPU/memory that align with APM latency or error spikes.
Example: Filter logs by service or trace ID
To understand behavior for a specific service or a single trace, filter logs accordingly:
- By service: Run ES|QL or Elasticsearch search on log indices with
service.name == "<service_name>"and time
range to get application logs (errors, warnings, restarts) in the service context.
- By trace ID: When investigating a specific request, take the
trace.idfrom the APM trace and filter logs by
trace.id == "<trace_id>" (or equivalent field in your log schema). Logs with that trace ID show the full request path and help explain failures or latency.
Guidelines
- Use Observability APIs (SLOs API,
Alerting API) and ES|QL on traces*apm*,traces*otel*/metrics*apm*,metrics*otel* (8.11+ or Serverless), filtering by service.name (and service.environment when relevant). For active APM alerts, call /api/alerting/rules/_find?search=apm&search_fields=tags&per_page=100&filter=alert.attributes.executionStatus.status:active. When checking one service, evaluate both rule types: rules where params.serviceName matches the target service, and rules where params.serviceName is absent (all-services rules). Treat either as applicable to the service before declaring health. Do not query .alerts* indices when determining currently active alerts; use the Alerting API response above as the source of truth. For APM correlations, run the apm-correlations script (see APM Correlations script); for dependency/service-map data, use ES|QL or Kibana repo route handlers. For Elasticsearch index and search behavior, see the Elasticsearch APIs in the Elasticsearch repo.
- Always use the user's time range; avoid assuming "last 1 hour" if the issue is historical.
- When SLOs exist, anchor the health summary to SLO status and burn rate; when they do not, rely on alerts, anomalies,
throughput, latency, error rate, and dependencies.
- When analyzing only application metrics ingested via OpenTelemetry, use the ES|QL TS (time series) command for
efficient metrics queries. The TS command is available in Elasticsearch 9.3+ and is always available in Elastic Observability Serverless.
- Summary: one short health verdict plus bullet points for evidence and next steps.
Multi Search API Reference
Quick reference for the Elasticsearch search APIs used by this skill. For full documentation, see the Search APIs.
Multi Search
POST /_msearch
POST /{index}/_msearchExecutes multiple searches in a single request using NDJSON (newline-delimited JSON). Each request is a header/body pair. The final line must end with \n and the Content-Type must be application/x-ndjson.
| Parameter | Description |
|---|---|
max_concurrent_searches | Maximum number of concurrent searches (defaults to node count x pool size) |
max_concurrent_shard_requests | Maximum concurrent shard requests per node per sub-search |
search_type | query_then_fetch (default) or dfs_query_then_fetch for global scoring |
ccs_minimize_roundtrips | Minimize network round-trips for cross-cluster search |
rest_total_hits_as_int | Return hits.total as an integer instead of an object |
Search
POST /{index}/_searchRun a single search against one or more indices.
| Parameter | Description |
|---|---|
from | Starting document offset (default 0) |
size | Number of hits to return (default 10) |
timeout | Per-shard timeout; request fails if exceeded |
track_total_hits | Accurate hit count (true) or fast approximate (false) |
search_type | query_then_fetch (default) or dfs_query_then_fetch |
request_cache | Enable caching for size: 0 requests |
ES|QL Query
POST /_queryRun an ES|QL query. ES|QL uses a piped syntax (FROM index | WHERE ... | STATS ... | LIMIT n) and returns columnar results by default.
| Parameter | Description |
|---|---|
format | Response format: json, csv, tsv, txt, yaml |
drop_null_columns | Remove entirely null columns from the response |
allow_partial_results | Return partial results on shard failures instead of failing |
NDJSON Format
The _msearch body alternates between header and body lines:
header\n
body\n
header\n
body\n- Header — JSON object with optional
index,routing,preference, andsearch_typefields. Use{}to inherit
the index from the URL path.
- Body — A standard search request body (
query,aggs,size,sort,_source, etc.).
Common Query Types
| Query | Use for |
|---|---|
match | Full-text search on analyzed fields |
term | Exact-value lookup on keyword or numeric fields |
bool | Combine must, should, must_not, and filter clauses |
range | Numeric or date range filtering |
multi_match | Full-text search across multiple fields |
match_phrase | Exact phrase matching with term order preserved |
exists | Documents where a field has a non-null value |
#!/usr/bin/env node
/**
* APM Correlations: attributes that correlate with high-latency or failed transactions.
* Tries Kibana internal APM correlations API first; falls back to Elasticsearch significant_terms.
*
* Usage:
* node apm-correlations.js latency-correlations --service-name <name> [--start <iso>] [--end <iso>] [--transaction-type <t>] [--transaction-name <n>] [--space <id>] [--json]
* node apm-correlations.js failed-correlations --service-name <name> [--start <iso>] [--end <iso>] [--transaction-type <t>] [--transaction-name <n>] [--space <id>] [--json]
* node apm-correlations.js test [--space <id>]
*
* Environment:
* KIBANA_URL, KIBANA_API_KEY (or KIBANA_USERNAME + KIBANA_PASSWORD) for Kibana internal API.
* ELASTICSEARCH_URL, ELASTICSEARCH_API_KEY for fallback (or ELASTICSEARCH_CLOUD_ID + ELASTICSEARCH_API_KEY).
*/
import { kibanaFetch } from "./kibana-client.js";
const DEFAULT_LAST_MINUTES = 60;
const EVENT_OUTCOME = "event.outcome";
const FIELD_CANDIDATE_BATCH_SIZE = 10;
function chunkArray(items, chunkSize) {
if (!Array.isArray(items) || items.length === 0) return [];
const chunks = [];
for (let i = 0; i < items.length; i += chunkSize) {
chunks.push(items.slice(i, i + chunkSize));
}
return chunks;
}
function parseArgs(argv) {
const args = {
serviceName: null,
start: null,
end: null,
lastMinutes: null,
transactionType: null,
transactionName: null,
environment: null,
space: null,
json: false,
};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === "--service-name" || a === "-s") args.serviceName = argv[++i] ?? null;
else if (a === "--start") args.start = argv[++i] ?? null;
else if (a === "--end") args.end = argv[++i] ?? null;
else if (a === "--last-minutes") args.lastMinutes = parseInt(argv[++i], 10) || null;
else if (a === "--transaction-type") args.transactionType = argv[++i] ?? null;
else if (a === "--transaction-name") args.transactionName = argv[++i] ?? null;
else if (a === "--environment") args.environment = argv[++i] ?? null;
else if (a === "--space") args.space = argv[++i] ?? null;
else if (a === "--json" || a === "-j") args.json = true;
}
return args;
}
function resolveTimeRange(args) {
let start = args.start ? new Date(args.start) : null;
let end = args.end ? new Date(args.end) : null;
if (args.lastMinutes && !start && !end) {
end = new Date();
start = new Date(end.getTime() - args.lastMinutes * 60 * 1000);
}
if (!start) start = new Date(Date.now() - DEFAULT_LAST_MINUTES * 60 * 1000);
if (!end) end = new Date();
return { start: start.toISOString(), end: end.toISOString() };
}
/**
* Fetch Kibana APM correlations using the 3-step correlations flow:
* 1) field candidates
* 2) field value pairs
* 3) significant correlations
*/
async function fetchKibanaCorrelations(type, serviceName, timeRange, args) {
const { start, end } = timeRange;
const fetchParams = { serviceName, start, end, kuery: "", environment: args.environment || "ENVIRONMENT_ALL" };
if (args.transactionType) fetchParams.transactionType = args.transactionType;
if (args.transactionName) fetchParams.transactionName = args.transactionName;
const fieldCandidatesResult = await kibanaFetch("/internal/apm/correlations/field_candidates/transactions", {
method: "GET",
params: fetchParams,
space: args.space,
});
if (!fieldCandidatesResult.success) return fieldCandidatesResult;
let fieldCandidates = Array.isArray(fieldCandidatesResult.data?.fieldCandidates)
? fieldCandidatesResult.data.fieldCandidates
: [];
if (type === "failed") {
fieldCandidates = fieldCandidates.filter((field) => field !== EVENT_OUTCOME);
}
if (fieldCandidates.length === 0) {
return {
success: true,
data: type === "latency" ? { latencyCorrelations: [] } : { failedTransactionsCorrelations: [] },
};
}
if (type === "latency") {
const fieldCandidateBatches = chunkArray(fieldCandidates, FIELD_CANDIDATE_BATCH_SIZE);
const fieldValuePairsBatchResults = await Promise.all(
fieldCandidateBatches.map((fieldCandidatesBatch) =>
kibanaFetch("/internal/apm/correlations/field_value_pairs/transactions", {
method: "POST",
body: JSON.stringify({
...fetchParams,
fieldCandidates: fieldCandidatesBatch,
}),
space: args.space,
}),
),
);
const firstFieldValuePairsError = fieldValuePairsBatchResults.find((result) => !result.success);
if (firstFieldValuePairsError) return firstFieldValuePairsError;
const fieldValuePairs = fieldValuePairsBatchResults.flatMap((result) =>
Array.isArray(result.data?.fieldValuePairs) ? result.data.fieldValuePairs : [],
);
if (fieldValuePairs.length === 0) {
return {
success: true,
data: { latencyCorrelations: [] },
};
}
const significantResult = await kibanaFetch("/internal/apm/correlations/significant_correlations/transactions", {
method: "POST",
body: JSON.stringify({
...fetchParams,
fieldValuePairs,
}),
space: args.space,
});
if (!significantResult.success) return significantResult;
const significantData = significantResult.data ?? {};
return {
success: true,
data: {
ccsWarning: significantData.ccsWarning,
latencyCorrelations: Array.isArray(significantData.latencyCorrelations)
? significantData.latencyCorrelations.map((correlation) => ({
fieldName: correlation.fieldName,
fieldValue: correlation.fieldValue,
correlation: correlation.correlation,
}))
: [],
},
};
}
const fieldCandidateBatches = chunkArray(fieldCandidates, FIELD_CANDIDATE_BATCH_SIZE);
const pValuesBatchResults = await Promise.all(
fieldCandidateBatches.map((fieldCandidatesBatch) =>
kibanaFetch("/internal/apm/correlations/p_values/transactions", {
method: "POST",
body: JSON.stringify({
...fetchParams,
fieldCandidates: fieldCandidatesBatch,
}),
space: args.space,
}),
),
);
const firstPValuesError = pValuesBatchResults.find((result) => !result.success);
if (firstPValuesError) return firstPValuesError;
const failedTransactionsCorrelations = pValuesBatchResults.flatMap((result) =>
Array.isArray(result.data?.failedTransactionsCorrelations) ? result.data.failedTransactionsCorrelations : [],
);
return {
success: true,
data: {
ccsWarning: pValuesBatchResults.some((result) => result.data?.ccsWarning),
failedTransactionsCorrelations: failedTransactionsCorrelations.map(({ histogram, ...correlation }) => ({
fieldName: correlation.fieldName,
fieldValue: correlation.fieldValue,
correlation: correlation.normalizedScore,
})),
},
};
}
/**
* Elasticsearch fallback: significant_terms aggregation on traces*apm*,traces*otel* for attributes
* over-represented in high-latency or failed transactions vs all transactions.
*/
async function fetchEsCorrelations(type, serviceName, timeRange, args) {
const esUrl = process.env.ELASTICSEARCH_URL || process.env.ELASTICSEARCH_CLOUD_ID;
const apiKey = process.env.ELASTICSEARCH_API_KEY;
const username = process.env.ELASTICSEARCH_USERNAME;
const password = process.env.ELASTICSEARCH_PASSWORD;
if (!esUrl) {
return { success: false, error: "ELASTICSEARCH_URL or ELASTICSEARCH_CLOUD_ID required for fallback" };
}
const baseFilter = [
{ term: { "service.name": serviceName } },
{ range: { "@timestamp": { gte: timeRange.start, lte: timeRange.end } } },
];
if (args.transactionType) baseFilter.push({ term: { "transaction.type": args.transactionType } });
if (args.transactionName) baseFilter.push({ term: { "transaction.name": args.transactionName } });
const foregroundQuery =
type === "latency"
? {
bool: {
filter: [...baseFilter, { range: { "transaction.duration.us": { gte: 500000 } } }],
},
}
: {
bool: {
filter: [...baseFilter, { term: { "event.outcome": "failure" } }],
},
};
const correlationFields = [
"host.name",
"service.version",
"service.environment",
"container.id",
"kubernetes.pod.name",
"cloud.availability_zone",
"cloud.region",
];
const aggs = {};
for (const field of correlationFields) {
aggs[`correlations_${field.replace(/\./g, "_")}`] = {
significant_terms: {
field: `${field}.keyword`,
min_doc_count: 2,
background_filter: { bool: { filter: baseFilter } },
size: 10,
},
};
}
const body = {
size: 0,
query: foregroundQuery,
aggs,
};
const node = esUrl.startsWith("http") ? esUrl : `https://${esUrl}`;
const headers = { "Content-Type": "application/json", "User-Agent": "elastic-agentic" };
if (apiKey) headers["Authorization"] = `ApiKey ${apiKey}`;
else if (username && password) {
headers["Authorization"] = "Basic " + Buffer.from(`${username}:${password}`).toString("base64");
}
const res = await fetch(`${node}/traces*apm*,traces*otel*/_search`, {
method: "POST",
headers,
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text();
return { success: false, error: `Elasticsearch ${res.status}: ${text}` };
}
const data = await res.json();
const bucketsByField = {};
for (const [aggName, agg] of Object.entries(data.aggregations || {})) {
if (agg.buckets && agg.buckets.length) {
const field = aggName.replace("correlations_", "").replace(/_/g, ".");
bucketsByField[field] = agg.buckets.map((b) => ({
value: b.key,
score: b.score,
doc_count: b.doc_count,
bg_count: b.bg_count,
}));
}
}
return {
success: true,
source: "elasticsearch",
type,
serviceName,
timeRange: { start: timeRange.start, end: timeRange.end },
attributes: bucketsByField,
};
}
async function runLatencyCorrelations(args) {
const timeRange = resolveTimeRange(args);
let result = await fetchKibanaCorrelations("latency", args.serviceName, timeRange, args);
if (!result.success && (result.status === 404 || result.error?.includes("404"))) {
result = await fetchEsCorrelations("latency", args.serviceName, timeRange, args);
}
return result;
}
async function runFailedCorrelations(args) {
const timeRange = resolveTimeRange(args);
let result = await fetchKibanaCorrelations("failed", args.serviceName, timeRange, args);
if (!result.success && (result.status === 404 || result.error?.includes("404"))) {
result = await fetchEsCorrelations("failed", args.serviceName, timeRange, args);
}
return result;
}
function printResult(result, json) {
if (json) {
const out = result.success ? (result.data ?? result) : { success: false, error: result.error };
console.log(JSON.stringify(out, null, 2));
return;
}
if (!result.success) {
console.error("Error:", result.error);
if (result.details) console.error(result.details);
return;
}
const data = result.data ?? result;
if (data.attributes) {
console.log("Correlated attributes (over-represented in high-latency or failed transactions):\n");
for (const [field, buckets] of Object.entries(data.attributes)) {
if (buckets.length) {
console.log(` ${field}:`);
for (const b of buckets) {
console.log(` - ${b.value} (score: ${b.score?.toFixed(4) ?? "N/A"}, count: ${b.doc_count})`);
}
console.log("");
}
}
} else {
console.log(JSON.stringify(data, null, 2));
}
}
async function main() {
const cmd = process.argv[2];
const args = parseArgs(process.argv.slice(3));
if (cmd === "test") {
const r = await kibanaFetch("/api/status", { method: "GET", space: args.space });
if (r.success) {
console.log("Kibana:", r.data?.version?.number ?? "OK");
} else {
console.error("Kibana:", r.error);
}
return;
}
if (cmd !== "latency-correlations" && cmd !== "failed-correlations") {
console.error(`Usage: ${process.argv[1]} <latency-correlations|failed-correlations|test> [options]`);
console.error(" --service-name <name> Service name (required for correlations)");
console.error(" --start <iso> Start time");
console.error(" --end <iso> End time");
console.error(" --last-minutes <n> Last N minutes (default 60)");
console.error(" --transaction-type Optional transaction type");
console.error(" --transaction-name Optional transaction name");
console.error(" --environment Optional environment");
console.error(" --space <id> Kibana space");
console.error(" --json JSON output");
process.exit(1);
}
if (!args.serviceName) {
console.error("Error: --service-name is required");
process.exit(1);
}
let result;
if (cmd === "latency-correlations") {
result = await runLatencyCorrelations(args);
} else {
result = await runFailedCorrelations(args);
}
printResult(result, args.json);
if (!result.success) process.exit(1);
}
main().catch((err) => {
console.error(err.message);
process.exit(1);
});
/**
* Lightweight HTTP client for the Kibana REST API.
* Uses native fetch() with auth, retry on 429, and space support.
*/
try {
process.loadEnvFile();
} catch {}
const RETRY_DELAYS = [5, 10, 20];
export function getKibanaConfig() {
const url = process.env.KIBANA_URL;
const apiKey = process.env.KIBANA_API_KEY;
const username = process.env.KIBANA_USERNAME || process.env.ELASTICSEARCH_USERNAME;
const password = process.env.KIBANA_PASSWORD || process.env.ELASTICSEARCH_PASSWORD;
const spaceId = process.env.KIBANA_SPACE_ID;
const insecure = process.env.KIBANA_INSECURE === "true";
if (!url) {
console.error("Error: No Kibana connection configured.");
console.error("Set KIBANA_URL environment variable.");
process.exit(1);
}
if (!apiKey && !username && !password && process.env.KIBANA_NO_AUTH !== "true") {
console.error("Error: No Kibana authentication configured.");
console.error("Set KIBANA_API_KEY or KIBANA_USERNAME + KIBANA_PASSWORD.");
console.error("Or set KIBANA_NO_AUTH=true for clusters with security disabled.");
process.exit(1);
}
if (!apiKey && ((username && !password) || (!username && password))) {
console.error("Error: Both username and password must be set for basic auth.");
console.error("Set KIBANA_USERNAME + KIBANA_PASSWORD (or ELASTICSEARCH_USERNAME + ELASTICSEARCH_PASSWORD).");
process.exit(1);
}
return { url, apiKey, username, password, spaceId, insecure };
}
function getHeaders(config) {
const headers = {
"Content-Type": "application/json",
"kbn-xsrf": "true",
"User-Agent": "elastic-agentic",
};
if (config.apiKey) {
headers["Authorization"] = `ApiKey ${config.apiKey}`;
} else if (config.username && config.password) {
const auth = Buffer.from(`${config.username}:${config.password}`).toString("base64");
headers["Authorization"] = `Basic ${auth}`;
}
return headers;
}
function getBasePath(config, space) {
let basePath = config.url.replace(/\/$/, "");
const effectiveSpace = space || config.spaceId;
if (effectiveSpace && effectiveSpace !== "default") {
basePath += `/s/${effectiveSpace}`;
}
return basePath;
}
/**
* Make an HTTP request to the Kibana API with automatic 429 retry.
*
* @param {string} path - API path (e.g. "/api/cases")
* @param {object} [options] - fetch options (method, body, headers, params)
* @param {string} [options.space] - Override Kibana space for this request
* @returns {{ success: boolean, data?: any, status?: number, error?: string }}
*/
export async function kibanaFetch(path, options = {}) {
const config = getKibanaConfig();
const { space, params, ...fetchOpts } = options;
const basePath = getBasePath(config, space);
let url = `${basePath}${path}`;
if (params) {
const searchParams = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null) {
if (Array.isArray(value)) {
for (const v of value) searchParams.append(key, v);
} else {
searchParams.append(key, String(value));
}
}
}
const qs = searchParams.toString();
if (qs) url += `?${qs}`;
}
const requestOptions = {
...fetchOpts,
headers: {
...getHeaders(config),
...fetchOpts.headers,
},
};
if (config.insecure) {
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
}
for (let attempt = 0; attempt <= RETRY_DELAYS.length; attempt++) {
try {
const response = await fetch(url, requestOptions);
if (response.status === 429 && attempt < RETRY_DELAYS.length) {
const delay = RETRY_DELAYS[attempt];
console.error(`Rate limited, retrying in ${delay}s (attempt ${attempt + 1}/${RETRY_DELAYS.length + 1})...`);
await new Promise((r) => setTimeout(r, delay * 1000));
continue;
}
const contentType = response.headers.get("content-type");
let data;
if (contentType && contentType.includes("application/json")) {
data = await response.json();
} else {
data = await response.text();
}
if (!response.ok) {
return {
success: false,
status: response.status,
error: data?.message || data?.error || `HTTP ${response.status}`,
details: data,
};
}
return { success: true, data };
} catch (error) {
if (attempt < RETRY_DELAYS.length && error.message?.includes("429")) {
const delay = RETRY_DELAYS[attempt];
console.error(`Rate limited, retrying in ${delay}s...`);
await new Promise((r) => setTimeout(r, delay * 1000));
continue;
}
return { success: false, error: error.message, details: error };
}
}
}
/**
* Convenience wrappers matching the Python KibanaClient interface.
* These throw on HTTP errors (matching the old behavior where scripts
* relied on exceptions for error handling).
*/
export async function kibanaGet(path, params, space) {
const result = await kibanaFetch(path, { method: "GET", params, space });
if (!result.success) throw new Error(result.error || `HTTP ${result.status}`);
return result.data;
}
export async function kibanaPost(path, body, space) {
const result = await kibanaFetch(path, {
method: "POST",
body: body !== undefined ? JSON.stringify(body) : undefined,
space,
});
if (!result.success) throw new Error(result.error || `HTTP ${result.status}`);
return result.data;
}
export async function kibanaPatch(path, body, space) {
const result = await kibanaFetch(path, {
method: "PATCH",
body: body !== undefined ? JSON.stringify(body) : undefined,
space,
});
if (!result.success) throw new Error(result.error || `HTTP ${result.status}`);
return result.data;
}
export async function kibanaPut(path, body, space) {
const result = await kibanaFetch(path, {
method: "PUT",
body: body !== undefined ? JSON.stringify(body) : undefined,
space,
});
if (!result.success) throw new Error(result.error || `HTTP ${result.status}`);
return result.data;
}
export async function kibanaDelete(path, space) {
const result = await kibanaFetch(path, { method: "DELETE", space });
if (!result.success) throw new Error(result.error || `HTTP ${result.status}`);
return result.data;
}
export async function testConnection(space) {
try {
const status = await kibanaGet("/api/status", undefined, space);
const version = status?.version;
const versionStr = typeof version === "object" ? version?.number : version;
console.log(`Connected to Kibana: ${status?.name || "unknown"}`);
console.log(`Version: ${versionStr || "unknown"}`);
return true;
} catch (error) {
console.error(`Connection failed: ${error.message}`);
return false;
}
}