<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">
  <channel>
    <title><![CDATA[Jeffrey Rengifo - Elastic Observability Labs]]></title>
    <description><![CDATA[Trusted security news & research from the team at Elastic.]]></description>
    <copyright><![CDATA[© 2026. Elasticsearch B.V. All Rights Reserved]]></copyright>
    <image>
      <title><![CDATA[Jeffrey Rengifo - Elastic Observability Labs]]></title>
      <url>https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltad972c1c27dbefc6/6a88d9782904ea5e8511d473/observability-labs-thumbnail.png</url>
      <link>https://www.elastic.co/observability-labs/author/jeffrey-rengifo</link>
    </image>
    <link>https://www.elastic.co/observability-labs/author/jeffrey-rengifo</link>
    <atom:link href="https://www.elastic.co/observability-labs/rss/author/jeffrey-rengifo.xml" rel="self" type="application/rss+xml"/>
    <language><![CDATA[en]]></language>
    <lastBuildDate>Fri, 18 Sep 2026 11:22:17 GMT</lastBuildDate>
  <item>
    <title><![CDATA[AI root cause analysis in Elastic Agent Builder that cites its evidence]]></title>
    <description><![CDATA[The new release failed at 27.2%, the old one at 28.2%, so the deploy was never the cause; the agent worked that out in 72 seconds and handed back a trace ID for the failure that was.]]></description>
    <content:encoded><![CDATA[<p>Root cause analysis is the work of separating the failure that started an incident from everything that broke because of it, or merely alongside it. A cascading failure makes that hard: a deploy that shipped the same minute and a second service failing at the same time both look like causes.</p>
<p>Three ES|QL tools and about two dozen lines of query text turn AI root cause analysis into something you can check. This article builds them in <a href="https://www.elastic.co/docs/solutions/observability/ai/agent-builder-observability">Elastic Agent Builder</a> and wires them to an agent whose report names the tool behind every number, and a <code>trace.id</code> and document <code>_id</code> behind every root cause claim.</p>
<h2 id="whatyouneedtoreproducethisrootcauseanalysis">What you need to reproduce this root cause analysis</h2>
<ul>
<li>Elasticsearch and Kibana 9.5, self-managed or on Elastic Cloud.</li>
<li>A generative AI connector for Agent Builder. Ours is Anthropic Claude Sonnet 4.6.</li>
<li>Python 3.12 with <code>opentelemetry-distro[otlp]</code> and the Flask and requests instrumentations.</li>
<li>An Elasticsearch API key with write access for OTLP ingest and Kibana access for the Agent Builder APIs.</li>
</ul>
<p>Use the <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/observability-labs/ai-root-cause-analysis-agent-builder/evidence-first-rca-agent-builder.ipynb">companion notebook</a> if you want to reproduce the use case in this article.</p>
<h2 id="thedemoenvironmentfourpythonservicesonopentelemetry">The demo environment: four Python services on OpenTelemetry</h2>
<p>Four Python services run on one host. <code>checkout-api</code> handles the customer request and calls <code>pricing-api</code>, which calls <code>fx-rates</code> for a currency quote. A fourth service, <code>search-api</code>, serves product search and sits outside that call path.</p>
<p>Each service exports OTLP straight to the Elasticsearch native OTLP endpoint, with no collector in between. Logs land in <code>logs-generic.otel-default</code> and spans in <code>traces-generic.otel-default</code>. Two <code>checkout-api</code> processes run on different versions at once, which one of the tools below depends on.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blta41d352de970c26a/6aa13544e1500a5ccf018ff5/02-demo-environment.png" alt="checkout-api calls pricing-api, which calls fx-rates. search-api sits outside that path. All four export OTLP to logs-generic.otel-default and traces-generic.otel-default, which the three tools read with ES|QL" /></p>
<p>Starting a service is one command, with the OTLP endpoint and the service identity in the environment:</p>
<pre><code>export OTEL_EXPORTER_OTLP_ENDPOINT="${ES_URL}/_otlp"
export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=ApiKey ${ES_API_KEY}"
export OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED=true

OTEL_SERVICE_NAME=checkout-api \
OTEL_RESOURCE_ATTRIBUTES="service.version=2026.07.26.2,deployment.environment=production" \
  opentelemetry-instrument python services/checkout_api.py
</code></pre>
<h2 id="whytheloudestserviceisnottherootcause">Why the loudest service is not the root cause</h2>
<p>At 09:55:33Z, <code>fx-rates</code> starts refusing about a third of its requests because its cached FX snapshot is older than the <code>max_age</code> it enforces. <code>pricing-api</code> turns each refusal into a 500, and <code>checkout-api</code> returns a 500 to the customer.</p>
<p>Two unrelated things happen in the same window. Release 2026.07.26.2 of <code>checkout-api</code> rolls out at 09:55, the minute the errors start, and 120 milliseconds after <code>fx-rates</code> breaks, <code>search-api</code> starts timing out during a scheduled reindex of its catalog.</p>
<p>One failure propagates, and the two other signals share only the clock:</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd86d1d0df5c606bb/6aa1355b5f9db7554a56331f/03-cascade-vs-coincidence.png" alt="fx-rates returns a 503 at 09:55:33.991 that pricing-api turns into a 500 and checkout-api passes to the customer. The deploy at 09:55:00.000 and the search-api timeouts at 09:55:34.111 hang off that chain, sharing no traces with it" /></p>
<p>The on-call view is four unhealthy services and a fresh deploy. This query gives the failure rate per service over the window:</p>
<pre><code>FROM traces-generic.otel-default
| WHERE kind == "Server"
| EVAL failed = CASE(attributes.http.status_code &gt;= 500, 1, 0)
| STATS failures = SUM(failed), requests = COUNT(*)
    BY service = resource.attributes.service.name
| EVAL failure_rate_pct = ROUND(100.0 * failures / requests, 1)
| SORT failure_rate_pct DESC
</code></pre>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt271a014535cce4bb/6aa1356b5c31268ee14413e6/04-failure-rate-query.png" alt="The failure rate query in the ES|QL editor" /></p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltc67e72759b5b3863/6aa1357bde23953f28e86152/05-failure-rate-results.png" alt="Failure rate per service: search-api 36.4%, and 27.7% each for checkout-api, pricing-api and fx-rates" /></p>
<p>Nothing in the result ranks the candidates. <code>search-api</code> has the highest failure rate at 36.4%, and it is the one service with no involvement in the failing checkouts.</p>
<h2 id="automatedrootcauseanalysiswiththedefaultelasticaiagent">Automated root cause analysis with the default Elastic AI Agent</h2>
<p>Before writing any tools, we gave the incident to the default Elastic AI Agent to see how far the built-in observability skills get on their own:</p>
<pre><code>checkout-api started returning HTTP 500s to customers today. Investigate the
window 2026-07-26T09:50:00.000Z to 2026-07-26T10:02:00.000Z and tell me the root
cause. Context you have from the deploy log: checkout-api release 2026.07.26.2
rolled out at 09:55 UTC, and the on-call channel also reported search-api
timeouts starting at 09:55 UTC.
</code></pre>
<p>It got the cascade right. It named <code>fx-rates</code> as the origin, quoted the stale-snapshot message, and separated the <code>search-api</code> timeouts as a different problem. Then it ranked its hypotheses:</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd06ff05a0e9fa650/6aa1358e346a4b0d74409087/06-default-agent-hypotheses.png" alt="The default agent's ranked hypotheses, with the deploy second, and its impact summary reporting ~38% over 5,331 requests" /></p>
<p>Hypothesis 2 claims the release restarted <code>fx-rates</code> without refreshing its snapshot, or lowered its <code>max_age</code>. Neither appears in the telemetry: the release was on <code>checkout-api</code>, and <code>fx-rates</code> reports <code>service.version</code> 2026.07.19.1 across all 7,374 of its spans.</p>
<p>The agent had a timestamp: the deploy and the first error share a minute. Across three runs, it connected them with a different invented mechanism each time:</p>
<ul>
<li>a restart that dropped the snapshot</li>
<li>a new call path that was not exercised before the release</li>
<li>a previously tolerant code path that stopped tolerating stale data</li>
</ul>
<p>The numbers have the same problem. The impact summary in that same answer reports the failure rate as "~38%", dividing the 2,043 failures by the 5,331 successes instead of the 7,374 requests, with no query next to the number for a reader to check.</p>
<p>The rest of this article closes that gap with tools.</p>
<h2 id="the16builtinobservabilitytoolsinagentbuilder">The 16 built-in observability tools in Agent Builder</h2>
<p>Agent Builder ships with tools that cover most of the exploration work, so check the <a href="https://www.elastic.co/docs/explore-analyze/ai-features/agent-builder/tools/builtin-tools-reference">built-in tools reference</a> before writing anything. The catalog is under <strong>Agent Builder &gt; Manage components &gt; Tools</strong>:</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt941df10ff2d942b0/6aa1359e80575524cc70670a/07-tools-catalog.png" alt="The Agent Builder tools catalog" /></p>
<p>Sixteen of the built-in tools are observability tools, organized around investigation steps instead of index operations:</p>
<p>| Tool | What it answers |
| :---- | :---- |
| <code>observability.get_logs</code> | What is the log volume and shape for this filter, with samples and message categories |
| <code>observability.get_traces</code> | What documents belong to these traces, grouped by <code>trace.id</code> |
| <code>observability.get_service_topology</code> | Which dependencies does this service have, with error rate per connection |
| <code>observability.get_apm_correlations</code> | Which attributes are over-represented in the slow or failing transactions |
| <code>observability.run_log_rate_analysis</code> | Which fields or patterns correlate with a change in log throughput |
| <code>observability.get_log_change_points</code> | Which message categories spiked, dipped or shifted, and when |</p>
<p>One platform tool matters here too. <code>platform.streams.investigation_progress_report</code> is what an agent calls to publish its hypothesis list while it works, with a status per hypothesis, a conclusion, and an explicit list of what the data could not settle:</p>
<pre><code>{
  "summary": "string",
  "hypotheses": [
    {
      "candidate": "string",
      "confidence": 0.0,
      "status": "investigating | dismissed | confirmed",
      "reason": "string"
    }
  ],
  "conclusion": "string",
  "gaps_found": ["string"]
}
</code></pre>
<h2 id="threeesqltoolsforairootcauseanalysis">Three ES|QL tools for AI root cause analysis</h2>
<p>Agent Builder's built-in observability tools are shaped for showing the landscape. Ruling a candidate out needs one number that answers one question, and two questions in this incident have no built-in tool. A third returns the document IDs that make a claim checkable.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt95268108854607c7/6aa135b032b5309b366d32f9/08-rca-tools.png" alt="The three registered rca tools in the Agent Builder tools library" /></p>
<h3 id="howtotelloneincidentfromtwocoincidentfailuresrca_failure_shapes">How to tell one incident from two coincident failures (rca_failure_shapes)</h3>
<p>Two services failing in the same minute belong to the same incident only if they appear in the same requests. This tool groups error logs by trace, collapses each trace to the set of services that erred inside it, then counts each shape:</p>
<pre><code>FROM logs-generic.otel-default
| WHERE @timestamp &gt;= TO_DATETIME(?start) AND @timestamp &lt;= TO_DATETIME(?end)
  AND severity_text == "ERROR" AND trace_id IS NOT NULL
| STATS services = VALUES(resource.attributes.service.name) BY trace_id
| EVAL failure_shape = MV_CONCAT(MV_SORT(services), " + ")
| STATS traces = COUNT(*) BY failure_shape
| SORT traces DESC
| LIMIT 20
</code></pre>
<p><code>MV_CONCAT(MV_SORT(...))</code> matters here. Grouping directly by a multivalue field makes ES|QL expand it, one row per service, which loses the combination. Collapsing the set to a single string first keeps the shape intact.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt02d3435111f1cc61/6aa135c5ee57e5b4c90525b1/09-failure-shapes.png" alt="Two failure shapes with no overlap: 2,043 traces for the three checkout services and 1,324 for search-api alone" /></p>
<p>Two clusters come back, and they share no traces. The three checkout services appear together in 2,043 traces, <code>search-api</code> errs alone in 1,324, and that zero overlap settles the coincidence in one row.</p>
<h3 id="howtoruleoutadeployastherootcauserca_version_split">How to rule out a deploy as the root cause (rca_version_split)</h3>
<p>A release that caused the failures makes the version carrying it fail at a materially higher rate than the version it replaced. Server spans carry both <code>service.version</code> and the response status, so one query decides it:</p>
<pre><code>FROM traces-generic.otel-default
| WHERE @timestamp &gt;= TO_DATETIME(?start) AND @timestamp &lt;= TO_DATETIME(?end)
  AND resource.attributes.service.name == ?service AND kind == "Server"
| EVAL failed = CASE(attributes.http.status_code &gt;= 500, 1, 0)
| STATS failures = SUM(failed), requests = COUNT(*)
    BY version = resource.attributes.service.version
| EVAL failure_rate_pct = ROUND(100.0 * failures / requests, 1)
| SORT version
</code></pre>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt0297cfe3b7a1122a/6aa135d55c3126a1c04413ea/10-version-split.png" alt="The two checkout-api versions fail at 27.2% and 28.2% on roughly 3,700 requests each" /></p>
<p>27.2% against 28.2%, on roughly 3,700 requests per version, is a difference within noise. The version that was already running fails as often as the one that shipped, which rules the release out.</p>
<p>The tool depends on both versions serving at once. With an instantaneous and total rollout, no query separates a broken new version from something else breaking at the same moment.</p>
<h3 id="howtoreturndocumentidsanagentcanciterca_evidence_sample">How to return document IDs an agent can cite (rca_evidence_sample)</h3>
<p><code>METADATA _id</code> on an ES|QL source command returns the Elasticsearch document ID, so a claim can point at a specific record:</p>
<pre><code>FROM logs-generic.otel-default METADATA _id, _index
| WHERE @timestamp &gt;= TO_DATETIME(?start) AND @timestamp &lt;= TO_DATETIME(?end)
  AND severity_text == "ERROR" AND resource.attributes.service.name == ?service
| KEEP @timestamp, _id, _index, trace_id,
       attributes.error.kind, attributes.upstream.service, body.text
| SORT @timestamp DESC
| LIMIT 5
</code></pre>
<p>Each tool is registered with one <code>POST kbn:/api/agent_builder/tools</code> call carrying the query and its typed parameters. Registered, <code>rca_failure_shapes</code> looks like this; the agent fills <code>start</code> and <code>end</code> at call time:</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltea8e8815b60d1329/6aa135e33eabd0004c442e8c/11-registered-tool.png" alt="The registered rca_failure_shapes tool, with its query and its two typed parameters" /></p>
<h2 id="writethetooldescriptionasthedecisionitsupports">Write the tool description as the decision it supports</h2>
<p>The model reads the tool description to decide when to call the tool, so the description does more work than the name.</p>
<p>We describe <code>rca_failure_shapes</code> as the way to test whether two services that broke together are one failure or two. That phrasing got it called whenever a question mentioned a second failing service.</p>
<p>The description also outweighs the agent instructions. An agent with these three tools and one line of instruction, "You are an SRE assistant, help the user find the root cause of production incidents", still ruled out the deploy and the coincidence every time.</p>
<h2 id="wiringthetoolsintoanincidentrootcauseanalysisagent">Wiring the tools into an incident root cause analysis agent</h2>
<p>The agent gets the three custom tools, four built-in observability tools, and <code>platform.streams.investigation_progress_report</code>. Its instructions are five numbered steps:</p>
<pre><code>1. Scope. Establish the affected service, the failure window, and the size of the
   symptom before you name any cause. State the window as an explicit ISO 8601
   range and reuse that same range in every tool call.

2. Enumerate. Write down at least three candidate causes before you test any of
   them. Include the candidate a human on call would reach for first, such as a
   recent deploy or another service that started failing at the same minute.

3. Refute. For each candidate, state the observation that would prove it wrong,
   then run the query that produces that observation. A candidate is dismissed
   when the refuting observation is present, not when a different candidate
   looks better.

4. Cite. Every number you report must name the tool that returned it. Every claim
   about a root cause must carry at least one trace_id and at least one document
   _id. A claim with no citation is not a finding, it is a guess.

5. Report. Put anything the available data cannot settle in gaps_found rather
   than resolving it with reasoning.
</code></pre>
<p>Two rules follow the steps. Each one blocks a mistake we saw in the default agent's answers:</p>
<ul>
<li>Do not rank services by error volume and call the loudest one the cause.</li>
<li>Do not treat time correlation as causation. Two services that start failing in the same minute belong to the same incident only if they appear in the same traces.</li>
</ul>
<p>The finished agent, with Elastic capabilities switched off so everything it produces comes from these eight tools:</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt77e4032c3e72acd0/6aa135f15f9db7ba68563323/12-agent.png" alt="The agent in Agent Builder with its eight tools" /></p>
<h2 id="runningtheinvestigation11toolcalls72seconds">Running the investigation: 11 tool calls, 72 seconds</h2>
<p>We gave the new agent the same question, word for word. It opened with a progress report listing its candidates, then ran the refuting queries in parallel batches:</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt65b8f00289526519/6aa135fe32b53003116d32fd/13-progress-report.png" alt="The agent's progress report and its parallel tool calls" /></p>
<p>Eleven tool calls and 72 seconds later, the report opens with a citation table:</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt7c0281d13b148e27/6aa1362832b5305c286d3301/14-citation-table.png" alt="The report's citation table, with three services, three messages, three document IDs and one shared trace ID" /></p>
<p>Three layers, three verbatim messages, three document IDs, and one trace ID shared by all of them. The origin message states the mechanism: <code>StaleQuoteError: fx snapshot age 1215s exceeds max_age 900s (provider=ecb-eod)</code>.</p>
<p>The dismissals come next, each with the observation that ruled it out:</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt25e57cb0f0d9b5d1/6aa1363a3481c252e28b0a1b/15-dismissals.png" alt="The dismissed hypotheses, each with its refuting observation" /></p>
<p>| Candidate | Refuting observation | Tool |
| :---- | :---- | :---- |
| <code>checkout-api</code> release 2026.07.26.2 | Both versions fail at the same rate, 27.2% against 28.2%, on roughly 3,700 requests each | <code>rca_version_split</code> |
| <code>search-api</code> timeouts | Zero shared traces. 2,043 traces contain the three checkout services, 1,324 contain <code>search-api</code> alone | <code>rca_failure_shapes</code> |
| Database failure | No database errors in the <code>checkout-api</code> logs | <code>rca_evidence_sample</code> |</p>
<p>The report closes with a gaps section:</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltaf924586ef3174a4/6aa1364c346a4b5cba40908f/16-gaps.png" alt="The gaps_found section of the report" /></p>
<p>The first gap covers the question the default agent answered with an invented mechanism. The snapshot was already 1,127 seconds old at the first error, so the feed stopped refreshing before the window opened, and no log in this system records when. The next place to look is the feed ingestion job.</p>
<h2 id="verifyingtheagentscitationbyhandinesql">Verifying the agent's citation by hand in ES|QL</h2>
<p>The report gives a trace ID, so open it:</p>
<pre><code>FROM logs-generic.otel-default
| WHERE trace_id == "a131d0cf7343195c0a7a14f6a98da6b7"
| KEEP @timestamp, resource.attributes.service.name,
       attributes.upstream.service, body.text
| SORT @timestamp ASC
</code></pre>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd4f02f8e160e9420/6aa1365cf08ee1abfc855b36/17-trace-query.png" alt="The trace query in the ES|QL editor" /></p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt725ef26468021fc2/6aa13676508cca15a8a169a3/18-trace-results.png" alt="The three error records of that trace, fx-rates with a null upstream.service, then pricing-api naming fx-rates, then checkout-api naming pricing-api" /></p>
<p>Three records, one millisecond apart, in the order the report claimed. <code>fx-rates</code> errs first and names no upstream, because it is the origin. <code>pricing-api</code> names <code>fx-rates</code>, and <code>checkout-api</code> names <code>pricing-api</code>.</p>
<h2 id="addupstreamservicetoyourerrorlogs">Add upstream.service to your error logs</h2>
<p><code>upstream.service</code> is a logging convention that OpenTelemetry does not provide: each service writes it on the error it emits when a dependency fails. With that one key, error records order themselves into a call chain, and the ordering survives the clock skew and sub-millisecond hops that break timestamp sorting.</p>
<p>Two other fields carry the rest of the weight: <code>trace.id</code> arrives free from auto-instrumentation as long as the log is emitted inside an active span, and a structured <code>error.kind</code> lets you count failure modes without matching on message text.</p>
<h2 id="extractingknowledgeindicatorsfromyourstreams">Extracting knowledge indicators from your streams</h2>
<p>Knowledge indicators are facts Elastic extracts from your raw data with LLM models. It picks out things like the underlying infrastructure and the dependencies between services.</p>
<p>You find this option under <code>Streams &gt; {your_stream} &gt; Significant events</code>:</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt5f3923d8527d0113/6aa13518a1633692653742c7/19-significant-events.png" alt="Significant events in the stream view" /></p>
<p>Click <strong>Generate</strong> and the indicators are created for you:</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltfea38d81276e8307/6aa1352b8057551fde706706/20-knowledge-indicators.png" alt="The generated knowledge indicators" /></p>
<h2 id="whatthreeesqltoolschangedabouttheagentsanswer">What three ES|QL tools changed about the agent's answer</h2>
<p>Three ES|QL tools and about two dozen lines of query text turned an agent's answer into a report that names its candidates, shows the observation that ruled each one out, and carries a record ID behind every number.</p>
<h2 id="nextsteps">Next steps</h2>
<ul>
<li>Run the <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/observability-labs/ai-root-cause-analysis-agent-builder/evidence-first-rca-agent-builder.ipynb">companion notebook</a> to reproduce the incident, the tools, and the agent in your own cluster.</li>
<li>Write the query that rules out your team's most common wrong answer, the recent deploy or the loudest service, and register it as a tool whose description states the decision it supports.</li>
<li>Audit one service's error logs for <code>trace.id</code>, a structured <code>error.kind</code>, and a declared <code>upstream.service</code>. The last two are a one-line change in your logging.</li>
<li>Register the failure modes your team already knows about as significant events, so the next investigation starts with a history instead of a blank window.</li>
<li>For a different shape of Agent Builder investigation, read <a href="https://www.elastic.co/observability-labs/blog/apm-health-check-elastic-agent-builder">From five dashboards to one prompt</a>, which scores APM service health with five ES|QL tools.</li>
</ul>]]></content:encoded>
    <link>https://www.elastic.co/observability-labs/blog/ai-root-cause-analysis-agent-builder</link>
    <guid isPermaLink="false">ai-root-cause-analysis-agent-builder</guid>
    <category><![CDATA[Agentic AI]]></category>
    <category><![CDATA[Logs Analytics]]></category>
    <category><![CDATA[OpenTelemetry]]></category>
    <dc:creator><![CDATA[Jeffrey Rengifo]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blte526f338f22edd08/6aa25191346a4ba897409639/01-header.png" length="0" type="image/png"/>
    <pubDate>Wed, 09 Sep 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Your AI agent needs an alibi: Observability and audit trails for Agent Builder in Elastic]]></title>
    <description><![CDATA[Elastic 9.5 traces every Agent Builder run as OpenTelemetry spans in your own cluster, so tool calls and token counts are queryable with ES|QL. One workflow step adds the approval record, in a data stream the pipeline cannot rewrite.]]></description>
    <content:encoded><![CDATA[<p>One question to an Elastic Agent Builder agent produced 24 spans, 10 model calls across two models, and roughly 160,000 input tokens. Elastic 9.5 records AI agent observability data without a collector or a scraper. Every run lands as OpenTelemetry traces in your own cluster, on by default, writing to <code>traces-agent_builder.otel-&lt;space-id&gt;</code> down to each ES|QL query the agent generated and each index it looked up.</p>
<p>Those traces show how the agent reached its recommendation and what it cost. They do not record who approved it. Below: how to read the traces, scope the three identities a run touches, and append the approval decision to a data stream the pipeline cannot rewrite.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt8b462c91ad11f060/6a95560048c299343ec688fd/02-architecture.png" alt="Agent Builder investigates, a workflow gate takes the human approval, and each stage writes to a different Elasticsearch data stream" /></p>
<h2 id="prerequisites">Prerequisites</h2>
<ul>
<li>Elastic Stack 9.5 or Serverless  </li>
<li>Privileges to manage Kibana advanced settings, needed to install the traces dashboard.</li>
</ul>
<h2 id="whereelasticobservabilityrecordseachpartofanaiagentaction">Where Elastic Observability records each part of an AI agent action</h2>
<p>Four questions come up in every review of an agentic operations pipeline, and each one is answered by a different record.</p>
<p>| Question | Where the answer lives | Who creates it |
| :---- | :---- | :---- |
| How did the agent reach its recommendation? | <code>traces-agent_builder.otel-*</code> spans | Agent Builder, automatically |
| Which tools did it call, and did they fail? | <code>execute_tool</code> spans in the same data stream | Agent Builder, automatically |
| Whose privileges did the run execute with? | Workflow execution record and Elasticsearch security audit logs | Kibana, partly |
| What did a human decide, and did the action run? | An index you write to yourself | You |</p>
<p>The first two are new in 9.5 and cost nothing but a toggle. The last one has no automatic source, so it is the one most pipelines are missing.</p>
<h2 id="thescenarioastalepricingcacheincheckout">The scenario: a stale pricing cache in checkout</h2>
<p>Three <code>checkout-service</code> workers serve production traffic. One of them, <code>checkout-worker-1</code>, was rolled to version <code>2026.07.26.1</code> and now returns HTTP 500 on every quote because its pricing cache stopped refreshing. The other two stay on <code>2026.07.25.3</code> and serve normally.</p>
<p>The <a href="https://www.elastic.co/observability-labs/blog/sre-control-plane-agent-builder-workflows">SRE control plane</a> pattern behind this setup connects telemetry, an Agent Builder agent that reasons over it, and Elastic Workflows that run known actions, with a <a href="https://www.elastic.co/observability-labs/blog/human-approval-sre-automation-elastic-workflows">human approval gate</a> before the first step that changes production.</p>
<p>Telemetry arrives through the documented OpenTelemetry path, so the agent reads standard OTel fields. Elasticsearch 9.5 exposes a native OTLP endpoint, which lets an OTel SDK write directly to the cluster with no collector in between:</p>
<pre><code>from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter
from opentelemetry.sdk._logs import LoggerProvider
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
from opentelemetry.sdk.resources import Resource

provider = LoggerProvider(
    resource=Resource.create({
        "service.name": "checkout-service",
        "deployment.environment": "production",
    })
)
provider.add_log_record_processor(
    BatchLogRecordProcessor(
        OTLPLogExporter(
            endpoint=f"{ES_URL}/_otlp/v1/logs",
            headers={"Authorization": f"ApiKey {API_KEY}"},
        )
    )
)
</code></pre>
<p>The endpoint speaks OTLP over protobuf and rejects <code>application/json</code> with HTTP 406, so send it through an SDK or collector rather than hand-built JSON. That writes 450 records to <code>logs-generic.otel-default</code>: 360 healthy events across the three workers and 90 <code>PricingCacheStaleError</code> events from the broken one. The agent is given none of that context and has to find it by querying.</p>
<h2 id="readingaiagentobservabilitytracesinelastic">Reading AI agent observability traces in Elastic</h2>
<p>Everything Agent Builder records about a run lives in two data streams, and all of it is queryable with ES|QL.</p>
<h3 id="howtoturnonaiagenttracingingenaisettings">How to turn on AI agent tracing in GenAI Settings</h3>
<p>Open <strong>Stack Management</strong>, then <strong>GenAI Settings</strong>, and find the <strong>Agent Builder Traces</strong> section. <strong>Collect conversation traces</strong> is on by default in 9.5.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt907aa26526dec518/6a9555e8893681fd923dd487/03-genai-settings.png" alt="Agent Builder Traces section in GenAI Settings with Collect conversa" /></p>
<p>Two details on that panel matter more than the toggle. Traces are written to <code>traces-agent_builder.otel-&lt;space-id&gt;</code>, one data stream per Kibana space, with a companion <code>logs-agent_builder.otel-&lt;space-id&gt;</code> for agent-side events. These are ordinary data streams on the standard OTel index templates, not hidden system indices, so Discover, Lens, and ES|QL query them directly.</p>
<p>The callout states the access model plainly: anyone who can read the index can read every trace in it. Trace access is not scoped per user, so restrict the index pattern through a role before granting access to a space with sensitive conversations.</p>
<h3 id="readingthellmtracewaterfallforoneagentrun">Reading the LLM trace waterfall for one agent run</h3>
<p>A single question through the <a href="https://www.elastic.co/docs/explore-analyze/ai-features/agent-builder/kibana-api">converse API</a> asks the built-in Elastic AI Agent to investigate <code>logs-generic.otel-default</code>, find which pod and version are returning HTTP 500, and propose one bounded action. It answers correctly, naming <code>checkout-worker-1</code> on <code>2026.07.26.1</code> with a 43% error rate against zero errors on the two workers still on <code>2026.07.25.3</code>.</p>
<p>Select the trace icon under any agent response to open the waterfall.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt0a0275868ed6d61a/6a95566e9b940a2b62cb3c62/04-trace-waterfall.png" alt="Trace waterfall for one agent run showing 24 spans across invoke_agent, chat, and execute_tool" /></p>
<p>One question through the converse API produced 24 spans over 43.7 seconds. The structure is an <code>invoke_agent</code> root, a <code>generate_title</code> side branch, then alternating <code>chat</code> and <code>execute_tool</code> spans as the agent queries, reads the result, and picks the next query.</p>
<p>Three span families carry everything you will aggregate on.</p>
<p>| Span name prefix | What it represents | Key attributes |
| :---- | :---- | :---- |
| <code>invoke_agent</code> | A conversation round (<code>CHAIN</code>) or an agent execution (<code>AGENT</code>) | <code>elastic.inference.span.kind</code>, <code>gen_ai.agent.id</code> |
| <code>chat</code> | One model call | <code>gen_ai.request.model</code>, <code>gen_ai.provider.name</code>, <code>gen_ai.usage.input_tokens</code>, <code>gen_ai.usage.output_tokens</code> |
| <code>execute_tool</code> | One tool invocation | <code>gen_ai.tool.name</code>, <code>gen_ai.tool.call.id</code>, <code>status.code</code> |</p>
<p>The token breakdown for that run:</p>
<p>| Model | Calls | Input tokens | Output tokens |
| :---- | ----: | ----: | ----: |
| <code>anthropic-claude-4.6-sonnet</code> | 5 | 113,315 | 2,039 |
| <code>anthropic-claude-4.5-haiku</code> | 5 | 47,211 | 651 |</p>
<p>Half the model calls went to the smaller model. That is <a href="https://www.elastic.co/docs/explore-analyze/ai-features/agent-builder/models">fast model routing</a>, which sends low-effort steps to a cheaper model, and the split is only visible in the trace.</p>
<p>The single agent investigation consumed roughly 160,000 input tokens in total. Each round replays the accumulated context, so cost scales with conversation length rather than with the length of the question.</p>
<h3 id="howdoyouqueryagenttoolcallswithesql">How do you query agent tool calls with ES|QL?</h3>
<p>Tool invocations are the part of agent behavior most worth watching, because that is where the agent touches your data. Every call is one <code>execute_tool</code> span, and the documented query aggregates them directly:</p>
<pre><code>FROM traces-agent_builder.otel-*
| WHERE span.name LIKE "execute_tool *"
| STATS calls = COUNT(*),
        errors = COUNT(*) WHERE status.code == "Error",
        avg_ms = ROUND(AVG(duration) / 1000000.0, 1)
  BY tool = attributes.gen_ai.tool.name
| SORT calls DESC
</code></pre>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blte3954942227269d2/6a955510d04dac38a16c9c15/05-tool-calls-esql.png" alt="ES|QL aggregation of execute_tool spans grouped by tool name" /></p>
<p>Across that run the agent leaned on <code>platform.core.execute_esql</code>, with <code>platform.core.generate_esql</code> and <code>load_skill</code> behind it at two calls each. <code>duration</code> is in nanoseconds on the root of the document, which is why the query divides by a million for milliseconds.</p>
<p>Check the companion logs data stream as well. During a different run in the same session, the agent tried to call a tool that was not in its available set, and the attempt was recorded as an exception event in <code>logs-agent_builder.otel-default</code>, correlated to the trace by <code>trace_id</code>:</p>
<pre><code>{
  "trace_id": "3f8b9722dbd371ac4b7ad75e4bed13b6",
  "event_name": "exception",
  "attributes": {
    "exception.type": "toolNotFoundError",
    "exception.message": "Tool \"platform.streams.query_documents\" called but was not available"
  }
}
</code></pre>
<p>A blocked tool attempt is audit-relevant, and it does not appear in the trace waterfall. Querying only the traces data stream will miss it.</p>
<p>For aggregate views there is a managed dashboard, installed per space from the same settings panel.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt84e139b24e474a43/6a9556211ade64a39142b72a/06-traces-dashboard.png" alt="Agent Builder traces overview dashboard showing token usage, conversation latency, and tool call frequency" /></p>
<p>Over a fifteen-minute window covering these runs, it reported 903,914 input tokens, 11,734 output tokens, and 44 LLM requests, with 35 tool spans at a 100% success rate and 0.42 seconds average duration. The dashboard is managed and read-only, so duplicate it to change a panel, and Elastic can still ship improvements to the original.</p>
<h3 id="whatopentelemetryllmtracesdonotcapturebydefault">What OpenTelemetry LLM traces do not capture by default</h3>
<p>By default, a trace records structure and cost, not content. Six toggles under <strong>Advanced privacy settings</strong> control prompts, responses, tool call details, system prompts, real tool and agent names, and real conversation and workflow IDs, and all six are off.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt6c873c449fc27e07/6a9554a860f7958318d76b3c/07-privacy-settings.png" alt="Advanced privacy settings with six toggles, all off by default" /></p>
<p>By default, a trace records structure and cost, not content. Select any span in the waterfall and the detail panel says so directly: "No input/output data available for this span."</p>
<p>Identifiers are hashed rather than dropped. The run with the blocked tool call returned conversation <code>be30fb53-d351-4fa1-b5e1-a569816f85d9</code> from the API, but its spans carry <code>gen_ai.conversation.id: b1141340d0851a46</code>. That lets you group every span belonging to one conversation and compare conversations against each other, without exposing an identifier that ties back to a user's session.</p>
<p>The consequence is that you cannot join traces to conversations on the conversation ID unless you enable real IDs, and you rarely need to. The converse API hands you the correlation key directly:</p>
<pre><code>{
  "conversation_id": "be30fb53-d351-4fa1-b5e1-a569816f85d9",
  "trace_id": "3f8b9722dbd371ac4b7ad75e4bed13b6",
  "model_usage": {
    "llm_calls": 22,
    "input_tokens": 518251,
    "output_tokens": 6423,
    "model": "anthropic-claude-4.6-sonnet"
  }
}
</code></pre>
<p>Store that <code>trace_id</code> in your own decision record, and the join works without weakening the privacy defaults. Enable real IDs only when exact response-to-decision attribution is required, and restrict the trace index in the same change.</p>
<h2 id="auditingaiagentactionsbeyondthetrace">Auditing AI agent actions beyond the trace</h2>
<p>The traces stop at what the agent did, so the records that show who authorised it have to come from somewhere else.</p>
<h3 id="whoseprivilegesdoesanaiagentactionrunwith">Whose privileges does an AI agent action run with?</h3>
<p>Three identities are involved in an agentic pipeline, and each has its own boundary.</p>
<p>| Identity | Runs with | Determined by | How to scope |
| :---- | :---- | :---- | :---- |
| Agent Builder tools | The privileges of whoever is chatting | The current user, so two people can get different data from the same question | Roles, as described in <a href="https://www.elastic.co/docs/explore-analyze/ai-features/agent-builder/permissions">Agent Builder permissions</a> |
| Workflow steps | One stored API key shared by every <code>elasticsearch.*</code> and <code>kibana.*</code> step | The trigger: manual runs use the person who started them, scheduled runs use whoever last saved the workflow | <a href="https://www.elastic.co/docs/explore-analyze/workflows/authorization">Workflow authorization</a> |
| Trace readers | Index-level access, all or nothing | A role grant on <code>traces-agent_builder.otel-*</code> | A role boundary on the trace index pattern |</p>
<p>One consequence of the stored key belongs in any review. Deactivating a user or changing their role does not refresh it, and the workflow keeps running with the privileges it captured until someone saves it again or toggles <strong>Enabled</strong> off and back on. Revoking an engineer's access does not, by itself, stop workflows that still run as them.</p>
<p>Scope the investigation role to reads only:</p>
<pre><code>POST /_security/role/agent-builder-observability-investigator
{
  "cluster": ["monitor_inference"],
  "indices": [
    {
      "names": ["logs-*", "metrics-*", "traces-*"],
      "privileges": ["read", "view_index_metadata"]
    }
  ]
}
</code></pre>
<p>Reading the agent's own traces is a separate grant, needing <code>read</code> and <code>view_index_metadata</code> on <code>traces-agent_builder.otel-*</code>. Keep the two roles apart, because the people who investigate incidents and the people who audit the agent are not always the same people.</p>
<h3 id="recordingwhoapprovedanagentactioninelasticsearch">Recording who approved an agent action in Elasticsearch</h3>
<p>Run the workflow, and it stops at the approval gate, where the reviewer sees the agent's structured output rendered into the request rather than a bare confirmation prompt.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt38b441b0cded6d2c/6a9554bc0897901dfdefb3ee/08-approval-gate.png" alt="Workflow approval gate showing the agent structured output and the decision input form" /></p>
<p>The execution record is detailed. It captures <code>resumedAt</code>, <code>resumedBy</code>, the full <code>resumeInput</code> payload, per-step token usage, and a deep link back to itself.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltc05efc447ca49698/6a9554d2e657a3f7b475b008/09-execution-record.png" alt="Workflow execution record showing resumedBy, resumeInput, and per-step token usage" /></p>
<p>That execution history is an operational view, not an audit store. The underlying <code>.workflows-events</code> data stream is reserved for system operations and rejects user queries outright, so you cannot run ES|QL across a quarter of decisions, and execution history is subject to retention rather than to your compliance policy.</p>
<p>Write the decision to an index you control:</p>
<pre><code>- name: review
  type: waitForInput
  with:
    message: |
      ## Approve the proposed checkout remediation?

      The evidence query matched {{ steps.collect_evidence.output.hits.total.value }} error events in the last hour.

      Agent classification: {{ steps.investigate.output.structured_output.incident_class }}
      Affected pod: {{ steps.investigate.output.structured_output.affected_pod }}
      Proposed action: {{ steps.investigate.output.structured_output.recommended_action }}
    schema:
      type: object
      properties:
        decision:
          type: string
          enum: ["approve", "decline"]
        reason:
          type: string
          enum: ["supported-by-evidence", "insufficient-evidence", "wrong-target", "unsafe-action"]
        notes:
          type: string
      required: ["decision", "reason"]

- name: record_decision
  type: elasticsearch.index
  with:
    index: "agent-action-audit"
    document:
      "@timestamp": "{{ now | date: '%Y-%m-%dT%H:%M:%S.%LZ' }}"
      "event.action": "agent_recommendation_reviewed"
      "incident.id": "{{ consts.incident_id }}"
      "agent.conversation_id": "{{ steps.investigate.output.conversation_id }}"
      "agent.incident_class": "{{ steps.investigate.output.structured_output.incident_class }}"
      "agent.affected_pod": "{{ steps.investigate.output.structured_output.affected_pod }}"
      "agent.recommended_action": "{{ steps.investigate.output.structured_output.recommended_action }}"
      "agent.evidence_count": "{{ steps.collect_evidence.output.hits.total.value }}"
      "review.decision": "{{ steps.review.output.response.decision }}"
      "review.reason": "{{ steps.review.output.response.reason }}"
      "review.notes": "{{ steps.review.output.response.notes }}"
      "review.responded_by": "{{ steps.review.output.respondedBy }}"
      "workflow.execution_id": "{{ execution.id }}"
      "workflow.executed_by": "{{ execution.executedBy }}"
      "workflow.execution_url": "{{ execution.url }}"
</code></pre>
<p>Two details in the workflow snippet above differ from the reference page.</p>
<p>The reviewer payload is nested one level deeper. The docs describe <code>steps.&lt;name&gt;.output.&lt;field&gt;</code>, but the running build returns the submitted values under <code>response</code>, alongside a <code>respondedBy</code> field:</p>
<pre><code>{
  "response": { "decision": "approve", "reason": "supported-by-evidence" },
  "respondedBy": "1506416774"
}
</code></pre>
<p><code>execution.executedBy</code> records who started the run, and <code>respondedBy</code> records who approved the action, which in a human-in-the-loop pipeline are usually different people.</p>
<p>The second detail is the timestamp. <code>{{ now }}</code> renders a JavaScript date string like <code>Sun Jul 26 2026 07:37:11 GMT+0000 (Coordinated Universal Time)</code>, which Elasticsearch rejects with <code>failed to parse date field</code>, and <code>execution.startedAt</code> has the same problem. The Liquid <code>date</code> filter fixes it.</p>
<p>The workflow editor also flags <code>steps.review.output.*</code> as an invalid variable before the first run, because the reviewer payload shape is only known once someone responds. The warning clears after the step has real output, and the templates resolve correctly at runtime.</p>
<h3 id="makingtheauditdatastreamappendonly">Making the audit data stream append-only</h3>
<p>An audit trail the agent's own pipeline can rewrite is not an audit trail. Elasticsearch provides two independent controls, and they compose.</p>
<p>First, write to a data stream rather than an index, because data streams accept appends and nothing else:</p>
<pre><code>PUT _index_template/agent-action-audit
{
  "index_patterns": ["agent-action-audit"],
  "data_stream": {},
  "priority": 500,
  "template": {
    "mappings": {
      "properties": {
        "@timestamp":            { "type": "date" },
        "event.action":          { "type": "keyword" },
        "incident.id":           { "type": "keyword" },
        "agent.conversation_id": { "type": "keyword" },
        "agent.evidence_count":  { "type": "long" },
        "agent.recommended_action": { "type": "keyword" },
        "review.decision":       { "type": "keyword" },
        "review.reason":         { "type": "keyword" },
        "review.responded_by":   { "type": "keyword" },
        "workflow.execution_id": { "type": "keyword" }
      }
    }
  }
}
</code></pre>
<p>Second, give the writer <code>create_doc</code> and nothing else, so it can add records but cannot reach for the by-query escape hatches:</p>
<pre><code>PUT _security/role/agent-action-audit-writer
{
  "indices": [
    { "names": ["agent-action-audit"], "privileges": ["create_doc", "auto_configure"] }
  ]
}
</code></pre>
<p>Tested against the running cluster, that pair behaves the way an audit store should:</p>
<p>| Attempt as the audit writer | Result |
| :---- | :---- |
| Append a decision record | <code>201 Created</code> |
| Overwrite a record by ID | <code>400</code>, only <code>op_type: create</code> is allowed in data streams |
| <code>_update_by_query</code> to change a decision | <code>403</code>, action unauthorized |
| <code>_delete_by_query</code> to erase history | <code>403</code>, action unauthorized |
| <code>_search</code> to read the trail back | <code>403</code>, action unauthorized |</p>
<p>The write-only behaviour in the last row is deliberate. The workflow that writes decisions has no reason to read them, so auditors get a separate read role and the writer stays write-only.</p>
<p>The two controls fail differently, which matters. The <code>400</code> comes from the data stream itself and applies to everyone, including a superuser. The <code>403</code> rows come from the role, and a superuser could still run them, which is why tamper-resistant retention means shipping records off the cluster the agent's operators administer.</p>
<p>For cluster-level activity, <a href="https://www.elastic.co/docs/deploy-manage/security/logging-configuration/enabling-audit-logs">enable Elasticsearch and Kibana security audit logging</a> and forward the logs to a monitoring deployment. On 9.5 <code>xpack.security.audit.enabled</code> became a dynamic cluster setting, so Elasticsearch no longer needs a restart to turn it on, though on orchestrated deployments the logs still have to be shipped somewhere readable.</p>
<h3 id="querythedecisiontrailwithesql">Query the decision trail with ES|QL</h3>
<p>Two runs of the workflow, one approved and one declined, produce two rows you can query alongside everything else in Elastic.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd2e55b4cd891ee8e/6a9554e18814aa7f0289c29f/10-decision-trail-esql.png" alt="ES|QL query over the agent-action-audit data stream returning two decision records" /></p>
<pre><code>FROM agent-action-audit
| KEEP @timestamp, agent.incident_class, agent.affected_pod, agent.recommended_action,
       agent.evidence_count, review.decision, review.reason, review.responded_by,
       workflow.execution_id
| SORT @timestamp DESC
</code></pre>
<p>Both runs saw the same 90 error events and proposed <code>restart-checkout-worker</code> on <code>checkout-worker-1</code>. The first review approved it as <code>supported-by-evidence</code>, and the second declined it as <code>wrong-target</code>, on the argument that restarting the pod hides a pricing-feed problem rather than fixing it.</p>
<p>Because both decisions are structured fields, disagreement between reviews is queryable. You can count rejections per incident class and group them by reason: <code>insufficient-evidence</code> sends you back to the investigation path, and <code>unsafe-action</code> sends you to the workflow and its permission boundary.</p>
<h2 id="aiagentobservabilitylimitstodesignaround">AI agent observability limits to design around</h2>
<p>Four behaviors are worth designing around, and each is cheaper to handle before the workflows are written.</p>
<ol>
<li><strong>Trace access is index-level, not per user.</strong> A space with sensitive conversations needs a role boundary on <code>traces-agent_builder.otel-*</code> rather than a UI setting.</li>
<li><strong>The managed dashboard is not installed automatically in a new space.</strong> Add it to your space provisioning checklist.</li>
<li><strong>The workflow execution carries its own APM <code>traceId</code>.</strong> It is not the same trace as the Agent Builder spans its <code>ai.agent</code> step produced, so correlate through the conversation ID or the <code>trace_id</code> returned by the agent rather than expecting one trace to span both.</li>
<li><strong>The <code>waitForInput</code> output shape differs from the reference page.</strong> The submitted values arrive under <code>response</code>, alongside <code>respondedBy</code>, as covered above.</li>
</ol>
<p>None of these blocks the pattern.</p>
<h2 id="wheretostartwithaiagentobservability">Where to start with AI agent observability</h2>
<p>Turn trace collection on, install the dashboard in the space your agents run in, and open the waterfall for one real conversation. It shows the tool sequence, the model split, and the latency distribution that the answer text does not.</p>
<p>Then pick the single incident class where you already trust the runbook, and add one <code>elasticsearch.index</code> step after its approval gate. An append-only decision record costs one workflow step and answers the three questions a review needs: who approved this, on what evidence, and what happened next.</p>
<p>For the details, see <a href="https://www.elastic.co/docs/explore-analyze/ai-features/agent-builder/collect-traces">Collect Agent Builder traces</a>, the <a href="https://www.elastic.co/docs/explore-analyze/ai-features/agent-builder/agent-traces-dashboard">traces overview dashboard</a>, <a href="https://www.elastic.co/docs/explore-analyze/ai-features/agent-builder/permissions">Agent Builder permissions</a>, <a href="https://www.elastic.co/docs/explore-analyze/workflows/authorization">workflow authorization</a>, and the <a href="https://www.elastic.co/docs/explore-analyze/workflows/steps/wait-for-input"><code>waitForInput</code> reference</a>.</p>]]></content:encoded>
    <link>https://www.elastic.co/observability-labs/blog/ai-agent-observability-audit-trail</link>
    <guid isPermaLink="false">ai-agent-observability-audit-trail</guid>
    <category><![CDATA[Agentic Observability]]></category>
    <category><![CDATA[LLM Observability]]></category>
    <category><![CDATA[OpenTelemetry]]></category>
    <dc:creator><![CDATA[Jeffrey Rengifo]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt2c757a76a8a7504d/6a9553fe0897906e2aefb3e4/01-header.png" length="0" type="image/png"/>
    <pubDate>Mon, 31 Aug 2026 15:13:52 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[From a 582ms latency spike to the team that owns it, using Kibana Discover]]></title>
    <description><![CDATA[Getting there takes a data view, some filter pills, a KQL query and a switch to Lucene query syntax, but the part that actually names the team is one ES|QL LOOKUP JOIN against a service catalog index.]]></description>
    <content:encoded><![CDATA[<p>A checkout service is running at 582ms p95 against a 350ms SLO target. One KQL query in Kibana <a href="https://www.elastic.co/docs/explore-analyze/discover/discover-get-started">Discover</a> finds it. Working out which team owns that service takes an ES|QL query that joins the metric documents to a small service catalog index using <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/lookup-join"><code>LOOKUP JOIN</code></a>. Below, that investigation runs in order. A <a href="https://www.elastic.co/docs/explore-analyze/find-and-organize/data-views">data view</a> narrows the scope and <a href="https://www.elastic.co/docs/explore-analyze/query-filter/filtering">filter pills</a> keep it visible, which matters more than it sounds when someone else has to reconstruct what you searched. <a href="https://www.elastic.co/docs/explore-analyze/query-filter/languages/kql">KQL</a> does most of the work. <a href="https://www.elastic.co/docs/explore-analyze/query-filter/languages/lucene-query-syntax">Lucene</a> query syntax handles the one case that needs a regex, and <a href="https://www.elastic.co/docs/reference/query-languages/esql">ES|QL</a> takes over once filtering stops answering the question.</p>
<h2 id="prerequisites">Prerequisites</h2>
<p>To follow along, you need:</p>
<ul>
<li>An <a href="https://www.elastic.co/elasticsearch">Elasticsearch</a> cluster with <a href="https://www.elastic.co/kibana">Kibana</a>. Everything up to the ES|QL section works on any recent version; <code>LOOKUP JOIN</code> is generally available in Elasticsearch 9.1 and was a technical preview in 9.0, so use 9.1 or later for the last section.</li>
<li>No special license tier. Everything in this article, including <code>LOOKUP JOIN</code>, works on the free basic license.</li>
<li>The two small sample indices created in the next section.</li>
</ul>
<h2 id="whycheckoutlatencyincreasedinproduction">Why checkout latency increased in production</h2>
<p>The example starts with a common operations question:</p>
<blockquote>
  <p>Why did checkout latency increase in production, and which team owns the service?</p>
</blockquote>
<p>The metrics index contains 15-minute service measurements for four services across three regions. One service, <code>checkout-api</code>, has higher p95 latency in <code>us-central1</code> during the investigation window. The goal is to get from all metrics to the small set of documents that explain the issue.</p>
<p>The walkthrough follows these steps:</p>
<ol>
<li>Select the right data view and time range.</li>
<li>Use UI filters to include, exclude, disable, and pin criteria.</li>
<li>Use KQL for the main field and range search.</li>
<li>Switch to Lucene when regular expression syntax is useful.</li>
<li>Use ES|QL mode with <code>LOOKUP JOIN</code> to enrich metrics with service catalog data.</li>
</ol>
<h2 id="setupthesamplemetricsindex">Set up the sample metrics index</h2>
<p>The walkthrough searches a metrics index named <code>o11y-labs-discover-service-metrics</code>. Create it with keyword fields for the service dimensions and numeric fields for the measurements:</p>
<pre><code>PUT o11y-labs-discover-service-metrics
{
  "mappings": {
    "properties": {
      "@timestamp": { "type": "date" },
      "service": {
        "properties": {
          "name": { "type": "keyword" },
          "environment": { "type": "keyword" },
          "version": { "type": "keyword" }
        }
      },
      "cloud": { "properties": { "region": { "type": "keyword" } } },
      "host": { "properties": { "name": { "type": "keyword" } } },
      "metrics": {
        "properties": {
          "latency": { "properties": { "p95_ms": { "type": "float" } } },
          "cpu": { "properties": { "pct": { "type": "float" } } },
          "error": { "properties": { "rate": { "type": "float" } } }
        }
      }
    }
  }
}
</code></pre>
<p>Each document is one 15-minute measurement for one service in one region:</p>
<pre><code>POST o11y-labs-discover-service-metrics/_bulk
{ "index": {} }
{ "@timestamp": "2026-06-30T16:15:00.000Z", "service": { "name": "checkout-api", "environment": "production", "version": "2026.06.30-1" }, "cloud": { "region": "us-central1" }, "host": { "name": "checkout-api-us-central1-01" }, "metrics": { "latency": { "p95_ms": 582.6 }, "cpu": { "pct": 0.81 }, "error": { "rate": 0.041 } } }
{ "index": {} }
{ "@timestamp": "2026-06-30T16:15:00.000Z", "service": { "name": "payments-api", "environment": "production", "version": "2026.06.29-7" }, "cloud": { "region": "us-central1" }, "host": { "name": "payments-api-us-central1-01" }, "metrics": { "latency": { "p95_ms": 231.4 }, "cpu": { "pct": 0.31 }, "error": { "rate": 0.008 } } }
</code></pre>
<p>To reproduce the screenshots, index one document per service, region, and 15-minute interval:</p>
<ul>
<li><strong>Services:</strong> <code>checkout-api</code>, <code>checkout-worker</code>, <code>payments-api</code>, <code>inventory-api</code></li>
<li><strong>Regions:</strong> <code>us-central1</code>, <code>us-east4</code>, <code>europe-west1</code></li>
<li><strong>Window:</strong> 14:00 to 19:45 UTC on June 30, 2026, giving 24 intervals of 15 minutes</li>
<li><strong>Documents per interval:</strong> 12 production, plus two <code>staging</code> (<code>checkout-api</code> and <code>payments-api</code>, both in <code>us-central1</code>)</li>
<li><strong>Total:</strong> 24 intervals × 14 documents = 336 documents</li>
</ul>
<p>The exact values do not matter, as long as <code>checkout-api</code> in <code>us-central1</code> reports <code>metrics.latency.p95_ms</code> above 500 between 15:45 and 18:45 UTC and stays well under 500 ms everywhere else.</p>
<p>Instead of indexing everything by hand, you can run the <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/observability-labs/exploring-discover-search-methods/exploring-discover-search-methods.ipynb">supporting notebook</a>, which generates the full 336-document dataset, creates both indices, and verifies the final ES|QL query.</p>
<p>The ES|QL section also uses a second, four-document lookup index for service catalog data. We will create it when we get there.</p>
<h2 id="chooseadataviewinkibanadiscover">Choose a data view in Kibana Discover</h2>
<p>The data view is the first filter in Discover. It decides which Elasticsearch indices are searched, which time field drives the histogram, and which fields are available in the left field list.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt9d498fdd3ec06441/6a903c0da59451f9be62c32e/02-data-view.jpg" alt="Discover with the service metrics data view selected and three filter pills" /></p>
<p>For this walkthrough, the Discover data view points to:</p>
<pre><code>o11y-labs-discover-service-metrics
</code></pre>
<p>The time field is <code>@timestamp</code>. That matters because the time picker limits the documents before you add a query, a filter pill, or a selected field.</p>
<p>Use a narrow data view when you can. For example, a data view that targets only service metrics makes Discover easier to scan than a broad <code>logs-*,metrics-*</code> data view when you already know the question is about metrics.</p>
<p>Once the data view is selected, add the fields that support the investigation:</p>
<ul>
<li><code>service.name</code></li>
<li><code>service.environment</code></li>
<li><code>cloud.region</code></li>
<li><code>metrics.latency.p95_ms</code></li>
<li><code>metrics.cpu.pct</code></li>
<li><code>metrics.error.rate</code></li>
</ul>
<h2 id="filterpillsindiscoverincludeexcludedisableandpin">Filter pills in Discover: include, exclude, disable, and pin</h2>
<p>UI filters are useful when you want a visible, editable list of constraints. They are also helpful when you are exploring fields from the document table and want Discover to write the field syntax for you.</p>
<p>In the document table, use the field actions (the +/- icons that appear when you hover over a value) to include or exclude it. For example:</p>
<pre><code>service.environment: production
NOT cloud.region: us-east4
service.version: 2026.06.29-7  (disabled)
</code></pre>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt003d380fc9aa026e/6a903c25a8b3230822cc2ac8/03-filter-pills.jpg" alt="Filter pills showing include, exclude, and disabled states in Discover" /></p>
<p>These three filters show the main filter controls:</p>
<ul>
<li>Include a value when you want only matching documents.</li>
<li>Exclude a value when a dimension is not part of the problem.</li>
<li>Temporarily disable a filter when you want to keep it nearby but remove it from the current query.</li>
<li>Pin a filter when it should stay active as you move between Kibana apps.</li>
</ul>
<p>Pinned filters are useful for investigations that cross app boundaries. For example, you can pin <code>service.environment: production</code> before moving from Discover to <a href="https://www.elastic.co/docs/explore-analyze/dashboards">dashboards</a>, <a href="https://www.elastic.co/docs/explore-analyze/visualize/lens">Lens</a>, or another view. Disabled filters are useful for testing a theory without deleting the context that got you there.</p>
<p>The key habit is to keep the filters readable. If a query has a long search expression and many hidden assumptions, another engineer has to reconstruct your thinking. Filter pills make the major scope decisions visible.</p>
<h2 id="kqlquerysyntaxforfieldrangeandbooleansearches">KQL query syntax for field, range, and boolean searches</h2>
<p>KQL, the Kibana Query Language, is a good default for Discover searches. It supports field names, exact values, ranges, wildcards, and boolean logic in a readable form.</p>
<p>For the checkout latency example, this KQL query narrows the view to one service, one region, and high p95 latency:</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt53fe42de1fc90184/6a903d899230821341c49a11/04-kql-query.jpg" alt="" /></p>
<pre><code>service.name : "checkout-api" and cloud.region : "us-central1" and metrics.latency.p95_ms &gt;= 500
</code></pre>
<p>Read it from left to right:</p>
<ul>
<li><code>service.name : "checkout-api"</code> keeps one service.</li>
<li><code>cloud.region : "us-central1"</code> keeps one cloud region.</li>
<li><code>metrics.latency.p95_ms &gt;= 500</code> keeps latency samples at or above 500 ms.</li>
</ul>
<p>You can add the environment in KQL:</p>
<pre><code>service.environment : "production" and service.name : "checkout-api" and cloud.region : "us-central1" and metrics.latency.p95_ms &gt;= 500
</code></pre>
<p>Or you can keep <code>service.environment: production</code> as a UI filter. Both approaches are valid. For shared investigations, we prefer stable scope, such as environment and service, as filter pills, and the active hypothesis, such as a latency threshold, in the search bar.</p>
<p>KQL also works well for combining fields:</p>
<pre><code>service.environment : "production" and
(service.name : "checkout-api" or service.name : "payments-api") and
metrics.error.rate &gt; 0.02
</code></pre>
<p>This is useful when a user-facing flow crosses multiple services. You can compare a small group of services without switching data views or creating a dashboard first.</p>
<h2 id="lucenequerysyntaxinkibanasearchingwithregularexpressions">Lucene query syntax in Kibana: searching with regular expressions</h2>
<p>Lucene query syntax is the option in Kibana that supports regular expressions. KQL does not, so when you need a regex in the search bar, open the query menu at the right of the search bar and switch the language to <strong>Lucene</strong>.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4a56107ae6b7d0be/6a903c4f6ea6da9c2d00a2e8/05-lucene-language.jpg" alt="Filter language menu in Discover with Lucene selected" /></p>
<p>For example, this Lucene query searches production services whose names start with <code>checkout-</code> and whose p95 latency is above 500 ms:</p>
<pre><code>service.name:/checkout-.*/ AND service.environment:production AND metrics.latency.p95_ms:&gt;500
</code></pre>
<p>Lucene syntax is more compact, but it is also easier to misread. Use it when it gives you something you cannot express as clearly in KQL, such as a regex pattern over a field. For everyday field, value, and range filtering, KQL is usually easier for a teammate to review.</p>
<h2 id="howtojointwoindicesindiscoverwithesqllookupjoin">How to join two indices in Discover with ES|QL LOOKUP JOIN</h2>
<p>Classic Discover mode is good when you want to search, filter, inspect fields, and look at raw documents. <a href="https://www.elastic.co/docs/explore-analyze/discover/try-esql">ES|QL in Discover</a> is better when the question needs transformation before the result is useful. Use the <strong>Query in ES|QL</strong> button in the Discover toolbar to switch modes.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltc2c6e69cd05ecb1e/6a903c6ba8b323f8facc2ad0/06-esql-button.jpg" alt="Query in ES|QL button in the Discover toolbar" /></p>
<p>In this example, raw metrics tell us that <code>checkout-api</code> latency is high. They do not tell us who owns that service or what latency target the service is expected to meet. That data lives in a small service catalog lookup index.</p>
<h3 id="createalookupindexforservicecatalogdata">Create a lookup index for service catalog data</h3>
<pre><code>PUT o11y-labs-service-catalog-lookup
{
  "settings": {
    "index.mode": "lookup"
  },
  "mappings": {
    "properties": {
      "service": {
        "properties": {
          "name": {
            "type": "keyword"
          }
        }
      },
      "owner": {
        "properties": {
          "team": {
            "type": "keyword"
          }
        }
      },
      "slo": {
        "properties": {
          "latency_target_ms": {
            "type": "long"
          }
        }
      },
      "runbook": {
        "properties": {
          "url": {
            "type": "keyword"
          }
        }
      }
    }
  }
}
</code></pre>
<p>One catalog document can attach ownership and an SLO target to the service:</p>
<pre><code>POST o11y-labs-service-catalog-lookup/_doc/checkout-api
{
  "service": {
    "name": "checkout-api"
  },
  "owner": {
    "team": "checkout-platform"
  },
  "slo": {
    "latency_target_ms": 350
  },
  "runbook": {
    "url": "https://runbooks.example.com/checkout-api/latency"
  }
}
</code></pre>
<h3 id="runthelookupjoinquery">Run the LOOKUP JOIN query</h3>
<p>Now Discover can run an ES|QL query that joins the metric documents with that catalog metadata using <code>LOOKUP JOIN</code>. Remember that this command needs Elasticsearch 9.1 or later, that the lookup index must be created with <code>index.mode: lookup</code>, and that the join field, <code>service.name</code> here, must be mapped as <code>keyword</code> in the lookup index.</p>
<pre><code>FROM o11y-labs-discover-service-metrics
| WHERE @timestamp &gt;= "2026-06-30T15:00:00.000Z" AND @timestamp &lt;= "2026-06-30T18:45:00.000Z"
| WHERE service.environment == "production"
| LOOKUP JOIN o11y-labs-service-catalog-lookup ON service.name
| WHERE owner.team == "checkout-platform" AND metrics.latency.p95_ms &gt; slo.latency_target_ms
| KEEP @timestamp, service.name, cloud.region, metrics.latency.p95_ms, slo.latency_target_ms, owner.team
| SORT @timestamp DESC
</code></pre>
<p>This is the part classic mode does not cover. Classic Discover can filter the metric documents, but ES|QL can enrich those rows with data from another index before displaying the result.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt177047f468855477/6a903c7cda6aea445b37bdb3/07-esql-lookup-join.jpg" alt="ES|QL LOOKUP JOIN results in Discover showing 13 rows for checkout-api" /></p>
<p>The result table answers a more operational question than the original search. It shows the affected service, the region, the latency value, the target, and the owning team in one view.</p>
<p>This pattern is useful for more than ownership. You can keep small lookup indices for service tier, deployment ring, escalation channel, business capability, or runbook URL. Then you can join that context into metric searches at investigation time.</p>
<h2 id="howtochoosetherightdiscoversearchmethod">How to choose the right Discover search method</h2>
<p>The most useful workflow is not one search language for everything. It is a progression from broad scope to specific evidence.</p>
<p>| Use case | Discover feature | Why it helps |
| :---- | :---- | :---- |
| Limit the searchable data | Data view and time picker | Removes irrelevant indices and old documents before the query runs |
| Keep scope visible | UI filters | Makes include, exclude, disabled, and pinned criteria easy to review |
| Search exact fields and ranges | KQL | Keeps common metric searches readable |
| Match field values with regex | Lucene mode | Adds regular expression syntax when the search needs it |
| Enrich or reshape results | ES|QL mode | Adds joins, projections, sorting, and transformations |</p>
<p>For a real investigation, start with the smallest data view that still contains the data you need. Add filter pills for stable scope. Use KQL for the active search. Switch to Lucene only when regex syntax is worth the extra complexity. Move to ES|QL when the question needs enrichment, aggregation, or reshaping.</p>
<h2 id="fieldnamingconventionsthatmakemetricseasiertosearch">Field naming conventions that make metrics easier to search</h2>
<p>Metric search works best when the field names carry enough context. The examples above use <a href="https://www.elastic.co/docs/reference/ecs">Elastic Common Schema</a>-style fields where possible:</p>
<ul>
<li><code>service.name</code> for the monitored service.</li>
<li><code>service.environment</code> for production, staging, or development.</li>
<li><code>cloud.region</code> for the deployment region.</li>
<li><code>host.name</code> for host-level drill-down.</li>
<li>Numeric metric fields under <code>metrics.*</code>.</li>
</ul>
<p>You do not need this exact schema to use Discover, but predictable field names make the search bar and filter pills much easier to use. They also make <a href="https://www.elastic.co/docs/explore-analyze/discover/save-open-search">saved searches</a> and screenshots easier to understand during a handoff.</p>
<p>For service catalog data, keep the lookup index small and stable. Fields like service owner, tier, SLO target, and runbook URL change less often than raw metrics. That makes them good candidates for <code>LOOKUP JOIN</code> during analysis.</p>
<h2 id="runthewalkthroughonyourowncluster">Run the walkthrough on your own cluster</h2>
<p>Use Discover as a drill-down path, not only as a document table. In this walkthrough, we:</p>
<ul>
<li>Scoped the search with a narrow data view and the time picker before writing any query.</li>
<li>Made the investigation scope visible and shareable with include, exclude, disabled, and pinned filter pills.</li>
<li>Used KQL for readable field, range, and boolean searches.</li>
<li>Switched to Lucene only for the regex case KQL cannot express.</li>
<li>Enriched metric documents with ownership and SLO data from a lookup index using ES|QL <code>LOOKUP JOIN</code>.</li>
</ul>
<p>To try the full flow on your own cluster, run the <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/observability-labs/exploring-discover-search-methods/exploring-discover-search-methods.ipynb">supporting notebook</a>, which creates both indices and the incident data used in every example.</p>
<p>Related documentation:</p>
<ul>
<li><a href="https://www.elastic.co/docs/explore-analyze/discover/discover-get-started">Discover</a></li>
<li><a href="https://www.elastic.co/docs/explore-analyze/find-and-organize/data-views">Data views</a></li>
<li><a href="https://www.elastic.co/docs/explore-analyze/query-filter/filtering">Filtering</a></li>
<li><a href="https://www.elastic.co/docs/explore-analyze/query-filter/languages/kql">KQL</a></li>
<li><a href="https://www.elastic.co/docs/explore-analyze/query-filter/languages/lucene-query-syntax">Lucene query syntax</a></li>
<li><a href="https://www.elastic.co/docs/explore-analyze/discover/try-esql">ES|QL in Discover</a></li>
<li><a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/lookup-join"><code>LOOKUP JOIN</code></a></li>
</ul>
<p>Related Observability Labs articles:</p>
<ul>
<li><a href="https://www.elastic.co/observability-labs/blog/exploring-metrics-new-data-source-discover">Exploring metrics from a new time series data stream in Discover</a></li>
<li><a href="https://www.elastic.co/observability-labs/blog/metrics-explore-analyze-with-esql-discover">Explore and analyze metrics with ease in Elastic Observability</a></li>
<li><a href="https://www.elastic.co/observability-labs/blog/elastic-discover-traces-apm">Traces in Discover for deeper application insights in Elastic Observability</a></li>
<li><a href="https://www.elastic.co/observability-labs/blog/elastic-esql-join-observability">Connecting the dots: ES|QL joins for richer observability insights</a></li>
<li><a href="https://www.elastic.co/observability-labs/blog/esql-kubernetes-monitoring">Common ES|QL queries for Kubernetes monitoring</a></li>
</ul>]]></content:encoded>
    <link>https://www.elastic.co/observability-labs/blog/kibana-discover-search-kql-lucene-esql</link>
    <guid isPermaLink="false">kibana-discover-search-kql-lucene-esql</guid>
    <category><![CDATA[Metrics]]></category>
    <dc:creator><![CDATA[Jeffrey Rengifo]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt3d63a247bf43f8b7/6a903bbb386ac3e853ae147b/01-header.jpg" length="0" type="image/jpeg"/>
    <pubDate>Fri, 28 Aug 2026 15:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[From recommendation to remediation in 4 stages: human-in-the-loop automation with Elastic Workflows]]></title>
    <description><![CDATA[An approval gate that pauses incident response automation before the action and gives the reviewer enough evidence to decide in seconds. Whatever happens next, approved or declined, lands in one auditable record.]]></description>
    <content:encoded><![CDATA[<p>An approve button with no evidence behind it is not a control. <a href="https://www.elastic.co/docs/explore-analyze/workflows/authoring-techniques/human-in-the-loop">Human-in-the-loop</a> automation binds five things into one inspectable record: the evidence you observed, the action you propose, the person who decided, the deadline they had, and what actually executed.</p>
<p>In this article, you'll build that gate with <a href="https://www.elastic.co/docs/explore-analyze/workflows">Elastic Workflows</a>, reusing the control plane from the companion article <a href="https://www.elastic.co/observability-labs/blog/automated-root-cause-analysis-agent-builder">Build an SRE Control Plane with Agent Builder and Workflows</a>. This time the incident is narrow enough to act on: a stale pricing cache on a single worker, with a structured approval step sitting between the recommendation and the remediation. Nothing here touches production, so you can run the approved branch and the declined branch and compare what each one leaves in the execution record. That record is how incident response automation earns autonomy one incident class at a time.</p>
<h2 id="prerequisites">Prerequisites</h2>
<ul>
<li>Elastic Stack 9.4+</li>
</ul>
<h2 id="whatansrecontrolplaneneedsbeforeyouaddapproval">What an SRE control plane needs before you add approval</h2>
<p>This article builds on <a href="https://www.elastic.co/observability-labs/blog/automated-root-cause-analysis-agent-builder">Build an SRE Control Plane with Agent Builder and Workflows</a>, which defines the control-plane pattern used here: telemetry, investigation context, policy, and a set of known actions, wired together. <a href="https://www.elastic.co/docs/solutions/search/elastic-agent-builder">Agent Builder</a> reasons over logs, traces, metrics, alerts, runbooks, and previous cases. <a href="https://www.elastic.co/docs/explore-analyze/workflows">Elastic Workflows</a> executes a defined sequence with explicit inputs and permissions. <a href="https://www.elastic.co/docs/explore-analyze/workflows/authoring-techniques/human-in-the-loop">Human-in-the-loop</a>, or HITL, connects those two responsibilities at exactly the point where evidence becomes action.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt6b61f78deb82055c/6a902c51346647461ca2ba65/diagram1.png" alt="Agent Builder reasoning over logs, traces, and metrics alongside a single Elastic Workflow execution that collects evidence, runs the analysis, opens a case, pauses at waitForInput for a reviewer, and branches into an approved or a declined path" /></p>
<p>A recommendation and a remediation have very different failure modes. A weak recommendation wastes an engineer's time. A weak remediation changes production. This is why the approval gate belongs inside the execution model.</p>
<h2 id="thefivebindingsthatmakeanapprovalgateareliabilitycontrol">The five bindings that make an approval gate a reliability control</h2>
<p>A useful gate preserves five properties. Remove any one of them and the gate gets weaker.</p>
<ul>
<li><strong>Evidence binding</strong>: The reviewer sees the logs, alert details, enrichment, or agent rationale that produced the proposal. An "Approve" button without evidence only asks a person to accept the automation's confidence.  </li>
<li><strong>Action binding</strong>: The request names the exact bounded action, target, parameters, and expected effect. A request without an explicit target can authorize far more than the reviewer intended.  </li>
<li><strong>Identity binding</strong>: The record shows who approved or declined the request, and when. Without it, you have an outcome but no accountability.  </li>
<li><strong>Time binding</strong>: The decision has a deadline, and stale requests fail closed instead of running later without context. An approval without a timeout can outlive the incident evidence that justified it.  </li>
<li><strong>Outcome binding</strong>: The execution record shows what ran, what was skipped, and whether post-action verification passed.</li>
</ul>
<p>The form still matters, but the form is only the human-facing part of a much larger control. Approval design is reliability engineering.</p>
<h2 id="thefourstagesfromairecommendationtoautoremediation">The four stages from AI recommendation to auto remediation</h2>
<p>Treat autonomy as a sequence of operational states rather than a product toggle.</p>
<p>| Autonomy stage | System behavior | Human responsibility | Promotion evidence |
| :---- | :---- | :---- | :---- |
| 0. Observe | Search logs and assemble context. | Investigate and act manually. | Queries find the right incident evidence. |
| 1. Recommend | Propose one bounded next step with rationale. | Decide and execute outside the workflow. | Recommendations are accurate enough to review quickly. |
| 2. Approve | Pause before action and resume only with structured input. | Approve or decline the exact proposal. | Approval quality, execution success, and rollback behavior are measured. |
| 3. Automate narrowly | Run the same action automatically for a proven incident class. | Review exceptions and audit samples. | Scope, permissions, timeout, verification, and rollback remain enforced. |</p>
<p>The important transition is from stage 1 to stage 2. That is where the system stops being an advisor and gains an execution path. That path should be deterministic even when an AI agent contributed to the investigation: the agent summarizes evidence and recommends an action, while the workflow owns the pause, the structured decision, the branch, and the execution record.</p>
<h2 id="buildthehumanintheloopautomationgateinelasticworkflows">Build the human-in-the-loop automation gate in Elastic Workflows</h2>
<p>Elastic Workflows gives you the <a href="https://www.elastic.co/docs/explore-analyze/workflows/authoring-techniques/human-in-the-loop"><code>waitForInput</code> step</a> for this. When execution reaches that step, the workflow stops in the <code>WAITING_FOR_INPUT</code> state and waits for a person. The reviewer answers a small form in the <a href="https://www.elastic.co/docs/explore-analyze/workflows/authoring-techniques/monitor-workflows">Kibana execution view</a> (or through the resume API), and whatever they submit becomes available to later steps at <code>steps.&lt;step_name&gt;.output</code>.</p>
<p>The workflow pulls the last 30 minutes of <code>checkout-api</code> failure logs, hands them to Agent Builder for a root-cause analysis, and writes that analysis into an Observability case. Then it stops and asks one question: should we clear the pricing cache on <code>checkout-worker-07</code>? If you approve, it records the simulated action, runs a verification query, and appends the result to the case. If you decline, it records the decision and runs nothing. Either way, production is untouched.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt522bdd7a74336a11/6a902cd3971ef9b438537e9c/diagram2.png" alt="Flow from trigger to evidence collection to a proposed bounded action, pausing in WAITING_FOR_INPUT before splitting into a declined path that records the decision and an approved path that executes, verifies, and records the outcome in the case" /></p>
<pre><code>name: obs-labs-checkout-control-plane-hitl
description: Human approval gate for the OpenTelemetry-grounded checkout control plane.
enabled: false
tags:
  - sre-control-plane
  - agent-builder
  - human-in-the-loop
  - workflows
  - opentelemetry

settings:
  timeout: "30m"

triggers:
  - type: manual

consts:
  incident_id: "obs-labs-checkout-hitl-20260719"

steps:
  - name: collect_evidence
    type: elasticsearch.search
    with:
      index: "logs-*"
      size: 10
      query:
        bool:
          filter:
            - range:
                "@timestamp":
                  gte: "now-30m"
            - term:
                "service.name": "checkout-api"
            - term:
                "attributes.incident.id": "{{ consts.incident_id }}"
            - match_phrase:
                "body.text":
                  query: "checkout failed: stale pricing cache"

  - name: rca_analysis
    type: ai.agent
    agent-id: elastic-ai-agent
    create-conversation: true
    with:
      message: |
        Investigate the checkout-api incident identified by {{ consts.incident_id }}.
        The workflow evidence query found {{ steps.collect_evidence.output.hits.total.value }} matching OpenTelemetry log events in the last 30 minutes.
        Search logs, traces, and metrics for service.name checkout-api and incident.id {{ consts.incident_id }}.
        Identify the affected worker, deployment version, error type, HTTP status, and latency evidence.
        Return the likely cause, supporting evidence, confidence, and whether the bounded simulated action below is consistent with the evidence.
        Proposed simulated action: simulate clearing the pricing cache on checkout-worker-07.
        This workflow must not change production.

  - name: case_title
    type: ai.agent
    agent-id: elastic-ai-agent
    with:
      conversation_id: "{{ steps.rca_analysis.output.conversation_id }}"
      message: "Produce a short case title for this checkout incident. Output only the title."

  - name: case_description
    type: ai.agent
    agent-id: elastic-ai-agent
    with:
      conversation_id: "{{ steps.rca_analysis.output.conversation_id }}"
      message: "Produce a concise case description grounded in the OpenTelemetry evidence. Include the incident ID and say that remediation requires human approval. Output only the description."

  - name: create_case
    type: cases.createCase
    with:
      title: "{{ steps.case_title.output.message }}"
      description: "{{ steps.case_description.output.message }}"
      owner: "observability"
      severity: "medium"
      tags:
        - sre-control-plane
        - agent-builder
        - human-in-the-loop
        - opentelemetry

  - name: add_agent_analysis
    type: cases.addComment
    with:
      case_id: "{{ steps.create_case.output.case.id }}"
      comment: |
        ## Agent Builder RCA and proposed action

        Evidence query matches: {{ steps.collect_evidence.output.hits.total.value }}

        {{ steps.rca_analysis.output.message }}

        Proposed simulated action: simulate clearing the pricing cache on checkout-worker-07.
        No action has run yet.

        Agent conversation: {{ kibanaUrl }}/app/agent_builder/conversations/{{ steps.rca_analysis.output.conversation_id }}

  - name: review
    type: waitForInput
    with:
      message: |
        Approve the bounded checkout response?

        Incident: {{ consts.incident_id }}
        Evidence: {{ steps.collect_evidence.output.hits.total.value }} matching checkout failure logs in the last 30 minutes.
        Target: checkout-worker-07
        Action: simulate clearing the pricing cache on this worker only.
        Expected effect: subsequent checkout requests no longer use the stale cache.
        Blast radius: one worker. This simulated step does not change production.

        Review the Agent Builder analysis in case {{ steps.create_case.output.case.id }} before deciding.
      schema:
        type: object
        properties:
          approved:
            type: boolean
            title: "Approve the simulated cache clear"
          notes:
            type: string
            title: "Reviewer notes"
        required:
          - approved

  - name: approved_action
    type: console
    if: "steps.review.output.approved : true"
    with:
      message: "Approved. Simulated cache clear recorded for checkout-worker-07. Reviewer notes: {{ steps.review.output.notes }}"

  - name: verify_after_approval
    type: elasticsearch.search
    if: "steps.review.output.approved : true"
    with:
      index: "logs-*"
      size: 0
      query:
        bool:
          filter:
            - range:
                "@timestamp":
                  gte: "now-5m"
            - term:
                "service.name": "checkout-api"
            - term:
                "attributes.incident.id": "{{ consts.incident_id }}"
            - match_phrase:
                "body.text":
                  query: "checkout failed: stale pricing cache"

  - name: record_approved
    type: cases.addComment
    if: "steps.review.output.approved : true"
    with:
      case_id: "{{ steps.create_case.output.case.id }}"
      comment: |
        ## Human decision: approved

        Reviewer notes: {{ steps.review.output.notes }}
        Simulated action target: checkout-worker-07
        Verification query matches in the last 5 minutes: {{ steps.verify_after_approval.output.hits.total.value }}

        This walkthrough did not change production.

  - name: record_declined
    type: cases.addComment
    if: "steps.review.output.approved : false"
    with:
      case_id: "{{ steps.create_case.output.case.id }}"
      comment: |
        ## Human decision: declined

        Reviewer notes: {{ steps.review.output.notes }}
        No action ran.

  - name: declined_console
    type: console
    if: "steps.review.output.approved : false"
    with:
      message: "Declined. No action executed. Reviewer notes: {{ steps.review.output.notes }}"
</code></pre>
<p>The <code>collect_evidence</code> and <code>rca_analysis</code> steps keep the same evidence-then-reasoning sequence as the first article, and the workflow writes that analysis into the case <em>before</em> it ever reaches the <code>waitForInput</code> boundary. By the time a human is asked to decide, the reasoning is already durable and linkable.</p>
<p>The approval form asks for one boolean and accepts optional notes. </p>
<p>Only the approved branch records the simulated cache clear, runs a bounded verification query, and appends the result to the case. The declined branch records the decision and runs nothing.</p>
<p>The approval request carries the resulting count into the decision, while the linked case retains the full Agent Builder analysis. Treat that count as evidence for the reviewer, not as a root-cause claim or a performance measurement.   </p>
<h3 id="replacethesimulatedactionwithrealautoremediation">Replace the simulated action with real auto remediation</h3>
<p>The walkthrough keeps the approved branch as a <code>console</code> step so you can run both branches safely. In production you replace <em>only</em> that step with a narrowly scoped <a href="https://www.elastic.co/docs/explore-analyze/workflows/steps/external-systems-apps">external action</a>, and leave the case, timeout, verification, and decline path exactly as they are.</p>
<p>You have two options, depending on how the target system is reached.</p>
<h3 id="callaninternalremediationapiwithanhttpconnector">Call an internal remediation API with an HTTP connector</h3>
<p>Configure an HTTP connector in Kibana with the base URL, authentication, and any encrypted headers, then reference it by <code>connector-id</code>. Secrets stay in the connector, never in the workflow YAML.</p>
<pre><code>  - name: clear_pricing_cache
    type: http
    connector-id: "checkout-remediation-api"
    if: "steps.review.output.approved : true"
    with:
      path: "/v1/cache/pricing/purge"
      method: "POST"
      body:
        worker: "checkout-worker-07"
        incident_id: "{{ consts.incident_id }}"
</code></pre>
<h3 id="handofftojiraslackorpagerduty">Hand off to Jira, Slack or PagerDuty</h3>
<p><a href="https://www.elastic.co/docs/reference/kibana/connectors-kibana">Kibana connectors</a> are available as workflow steps, so the approved branch can open a <code>jira</code> ticket for a change-managed action, post to <code>slack</code> for the on-call channel, or page through PagerDuty, all using credentials your team already manages centrally.</p>
<pre><code>  - name: notify_oncall
    type: slack
    connector-id: "sre-oncall-channel"
    if: "steps.review.output.approved : true"
    with:
      message: "Approved by {{ steps.review.output.notes }}: pricing cache cleared on checkout-worker-07 for {{ consts.incident_id }}."
</code></pre>
<p>Whichever you choose, keep the action bound. </p>
<h2 id="howtodesignahumanintheloopautomationgate">How to design a human-in-the-loop automation gate</h2>
<p>The workflow above shows the mechanics. Getting the gate right is a design problem: where the pause goes, what the request tells the reviewer, what happens when nobody answers, and what the execution record has to preserve. Each one maps back to one of the five bindings.</p>
<h3 id="whereshouldtheapprovalstepgoinanincidentresponseworkflow">Where should the approval step go in an incident response workflow?</h3>
<p>The best place for <code>waitForInput</code> is immediately before the first step that increases impact. Don't pause before gathering evidence, because the workflow can usually search, enrich, classify, and open a draft case without touching the affected service. And don't pause after the remediation, because that just asks a person to ratify something that already happened.</p>
<p>Before anyone enables the workflow, a reviewer can inspect the evidence query, the form schema, the timeout, the branch condition, and the action itself.</p>
<h3 id="whatagoodapprovalrequesttellsthereviewer">What a good approval request tells the reviewer</h3>
<p>An on-call engineer should not have to reconstruct the investigation from five other screens. Lead with the decision and include only the evidence needed to make it. A strong approval request answers these questions, in this order:</p>
<ol>
<li>What exactly am I deciding?  </li>
<li>What telemetry supports the proposal?  </li>
<li>What target and parameters will the action use?  </li>
<li>What is the expected effect and blast radius?  </li>
<li>What happens if I decline or do nothing?</li>
</ol>
<p>One required decision plus optional notes is usually enough. If the reviewer has to type in service names, host identifiers, environment, and action parameters, the proposal wasn't specific enough before the pause.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltda4f7ca71ecde303/6a90260eb74e9df38c44b26e/03-approval-form.png" alt="The Provide action dialog showing the approval request with the incident, the evidence count, the target worker, the expected effect, and the blast radius, above a JSON form submitting the approved decision" /></p>
<p>Paused executions stay discoverable in history and can be resumed by any authorized reviewer, which brings up a queueing requirement. Once a team has more than a few paused executions, reviewers need an inbox or an equivalent filtered view showing pending decisions, age, owner, severity, and target. Otherwise, a safe pause quietly becomes an invisible backlog.</p>
<h3 id="whathappensifnobodyapprovesintime">What happens if nobody approves in time</h3>
<p><code>waitForInput</code> has no default timeout; the execution waits indefinitely. The <a href="https://www.elastic.co/docs/explore-analyze/workflows/authoring-techniques/settings"><code>settings.timeout</code> field</a> caps the entire execution, including time spent waiting for input, and in this workflow the 30-minute value limits how long the proposal stays actionable after the evidence was collected.</p>
<p>Choose that value from the incident and the action; a traffic shift during an active outage may need a decision within minutes. A maintenance approval may stay valid for hours. Confirm the timeout behavior on the exact Elastic version you operate, and add an external escalation or cancellation path if what you observe doesn't meet your fail-closed requirement.</p>
<p>Whatever you do, don't convert silence into approval.</p>
<h3 id="whattheauditrecordmustcapture">What the audit record must capture</h3>
<p>The execution history should answer four questions without anyone digging through chat history:</p>
<ul>
<li>What evidence did the workflow collect?  </li>
<li>What exact input did the reviewer submit?  </li>
<li>Which branch ran?  </li>
<li>What did the action and verification steps return?</li>
</ul>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt7d83a3c0613fb503/6a902629a3077c37f23fda9f/04-execution-waiting.png" alt="Workflow execution paused in the waiting state, with the evidence and case steps complete and the review step flagged as requiring action" />
<img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd8b8b6a5b5873667/6a9026471111551c070d0a5f/05-execution-approved.png" alt="Completed workflow execution showing the reviewer's approved input in the execution record, with the action, verification, and case steps all resolved" /></p>
<p>A paused execution shows the evidence step and the decision still pending. An approved execution adds the action branch and the verification output to that same view. A declined execution preserves the same evidence and the same decision while skipping every action step. Keeping the walkthrough action simulated lets you inspect both branches without changing production.</p>
<p>For longer-lived incident context, push it into the case. Add the evidence summary, reviewer notes, action result, and verification result as comments, and the case becomes the durable record that outlives the execution.</p>
<h2 id="howtoknowwhenaworkflowisreadytorunwithoutapproval">How to know when a workflow is ready to run without approval</h2>
<p>Don't remove the approval gate because a handful of runs succeeded. Review enough executions to understand both the normal and the exceptional paths, and measure at least these outcomes:</p>
<p>| Measure | Question it answers |
| :---- | :---- |
| Proposal acceptance rate | Does the workflow recognize the right incident class? |
| Reviewer edits or declines | Which evidence or action parameters are still wrong? |
| Approval age | Can the team respond before evidence becomes stale? |
| Action success rate | Does the bounded action execute reliably? |
| Verification success rate | Did the service improve after the action? |
| Rollback rate | How often did the response create a new problem? |</p>
<p>Autonomous execution is reasonable only when the incident class, target selection, action, verification, permissions, timeout, and rollback path are all narrow and repeatable. Even then, keep the same workflow structure. Automation should bypass the human wait for the proven path, not bypass evidence collection, authorization, verification, or audit records. Route anything uncertain back to human review.</p>
<h2 id="conclusion">Conclusion</h2>
<p>The approval gate is a small amount of YAML, but it changes what the automation is. <code>waitForInput</code> turns the decision into a structured input the branch logic depends on, so the action, the verification, and the case comment all exist because a named person answered a specific question at a specific time. Placing that pause right before the first step that increases impact, and capping it with <code>settings.timeout</code>, is what makes it a reliability control instead of a confirmation dialog.</p>
<p>Taking this to production is a smaller change than it looks: the simulated <code>console</code> step becomes an <code>http</code>, <code>slack</code>, or <code>jira</code> connector step, and everything else stays as it is. From there you earn autonomy one incident class at a time, letting acceptance rate, approval age, action success, verification success, and rollback rate tell you when a path is proven enough to run without the wait.</p>
<h2 id="wheretostartwithhumanintheloopautomation">Where to start with human-in-the-loop automation</h2>
<p>Start with one recurring operational signal and one reversible response. Build the evidence query first, then add a recommendation that names the target and the expected effect, then insert <code>waitForInput</code> immediately before the action and run the workflow in a lab or case-only mode. Review the execution history with SREs, developers, support, security, and the product owners of the affected service.</p>
<p>The approval gate is not the destination. It is the mechanism that lets a team move toward narrow autonomy without giving up evidence, accountability, or control.</p>
<ul>
<li><a href="https://www.elastic.co/docs/explore-analyze/workflows/authoring-techniques/human-in-the-loop">Human-in-the-loop workflow guide</a>  </li>
<li><a href="https://www.elastic.co/docs/explore-analyze/workflows/steps/flow-control-steps#waitforinput"><code>waitForInput</code> flow-control reference</a>  </li>
<li><a href="https://www.elastic.co/docs/explore-analyze/workflows/steps/external-systems-apps">External systems and apps steps</a>  </li>
<li><a href="https://www.elastic.co/docs/reference/kibana/connectors-kibana">Kibana connectors reference</a>  </li>
<li><a href="https://www.elastic.co/docs/explore-analyze/workflows/authoring-techniques/monitor-workflows">Workflow monitoring guide</a></li>
</ul>]]></content:encoded>
    <link>https://www.elastic.co/observability-labs/blog/incident-response-automation-human-approval-gate</link>
    <guid isPermaLink="false">incident-response-automation-human-approval-gate</guid>
    <category><![CDATA[Agentic Observability]]></category>
    <dc:creator><![CDATA[Jeffrey Rengifo]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt0a01d1c6dcc3a9d6/6a90266fd62e111636e3638c/header.jpg" length="0" type="image/jpeg"/>
    <pubDate>Thu, 27 Aug 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[From alert to root cause in 3 minutes: automated root cause analysis with Elastic Agent Builder]]></title>
    <description><![CDATA[Automated root cause analysis only works if the agent compares the incident window against the last healthy one. Skip that step and you get a summariser. The read-only skill, the scoped role and the Elastic Workflow are all here.]]></description>
    <content:encoded><![CDATA[<p>An alert fires on checkout latency. Three minutes and 22 seconds later there's an Elastic Observability case open with the root cause, the evidence behind it, and how confident the agent was. Nobody moved between Kibana, chat, tickets and a terminal to get there.</p>
<p>In this article, we'll build that loop end to end. We'll use <a href="https://www.elastic.co/docs/solutions/observability/ai/agent-builder-observability">Elastic Agent Builder</a> to feed logs, traces, metrics, alerts, and runbook context into an agent that investigates a problem, and <a href="https://www.elastic.co/docs/explore-analyze/workflows">Elastic Workflows</a> to execute the known next steps: opening a case, sending a notification, running an enrichment query, or triggering a remediation path.</p>
<p>We'll work through a checkout latency regression as our example, but the same pattern applies to any incident class where your team already knows the manual steps.</p>
<h2 id="prerequisites">Prerequisites</h2>
<ul>
<li><a href="https://www.elastic.co/cloud">Elastic Cloud</a> or <a href="https://www.elastic.co/docs/deploy-manage/deploy/self-managed">self-managed</a> cluster running 9.4+</li>
</ul>
<p>We'll use a checkout latency regression as the running example. If you want to follow along against your own telemetry, point the queries at your service instead. If you'd rather reproduce the exact incident, the <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/observability-labs/automated-root-cause-analysis-agent-builder/automated-root-cause-analysis-agent-builder.ipynb">supporting notebook</a> simulates it and sets up the role, skill, tool, and workflow for you.</p>
<h2 id="whydashboardsarenotenoughforincidentresponse">Why dashboards are not enough for incident response</h2>
<p>Dashboards show the symptom but cannot choose the next query. A dashboard is still one of the best tools for shared situational awareness, and during an incident the hard work starts after the chart turns red and the engineer still needs to answer a sequence of operational questions.</p>
<ul>
<li><strong>What changed?</strong> You need access to related deploys, alerts, logs, traces, and metrics from the same time window.</li>
<li><strong>What is affected?</strong> You need visibility into services, hosts, users, regions, SLOs, and dependency paths.</li>
<li><strong>What is the likely cause?</strong> You need evidence from telemetry combined with runbooks or previous incident cases.</li>
<li><strong>What is safe to do next?</strong> You need a bounded action that includes proper permissions, an audit trail, and a rollback path.</li>
</ul>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt6114ae1146e77d77/6a8ea04cf65645830854b8a0/02-dashboard-limits.png" alt="Kibana dashboard with KPI, trend, and breakdown panels showing current state but no next step" /></p>
<p>That last question is where a dashboard stops. It can show the symptom, but it cannot decide which query to run next, which runbook applies, or which workflow should run. Engineers provide that judgment today by moving between Kibana, chat, tickets, terminals, and internal docs.</p>
<p>An SRE control plane keeps the judgment with the engineer while moving more context and more action into the same operational surface.</p>
<h2 id="howautomatedrootcauseanalysisworksstatepolicyandaction">How automated root cause analysis works: state, policy and action</h2>
<p>Automated root cause analysis needs three things in one place: the telemetry, the permissions that bound it, and the actions it can trigger.</p>
<ul>
<li><strong>State:</strong> For SRE work, that state is telemetry in Elasticsearch: logs, traces, metrics, alerts, SLOs, and related operational records.</li>
<li><strong>Policy:</strong> Policy defines who can query which data, which tools an agent can call, which workflows can run, and where a human decision is required.</li>
<li><strong>Action:</strong> Action is a set of known tools and workflows that run with explicit inputs, permissions, and outputs.</li>
</ul>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltb6dd104b9aa23ca4/6a8ea04fb0dddebce0929d43/03-control-plane.png" alt="Elastic as an SRE control plane: investigate with Agent Builder and tools, decide behind guardrails, act through workflows" /></p>
<p>Agent Builder is useful where the system needs reasoning over messy context, and Workflows are useful where the system needs deterministic execution.</p>
<p>The two can work in both directions; a workflow can call an agent with an <code>ai.agent</code> step when it needs analysis before the next step, and an agent can call a workflow through a workflow tool when a conversation needs a repeatable action.</p>
<h2 id="whatelasticagentbuilderaddstoaiincidentresponse">What Elastic Agent Builder adds to AI incident response</h2>
<p><a href="https://www.elastic.co/docs/explore-analyze/ai-features/agent-builder/skills">Agent Builder skills</a> are reusable capability packs. A skill can include instructions, tools, and context that guide an agent through a specific task.</p>
<p>Reusable skill packs matter for SRE work because incident response is rarely a single query. A good investigation has a shape, and root cause analysis is a good example. The useful unit is not "ask the model what happened." It's a repeatable investigation path that starts from an alert, scopes the time window, checks the right telemetry, records uncertainty, and hands a case or workflow a structured result. The agent needs to decide which signal to start from, query the right index, compare the right time windows, inspect related services, and explain the evidence without hiding what it doesn't know.</p>
<p>Elastic includes a built-in Elastic AI Agent for this pattern. Built-in skills are scoped by solution, so the one that carries an SRE incident loop is <code>observability.investigation</code>, alongside platform skills such as <code>dashboard-management</code> that any solution can use. The list shows the short name, so look for <code>investigation</code> in the UI.</p>
<p>The skill ships as Markdown instructions, the same format we use for our own skill in the next section.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt0fd3e84ed2a01173/6a8ea052cf1e0e1f3e756709/04-skills.png" alt="The observability.investigation skill in Agent Builder, showing its description and its Markdown instructions" /></p>
<p>There are also out-of-the-box <a href="https://www.elastic.co/docs/explore-analyze/ai-features/agent-builder/tools">tools</a> such as <code>platform.core.search</code>, <code>platform.core.get_document_by_id</code>, <code>platform.core.get_index_mapping</code>, <code>platform.core.list_indices</code>, <code>platform.core.get_workflow_execution_status</code>, and <code>platform.core.resume_workflow_execution</code>.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltef9789bfb6591041/6a8ea056af2548467458e3a4/05-tools.png" alt="Agent Builder Tools page listing the built-in platform.core tools, with the search tool description open" /></p>
<p>Skills guide the work, tools perform bounded operations, and the agent chooses what to use based on the task.</p>
<h2 id="thescenarioacheckoutlatencyregression">The scenario: a checkout latency regression</h2>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt6294afc4fcd74760/6a8ea0583e4fd5f1131a8a02/06-scenario.png" alt="Alert triggers an Agent Builder investigation across logs, traces, and metrics, ending in a root cause and a case" /></p>
<p>Deployment <code>2026.07.09.1</code> ships a connection pool misconfiguration to <code>checkout-api</code>. Within minutes, p95 latency goes from 180ms to over 2s and HTTP 500s appear for the first time. Nobody knows yet that the pool is the cause.</p>
<p>The evidence is spread across three signals, and no single one answers the question:</p>
<p>| Signal | What it shows |
| :---- | :---- |
| Logs | <code>PoolExhaustedException</code> and HTTP 500s, only on the new version |
| Traces | The <code>payment-gateway</code> span goes from ~180ms to ~2500ms |
| Metrics | Connection pool pinned at 20 of 20 right after the deploy |</p>
<p>Correlating those three is the work we want the agent to do. That gives us the contract for the rest of this article:</p>
<p>| Contract | Detail |
| :---- | :---- |
| <strong>Input</strong> | Service name and the alert summary |
| <strong>Access</strong> | Read-only search over <code>logs-*</code>, <code>traces-*</code>, and <code>metrics-*</code> |
| <strong>Output</strong> | Likely cause, supporting evidence, confidence, and the next safe action |
| <strong>Side effect</strong> | One Observability case with the analysis attached |</p>
<p>Everything after this point builds one piece of that contract: the skill shapes the investigation, the tool and role bound the access, and the workflow turns the output into a case.</p>
<h2 id="buildareadonlyinvestigationskillinelasticagentbuilder">Build a read-only investigation skill in Elastic Agent Builder</h2>
<p>Let's start with a read-only skill that improves investigation quality without touching production:</p>
<pre><code># Checkout latency investigation

Use this skill when an engineer asks why checkout latency, errors, or failed transactions increased.

Work through the investigation in this order:

1. Identify the affected service, environment, and time range.
2. Query traces for the slowest transactions in that window.
3. Query logs for errors from the same service and dependency path.
4. Compare current error and latency rates with the previous healthy window.
5. Return the likely cause, supporting evidence, confidence level, and the next safe action.

Do not recommend a production change unless there is a workflow tool assigned for that action.

If the evidence is incomplete, say what data is missing.
</code></pre>
<p>This kind of skill is a runbook execution guide, and it keeps the agent consistent across incidents. It also helps less experienced engineers ask better follow-up questions, because the agent can show the next query and explain why it matters.</p>
<p>Without step 4, the agent describes what's happening now and stops there. Comparing against the previous healthy window is what makes it an analysis. And the last line lets the agent say the data is missing instead of guessing.</p>
<h2 id="addaiagentobservabilitytoolswithnarrowpermissions">Add AI agent observability tools with narrow permissions</h2>
<p>Each tool should expose the smallest operation the agent needs, with the smallest data access that still supports the task.</p>
<p>For a read-only investigation agent, the required privileges usually start with searching observability data and inspecting index structure. The <a href="https://www.elastic.co/docs/explore-analyze/ai-features/agent-builder/permissions">Agent Builder permissions documentation</a> calls out that tools run against Elasticsearch data as the current user, and that read-oriented tools need index privileges such as <code>read</code> and <code>view_index_metadata</code>.</p>
<p>Run this in Dev Tools to create an investigation-scoped role:</p>
<pre><code>POST /_security/role/agent-builder-observability-investigator
{
  "cluster": ["monitor_inference"],
  "indices": [
    {
      "names": ["logs-*", "metrics-*", "traces-*"],
      "privileges": ["read", "view_index_metadata"]
    }
  ],
  "applications": [
    {
      "application": "kibana-.kibana",
      "privileges": ["feature_agentBuilder.read", "feature_actions.read"],
      "resources": ["space:default"]
    }
  ]
}
</code></pre>
<p>This role gives the agent enough access to inspect telemetry while keeping production-changing actions out of scope. The <code>monitor_inference</code> cluster privilege is what lets the agent use the inference endpoints behind Agent Builder, and it grants no data access on its own.</p>
<p>When you add a custom tool, describe it in operational language, because the tool description is part of how the agent decides when to call it. Prefer descriptions like this:</p>
<pre><code>Use this tool to search checkout service logs for errors in a bounded time range.

Required inputs:
- service_name
- environment
- start_time
- end_time

Return:
- matching log samples
- error counts by message
- affected host and pod names when present
</code></pre>
<p>A narrow tool description is much safer than a broad tool that says "search all logs for anything relevant." The agent gets a clear contract, and reviewers can reason about what the tool can and cannot do.</p>
<h2 id="useelasticworkflowsforincidentresponseautomation">Use Elastic Workflows for incident response automation</h2>
<p>Once the investigation path is useful, we can add Workflows for the actions that should be repeatable. With Workflows the control plane becomes operational because it can query more context, ask an agent to summarize evidence, open a case, notify a channel, or call a remediation endpoint. The key is that each step is explicit.</p>
<p>The Workflows editor gives you a validation loop before you save or run anything. Use it to catch syntax issues before the workflow writes to Cases or calls any action.</p>
<p>Go to <strong>Workflows &gt; Create workflow</strong> and paste the following:</p>
<pre><code>name: obs-labs-checkout-control-plane
description: Checkout regression investigation with Agent Builder and case creation.
tags: ["sre-control-plane", "agent-builder", "workflows"]

triggers:
  - type: manual

inputs:
  - name: service_name
    type: string
    default: "checkout-api"
  - name: alert_summary
    type: string
    default: "Checkout API p95 latency increased above 2s and HTTP 500s rose in the last 15 minutes after deployment 2026.07.09.1."

steps:
  - name: rca_analysis
    type: ai.agent
    agent-id: elastic-ai-agent
    create-conversation: true
    with:
      message: |
        Investigate this checkout incident as an SRE would.

        Service: {{ inputs.service_name }}
        Alert: {{ inputs.alert_summary }}

        Search the available logs, traces, and metrics for this service.
        Compare the window before and after the most recent deployment.

        Return a concise likely cause, supporting evidence, confidence, and next safe action.
        If the evidence is incomplete, say what data is missing.

  - name: case_title
    type: ai.agent
    agent-id: elastic-ai-agent
    with:
      conversation_id: "{{ steps.rca_analysis.output.conversation_id }}"
      message: "Produce a short case title for this incident. Output only the title."

  - name: case_description
    type: ai.agent
    agent-id: elastic-ai-agent
    with:
      conversation_id: "{{ steps.rca_analysis.output.conversation_id }}"
      message: "Produce a concise case description. Output only the description."

  - name: create_case
    type: cases.createCase
    with:
      title: "{{ steps.case_title.output.message }}"
      description: "{{ steps.case_description.output.message }}"
      owner: "observability"
      severity: "medium"
      tags: ["sre-control-plane", "agent-builder", "workflows"]

  - name: add_agent_analysis
    type: cases.addComment
    with:
      case_id: "{{ steps.create_case.output.case.id }}"
      comment: |
        ## Agent Builder RCA

        {{ steps.rca_analysis.output.message }}

        Agent conversation: {{ kibanaUrl }}/app/agent_builder/conversations/{{ steps.rca_analysis.output.conversation_id }}
</code></pre>
<p>Each step feeds the next one through its output. <code>ai.agent</code> steps emit a <code>message</code> with the model's text and a <code>conversation_id</code>, and <code>cases.createCase</code> emits the new <code>case.id</code>. Those three fields are the whole contract:</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd791f884d5887901/6a8ea080f61d6e99f39d4f76/07-workflow-steps.png" alt="Workflow steps: rca_analysis feeds case_title and case_description through a shared conversation, then a case is created and the analysis added as a comment" /></p>
<p>This workflow doesn't restart anything. It asks Agent Builder to investigate, reuses the same conversation to generate the case title and description, creates an Observability case, and writes the agent analysis back as a case comment.</p>
<p>Two details are worth calling out. The <code>create-conversation: true</code> flag on the first step is what makes the next two steps cheap: <code>case_title</code> and <code>case_description</code> pass the same <code>conversation_id</code>, so the agent already has the investigation in context and doesn't repeat the queries. And we use a manual trigger with a default <code>alert_summary</code> so you can run the sequence before attaching it to a live alert rule. In production, you'd switch the trigger to <code>alert</code> and attach the workflow to the rule that owns that incident class.</p>
<p>Run the workflow with the play button. Our run took 3 minutes and 22 seconds, with <code>rca_analysis</code>, <code>case_title</code>, <code>case_description</code>, <code>create_case</code>, and <code>add_agent_analysis</code> all marked as successful. Almost all of that is the investigation itself: <code>rca_analysis</code> alone took 3 minutes and 5 seconds, while the two case writes finished in about a second each.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt0de6f806a10502c9/6a8ea083bc5bb390c5f93c4c/08-workflow-execution.png" alt="Workflow execution view with the five steps successful in 3 minutes and 22 seconds" /></p>
<p>The workflow then wrote an Observability case. The case list shows one open case with the generated checkout title, our tags, medium severity, and one comment.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt2bac0ad2a9ed3c6b/6a8ea086f656457ab454b8bf/09-cases-list.png" alt="Observability Cases list showing one open case created by the workflow" /></p>
<p>The case detail is the audit artifact for the investigation. It records the evidence considered, the affected hosts and deployment version, the likely error type, the agent's confidence, and any missing signals.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf370e642a67d50e5/6a8ea0eabc5bb3171ff93c5c/10-case-detail.png" alt="Case detail with the generated description and the Agent Builder RCA comment" /></p>
<p>A useful operational control plane surfaces the limits of its evidence instead of turning uncertainty into a confident claim. If your agent never reports missing data or a lower confidence, that's a signal to tighten the skill instructions, not a sign that every investigation went well.</p>
<p>The read-only automated root cause analysis pattern improves response quality without changing the affected service. Add remediation only when the action is well understood, narrowly scoped, and paired with verification and rollback: clearing one cache key, restarting one worker, shifting traffic away from one unhealthy instance, or running a pre-approved maintenance task.</p>
<h3 id="turnelasticworkflowsintoagenttools">Turn Elastic Workflows into agent tools</h3>
<p><a href="https://www.elastic.co/docs/explore-analyze/ai-features/agent-builder/tools/workflow-tools">Workflow tools</a> let an Agent Builder conversation trigger an Elastic Workflow and use its output. This is the bridge from "the agent recommended a next step" to "the agent can offer a known action."</p>
<p>A workflow tool should have a narrow description:</p>
<pre><code>Use this tool only when checkout errors are caused by connection pool exhaustion on a single worker.

The workflow drains and recycles the connection pool for one worker, then verifies that the worker resumes successful requests.

Required input:
- host_name

Do not use this tool for database outages, deploy regressions affecting all hosts, or multi-host failures.
</code></pre>
<p>The description matters because it sets the agent's selection boundary. Note how the last line excludes the very scenario we just investigated: our incident hit both hosts and was caused by a deploy, so the agent should not offer this tool. That's the point. A workflow tool that matches every incident is a workflow tool with no boundary.</p>
<p>The workflow still owns execution. The agent doesn't need to know how to recycle the pool. It only needs to recognize when a known workflow may apply, collect the required input, and present the action to the engineer.</p>
<h2 id="howdoyoustopanaiagentfromchangingproduction">How do you stop an AI agent from changing production?</h2>
<p>An SRE control plane should be built around blast-radius control, which means every action path needs a clear boundary. Use these checks before exposing a workflow as an agent tool:</p>
<p>| Check | Importance |
| :---- | :---- |
| Read-only first | Proves the investigation path before adding production action |
| Narrow input schema | Prevents vague prompts from becoming vague actions |
| Explicit permissions | Keeps the agent limited to the current user's allowed data and actions |
| Dry-run or case-only mode | Lets teams review outputs before enabling remediation |
| Human review for risky steps | Keeps judgment in the loop where impact is high |
| Post-action verification | Confirms that the workflow improved the service instead of only executing a command |</p>
<p>For the review boundary itself, Workflows gives you <code>wait</code> steps, timeouts, and execution history, so a risky path can pause for an approval and still leave an audit trail.</p>
<p>An agent can help gather evidence and propose the next step, but production action should stay inside known workflow paths.</p>
<h3 id="validateagainstoneincidentclassfirst">Validate against one incident class first</h3>
<p>For a real rollout, validate the control plane against one recurring incident class. Track whether the agent finds the right evidence, whether the workflow output is complete enough for review, and whether engineers trust the recommended next step.</p>
<p>Use a simple validation plan:</p>
<ol>
<li>Pick one alert type with a known runbook.</li>
<li>Build a read-only investigation skill for that alert.</li>
<li>Add one or two query tools with scoped index permissions.</li>
<li>Run the agent against historical incidents and compare its summary with the actual case notes.</li>
<li>Add a case-creation workflow and review the output with the owning SRE team.</li>
<li>Only then consider a workflow tool that performs a bounded remediation step.</li>
</ol>
<p>The main failure mode is not that the model gives an imperfect summary. It's granting broad action before the investigation path is proven. Keep the first version boring, scoped, and reviewable.</p>
<h2 id="conclusion">Conclusion</h2>
<p>What we covered:</p>
<ul>
<li>An SRE control plane combines state (telemetry in Elasticsearch), policy (permissions and review boundaries), and action (known tools and workflows).</li>
<li>Agent Builder handles reasoning over messy context, while Workflows handles deterministic execution, and the two can call each other.</li>
<li>A read-only investigation skill turns a runbook into a repeatable investigation path that records uncertainty instead of hiding it.</li>
<li>Scoped roles with <code>read</code> and <code>view_index_metadata</code> on <code>logs-*</code>, <code>metrics-*</code>, and <code>traces-*</code> keep the agent useful without letting it change production.</li>
<li>Reusing a <code>conversation_id</code> across <code>ai.agent</code> steps lets later steps build on the investigation instead of repeating it.</li>
<li>A case-only workflow gives you the full audit artifact before you enable any remediation.</li>
<li>Tool descriptions are a security boundary, not documentation, because they decide when the agent offers an action.</li>
</ul>
<h2 id="resources">Resources</h2>
<ul>
<li><a href="https://www.elastic.co/docs/solutions/observability/ai/agent-builder-observability">Agent Builder for Observability</a></li>
<li><a href="https://www.elastic.co/docs/explore-analyze/ai-features/agent-builder/skills">Agent Builder skills</a></li>
<li><a href="https://www.elastic.co/docs/explore-analyze/ai-features/agent-builder/tools">Agent Builder tools</a></li>
<li><a href="https://www.elastic.co/docs/explore-analyze/ai-features/agent-builder/tools/workflow-tools">Workflow tools</a></li>
<li><a href="https://www.elastic.co/docs/explore-analyze/workflows/use-cases/ai-augmented-workflows">AI-augmented workflows</a></li>
<li><a href="https://www.elastic.co/docs/explore-analyze/workflows/use-cases/observability/root-cause-analysis">Root cause analysis workflow for observability alerts</a></li>
</ul>]]></content:encoded>
    <link>https://www.elastic.co/observability-labs/blog/automated-root-cause-analysis-agent-builder</link>
    <guid isPermaLink="false">automated-root-cause-analysis-agent-builder</guid>
    <category><![CDATA[AI]]></category>
    <category><![CDATA[Agentic Observability]]></category>
    <dc:creator><![CDATA[Jeffrey Rengifo]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltcd0b2a7ebf36b14d/6a8ea0ee73006e52f1d8d9b3/01-header.png" length="0" type="image/png"/>
    <pubDate>Tue, 25 Aug 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[One edit, every dashboard updated: managing Kibana observability at scale with Terraform]]></title>
    <description><![CDATA[Define your golden-signals panels once in a shared HCL library and use for_each to generate every team's dashboard, with drift detection and git rollback built in.]]></description>
    <content:encoded><![CDATA[<p>Elastic ships a Kibana Dashboards API and a native Terraform resource for managing dashboards as code. This capability was introduced as a technical preview in Elastic 9.4 and was made generally available in Elastic 9.5. You define a golden signals panel library once in HCL, and <code>for_each</code> generates a dashboard for every team from it. When you need to change an error threshold, a panel layout or a query, one pull request updates every team at once. If something drifts or breaks, you roll back with git.</p>
<h2 id="whymanagingobservabilitydashboardsbyhandbreaksdownatscale">Why managing observability dashboards by hand breaks down at scale</h2>
<p>Large organizations often end up with hundreds of dashboards. Teams build similar panels and maintain them using the Kibana UI.</p>
<p>When a small change comes in (a panel rename, a field fix, a new error threshold), there is no easy way to apply it across all of them. You either open each dashboard and edit it in the UI one by one, or you export the NDJSON, run a string replace, and re-import it.</p>
<h2 id="dashboardsarecodenow">Dashboards are code now</h2>
<p>Elastic ships a <a href="https://www.elastic.co/search-labs/blog/kibana-dashboards-as-code-terraform-api">typed Kibana Dashboards API</a> and a native <a href="https://registry.terraform.io/providers/elastic/elasticstack/latest/docs/resources/kibana_dashboard"><code>elasticstack_kibana_dashboard</code></a> Terraform resource. You define a dashboard in an HCL file and then manage versions and changes as if it was regular code.</p>
<h2 id="goldensignalsdashboardonedefinitionforeveryteam">Golden signals dashboard: one definition for every team</h2>
<p>The platform team owns a standard dashboard built on the four <a href="https://sre.google/sre-book/monitoring-distributed-systems/#xref_monitoring_golden-signals">golden signals</a>: latency, traffic, errors, and saturation. Every team should get that standard, and some teams add a panel or two of their own.</p>
<p>We want one definition of the standard, each team's dashboard generated from it, and a single change that reaches every team.</p>
<h2 id="prerequisites">Prerequisites</h2>
<ul>
<li>An Elastic Cloud deployment or self-managed cluster running <strong>Elastic 9.4</strong> or newer, or an <strong>Elastic Cloud Serverless</strong> project</li>
<li><strong>Terraform</strong> installed</li>
<li>An Elasticsearch API key</li>
</ul>
<p>The full Terraform configuration, the seed script, and the captured <code>terraform plan</code> outputs used in this article are available in the <a href="https://github.com/Delacrobix/Observability-dashboards-as-code-one-standard-across-every-team-with-Terraform">companion repo</a>.</p>
<h2 id="configuretheelasticterraformprovider">Configure the Elastic Terraform provider</h2>
<p>Create a <a href="https://github.com/Delacrobix/Observability-dashboards-as-code-one-standard-across-every-team-with-Terraform/blob/main/provider.tf"><code>provider.tf</code></a> next to the rest of your Terraform files:</p>
<pre><code>terraform {
  required_providers {
    elasticstack = {
      source  = "elastic/elasticstack"
      version = "~&gt; 0.11"
    }
  }
}

variable "elasticsearch_endpoint" {
  type = string
}

variable "elasticsearch_api_key" {
  type      = string
  sensitive = true
}

variable "kibana_endpoint" {
  type = string
}

variable "kibana_api_key" {
  type      = string
  sensitive = true
}

provider "elasticstack" {
  elasticsearch {
    endpoints = [var.elasticsearch_endpoint]
    api_key   = var.elasticsearch_api_key
  }
  kibana {
    endpoints = [var.kibana_endpoint]
    api_key   = var.kibana_api_key
  }
}
</code></pre>
<p>Provide your credentials through a local <code>terraform.tfvars</code> file (and add it to <code>.gitignore</code> so the keys never reach the repo):</p>
<pre><code>elasticsearch_endpoint = "https://...es.region.cloud.es.io"
elasticsearch_api_key  = "..."
kibana_endpoint        = "https://...kb.region.cloud.es.io"
kibana_api_key         = "..."
</code></pre>
<p>You can use the same API key for both <code>elasticsearch_api_key</code> and <code>kibana_api_key</code> as long as it has dashboard write privileges in the target space.</p>
<p>Then initialize the working directory:</p>
<pre><code>terraform init
</code></pre>
<h2 id="defineasingleteamkibanadashboardinhcl">Define a single-team Kibana dashboard in HCL</h2>
<p>Start with a baseline dashboard for a single team. Panels sit on a 48-column grid, and each one is a <a href="https://www.elastic.co/docs/explore-analyze/visualize/lens">Lens</a> visualization configured inline. Use <code>config_json</code> for KPI tiles (it exposes secondary metrics and value coloring) and <code>xy_chart_config</code> for time-series charts.</p>
<p>Add the baseline resource to a new <a href="https://github.com/Delacrobix/Observability-dashboards-as-code-one-standard-across-every-team-with-Terraform/blob/main/dashboards.tf"><code>dashboards.tf</code></a>:</p>
<pre><code>resource "elasticstack_kibana_dashboard" "golden_signals" {
  title            = "Golden Signals - payments"
  description      = "Latency, traffic, errors"
  query            = { language = "kql", text = "" }
  refresh_interval = { pause = false, value = 60000 }
  time_range       = { from = "now-15m", to = "now" }

  panels = [
    {
      type = "vis"
      grid = { x = 0, y = 0, w = 12, h = 5 }
      config_json = jsonencode({
        type        = "metric"
        data_source = {
          type  = "esql"
          query = "FROM logs-payments-* | STATS `5xx errors` = COUNT(CASE(status &gt;= 500, 1, null))"
        }
        metrics = [{ type = "primary", column = "5xx errors" }]
      })
    },
    # More panels follow the same shape: other metric tiles, xy_chart_config line charts, and a breakdown datatable. See the companion repo for the full file.
  ]
}
</code></pre>
<p>Each panel sets a <code>type</code> and <code>grid</code> position, then picks one chart kind. KPI tiles serialize the whole Lens config into <code>config_json</code>; the <a href="https://www.elastic.co/docs/reference/query-languages/esql">ES|QL</a> query lives under <code>data_source</code> and the metric column is referenced by name in <code>metrics[*].column</code>. The dashboard time picker already scopes ES|QL panels, so the query needs no explicit <code>@timestamp</code> range filter.</p>
<h3 id="previewkibanadashboardchangeswithterraformplan">Preview Kibana dashboard changes with terraform plan</h3>
<p>Run <code>terraform plan</code> to see what Terraform will create:</p>
<pre><code>terraform plan
</code></pre>
<p>The plan output lists the new <code>elasticstack_kibana_dashboard.golden_signals</code> resource and every attribute it will set: the top-level dashboard fields and one entry per panel with its grid position, chart kind, and data source.</p>
<pre><code>Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # elasticstack_kibana_dashboard.golden_signals will be created
  + resource "elasticstack_kibana_dashboard" "golden_signals" {
      + description      = "Latency, traffic, errors"
      + title            = "Golden Signals - payments"
      + query            = { language = "kql", text = "" }
      + refresh_interval = { pause = false, value = 60000 }
      + time_range       = { from = "now-15m", to = "now" }
      + panels           = [
          # Every panel described in full: KPI tiles (config_json),
          # line charts (xy_chart_config), and the breakdown datatable.
        ]
    }

Plan: 1 to add, 0 to change, 0 to destroy.
</code></pre>
<p>Reviewing the plan is your last check before anything ships to Kibana.</p>
<p>Don't apply yet. The next section extends the file with per-team dashboards, and then a single <code>terraform apply</code> ships everything.</p>
<h2 id="generateperteamobservabilitydashboardsfromasharedpanellibrary">Generate per-team observability dashboards from a shared panel library</h2>
<p>On top of the baseline, each team gets the standard set of panels, with the option to add a few of their own. Hardcoding one resource per team does not scale. Instead, define a panel library and a teams map as <code>locals</code>, then build the dashboards with <code>for_each</code>. Each library entry describes a chart kind, a title, and the data it needs; the resource emits the right Lens block (<code>config_json</code> for metric tiles, <code>xy_chart_config</code> for line charts) based on <code>chart_type</code>.</p>
<p>Replace the contents of <a href="https://github.com/Delacrobix/Observability-dashboards-as-code-one-standard-across-every-team-with-Terraform/blob/main/dashboards.tf"><code>dashboards.tf</code></a> with:</p>
<pre><code>locals {
  panel_library = {
    errors = {
      chart_type     = "metric"
      title          = "Error rate"
      esql_query_tpl = "FROM {idx} | STATS `5xx errors` = COUNT(CASE(status &gt;= 500, 1, null))"
      esql_column    = "5xx errors"
    }
    saturation = {
      chart_type     = "metric"
      title          = "Saturation (CPU)"
      # Saturation reads from the metrics TSDB, so this query is not parameterized by {idx}.
      esql_query_tpl = "TS metrics-payments-* | STATS avg_cpu = AVG(cpu.pct)"
      esql_column    = "avg_cpu"
    }
    latency = {
      chart_type = "xy"
      title      = "Latency p95"
      x_json     = jsonencode({
        operation          = "date_histogram"
        field              = "@timestamp"
        suggested_interval = "auto"
      })
      y_json = jsonencode({
        operation  = "percentile"
        field      = "duration_ms"
        percentile = 95
      })
    }
    # ... more entries (traffic, cart_value) in the companion repo.
  }

  teams = {
    payments = {
      index  = "logs-payments-*"
      panels = ["errors", "saturation", "latency", "traffic"]
    }
    checkout = {
      index  = "logs-checkout-*"
      panels = ["errors", "cart_value", "latency", "traffic"]
    }
  }
}

resource "elasticstack_kibana_dashboard" "golden_signals" {
  for_each         = local.teams
  title            = "Golden Signals - ${each.key}"
  description      = "Latency, traffic, and errors for the ${each.key} service"
  query            = { language = "kql", text = "" }
  refresh_interval = { pause = false, value = 60000 }
  time_range       = { from = "now-15m", to = "now" }

  sections = [
    {
      title     = "KPIs"
      grid      = { y = 0 }
      collapsed = false
      panels = [
        for i, p in [for q in each.value.panels : q if local.panel_library[q].chart_type == "metric"] : {
          type        = "vis"
          grid        = { x = (i % 4) * 12, y = 0, w = 12, h = 5 }
          config_json = jsonencode({ ... }) # one metric tile per panel; see the companion repo for the full config
        }
      ]
    },
    {
      title     = "Trends"
      grid      = { y = 1 }
      collapsed = false
      panels = [
        for i, p in [for q in each.value.panels : q if local.panel_library[q].chart_type == "xy"] : {
          type       = "vis"
          grid       = { x = (i % 3) * 16, y = 0, w = 16, h = 10 }
          vis_config = { by_value = { xy_chart_config = { ... } } }
        }
      ]
    },
    # A third "Breakdown" section holds the request-by-status datatable. See the companion repo.
  ]
}
</code></pre>
<p>Adding a team is one entry in <code>teams</code>. Adding a panel to every team is one entry in <code>panel_library</code> and one reference per team. The full config (data source ES|QL queries, metrics, layers, axis defaults, and legend placement) lives in <a href="https://github.com/Delacrobix/Observability-dashboards-as-code-one-standard-across-every-team-with-Terraform/blob/main/dashboards.tf"><code>dashboards.tf</code></a>.</p>
<p>The saturation panel queries the metrics data stream with the ES|QL <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/ts"><code>TS</code></a> command, which is designed for TSDB. For the query to work, data streams matching <code>metrics-payments-*</code> must use <code>time_series</code> mode, so the configuration also ships an index template (<a href="https://github.com/Delacrobix/Observability-dashboards-as-code-one-standard-across-every-team-with-Terraform/blob/main/metrics_tsdb.tf"><code>metrics_tsdb.tf</code></a>) that enables that.</p>
<h3 id="applydashboardsascodetokibanawithterraformapply">Apply dashboards as code to Kibana with terraform apply</h3>
<p>Run <code>terraform plan</code> to confirm both team dashboards (payments and checkout) will be created then apply:</p>
<pre><code>terraform apply
</code></pre>
<p>Open Kibana and you'll see one <strong>Golden Signals</strong> dashboard per team, each backed by its own index pattern.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd3d461fb8f841d32/6a85cdb4331d7a7965c3180b/image2.jpg" alt="" /></p>
<h2 id="dashboardsascodeinthegitopsloopreviewchangesinpullrequests">Dashboards as code in the GitOps loop: review changes in pull requests</h2>
<p>Dashboards are now an artifact in version control, like the rest of your infrastructure.</p>
<p>You edit the library or a team's selection, open a pull request, your reviewer reads the <code>terraform plan</code> diff and sees which dashboards change.</p>
<p>For example, say you tighten the "critical error" threshold from <code>status &gt;= 500</code> to <code>status &gt;= 503</code> in <code>panel_library.errors.esql_query_tpl</code>. Running <code>terraform plan</code> shows the change reaching both teams at once:</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt6a891e92c1b74fba/6a85cdb79a32f15bbda7e038/image3.jpg" alt="" /></p>
<p><em>Note: Full output in <a href="https://github.com/Delacrobix/Observability-dashboards-as-code-one-standard-across-every-team-with-Terraform/blob/main/outputs/terraform-plan-update.txt"><code>terraform-plan-update.txt</code></a>.</em></p>
<p>A single edit to <code>panel_library.errors</code> propagates to every team that references it. After the PR merges, it's time to run <code>terraform apply</code>.</p>
<p>After the apply finishes, refresh the dashboards in Kibana and the new threshold is in effect:</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4a1490023fd81294/6a85cdba0782902a5a3217c4/image4.jpg" alt="" /></p>
<h2 id="detectdashboarddriftandrollbackwithgit">Detect dashboard drift and roll back with git</h2>
<p>If someone edits a dashboard using the UI, the next <code>terraform plan</code> shows the difference, because the code and the live state no longer match.</p>
<p>To see this in action, open <code>Golden Signals - payments</code> in Kibana, rename the <strong>Latency p95</strong> panel to <code>Latency p95 (EDITED)</code>, and save the dashboard.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf100d616be5f89de/6a85cdbc9bf99401610a05c1/image5-small.jpg" alt="" /></p>
<p>Then run <code>terraform plan</code>:</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt55cc1662818757b8/6a85cdbfeaf245b4d9a49fa5/image6-small.jpg" alt="" /></p>
<p>Terraform reads the panel title from the live dashboard, compares it against the code, and proposes reverting the UI rename. You decide whether to keep the change (update the code to match) or revert it by running <code>terraform apply</code>.</p>
<p>You can commit the new version, or rollback one or many versions using git.</p>
<p>Replaying the earlier example: if you reopen the PR that changed <code>panel_library.errors</code> to broaden the error threshold and add a clearer title, <code>git diff dashboards.tf</code> shows the entire intent in two lines:</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt6c138d8078b2634c/6a85cdc2bc5bb33835f81b45/image7.jpg" alt="" /></p>
<p>Every team that references <code>errors</code> picks up the new threshold on the next <code>terraform apply</code>, and reverting that commit rolls the change back across all of them at once.</p>
<h2 id="wrapup">Wrap up</h2>
<p>Managing Kibana observability dashboards by hand does not scale past a few teams. With the Kibana Dashboards API and Terraform, you define a standard once, compose each team's dashboard from a shared library, and review every change in a pull request. One edit reaches every team, and you can roll back by reverting a commit.</p>
<p>The proposed file structure is only one of many ways you can organize your dashboards depending on how much information they share.</p>
<h2 id="nextsteps">Next steps</h2>
<ul>
<li><a href="https://www.elastic.co/search-labs/blog/kibana-dashboards-as-code-terraform-api">Kibana Dashboards as code with Terraform</a></li>
<li><a href="https://registry.terraform.io/providers/elastic/elasticstack/latest/docs/resources/kibana_dashboard"><code>elasticstack_kibana_dashboard</code> resource reference</a></li>
<li><a href="https://registry.terraform.io/providers/elastic/elasticstack/latest/docs">Elastic Stack Terraform provider documentation</a></li>
<li><a href="https://www.elastic.co/docs/api/doc/kibana/group/endpoint-dashboards">Kibana Dashboards API documentation</a></li>
<li><a href="https://www.elastic.co/docs/reference/query-languages/esql">ES|QL reference</a></li>
</ul>]]></content:encoded>
    <link>https://www.elastic.co/observability-labs/blog/kibana-observability-dashboards-terraform</link>
    <guid isPermaLink="false">kibana-observability-dashboards-terraform</guid>
    <category><![CDATA[Metrics]]></category>
    <category><![CDATA[Logs Analytics]]></category>
    <dc:creator><![CDATA[Jeffrey Rengifo]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltdbc974846f9b410e/6a85cdc4342d69301d21b147/image1.jpg" length="0" type="image/jpeg"/>
    <pubDate>Mon, 03 Aug 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Skip writing alert rules: 6 ready-made ES|QL templates ship inside the NGINX OTel integration]]></title>
    <description><![CDATA[Elastic integrations come with alerting rule templates, each one an ES|QL query with a threshold already set. Create Elasticsearch alert rules in minutes, tune them to your traffic, and catch silent data streams early.]]></description>
    <content:encoded><![CDATA[<p>The NGINX OpenTelemetry Assets integration ships six <a href="https://www.elastic.co/docs/reference/fleet/alerting-rule-templates">alerting rule templates</a>. Each one is an <a href="https://www.elastic.co/docs/reference/query-languages/esql">ES|QL</a> query with a threshold already tuned. Install the integration, create a rule from one of the templates, and adjust the threshold to match your traffic. You get working alerts in minutes instead of writing them from scratch. This walkthrough covers the full setup, threshold tuning, and how to use an idle data streams rule to catch a service that stops sending data.</p>
<h2 id="prerequisitesforelasticintegrationalertingruletemplates">Prerequisites for Elastic integration alerting rule templates</h2>
<p>Elastic Stack 9.4.0 or later.</p>
<p><em>Alerting rule templates have been available since 9.2.1, under the integration <strong>Assets</strong> tab. This article covers three things that need 9.4.0: the dedicated <strong>Alerting</strong> tab, idle data streams rules, and the NGINX OpenTelemetry Assets package, which is in technical preview.</em></p>
<h2 id="step1sendnginxlogsandmetricstoelasticsearchwithopentelemetry">Step 1: Send NGINX logs and metrics to Elasticsearch with OpenTelemetry</h2>
<p>First, get NGINX metrics and logs into Elasticsearch.</p>
<p>Enable the NGINX <code>stub_status</code> module and make the access and error logs readable by the collector. Then, configure an <a href="https://www.elastic.co/docs/reference/opentelemetry">EDOT</a> or upstream OpenTelemetry Collector with the <code>nginx</code> and <code>filelog</code> receivers to export metrics and logs to Elasticsearch.</p>
<p>The <a href="https://www.elastic.co/docs/reference/integrations/nginx_otel">integration setup</a> has the full receiver and pipeline configuration.</p>
<p>If you want to reproduce this example, you can use the <a href="https://github.com/Delacrobix/Creating-alerts-from-OOTB-alerting-template">companion repository</a>.</p>
<p>Both signals matter for alerting, and each group of templates reads a different data stream:</p>
<ul>
<li>The <strong>log-based</strong> templates (4xx and 5xx error rates, error log spike) query <code>logs-nginx.access.otel-*</code> and <code>logs-nginx.error.otel-*</code>, which come from the <code>filelog</code> receiver.</li>
<li>The <strong>metric-based</strong> templates (active connections, dropped connections) query <code>metrics-nginxreceiver.otel-*</code>, which comes from the <code>nginx</code> receiver.</li>
</ul>
<p>This is easy to get wrong: the Fleet <a href="https://www.elastic.co/docs/solutions/observability/infra-and-hosts/collect-nginx-data-otel-integration-fleet-managed"><strong>Nginx (OpenTelemetry)</strong> input package</a> collects <code>stub_status</code> metrics only. Its companion for logs is the classic Nginx integration, which writes ECS-based <code>nginx.access</code> and <code>nginx.error</code> datasets, not the <code>*.otel-*</code> data streams the log-based templates query. If you rely on that pairing alone, the log-based rules have nothing to evaluate and silently never fire. Run the <code>filelog</code> receiver too, not just the <code>nginx</code> receiver.</p>
<h2 id="step2installthenginxopentelemetryassetsintegration">Step 2: Install the NGINX OpenTelemetry Assets integration</h2>
<p>NGINX OpenTelemetry Assets is a content-only package. It ships the dashboards, alerting rule templates, and SLO templates, but it does not collect data itself. The data comes from the collector you set up in Step 1.</p>
<p>You don't need to install it by hand. Once the NGINX OTel data from Step 1 starts arriving, Elastic detects it and installs the Assets package for you, which takes a minute or two. Confirm it under <strong>Management</strong> &gt; <strong>Integrations</strong> &gt; <strong>Installed integrations</strong>, where <code>NGINX OpenTelemetry Assets</code> should appear.</p>
<h2 id="step3createelasticsearchalertrulesfromaruletemplate">Step 3: Create Elasticsearch alert rules from a rule template</h2>
<p>Open the integration and select the <strong>Alerting</strong> tab.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt06c9c544b653b81c/6a85c98d43c0b7f8172f05f0/02-alerting-tab.png" alt="The Alerting tab of the NGINX OpenTelemetry Assets integration listing its rule templates" /></p>
<p>This package ships six templates: high 4xx and 5xx error rates, high active connections, an error log spike, dropped connections, and a generic <code>High error rate by service</code> template that points at a placeholder <code>logs-myservicereceiver.otel-*</code> index for you to repoint and rename. The five NGINX rules run ES|QL every minute and group results by <code>host.name</code>, so an alert points at the host with the problem. The generic template groups by <code>service.name</code> instead, since it is meant to be repointed at whichever service you choose.</p>
<p>Select a template, for example <code>[Nginx OTel] High 5xx error rate</code>. Kibana opens a prefilled <strong>Create rule</strong> form built on an <a href="https://www.elastic.co/docs/explore-analyze/alerts-cases/alerts/rule-type-es-query">Elasticsearch query rule</a>. It runs the template's ES|QL on a schedule and alerts when the query returns rows.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt2d9547363761de4b/6a85c991bc5bb36529f81acd/03-create-rule-form.png" alt="The prefilled Create rule form for the High 5xx error rate template" /></p>
<p>The query looks like this:</p>
<pre><code>FROM logs-nginx.access.otel-*
// Flag each access log entry as a server error (5xx) or not
| EVAL is_5xx = CASE(http.response.status_code &gt;= 500, 1, 0)
// Aggregate total requests and 5xx count per NGINX host
| STATS total = COUNT(*), errors_5xx = SUM(is_5xx) BY host.name
// Minimum sample size to avoid noisy low-traffic hosts
| WHERE total &gt; 50
// Calculate 5xx error rate as a percentage
| EVAL error_rate_pct = ROUND(TO_DOUBLE(errors_5xx) / TO_DOUBLE(total) * 100.0, 2)
// Alert threshold: adjust to tune sensitivity
| WHERE error_rate_pct &gt; 5.0
| SORT error_rate_pct DESC
| LIMIT 10
</code></pre>
<p>It counts requests and 5xx responses per host, keeps hosts with enough traffic to matter, and returns those above five percent.</p>
<p>Three things to get right while the form is open:</p>
<ul>
<li><strong>Send data first.</strong> ES|QL validates column names against the indices that exist when the query runs. Open a template before any NGINX data has been ingested and the editor reports <code>Unknown column "http.response.status_code"</code> and the form shows errors. Once data is flowing (Step 1), the same query validates and the error clears, so collect data before you create the rule.</li>
<li><strong>Set the time field to <code>@timestamp</code>.</strong></li>
<li><strong>Leave "Create an alert for each row" selected.</strong> Because the query groups by <code>host.name</code>, this makes every affected host raise its own alert.</li>
</ul>
<p>Add a <a href="https://www.elastic.co/docs/deploy-manage/manage-connectors">connector</a> and an action so the alert reaches Slack, email, or PagerDuty, then save and enable the rule.</p>
<h2 id="step4tunealertingruletemplatethresholdsinesql">Step 4: Tune alerting rule template thresholds in ES|QL</h2>
<p>The thresholds are starting points, so confirm them against your own traffic. The threshold lives in the ES|QL <code>WHERE</code> clause.</p>
<p>To make the rule stricter, change <code>error_rate_pct &gt; 5.0</code> to <code>error_rate_pct &gt; 2.0</code>. To require more traffic before it fires, raise <code>total &gt; 50</code>. Use <strong>Test query</strong> in the rule form to confirm the edited query parses and returns rows before you save.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt8b66296ca6cd70c8/6a85c994bc5bb3f376f81ad1/04-test-query.png" alt="Test query results after editing the threshold in the ES|QL query" /></p>
<p>Three more settings are worth a look:</p>
<ul>
<li><strong>Time window</strong>: the look-back period the query runs over. A shorter window reacts faster but is noisier on bursty traffic.</li>
<li><strong>Rule schedule</strong>: how often the query runs, every minute by default.</li>
<li><strong>Alert delay</strong>: the number of consecutive runs the condition must hold before an alert is created, which filters out single-run blips.</li>
</ul>
<h2 id="howdoyoudetectidledatastreamsinelasticsearch">How do you detect idle data streams in Elasticsearch?</h2>
<p>Threshold rules only fire while data keeps arriving. When an agent goes offline or an output breaks, the data stops, and a threshold rule has nothing to evaluate.</p>
<p>Many Elastic integrations include a dynamically generated <a href="https://www.elastic.co/docs/reference/fleet/alerting-rule-templates">idle data streams template</a> for exactly this case. It is named <code>[{Integration name}] Idle data streams</code> and appears in the same Alerting tab, though it is generated automatically rather than bundled with the integration. It alerts when no data is written to any of the integration's data stream patterns within a set period.</p>
<p>The NGINX OpenTelemetry packages do not include an idle data streams template, which is why no such template appears in the Alerting tab from Step 3. The end of this section covers what to do instead.</p>
<p>The default period is 24 hours, which is usually too long. A production service can go quiet for most of a day before you hear about it.</p>
<p>When you create the rule, drop the period to match how fast you need to know. Fifteen minutes to one hour works for a critical service. For a batch job, set a period comfortably longer than its run interval, so the quiet gaps between runs do not trigger it.</p>
<p>So why is this example left out? The template is generated from the data stream patterns an integration defines, and it is not generated for input-only packages. A content-only package like NGINX OpenTelemetry Assets defines no data streams of its own either. To catch silence in a collector-based setup like this, recreate the rule by hand with an <a href="https://www.elastic.co/docs/explore-analyze/alerts-cases/alerts/rule-type-es-query">Elasticsearch query rule</a>. Use the query DSL or KQL variant rather than ES|QL, because an ES|QL rule fires on returned rows and so cannot alert on the <em>absence</em> of data. Point it at the OTel data streams (<code>logs-nginx.access.otel-*</code>, or <code>metrics-nginxreceiver.otel-*</code>) and set the condition to fire when the number of matching documents <strong>is below 1</strong> over a window of, say, the last 15 minutes. That reproduces what an idle data streams template does, scoped to the data streams your collector writes.</p>
<h2 id="getstartedwithelasticintegrationalertingruletemplates">Get started with Elastic integration alerting rule templates</h2>
<p>Alerting rule templates turn alert setup into a few steps: send data, install the integration, create a rule from a template, and adjust the threshold. Treat the bundled thresholds as defaults to confirm, not numbers to trust blindly. And where an idle data streams template is available, reduce its 24-hour default so you find out quickly when a service goes silent.</p>
<h2 id="resources">Resources</h2>
<ul>
<li><a href="https://github.com/Delacrobix/Creating-alerts-from-OOTB-alerting-template">Companion repository</a>, to generate the NGINX demo data used here</li>
<li><a href="https://www.elastic.co/docs/reference/fleet/alerting-rule-templates">Alerting rule templates</a></li>
<li><a href="https://www.elastic.co/docs/reference/integrations/nginx_otel">NGINX OpenTelemetry Assets integration</a></li>
<li><a href="https://www.elastic.co/observability-labs/blog/nginx-opentelemetry-end-to-end-tracing">End-to-end tracing for NGINX with OpenTelemetry</a></li>
<li><a href="https://www.elastic.co/docs/explore-analyze/alerts-cases/alerts/rule-type-es-query">Elasticsearch query rule</a></li>
<li><a href="https://www.elastic.co/docs/reference/fleet/alert-templates">Elastic Agent built-in alerts</a>, for monitoring the agents themselves</li>
</ul>]]></content:encoded>
    <link>https://www.elastic.co/observability-labs/blog/alerting-rule-templates-elastic-integrations</link>
    <guid isPermaLink="false">alerting-rule-templates-elastic-integrations</guid>
    <category><![CDATA[Incident Management]]></category>
    <category><![CDATA[OpenTelemetry]]></category>
    <category><![CDATA[Infrastructure Monitoring]]></category>
    <dc:creator><![CDATA[Jeffrey Rengifo]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt20e53c65a0321a28/6a85c997078290b06c321742/01-header.jpg" length="0" type="image/jpeg"/>
    <pubDate>Fri, 31 Jul 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Common ES|QL queries for Kubernetes monitoring]]></title>
    <description><![CDATA[Copy-paste ES|QL queries for Elasticsearch that turn memory pressure and error spikes into a five-minute diagnosis.]]></description>
    <content:encoded><![CDATA[<p>This post has nine ES|QL queries for diagnosing Kubernetes problems in Elasticsearch. They cover crash-looping pods, memory pressure before an OOM kill, saturated nodes, and error spikes by namespace. Every query runs against Kubernetes data collected with the <a href="https://www.elastic.co/docs/reference/opentelemetry">Elastic Distributions of OpenTelemetry (EDOT)</a> and pastes into Discover with little to no editing. Turn any of them into a dashboard panel or an alert once you've found the one you need. Jump straight to the query that matches what you're seeing or read through the whole set to get a feel for your cluster.</p>
<h2 id="whatyouneedbeforerunningthesekubernetesesqlqueries">What you need before running these Kubernetes ES|QL queries</h2>
<p>To follow the queries in this article, you need:</p>
<ul>
<li>Elasticsearch 9.2 or later.</li>
<li>EDOT Collectors running in your cluster and shipping data to Elasticsearch,
with the <code>kubeletstats</code>, <code>k8s_cluster</code>, and <code>filelog</code> receivers enabled.</li>
</ul>
<h2 id="whyesqlforkubernetesmonitoring">Why ES|QL for Kubernetes monitoring</h2>
<p>Elasticsearch <a href="https://www.elastic.co/docs/reference/query-languages/esql">ES|QL</a> is a piped query language that lets you start from a data source and then add one operation per line: filter, compute, aggregate, sort. That structure fits investigation work well because you refine a query step by step as you narrow down a problem.</p>
<h2 id="whentousetheesqltscommandforkubernetesmetrics">When to use the ES|QL TS command for Kubernetes metrics</h2>
<p>The <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/ts"><code>TS</code></a> command understands time series. This matters because most Kubernetes metrics are counters or gauges sampled over time. Using <code>TS</code> for metrics avoids the common mistake of summing cumulative counters across pods and getting a meaningless number.</p>
<h2 id="edotreceiversthesekubernetesqueriesdependon">EDOT receivers these Kubernetes queries depend on</h2>
<p>The queries below assume EDOT Collectors are running in your cluster and shipping data to Elasticsearch. A typical setup uses:</p>
<ul>
<li>The <code>kubeletstats</code> receiver for pod, container, and node resource metrics.</li>
<li>The <code>k8s_cluster</code> receiver for object state such as pod phase and container restarts.</li>
<li>The <code>filelog</code> receiver with the <code>k8sattributes</code> processor for container logs.</li>
</ul>
<p>Resource attributes follow the <a href="https://opentelemetry.io/docs/specs/semconv/">OpenTelemetry semantic conventions</a>. Pod, namespace, and node identifiers appear as <code>k8s.pod.name</code>, <code>k8s.namespace.name</code>, and <code>k8s.node.name</code>. Metric names are defined by the receiver that emits them, not by the semconv spec itself: <code>k8s.pod.phase</code> comes from the <code>k8s_cluster</code> receiver, while utilization metrics like <code>k8s.container.memory_limit_utilization</code> come from <code>kubeletstats</code>. EDOT preserves all of these names natively in Elasticsearch. The exact fields you have depend on which receivers you enabled, so treat the queries as templates and adjust field names if a metric is missing.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt3633a3879fe6bdc6/6a85c83b99083f767840f971/image2.jpg" alt="Kubernetes cluster overview" /></p>
<h2 id="exploringthecluster">Exploring the cluster</h2>
<p>Start broad. Before investigating a specific symptom, it helps to see what the cluster looks like in your data.</p>
<p>This query counts the pods reporting metrics in each namespace over the past hour.</p>
<pre><code>FROM metrics-*
| WHERE @timestamp &gt;= NOW() - 1 hour
| STATS pod_count = COUNT_DISTINCT(k8s.pod.name) BY k8s.namespace.name
| SORT pod_count DESC
</code></pre>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt808ee0ccf116be22/6a85c83df5f1a039cd2ec873/image3.jpg" alt="Pod count by namespace" /></p>
<p><code>COUNT_DISTINCT</code> collapses the many metric samples per pod into a single count per namespace. The result is a quick inventory: which namespaces are busy and whether anything you expected to be running is missing.</p>
<h2 id="findingpodrestartsandcrashloops">Finding pod restarts and crash loops</h2>
<p>Restarts are usually the first signal that something is wrong. The <code>k8s.container.restarts</code> metric is a gauge that reports the current restart count for each container.</p>
<p>This query surfaces the containers that have restarted the most in the last 24 hours.</p>
<pre><code>FROM metrics-*
| WHERE @timestamp &gt;= NOW() - 24 hours AND k8s.container.restarts IS NOT NULL
| STATS restarts = MAX(k8s.container.restarts)
    BY k8s.namespace.name, k8s.pod.name, k8s.container.name
| WHERE restarts &gt; 0
| SORT restarts DESC
| LIMIT 20
</code></pre>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1a4a354576a92c07/6a85c840982926cb3e583888/image4.jpg" alt="Container restarts leaderboard" /></p>
<p><code>MAX</code> takes the highest restart count seen in the window, which reflects the latest value of the gauge. A container with a high and climbing restart count is the textbook sign of a crash loop. Once you have the pod name, you can pivot straight to its logs with the queries further down.</p>
<h2 id="spottingpodsthatarenotrunning">Spotting pods that are not running</h2>
<p>A restart count tells you a pod recovered. The pod phase tells you whether it is healthy right now. The <code>k8s.pod.phase</code> metric encodes the phase as a number:</p>
<p>| Value | Phase |
| :---- | :---- |
| 1 | Pending |
| 2 | Running |
| 3 | Succeeded |
| 4 | Failed |
| 5 | Unknown |</p>
<p>This query uses <code>TS</code> to read the latest phase per pod and keeps anything that is not Running.</p>
<pre><code>TS metrics-*
| WHERE TRANGE(15m)
| STATS phase = MAX(LAST_OVER_TIME(k8s.pod.phase))
    BY k8s.namespace.name, k8s.pod.name
| WHERE phase != 2
| EVAL phase_name = CASE(
    phase == 1, "Pending",
    phase == 3, "Succeeded",
    phase == 4, "Failed",
    phase == 5, "Unknown",
    "Other")
| SORT phase_name
</code></pre>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltc48536c7b187dae4/6a85c84333f244bcc149f492/image5.jpg" alt="Non-running pods" /></p>
<p><code>LAST_OVER_TIME</code> picks the most recent sample for each pod's time series, so you compare the current state rather than an average. Pods stuck in <code>Pending</code> often point to scheduling problems, such as insufficient CPU or memory on the nodes. Pods in <code>Failed</code> or <code>Unknown</code> are worth an immediate look.</p>
<h2 id="catchingmemorypressurebeforetheoomkill">Catching memory pressure before the OOM kill</h2>
<p>Out-of-memory kills are one of the most common Kubernetes failures, and they are easier to prevent than to debug after the fact. When you enable limit metadata on the <code>kubeletstats</code> receiver, EDOT reports <code>k8s.container.memory_limit_utilization</code> as a fraction between 0 and 1 of the container's memory limit.</p>
<p>This query finds containers that ran close to their limit in the last hour.</p>
<pre><code>TS metrics-*
| WHERE TRANGE(1h)
| STATS peak_mem_pct = MAX(MAX_OVER_TIME(k8s.container.memory_limit_utilization))
    BY k8s.namespace.name, k8s.pod.name, k8s.container.name
| EVAL peak_mem_pct = ROUND(peak_mem_pct * 100, 1)
| WHERE peak_mem_pct &gt; 85
| SORT peak_mem_pct DESC
</code></pre>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt525f72c7f235f1d5/6a85c84680984ca3b5668f7a/image1.jpg" alt="Memory pressure near limit" /></p>
<p><code>MAX_OVER_TIME</code> finds the peak within each container's series, and the outer <code>MAX</code> keeps that peak per container. A container that repeatedly touches 95% or higher is a strong candidate for the next OOM kill. Pair this with the restart query above: a container with both a rising restart count and high memory utilization was very likely OOM killed.</p>
<h2 id="trackingcpuusageandnodepressure">Tracking CPU usage and node pressure</h2>
<p>CPU problems show up as throttling and slow response times rather than crashes. The <code>k8s.pod.cpu.node.utilization</code> metric reports each pod's CPU use as a fraction of total node capacity.</p>
<p>This query charts the busiest pods over the last hour in five-minute buckets.</p>
<pre><code>TS metrics-*
  | WHERE TRANGE(1h)
  | STATS avg_cpu = AVG(AVG_OVER_TIME(k8s.pod.cpu.node.utilization))
      BY k8s.pod.name, TBUCKET(5m)
  | SORT avg_cpu DESC
</code></pre>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt3c8cf93267186f11/6a85c84999083fa62340f975/image6.jpg" alt="CPU usage by pod over time" /></p>
<p><code>AVG_OVER_TIME</code> averages the samples inside each pod's series for the bucket, and the outer <code>AVG</code> combines series that share a pod name. <code>TBUCKET(5m)</code> produces one point every five minutes, which renders cleanly as a time series chart.</p>
<p>To check whether the nodes themselves are saturated, query node utilization directly.</p>
<pre><code>TS metrics-*
  | WHERE TRANGE(1h)
  | STATS cpu = AVG(AVG_OVER_TIME(k8s.node.cpu.usage)),
          mem = AVG(LAST_OVER_TIME(k8s.node.memory.usage))
      BY k8s.node.name, TBUCKET(5m)
  | SORT cpu DESC
</code></pre>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltcaac108bf0586043/6a85c84c501a852e4efbb2b4/image7.jpg" alt="Node CPU and memory utilization" /></p>
<p>A node sitting near full CPU explains throttled pods across many namespaces at once, which is easy to misread as an application bug when you only look at a single pod.</p>
<h2 id="investigatingcontainerlogs">Investigating container logs</h2>
<p>Once metrics point you at a pod, logs explain what it was doing. EDOT stores the log message in <code>body.text</code> and the level in <code>severity_text</code>, alongside the same <code>k8s.*</code> fields as the metrics.</p>
<p>This query ranks namespaces and pods by error volume in the last hour.</p>
<pre><code>FROM logs-*
| WHERE @timestamp &gt;= NOW() - 1 hour
  AND severity_text IN ("ERROR", "FATAL")
| STATS errors = COUNT(*)
    BY k8s.namespace.name, k8s.pod.name
| SORT errors DESC
| LIMIT 20
</code></pre>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt042eff9ad8d18180/6a85c84ee2447a79218b13a4/image8.jpg" alt="Error count by pod" /></p>
<p>Counting by pod tells you whether errors are concentrated in one workload or spread across the cluster. A single noisy pod and a cluster-wide spike call for very different responses.</p>
<p>To read what a specific pod is logging, filter by pod name and search the message text.</p>
<pre><code>FROM logs-*
| WHERE @timestamp &gt;= NOW() - 1 hour
  AND k8s.pod.name == "checkout-&lt;your-hash&gt;"
  AND body.text LIKE "*timeout*"
| KEEP @timestamp, severity_text, body.text
| SORT @timestamp DESC
| LIMIT 50
</code></pre>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt158bdcfec830c991/6a85c85133f2443e7649f49a/image9.jpg" alt="Pod log messages filtered by keyword" /></p>
<p><code>LIKE "*timeout*"</code> does a simple wildcard match on the message. For full-text relevance instead of wildcards, swap it for <a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/search-functions"><code>MATCH(body.text, "timeout")</code></a>.</p>
<h2 id="chainingesqlqueriestoinvestigateakubernetesincident">Chaining ES|QL queries to investigate a Kubernetes incident</h2>
<p>Real Kubernetes investigations chain multiple ES|QL queries together instead of running one in isolation.</p>
<p>A useful loop looks like this:</p>
<ol>
<li>Count errors by pod to find the noisy workload.</li>
<li>Check that pod's restart count and memory utilization to see if it is crashing or starved.</li>
<li>Read its recent logs to find the specific failure.</li>
</ol>
<p>Because every query uses the same <code>k8s.namespace.name</code> and <code>k8s.pod.name</code> fields, you can carry a pod name straight from one query to the next. The same fields let you build a single dashboard where a metrics panel and a logs panel filter together as you click through namespaces.</p>
<h2 id="turningqueriesintoalertsanddashboards">Turning queries into alerts and dashboards</h2>
<p>Any ES|QL query in this post that produces an aggregated value can back an alert, not just support ad hoc investigation.</p>
<p>For example, the restart query becomes an alert when you keep only containers above a threshold and trigger on a non-empty result.</p>
<pre><code>FROM metrics-*
| WHERE @timestamp &gt;= NOW() - 15 minutes AND k8s.container.restarts IS NOT NULL
| STATS restarts = MAX(k8s.container.restarts)
    BY k8s.namespace.name, k8s.pod.name, k8s.container.name
| WHERE restarts &gt;= 5
</code></pre>
<p>Wire this into an <a href="https://www.elastic.co/docs/explore-analyze/alerts-cases/alerts/rule-type-es-query">Elasticsearch query rule</a> and you get notified the moment a container crosses five restarts in fifteen minutes, instead of finding out when a user does. The same pattern applies to memory utilization, node saturation, and error counts.</p>
<h2 id="buildingakubernetesmonitoringtoolkitwithesql">Building a Kubernetes monitoring toolkit with ES|QL</h2>
<p>ES|QL gives you one language for every Kubernetes signal, from object state to resource metrics to container logs. Start with the exploration query to understand your cluster's shape, then keep the restart, phase, memory, CPU, and log queries close for the next incident. Use <code>TS</code> when you need time series functions like <code>MAX_OVER_TIME</code> or <code>TBUCKET</code> to aggregate correctly within each pod or container series. For counting distinct values or taking a simple MAX on a gauge, <code>FROM</code> is enough.</p>
<p>From here, you can adapt the field names to your own receivers, save the most useful queries as dashboard panels, and promote the critical ones to alerts.</p>
<p>To go deeper, see the <a href="https://www.elastic.co/docs/reference/query-languages/esql">ES|QL reference</a>, the <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/ts"><code>TS</code> command documentation</a>, and the <a href="https://www.elastic.co/docs/reference/opentelemetry/use-cases/kubernetes">EDOT Kubernetes guide</a>.</p>]]></content:encoded>
    <link>https://www.elastic.co/observability-labs/blog/esql-kubernetes-monitoring</link>
    <guid isPermaLink="false">esql-kubernetes-monitoring</guid>
    <category><![CDATA[Kubernetes]]></category>
    <category><![CDATA[OpenTelemetry]]></category>
    <dc:creator><![CDATA[Jeffrey Rengifo]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd8fedad7d2a4e61e/6a85c854d6cf29b0cabb089c/cover.png" length="0" type="image/png"/>
    <pubDate>Wed, 08 Jul 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Contextual AI: Stop pinging the SRE: three MCP tools that turn Elastic Agent Builder into your team's runbook]]></title>
    <description><![CDATA[Build three MCP tools in Elastic Agent Builder that read endpoint health, recent deploys and SLO burn rate directly in your editor. Encode your platform team's runbook once; every developer gets self-serve production context without pinging an SRE.]]></description>
    <content:encoded><![CDATA[<p>A developer asks their editor, "Is it safe to merge this PR?" and gets a real answer in seconds, not a 10–15 minute dashboard hunt or a Slack ping to an SRE. This post shows how to build three MCP tools in Elastic Agent Builder that read endpoint health, recent deploys, and SLO burn rate, and encode the platform team's interpretation rules, error rate thresholds, deploy warm-up windows, and burn rate limits directly into the tool descriptions. The result is contextual AI: an agent that reasons over production signals using the runbook the platform team wrote once.</p>
<h2 id="prerequisitesforelasticagentbuildermcptools">Prerequisites for Elastic Agent Builder MCP tools</h2>
<ul>
<li>An <a href="https://cloud.elastic.co/registration">Elastic Cloud</a> deployment with Elastic Stack 9.3+ (or Elastic Cloud Serverless) with Agent Builder enabled.</li>
<li>An APM-ingested service. If your cluster does not already have APM data, the companion notebook includes instructions to generate synthetic traffic using <a href="https://github.com/elastic/apm-integration-testing">elastic/apm-integration-testing</a> with the <code>opbeans-node</code> demo app.</li>
<li>An MCP-compatible client: <a href="https://docs.anthropic.com/en/docs/claude-code/overview">Claude Code</a>, <a href="https://www.cursor.com/">Cursor</a>, or VS Code with an MCP extension.</li>
<li>Basic familiarity with <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/esql.html">ES|QL</a> syntax.</li>
<li><a href="https://nodejs.org/">Node.js</a> 18+ (for the <code>mcp-remote</code> bridge).</li>
</ul>
<p>If you are new to MCP or need to set up the Elastic MCP server for the first time, check out <a href="https://www.elastic.co/search-labs/blog/elastic-mcp-server-agent-builder-tools">Connect Agent Builder tools to any AI agent with Elastic MCP server</a> for the full setup walkthrough. This article assumes the MCP server is already configured.</p>
<h2 id="theproblemwhydevelopersflyblind">The problem: why developers fly blind</h2>
<p>A developer is about to merge a pull request. The change looks simple: increasing the timeout for the downstream <code>recommendations</code> service call from 2 seconds to 5 seconds. But before hitting the merge button, a question lingers: <em>is the service healthy enough to absorb this change right now?</em></p>
<p>To answer that question today, the developer has two options:</p>
<ol>
<li><strong>Check dashboards manually.</strong> Open the APM UI, look at error rates, scan latency charts, find the SLO page, and look for recent deploys. This takes 10-15 minutes and requires knowing what to look for and how to interpret it.</li>
<li><strong>Ask an SRE.</strong> Ping the platform team on Slack: "Hey, is checkout healthy? I want to merge something." This creates an interruption, adds latency to the decision, and doesn't scale.</li>
</ol>
<p>The core problem is not the data. Elastic already collects everything: traces, metrics, error logs, deploy markers, and SLO budgets. The problem is that <strong>correlating multiple signals requires mental overhead and domain knowledge that most developers don't have</strong>.</p>
<p>An SRE knows that a p99 spike after a deploy is normal for 5 minutes, that an error rate under 0.5% is acceptable during a release window, and that merging when the SLO budget is below 20% is risky. That knowledge lives in runbooks, tribal memory, and experience.</p>
<p>What if the platform engineer could encode that knowledge into tools that any developer can query from their editor?</p>
<h2 id="howmcptoolsinelasticagentbuilderencodeyourrunbook">How MCP tools in Elastic Agent Builder encode your runbook</h2>
<p>The key insight is this: <strong>a tool is not just a query; it is a query plus interpretation</strong>. A dashboard shows you a p99 of 450ms. A well-designed tool tells you "p99 is 450ms, which is within normal range for this service, and has been stable since the last deploy 2 hours ago."</p>
<p>The difference is that the tool description carries the domain knowledge. When a platform engineer creates a tool in <a href="https://www.elastic.co/search-labs/blog/elastic-ai-agent-builder-context-engineering-introduction">Agent Builder</a>, they write descriptions like: "Error rate above 1% typically indicates a regression. If this coincides with a recent deploy, the deploy is the likely cause." That description becomes part of the context the AI agent uses when reasoning across multiple tool results.</p>
<p>This is what we mean by <em>contextual AI</em>: the AI agent does not just fetch data; it reasons over it using the interpretation rules that the platform team encoded.</p>
<p>Here is the architecture:</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf9dab0ce1789f384/6a85c8599d2b71139df93915/image-02.png" alt="Architecture: developer editor with MCP client connecting to Elastic Agent Builder tools authored by the platform engineer" /></p>
<p>The platform engineer authors the tools once. Every developer on the team benefits from their own editor, without needing to learn ES|QL or understand APM data models.</p>
<h2 id="settinguptheelasticagentbuildersampleenvironment">Setting up the Elastic Agent Builder sample environment</h2>
<p>The full end-to-end setup (traffic generation with <a href="https://github.com/elastic/opbeans-node">opbeans-node</a>, deploy annotations, SLO creation, and the three Agent Builder tools) is available as a runnable notebook at this repository: <a href="https://github.com/Delacrobix/OART-Contextual-AI-Bridging-the-Gap-between-Platform-Engineering-and-Product-Development/blob/main/notebook.ipynb"><code>notebook.ipynb</code></a>. The sections below focus on the ES|QL queries and tool descriptions: the <em>why</em> behind each tool, not the mechanics of posting them.</p>
<h2 id="buildingtool1get_endpoint_health">Building Tool 1: get_endpoint_health</h2>
<p>This tool answers the question: "How is this endpoint performing right now?" It returns error rate, latency percentiles (p50, p95, p99), and throughput for a given service and endpoint within a time window.</p>
<p>Here is the full <a href="https://www.elastic.co/docs/explore-analyze/ai-features/agent-builder/tools/esql-tools">tool configuration</a> as created in Agent Builder:</p>
<pre><code>{
  "id": "get_endpoint_health",
  "type": "esql",
  "description": "Returns the current health of a service endpoint: error rate, latency percentiles (p50/p95/p99), and throughput. Use this tool to assess whether a service is healthy before making changes. Interpretation guide: error rate below 0.5% is healthy, 0.5-1% is elevated (check for recent deploys), above 1% indicates a problem. For latency, compare p99 against the service baseline: checkout is typically under 500ms, product-search under 200ms. A sudden p99 spike within 15 minutes of a deploy suggests the deploy caused a regression.",
  "tags": ["apm", "reliability", "health"],
  "configuration": {
    "query": "FROM traces-apm-* | WHERE service.name == ?serviceName AND @timestamp &gt;= NOW() - ?timeWindow AND transaction.duration.us IS NOT NULL | STATS total_transactions = COUNT(*), error_count = SUM(CASE(event.outcome == \"failure\", 1, 0)), p50_latency_ms = PERCENTILE(transaction.duration.us, 50) / 1000, p95_latency_ms = PERCENTILE(transaction.duration.us, 95) / 1000, p99_latency_ms = PERCENTILE(transaction.duration.us, 99) / 1000 BY service.name | EVAL error_rate_pct = ROUND(error_count / total_transactions * 100, 2) | EVAL throughput_per_min = ROUND(total_transactions / ?windowMinutes, 1)",
    "params": {
      "serviceName": {
        "type": "keyword",
        "description": "The APM service name to check (e.g., opbeans-node)"
      },
      "timeWindow": {
        "type": "keyword",
        "description": "Time window to analyze, in ES|QL duration format (e.g., 30 minutes, 1 hour, 6 hours)"
      },
      "windowMinutes": {
        "type": "integer",
        "description": "Time window in minutes, used to calculate throughput per minute"
      }
    }
  }
}
</code></pre>
<p>The query uses the <a href="https://www.elastic.co/observability-labs/blog/elastic-discover-traces-apm"><code>traces-apm-*</code></a> data stream, which contains raw transaction data. We filter with <code>transaction.duration.us IS NOT NULL</code> to select only transaction events (excluding spans). Using <code>traces-apm-*</code> is more portable than the pre-aggregated <code>metrics-apm.transaction.1m-*</code> stream, which only populates after sustained traffic.</p>
<p>Notice the <code>description</code> field. It is not just "returns health metrics." It includes <strong>interpretation rules</strong>: what error rate thresholds mean, what latency baselines look like, and how to correlate spikes with deploys. This is the runbook encoded in the tool.</p>
<h2 id="buildingtool2get_recent_deploys">Building Tool 2: get_recent_deploys</h2>
<p>This tool answers: "What has been deployed recently?" Deploy history is a critical context because most production issues correlate with code changes. The agent needs this to reason about whether current metrics are normal or reflect a recent deployment.</p>
<p>Deploy annotations are stored in the <code>observability-annotations</code> index. Here is the full tool configuration:</p>
<pre><code>{
  "id": "get_recent_deploys",
  "type": "esql",
  "description": "Returns the deployment history for a service over the last 24 hours, including version numbers, timestamps, and deploy messages. Use this tool to understand the deployment timeline when assessing service health. Key patterns: if a deploy happened within the last 15 minutes, elevated error rates or latency may be expected (warm-up period). If metrics degraded immediately after a deploy, the deploy is the likely cause. Multiple deploys in a short window (under 2 hours) increase risk because it becomes harder to isolate which change caused an issue.",
  "tags": ["apm", "deploys", "change-tracking"],
  "configuration": {
    "query": "FROM observability-annotations | WHERE service.name == ?serviceName AND @timestamp &gt;= NOW() - 24 hours | SORT @timestamp DESC | KEEP @timestamp, service.version, service.environment, message | LIMIT 10",
    "params": {
      "serviceName": {
        "type": "keyword",
        "description": "The APM service name to check deploy history for"
      }
    }
  }
}
</code></pre>
<p>Again, the <code>description</code> encodes domain knowledge: the 15-minute warm-up window, the correlation between deploys and metric changes, and the risk of multiple rapid deploys. This is how a platform engineer transfers their intuition into something an AI agent can reason with.</p>
<h2 id="buildingtool3get_slo_status">Building Tool 3: get_slo_status</h2>
<p>This tool answers: "How much error budget do we have left?" <a href="https://www.elastic.co/docs/solutions/observability/incident-management/service-level-objectives-slos">SLO budget</a> is the platform team's quantified way of expressing risk tolerance. If the budget is nearly spent, even a small change could cause a violation.</p>
<p>Unlike the previous tools that query APM data, this one queries the internal SLO indices where Elastic stores pre-computed SLI data. The query calculates the current burn rate, that is, how fast the service is consuming error budget relative to the allowed threshold:</p>
<pre><code>{
  "id": "get_slo_status",
  "type": "esql",
  "description": "Returns the current SLO burn rate for a service over the last hour. The response includes: SLI value (current performance), error budget target, and burn rate percentage. The burn rate tells you how fast the service is consuming error budget relative to the allowed threshold. Interpretation: a burn rate below 100% means the service is consuming budget slower than the limit (sustainable). Between 100-200%, the service is burning budget faster than planned (proceed with caution). Above 200%, the service is burning budget at double the allowed rate (delay non-critical changes). Above 500%, investigate immediately. Note: this measures the current burn rate over the last hour, not cumulative budget consumption over the full SLO window. A temporarily high burn rate does not mean the overall budget is exhausted.",
  "tags": ["slo", "reliability", "budget"],
  "configuration": {
    "query": "FROM .slo-observability.sli-v* | WHERE slo.id == ?sloId AND @timestamp &gt;= NOW() - 1 hour | STATS sli_value = AVG(slo.numerator) / AVG(slo.denominator) BY slo.id, slo.name | EVAL error_budget_target = 0.995 | EVAL burn_rate_pct = ROUND((1 - sli_value) / (1 - error_budget_target) * 100, 1)",
    "params": {
      "sloId": {
        "type": "keyword",
        "description": "The SLO identifier. Use the SLO ID for the service you are evaluating."
      }
    }
  }
}
</code></pre>
<blockquote>
  <p><strong>Note on the SLI index:</strong> the version suffix in <code>.slo-observability.sli-v*</code> depends on your Stack release (e.g., <code>v3.6</code> in Stack 9.3). Verify with <code>GET _cat/indices/.slo-observability.*?v</code> and adjust the pattern if your cluster uses a different version.</p>
</blockquote>
<p>The burn rate interpretation rules in the <code>description</code> are the most valuable part. A raw number like "burn rate 85%" means nothing to a developer without context. The tool description translates that into actionable guidance: "below 100% means sustainable, above 200% means delay non-critical changes."</p>
<h2 id="connectingtoyoureditorviamcp">Connecting to your editor via MCP</h2>
<p>With all three tools created in Agent Builder, they are automatically available through the <a href="https://www.elastic.co/docs/solutions/search/agent-builder/mcp-server">MCP server endpoint</a>. Configure your MCP client to connect.</p>
<h3 id="claudecodeconfiguration">Claude Code configuration</h3>
<p>Add the Elastic MCP server to your Claude Code settings:</p>
<pre><code>{
  "mcpServers": {
    "elastic-agent-builder": {
      "command": "npx",
      "args": [
        "mcp-remote",
        "https://your-kibana-url/api/agent_builder/mcp",
        "--header",
        "Authorization: ApiKey your-base64-api-key"
      ]
    }
  }
}
</code></pre>
<h3 id="cursorconfiguration">Cursor configuration</h3>
<p>For Cursor, add the server in <strong>Settings &gt; MCP Servers</strong>:</p>
<pre><code>{
  "mcpServers": {
    "elastic-agent-builder": {
      "command": "npx",
      "args": [
        "mcp-remote",
        "https://your-kibana-url/api/agent_builder/mcp",
        "--header",
        "Authorization: ApiKey your-base64-api-key"
      ]
    }
  }
}
</code></pre>
<p>Once connected, your editor's AI agent will discover all three tools automatically. You can verify by asking: "What Elastic tools do you have available?" The agent should list <code>get_endpoint_health</code>, <code>get_recent_deploys</code>, and <code>get_slo_status</code>.</p>
<p><strong>API key permissions:</strong> the API key needs the <a href="https://www.elastic.co/docs/solutions/search/agent-builder/kibana-api"><code>feature_agentBuilder.read</code></a> Kibana privilege and read access to the relevant indices (<code>traces-apm.*</code>, <code>observability-annotations</code>, <code>.slo-observability.*</code>). For production use, set the key expiry to 30-90 days and follow the principle of least privilege.</p>
<h2 id="thescenarioisitsafetomergethispr">The scenario: "Is it safe to merge this PR?"</h2>
<p>A developer on the team has a pull request that increases the timeout for the downstream <code>recommendations</code> service call from 2 seconds to 5 seconds in <code>opbeans-node</code>. Before merging, they ask the agent:</p>
<blockquote>
  <p><strong>Developer:</strong> "I'm about to merge PR #42, which increases the recommendations service timeout from 2s to 5s in opbeans-node. Is it safe to merge right now?"</p>
</blockquote>
<p>The agent begins its multi-signal reasoning chain. Here is what happens.</p>
<h3 id="step1theagentcallsget_endpoint_health">Step 1: the agent calls get_endpoint_health</h3>
<p>The agent checks the current health of the service:</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt96106b17d8c1dfbf/6a85c85b27c5cd3f9f5f7394/image-03.png" alt="Agent calls get_endpoint_health and returns latency percentiles, error rate, and throughput" /></p>
<h3 id="step2theagentcallsget_recent_deploys">Step 2: the agent calls get_recent_deploys</h3>
<p>Next, it checks for recent deployments:</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt87131c78a4a6a828/6a85c85e1aa1e11c92ff8cf9/image-04.png" alt="Agent calls get_recent_deploys and returns the recent deploy timeline for the service" /></p>
<h3 id="step3theagentcallsget_slo_status">Step 3: the agent calls get_slo_status</h3>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt33ce0ed438af263b/6a85c86168266647891eab97/image-05.png" alt="Agent calls get_slo_status and returns the current SLO burn rate" /></p>
<h3 id="theagentsresponse">The agent's response</h3>
<p>After correlating all three results, the agent produces a recommendation:</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt55cae1213a8da2c9/6a85c8649d2b71f445f9391d/image-06.png" alt="Final agent recommendation correlating endpoint health, recent deploys, and SLO burn rate to flag the merge as risky" /></p>
<p>The agent pulled the current p99, checked recent deploys, and read the SLO burn rate. It combined those signals with the timeout change in the PR, flagged the merge as risky, and recommended next steps.</p>
<h2 id="conclusionwhentousemcptoolsinsteadofpingingansre">Conclusion: when to use MCP tools instead of pinging an SRE</h2>
<p>With Elasticsearch, Agent Builder, and MCP, a developer can answer questions like "is it safe to merge this PR?" from inside their editor, in seconds, without pinging an SRE. Elasticsearch holds the signals: traces, deploy markers, and SLO budgets. Agent Builder is where the platform team encodes how to read those signals: the thresholds, the warm-up windows, the correlation rules. MCP is what carries those tools into the developer's editor.</p>
<p>The query pulls the data. The description tells the agent how to read it. The platform engineer writes the runbook once, and every developer on the team gets to use it.</p>
<h2 id="nextstepsextendelasticagentbuildermcptoolstocicd">Next steps: extend Elastic Agent Builder MCP tools to CI/CD</h2>
<ul>
<li>Explore the <a href="https://www.elastic.co/docs/solutions/search/elastic-agent-builder">Elastic Agent Builder documentation</a> for more tool types and configuration options.</li>
<li>See <a href="https://www.elastic.co/observability-labs/blog/agentic-cicd-kubernetes-mcp-server">Agentic CI/CD: Kubernetes Deployment Gates with Elastic MCP Server</a> for extending this pattern into your CI/CD pipeline.</li>
<li>Check out <a href="https://www.elastic.co/observability-labs/blog/elastic-agent-skills-observability-workflows">Agent Skills for Elastic Observability</a> for a complementary approach using pre-packaged observability skills.</li>
</ul>]]></content:encoded>
    <link>https://www.elastic.co/observability-labs/blog/mcp-tools-elastic-agent-builder</link>
    <guid isPermaLink="false">mcp-tools-elastic-agent-builder</guid>
    <category><![CDATA[Agentic Observability]]></category>
    <category><![CDATA[APM]]></category>
    <dc:creator><![CDATA[Jeffrey Rengifo]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt5c071b863a97c979/6a85c867abdc29d3a612248a/header.png" length="0" type="image/png"/>
    <pubDate>Thu, 04 Jun 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[How to trace MCP server tool calls with OpenTelemetry and Elastic APM]]></title>
    <description><![CDATA[Add OpenTelemetry tracing to an MCP server, visualize tool call performance in Elastic APM, and query the trace data from Claude Desktop using the Agent Builder MCP.]]></description>
    <content:encoded><![CDATA[<p>An MCP server is just a Node process, which means OpenTelemetry instrumentation is one <code>--import</code> flag away. What is new is what happens after the traces land in <a href="https://www.elastic.co/docs/solutions/observability/apm">Elastic APM</a>. The same Claude Desktop session that produced them can query them back through the <a href="https://www.elastic.co/docs/explore-analyze/ai-features/agent-builder/mcp-server">Elastic Agent Builder MCP</a>. The agent analyzes its own tool-call latency, identifies slow tools, and explains failures without leaving the chat. Observability stops being a dashboard that the human checks after the fact and becomes the context the agent uses while working. This post walks through the OTel semantic conventions for MCP, the wrapper pattern for tool spans, and how the loop closes on the Elastic side.</p>
<h2 id="prerequisites">Prerequisites</h2>
<ul>
<li><p>Elastic Cloud hosted (9.3+) or <a href="https://www.elastic.co/docs/deploy-manage/deploy/elastic-cloud/serverless">serverless</a></p></li>
<li><p><a href="https://claude.com/download">Claude Desktop</a></p></li>
<li><p>An MCP server instrumented with the Elastic Distribution of OpenTelemetry (EDOT). We cover how to instrument one below.</p></li>
</ul>
<h2 id="theobservabilitygapinmcpservers">The observability gap in MCP servers</h2>
<p>MCP servers ship with no built-in observability, which means tool-call latency, errors, and performance baselines are invisible to developers.
<a href="https://modelcontextprotocol.io/docs/getting-started/intro">MCP (Model Context Protocol) servers</a> are increasingly used as infrastructure for AI-powered applications, giving AI models access to databases, APIs, internal <a href="https://modelcontextprotocol.io/specification/2025-06-18/server/tools">tools</a>, and business data.
The MCP SDK does not instrument any of it.</p>
<p>The gap shows up in three concrete ways:</p>
<ul>
<li>When a tool call takes 3 seconds, you don't know if the bottleneck is in your business logic, a downstream API, or the data layer.</li>
<li>When a tool call fails, you get the error message but no context about what the server was doing before it failed.</li>
<li>When you add a new tool, you have no baseline to compare its performance against.</li>
</ul>
<p>These are the same problems any backend service faces.
The answer is the same one backend developers have used for years: distributed tracing with <a href="https://www.elastic.co/docs/solutions/observability/apm/opentelemetry">OpenTelemetry</a>.</p>
<p>MCP servers are standard programmatic processes.
There is nothing special about them from an instrumentation perspective.
You add the <a href="https://opentelemetry.io/docs/languages/js/getting-started/nodejs/">OTel SDK</a>, define spans around your tool handlers, and ship traces to your backend.
The only new part is knowing which span names and attributes to use so your traces are meaningful and consistent.</p>
<h2 id="whatwebuilt">What we built</h2>
<p>For this article, we use the <a href="https://www.npmjs.com/package/@modelcontextprotocol/server-everything"><code>@modelcontextprotocol/server-everything</code></a> package, the official reference MCP server published by Anthropic.
It ships with a set of tools that cover the common patterns you will find in real-world MCP servers: simple request/response, parameterized calls, long-running operations, and calls that return structured data.</p>
<p>The server exposes several tools.
In this article we use three of them:</p>
<ul>
<li><p><code>echo</code>: receives a string and returns it unchanged. A minimal request/response tool, useful for verifying that the instrumentation pipeline works end to end.</p></li>
<li><p><code>get-sum</code>: receives two numbers and returns their sum. Represents a parameterized tool with simple business logic.</p></li>
<li><p><code>trigger-long-running-operation</code>: starts a multi-step operation that takes several seconds to complete. Simulates tools that call downstream APIs or run expensive computations.</p></li>
</ul>
<p>We instrumented it with <a href="https://www.elastic.co/docs/reference/opentelemetry/edot-sdks/node/setup">EDOT Node.js</a> (<code>@elastic/opentelemetry-node</code>), which is Elastic's distribution of the OpenTelemetry SDK.
EDOT replaces the five or six individual OTel packages you would otherwise install and adds the <a href="https://www.elastic.co/docs/reference/opentelemetry/compatibility/edot-vs-upstream"><code>elasticapm</code> connector</a> that the Kibana APM UI needs to build its service maps, transaction groupings, and latency charts.
Without that connector, raw OTLP data arrives in Elasticsearch but the APM views have nothing to build from.</p>
<p>The architecture looks like this:</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blte33fadb139d4917e/6a7f0d48c2cc0911f82495b0/architecture.png" alt="Architecture diagram showing Claude Desktop connected to two MCP servers: an instrumented MCP server that emits OpenTelemetry traces to Elastic APM, and the Elastic Agent Builder MCP that queries those traces back from Elasticsearch" /></p>
<p>This is the loop: Claude executes tools, generates telemetry, and then uses a second MCP to analyze that telemetry.
The observability data becomes something the AI can reason about, not just something that sits in a dashboard waiting for a human to check it.</p>
<p>Both MCP servers are active simultaneously in Claude Desktop.
The instrumented MCP generates telemetry.
The Agent Builder MCP lets us query it.</p>
<p>To connect Claude Desktop to both servers, the <code>claude_desktop_config.json</code> looks like this:</p>
<pre><code>{
  "mcpServers": {
    "everything": {
      "command": "node",
      "args": [
        "--import",
        "/path/to/node_modules/@elastic/opentelemetry-node/import.mjs",
        "/path/to/everything/dist/index.js",
        "stdio"
      ],
      "env": {
        "OTEL_SERVICE_NAME": "everything-mcp-server",
        "OTEL_EXPORTER_OTLP_ENDPOINT": "https://&lt;your-otlp-endpoint&gt;",
        "OTEL_EXPORTER_OTLP_HEADERS": "Authorization=ApiKey &lt;your-api-key&gt;",
        "OTEL_LOG_LEVEL": "none"
      }
    },
    "elastic-agent-builder": {
      "command": "npx",
      "args": [
        "mcp-remote",
        "https://&lt;your-kibana-url&gt;/api/agent_builder/mcp",
        "--header",
        "Authorization:ApiKey &lt;your-api-key&gt;"
      ]
    }
  }
}
</code></pre>
<p>The <code>--import /path/to/@elastic/opentelemetry-node/import.mjs</code> flag is all it takes for zero-code auto-instrumentation.
But auto-instrumentation only captures HTTP calls, database queries, and other Node.js instrumented libraries.
MCP tool calls are application logic, and application logic needs manual spans.</p>
<h2 id="whattracesmcptoolcallsgenerate">What traces MCP tool calls generate</h2>
<p>The OpenTelemetry specification includes <a href="https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/">official semantic conventions for MCP</a>.
Following them means your traces are consistent, searchable by name across tools and teams, and compatible with any OTel-aware backend, including Elastic APM.</p>
<p><strong>Span naming</strong> follows the pattern <code>{mcp.method.name} {target}</code>.
For a tool call, this becomes <code>tools/call echo</code> or <code>tools/call get-sum</code>.
This is what you will see as the transaction name in Kibana APM.</p>
<p><strong>Key attributes</strong> on each span:</p>
<p>| Attribute               | Value            | Purpose                   |
| ----------------------- | ---------------- | ------------------------- |
| <code>mcp.method.name</code>       | <code>tools/call</code>     | The MCP protocol method   |
| <code>gen_ai.tool.name</code>      | <code>echo</code>           | The specific tool invoked |
| <code>gen_ai.operation.name</code> | <code>execute_tool</code>   | GenAI semantic convention |
| <code>error.type</code>            | error class name | Set only on failure       |</p>
<p>The wrapper pattern that creates these spans looks like this:</p>
<pre><code>const tracer = trace.getTracer('everything-mcp-server', '1.0.0');

function withToolSpan(toolName, fn) {
  return tracer.startActiveSpan(`tools/call ${toolName}`, (span) =&gt; {
    span.setAttribute('mcp.method.name', 'tools/call');
    span.setAttribute('gen_ai.tool.name', toolName);
    span.setAttribute('gen_ai.operation.name', 'execute_tool');

    try {
      const result = fn();
      span.end();
      return result;
    } catch (err) {
      span.recordException(err);
      span.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
      span.setAttribute('error.type', err.constructor.name);
      span.end();
      throw err;
    }
  });
}
</code></pre>
<p>Each tool handler wraps its logic in <code>withToolSpan</code>.
The result is a named span in Elastic APM for every tool invocation, with duration, status, and error details attached.</p>
<p><strong><em>Security note:</em></strong> <em>The OTel spec defines two optional attributes for tool calls:</em> <code>gen_ai.tool.call.arguments</code> <em>and</em> <code>gen_ai.tool.call.result</code>_. Both are flagged as potentially containing sensitive data. The_ <code>get-env</code> <em>tool in the everything server is a good example of why this matters: it returns all environment variables, which may include API keys and credentials. Capture these attributes only if you have confirmed the data is safe to store in your observability backend, and consider masking or filtering at the SDK level before export.</em></p>
<h2 id="kibanaapmexploringmcpserverperformance">Kibana APM: exploring MCP server performance</h2>
<p>After starting Claude Desktop with both MCPs configured and triggering a few tool calls, the <code>everything-mcp-server</code> service appears in Kibana under <strong>Observability &gt; Applications &gt; Services Inventory</strong>:</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltdbd9c14fcc24ffb0/6a7f0d4bbd2198f4547580d3/services-inventory.png" alt="Kibana APM Services Inventory view showing the everything-mcp-server entry with latency, throughput, and error rate columns populated" /></p>
<h3 id="transactionsview">Transactions view</h3>
<p>Kibana groups traces by transaction name.
Because we follow the semantic conventions, each tool gets its own row: <code>tools/call echo</code>, <code>tools/call get-sum</code>, <code>tools/call trigger-long-running-operation</code>.
You can immediately see latency, throughput, and error rate per tool without any configuration.</p>
<p>This is where the value of consistent span naming becomes concrete.
If you have five different developers adding tools to an MCP server and everyone follows <code>tools/call {toolName}</code>, the APM UI stays organized automatically.</p>
<h3 id="tracewaterfall">Trace waterfall</h3>
<p>Clicking on a specific trace shows the waterfall view.
For a single tool call, the waterfall is straightforward: one span covering the full execution.
If your tool handler makes downstream HTTP requests or database queries that are auto-instrumented, those appear as child spans.
You can see exactly how much time was spent in business logic versus waiting for external calls.</p>
<h3 id="latencydistribution">Latency distribution</h3>
<p>The latency chart shows p50, p95, and p99 distribution across all executions of a given tool.
This makes it easy to distinguish between tools that are consistently fast and those that have occasional outliers.
The <code>trigger-long-running-operation</code> tool, for example, shows a wide distribution depending on how many steps were requested: a useful baseline for understanding expected execution time ranges before setting alerts.</p>
<h3 id="errortracking">Error tracking</h3>
<p>Failed tool calls appear in the Errors panel with their full stack trace, the span attributes attached at the time of failure, and a count of how many times the error has occurred.
If you record the exception with <code>span.recordException(err)</code>, Kibana links the error directly to the trace that produced it.</p>
<h2 id="closingtheloopwiththeagentbuildermcp">Closing the loop with the Agent Builder MCP</h2>
<p>The <a href="https://www.elastic.co/docs/explore-analyze/ai-features/agent-builder/mcp-server">Elastic Agent Builder MCP</a> lets Claude query its own trace data from Elasticsearch in the same chat session that produced the traces.
The Agent Builder MCP can query any Elasticsearch index the API key has access to, and APM traces are stored under <code>.ds-traces-apm.otel-default-*</code>.
Granting the Agent Builder API key read access to those indices is what closes the loop: the agent that executed the tool calls can now reason about how they performed.</p>
<p>Here is what this looks like in practice.
To generate traces, let's ask in Claude Desktop: <em>"Use the echo tool to say hello, then use get-sum to add 1337 and 42, then run a long-running operation with 3 steps."</em></p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf3f00a45a235deb1/6a7f0d4eeab5be043420a709/claude-tool-execution.png" alt="Claude Desktop conversation showing three tool calls executed in sequence: echo, get-sum, and trigger-long-running-operation, each with its result rendered inline" /></p>
<p>Claude executes three tool calls on the instrumented MCP.
Three spans land in Elastic APM.</p>
<p>Now, without leaving the chat, let's try querying the traces by asking: <em>"Search the APM trace data from the last 10 minutes. What tool calls were made, and how long did each one take?"</em></p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt74ef63c895e22bbc/6a7f0d51bd219823847580d9/claude-trace-query.png" alt="Claude Desktop chat where the model uses the Elastic Agent Builder MCP to run an ES|QL query against the APM traces index and reports tool names, durations, and status in natural language" /></p>
<p>We can confirm the information against the services data:</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltb362dbef6539a49f/6a7f0d5305b7b511c418b998/services-data-confirmation.png" alt="Kibana APM transactions view for everything-mcp-server showing the same three tool call transactions and their durations as returned by Claude" /></p>
<p>Claude uses the Agent Builder MCP to run a query against the traces index.
It returns the tool names, durations, and status from the actual trace data, then synthesizes an answer in natural language.</p>
<p>You can go further by asking:</p>
<ul>
<li><p>"Which of those tool calls had the highest p95 latency?"</p></li>
<li><p>"Did any tool calls fail? If so, what was the error message?"</p></li>
<li><p>"Compare the latency of the echo tool vs get-sum across all calls in the last hour."</p></li>
</ul>
<p>Each of these questions translates into an ES|QL query via the Agent Builder's <code>platform.core.execute_esql</code> tool, run against the APM trace indices.</p>
<h2 id="whyelasticformcpobservability">Why Elastic for MCP observability</h2>
<p><strong>The Agent Builder closes the loop:</strong> this is the part that is specific to the Elastic ecosystem.
Because APM data lives in Elasticsearch, and Elasticsearch is queryable via the Agent Builder MCP, you can bring your AI agent's own observability data back into the conversation.
Your AI can reflect on its own performance and spot anomalies.</p>
<p><strong>APM UI built for <a href="https://www.elastic.co/observability-labs/blog/openai-tracing-elastic-opentelemetry">distributed tracing</a>:</strong> Kibana's APM interface is designed for exactly this kind of data: named transactions, trace waterfalls, latency percentiles, error tracking with stack traces, and service maps.</p>
<p><strong>Managed <a href="https://www.elastic.co/docs/reference/opentelemetry/motlp">OTLP</a> endpoint:</strong> Elastic APM accepts OTLP directly since Elastic 8.x.
You point <code>OTEL_EXPORTER_OTLP_ENDPOINT</code> at your APM server and it works.</p>
<p><strong><a href="https://www.elastic.co/observability-labs/blog/elastic-distributions-opentelemetry-ga">EDOT</a> simplifies the setup:</strong> the <code>elasticapm</code> connector is included, which means the APM UI views work without any additional configuration.</p>
<h2 id="conclusion">Conclusion</h2>
<p>MCP servers do not need special observability tooling.
They are programmatic processes, and OpenTelemetry is the right instrument for processes.
The OTel MCP semantic conventions are stable and give you a consistent naming scheme that scales across tools and teams.</p>
<p>What makes the Elastic setup interesting is not the instrumentation itself.
It is the second MCP.
When your observability data lives in Elasticsearch, you can query it from the same AI session that generated it.
That feedback loop is new, and it opens up use cases that dashboards alone cannot cover: real-time anomaly questions, automated triage, and <a href="https://www.elastic.co/observability-labs/blog/elastic-agent-skills-observability-workflows">AI-assisted incident investigation</a> from the chat interface your team is already using.</p>
<h2 id="nextsteps">Next steps</h2>
<ul>
<li><p><a href="https://www.elastic.co/docs/reference/opentelemetry/edot-sdks/node/setup">Set up EDOT Node.js</a></p></li>
<li><p><a href="https://www.elastic.co/docs/explore-analyze/ai-features/agent-builder/mcp-server">Configure the Elastic Agent Builder MCP</a></p></li>
<li><p><a href="https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/">OTel semantic conventions for MCP</a></p></li>
<li><p><a href="https://www.elastic.co/observability-labs/blog/elastic-managed-otlp-endpoint-ga-elastic-cloud-hosted">Managed OTLP endpoint on Elastic Cloud (now GA)</a></p></li>
<li><p><a href="https://www.elastic.co/observability-labs/blog/elastic-opentelemetry-openai">Instrumenting Node.js applications with EDOT</a></p></li>
</ul>
<h2 id="frequentlyaskedquestions">Frequently asked questions</h2>
<p><strong>How do I add tracing to an MCP server?</strong>
Use the OpenTelemetry SDK and follow the official OTel MCP semantic conventions. With the Elastic Distribution of OpenTelemetry (EDOT) for Node.js, a single <code>--import</code> flag enables auto-instrumentation for HTTP and database calls. Tool-call spans need to be added manually using a wrapper that sets <code>mcp.method.name</code>, <code>gen_ai.tool.name</code>, and <code>gen_ai.operation.name</code>.</p>
<p><strong>Why are my MCP tool calls slow and how do I find the bottleneck?</strong>
Without tracing, an MCP tool call is a black box: you see the result but not where the time went. Instrument the server with OpenTelemetry, ship traces to Elastic APM, and use the trace waterfall view in Kibana to see exactly how much time was spent in business logic versus downstream HTTP or database calls.</p>
<p><strong>Can I send MCP server traces to Elastic APM without a custom collector?</strong>
Yes. Elastic APM accepts OTLP directly. Set <code>OTEL_EXPORTER_OTLP_ENDPOINT</code> to your APM endpoint and <code>OTEL_EXPORTER_OTLP_HEADERS</code> with an API key, and traces flow in. EDOT bundles the <code>elasticapm</code> connector that the Kibana APM UI needs for service maps and transaction grouping.</p>
<p><strong>What span names and attributes should I use for MCP tool calls?</strong>
Follow the OpenTelemetry MCP semantic conventions: name spans <code>{mcp.method.name} {target}</code> (for example, <code>tools/call echo</code>), and set <code>mcp.method.name</code>, <code>gen_ai.tool.name</code>, and <code>gen_ai.operation.name=execute_tool</code>. On failure, set <code>error.type</code> to the error class name. Consistent naming means the Elastic APM transactions view groups your tool calls automatically.</p>
<p><strong>How is this different from sending MCP traces to Datadog or Grafana?</strong>
Any OTel-compatible backend can receive the traces. The Elastic-specific part is the Agent Builder MCP, which lets the same AI agent that generated the traces query them back from Elasticsearch in natural language. That feedback loop, where the AI reasons about its own tool-call performance, is not available with backends that do not expose their data through an MCP server.</p>
<p><strong>Should I capture MCP tool call arguments and results in my traces?</strong>
The OTel spec defines <code>gen_ai.tool.call.arguments</code> and <code>gen_ai.tool.call.result</code> as optional and warns they may contain sensitive data. Tools like <code>get-env</code>, which returns environment variables, illustrate the risk: API keys and credentials can land in your observability backend. Capture these only when the data is safe to store, and consider masking at the SDK level before export.</p>
<p><strong>Does this work for MCP servers written in languages other than Node.js?</strong>
The OpenTelemetry MCP semantic conventions are language-agnostic. EDOT is available for Node.js, Java, Python, .NET, and other languages, and any of them can send OTLP to Elastic APM. The wrapper pattern shown in this post translates directly: open a span around the tool handler, set the standard attributes, record exceptions on failure.</p>]]></content:encoded>
    <link>https://www.elastic.co/observability-labs/blog/mcp-tracing-opentelemetry-elastic-apm</link>
    <guid isPermaLink="false">mcp-tracing-opentelemetry-elastic-apm</guid>
    <category><![CDATA[APM]]></category>
    <category><![CDATA[OpenTelemetry]]></category>
    <category><![CDATA[AI]]></category>
    <dc:creator><![CDATA[Jeffrey Rengifo]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt01a77347914d1229/6a7f0d57c2e9145f21016c0c/header_image.png" length="0" type="image/png"/>
    <pubDate>Thu, 14 May 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Connecting Cursor to Production Logs via the Elastic MCP Server]]></title>
    <description><![CDATA[Learn how to connect Cursor to your Elastic APM data using the Elastic Agent Builder MCP server, so you can debug production errors and make UI decisions backed by real usage data without leaving your editor.]]></description>
    <content:encoded><![CDATA[<h2 id="prerequisites">Prerequisites</h2>
<ul>
<li><p>Elasticsearch 9.3+ (or Elastic Cloud Serverless)</p></li>
<li><p>Elasticsearch API KEY and Kibana URL</p></li>
<li><p>An application instrumented with Elastic APM: the <a href="https://www.elastic.co/guide/en/apm/agent/rum-js/current/index.html">RUM agent</a> for frontend interactions (populates <code>traces-apm-*</code>) and the <a href="https://www.elastic.co/docs/reference/apm-agents">APM agent</a> for backend errors (populates <code>logs-apm.error-*</code></p></li>
<li><p><a href="https://cursor.com/home">Cursor</a> (version 2.6+) installed</p></li>
</ul>
<h2 id="theproblemwithtwoworlds">The problem with two worlds</h2>
<p>Application logs and code are two separate worlds that don't talk to each other. If you want to apply log insights into the application you have to analyze the logs, and then come back to the editor and apply your findings.</p>
<p>The <a href="https://modelcontextprotocol.io/">Model Context Protocol (MCP)</a> changes this. MCP is an open standard that lets AI clients like Cursor connect to external tools and data sources through a standardized interface. Instead of your IDE only knowing about your local code, it can also talk to your Elasticsearch cluster, query your APM data, and reason about production behavior alongside your source files.</p>
<p>Elastic ships a <a href="https://www.elastic.co/docs/explore-analyze/ai-features/agent-builder/mcp-server">built-in MCP server</a> as part of <a href="https://www.elastic.co/docs/explore-analyze/ai-features/elastic-agent-builder">Agent Builder</a>. You define tools in Kibana, expose them via the MCP endpoint, and any MCP-compatible client can call them. Cursor supports MCP natively, which means you can set this up in minutes.</p>
<h2 id="whatwerebuilding">What we're building</h2>
<p>We're working with an eCommerce search app instrumented with Elastic APM. The RUM JS agent tracks filter click interactions from the browser, stored in <code>traces-apm-default</code>. The Node.js APM agent captures backend errors, stored in <code>logs-apm.error-default</code>.</p>
<p>Two situations come up during development:</p>
<ul>
<li><p><strong>Use case 1</strong>: The product team wants to simplify the search page. There are six filters but we don't know which ones users actually click. We need usage data to decide which to keep.</p></li>
<li><p><strong>Use case 2</strong>: Users report intermittent 500 errors on search. The errors are not constant and started two days ago. We need the error details to find the root cause.</p></li>
</ul>
<p>To bring that data into Cursor, we'll build two Agent Builder tools in Kibana and connect them via the <a href="https://www.elastic.co/docs/explore-analyze/ai-features/agent-builder/mcp-server">Elastic Agent Builder MCP Server</a>:</p>
<ul>
<li><p><code>get_filter_usage</code>: queries <code>traces-apm-default</code> for filter click events and returns a usage breakdown by filter name</p></li>
<li><p><code>get_recent_errors</code>: queries <code>logs-apm.error-default</code> for the most recent error groups for a given service, including the exception message and stack trace culprit</p></li>
</ul>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt62d3a8324299ca7a/6a7f080dc2cc09675c24935f/architecture.png" alt="Architecture diagram showing Cursor connecting to the Elastic Agent Builder MCP server, which queries Elasticsearch APM data" /></p>
<p>For a deeper look at the overall architecture, see the <a href="https://www.elastic.co/search-labs/blog/agent-builder-mcp-reference-architecture-elasticsearch">Agent Builder reference guide</a>.  </p>
<h2 id="settinguptheelasticmcpservernbspnbsp">Setting up the Elastic MCP Server  </h2>
<h3 id="step1createtheagentbuildertools">Step 1: Create the Agent Builder tools</h3>
<p>We create both tools via the <a href="https://www.elastic.co/docs/explore-analyze/ai-features/agent-builder/kibana-api">Kibana Agent Builder API</a>. Each tool is an ES|QL query with a name and description that Cursor uses to decide when to call it. The full implementation of the tools is in the following <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/cursor-production-logs-elastic-mcp-server/notebook.ipynb"><code>notebook</code></a>.</p>
<h4 id="tool1get_filter_usage">Tool 1: get_filter_usage</h4>
<p>The product team needs to know which filters users actually click before deciding which ones to remove. The query reads RUM interaction events from <code>traces-apm-default</code> and groups them by filter name:</p>
<pre><code>    {
    &amp;nbsp;&amp;nbsp;"id": "get_filter_usage",
    &amp;nbsp;&amp;nbsp;"type": "esql",
    &amp;nbsp;&amp;nbsp;"description": "Returns the usage count for each search filter in the ecommerce-search-ui service, sorted by most used first.",
    &amp;nbsp;&amp;nbsp;"configuration": {
    &amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;"query": "FROM traces-apm-default | WHERE service.name == \"ecommerce-search-ui\" | WHERE transaction.type == \"user-interaction\" | WHERE labels.filter_name IS NOT NULL | STATS count = COUNT(*) BY labels.filter_name | SORT count DESC"
    &amp;nbsp;&amp;nbsp;}
    }
</code></pre>
<h4 id="tool2get_recent_errors">Tool 2: get_recent_errors</h4>
<p>For the error debugging use case, we need to surface the most frequent recent errors for a service, along with where in the code they originate. <code>STATS ... BY</code> groups errors by their fingerprint (<code>grouping_key</code>), surfaces the exception message and the line of code that caused it (<code>culprit</code>), and ranks by frequency:</p>
<pre><code>    {
    &amp;nbsp;&amp;nbsp;"id": "get_recent_errors",
    &amp;nbsp;&amp;nbsp;"type": "esql",
    &amp;nbsp;&amp;nbsp;"description": "Returns the most frequent error groups for ecommerce-search-ui, ranked by occurrence count, with the exception message and code location.",
    &amp;nbsp;&amp;nbsp;"configuration": {
    &amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;"query": "FROM logs-apm.error-default | WHERE service.name == \"ecommerce-search-ui\" | WHERE processor.name == \"error\" | STATS count = COUNT(*) BY error.grouping_key, error.exception.0.message, error.culprit | SORT count DESC | LIMIT 5"
    &amp;nbsp;&amp;nbsp;}
    }
</code></pre>
<p>Both tools are created with <code>POST /api/agent_builder/tools</code>. You can learn more about the Kibana API endpoints for Elastic Agent Builder <a href="https://www.elastic.co/docs/explore-analyze/ai-features/agent-builder/kibana-api">here</a>.</p>
<h3 id="step2connecttocursor">Step 2: Connect to Cursor</h3>
<p>Open <code>~/.cursor/mcp.json</code> and add the Elastic server. For detailed information, see the Cursor <a href="https://cursor.com/docs/mcp#using-mcpjson">documentation</a>. The Agent Builder MCP endpoint uses Server-Sent Events (SSE) transport, so we connect via <code>mcp-remote</code>, a lightweight bridge that Cursor invokes as a local process:</p>
<pre><code>    {
    &amp;nbsp;&amp;nbsp;"mcpServers": {
    &amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;"elastic-agent-builder": {
    &amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;"command": "npx",
    &amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;"args": [
    &amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;"-y",
    &amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;"mcp-remote",
    &amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;"https://YOUR_KIBANA_URL/api/agent_builder/mcp",
    &amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;"--header",
    &amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;"Authorization: ApiKey YOUR_API_KEY"
    &amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;]
    &amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}
    &amp;nbsp;&amp;nbsp;}
    }
</code></pre>
<p>Replace <code>YOUR_KIBANA_URL</code> and <code>YOUR_API_KEY</code> with your values.</p>
<p>Restart Cursor, open the Agent panel, and confirm that <code>get_filter_usage</code> and <code>get_recent_errors</code> appear in the available tools list. </p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt26bfbf5b85c381ad/6a7f0810c2cc099f19249363/cursor-mcp-tools.png" alt="Cursor MCP panel showing the get_filter_usage and get_recent_errors tools available from the Elastic Agent Builder server" /></p>
<h2 id="usecase1datadrivenuioptimization">Use case 1: Data-driven UI optimization</h2>
<p>The eCommerce search page has six filters: category, manufacturer, price range, customer gender, day of week, and region. The product team wants to simplify the UI by removing filters that users don't use as much. Rather than guessing, we ask Cursor to check.</p>
<p>When you type a prompt in Cursor's Agent panel, the model sees the name and description of every connected MCP tool. It matches your intent to the best-fitting tool and calls it automatically. This is why the <code>description</code> field we set in Step 1 matters: it's what the model reads to decide which tool answers your question. If you are interested in learning more about Cursor’s MCP tools management, read the following <a href="https://cursor.com/docs/mcp#using-mcp-in-chat">documentation</a>.</p>
<p>Open a Cursor chat and ask: "Show me how often each search filter is used." Cursor calls the tool and returns something like:</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt0cc46e50f9d94970/6a7f0813bd21980745757eda/filter-usage-chart.png" alt="Filter usage breakdown returned by the get_filter_usage tool" /></p>
<p>The category and manufacturer filters get most of the clicks. The bottom three filters (<code>customer_gender</code>, <code>day_of_week</code>, <code>region</code>) are rarely used.</p>
<p>Ask Cursor to act on this: <strong><em>"Based on this data, simplify the SearchFilters component. Keep the top 3 filters visible, collapse the others under a 'More filters' toggle."</em></strong></p>
<p>Cursor opens <code>src/components/SearchFilters.jsx</code>, reads the current implementation, and proposes the change.</p>
<p>Before: </p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt8af7e161637b0151/6a7f0816e3a219301899f2a4/search-filters-before.png" alt="SearchFilters component before the change, showing all six filters" /></p>
<p>After: </p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt61f5bd24ca30c19e/6a7f0819ead8ece767baa672/search-filters-after.png" alt="SearchFilters component after the change, showing the top three filters with the rest collapsed under a More filters toggle" /></p>
<p>The entire loop took one chat prompt. The decision was backed by production data, not a team discussion about what users probably care about.</p>
<h2 id="usecase2productionerrordebugging">Use case 2: Production error debugging</h2>
<p>A bug report comes in: intermittent 500 errors on the search endpoint. The errors started appearing two days ago but they're not constant. The developer opens Cursor and asks: "Show me what errors ecommerce-search-ui is throwing."</p>
<p>Cursor calls the tool and returns the error groups:</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt67bae433977e721a/6a7f081c227b1c4eeb59841e/recent-errors.png" alt="Most recent error groups returned by the get_recent_errors tool" /></p>
<p>The error message is explicit: <code>category</code> is a text field and can't be used in terms of aggregation. The correct field is <code>category.keyword</code>. </p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt37f176d0f26a2fb9/6a7f081f3cab1cfd580e4662/error-fix-diff.png" alt="Cursor proposing the fix that changes category to category.keyword in the ES|QL query" /></p>
<p>With APM data available alongside your code, the debugging session becomes a conversation: you describe the symptom, the agent pulls the relevant logs, and you work through what's happening together. You can ask follow-up questions, check whether the error correlates with a recent deployment, or ask which endpoints are most affected, all within the same context where you'll make the fix. If you want to go further, Elastic also provides <a href="https://www.elastic.co/docs/solutions/observability/ai/agent-builder-observability">pre-built observability tools in Agent Builder</a> that you can use alongside custom tools like the ones we created here. For a complementary approach to AI-driven observability, see <a href="https://www.elastic.co/observability-labs/blog/ai-observability-web-agents-openlit">how to monitor web AI agents with OpenLIT and Elastic</a>.</p>
<h2 id="conclusion">Conclusion</h2>
<p>What we covered:</p>
<ul>
<li><p>How to create Agent Builder tools in Kibana that wrap APM data queries</p></li>
<li><p>How to connect the Elastic Agent Builder MCP Server to Cursor in three lines of JSON</p></li>
<li><p>Using production telemetry to make a UI decision backed by real usage data</p></li>
<li><p>Debugging a production error from the same window where you fix it</p></li>
</ul>
<p>These two use cases are a starting point. The same pattern works for any data you have in Elasticsearch: performance metrics, A/B test results, audit logs, feature flag usage, user session data. Define the Agent Builder tool, connect it via MCP, and it becomes part of your development context in Cursor. For other examples of what's possible, see <a href="https://www.elastic.co/observability-labs/blog/mcp-elastic-synthetics">automating synthetic monitoring with MCP</a> and <a href="https://www.elastic.co/observability-labs/blog/agentic-cicd-kubernetes-mcp-server">agentic CI/CD deployment gates</a>.</p>
<h2 id="nextsteps">Next steps</h2>
<ul>
<li><p><a href="https://www.elastic.co/docs/explore-analyze/ai-features/agent-builder/mcp-server">Elastic Agent Builder MCP server documentation</a></p></li>
<li><p><a href="https://modelcontextprotocol.io/">Model Context Protocol specification</a></p></li>
<li><p><a href="https://www.elastic.co/docs/explore-analyze/ai-features/elastic-agent-builder">Elastic Agent Builder overview</a></p></li>
</ul>]]></content:encoded>
    <link>https://www.elastic.co/observability-labs/blog/elastic-mcp-server-cursor-production-logs</link>
    <guid isPermaLink="false">elastic-mcp-server-cursor-production-logs</guid>
    <category><![CDATA[Agentic Observability]]></category>
    <category><![CDATA[Logs Analytics]]></category>
    <category><![CDATA[APM]]></category>
    <dc:creator><![CDATA[Jeffrey Rengifo]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt995e6ed7b699e8fa/6a7f08226c6eacad3ef13f31/header.png" length="0" type="image/png"/>
    <pubDate>Wed, 29 Apr 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[How to cut Elasticsearch log storage costs with LogsDB]]></title>
    <description><![CDATA[Learn how to enable LogsDB index mode in Elasticsearch and measure real storage savings. We compare a standard index against a LogsDB index using Apache logs and show how much storage you can reclaim.]]></description>
    <content:encoded><![CDATA[<p>LogsDB is a specialized Elasticsearch index mode that gives you full functionality at a fraction of the storage cost. Your Kibana dashboards, searches, alerts, and visualizations all continue to work exactly as before. No data is discarded. No queries need to be updated. No workflows break. It is one setting, and everything else gets cheaper.</p>
<p>In benchmarks, LogsDB brought a dataset from <strong>162.7 GB down to 39.4 GB</strong> — a <strong>76% reduction in storage</strong>. You can explore the full nightly benchmark results at <a href="https://elasticsearch-benchmarks.elastic.co/#tracks/logsdb/nightly/default/90d">elasticsearch-benchmarks.elastic.co</a>.</p>
<p>In this tutorial you'll reproduce the experiment yourself using Kibana Dev Tools and an Apache logs dataset. You'll create two identical indices, ingest the same documents into both, and measure the storage difference with the <code>_stats</code> API. By the end, you'll see a 44% reduction on your test data — and understand exactly why production numbers push even higher.</p>
<blockquote>
  <p><strong>Already on Elasticsearch 9.2+?</strong> Any data stream with a <code>logs-</code> prefix already uses LogsDB by default. Jump to <a href="https://www.elastic.co/observability-labs/blog/elasticsearch-logsdb-index-mode-storage-savings#what-about-your-existing-logs">What about your existing logs?</a> to verify your setup.</p>
  <p><strong>Want the full picture?</strong> For the engineering history behind these savings — how Lucene doc values, synthetic <code>_source</code>, index sorting, and ZSTD were developed and stacked over twelve years — see <a href="https://www.elastic.co/blog/elasticsearch-logsdb-storage-evolution"><em>Elasticsearch over the years: how LogsDB cuts index size by up to 75%</em></a>.</p>
</blockquote>
<h2 id="prerequisites">Prerequisites</h2>
<ul>
<li>Elasticsearch 8.17+ cluster, Elastic Cloud deployment, or Serverless</li>
<li>Kibana with Dev Tools access</li>
<li>Some logs</li>
<li>Basic familiarity with running API calls in Kibana Dev Tools</li>
</ul>
<h2 id="howlogsdbsavesstorage">How LogsDB saves storage</h2>
<p>LogsDB stacks three mechanisms to achieve its storage reduction:</p>
<ul>
<li><strong>Index sorting</strong> — documents are sorted by <code>host.name</code> then <code>@timestamp</code>, grouping similar log lines so compression codecs find far more repeated patterns. Sorting alone accounts for roughly 30% of the savings.</li>
<li><strong>ZSTD compression with delta/GCD/run-length encoding</strong> — <code>best_compression</code> switches from LZ4 to Zstandard and applies numeric codecs to each doc values column. The standard index in this tutorial uses LZ4, so part of what you're measuring is the full package LogsDB delivers automatically.</li>
<li><strong>Synthetic <code>_source</code></strong> — Elasticsearch skips storing the raw JSON blob entirely and reconstructs <code>_source</code> on demand from doc values, adding another 20–40% of savings on top.</li>
</ul>
<blockquote>
  <p><strong>Synthetic <code>_source</code> trade-offs:</strong> Field ordering in returned documents may differ from the original, and some edge cases around multi-value array fields behave differently. For most log analytics workloads these differences are invisible, but check the <a href="https://www.elastic.co/observability-labs/blog/elasticsearch-logsdb-index-mode-storage-savings#next-steps">synthetic <code>_source</code> documentation</a> before enabling it in latency-sensitive applications.</p>
</blockquote>
<p>For a deep dive into the architecture behind each mechanism, see <a href="https://www.elastic.co/blog/elasticsearch-logsdb-storage-evolution"><em>Elasticsearch over the years: how LogsDB cuts index size by up to 75%</em></a>.</p>
<p>Let's now walk through the steps you can take to enable LogsDB and measure the storage savings.</p>
<h2 id="step1collectlogswithelasticagent">Step 1: Collect logs with Elastic Agent</h2>
<p>The recommended way to ingest Apache logs into Elasticsearch is through Elastic Agent with the Apache integration. It handles collection, parsing, ECS field mapping, and routing automatically.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt909c6349a734ff01/6a7f09dbc2cc09cfe3249426/integration.png" alt="Elastic Agent Apache integration setup in Kibana" /></p>
<p>Browse all available integrations in the <a href="https://www.elastic.co/integrations">Elastic integrations catalog</a>.</p>
<p>Once the Agent is collecting logs and routing them to <code>logs-apache.access-*</code>, move to the next step.</p>
<h2 id="step2createthetwoindices">Step 2: Create the two indices</h2>
<p>All commands in this tutorial are run in <strong>Kibana Dev Tools</strong>.</p>
<p>Create one standard index and one LogsDB index with identical field mappings. The only difference is <code>"index.mode": "logsdb"</code>.</p>
<p><strong>Standard index:</strong></p>
<pre><code>PUT /apache-standard
{
  "mappings": {
    "properties": {
      "@timestamp":                  { "type": "date" },
      "host.name":                   { "type": "keyword" },
      "http.request.method":         { "type": "keyword" },
      "url.path":                    { "type": "keyword" },
      "http.version":                { "type": "keyword" },
      "http.response.status_code":   { "type": "integer" },
      "http.response.bytes":         { "type": "integer" },
      "http.request.referrer":       { "type": "keyword" },
      "user_agent.original":         { "type": "keyword" }
    }
  }
}
</code></pre>
<p><strong>LogsDB index:</strong></p>
<pre><code>PUT /apache-logsdb
{
  "settings": {
    "index.mode": "logsdb"
  },
  "mappings": {
    "properties": {
      "@timestamp":                  { "type": "date" },
      "host.name":                   { "type": "keyword" },
      "url.path":                    { "type": "keyword" },
      "http.request.method":         { "type": "keyword" },
      "http.version":                { "type": "keyword" },
      "http.response.status_code":   { "type": "integer" },
      "http.response.bytes":         { "type": "integer" },
      "http.request.referrer":       { "type": "keyword" },
      "user_agent.original":         { "type": "keyword" }
    }
  }
}
</code></pre>
<p>That single <code>"index.mode": "logsdb"</code> line activates all three storage mechanisms. Elasticsearch enables these additional settings behind the scenes — you don't set any of them manually:</p>
<pre><code>{
  "index.sort.field":              ["host.name", "@timestamp"],
  "index.sort.order":              ["asc", "desc"],
  "index.codec":                   "best_compression",
  "index.mapping.ignore_malformed": true,
  "index.mapping.ignore_above":    8191
}
</code></pre>
<h2 id="step3reindexthelogs">Step 3: Reindex the logs</h2>
<p>Use the <code>_reindex</code> API to copy the same documents into both test indices:</p>
<pre><code>POST /_reindex
{
  "source": { "index": "logs-apache.access-*" },
  "dest":   { "index": "apache-standard" }
}

POST /_reindex
{
  "source": { "index": "logs-apache.access-*" },
  "dest":   { "index": "apache-logsdb" }
}
</code></pre>
<p>Both indices now hold identical documents, so the storage comparison in the next step reflects only the index mode difference.</p>
<h2 id="step4forcemergeforafaircomparison">Step 4: Force merge for a fair comparison</h2>
<p>Before measuring, force merge both indices to a single segment:</p>
<pre><code>POST /apache-standard/_forcemerge?max_num_segments=1

POST /apache-logsdb/_forcemerge?max_num_segments=1
</code></pre>
<p>These calls block until the merge finishes. Wait for both responses before continuing.</p>
<p><strong>Why this matters:</strong> Elasticsearch writes data into multiple Lucene segments before merging them in the background. Measuring mid-merge gives artificially inflated numbers because each segment is compressed independently. Forcing a single segment shows the real steady-state storage footprint you'd see in a mature production index.</p>
<blockquote>
  <p><strong>Only run <code>_forcemerge</code> on indices that are no longer being written to.</strong> Force merging an index that is still receiving writes is resource-intensive and can impact ingestion performance. In production, you can use <a href="https://www.elastic.co/docs/manage-data/lifecycle/index-lifecycle-management">Index Lifecycle Management (ILM)</a> to automate force merges as part of the warm or cold phase, once an index is rolled over and no longer actively ingested into.</p>
</blockquote>
<h2 id="step5measurethedifference">Step 5: Measure the difference</h2>
<pre><code>GET /apache-standard/_stats?filter_path=indices.*.primaries.store

GET /apache-logsdb/_stats?filter_path=indices.*.primaries.store
</code></pre>
<p>The <code>filter_path</code> parameter keeps the response focused. Look for <code>primaries.store.size_in_bytes</code> in each response.</p>
<p>In our test with Apache log records, the results were:</p>
<p>| Index            | Documents | Size     |
|------------------|-----------|----------|
| apache-standard  | 111,818   | 15.37 MB |
| apache-logsdb    | 111,818   | 8.6 MB   |
| <strong>Reduction</strong>    |           | <strong>44%</strong>  |</p>
<p>To put this in perspective: at 1 TB of log data, LogsDB brings that down to around 560 GB. That's 450 GB saved without any changes to your queries. At production scale with billions of documents and synthetic <code>_source</code> enabled, savings push to 76% — taking 162.7 GB down to 39.4 GB in our benchmark.</p>
<h2 id="visualizeinkibana">Visualize in Kibana</h2>
<p>To see the storage difference visually, open Kibana and go to <strong>Management → Stack Management → Index Management</strong>. You'll see both indices listed with their current sizes side by side.</p>
<p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltedf2816dfc31a34b/6a7f09deb43770a7a64d6b65/index-stats.png" alt="Kibana Index Management showing storage comparison between standard and LogsDB indices" /></p>
<blockquote>
  <p><strong>Why Kibana shows larger numbers than <code>_stats</code>:</strong> Kibana Index Management displays the total index size including all replica shards. The <code>_stats</code> query above uses <code>primaries</code> to report primary shards only. The ratio between the two indices remains the same either way.</p>
</blockquote>
<h2 id="whataboutyourexistinglogs">What about your existing logs?</h2>
<h3 id="elasticsearch92alreadyenabledbydefault">Elasticsearch 9.2+ (already enabled by default)</h3>
<p>Since 9.2, any data stream matching the <code>logs-*</code> naming pattern automatically uses LogsDB. You're likely already saving storage without any configuration change.</p>
<p>Verify your existing data streams:</p>
<pre><code>GET /.ds-logs-*/_settings?filter_path=*.settings.index.mode
</code></pre>
<p>If you see <code>"index.mode": "logsdb"</code> in the responses, you're already getting the savings.</p>
<h3 id="elasticsearch8xor9091enableperdatastreamviaindextemplate">Elasticsearch 8.x or 9.0–9.1 (enable per data stream via index template)</h3>
<p>For earlier versions, enable LogsDB on a data stream by updating its index template. This affects all new indices created from that template — existing indices are not changed, so the transition is safe and gradual.</p>
<p><strong>Option A — Update an existing template:</strong></p>
<pre><code>PUT _index_template/logs-myapp-template
{
  "index_patterns": ["logs-myapp-*"],
  "data_stream": {},
  "template": {
    "settings": {
      "index.mode": "logsdb"
    }
  },
  "priority": 200
}
</code></pre>
<p><strong>Option B — Check and patch an existing integration template:</strong></p>
<p>First, find the template managing your data stream:</p>
<pre><code>GET _index_template/logs-apache*
</code></pre>
<p>Then add the <code>index.mode</code> setting to the <code>template.settings</code> block using a <code>PUT _index_template/&lt;name&gt;</code> call with the full template body including your addition.</p>
<p>After updating the template, the next index rollover will use LogsDB. Trigger a rollover immediately if you don't want to wait:</p>
<pre><code>POST /logs-myapp-default/_rollover
</code></pre>
<p><strong>Upgrading from 8.x to 9.0+:</strong> Existing data streams are not changed automatically. Only new rollovers will use LogsDB. There is no data loss and no reindexing required — the savings accumulate as new indices roll over.</p>
<h2 id="whataboutqueryperformance">What about query performance?</h2>
<p>LogsDB does not significantly impact query performance for typical log analytics workloads. The index sorting by <code>host.name</code> and <code>@timestamp</code> can actually <em>improve</em> range query and aggregation performance on those fields, since matching documents are stored adjacently. Queries that don't filter on those fields perform comparably to a standard index.</p>
<p>For indexing throughput data across releases, see the <a href="https://www.elastic.co/blog/elasticsearch-logsdb-storage-evolution#performance-not-just-storage">performance section</a> of the companion article.</p>
<h2 id="conclusion">Conclusion</h2>
<p>LogsDB activates with a single <code>"index.mode": "logsdb"</code> setting and delivers measurable storage savings immediately: 44% in our hands-on test, and 76% (162.7 GB → 39.4 GB) in production benchmarks with synthetic <code>_source</code>. On Elasticsearch 9.2+, <code>logs-*</code> data streams already use LogsDB by default. For 8.x or earlier 9.x clusters, a one-line index template change enables it on your next rollover with no data loss and no reindexing required.</p>
<h2 id="nextsteps">Next steps</h2>
<ul>
<li><a href="https://www.elastic.co/docs/manage-data/data-store/data-streams/logs-data-stream-integrations">LogsDB index mode documentation</a></li>
<li><a href="https://www.elastic.co/docs/manage-data/data-store/data-streams/logs-data-stream">Configuring a logs data stream</a></li>
<li><a href="https://www.elastic.co/blog/logsdb-index-mode-generally-available">LogsDB GA announcement</a></li>
<li><a href="https://www.elastic.co/blog/elasticsearch-logsdb-tsds-benchmarks">LogsDB and TSDS performance benchmarks</a></li>
</ul>]]></content:encoded>
    <link>https://www.elastic.co/observability-labs/blog/elasticsearch-logsdb-index-mode-storage-savings</link>
    <guid isPermaLink="false">elasticsearch-logsdb-index-mode-storage-savings</guid>
    <category><![CDATA[Data Management]]></category>
    <category><![CDATA[Logs Analytics]]></category>
    <dc:creator><![CDATA[Jeffrey Rengifo]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt99ec1c2ec2a7af55/6a7f09e23ce8e203b0cf5277/header.png" length="0" type="image/png"/>
    <pubDate>Thu, 09 Apr 2026 00:00:00 GMT</pubDate>
  </item>
  </channel>
</rss>