<?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[Matthew Adams - Elasticsearch Labs]]></title>
    <description><![CDATA[Articles and tutorials from the Search team at Elastic]]></description>
    <copyright><![CDATA[© 2026. Elasticsearch B.V. All Rights Reserved]]></copyright>
    <image>
      <title><![CDATA[Matthew Adams - Elasticsearch Labs]]></title>
      <url>https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1121c0bf0e8a6e65/6a88da6340a1841030ef456f/search-labs-thumbnail.png</url>
      <link>https://www.elastic.co/search-labs/author/matthew-adams</link>
    </image>
    <link>https://www.elastic.co/search-labs/author/matthew-adams</link>
    <atom:link href="https://www.elastic.co/search-labs/rss/author/matthew-adams.xml" rel="self" type="application/rss+xml"/>
    <language><![CDATA[en]]></language>
    <lastBuildDate>Sat, 24 Oct 2026 11:14:53 GMT</lastBuildDate>
  <item>
    <title><![CDATA[Three SLOs every search team needs: monitoring search latency, availability and quality with OpenTelemetry]]></title>
    <description><![CDATA[Your OpenTelemetry search spans already carry the signals for SLOs, burn rate alerts, anomaly detection and incident response, and this post shows how to build all four in Elastic Observability.]]></description>
    <content:encoded><![CDATA[<p>Every search request your API handles already emits an OpenTelemetry span with latency, error, and result count data. You built that instrumentation for product analytics. Turns out it also gives you search monitoring for free. This post takes those spans and turns them into three SLOs (99% of queries under 250ms, 99.9% availability, zero-results rate below 15%), then layers on alerting, anomaly detection and an incident response workflow, all with Elastic Observability's built-in tooling. If you instrumented your search API following Blogs 2-4, you can set this up in an afternoon.</p><h2>What you'll discover</h2><p>In this post, you'll learn how to:</p><ul><li><p>Use Elastic APM's built-in views to explore search latency and throughput and to explore errors.</p></li><li><p>Define Service Level Objective (SLOs) for search, including latency targets and availability, along with search quality.</p></li><li><p>Create SLOs in Kibana that track your search health over time, with burn rate alerting.</p></li><li><p>Build operational dashboards with Elasticsearch Query Language (ES|QL) that show latency percentiles and time breakdowns and that include trends.</p></li><li><p>Set up alerts for latency regressions and error spikes, along with zero-results rate increases.</p></li><li><p>Establish an incident response pattern for search degradation.</p></li></ul><h2>What you'll need</h2><ul><li><p>Search instrumentation from <a href="https://www.elastic.co/search-labs/blog/series/search-analytics-opentelemetry">Blogs 2–4</a> (search spans with <code>search.*</code>  attributes in Elastic).</p></li><li><p>Kibana access with permissions to create SLOs and alert rules.</p></li><li><p>Basic understanding of SLOs. (We'll explain the search-specific parts.)</p></li><li><p>An Elastic cluster with an Enterprise subscription, an <a href="https://www.elastic.co/cloud?utm_campaign=G-TXT-EMEA-UK+CA-Core-EN-Lead_Gen-CloudTrials-BR&amp;utm_content=Brand-Cloud&amp;utm_source=google&amp;utm_medium=cpc&amp;device=c&amp;utm_term=elastic%20cloud%20trial&amp;utm_id=701610000005lJVAAY&amp;gad_source=1&amp;gad_campaignid=22979576770&amp;gbraid=0AAAAADrDgoKn2OUpnHv5-QMO2ZrcBzj4K&amp;gclid=Cj0KCQjwjb3SBhDgARIsAMKiWziycspjFFEKHsgcIEsVdAYu6qNwIrTL27hQoMuX4eZbcm9oFox0tBYaAmH1EALw_wcB">Elastic Cloud trial</a>, or <a href="https://www.elastic.co/docs/deploy-manage/deploy/self-managed/local-development-installation-quickstart">a local deployment</a> with the trial activated.</p></li></ul><h2>Why search monitoring matters beyond cluster health</h2><p>Search is the primary navigation path for a significant share of visitors, and search-initiated sessions tend to show stronger purchase intent than browse sessions. A search outage is a revenue event, rather than a minor feature degradation. A latency regression from 100ms to 500ms changes user behavior before anyone files a ticket.</p><p>Most platform teams monitor search at the infrastructure level, checking whether the Elasticsearch cluster is healthy and whether nodes are responding. They also determine whether the disk is full. These are all necessary but not sufficient. A cluster can be green while search quality silently degrades; for example, queries returning stale data after a bad index deployment or latency creeping up as the index grows. This could also include zero-results rates climbing because a synonym list wasn't updated.</p><p>The gap is between "search is up" and "search is working well."</p><p><strong>A note on examples:</strong> As we have throughout this series, we use ecommerce search for concrete examples, but these reliability patterns apply equally to any search application, including content platforms, internal knowledge bases, job boards, and support portals.</p><h3>OpenTelemetry search spans as monitoring signals</h3><p>If your team followed <a href="https://www.elastic.co/search-labs/blog/series/search-analytics-opentelemetry">Blogs 2–4</a> in this series, every search request already emits an OTel span with <code>search.*</code> attributes. These include the query text, result count, Elasticsearch execution time, and error status. Those spans land in <code>traces-generic.otel-default</code>  in Elastic.</p><p><strong>Following along with code?</strong> The <a href="https://github.com/elastic/elasticsearch-labs/tree/main/supporting-blog-content/search-analytics-otel">reference project</a> has all the instrumentation from <a href="https://www.elastic.co/search-labs/blog/series/search-analytics-opentelemetry">Blogs 2–4</a>. Generate traffic, and then follow along with the SLO and dashboard setup below. See <code>queries/blog6_reliability.esql </code> for ready-to-run queries.</p><p>The search team built that instrumentation for <em>product analytics</em>; that is, understanding what users search for and measuring click-through rates (CTRs) and conversion rates. They’ve prioritized relevance work, but the same spans contain everything you need for operational monitoring. Span duration gives you a latency signal, and <code>search.result_count == 0</code> value reflects quality. Span errors point to availability signals.</p><p>This post shows how to put this operational value to work, beginning with what you can see right now in Kibana and then building SLOs and alerting on top of it, along with incident response.</p><h2>Search monitoring out of the box with Elastic APM</h2><p>Before building anything new, let's look at what Elastic APM already gives you out of the box.</p><p>If your search API is instrumented with Elastic Distribution of OpenTelemetry (EDOT) (as in <a href="https://www.elastic.co/search-labs/blog/search-analytics-opentelemetry-esql">Blog 2</a>), it appears automatically as a service in the Elastic APM UI. Open <strong>Observability </strong>&gt;<strong> APM </strong>&gt;<strong> Services</strong> in Kibana, and select your search service (named <code>search-analytics-demo</code> if you're using the reference project). You'll immediately see the following:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt901b364e81dae2dc/6a8d6f3e0897900dc2ef9425/image6.gif" alt=" Elastic APM service overview page for an OpenTelemetry-instrumented search API showing Overview, Transactions and Errors tabs" /><h3>Elastic APM service overview for search</h3><p>The service overview page shows latency distribution and throughput over time, along with error rate, and doesn’t require configuration. You can see at a glance whether search is healthy, and the time-series charts make regressions obvious. If latency crept up after yesterday's deployment, you'll see it here.</p><h3>Trace waterfall: breaking down search request latency</h3><p>Click into any transaction, and you'll see the <em>trace waterfall</em>, which is a visual breakdown of every span in the request. For a search API call, this typically shows:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltb005066de9a98d55/6a8d6f82b74e9d482044a3b3/blog6-trace-waterfall-span-details.gif" alt="Elastic APM transaction view for POST /api/search showing search latency distribution, throughput, and failed request rate" /><p>The waterfall makes the invisible visible. You can see that the 503ms API response breaks down into HTTP handling, a 241ms query rules lookup, and a 260ms Elasticsearch query,  plus the custom <code>search</code> span (36ms) carrying all of our <code>search.*</code> attributes. Click any span, and the metadata flyout shows exactly what was captured: <code>search.query: "usb hub"</code><code>,</code> <code>search.result_count: 33</code>, <code>search.took_ms: 43</code>, the index name, hit IDs, and more.</p><p><a href="https://www.elastic.co/search-labs/blog/search-analytics-opentelemetry-esql">Blog 2</a> discussed the gap between <code>search.took_ms </code> and span duration. The waterfall shows you exactly where that gap lives, without writing any queries.</p><h3>Automatic search error capture with OpenTelemetry</h3><p>One of the most valuable things OTel auto-instrumentation gives you is <em>automatic error capture</em>. When an Elasticsearch query fails because of issues like a tripped circuit breaker or a timeout, or if an index isn’t found,  the span records the exception type and message, along with the stack trace. <a href="https://www.elastic.co/search-labs/blog/search-conversion-tracking-opentelemetry">Blog 4</a> mentioned this as a side benefit of span-based conversion tracking; here it becomes an operational lifeline.</p><p>The errors tab on your service page automatically aggregates these, grouped by error type and frequency. The instrumentation captures the details for you, so you don't need custom error handling or logging. During an incident, this is often the fastest way to understand what's actually failing.</p><h3>Service map: search API and Elasticsearch dependencies</h3><p>The <a href="https://www.elastic.co/docs/solutions/observability/apm/service-map">service map</a> shows dependencies between your search API and Elasticsearch, making it easy to see whether a latency problem is in your service or in the cluster it depends on.</p><p>All of this is available the moment you deploy the instrumentation from <a href="https://www.elastic.co/search-labs/blog/search-analytics-opentelemetry-esql">Blog 2</a>, without building any dashboards or writing any queries. This is the foundation everything else in this post builds on.</p><h2>Defining service level objectives for search</h2><p>An SLO defines <em>good enough</em> in measurable terms. You define what <em>working</em> means, measure it continuously, and alert when you're burning through your error budget too fast, instead of reacting when something breaks.</p><p>Elastic Observability has a <a href="https://www.elastic.co/guide/en/observability/current/slo.html">built-in SLO framework</a> that handles <a href="https://www.elastic.co/guide/en/observability/current/slo.html#slo-important-concepts">Service Level Indicator</a> (SLI) calculation and budget tracking. It also takes care of burn rate alerting. You create SLOs directly in Kibana. No ES|QL or custom pipelines are required for the core indicators.</p><h3>Three SLOs every search service needs</h3><p>Navigate to <strong>Observability </strong>&gt; <strong>SLOs</strong> in Kibana, and click <strong>Create SLO</strong>. The <a href="https://www.elastic.co/guide/en/observability/current/slo-create.html">SLO creation workflow</a> walks you through three steps: Define the SLI (what to measure), set the objective (the target), and describe the SLO.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt2ea1fc523a27cb57/6a8d7037bc1d3a69d7701bba/blog6-slo-creation-form.gif" alt="Creating a search latency SLO in Kibana showing SLI preview chart, rolling time window, and 99% target objective setting" /><h4>1. Search latency SLO: 99% of queries under 250ms</h4><p>Indicator type: Elastic APM latency target: 99% of searches complete in under 250ms.</p><p>The Elastic APM latency indicator is purpose-built for this. Select your search service (<code>search-analytics-demo</code>), and set the threshold to 250ms.  Elastic handles the rest, including calculating the percentage of transactions below the threshold and tracking your error budget over time.</p><p>Note: The Elastic APM latency indicator measures all HTTP transactions for the <code>search-analytics-demo</code> service, including health checks and click/cart/checkout endpoints, along with static asset requests, not just the <code>POST /api/search</code> endpoint. For a search-only latency SLO, use a custom Kibana Query Language (KQL) indicator with <code>name: "search" AND attributes.search.query: *</code> on the <code>traces-generic.otel-default</code><code> index</code>. The Elastic APM indicator is still valuable for whole-service health. For full coverage, combine both.</p><p>This measures end-to-end span duration; that is, what the user actually experiences. If your Elasticsearch query takes 50ms but the user waits 300ms because of network overhead or slow application logic, this SLO catches it. Use <code>search.took_ms</code> in the Elastic APM waterfall to diagnose <em>where</em> the latency lives when the SLO starts burning.</p><h4>2. Search availability SLO: 99.9% success rate</h4><p>Indicator type: Elastic APM availability <strong>t</strong>arget: 99.9% of searches succeed.</p><p>The Elastic APM availability indicator calculates the percentage of successful transactions for your service. When the Elasticsearch client throws an exception or the search endpoint returns a 5xx, the span's status records an error and this SLO counts it.</p><p>Note: Like the latency SLO, the Elastic APM availability indicator covers all HTTP transactions on <code>search-analytics-demo</code>, not just <code>POST /api/search</code>. Click/cart/checkout errors will consume this budget. For a search-only availability SLO, use a custom KQL indicator with <code>name: "search" AND attributes.search.query: *</code> for good events and <code>name: "search"</code> as the total query.</p><p>An 0.1% error budget on 100,000 daily searches means that you can tolerate 100 errors per day. That's tight, but search errors are hard failures and the user gets nothing. Availability SLOs should be stricter than latency SLOs.</p><h4>3. Search quality SLO: tracking zero-results rate</h4><p>Indicator type: Custom KQL target: 85% of searches return at least one result (zero-results rate &lt; 15%); index: <code>traces-generic.otel-default</code>; <strong>g</strong>ood query: <code>name: "search" AND attributes.search.result_count &gt; 0</code>; total query:<code>name: "search" AND attributes.search.query: *</code>.</p><p></p><p>Note on KQL versus ES|QL: The SLO framework uses KQL for its indicator filters rather than ES|QL. KQL uses <code>field: value</code>syntax and is the same language you see in the Kibana search bar. The ES|QL queries throughout this series are for ad hoc analysis and dashboards; KQL here is the SLO indicator's document filter. Both query the same <code>traces-generic.otel-default</code> index.</p><p>This is the SLO that surprises most teams. A search that returns an empty result set isn't an error; HTTP status is 200 and the span status is OK. Plus, no exception was thrown. But from the user's perspective, it failed. They asked for something and got nothing.</p><p>The quality SLO uses the custom KQL indicator type because it relies on our custom <code>search.result_count</code> attribute, which the built-in Elastic APM indicators don't know about. But the SLO framework handles everything else, including budget tracking and burn rate calculation, along with alerting.</p><p>A sudden spike in zero-results rate, such as from 12% to 40% over an hour, is almost always an infrastructure event, like a failed index deployment or a mapping change that broke queries. It could also be a synonym list misconfiguration. That's an operational problem, not a relevance problem.</p><h3>Reading your search health in the SLO overview</h3><p>Once the latency, availability and quality SLOs are created, the SLO overview page shows your search health at a glance:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt9e9ed72bf5840749/6a8d706fd05d4c5a8957f946/blog6-slo-overview-drill-in.gif" alt="Search Latency SLO detail showing 50% observed against 99% objective, burn rate windows, historical SLI, and error budget" /><p></p><p>Each SLO shows the current value, the target, the remaining error budget, and the burn rate. Green means <em>healthy</em>:  Search availability is at 100%, and Search quality is just above its 85% target. Red means <em>violated</em>: Search latency is at 50% against a 99% objective, with the burn rate breached at 200x the sustainable rate. When a budget bar starts shrinking faster than expected, you know something changed, even before users complain.</p><p>Clicking into the SLO detail shows burn rate across multiple time windows (1h, 6h, 24h, 72h) and the historical SLI trend. It also shows remaining error budget. For the latency SLO, the Elastic APM latency indicator tracks the percentage of transactions below your 250ms threshold. For the quality SLO, the custom KQL indicator uses <code>traces-generic.otel-default</code> with the good query filtering for <code>attributes.search.result_count &gt; 0</code>. This is where the custom <code>search.*</code> attributes from <a href="https://www.elastic.co/search-labs/blog/search-analytics-opentelemetry-esql">Blog 2</a> pay off, since they're the foundation of meaningful SLOs.</p><h2>Burn rate alerting for search SLOs</h2><p>When you create an SLO through the Kibana UI, a default burn rate alert rule is automatically created. This is where the real operational value lives.</p><p><a href="https://www.elastic.co/guide/en/observability/current/slo-burn-rate-alert.html">Burn rate alerts</a> improve on threshold alerts ("error rate &gt; 1%"), which are noisy and miss slow degradation. : Burn rate alerts measure how fast you're consuming your error budget relative to the SLO window.</p><p>A burn rate of 1.0 means that you're spending budget at exactly the sustainable rate, but a burn rate of 10.0 means that you're burning 10x too fast and you'll exhaust the budget in 1/10th of the window.</p><p>The default burn rate rule uses a multi-window approach, with four severity levels:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt5794fbcc015d4076/6a8d709811115542160cfc88/blog6-burn-rate-alert-config.gif" alt="Selecting alert rule types in Elastic Observability including anomaly detection, APM anomaly, custom threshold and SLOs" /><p></p><p><strong>Severity</strong></p><p><strong>Burn rate</strong></p><p><strong>Long window</strong></p><p><strong>Short window</strong></p><p><strong>What it means</strong></p><p>Critical (page)</p><p>&gt; 14.4x</p><p>1 hour</p><p>5 minutes</p><p>Exhausts budget in ~50 hours</p><p>High (ticket)</p><p>&gt; 6.0x</p><p>6 hours</p><p>30 minutes</p><p>Exhausts budget in ~5 days</p><p>Medium (review)</p><p>&gt; 3.0x</p><p>24 hours</p><p>120 minutes</p><p>Exhausts budget in ~10 days</p><p>Low (awareness)</p><p>&gt; 1.0x</p><p>72 hours</p><p>360 minutes</p><p>Trending toward exhaustion</p><p>The short window prevents alerting on brief spikes that self-resolve, and the long window catches sustained degradation. Together, they balance responsiveness with alert fatigue.</p><h3>Routing search alerts to PagerDuty, Slack and Jira</h3><p>Alerts are only useful if they reach the right people in the right tools. Elastic's <a href="https://www.elastic.co/guide/en/kibana/current/alerting-getting-started.html">alerting framework</a> supports a wide range of <a href="https://www.elastic.co/guide/en/kibana/current/action-types.html">connectors</a> out of the box, including:</p><ul><li><p><strong>Incident management:</strong> PagerDuty, Opsgenie, xMatters for on-call routing.</p></li><li><p><strong>Chat:</strong> Slack, Microsoft Teams for team notifications.</p></li><li><p><strong>Case management:</strong> <a href="https://www.elastic.co/guide/en/kibana/current/jira-action-type.html">Jira</a>, <a href="https://www.elastic.co/guide/en/kibana/current/servicenow-action-type.html">ServiceNow</a> for automatic ticket creation when SLOs breach.</p></li><li><p><strong>Custom:</strong> Webhooks for integrating with any system via HTTP.</p></li></ul><p>You can also use Elastic's built-in <a href="https://www.elastic.co/guide/en/kibana/current/cases.html">cases</a> to track incidents directly within Kibana, linking alerts and traces in one place, along with investigation notes, with push to Jira or ServiceNow when escalation is needed.</p><p>A typical routing setup:</p><p></p><p><strong>Alert</strong></p><p><strong>Severity</strong></p><p><strong>Channel</strong></p><p>Latency SLO burn rate &gt; 14.4</p><p>Page</p><p>PagerDuty</p><p>Availability SLO burn rate &gt; 14.4</p><p>Page</p><p>PagerDuty + Slack</p><p>Quality SLO burn rate &gt; 6</p><p>Ticket</p><p>Jira (auto-create) + Slack</p><p>CTR anomaly (machine learning [ML] job)</p><p>Notification</p><p>Slack (search team)</p><h2>Anomaly detection for search quality</h2><p>Some search degradations are gradual shifts that slip past threshold-based alerts, rather than sudden spikes. A relevance regression after a model update might reduce CTR by 15% over a week, and latency might creep up by 5ms per day as the index grows. These are real problems, but they don't trigger burn rate alerts until it's too late.</p><p>Elastic's <a href="https://www.elastic.co/guide/en/machine-learning/current/ml-ad-overview.html">anomaly detection</a> is built for exactly this. It learns normal patterns in your search metrics and flags deviations automatically, and you don’t have to configure any thresholds. </p><h3>Detecting search quality degradation with ML anomaly detection</h3><ul><li><strong>Latency anomalies:</strong></li></ul><p> <a href="https://www.elastic.co/docs/reference/machine-learning/ootb-ml-jobs-apm">Elastic APM anomaly detection</a> can be enabled directly from the Elastic APM UI for your search service. It learns the typical latency distribution, including daily and weekly patterns, and alerts when behavior deviates. A gradual 5ms/day creep will eventually register as anomalous before it hits your SLO threshold.</p><ul><li><strong>CTR drops:</strong></li></ul><p>A relevance regression is invisible to traditional monitoring; latency is fine and errors are zero, plus the result counts are normal, but the ranking changed and users aren't clicking. Anomaly detection on click volume per query is a practical proxy: When a query that normally receives 20 first-click events per hour drops to 5, something likely changed.</p><p>To set this up: In <strong>Kibana</strong> → <strong>Machine Learning</strong> → <strong>Anomaly Detection</strong>, create a new job. Use the <strong>Multi-metric</strong> wizard, and select <code>traces-generic.otel-default</code> as the index. Configure a <code>count</code> detector on <code>attributes.search.first_click</code> split by <code>attributes.search.query</code>. This creates a per-query click-volume baseline and alerts when individual query engagement drops outside the expected range.</p><p>Note: This job detects click-volume anomalies per query, not CTR (which requires dividing clicks by searches). Click volume is a useful proxy (a CTR regression usually manifests as a drop in absolute click count), but be aware that a traffic surge with flat click volume would show as a CTR drop without triggering this alert. For true CTR anomaly detection, use a scheduled ES|QL transform to materialize hourly CTR values and run anomaly detection on the computed ratio.</p><p>Route the resulting ML alert rule to your Slack search channel.</p><ul><li><strong>Throughput shifts:</strong></li></ul><p>A sudden drop or unexpected surge in search volume can indicate upstream problems (like load balancer changes or traffic shifts) or downstream issues (such as search becoming unresponsive or users retrying).</p><p>Configure <a href="https://www.elastic.co/guide/en/machine-learning/current/ml-configuring-alerts.html">ML anomaly alert rules</a> to route these to your notification channels. These complement your SLO burn rate alerts; burn rates catch budget consumption, and anomaly detection catches pattern changes.</p><h2>Building a search monitoring dashboard with ES|QL</h2><p>SLOs tell you <em>whether</em> search is healthy. When they indicate a problem, you need a dashboard that tells you <em>why</em>.</p><p>The search team and the on-call team need different views of the same data. A search engineer wants query-level detail, such as which queries have low CTR and which ones return nothing. They’re also interested in where to invest in relevance. But an on-call SRE wants the operational picture, including whether search is fast and whether it’s up. It also wants to know whether search is degrading, and if so, since when.</p><h3>Search monitoring panels for the on-call dashboard</h3><p>Build it in <a href="https://www.elastic.co/guide/en/kibana/current/dashboard.html">Kibana dashboards</a> using <a href="https://www.elastic.co/guide/en/kibana/current/lens.html">Kibana Lens</a> panels. Lens supports <a href="https://www.elastic.co/docs/explore-analyze/visualize/esorql">ES|QL as a data source</a>, so the queries from <a href="https://www.elastic.co/search-labs/blog/series/search-analytics-opentelemetry">Blogs 2–4</a> can power dashboard panels directly. The key panels include:</p><ul><li><p><strong>Search throughput over time:</strong>  A sudden drop is often the first sign of a problem.</p></li><li><p><strong>Latency percentiles (p50, p95, p99) over time:</strong> When they diverge (p50 flat, p99 spikes), you have a subset of slow queries.</p></li><li><p><strong>Error rate over time:</strong> Spikes here mean hard failures.</p></li><li><p><strong>Zero-results rate over time:</strong> A step change upward, especially correlated with a deployment, means something changed in the index or query pipeline.</p></li></ul><p>The ES|QL for each panel follows the patterns from earlier blogs. For example, a latency percentile panel:</p>FROM traces-generic.otel-default
| WHERE name == "search"
AND attributes.search.query IS NOT NULL
| EVAL bucket = DATE_TRUNC(5 minutes, @timestamp)
| STATS
    p50 = PERCENTILE(attributes.search.took_ms, 50),
    p95 = PERCENTILE(attributes.search.took_ms, 95),
    p99 = PERCENTILE(attributes.search.took_ms, 99)
BY bucket
| SORT bucket<p>This includes three lines on one chart. When they diverge, such as when p50 stays flat but p99 spikes, you likely have a subset of queries that are slow while the majority are fine. That's a different diagnosis than all queries slowing down (cluster-level pressure).</p><h3>Drill-down panels: slowest queries and top zero-result queries</h3><p>For investigation, add a few detail panels, such as:</p><p><strong>Slowest queries:</strong> A table showing the queries with the highest p95 latency and their search volume. During an incident, this narrows the problem from "search is slow" to "these specific queries are slow."</p><ul><li><p><strong>Top zero-result queries:</strong> A table showing which queries most frequently return nothing. When zero-results rate spikes, this panel immediately shows which queries are responsible.</p></li></ul><p>These drill-down panels use the same ES|QL patterns as Blogs <a href="https://www.elastic.co/search-labs/blog/search-analytics-opentelemetry-esql">2</a> and <a href="https://www.elastic.co/search-labs/blog/search-click-tracking-opentelemetry-esql">3</a>, just surfaced on a persistent dashboard instead of run ad hoc.</p><h2>Search incident response using OpenTelemetry traces</h2><p>As an example, an alert fires, noting that search latency has spiked. What happens now?</p><p>The trace data from Blog 2's instrumentation gives you a structured path from symptom to root cause.</p><h3>Step 1: Assess scope</h3><p>Start at the on-call dashboard, and get answers to the basics:</p><ul><li><p><em>When did it start?</em> Narrow the time range to the degradation window.</p></li><li><p><em>How bad is it?</em> Is p50 affected (all queries slow) or just p99 (a subset)?</p></li><li><p><em>Is it just search?</em> Check the Elastic APM service map to determine whether the Elasticsearch dependency is also degraded.</p></li></ul><h3>Step 2: Find the problem queries</h3><p>If the problem is a subset of queries (p99 spike but p50 is fine), use the slowest queries panel or run:</p>FROM traces-generic.otel-default
| WHERE name == "search"
AND attributes.search.query IS NOT NULL
  AND attributes.search.took_ms &gt; 100
| STATS
    count = COUNT(*),
    avg_ms = ROUND(AVG(attributes.search.took_ms), 0),
    max_ms = MAX(attributes.search.took_ms)
BY attributes.search.query
| SORT count DESC
| LIMIT 10<p>Adjust the <code>100</code> ms threshold to match your environment's normal range. It can be lower for a fast cluster or higher if your data volume makes 100ms typical. This narrows the problem from "search is slow" to "these specific queries are slow." That's the difference between restarting the cluster and investigating a specific query pattern.</p><h3>Step 3: Drill into the trace waterfall</h3><p>Pick a slow query, and open it in the Elastic APM trace view. The waterfall shows exactly where time was spent. (Refer back to the trace waterfall GIF above to see a real example of a <code>POST /api/search</code>  trace broken down into its component spans.)</p><p>The overhead gap between <code>search.took_ms</code> (Elasticsearch time) and span duration (end-to-end time) is your diagnostic tool:</p><p></p><p><strong>Scenario</strong></p><p><strong>search.took_ms</strong></p><p><strong>Span duration</strong></p><p><strong>Diagnosis</strong></p><p>Elasticsearch slow</p><p>400ms</p><p>430ms</p><p>Elasticsearch problem: Check slow log, cluster metrics.</p><p>App slow</p><p>50ms</p><p>350ms</p><p>Application / network overhead: Check serialization, network.</p><p>Both slow</p><p>400ms</p><p>700ms</p><p>Multiple issues: Investigate both.</p><p></p><p>If the problem is in Elasticsearch, drill into the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/search-profile.html">Search Profile API</a> or <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/monitor-elasticsearch-cluster.html">cluster monitoring</a>. If it's application overhead, look at the spans around the search span in the waterfall.</p><h3>Step 4: Correlate with events</h3><p>Check whether the degradation correlates with:</p><ul><li><p><strong>Deployments:</strong> Did someone deploy a new version of the search service or push a new index?</p></li><li><p><strong>Cluster events:</strong> Is Elasticsearch under memory pressure, or are there long garbage collection pauses? Or maybe the disk is I/O saturated?</p></li><li><p><strong>Network:</strong> Is latency between the search service and Elasticsearch elevated?</p></li></ul><p>Elastic Observability's unified platform makes this correlation straightforward because traces, logs, metrics, and infrastructure data all live in the same Kibana instance. You're adding filters in the same interface, rather than switching between tools.</p><h2>Going further: Infrastructure metrics and cost attribution</h2><p>This post focuses on what you get from trace data; that is, the spans your search API already emits. But OTel and Elastic Observability support a wider instrumentation picture that becomes valuable as your search infrastructure matures.</p><ul><li><p><strong>Infrastructure metrics.</strong> Adding host and container metrics (like CPU, memory, disk I/O, and network) alongside your traces lets you correlate search performance with infrastructure use. When p99 latency spikes, you can immediately see whether the Elasticsearch nodes are under memory pressure and whether garbage collection  pauses are increasing. You can also check whether disk I/O is saturated, and you can do all this in the same Kibana interface. The <a href="https://www.elastic.co/guide/en/fleet/current/elastic-agent-installation.html">Elastic Agent</a> collects these automatically for your infrastructure, and the <a href="https://www.elastic.co/guide/en/observability/current/analyze-hosts.html">infrastructure monitoring UI</a> surfaces them alongside your Elastic APM data.</p></li></ul><ul><li><p><strong>Total cost attribution (TCA).</strong> With infrastructure metrics flowing alongside traces, you can start attributing infrastructure costs to specific services and operations. How much compute does your search service consume? How does that correlate with query volume? If a new ranking model doubles CPU usage per query, you can see the cost impact directly. This is particularly valuable for teams running search on cloud infrastructure where costs scale with resource consumption; understanding the cost per search helps justify infrastructure investment and identify optimization opportunities.</p></li></ul><ul><li><p><strong>Logs correlation.</strong> OTel auto-instrumentation injects trace context (such as trace ID and span ID) into your application logs. This means that when you're investigating a slow search in the trace waterfall, you can click through to the exact log lines from that request, including Elasticsearch slow log entries and application debug output. It also includes error details that don't fit in span attributes. The <a href="https://www.elastic.co/guide/en/observability/current/application-logs.html">logs correlation</a> feature automatically ties them together.</p></li></ul><p>These are natural next steps once you have traces working. Each one extends the same unified platform, without new tools or separate pipelines.</p><h2>How search analytics and search monitoring share one data pipeline</h2><p>Here's how it all fits together:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt540076694fa1cbcc/6a8d717236492a486785f109/image3.png" alt="Search monitoring workflow: out-of-the-box APM features, SLO and alert definition, dashboard building, incident response" /><p></p><p>The data flows from the instrumentation you built in Blog 2. This one investment supports two audiences: The search team gets product analytics (<a href="https://www.elastic.co/search-labs/blog/series/search-analytics-opentelemetry">Blogs 2–5</a>), and the SRE team gets operational monitoring (this post). Neither team needs separate data pipelines.</p><h2>Getting started with search monitoring in Elastic</h2><p>This is the last post in the series, and it brings us full circle. <a href="https://www.elastic.co/search-labs/blog/search-analytics-opentelemetry">Blog 1</a> describes the vision: Instrument search once with OTel, and send spans to Elastic. Then use ES|QL to answer any question about search behavior. <a href="https://www.elastic.co/search-labs/blog/series/search-analytics-opentelemetry">Blogs 2–4</a> build the instrumentation and analytics, and <a href="https://www.elastic.co/search-labs/blog/search-analytics-relevance-click-streams">Blog 5</a> shows how to feed that data back into relevance improvements. This post shows how the same data powers operational monitoring, including SLOs, alerting, anomaly detection, and incident response.</p><p>The key takeaway for search engineers is that the instrumentation you built for analytics already generates the signals. The SLOs and alerts are built-in capabilities of Elastic Observability, as are the dashboards. You're closer to production-grade search monitoring than you might think. Observability isn't a separate discipline you need to learn from scratch. </p><p>If you've been following along and built the instrumentation from Blogs 2–4, start here:</p><ol><li><p><strong>Open Elastic APM:</strong> Look at your search service, and explore a trace waterfall. You can also check the errors tab.</p></li><li><p><strong>Create three SLOs:</strong> Latency (Elastic APM latency), availability (Elastic APM availability), and quality (custom KQL for zero-results).</p></li><li><p><strong>Enable anomaly detection:</strong> One click in the Elastic APM UI for latency anomalies.</p></li><li><p><strong>Build the on-call dashboard:</strong> Four Lens panels with the queries from this post.</p></li></ol><p>By the end of an afternoon of work, your search service can have the same observability coverage as any other critical production system.</p><h2>Get started</h2><h3>Working code</h3><ul><li><p><a href="https://github.com/elastic/elasticsearch-labs/tree/main/supporting-blog-content/search-analytics-otel">Reference project:</a> Working code for the entire blog series; clone, configure, and run.</p></li></ul><h3>Elastic APM and traces</h3><ul><li><p><a href="https://www.elastic.co/guide/en/apm/guide/current/apm-overview.html">Elastic APM Overview:</a> Elastic APM concepts and trace analysis.</p></li><li><p><a href="https://www.elastic.co/guide/en/observability/current/apm-ui.html">Elastic APM UI:</a> Service overview, transactions, dependencies, errors.</p></li><li><p><a href="https://www.elastic.co/docs/solutions/observability/apm/service-map">Service Maps:</a> Dependency visualization and health.</p></li><li><p><a href="https://www.elastic.co/guide/en/observability/current/open-telemetry.html">OpenTelemetry Integration:</a> OTel ingestion in Elastic.</p></li></ul><h3>SLOs and alerting</h3><ul><li><p><a href="https://www.elastic.co/guide/en/observability/current/slo.html">SLOs in Elastic Observability:</a> Creating and managing SLOs.</p></li><li><p><a href="https://www.elastic.co/guide/en/observability/current/slo-create.html">Create an SLO:</a> SLI types (like Elastic APM latency, Elastic APM availability, or custom KQL), time windows, budgeting.</p></li><li><p><a href="https://www.elastic.co/guide/en/observability/current/slo-burn-rate-alert.html">SLO Burn Rate Alerts:</a> Multi-window burn rate alerting.</p></li><li><p><a href="https://www.elastic.co/guide/en/kibana/current/action-types.html">Alert Connectors:</a> Slack, PagerDuty, webhook, email integrations.</p></li><li><p><a href="https://www.elastic.co/guide/en/kibana/current/alerting-getting-started.html">Alerting Framework:</a> Rule types and configuration.</p></li><li><p><a href="https://www.elastic.co/guide/en/kibana/current/jira-action-type.html">Jira Connector:</a> Automatic ticket creation from alerts.</p></li><li><p><a href="https://www.elastic.co/guide/en/kibana/current/servicenow-action-type.html">ServiceNow Connector:</a> ITSM integration.</p></li><li><p><a href="https://www.elastic.co/guide/en/kibana/current/cases.html">Elastic Cases:</a> Built-in incident tracking with external push.</p></li></ul><h3>Anomaly detection</h3><ul><li><p><a href="https://www.elastic.co/guide/en/machine-learning/current/ml-ad-overview.html">Anomaly Detection Overview:</a> Unsupervised time series anomaly detection.</p></li><li><p><a href="https://www.elastic.co/docs/reference/machine-learning/ootb-ml-jobs-apm">Elastic APM Anomaly Detection:</a> Enable ML for latency, throughput, error rate.</p></li><li><p><a href="https://www.elastic.co/guide/en/machine-learning/current/ml-configuring-alerts.html">ML Anomaly Alert Rules:</a> Alerting on detected anomalies.</p></li></ul><h3>Dashboards and ES|QL</h3><ul><li><p><a href="https://www.elastic.co/guide/en/kibana/current/dashboard.html">Kibana dashboards:</a> Building operational dashboards.</p></li><li><p><a href="https://www.elastic.co/guide/en/kibana/current/lens.html">Lens:</a> Visualization editor.</p></li><li><p><a href="https://www.elastic.co/docs/explore-analyze/visualize/esorql">ES|QL in Lens:</a> ES|QL-powered dashboard panels.</p></li><li><p><a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/esql.html">ES|QL Overview:</a> Language reference.</p></li></ul><h3>Elasticsearch operations</h3><ul><li><p><a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/index-modules-slowlog.html">Slow Log Configuration:</a> Threshold-based query slow logging.</p></li><li><p><a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/search-profile.html">Search Profiling:</a> Profile API for query execution analysis.</p></li><li><p><a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/monitor-elasticsearch-cluster.html">Monitoring Elasticsearch:</a> Cluster stats, search rate, latency.</p></li></ul><h3>From this series</h3><ul><li><p><a href="https://www.elastic.co/search-labs/blog/search-analytics-opentelemetry">Modern search analytics with OpenTelemetry:</a> The vision.</p></li><li><p><a href="https://www.elastic.co/search-labs/blog/search-analytics-opentelemetry-esql">Instrument your search API </a>: Search spans and <code>search.*</code> attributes.</p></li><li><p><a href="https://www.elastic.co/search-labs/blog/search-click-tracking-opentelemetry-esql">Measuring search quality </a>: CTR, MRR, click distribution.</p></li><li><p><a href="https://www.elastic.co/search-labs/blog/search-conversion-tracking-opentelemetry">From clicks to conversions</a>: Conversion tracking and revenue attribution.</p></li><li><p><a href="https://www.elastic.co/search-labs/blog/search-analytics-relevance-click-streams">Personalizing search from behavior</a>: Judgment lists, rank features, Learning To Rank (LTR).</p></li></ul><p><em>This is the final post in a six-part series on search analytics with OpenTelemetry and Elastic. Start from the beginning: </em><a href="https://www.elastic.co/search-labs/blog/search-analytics-opentelemetry"><em>Modern search analytics with OpenTelemetry,</em></a><em> or to start building, jump to </em><a href="https://www.elastic.co/search-labs/blog/search-analytics-opentelemetry-esql"><em>Instrument your search API.</em></a></p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/opentelemetry-search-monitoring-slos</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/opentelemetry-search-monitoring-slos</guid>
    <category><![CDATA[Operations]]></category>
    <category><![CDATA[Analytics]]></category>
    <category><![CDATA[ES|QL]]></category>
    <dc:creator><![CDATA[Matthew Adams]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltb4ac8bfdb49b5f32/6a8d5eb7da6aeaa5fa37aae2/image1.png" length="0" type="image/png"/>
    <pubDate>Tue, 25 Aug 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Search relevance from click streams: Using Learn To Rank and behavioral signals with OpenTelemetry]]></title>
    <description><![CDATA[Learn how to turn click streams and behavioral signals from OpenTelemetry search analytics into judgment lists, rank features and Learn To Rank models that make search relevance improve over time.]]></description>
    <content:encoded><![CDATA[<p>Every click on a search result is an implicit relevance judgment, and conversions are a stronger signal. The search analytics you've been capturing through OpenTelemetry contain the behavioral data to improve search relevance. Techniques start simple, with fixes you can ship this week and build toward Learn To Rank (LTR) models trained on real click data. The instrumentation that surfaces problems also generates the training data to fix them.</p><h2>What you'll discover</h2><p>In this post, you'll learn how to:</p><ul><li><p>Build judgment lists from click data to evaluate and improve relevance.</p></li><li><p>Apply basic search tuning (field weights, boosts, query rules) informed by analytics.</p></li><li><p>Create rank features from behavioral signals, like popularity and conversion rate.</p></li><li><p>Understand LTR and how click data becomes training data.</p></li><li><p>Close the feedback loop between analytics and relevance improvement.</p></li></ul><h2>What you'll need</h2><ul><li><p>Search analytics data from the previous blogs in this <a href="https://www.elastic.co/search-labs/blog/series/search-analytics-opentelemetry">series</a> (<a href="https://www.elastic.co/search-labs/blog/search-analytics-opentelemetry-esql">search</a>, <a href="https://www.elastic.co/search-labs/blog/search-click-tracking-opentelemetry-esql">click</a>, and <a href="https://www.elastic.co/search-labs/blog/search-conversion-tracking-opentelemetry">conversion</a> spans in Elastic).</p></li><li><p>An Elasticsearch index with product data (the reference project includes sample data with rank features).</p></li><li><p>Familiarity with Elasticsearch queries (BM25 [Elasticsearch's default text scoring algorithm], <code>rank_feature</code>, function scores).</p></li></ul><h2>Search relevance techniques at a glance</h2><p><strong>Technique</strong></p><p><strong>Effort</strong></p><p><strong>What it improves</strong></p><p><strong>When to use</strong></p><p><strong>Field weight tuning</strong></p><p>Low</p><p>Relevance for attribute-rich queries</p><p>First step; quick wins</p><p><strong>Query rules</strong></p><p>Low–medium</p><p>Specific high-value queries</p><p>Known bad results for specific terms</p><p><strong>Rank features</strong></p><p>Medium</p><p>Blending behavioral signals with text score</p><p>Popularity, conversion, freshness boosts</p><p><strong>LTR</strong></p><p>High</p><p>Systematic ranking from click data</p><p>When you have ≥5k labeled query-doc pairs</p><h2>From search analytics to search relevance improvements</h2><p>Over the past three posts, you built a full instrumentation pipeline, including search spans with <code>search.*</code> attributes and click tracking with position data and click-through rate (CTR) / Mean Reciprocal Rank (MRR) metrics. This pipeline also includes conversion spans tying searches to revenue. And it all sits in <code>traces-generic.otel-default</code>, queryable with Elasticsearch Query Language (ES|QL).</p><p></p><p>Now you have dashboards, and you know that your CTR is 28%. You also know which queries generate revenue and which ones users abandon. Plus, you can tell your product manager exactly where the funnel leaks.</p><p></p><p>Now what?</p><p></p><p>The real value of search analytics is using behavioral data to improve relevance and close feedback loops. It’s also important to make search learn from its users. Your experience tells you that measurement is necessary but not sufficient, and a dashboard that shows poor ranking doesn't improve that ranking. </p><p></p><p></p><p></p><p>This post covers four practical areas:</p><p></p><ol><li><p><strong>Judgment lists:</strong> The foundation for evaluating and improving relevance.</p></li><li><p><strong>Basic search tuning:</strong> Field weights, boosts, and query rules informed by analytics.</p></li><li><p><strong>Rank features for personalization:</strong> Feeding behavioral signals back into ranking.</p></li><li><p><strong>LTR:</strong> Training machine learning (ML) models on click data to optimize ranking automatically.</p></li></ol><p></p><p>Each one draws directly from the <code>search.*</code> attributes you're already collecting, and they don’t require any new instrumentation.</p><h2>What are judgment lists?</h2><p>Before diving into specific techniques, it's worth understanding the concept that ties them all together: <em>judgment lists</em>.</p><p></p><p>A judgment list is a set of query-document pairs with relevance grades: For a given query, how relevant is each document? They look like this:</p><p></p><p><strong>Query</strong></p><p><strong>Document</strong></p><p><strong>Grade</strong></p><p><strong>Label</strong></p><p>"wireless headphones"</p><p>SKU-001 (Sony WH-1000XM5)</p><p>3</p><p>Highly relevant</p><p>"wireless headphones"</p><p>SKU-042 (AirPods Max)</p><p>2</p><p>Relevant</p><p>"wireless headphones"</p><p>SKU-099 (Wired earbuds)</p><p>0</p><p>Not relevant</p><p></p><p>Judgment lists serve three purposes:</p><p></p><ul><li><p><strong>Evaluating current relevance.</strong> Given a set of queries and known-good results, how well does your search rank them? Metrics like <a href="https://en.wikipedia.org/wiki/Discounted_cumulative_gain">Normalized Discounted Cumulative Gain</a> (NDCG) and the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/search-rank-eval.html">Rank Eval API</a> use judgment lists to score your ranking quality. This gives you a baseline before making changes.</p></li></ul><p></p><ul><li><p><strong>Measuring the impact of changes.</strong> When you adjust field weights or add synonyms, judgment lists tell you whether the change helped or hurt. The same is true when you change boosting rules. Run the same evaluation before and after: If NDCG went up, the change improved relevance for those queries.</p></li></ul><p></p><ul><li><p><strong>Training LTR models.</strong> LTR algorithms need labeled training data, such as, <em>For this query, these documents are relevant and those aren't</em>. Judgment lists are that training data.</p></li></ul><h3>Manual vs. automated judgment lists</h3><p>Traditionally, judgment lists are created by human assessors who manually rate documents for a set of test queries. This works well for a small number of high-value queries (your top 50 searches, for example), but it doesn't scale. A large catalog with thousands of distinct queries and frequent inventory changes makes manual assessment impractical.</p><p></p><p>This is where your click data becomes valuable. Every click is an implicit relevance judgment; that is, a signal that for a given query, a given document was relevant enough to engage with. Conversions are even stronger signals. By aggregating this data, you can build judgment lists automatically from real user behavior, at a scale that manual assessment can't match.</p><p></p><p>The trade-off is noise. Clicks are influenced by position bias (users click higher-ranked results more often regardless of relevance) and presentation effects. They’re also influenced by accidental clicks. We'll cover techniques to handle this noise later in this post. But the key insight is that approaches like personalization and LTR lean toward automated judgment lists because it's not scalable to manually create lists for every query or user segment, nor for every inventory change.</p><p></p><p>The <a href="https://www.elastic.co/search-labs/blog/judgment-lists-search-query-relevance-elasticsearch">judgment lists guide on Search Labs</a> covers the concept in depth, including how to structure lists for evaluation with the Rank Eval API.</p><h2>How search analytics identify search relevance problems</h2><p>The most immediate use of your analytics data is to identify and fix specific relevance problems. This only requires using data to direct manual improvements, no personalization or ML.</p><h3>Finding problem queries with search analytics</h3><p>The ES|QL queries from the click in your application give you per-query CTR and MRR. Sort by search volume descending and CTR ascending to find your highest-impact relevance failures:</p>FROM traces-generic.otel-default
| WHERE ((name == "search" AND attributes.search.query IS NOT NULL)
    OR attributes.search.first_click == true)
  AND attributes.search.query IS NOT NULL
| STATS
    searches = COUNT(CASE(name == "search" AND attributes.search.query IS NOT NULL, 1)),
    clicked = COUNT(CASE(attributes.search.first_click == true, 1))
  BY attributes.search.query
| EVAL ctr_pct = ROUND(100.0 * clicked / searches, 1)
| WHERE searches &gt; 5
| SORT ctr_pct ASC, searches DESC
| LIMIT 20<p>This is the CTR-by-query query re-sorted to surface problems first. The <code>WHERE searches &gt; 5</code> filter removes one-off queries that would dominate the low-CTR list with small sample noise.</p><ul><li><p><strong>Zero-CTR queries with results. </strong> Your ranking is returning content, but none of it’s compelling. These queries often benefit from synonym expansion. You can also improve them with boosting rules or pinned results.</p></li><li><p><strong>Low-CTR, high-volume queries.</strong> These are the biggest relevance investment opportunities, and they affect the most users.</p></li><li><p><strong>Low-MRR, high-CTR queries.</strong> Users find what they need but have to scroll for it. In these instances, the relevant documents exist, but they're ranked wrong.</p></li></ul><h3>Try it: Close the loop in the reference project</h3><p>If you have click data in the reference project(<code>python generate_traffic.py --blog 3 --sessions 100</code>), you can run the problem-query query above against <code>traces-generic.otel-default</code> and observe your lowest-CTR queries.</p><p></p><p>The reference project's <code>app.py</code> already uses <code>rank_feature</code> boosting: <code>rank_features.popularity</code>, <code>rank_features.conversion_rate</code>, <code>rank_features.margin_score</code>, and <code>rank_features.freshness</code> are indexed on every product and blended into the BM25 score at query time. To see the effect of adjusting a boost:</p><p></p><ol><li><p>Open <code>reference/app.py</code>, and find the <code>"should"</code> clause in <code>_build_search_query()</code>.</p></li><li><p>Change the <code>"boost"</code> value on <code>rank_features.popularity</code> from <code>2</code> to <code>5</code>.</p></li><li><p>Restart the server (<code>python app.py</code>) and rerun <code>generate_traffic.py --blog 3 --sessions 50</code>.</p></li><li><p>Compare the top-queries and click position distribution in ES|QL before and after.</p></li></ol><p></p><p>This is informed manual tuning using the behavioral data you collected, not ML. That's the pattern for the rest of this post: First, ES|QL tells you what's wrong. Then, configuration changes and, eventually, LTR models fix it.</p><h3>Field weights, boosts, and scoring for search relevance</h3><p>The simplest tuning lever in Elasticsearch is adjusting how different fields contribute to the relevance score. A <code>multi_match</code> query across <code>title</code>, <code>description</code>, and <code>brand</code> fields can weight the title higher because a match there is usually more relevant. Your analytics data tells you where these weights are wrong: If users consistently click products with titles that don't match the query but with descriptions that do, your title boost may be too aggressive.</p><p></p><p>Beyond field weights, you can incorporate business metrics directly into scoring. A common example is <em>boosting by profit margin</em>; that is, products with higher margins rank slightly higher when relevance scores are similar. The <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/rank-feature.html"><code>rank_feature</code> field type</a> is designed for exactly this: Index a numeric signal (margin, popularity, recency) alongside each document, and the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-rank-feature-query.html"><code>rank_feature</code> query</a> blends it with text relevance at query time. For more complex scoring combinations, like decay functions, weighted field values, and scripts, the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-function-score-query.html"><code>function_score</code> query</a> gives you full control. The <a href="https://www.elastic.co/search-labs/blog/function-score-query-boosting-profit-popularity-elasticsearch">boosting by profit and popularity guide on Search Labs</a> walks through this in detail, including the trade-offs between additive and <a href="https://www.elastic.co/search-labs/blog/bm25-ranking-multiplicative-boosting-elasticsearch">multiplicative boosting</a> approaches.</p><h3>Query rules for targeted search relevance fixes</h3><p>For targeted interventions, <a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/searching-with-query-rules">query rules</a> let you pin, boost, or exclude specific results for specific queries, and no model training is required. They're the search equivalent of a manual override.</p><p></p><p>Your analytics data tells you exactly where to apply them. When you see a zero-CTR query, like "returns policy", that consistently returns product results instead of the returns page, you can pin the returns page at position 1 for that query. If you notice a high-revenue query where the best-selling product appears at position 4, you can boost it.</p><p></p><p>Query rules are valuable precisely because they're simple. They solve known problems immediately while you build toward more sophisticated approaches. The <a href="https://www.elastic.co/search-labs/blog/elasticsearch-query-rules-ui-introduction">query rules tutorial</a> on Search Labs walks through the setup.</p><h2>Building judgment lists from click data</h2><p>The basic tuning above handles individual problems. To improve relevance systematically, you need judgment lists, and your click data can build them automatically.</p><h3>Converting click data into relevance grades</h3><p>The simplest approach is to count clicks per query-document pair and assign graded relevance:</p>FROM traces-generic.otel-default
| WHERE attributes.search.action == "click"
  AND attributes.search.query IS NOT NULL
| STATS
    click_count = COUNT(*),
    avg_position = AVG(attributes.search.result_click_position)
  BY attributes.search.query, attributes.search.result_click_id
| SORT attributes.search.query, click_count DESC<p>This gives you a table of (<code>query</code>, <code>document</code>, <code>click_count</code>, <code>avg_position</code>) tuples. The grading step maps click counts to relevance levels:</p><p><strong>Click count</strong></p><p><strong>Suggested grade</strong></p><p><strong>Label</strong></p><p>0</p><p>0</p><p>Not relevant (never clicked for this query)</p><p>1</p><p>1</p><p>Marginally relevant</p><p>2–3</p><p>2</p><p>Relevant</p><p>4+</p><p>3</p><p>Highly relevant</p><p></p><p>The thresholds depend on your traffic volume. For a high-traffic site, you might need 10+ clicks before calling something "highly relevant." For lower traffic, even two or three clicks is a meaningful signal. The point is that click frequency across users is a stronger signal than any single click.</p><p></p><p>You can strengthen judgments further by incorporating conversion data, a document that gets clicked <em>and</em> added to cart is a stronger relevance signal than one that gets clicked and abandoned:</p>FROM traces-generic.otel-default
| WHERE attributes.search.action IN ("click", "add_to_cart")
  AND attributes.search.query IS NOT NULL
| STATS
    clicks = COUNT(CASE(attributes.search.action == "click", 1)),
    carts = COUNT(CASE(attributes.search.action == "add_to_cart", 1))
  BY attributes.search.query, attributes.search.result_click_id
| SORT attributes.search.query, clicks DESC<p>A document with five clicks and three cart additions is a stronger candidate for grade 3 than one with five clicks and zero cart additions.</p><h3>Handling position bias in click data</h3><p>There's a problem with raw click counts: <em>position bias</em>. Users click position 1 more often because they <em>see</em> it first, not necessarily because it's the most relevant result. Blog 3 introduced this concept when we discussed click position distribution and referenced the foundational work by <a href="https://www.cs.cornell.edu/people/tj/publications/joachims_etal_05a.pdf">Joachims et al. (2005)</a>.</p><p></p><p>Position bias matters for judgment lists because it means raw click data over-weights whatever the current ranking happens to surface first. If you train an LTR model on biased judgments, it learns to replicate the existing ranking, which defeats the purpose.</p><p></p><p>Two practical approaches to handle this:</p><p></p><ul><li><p><strong>Position-normalized click rates.</strong> Instead of raw click counts, calculate click-through rate <em>per position</em>. A document clicked 3 out of 10 times when shown at position 5 is arguably more relevant than one clicked 5 out of 10 times at position 1. Position 5 gets less visibility, so a higher click rate there is a stronger relevance signal.</p></li></ul><ul><li><p><strong>Skip-above heuristics.</strong> When a user clicks position 3 but skips positions 1 and 2, those skipped documents are implicitly judged "less relevant" for that query. This is the core insight from Joachims et al.: Skipped-above results provide negative training signals. You can extract these pairs from your click data:</p></li></ul>FROM traces-generic.otel-default
| WHERE attributes.search.action == "click"
  AND attributes.search.query IS NOT NULL
| STATS
    min_click_position = MIN(attributes.search.result_click_position),
    max_click_position = MAX(attributes.search.result_click_position),
    click_count = COUNT(*)
  BY attributes.search.query_id, attributes.search.query
| WHERE min_click_position &gt; 1
| SORT click_count DESC<p>Queries where the minimum click position is greater than 1 are sessions where the user skipped the top result(s). The documents at those skipped positions, for those queries, are candidates for grade 0 in your judgment list, because the user saw them and chose something lower.</p><p></p><p>For production judgment list generation, the <a href="https://www.elastic.co/search-labs/blog/elasticsearch-learning-to-rank-introduction">LTR tutorial on Search Labs</a> walks through the complete pipeline. The <a href="https://www.elastic.co/search-labs/blog/training-learning-to-rank-models-elasticsearch-ubi-data">training LTR models with user behavior data</a> guide covers the specific workflow of deriving training data from click-through behavior, including the Clicks Over Expected Clicks (COEC) algorithm for debiasing. And the <a href="https://github.com/elastic/elasticsearch-labs/blob/main/notebooks/search/08-learning-to-rank.ipynb">LTR Jupyter notebooks</a> provide working Python code for feature extraction and model training.</p><h2>Rank features for search relevance personalization</h2><p>Basic tuning applies the same ranking to every user. Personalization means adapting results based on who’s searching (their history, preferences, or segment). Rank features are the most accessible way to do this in Elasticsearch.</p><h3>Building behavioral signals from click streams</h3><p>You can aggregate your click and conversion data at the document level to produce signals that reflect overall or segment-specific popularity:</p>FROM traces-generic.otel-default
| WHERE attributes.search.action IN ("click", "add_to_cart")
| STATS
    clicks = COUNT(CASE(attributes.search.action == "click", 1)),
    carts = COUNT(CASE(attributes.search.action == "add_to_cart", 1))
  BY attributes.search.result_click_id
| EVAL cart_rate_pct = ROUND(100.0 * carts / clicks, 1)
| SORT clicks DESC
| LIMIT 20<p>These document-level signals (click popularity, cart rate, and conversion rate) are indexed as <code>rank_feature</code> fields on each product document. The workflow:</p><p></p><ol><li><p>Run the ES|QL query above periodically (daily or weekly).</p></li><li><p>Write the results back to a <code>click_popularity</code> or <code>conversion_rate</code> <code>rank_feature</code> field on each product document.</p></li><li><p>Use a <code>rank_feature</code> query to blend text relevance with behavioral signals.</p></li></ol><p></p><p>The <code>rank_feature</code> query applies a saturation function by default: Initial popularity gains matter most, diminishing as values get large. This prevents a single viral product from dominating all queries. You can tune the function's pivot point to control how much influence the feature has relative to text relevance.</p><h3>Personalizing rank features by user segment</h3><p>The query above produces <em>global</em> popularity; that is, it’s the same for every user. Personalization comes from segmenting these signals. If you're tracking <code>enduser.pseudo.id</code> or <code>user.id</code>, you can compute features per user cohort:</p><p></p><ul><li><p><strong>Category affinity:</strong> How often does this user click products in "electronics" versus "clothing"?</p></li><li><p><strong>Price sensitivity:</strong> Does this user tend to click and convert on higher- or lower-priced items?</p></li><li><p><strong>Brand preference:</strong> Which brands does this user engage with most?</p></li></ul><p></p><p>These become additional rank features, applied at query time based on who's searching. The <a href="https://www.elastic.co/search-labs/blog/personalized-search-elasticsearch-ltr">personalized search with LTR</a> guide on Search Labs walks through training per-user ranking models. For a lighter approach, the <a href="https://www.elastic.co/search-labs/blog/ecommerce-search-relevance-cohort-aware-ranking-elasticsearch">cohort-aware ranking guide</a> shows how to use multiplicative boosting to personalize at the segment level without ML, just analytics-derived weights applied to rank features at query time.</p><h3>Combining multiple signals with the linear retriever</h3><p>When you're blending text relevance with semantic search and behavioral rank features, you need a way to combine them. Elasticsearch's <a href="https://www.elastic.co/search-labs/blog/linear-retriever-hybrid-search">linear retriever</a> gives you precise control over how different query types contribute to the final ranking. It computes a weighted sum of normalized scores, so you can say <em>text relevance matters 60%, semantic similarity 30%, popularity 10%</em> and adjust those weights based on your analytics. This is unlike Reciprocal Rank Fusion (RRF), which only considers relative rank positions.</p><p></p><p>This is particularly useful for personalization because you can vary the weights per user segment. A returning customer might get more weight on purchase history, while a first-time visitor gets more weight on global popularity.</p><p></p><p>If you're also using semantic search with embedding models, the <a href="https://www.elastic.co/search-labs/blog/jina-embeddings-v3-elastic-inference-service">Elastic Inference Service</a> (EIS) now offers GPU-accelerated embedding generation, including multilingual models, directly within Elastic Cloud, making it straightforward to add a semantic retriever alongside your text and behavioral signals.</p><h2>How Learn To Rank uses click streams to optimize ranking</h2><p>Rank features handle individual signals, but LTR handles all of them at once. It trains an ML model to combine features (like text relevance, popularity, CTR, recency, margin, or user affinity) into a single ranking function optimized for your users.</p><h3>What you need for Learn To Rank in Elasticsearch</h3><p>Elasticsearch has supported <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/learning-to-rank.html">native Learning to Rank since version 8.12</a> as an Enterprise subscription feature. The pipeline:</p><p></p><ol><li><p><strong>Judgment list.</strong> Query-document pairs with relevance grades (built from click data, as above).</p></li><li><p><strong>Feature extraction.</strong> Numeric signals for each query-document pair (BM25 score, popularity, CTR, recency, price, margin, user segment features).</p></li><li><p><strong>Model training.</strong> Typically XGBoost or LambdaMART, trained on your judgment list with features.</p></li><li><p><strong>Deployment.</strong> Upload the trained model to Elasticsearch via Eland, and use it as a rescorer.</p></li></ol><p></p><p>The click and conversion data from this series feeds steps 1 and 2, and the judgment list is your training labels. The document-level features from the rank features section above (click popularity, conversion rate, margin) are additional features alongside text relevance scores.</p><h3>Why automated judgment lists from click data scale better</h3><p>This is where the scalability argument for automated judgment lists becomes concrete. A manually curated judgment list might cover your top 100 queries well, but personalized ranking needs judgment data across thousands of queries and multiple user segments. You can't hire assessors to rate results for "wireless headphones" separately for electronics enthusiasts, budget shoppers, and professional audio engineers.</p><p></p><p>Automated judgment lists from click data scale to every query your users actually run and update as inventory and user behavior change. They can also be segmented by cohort. The <a href="https://www.elastic.co/search-labs/blog/training-learning-to-rank-models-elasticsearch-ubi-data">training LTR models with user behavior data</a> guide demonstrates this end-to-end workflow, showing how to go from raw click events to trained ranking models.</p><p></p><p>For teams looking to close this loop even further, the <a href="https://www.elastic.co/search-labs/blog/agentic-search-relevance-autotuning-elasticsearch">agentic autotuning approach</a> demonstrates using an AI agent to continuously monitor search quality and generate judgment lists from user interactions. It automatically retrains LTR models, turning the feedback loop into an autonomous system.</p><h3>What Learn To Rank requires: Traffic, pipeline, and evaluation</h3><p>LTR requires enough traffic to generate meaningful judgment lists and engineering time to build and maintain the pipeline. It also requires ongoing evaluation to ensure that the model improves over time. But for search applications with sufficient volume, it's the most effective way to make ranking learn from user behavior. The <a href="https://www.elastic.co/search-labs/blog/elasticsearch-learning-to-rank-introduction">LTR introduction on Search Labs</a> covers the full scope of what's involved.</p><h2>Elasticsearch Relevance Studio: Visual search relevance tuning</h2><p>Between query rules (manual, targeted) and LTR (ML, systemic), there's a middle ground: visual relevance tuning. <a href="https://elastic.github.io/relevance-studio/#/">Elasticsearch Relevance Studio</a> is a tool for comparing and tuning search configurations side by side. It lets you adjust boost values, field weights, and query structures while seeing the results update in real time.</p><p></p><p>Analytics data, especially CTR and MRR, tells you which queries to focus on. Start from a ranked list of problem queries (the low-CTR, high-volume queries from the ES|QL analysis above), and work through them systematically in Relevance Studio, instead of guessing which searches need tuning.</p><p></p><p>The workflow:</p><p></p><ol><li><p><strong>Identify problem queries.</strong> Run the CTR-by-query and MRR-by-query analyses.</p></li><li><p><strong>Open those queries in Relevance Studio.</strong> See current results alongside the tuned version.</p></li><li><p><strong>Adjust field weights and boosting.</strong> Experiment with configuration changes.</p></li><li><p><strong>Evaluate with judgment lists.</strong> Use the Rank Eval API to confirm that the change improves NDCG for your test queries.</p></li><li><p><strong>Measure the impact in production.</strong> Rerun the analytics after deploying changes, and compare CTR / MRR.</p></li></ol><p></p><p>This before-and-after loop is where analytics and tuning connect. With the data analytics you can prioritize the queries that affect the most users and verify that changes actually helped. Without this data, tuning is guesswork, and you're adjusting weights without knowing which queries matter or how to measure improvement. </p><p></p><p>If you are looking at tuning and evaluating relevance you should also investigate the <a href="https://elastic.github.io/relevance-studio/#/">Elasticsearch Relevance Studio</a> project which lets you directly compare search strategies. </p><h2>Closing the loop between search analytics and search relevance</h2><p>Search relevance improvement follows a consistent loop: measure quality with ES|QL analytics, apply changes (query rules, field weights, rank features or LTR models), then measure again.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltbc895531c8a5ff31/6a7ef484af7924b0b425af35/image1.png" alt="Diagram showing the search analytics feedback loop from click streams through judgment lists to search relevance improvement" /><p>The key insight is that the same instrumentation that measures search quality also generates the data to improve it. Your search spans produce the queries to analyze, and your click spans produce the judgment data for evaluation and LTR training. Plus, your conversion spans tell you which improvements matter most to the business.</p><h3>Measuring search relevance improvements after changes</h3><p>After deploying a change, like a new query rule or updated field weights, or deploying an LTR model, you need to know whether it helped:</p><p></p><p><strong>Metric</strong></p><p><strong>What it measures</strong></p><p><strong>How to compare</strong></p><p>CTR trend</p><p>Whether more users are clicking results after the change</p><p>Run the CTR-by-query ES|QL query for the week before and after; compare per-query percentages</p><p>MRR trend</p><p>Whether clicks are shifting toward higher-ranked positions</p><p>Compare mean reciprocal rank per query before and after using the MRR-by-query analysis</p><p>Conversion rate trend</p><p>Whether more clicks are turning into purchases or cart additions</p><p>Run the click-to-conversion ES|QL query for both periods; compare cart rate percentages</p><p>Revenue per query</p><p>Whether the business impact of the change is positive</p><p>Compare revenue attributed to affected queries via conversion spans before and after deployment</p><p></p><p>Run the same ES|QL queries before and after. If your change was a query rule for "laptop bag", compare that query's CTR and MRR from the week before to the week after.</p><h3>A/B testing with experiment attributes</h3><p>The <code>feature_flag.key</code> attribute  is designed for exactly this. Route a percentage of traffic to a new ranking configuration, and set <code>feature_flag.key</code> to the experiment name and optionally <code>feature_flag.result.variant</code> to the variant (for example, <code>"control"</code> or <code>"variant-boost-v2"</code>). Then propagate it to click spans the same way you propagate `query_id`, and compare metrics between groups:</p>FROM traces-generic.otel-default
| WHERE ((name == "search" AND attributes.search.query IS NOT NULL)
    OR attributes.search.first_click == true)
  AND attributes.feature_flag.key IS NOT NULL
| STATS
    searches = COUNT(CASE(name == "search" AND attributes.search.query IS NOT NULL, 1)),
    clicked = COUNT(CASE(attributes.search.first_click == true, 1))
  BY attributes.feature_flag.key, attributes.feature_flag.result.variant
| EVAL ctr_pct = ROUND(100.0 * clicked / searches, 1)<p>This gives you an A/B comparison of CTR by experiment variant. You don’t need a separate experimentation platform for basic comparisons, although you’ll want a proper framework for statistical rigor on sample sizes and significance.</p><h3>Monitoring for search relevance regressions</h3><p>Once you've identified your high-value queries, that is, the ones that drive revenue and have been tuned for good engagement, you need to protect them. Relevance regressions on your top 20 revenue-generating queries are business-critical incidents.</p><p></p><p>This is where monitoring comes in. In the next blog in the series, we'll cover setting up alerts on these metrics: CTR drops on high-revenue queries and MRR regressions after deployments. We’ll also cover conversion rate anomalies. The same ES|QL queries that power your analytics dashboards can drive alerting rules; the feedback loop covers measurement and improvement. It also provides operational protection.</p><h2>Search relevance improvement roadmap: Week 1 to Quarter 1</h2><p>Here's a practical starting point. You don't need to implement LTR on day one. The approaches build on each other:</p><p></p><ul><li><p><strong>Week 1: Evaluate and fix known problems.</strong> Run the CTR-by-query analysis. Find your zero-CTR queries with high search volume, and fix the worst ones with synonyms or pinned results via query rules. You could also use adjusted field weights. Use judgment lists (even a small manual one for your top queries) to confirm that the changes improve NDCG before deploying. You should get immediate, targeted impact.</p></li></ul><p></p><ul><li><p><strong>Month 1: Add behavioral rank features.</strong> Extract document-level click popularity and conversion rates from your analytics. Index them as <code>rank_feature</code> fields, and blend behavioral signals with text relevance using the linear retriever or `rank_feature` queries. Then consider simple business boosts like margin. This lifts baseline quality across all queries without manual intervention per query.</p></li></ul><p></p><ul><li><p><strong>Quarter 1: Automate with LTR.</strong> Once you have enough click data (typically several weeks of production traffic), build judgment lists automatically from click and conversion data. Train an LTR model that combines text features, behavioral features, and business features, and then deploy it as a rescorer. The ranking now learns from user behavior and improves as you collect more data.</p></li></ul><p></p><p>At each stage, measure the impact with the same metrics. CTR and MRR are your scorecards, as is conversion rate. If a change doesn't move them, it didn't help, regardless of how sophisticated the approach.</p><h2>What's next: Search reliability engineering and SLOs</h2><p>We've covered the full sequence: instrument search, measure quality, track conversions, and improve relevance. The missing piece is making sure it all keeps working.</p><p></p><p>Next, we close the series with search reliability engineering, including Service Level Objectives (SLOs) for search quality and alerting on metric regressions, along with operational dashboards that catch problems before users notice them. The same metrics you've been building become the basis for search health monitoring, turning your analytics pipeline into a reliability system.</p><p></p><h2>Get started</h2><h3>Working code</h3><ul><li><p><a href="https://github.com/elastic/elasticsearch-labs/tree/main/supporting-blog-content/search-analytics-otel">Reference project:</a> Working code for the entire blog series; clone, configure, and run.</p></li></ul><p></p><p></p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/search-analytics-relevance-click-streams</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/search-analytics-relevance-click-streams</guid>
    <category><![CDATA[Analytics]]></category>
    <category><![CDATA[ES|QL]]></category>
    <category><![CDATA[Relevance]]></category>
    <dc:creator><![CDATA[Matthew Adams]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt259007788af667fe/6a7ee686ef5bef8db54fa878/image2.png" length="0" type="image/png"/>
    <pubDate>Fri, 14 Aug 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[From search to checkout in 20 lines of code: building a 4-stage conversion funnel with OpenTelemetry]]></title>
    <description><![CDATA[Add cart and purchase tracking to your search analytics pipeline and use ES|QL to answer the question every product manager asks: which search queries drive the most revenue?]]></description>
    <content:encoded><![CDATA[<p>Your product manager wants to know which searches drive the most revenue. You can tell them what users search for (<a href="https://www.elastic.co/search-labs/blog/search-analytics-opentelemetry-esql">check out our second blog</a>) and what they click (<a href="https://www.elastic.co/search-labs/blog/search-click-tracking-opentelemetry-esql">in our third blog</a>), but not what they buy. Two new span types, add-to-cart and purchase, complete a four-stage funnel from search query to checkout, built on the same search.* attributes and ES|QL queries you've been using since Blog 2. About 20 lines of code, and every relevance decision you make gets a revenue number attached to it.</p><h2>What you'll discover</h2><p>In this post, you'll learn how to:</p><ul><li><p>Add conversion tracking (add-to-cart and purchase spans) with <code>search.*</code> attributes that tie back to the originating search.</p></li><li><p>Build a full search-to-revenue funnel: search → click → add-to-cart → purchase.</p></li><li><p>Write Elasticsearch Query Language (ES|QL) queries to calculate conversion rates, revenue per query, and average order value.</p></li><li><p>Identify where users drop off and which team should own each drop-off point.</p></li><li><p>Attribute revenue to specific search queries for prioritizing relevance work.</p></li></ul><h2>What you'll need</h2><ul><li><p>Click tracking from <a href="https://www.elastic.co/search-labs/blog/search-click-tracking-opentelemetry-esql">Blog 3</a> (search + click spans flowing to Elastic via OTel-native ingestion).</p></li><li><p>Backend endpoints for add-to-cart and checkout events (example code provided).</p></li><li><p>Basic understanding of ecommerce conversion funnels.</p></li></ul><h2>Why search revenue attribution matters</h2><p>Your product manager walks into a meeting and asks, "Which searches are driving the most revenue?"</p><p>You can tell them what users search for (<a href="https://www.elastic.co/search-labs/blog/series/search-analytics-opentelemetry">Blog 2</a>) and what they click (<a href="https://www.elastic.co/search-labs/blog/search-click-tracking-opentelemetry-esql">Blog 3</a>). But you can't tell them what they <em>buy</em>. The gap between "clicked a result" and "purchased a product" is where the business case for search investment lives, and right now it's invisible.</p><p>This post closes the loop. By adding two more span types (add-to-cart and purchase), you get a full funnel from search to revenue, built on the same <code>search.*</code> attributes and ES|QL queries you've been using since <a href="https://www.elastic.co/search-labs/blog/series/search-analytics-opentelemetry">Blog 2</a>.</p><h3>What search conversion tracking answers for your team</h3><p>Here's what conversion tracking lets you answer and why each question matters to different people on your team.</p><ul><li><p><strong>Which searches drive revenue?</strong> This is the product manager's question. When you can attribute dollars to specific queries, you can prioritize relevance work by business impact. A query with mediocre click-through rate (CTR) but high conversion value is more important than a high-CTR query that never leads to a purchase.</p></li></ul><ul><li><p><strong>Where do users drop off?</strong> The funnel from search to purchase has four stages: search, click, add-to-cart, and purchase. Each drop-off points to a different problem. High click-to-cart drop-off suggests that product pages aren't convincing. High cart-to-purchase drop-off is checkout friction rather than a search problem. Knowing <em>where</em> users abandon tells you <em>which team</em> should fix it.</p></li></ul><ul><li><p><strong>Which queries to protect?</strong> Once you know that "laptop bag" generates $12,000/month in attributed revenue, you treat it differently. Any relevance change that touches high-revenue queries gets extra scrutiny. You can set up monitoring (Blog 6, coming soon) to alert when conversion rates drop for your top-earning searches.</p></li></ul><p>Two new instrumentation points total about 20 lines of code, and they follow the same pattern as you’ve used before. You add attributes to spans, and query them with ES|QL.</p><p><strong>Following along with code?</strong> The <a href="https://github.com/elastic/elasticsearch-labs/tree/main/supporting-blog-content/search-analytics-otel">reference project</a> has conversion tracking ready to enable. Uncomment the Blog 4 sections in <code>app.py</code> and <code>app.js</code>, restart, and then generate traffic with <code>python generate_traffic.py --blog 4</code>.</p><h2>The four-stage search conversion funnel</h2><p>Before we write any code, here's the shape of what we're building. Each stage is an instrumentation point, and each creates spans in <code>traces-generic.otel-default</code>:</p>search          →  click              →  cart.add         →  checkout.complete
(Blog 2)           (Blog 3)              (this post)         (this post)
query_id=abc       query_id=abc          query_id=abc        query_id=abc
user_query=...     click_position=1      product_id=...      order_total=$149
result_count=15    product_id=...        quantity=1           item_count=2<p>The thread running through the entire chain is <code>search.query_id</code><code>.</code> The same identifier you derived from the trace ID in <a href="https://www.elastic.co/search-labs/blog/search-analytics-opentelemetry-esql">Blog 2</a> and used to link clicks to searches in <a href="https://www.elastic.co/search-labs/blog/search-click-tracking-opentelemetry-esql">Blog 3</a> now carries through to add-to-cart and purchase events. This is what makes revenue attribution possible: You can trace a purchase back to the search that started the journey.</p><h2>Add conversion tracking spans with OpenTelemetry</h2><h3>Add-to-cart span instrumentation</h3><p>When a user adds a product to their cart from a search results page (or from a product detail page they reached via search), you create a <code>cart.add</code> span. This captures the moment that intent turns into action.</p>@app.post("/api/cart/add")
async def add_to_cart(event: AddToCartRequest):  # reference project uses CartEvent
    with tracer.start_as_current_span("cart.add") as span:
        span.set_attribute("search.action", "add_to_cart")
        span.set_attribute("search.result_click_id", event.object_id)
        span.set_attribute("search.result_click_position", event.position)
        span.set_attribute("search.query_id", event.query_id)
        span.set_attribute("enduser.pseudo.id", event.client_id)
        span.set_attribute("cart.quantity", event.quantity)
        if event.price is not None:
            span.set_attribute("cart.price", event.price)
        if event.user_query:
            span.set_attribute("search.query", event.user_query)<p>This follows the same pattern as click tracking in <a href="https://www.elastic.co/search-labs/blog/search-click-tracking-opentelemetry-esql">Blog 3</a>; that is, an independent span linked to the originating search via <code>query_id</code>. The new attributes are <code>cart.quantity</code> and <code>cart.price</code> which let you aggregate revenue at query time.</p><h3>Purchase span instrumentation</h3><p>When the user completes checkout, you create a <code>checkout.complete</code> span. This is the revenue event, the one that answers the product manager's question.</p>@app.post("/api/checkout")
async def checkout(event: CheckoutRequest):  # reference project uses CheckoutEvent
    with tracer.start_as_current_span("checkout.complete") as span:
        span.set_attribute("search.action", "purchase")
        span.set_attribute("checkout.order_id", event.order_id)
        span.set_attribute("checkout.total_amount", event.total_amount)
        span.set_attribute("checkout.item_count", len(event.items))
        span.set_attribute("enduser.pseudo.id", event.client_id)
        if event.query_id:  # last search in journey
            span.set_attribute("search.query_id", event.query_id)
            span.set_attribute("search.query", event.user_query)<p></p><ul><li><p><strong>Spans capture errors automatically.</strong> This is a side benefit of using OTel spans for conversion events. If an add-to-cart or checkout call throws an unhandled exception, the span's status is automatically set to <code>ERROR</code> and the exception details are recorded. These errors are business-critical (a broken checkout flow means lost revenue), and they show up immediately in Elastic APM's error tracking, service maps, and alerting. You get conversion analytics <em>and</em> operational monitoring from the same instrumentation, with no extra code.</p></li></ul><ul><li><p><strong><code>checkout.total_amount</code></strong><strong> is the revenue number.</strong> This is what you'll aggregate in ES|QL to get revenue-by-query. It represents the order total, not the price of a single item.</p></li></ul><ul><li><p><strong><code>search.query_id</code></strong><strong> is conditional.</strong> Not every purchase originates from search. Users browse categories and follow promotional links. Then they return to their cart days later. The <code>if event.query_id:</code> guard ensures that you only attribute purchases to search when there's a genuine connection. Purchases without a <code>query_id</code> still get recorded; they just don't appear in search attribution queries.</p></li></ul><ul><li><p><strong><code>search.query</code></strong><strong> is set on both cart and purchase spans.</strong> This is a deliberate denormalization. You could join back to searches via <code>query_id</code> to get the query text, but while ES|QL does support <code>LOOKUP JOIN</code>, it’s likely not the right choice here due to the need to optimize the lookup index. By putting the query text directly on conversion spans, your revenue-by-query and cart-by-query queries are single, straightforward aggregations.</p></li></ul><h3>Span attributes for cart and purchase events</h3><p><strong>Add-to-cart attributes:</strong></p><p><strong>Attribute</strong></p><p><strong>Type</strong></p><p><strong>Required</strong></p><p><strong>Purpose</strong></p><p><code>search.action</code></p><p>string</p><p>yes</p><p><code>"add_to_cart"</code></p><p><code>search.result_click_id</code></p><p>string</p><p>yes</p><p>Product document ID</p><p><code>search.result_click_position</code></p><p>int</p><p>yes</p><p>Position in results when added</p><p><code>search.query_id</code></p><p>string</p><p>yes</p><p>Links to originating search</p><p><code>enduser.pseudo.id</code></p><p>string</p><p>yes</p><p>Client/device identifier (OTel Semantic Conventions  [SemConv])</p><p><code>cart.quantity</code></p><p>int</p><p>yes</p><p>Quantity added</p><p><code>cart.price</code></p><p>float</p><p>optional</p><p>Product price</p><p><code>search.query</code></p><p>string</p><p>recommended</p><p>The search query text (for cart-by-query analysis)</p><p><strong>Purchase attributes:</strong></p><p><strong>Attribute</strong></p><p><strong>Type</strong></p><p><strong>Required</strong></p><p><strong>Purpose</strong></p><p><code>search.action</code></p><p>string</p><p>yes</p><p><code>"purchase"</code></p><p><code>checkout.order_id</code></p><p>string</p><p>yes</p><p>Unique order identifier</p><p><code>checkout.total_amount</code></p><p>float</p><p>yes</p><p>Order total</p><p><code>checkout.item_count</code></p><p>int</p><p>yes</p><p>Number of items purchased</p><p><code>enduser.pseudo.id</code></p><p>string</p><p>yes</p><p>Client/device identifier (OTel SemConv)</p><p><code>search.query_id</code></p><p>string</p><p>recommended</p><p>Links to originating search (last search in journey)</p><p><code>search.query</code></p><p>string</p><p>recommended</p><p>The search query text (for revenue-by-query analysis)</p><p>These follow the same <code>search.*</code> namespace from Blogs 2 and 3, with new <code>cart.*</code> and <code>checkout.*</code> prefixes for conversion-specific data. With OTel-native ingestion, all attributes are stored under <code>attributes.*</code> with dot notation preserved. That means no more mapping strings to <code>labels.*</code> and numbers to <code>numeric_labels.*</code>. A <code>search.query</code> attribute is queryable as <code>attributes.search.query</code>. Likewise, <code>checkout.total_amount</code> is queryable as <code>attributes.checkout.total_amount</code>.</p><h3>User identity: connecting search to purchase across sessions</h3><p>You'll notice that <code>enduser.pseudo.id</code> appears on every span type in this series. It's the <em>minimum identity level</em>; that is, a persistent identifier stored in the browser's localStorage that ties events to a device across sessions.</p><p>For conversion tracking, identity becomes more important. You need to connect a search on Monday to a purchase on Tuesday, or correlate cart additions across tabs. Our schema supports three identity levels, aligned with OTel semantic conventions:</p><p><strong>Attribute</strong></p><p><strong>Persistence</strong></p><p><strong>Purpose</strong></p><p><code>enduser.pseudo.id</code></p><p>Permanent (localStorage)</p><p>Device/browser identifier (OTel SemConv)</p><p><code>session.id</code></p><p>Per-visit (sessionStorage)</p><p>Groups events within a single visit (OTel SemConv)</p><p><code>user.id</code></p><p>Account (auth system)</p><p>Authenticated user (OTel SemConv)</p><p>For the funnel queries in this post, <code>enduser.pseudo.id</code> is sufficient. It links the journey from search to purchase within a browser. If your users authenticate, adding <code>user.id</code> enables cross-device attribution (searched on mobile, purchased on desktop) and richer personalization. <code>session.id</code> helps disambiguate when the same client has multiple active sessions.</p><p>All three are optional on interaction spans. Start with <code>enduser.pseudo.id</code>, and add the others when your use case requires them. The important thing is consistency: Use the same identifiers across search, click, and conversion spans so the joins work.</p><h3>Frontend integration</h3><p>The front end needs to propagate <code>query_id</code> through the user journey. When the user clicks a search result, you already have <code>query_id</code> from the search response (<a href="https://www.elastic.co/search-labs/blog/search-click-tracking-opentelemetry-esql">Blog 3</a>). The key is carrying it forward.</p>// CLIENT_ID: persistent browser identifier from localStorage (set up in Blog 3)
// const CLIENT_ID = localStorage.getItem("search_client_id") || ...

// On add-to-cart from a search result page
fetch('/api/cart/add', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    object_id: product.id,
    position: product.resultPosition,  // from search results
    query_id: product.queryId,         // from search response
    client_id: CLIENT_ID,              // persistent browser identifier → enduser.pseudo.id
    quantity: 1,
    price: product.price,
  })
});

// On checkout completion
fetch('/api/checkout', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    order_id: order.id,
    total_amount: order.total,
    items: order.items,
    client_id: CLIENT_ID,              // same identifier as search and click spans
    query_id: lastSearchQueryId,       // last search in session
    user_query: lastSearchQuery,
  })
});<p>The front end sends <code>client_id</code> as an HTTP field name; and the back end maps it to the OTel semantic convention <code>enduser.pseudo.id</code> when setting span attributes. The <code>query_id</code> propagation is the critical piece. Store it alongside the product in the cart data structure so it survives navigation between pages. We'll discuss the design challenges of this in the attribution section below.</p><p></p><p><strong>Using the reference project?</strong> The reference app's <code>frontend/app.js</code> wires up add-to-cart buttons, but the checkout flow isn’t implemented in the browser UI. It goes through the traffic generator. To simulate conversion events, run: <code>python generate_traffic.py --blog 4 --sessions 100</code>. This sends a realistic mix of searches, clicks, cart additions, and purchases to all three backend endpoints.</p><h3>Verify that conversion events are arriving</h3><p>Before building funnel queries, confirm that both span types are flowing to Elastic:</p><p>Add-to-cart events:</p>FROM traces-generic.otel-default
| WHERE attributes.search.action == "add_to_cart"
| KEEP attributes.search.result_click_id, attributes.search.query_id,
       attributes.cart.quantity
| LIMIT 5<p>Purchase events:</p>FROM traces-generic.otel-default
| WHERE attributes.search.action == "purchase"
| KEEP attributes.checkout.order_id, attributes.checkout.total_amount,
       attributes.checkout.item_count, attributes.search.query_id,
       attributes.search.query
| LIMIT 5<p>If these return rows, you have the full funnel instrumented. If not, check the same things as always: OpenTelemetry Protocol (OTLP) endpoint, auth token, and span export.</p><h2>Funnel analysis with ES|QL</h2><h3>Count search, click, cart and purchase events in a single query</h3><p>You can count all four funnel stages in a single ES|QL query using the same <code>COUNT(CASE(...))</code>pattern from the CTR query in <a href="https://www.elastic.co/search-labs/blog/search-click-tracking-opentelemetry-esql">Blog 3</a>:</p>FROM traces-generic.otel-default
| WHERE (name == "search" AND attributes.search.query IS NOT NULL)
    OR attributes.search.first_click == true
    OR attributes.search.action IN ("add_to_cart", "purchase")
| STATS
    searches  = COUNT(CASE(name == "search" AND attributes.search.query IS NOT NULL, 1)),
    clicked   = COUNT(CASE(attributes.search.first_click == true, 1)),
    carts     = COUNT(CASE(attributes.search.action == "add_to_cart", 1)),
    purchases = COUNT(CASE(attributes.search.action == "purchase", 1))
| EVAL
    click_rate    = ROUND(100.0 * clicked   / searches, 1),
    cart_rate     = ROUND(100.0 * carts     / searches, 1),
    purchase_rate = ROUND(100.0 * purchases / searches, 1)<p><strong>Example output:</strong></p><p><strong>searches</strong></p><p><strong>clicked</strong></p><p><strong>carts</strong></p><p><strong>purchases</strong></p><p><strong>click_rate</strong></p><p><strong>cart_rate</strong></p><p><strong>purchase_rate</strong></p><p>146</p><p>41</p><p>28</p><p>12</p><p>28.1%</p><p>19.2%</p><p>8.2%</p><p>You get all four counts and three conversion rates from a single query. The <code>WHERE</code> clause pulls all four span types into one result set, and <code>COUNT(CASE(...))</code> counts each type separately. This is the same technique that made the CTR query in <a href="https://www.elastic.co/search-labs/blog/search-click-tracking-opentelemetry-esql">Blog 3</a> so clean.</p><p>Notice that we use <code>search.first_click</code>for the click stage rather than counting all click events. This gives you the number of searches that received at least one click (the same definition used for CTR in <a href="https://www.elastic.co/search-labs/blog/search-click-tracking-opentelemetry-esql">Blog 3</a>). Without this, a search with three clicks would inflate the click count to three while only counting as one search, making the funnel numbers misleading. Each stage now represents a unique progression: how many searches happened, how many of those got clicked, how many led to a cart addition, and how many resulted in a purchase.</p><p>You can turn this into a funnel visualization using Kibana Lens. Run each stage count as a separate ES|QL query, save them as dashboard panels, and arrange them as a horizontal bar chart with the four stages on the y-axis and counts on the x-axis. The drop-off at each step becomes immediately visible.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltcc9d695520cd0aff/6a7458d85751aa8c3e7e0a4f/image2.png" alt="Kibana dashboard showing search analytics: conversion funnel, revenue by query, click-through rate and latency SLOs" /><p>The dashboard above (from <a href="https://www.elastic.co/search-labs/blog/search-analytics-opentelemetry">the first blog in the series</a>) shows this in practice: The <strong>Conversion Funnel</strong> panel in the lower left uses a horizontal bar chart to visualize the four stages, and the <strong>Top Queries by Revenue</strong> table alongside it shows revenue attribution. You can build these directly from the ES|QL queries in this post.</p><h3>What drop-off rates tell you and who owns each bottleneck</h3><p>Each transition in the funnel tells you something specific:</p><ul><li><p><strong>Search to click (CTR):</strong> You measured this in <a href="https://www.elastic.co/search-labs/blog/search-click-tracking-opentelemetry-esql">Blog 3</a>. Low CTR means that results aren't compelling. This is a relevance problem.</p></li></ul><ul><li><p><strong>Click to cart:</strong> The user engaged with a result but didn't add it to their cart. This could mean that the product page isn't persuasive or the price isn't competitive. It could also mean that the item was out of stock. It's often <em>not</em> a search problem. It’s possible that the search worked (the user clicked), but something downstream lost them.</p></li></ul><ul><li><p><strong>Cart to purchase:</strong> The user committed to buying but didn't complete checkout. Complicated forms, unexpected shipping costs, and payment issues cause this <em>checkout friction</em>. This is almost never a search problem, but it's useful to know where the funnel leaks so you don't waste time optimizing relevance when checkout is the bottleneck.</p></li></ul><p>The diagnostic pattern is straightforward:</p><p><strong>Drop-off point</strong></p><p><strong>Likely cause</strong></p><p><strong>Who owns it</strong></p><p>Search to click</p><p>Relevance / ranking</p><p>Search team</p><p>Click to cart</p><p>Product page / pricing / availability</p><p>Product / merchandising</p><p>Cart to purchase</p><p>Checkout UX / payment / shipping</p><p>Checkout / growth</p><p>This is one of the most valuable things that full-funnel data gives you: the ability to point at the right problem. When the VP asks, "Why aren't searches converting?", you can show whether the bottleneck is relevance, product pages, or checkout.</p><h2>Revenue attribution by search query</h2><p>Here's the query your product manager actually wants:</p>FROM traces-generic.otel-default
| WHERE attributes.search.action == "purchase"
  AND attributes.search.query IS NOT NULL
| STATS
    purchase_count = COUNT(*),
    total_revenue = SUM(attributes.checkout.total_amount)
  BY attributes.search.query
| SORT total_revenue DESC<p>This gives you a ranked list of queries by the revenue they generated. The <code>search.query</code> attribute on purchase spans (the denormalization we discussed earlier) makes this a single aggregation query, without joins or subqueries.</p><h3>How to act on search revenue  data</h3><ul><li><p><strong>Protect high-revenue queries.</strong> If "laptop bag" generates the most revenue, any relevance change that affects that query gets extra scrutiny. You might add it to a regression test suite or pin specific results with <a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/searching-with-query-rules">query rules</a>. You might even set up an alert when its conversion rate drops (Blog 6).</p></li></ul><ul><li><p><strong>Prioritize relevance investment.</strong> The queries at the top of this list are where relevance improvements have the most business impact. A 10% CTR improvement on a query that generates $500/month in revenue is worth more than a 50% improvement on one that generates $20.</p></li></ul><ul><li><p><strong>Identify missed opportunities.</strong> Cross-reference with the top-queries data from <a href="https://www.elastic.co/search-labs/blog/search-analytics-opentelemetry-esql">Blog 2</a>. A query with high search volume but no purchase attribution is either a browsing query (informational intent) or a conversion gap worth investigating.</p></li></ul><h3>Top revenue queries: where to focus relevance investment</h3><p>To focus your relevance team's efforts, pull the top revenue-generating queries:</p>FROM traces-generic.otel-default
| WHERE attributes.search.action == "purchase"
  AND attributes.search.query IS NOT NULL
| STATS
    purchases = COUNT(*),
    revenue = SUM(attributes.checkout.total_amount)
  BY attributes.search.query
| SORT revenue DESC
| LIMIT 10<p>Cross-reference this with the per-query CTR from Blog 3. A query with high revenue but low CTR is underperforming; even small relevance improvements have outsized business impact. A query with high CTR but no purchase attribution might be informational (such as users browsing but not buying). The queries at the top of <em>both</em> lists deserve the most attention from your relevance team.</p><h3>Average order value by search query</h3><p>Average order value (AOV) tells you how much purchases are worth, in addition to which queries convert to those purchases. Queries with high AOV are your premium-intent searches; ranking improvements there have the biggest per-purchase impact:</p>FROM traces-generic.otel-default
| WHERE attributes.search.action == "purchase"
  AND attributes.search.query IS NOT NULL
| STATS
    purchase_count = COUNT(*),
    total_revenue = SUM(attributes.checkout.total_amount),
    avg_order_value = ROUND(AVG(attributes.checkout.total_amount), 2)
  BY attributes.search.query
| SORT avg_order_value DESC
| LIMIT 10<p>A query with high AOV but low volume is a different opportunity than high volume + low AOV. The first means premium intent from a small audience (consider featured results or dedicated landing pages); the second means broad reach with budget buyers (price sensitivity may be limiting conversion more than relevance).</p><h2>Search revenue attribution: limitations and workarounds</h2><p>Revenue attribution from search is valuable, but it's imperfect. Understanding the limitations helps you set appropriate expectations and design around them.</p><h3>Last-touch attribution in multi-search journeys</h3><p>Users rarely search once and buy. A typical journey might look like:</p><ol><li><p>Search "laptop bag": Browse results and click a few.</p></li><li><p>Search "laptop bag leather": Refine the search.</p></li><li><p>Search "laptop sleeve 15 inch": Try a different angle.</p></li><li><p>Add to cart from the third search's results.</p></li><li><p>Purchase.</p></li></ol><p>With the instrumentation above, this purchase attributes to the third search, the one whose <code>query_id</code> was on the cart item. The first two searches contributed to the journey but get no credit.</p><p>This is <em>last-touch attribution</em>, and it's the simplest model that works within a single <code>query_id</code> linkage. It's not perfect, but it's concrete and unambiguous. The alternative, that is,tracking every <code>query_id</code> in a user's session and distributing credit, adds significant complexity to both instrumentation and analysis.</p><p>For most teams, last-touch is a good starting point. If you need multi-touch attribution later, the raw data is there. You can query all searches and clicks for a given <code>client_id</code> within a time window and reconstruct the full journey:</p>FROM traces-generic.otel-default
| WHERE attributes.enduser.pseudo.id == "client-abc-123"
  AND (name == "search"
    OR attributes.search.action == "click"
    OR attributes.search.action == "add_to_cart"
    OR attributes.search.action == "purchase")
| KEEP @timestamp, name, attributes.search.action,
       attributes.search.query, attributes.search.query_id,
       attributes.search.result_click_id
| SORT @timestamp ASC<p>This reconstructs a user's full search journey in chronological order. It's useful for debugging individual sessions, even if you don't build automated multi-touch attribution.</p><h3>Cross-session attribution limits with query_id</h3><p>A user searches for "wireless headphones" on Monday, clicks a few results, leaves, and comes back on Wednesday to buy. The <code>query_id</code> from Monday's search is long gone, since it was a property of that specific search request.</p><p>This is a fundamental limitation of <code>query_id</code>-based attribution. It works within a session (or more precisely, within the scope where the front end retains the <code>query_id</code>). It doesn't work across sessions.</p><p>For cross-session attribution, you'd need a different approach, typically a user-level event store where you associate product views, cart additions, and purchases with a persistent user ID and then look back in time to find the originating search. That's a more complex analytics pipeline and is outside the scope of what we're building here.</p><p>The practical impact is that your search-attributed revenue will be an <em>undercount</em>. Some purchases that were genuinely influenced by search won't carry a <code>query_id</code>. This is fine for relative comparisons (such as, <em>Which queries generate </em>more<em> revenue than others?</em>), even if the absolute numbers are conservative.</p><h3>How to persist query_id from search to checkout</h3><p>A few practical decisions affect how far your <code>query_id</code> propagation reaches:</p><ul><li><p><strong>Store </strong><strong><code>query_id</code></strong><strong> in the cart.</strong> When a user adds a product to their cart, persist the <code>query_id</code> alongside the item. This way, even if the user navigates away and comes back to checkout later (within the same session), the attribution survives.</p></li></ul><ul><li><p><strong>Don't overwrite </strong><strong><code>query_id</code></strong><strong> on re-search.</strong> If a user adds a product from search A and then searches again and adds another product from search B, each cart item should keep its own <code>query_id</code>. The purchase event carries the last search's <code>query_id</code> as a summary, but per-item attribution gives you richer data.</p></li></ul><ul><li><p><strong>Accept the limitations.</strong> Not every purchase will have search attribution. Direct navigation, category browsing, promotional links, and returning customers who go straight to their cart will all produce purchases without a <code>query_id</code>. That's correct behavior, not missing data.</p></li></ul><h3>Spans vs. log events for conversion tracking</h3><p>Conversion events can emit both an OTel span and a UBI-compatible log event, the same dual-signal pattern used for click tracking in Blog 3. The log event is actually richer for conversions. It can contain the full items list with per-item <code>query_id</code> attribution, which doesn't map cleanly to flat span attributes.</p><p>The span gives you the simple, aggregatable view (total revenue by query), and the log gives you the detailed, per-item view (which specific products from which specific searches). For the funnel queries in this post, spans are sufficient. If you need item-level attribution analysis, the log events in <code>logs-generic.otel-default</code> have the detail you need, and ES|QL queries them the same way, just against a different index pattern.</p><h2>What's next: turning conversion data into relevance improvements</h2><p>We now have the complete instrumentation picture, with four span types: <code>search</code> (<a href="https://www.elastic.co/search-labs/blog/search-analytics-opentelemetry-esql">Blog 2</a>), <code>search.result.click</code> (<a href="https://www.elastic.co/search-labs/blog/search-click-tracking-opentelemetry-esql">Blog 3</a>), <code>cart.add</code>, and <code>checkout.complete</code> (this post). These capture the full user journey from query to purchase. Every span lives in <code>traces-generic.otel-default</code>, and every metric is queryable with ES|QL. The <code>search.query_id</code> thread ties the entire funnel together.</p><p>But measuring the funnel is only half the story. The real payoff is using this data to make search better.</p><p>In a later blog in this series, we take everything we've built and turn it into relevance improvements. Click positions and conversion data become judgment lists for <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/learning-to-rank.html">Learning To Rank</a>, and per-query CTR and revenue become rank features for boosting. Plus, high-revenue queries get protective monitoring. And tools like <a href="https://elastic.github.io/relevance-studio/#/">Elasticsearch Relevance Studio</a> give you a visual interface for tuning the searches that matter most, using exactly the data you're now collecting.</p><p>The instrumentation you've built in Blogs 2–4 is a feedback loop, beyond analytics: Measure, improve, and measure again.</p><h2>Get started with search conversion tracking</h2><ul><li><p><a href="https://github.com/elastic/elasticsearch-labs/tree/main/supporting-blog-content/search-analytics-otel">Reference project:</a> Working code for the entire blog series; clone, configure, and run.</p></li><li><p><a href="https://github.com/elastic/elastic-otel-python">Elastic Distribution of OpenTelemetry for Python:</a> EDOT for Python.</p></li><li><p><a href="https://www.elastic.co/docs/solutions/observability/apm/opentelemetry">OpenTelemetry with Elastic:</a> How to send OTel data to Elastic APM.</p></li><li><p><a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/esql.html">ES|QL documentation:</a> Query language reference.</p></li><li><p><a href="https://www.ubisearch.dev/">UBI Standard:</a> Reference schema for search event structure.</p></li><li><p><a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/query-rules.html">Query rules:</a> Pin, boost, or exclude results for specific queries.</p></li></ul>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/search-conversion-tracking-opentelemetry</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/search-conversion-tracking-opentelemetry</guid>
    <category><![CDATA[Analytics]]></category>
    <category><![CDATA[ES|QL]]></category>
    <category><![CDATA[Relevance]]></category>
    <dc:creator><![CDATA[Matthew Adams]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt9492cc64bcc1d0dc/6ab3ead098a4e3c62b53f8a0/diagram-building-a-4-stage-funnel.webp" length="0" type="image/webp"/>
    <pubDate>Thu, 06 Aug 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[15 lines of click tracking code that tell you what search logs can't ]]></title>
    <description><![CDATA[Three ES|QL queries calculate click-through rate, mean reciprocal rank and click position distribution from your search click data, so you can pinpoint which queries need relevance tuning and where ranking improvements will have the most impact.]]></description>
    <content:encoded><![CDATA[<p>Search volume and latency tell you that search is working, not that it's useful. About 15 lines of OpenTelemetry (OTel) instrumentation lets you track clicks on search results and then query click-through rate (CTR), Mean Reciprocal Rank (MRR), and click position distribution with Elasticsearch Query Language (ES|QL) against the same traces index that your search spans already live in. You'll wire click tracking to your existing <code>search.query_id</code> and write the queries that show which searches need relevance tuning.</p><h2>What you'll discover</h2><p>In this post, you'll learn how to:</p><ul><li><p>Add client-side click tracking that links clicks back to their originating search via <code>search.query_id</code>.</p></li><li><p>Calculate CTR; that is, the percentage of searches that produce at least one click.</p></li><li><p>Calculate MRR; that is, how far down the results users click on average.</p></li><li><p>Analyze click position distribution to see the full shape of user engagement.</p></li><li><p>Write ES|QL queries for all three metrics against  <code>traces-generic.otel-default</code>.</p></li><li><p>Identify which specific queries need relevance tuning.</p></li></ul><h3>What you'll need</h3><ul><li><p>A working OTel instrumentation setup from Blog 2 (search spans with <code>search.*</code> attributes flowing to Elastic via OTel-native ingestion).</p></li><li><p>A front end that can send click events (JavaScript example provided).</p></li><li><p>Familiarity with the <code>attributes.*</code> field mapping from Blog 2.</p></li></ul><h2>Why search logs alone can't measure search quality</h2><p>In the <a href="https://www.elastic.co/search-labs/blog/search-analytics-opentelemetry-esql">second blog</a> of the series, we instrumented search requests and ran six ES|QL queries against the data. We can see what users search for, which queries return nothing, and how fast search is.</p><p>But there's a blind spot. A search that returns 15 results looks healthy from the server side. Every metric we have says it worked. But if nobody clicks any of those results, your ranking has a problem, and none of the queries from Blog 2 will tell you.</p><p>This is the gap between <em>results returned</em> and <em>results that are useful</em>. Search volume, zero-results rate, and latency measure the mechanics of search, but they don't measure whether search is actually helping users find what they need.</p><p>To answer that question, you need a second instrumentation point: <em>click tracking</em>.</p><p>If you’re following along with code, the <a href="https://github.com/elastic/elasticsearch-labs/tree/main/supporting-blog-content/search-analytics-otel">reference project</a> has click tracking ready to enable. Uncomment the Blog 3 sections in <code>app.py</code> and <code>frontend/app.js</code>, restart, and then generate traffic with <code>python generate_traffic.py --blog 3</code>.</p><h2>How click data measures search relevance</h2><p>Before we write any code, here's what click data lets you measure and why each metric matters:</p><ul><li><p><strong>CTR</strong> answers the most basic engagement question: <em>What percentage of searches result in at least one click?</em> If your CTR is low, users are seeing results but not finding them compelling enough to engage. Establish your own baseline once you have data; CTR varies considerably across product category, query type, and industry vertical.</p></li></ul><ul><li><p><strong>MRR</strong> goes deeper: <em>When users do click, where in the results are they clicking?</em> An MRR of 1.0 means every user clicks the top result (a perfect ranking). An MRR of 0.5 means the average click is at position 2. Low MRR with high CTR is particularly telling. It means that users are finding what they need, but your ranking is making them work for it.</p></li></ul><ul><li><p><strong>Click position distribution</strong> shows the full shape of where users click. A healthy search engine shows most clicks at position 1 with a sharp drop-off. A flat distribution across positions 1–5 means that your ranking isn't differentiating well. Per-query distributions reveal exactly which searches need relevance tuning.</p></li></ul><p>Together, these metrics move you from <em>Did search work?</em> (Blog 2) to <em>Did search work well?</em>, and they pinpoint exactly where to invest in relevance improvements. Later in the series, we'll show how to turn these metrics into concrete actions: building judgment lists for Learning To Rank (LTR), tuning relevance with tools like <a href="https://elastic.github.io/relevance-studio/#/">Elasticsearch Relevance Studio</a>, and evaluating changes with the Rank Eval API. But first, you need the data.</p><p>All three metrics require just one new instrumentation point: about 15 lines of code.</p><h2>Add click tracking</h2><p>Click tracking captures what happens after the results appear. When a user clicks a search result, we create a new span with attributes describing the interaction, including which document they clicked, where it appeared in the results, and which search produced it.</p><p>Here's the code:</p># Track which query_ids have already received a click
_clicked_queries: set[str] = set()

@app.post("/api/events")
async def track_event(event: EventRequest):  # reference project uses ClickEvent
with tracer.start_as_current_span("search.result.click") as span:
        span.set_attribute("search.action", "click")
        span.set_attribute("search.result_click_id", event.object_id)
        span.set_attribute("search.result_click_position", event.position)
        span.set_attribute("search.result_click_type", event.object_id_type)
        span.set_attribute("search.query_id", event.query_id)
        span.set_attribute("enduser.pseudo.id", event.client_id)

# First click per search — enables single-query CTR
if event.query_id not in _clicked_queries:
            span.set_attribute("search.first_click", True)
            _clicked_queries.add(event.query_id)

if event.user_query:
            span.set_attribute("search.query", event.user_query)<p>Let's unpack what matters.</p><h3>Click spans are separate traces</h3><p>This is the key architectural difference from Blog 2. Search spans are created synchronously during the API request: The user searches, the span opens, Elasticsearch responds, and the span closes. Click spans are <em>asynchronous</em>. The user searches, gets results, browses the page, and might click 30 seconds later (or they might never click).</p><p>That means click spans aren't children of the search span's trace. They're independent traces, linked to the originating search through <code>search.query_id</code>. This is the same <code>query_id</code> we derived from the trace ID in Blog 2, and it now serves as the join key between searches and clicks across <code>traces-generic.otel-default</code>.</p><h3>Choosing the right OTel signal for clicks</h3><p>OTel gives you three signal types, and clicks could be modeled as any of them. Each has strengths:</p><p><strong>Signal</strong></p><p><strong>Index</strong></p><p><strong>Weight</strong></p><p><strong>Best for…</strong></p><p>Spans</p><p><code>traces-generic.otel-default</code></p><p>Full trace context</p><p>Same-index queries with search spans</p><p>Logs</p><p><code>logs-*</code></p><p>Lighter weight</p><p>Log-centric pipelines, high volume</p><p>Span events</p><p><code>logs-generic.otel-default</code></p><p>Lightest instrumentation</p><p>Attaching to existing spans</p><p></p><ul><li><p><strong>Spans</strong> land in <code>traces-generic.otel-default</code> alongside search spans, are fully queryable in ES|QL, appear in Kibana APM views, and carry timing information. Since our search spans are already in <code>traces-generic.otel-default</code>, using spans for clicks means you can query searches and clicks together in a single ES|QL statement, and no cross-index joins are needed.</p></li><li><p><strong>Log records</strong> are also independently queryable in ES|QL, living in <code>logs-*</code>. If you use the same <code>search.*</code> attribute names, the analytics queries are almost identical; just change the index pattern. Logs are lighter weight (no trace context overhead) and are a natural fit if your team already has a log-centric observability pipeline. One of Elastic's strengths here is that traces, logs, and metrics all land in the same platform and are all queryable with ES|QL, so choosing logs over spans doesn't mean giving up any query capability.</p></li><li><p><strong>Span events </strong>are lightweight at instrumentation time (attached to an existing span in the OTel API). In Elastic's OpenTelemetry Protocol (OTLP) ingestion pipeline, span events are written as separate documents to <code>logs-*</code> data streams (for example <code>logs-generic.otel-default</code>), and they’re independently queryable in the same way as logs. They’re a good, lightweight option but might require more code changes than logs, which can even pull in logs from legacy code.</p></li></ul><p>In this series, we use <em>spans </em>because they keep searches and clicks in the same index with the simplest query path. But if you're at high volume and want to optimize for cost, or if your organization already routes OTel logs to Elasticsearch, the log-based approach works well; the <code>search.*</code> attribute schema is the same either way, and ES|QL queries against <code>logs-*</code> follow the same patterns you'll see below.</p><h3>The <code>search.first_click</code> attribute</h3><p><code>search.first_click</code> is a Boolean set only on the first click for a given <code>query_id</code>. It exists for one reason: accurate CTR calculation without post-processing.</p><p>CTR is defined as the percentage of searches with at least one click. Without <code>search.first_click</code>, you'd need to deduplicate clicks by <code>query_id</code> at query time: grouping, counting distinct values, and subquerying. By marking the first click at instrumentation time, the ES|QL query becomes a simple count.</p><p>The set above is demo-only. It grows unbounded and breaks with multiple API replicas. The <a href="https://github.com/elastic/elasticsearch-labs/tree/main/supporting-blog-content/search-analytics-otel">reference implementation</a> uses a thread-safe time-to-live (TTL) dict with a 30-minute expiry window (<code>_is_first_click()</code> in <code>app.py</code>). For production with multiple replicas, use a shared external cache (Redis, Memcached) keyed by <code>query_id</code> with a TTL matching your session window.</p><h3>Where to track first click: Front end vs. back end</h3><p>The <code>search.first_click</code> deduplication could live in either the front end or the back end. Both are valid, and here are the trade-offs:</p><ul><li><p><strong>Frontend tracking</strong> is simpler to implement. The browser already knows the current query and whether the user has clicked before. No server-side state is required, you don’t have to worry about multiple replicas, and it works without any backend changes. The downside is that browser state is ephemeral; a page refresh, multiple tabs, or an ad blocker can interfere with accurate tracking.</p></li></ul><ul><li><p><strong>Backend tracking</strong> (our approach) gives you a single source of truth. All click events flow through one place, so the deduplication is consistent regardless of what the client does. It also means that the analytics logic is colocated with the instrumentation code, which simplifies reasoning about data quality. The trade-off is that the back end needs to maintain state: the <code>_clicked_queries</code> set. For a single-instance API, this is trivial; for multiple replicas behind a load balancer, you'd use a shared TTL cache (Redis or similar).</p></li></ul><p>We chose backend tracking here because we want the analytics data to be authoritative. This click data will later feed into relevance tuning and judgment lists, where accuracy matters. But if you're starting simple or running a client-side–only setup, frontend tracking is a perfectly reasonable first step. The <code>search.first_click</code> attribute works the same way regardless of where you set it.</p><h3>Sending click events from the browser</h3><p>The browser sends click events to the back end when a user clicks a result. It needs three things from the search response: the document ID, the position, and the <code>query_id</code>.</p>// Generate a persistent client ID once per browser (stored in localStorage)
const CLIENT_ID = localStorage.getItem("search_client_id")
    || (() =&gt; {
const id = crypto.randomUUID();
        localStorage.setItem("search_client_id", id);
return id;
    })();

// On result click
fetch('/api/events', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    object_id: product.id,
    position: index + 1,       // 1-indexed
query_id: lastQueryId,     // from the most recent search response
client_id: CLIENT_ID,      // persistent browser identifier → enduser.pseudo.id
user_query: currentQuery,
    object_id_type: 'product', // optional; defaults to "product" on the backend
})
});<p><code>CLIENT_ID</code> is generated once and stored in <code>localStorage</code>. It survives page reloads and gives you a stable <code>enduser.pseudo.id</code> without requiring a login. The back end maps <code>client_id</code> → <code>enduser.pseudo.id</code> on the span.</p><p>Positions are 1-indexed; that is, the first result is position 1, not 0.</p><h3>Click tracking OTel attributes and ES|QL field mapping</h3><p></p><p><strong>Attribute</strong></p><p><strong>Type</strong></p><p><strong>Required</strong></p><p><strong>Purpose</strong></p><p><code>search.action</code></p><p>string</p><p>yes</p><p>Event type: <code>"click"</code></p><p><code>search.result_click_id</code></p><p>string</p><p>yes</p><p>Document ID clicked</p><p><code>search.result_click_position</code></p><p>int</p><p>yes</p><p>Position in results (1-indexed)</p><p><code>search.query_id</code></p><p>string</p><p>yes</p><p>Links to originating search</p><p><code>enduser.pseudo.id</code></p><p>string</p><p>yes</p><p>Client/device identifier</p><p><code>search.first_click</code></p><p>boolean</p><p>recommended</p><p><code>true</code> if first click for this <code>query_id</code></p><p><code>search.result_click_type</code></p><p>string</p><p>recommended</p><p>Object type: <code>"product"</code>, <code>"article"</code></p><p><code>search.query</code></p><p>string</p><p>recommended</p><p>The search query text (for queryability)</p><p>These follow the same <code>search.*</code> namespace we established in Blog 2. With OTel-native ingestion, attributes map directly to <code>attributes.*</code> fields in ES|QL:</p><p></p><p><strong>OTel attribute</strong></p><p><strong>ES|QL field</strong></p><p><code>search.action</code></p><p><code>attributes.search.action</code></p><p><code>search.result_click_id</code></p><p><code>attributes.search.result_click_id</code></p><p><code>search.result_click_position</code></p><p><code>attributes.search.result_click_position</code></p><p><code>search.query_id</code></p><p><code>attributes.search.query_id</code></p><p><code>search.first_click</code></p><p><code>attributes.search.first_click</code></p><p>With OTel-native ingestion, <code>search.first_click</code> is stored as a native Boolean; you query it with <code>== true</code>, not <code>== "true"</code>, and no string coercion is needed.</p><h3>Verify that clicks are arriving</h3><p>Before calculating metrics, confirm that click spans are flowing to APM:</p>FROM traces-generic.otel-default
| WHERE attributes.search.action == "click"
| KEEP attributes.search.result_click_id, attributes.search.result_click_position,
       attributes.search.query_id, attributes.search.query
| LIMIT 5<p>If this returns rows, you're ready for analytics. If not, check the same things as we looked at in Blog 2: OTLP endpoint, auth token, and span export.</p><p><strong>Note:</strong> Results in this post are illustrative, generated by running <code>python generate_traffic.py --blog 3 --sessions 50</code> on the <a href="https://github.com/elastic/elasticsearch-labs/tree/main/supporting-blog-content/search-analytics-otel">reference project</a>. Running Blog 3 traffic adds click events <em>and</em> additional search sessions on top of the 62 from Blog 2, so cumulative search counts will exceed 62. Your exact numbers will vary based on session count and the random nature of the traffic simulator. The metric calculations and ES|QL patterns are what to focus on.</p><h2>CTR</h2><p><strong>CTR</strong> is the primary signal for search relevance, answering the question that Blog 2 couldn't: <em>Are users engaging with the results?</em></p><p><strong>CTR = searches with at least one click / total searches * 100</strong></p><p>CTR is a binary per-search metric: Either a search got clicked or it didn't. The maximum is 100%.</p><h3>Overall search CTR with ES|QL</h3>FROM traces-generic.otel-default
| WHERE (name == "search" AND attributes.search.query IS NOT NULL)
OR attributes.search.first_click == true
| STATS
    searches = COUNT(CASE(name == "search" AND attributes.search.query IS NOT NULL, 1)),
    clicked = COUNT(CASE(attributes.search.first_click == true, 1))
| EVAL ctr_pct = ROUND(100.0 * clicked / searches, 1)<p><strong>Result:</strong> 41 clicked searches out of 146 total. <strong>CTR: 28.1%</strong></p><p>This is a single query that pulls both search spans and first-click spans from <code>traces-generic.otel-default</code>. The <code>OR</code> in the <code>WHERE</code> clause brings both into one result set. <code>COUNT(CASE(...))</code> counts each type separately, and <code>EVAL</code> does the division.</p><p>This works because of <code>search.first_click</code>. Without it, you'd be counting raw clicks (a user who clicks three results on one search would inflate the count). The deduplication happened at instrumentation time; the query stays simple.</p><h3>CTR by search query: Finding your worst relevance failures</h3><p>The overall number is useful for dashboards. The per-query breakdown is where you find problems.</p>FROM traces-generic.otel-default
| WHERE ((name == "search" AND attributes.search.query IS NOT NULL)
OR attributes.search.first_click == true)
AND attributes.search.query IS NOT NULL
| STATS
    searches = COUNT(CASE(name == "search" AND attributes.search.query IS NOT NULL, 1)),
    clicked = COUNT(CASE(attributes.search.first_click == true, 1))
BY attributes.search.query
| EVAL ctr_pct = ROUND(100.0 * clicked / searches, 1)
| SORT searches DESC
| LIMIT 20<p>This shows CTR broken down by query text. </p><h3>What CTR tells you (and what it doesn't)</h3><ul><li><p><strong>High searches + zero clicks:</strong> These are the worst relevance failures. Fix these first.</p></li><li><p><strong>High searches + low CTR:</strong> Results appear, but they aren't compelling. Check ranking.</p></li><li><p><strong>Low CTR + high zero-results rate:</strong> This is a double problem; either no results or bad results.</p></li><li><p><strong>CTR trend over time:</strong> This measures the impact of relevance changes.</p></li></ul><p>CTR doesn't measure satisfaction. A user who clicks position 1, bounces back, and then clicks position 3 still counts as one clicked search. For a fuller picture, you need to know <em>where </em>they're clicking. That's what MRR measures.</p><h3>CTR vs. clicks per search</h3><p><strong>CTR</strong> (what we just calculated) is capped at 100%. This is the industry-standard definition.</p><p><strong>Clicks per search</strong> is total clicks divided by total searches. It can exceed 1.0. For example, a search where the user clicks three results scores 3.0. It measures engagement depth, which is useful but different. If you need it, count all click spans (not just <code>first_click</code>) divided by search spans.</p><h2>MRR</h2><p><strong>MRR</strong> tells you <em>where users </em>click. It measures how far down the results list users go before finding something worth clicking.</p><p><strong>MRR = average of (1 / click_position) across all clicks</strong></p><p>The reciprocal rank transforms click positions into a 0–to–1 scale, where higher is better:</p><p></p><p><strong>Click position</strong></p><p><strong>Reciprocal rank</strong></p><p>1</p><p>1.000</p><p>2</p><p>0.500</p><p>3</p><p>0.333</p><p>5</p><p>0.200</p><p>10</p><p>0.100</p><p></p><h3>Overall search MRR with ES|QL</h3><p>MRR can be calculated two ways, depending on what you want to measure:</p><ul><li><p><strong>All-click MRR:</strong> Averages the reciprocal rank of every click and reflects overall click quality, including repeated interactions.</p></li><li><p><strong>First-click MRR:</strong> Averages only the first click per search (using <code>search.first_click == true</code>). It’s more comparable to traditional information retrieval (IR) evaluation and aligns with how you computed CTR.</p></li></ul><p>For consistency with CTR and alignment with judgment-list workflows in Blog 5, we prefer first-click MRR:</p>FROM traces-generic.otel-default
| WHERE attributes.search.action == "click"
AND attributes.search.first_click == true
| EVAL reciprocal_rank = 1.0 / attributes.search.result_click_position
| STATS mrr = ROUND(AVG(reciprocal_rank), 3)<p><strong>Result:</strong> MRR = <strong>0.495</strong></p><p>This is decent but shows room for improvement. An MRR of 0.495 means the average first click lands around position 2. It isn’t a crisis, but there are queries where ranking can be improved.</p><h3>MRR by search query: Finding your worst-ranked results</h3><p>Like CTR, the per-query breakdown is where the actionable data lives. To surface the worst-ranked queries first, sort ascending.</p>FROM traces-generic.otel-default
| WHERE attributes.search.action == "click"
AND attributes.search.first_click == true
AND attributes.search.query IS NOT NULL
| EVAL reciprocal_rank = 1.0 / attributes.search.result_click_position
| STATS
    mrr = ROUND(AVG(reciprocal_rank), 3),
    clicks = COUNT(*)
BY attributes.search.query
| SORT mrr ASC
| LIMIT 20<p>This reveals which queries have the worst ranking. A query with multiple clicks and low MRR means that the ranking is consistently poor for that search; that is, users find results, but they have to dig for them.</p><h3>What is a good MRR score for search?</h3><ul><li><p><strong>MRR &gt; 0.8:</strong> This ranking is solid; users usually click position 1–2.</p></li><li><p><strong>MRR 0.5–0.8:</strong>  This is decent, but there’s room for improvement.</p></li><li><p><strong>MRR &lt; 0.5:</strong> This is a ranking problem, and users are scrolling past top results.</p></li><li><p><strong>MRR drop after a change:</strong> This is ranking regression that should be investigated immediately.</p></li><li><p><strong>Low MRR + high CTR:</strong> Users are finding things, but they have to work for it.</p></li></ul><p>That last pattern is particularly interesting. High CTR with low MRR means your results are relevant (users are clicking), but your ranking isn't surfacing the best results first. It's an optimization opportunity, not a crisis.</p><h3>MRR limitations: Position bias and multi-click sessions</h3><p>MRR is heavily influenced by the gap between position 1 and position 2 (1.0 versus 0.5). Positions 5 and beyond barely move the average. This means that MRR is most sensitive to whether your top result is good, which is often exactly what you want to optimize.</p><p>MRR also only measures clicks, not satisfaction. With <em>all-click MRR</em>, a user who clicks position 1, bounces, and then clicks position 3 contributes two data points, but only the second was useful. The <em>first-click MRR</em> queries above avoid this by counting only the first click per search via <code>search.first_click == true</code>.</p><h2>Click position distribution</h2><p>Click position distribution shows you the full picture of where in the results users are engaging.</p>FROM traces-generic.otel-default
| WHERE attributes.search.action == "click"
| STATS click_count = COUNT(*) BY attributes.search.result_click_position
| SORT attributes.search.result_click_position ASC<p><strong>Results:</strong></p><p><strong>Position</strong></p><p><strong>Clicks</strong></p><p>1</p><p>21</p><p>2</p><p>10</p><p>3</p><p>7</p><p>4</p><p>4</p><p>5</p><p>3</p><p></p><p>This is a reasonable distribution: 21 of 45 clicks (47%) land on position 1, with a tapering tail. If you paste this query into Discover's ES|QL editor, Kibana auto-generates a bar chart that makes the shape immediately visible.</p><h3>How to read click position distribution for search relevance</h3><ul><li><p><strong>Sharp dropoff after position 1:</strong> The ranking is effective, and the top result is usually right.</p></li><li><p><strong>Flat across positions 1–5:</strong> The ranking isn't differentiating well, and all positions are equally likely to be clicked.</p></li><li><p><strong>Spike at position 3+ for specific queries:</strong> Those queries have ranking problems.</p></li><li><p><strong>No clicks beyond position 5:</strong> Users don't scroll far. Top 5 ranking matters most.</p></li></ul><h3>Click position distribution by search query</h3><p></p><p>To see the shape for specific queries:</p>FROM traces-generic.otel-default
| WHERE attributes.search.action == "click"
AND attributes.search.query IS NOT NULL
| STATS click_count = COUNT(*)
BY attributes.search.query, attributes.search.result_click_position
| SORT attributes.search.query, attributes.search.result_click_position<p>A query where all clicks land on position 1 has perfect ranking. A query where clicks spread across positions 1–5 needs relevance tuning.</p><h3>Position bias and click models</h3><p>One caveat: Click position distribution is influenced by <em>position bias</em>; that is, users see position 1 first, so it gets clicked more regardless of relevance. A click at position 1 isn't necessarily more relevant, just more visible.</p><p>This is a well-studied problem in information retrieval. <em>Click models</em> are statistical models that attempt to separate genuine relevance from position bias in click data. The foundational work by <a href="https://www.cs.cornell.edu/people/tj/publications/joachims_etal_05a.pdf">Joachims et al. (2005)</a> showed that users are significantly biased toward higher-ranked results, and, in proposed methods like skip-above analysis (if a user clicks position 3 but skips positions 1 and 2), those skipped results are likely less relevant for that query.</p><p>For the metrics in this post, you don't need to implement a full click model. The key insight is practical: Compare distributions <em>between queries</em> rather than treating absolute position counts as ground truth. If query A has 80% of clicks at position 1 and query B has clicks spread across positions 1–5, query B's ranking is worse, even accounting for position bias. Later in the series, when we look at building judgment lists for LTR, position bias correction becomes more important, and the click data you're collecting here is exactly what those models need as input.</p><h2>CTR, MRR, and click distribution: Reading search quality metrics together</h2><p>These three metrics offer three different angles on search result quality:</p><p></p><p><strong>Metric</strong></p><p><strong>What it measures</strong></p><p><strong>Our value</strong></p><p><strong>Interpretation</strong></p><p><strong>CTR</strong></p><p>Do users click at all?</p><p>28.1%</p><p>Moderate: Roughly a third of searches get engagement, but there’s room to improve.</p><p><strong>MRR</strong></p><p>Where do they click?</p><p>0.495</p><p>Decent: The average click is around position 2, but ranking can be improved.</p><p><strong>Distribution</strong></p><p>What's the shape?</p><p>47% at position 1</p><p>Reasonable drop-off: The top result wins most but isn’t dominant.</p><p>Together, they tell a coherent story. For our demo data, search is performing adequately: Users are engaging with results and can find what they need, but the ranking has room to improve. The CTR of 28% and MRR of 0.495 are realistic starting points for a new search implementation without tuning.</p><p>Where they're most valuable is in combination at the query level. The queries to fix first are those with <strong>high volume + low CTR + low MRR</strong>; that is, lots of users are searching, few are clicking, and those who do click are scrolling deep. That's where relevance investment has the highest return.</p><h3>Using click data for LTR and relevance tuning</h3><p>These metrics don't just tell you how search is performing; they're the foundation for making it better. The click data you're now collecting feeds directly into relevance improvement workflows:</p><ul><li><p><strong>Judgment lists for LTR:</strong> Click positions and frequencies become graded relevance labels for training machine learning (ML) ranking models. A document clicked at position 1 across many queries is a strong positive signal.</p></li><li><p><strong>Relevance tuning tools:</strong> Per-query CTR and MRR tell you exactly which queries to focus on in tools like <a href="https://elastic.github.io/relevance-studio/#/">Relevance Studio</a> or the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/search-rank-eval.html">Rank Eval API</a>, which scores your ranking against expected results.</p></li><li><p><strong>Query rules and boosting:</strong> Zero-CTR queries with results are candidates for pinning, boosting, or synonym rules.</p></li></ul><p>We'll cover these applications in detail in Blog 5. For now, the important thing is that the instrumentation you've built here is doing double duty: It measures search quality <em>and</em> provides the training data to improve it.</p><h2>Next in the series: Conversion tracking from search to purchase</h2><p>We can now measure whether users find results (Blog 2) and whether they engage with them (this post). But a click isn't a conversion. A user who clicks a product and then abandons the page didn't get what they needed.</p><p>In the next post, we add <em>conversion tracking</em>, the third instrumentation point that closes the loop from search to purchase. It’s the same pattern: Add <code>search.*</code> attributes to add-to-cart and checkout spans, query with ES|QL, and answer the question your product manager actually cares about: <em>Which searches drive revenue?</em></p><h2>Get started</h2><ul><li><p><a href="https://github.com/elastic/elasticsearch-labs/tree/main/supporting-blog-content/search-analytics-otel">Reference project:</a> Working code for the entire blog series (clone, configure, and run).</p></li><li><p><a href="https://github.com/elastic/elastic-otel-python">Elastic Distribution of OpenTelemetry Python:</a> EDOT Python.</p></li><li><p><a href="https://www.elastic.co/docs/solutions/observability/apm/opentelemetry">OpenTelemetry with Elastic:</a> How to send OTel data to Elastic APM.</p></li><li><p><a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/esql.html">ES|QL documentation:</a> Query language reference.</p></li><li><p><a href="https://www.ubisearch.dev/">UBI standard:</a> Reference schema for search event structure.</p></li></ul><p><em>This is the third post in a </em><a href="https://www.elastic.co/search-labs/blog/series/search-analytics-opentelemetry"><em>series on search analytics with OpenTelemetry and Elastic</em></a><em>. Next up: From clicks to conversions: Conversion tracking, funnel analysis, and revenue attribution.</em></p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/search-click-tracking-opentelemetry-esql</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/search-click-tracking-opentelemetry-esql</guid>
    <category><![CDATA[Analytics]]></category>
    <category><![CDATA[ES|QL]]></category>
    <category><![CDATA[Relevance]]></category>
    <dc:creator><![CDATA[Matthew Adams]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt3f04684f0d65c705/6a6efbf02888390e8607b2c2/image1.png" length="0" type="image/png"/>
    <pubDate>Mon, 03 Aug 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[How to instrument your search API with OpenTelemetry and query it with ES|QL]]></title>
    <description><![CDATA[Add custom attributes to OpenTelemetry spans and run six ES|QL queries that reveal your top searches, zero-result rate and slowest queries.]]></description>
    <content:encoded><![CDATA[<p>Instrument your search API with about 20 lines of OpenTelemetry (OTel) code, and Elasticsearch Query Language (ES|QL) can tell you what people are searching for, how often they get nothing back, and how fast search actually runs. This builds directly on <a href="https://www.elastic.co/search-labs/blog/search-analytics-opentelemetry">the first post in this series</a>, where we made the case for using OpenTelemetry over a bespoke analytics pipeline. Here, we wire up a FastAPI search endpoint with custom <code>search.*</code> attributes and run six ES|QL queries against the resulting trace data. On our demo cluster, 17.7% of searches came back empty, a gap we found within minutes of turning the instrumentation on. No separate logging pipeline is required. It's the same spans, attributes, and query language you're probably already running somewhere else in Elastic.</p><h3>What you'll discover</h3><p>In this post, you'll learn how to:</p><ul><li><p>Set up OpenTelemetry in an example Python FastAPI back end.</p></li><li><p>Add custom <code>search.*</code> attributes to your search spans in ~20 lines of code.</p></li><li><p>Understand how OTel-native ingestion maps attributes to queryable data.</p></li><li><p>Write six ES|QL queries against real trace data: top queries, zero-results rate, which queries return nothing, average and max latency, slow query investigation, and search volume over time.</p></li><li><p>Turn those queries into saved Kibana visualizations.</p></li></ul><h3>What you'll need</h3><ul><li><p>An Elastic Cloud deployment (or self-managed with OTel-native ingestion enabled). Examples tested on Elastic Stack 9.x.</p></li></ul><h2>From concept to code: Building the search analytics instrumentation</h2><p>In the <a href="https://www.elastic.co/search-labs/blog/search-analytics-opentelemetry">first post</a>, we made the case for using OpenTelemetry to capture search analytics. The idea: Add <code>search.*</code> attributes to your existing OTel spans, send them to Elastic APM, and query them with <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/esql.html">ES|QL</a>.</p><p>Now let's build it.</p><p><strong>Want working code?</strong> A <a href="https://github.com/elastic/elasticsearch-labs/tree/main/supporting-blog-content/search-analytics-otel">companion reference project</a> accompanies this series. It's a minimal FastAPI app with the exact instrumentation described below. Clone it, add your Elastic Cloud credentials, and you'll have search analytics data flowing in 10 minutes. Each blog stage maps to a commented-out code block you can enable as you progress.</p><h3>An OpenTelemetry primer for search API developers</h3><p>If you've been building search systems but haven't worked with OpenTelemetry before, here's the minimum you need to know.</p><p>OTel is an open standard for collecting observability data, traces, metrics, and logs from your applications. It's vendor-neutral: You instrument your code once, and send data to any compatible back end.</p><p>The core concept is the <em>span</em>. A span represents a single operation, for example, an API call, a database query, or a search request. Every span has a start time, an end time (the difference is the <em>span duration</em>), and <em>attributes</em>, which are key-value pairs that describe what happened.</p><p>Spans nest inside each other to form <em>traces</em>. A trace is a tree of spans that represents one end-to-end request. When a user searches, the trace might look like: browser request → API handler → search logic → Elasticsearch query. Each step is a span, and the parent-child relationships show you exactly where time was spent. This is <em>distributed tracing</em>, which works across services and network boundaries, so a single trace can follow a request from front end to back end to database and back.</p><p><strong>Why traces instead of logs?</strong> You could log <code>"search query=headphones results=15 took=120ms"</code> and parse it later. But a log line is flat; it can't show you that the 120ms Elasticsearch time sat inside a 250ms API call, revealing 130ms of overhead in your application layer. Traces give you hierarchy, timing, and correlation across services. For search analytics, that means you can see not just <em>what</em> happened but also <em>where</em> time was spent and <em>how</em> operations relate to each other.</p><p>For this post, we don't need to understand the full OTel ecosystem. We just need three things:</p><ol><li><p><strong>Create a span</strong> when a search request happens.</p></li><li><p><strong>Add attributes</strong> to that span, describing the search (such as <code>search.query</code> or <code>result_count</code>).</p></li><li><p><strong>Send the span</strong> to Elastic, where we can query it with ES|QL.</p></li></ol><p>That's it. If you can call <code>span.set_attribute("key", value)</code>, you can build search analytics.</p><h3>What you'll build</h3><p>By the end of this post, every search request in your API will emit an OTel span that looks like this:</p>span.name:                     "search"
search.query:                  "wireless headphones"
search.result_count:           15
search.query_id:               "e2afdb85eb63382e..."
search.took_ms:                165<p>And you'll run six ES|QL queries against real data to answer questions that your team is already asking, using about 20 lines of instrumentation code in total.</p><h2>Install the OTel SDK</h2><p>We're using Python and FastAPI here. The same pattern applies to any language with an OTel SDK; the concepts are identical, only the imports change.</p><p>Elastic provides the <a href="https://github.com/elastic/elastic-otel-python">Elastic Distribution of OpenTelemetry Python (EDOT)</a>, which bundles the standard OTel SDK with sensible defaults, early access to Elastic-contributed improvements, and a single <code>configure_opentelemetry()</code> call that handles all the boilerplate. We recommend it:</p>pip install elastic-opentelemetry \
    opentelemetry-instrumentation-fastapi \
    opentelemetry-instrumentation-elasticsearch<p>Three packages, two roles:</p><ul><li><p><code>elastic-opentelemetry</code> EDOT: The OTel API, SDK, and OpenTelemetry Protocol (OTLP) exporter in one package, preconfigured for Elastic.</p></li><li><p><code>opentelemetry-instrumentation-fastapi</code>: Auto-instruments HTTP endpoints (automatic spans for every request).</p></li><li><p><code>opentelemetry-instrumentation-elasticsearch</code>: Auto-instruments Elasticsearch client calls (automatic spans for every query).</p></li></ul><p>The auto-instrumentation packages are doing real work here. Without writing a single line of tracing code, you already get HTTP request spans and Elasticsearch query spans. What we're adding is the search-specific context that turns generic traces into analytics.</p><p><strong>Using the standard OTel SDK instead? </strong>Replace <code>elastic-opentelemetry</code> with <code>opentelemetry-api</code>, <code>opentelemetry-sdk</code>, and <code>opentelemetry-exporter-otlp-proto-http</code>. You'll need to wire up the <code>TracerProvider</code>, <code>OTLPSpanExporter</code>, and <code>BatchSpanProcessor</code> manually (about 10 extra lines). Everything else in this post works the same either way. See the <a href="https://www.elastic.co/docs/solutions/observability/apm/opentelemetry">Elastic OTel guide</a> for the full setup.</p><h2>Configure the connection</h2><p>OTel uses environment variables for connection configuration. You need four to get started:</p><p>Variable</p><p>Purpose</p><p>Example</p><p>`OTEL_EXPORTER_OTLP_ENDPOINT`</p><p>Managed OTLP (mOTLP) endpoint URL</p><p>`https://my-deployment.ingest.us-central1.gcp.elastic-cloud.com`</p><p>`OTEL_EXPORTER_OTLP_HEADERS`</p><p>Authentication</p><p>`Authorization=ApiKey &lt;your-api-key&gt;`</p><p>`OTEL_SERVICE_NAME`</p><p>Service name (shown in Kibana APM)</p><p>`search-analytics-demo`</p><p>`OTEL_RESOURCE_ATTRIBUTES`</p><p>Other resource attributes</p><p>`service.version=1.0.0`</p><p>Where to find these values: In Elastic Cloud, your mOTLP endpoint follows the pattern <code>https://&lt;deployment&gt;.ingest.&lt;region&gt;.gcp.elastic-cloud.com</code>. You can find it in the Elastic Cloud console under your deployment's details or in Kibana at the APM integration page (<code>/app/home#/tutorial/apm</code>) under the <strong>OpenTelemetry</strong> tab. Your API key can be created from Kibana's Stack Management &gt; API Keys or via the Elasticsearch Create API Key API. For self-managed deployments, you can use the <a href="https://www.elastic.co/docs/reference/edot-collector">EDOT Collector</a> as an intermediary that receives OTLP and forwards to Elasticsearch.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltb920fc0478908c55/6a6a3402c40efb7565d30950/86c9ed08f305c168429eff0c0ad059c4d8262197-1440x708.png" alt="Kibana APM integration page showing OpenTelemetry configuration settings for search API instrumentation" /><p>Set them in your environment or <code>.env</code> file:</p>export OTEL_EXPORTER_OTLP_ENDPOINT="https://my-deployment.ingest.us-central1.gcp.elastic-cloud.com"
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=ApiKey &lt;your-api-key&gt;"
export OTEL_SERVICE_NAME="search-analytics-demo"
export OTEL_RESOURCE_ATTRIBUTES="service.version=1.0.0"<h2>Initialize the tracer</h2><p>With EDOT and environment variables configured, initialization wires up three things: the tracer provider and two auto-instrumentation packages that automatically create spans for every HTTP request and every Elasticsearch query:</p>from opentelemetry import trace
from elastic_opentelemetry import configure_opentelemetry
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.elasticsearch import ElasticsearchInstrumentor

def init_otel(app):
    configure_opentelemetry()
    FastAPIInstrumentor.instrument_app(app)
    ElasticsearchInstrumentor().instrument()

tracer = trace.get_tracer("search-api")<p><code>configure_opentelemetry()</code> reads the <code>OTEL_*</code> environment variables and sets up the tracer provider, exporter, and batch processor with Elastic-optimized defaults, including using the HTTP exporter automatically, which is what the mOTLP endpoint requires. If you see connection errors with vanilla OTel, the most common cause is accidentally using the gRPC exporter instead of HTTP.</p><p>The two instrumentors patch the FastAPI and <code>elasticsearch-py</code> libraries at startup so every request and every Elasticsearch call automatically generates a span, without any code changes to individual endpoints.</p><h2>Instrument your search API</h2><p>Here's where it gets interesting. This is the code that turns a generic API endpoint into a search analytics source:</p>from opentelemetry import trace

tracer = trace.get_tracer("search-api")

@app.post("/api/search")
def search(request: SearchRequest):
    with tracer.start_as_current_span("search") as span:
        # Set attributes BEFORE the query
        # (available even if the query fails)
        query_id = format(span.get_span_context().trace_id, "032x")
        span.set_attribute("search.query", request.query)
        span.set_attribute("search.query_id", query_id)

        results = es.search(
            index="products",
            body=build_query(request)
        )

        # Set attributes AFTER the query
        total_hits = results["hits"]["total"]["value"]
        span.set_attribute("search.result_count", total_hits)
        span.set_attribute("search.took_ms", results["took"])

        # Include query_id in the response so the frontend can link
        # click and conversion events back to this search
        return {
            **format_response(results),
            "query_id": query_id,
        }<p>A few things worth unpacking.</p><ul><li><p><code>start_as_current_span</code> creates the span and sets it as the active span in the current context. This matters because the Elasticsearch client instrumentation picks up the active span and nests its own spans underneath it. You get a span hierarchy automatically.</p></li><li><p><code>search.query_id</code> is derived from the trace ID. Every trace already has a unique identifier, and we're reusing it as the query identifier. There’s no UUID generation or database sequence. When we add click tracking later, clicks will reference this same <code>query_id</code> to link back to the search that produced the results.</p></li><li><p><code>search.result_count</code> does double duty. It tells you how many results came back, and when it's zero, you know you have a content gap. There’s no need for a separate boolean flag: Just filter on <code>result_count == 0</code> in your queries.</p></li></ul><p>Note that the application name is no longer set as a span attribute; it's the <code>service.name</code> resource attribute, configured once via <code>OTEL_SERVICE_NAME</code>. This is the standard OTel approach: Resource attributes describe the service, and span attributes describe the operation.</p><h3>Normalize search queries before analyzing them</h3><p>Notice that we're storing <code>request.query</code> as is. That means "Laptop Bag", "laptop bag", and " laptop bag " will be counted as three different queries when you aggregate with <code>STATS ... BY attributes.search.query</code>.</p><p>For cleaner analytics, normalize before setting the attribute:</p>span.set_attribute("search.query", request.query.strip().lower())<p>Lowercasing and trimming whitespace is enough for most cases. If you need the original phrasing (for display or debugging), store it in a separate attribute, like <code>search.query.original</code>. But start simple. You can always add the raw version later if you find you need it.</p><h3>What a search API trace looks like</h3><p>Once this is running, a single search request produces this trace:</p>HTTP POST /api/search          (root — auto-instrumented by FastAPI)
└── search                     (our span — search.* attributes live here)
    ├── info                   (ES client — auto-instrumented)
    ├── query_rules.get_ruleset (ES client)
    └── search                 (ES client — the actual Elasticsearch query)<p>The auto-instrumented spans give you HTTP latency and Elasticsearch query detail. Your <code>search</code> span in the middle ties them together with the business context: what the user searched for, how many results came back, how long Elasticsearch took.</p><h3>The search span attributes you need to capture</h3><p>Here's the full set of attributes we're capturing on the search span:</p><p>Attribute</p><p>Type</p><p>When set</p><p>Purpose</p><p>`search.query`</p><p>string</p><p>Before query</p><p>The query as the user entered it</p><p>`search.query_id`</p><p>string</p><p>Before query</p><p>Unique identifier, derived from trace ID</p><p>`search.result_count`</p><p>int</p><p>After query</p><p>Total matching results (0 = zero-result search)</p><p>`search.took_ms`</p><p>int</p><p>After query</p><p>Elasticsearch execution time in milliseconds</p><p>`search.query_response_hit_ids`</p><p>string[]</p><p>After query</p><p>Document IDs returned (optional; enables per-result analytics)</p><p>`feature_flag.key`</p><p>string</p><p>Before query</p><p>A/B test flag name (optional; pair with `feature_flag.result.variant` for the assigned variant; enables per-variant click-through rate (CTR) comparison)</p><p>We use the <code>search.*</code> namespace following OTel's convention of domain-specific prefixes (<code>http.*</code>, <code>db.*</code>, <code>messaging.*</code>). While there aren't standardized search conventions in OTel yet, <code>search.*</code> is self-describing and vendor-neutral. The naming is informed by the <a href="https://www.ubisearch.dev/">User Behavior Insights (UBI)</a> Standard, which defines a schema for search events. We reference it for structure without coupling to it. Where established OTel conventions exist, like <code>feature_flag.key</code> (flag name) and <code>feature_flag.result.variant</code> (assigned variant) for A/B experiments, we reuse them rather than inventing custom attributes.</p><p>You'll notice that <code>enduser.pseudo.id</code> isn't in the search span table above. We don't need it for query analytics, but you'll add it as soon as you introduce click tracking in our third blog; it ties click events back to a specific browser session, enabling per-user CTR and Mean Reciprocal Rank (MRR). Blog 3 adds <code>enduser.pseudo.id</code> (browser-generated, persistent across sessions) to link clicks back to searches. Our fourth blog focussing on revenue attribution documents <code>session.id</code> and <code>user.id</code> as optional extensions for authenticated users who want cross-device attribution.</p><h2>How OpenTelemetry attributes become queryable ES|QL fields</h2><p>Before we start querying, you need to understand how OTel attributes map to Elasticsearch fields. With OTel-native ingestion into Elastic, the mapping is straightforward.</p><p>OTel attribute</p><p>Type</p><p>ES|QL field</p><p>`search.query`</p><p>string</p><p>`attributes.search.query`</p><p>`search.result_count`</p><p>int</p><p>`attributes.search.result_count`</p><p>`search.took_ms`</p><p>int</p><p>`attributes.search.took_ms`</p><p>`search.query_id`</p><p>string</p><p>`attributes.search.query_id`</p><p>`feature_flag.key`</p><p>string</p><p>`attributes.feature_flag.key`</p><p>With OTel-native ingestion, attribute names preserve their dot notation under <code>attributes.*</code>. All types live in the same namespace; there’s no split between string and numeric fields. Booleans are stored as native booleans, not strings. If you've used Elastic APM's classic ingestion before, you'll appreciate the simplicity: What you set in code is what you query.</p><h2>Running ES|QL queries in Kibana Discover</h2><p>With spans flowing to Elastic, open Kibana and go to <strong>Discover</strong> (in the left sidebar under <strong>Analytics</strong>, or use the global search bar and type "Discover"). By default, you'll see the KQL query bar, a filter language familiar from Kibana dashboards. Click <strong>Try ES|QL</strong> in the top right to switch to the ES|QL editor. Unlike KQL (which filters documents) or the JSON query DSL (which requires nested objects), ES|QL is a piped language: Each <code>|</code> step transforms the previous output, making aggregations like <code>STATS count BY field</code> read naturally from left to right.</p><p>The editor gives you a full-width text area where you type piped queries. Results appear as both a table and an auto-generated chart; Kibana picks a sensible visualization based on your query shape. For <code>STATS ... BY</code> queries, you'll get a bar chart automatically.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt839635c0a21e4916/6a6a3403d57c1d4bd1c13ef0/d284fdad7c58576f79209e72b7579b37f4ebbe63-1440x708.png" alt="Kibana Discover ES|QL query results showing search count and average results by search query" /><p>Set the time range wide enough to capture your data (top-right date picker). If you're just getting started, try "Last 30 days".</p><h2>Six ES|QL queries for search analytics</h2><p>Everything below runs against <code>traces-generic.otel-default</code>, the index where Elastic's OTel-native ingestion automatically stores trace data. You don't need to create this index; it's provisioned by Elastic when the first OTLP span arrives.</p><p>These queries ran against our live demo cluster: 62 searches, ~20 distinct queries, latency range 77ms–153ms.</p><p><strong>Note:</strong> Results below are illustrative. When you run the <a href="https://github.com/elastic/elasticsearch-labs/tree/main/supporting-blog-content/search-analytics-otel">reference project</a> and generate traffic with <code>python generate_traffic.py --blog 2 --sessions 50</code>, your exact numbers and top queries will vary based on session count and random query selection. The query patterns and ES|QL syntax are what matter here.</p><h3>Query 1: Are spans arriving?</h3><p>Start simple. Count your search spans.</p>FROM traces-generic.otel-default
| WHERE attributes.search.query IS NOT NULL
  AND name == "search"
| STATS total_searches = COUNT(*)<p><strong>Result:</strong> 62.</p><p>If this returns zero, your spans aren't arriving. Check your OTLP endpoint and API key and that <code>init_otel()</code> is being called before any requests. The <code>name == "search"</code> filter ensures that you're counting your custom spans, not the auto-instrumented Elasticsearch client spans (which are also named "search").</p><h3>Query 2: What are users searching for?</h3><p>The first question every search team asks.</p>FROM traces-generic.otel-default
| WHERE attributes.search.query IS NOT NULL
  AND attributes.search.query != ""
  AND name == "search"
| STATS
    search_count = COUNT(*),
    avg_results = ROUND(AVG(attributes.search.result_count), 0)
  BY attributes.search.query
| SORT search_count DESC
| LIMIT 20<p><strong>Results:</strong></p><p>Query</p><p>Searches</p><p>Average results</p><p>laptop</p><p>9</p><p>8</p><p>headphones</p><p>7</p><p>12</p><p>running shoes</p><p>6</p><p>5</p><p>"laptop" was the most popular query, with nine searches. There were around 20 distinct queries total (including zero-result ones).</p><p>The <code>avg_results</code> column tells you whether popular queries are actually returning content. A query with high volume and low results is a relevance problem worth investigating. If your query has high volume and high results, check whether users are actually clicking. We'll get to that in the next blog focussing on measuring search quality with click data.</p><h3>Query 3: What percentage of searches return nothing?</h3>FROM traces-generic.otel-default
| WHERE attributes.search.query IS NOT NULL
  AND name == "search"
| STATS
    total = COUNT(*),
    zero_results = COUNT(CASE(attributes.search.result_count == 0, 1))
| EVAL zero_rate_pct = ROUND(100.0 * zero_results / total, 1)<p><strong>Result:</strong> 17.7% (11 out of 62 searches returned nothing).</p><p>We're using <code>attributes.search.result_count == 0</code>, a straightforward numeric comparison. No separate boolean attribute is needed when you already have the count.</p><p>A zero-results rate above 10% is worth investigating. Every zero-result search is a user who asked for something and got nothing back. Some of those are junk queries, but others reveal real content gaps or query parsing failures.</p><h3>Query 4: Which queries return nothing?</h3><p>The rate tells you there's a problem. This query tells you where.</p>FROM traces-generic.otel-default
| WHERE attributes.search.result_count == 0
  AND name == "search"
| STATS occurrences = COUNT(*) BY attributes.search.query
| SORT occurrences DESC
| LIMIT 20<p><strong>Results:</strong></p><p>Query</p><p>Occurrences</p><p>quantum physics calculator</p><p>4</p><p>unicorn saddle</p><p>3</p><p>holographic projector</p><p>2</p><p>time machine parts</p><p>2</p><p>Three different failure modes: "quantum physics calculator" and "unicorn saddle" are out-of-catalog queries you'll never be able to serve. This is useful to know but nothing to fix. "holographic projector" might be a real emerging category worth considering. "time machine parts" is probably noise. In a production catalog, these would be mixed with legitimate zero-result queries that <em>are</em> fixable, like missing synonyms, phrasing mismatches, or product gaps.</p><p>Repeated zero-result queries are the highest-priority fixes. One-off failures are usually noise.</p><h3>Query 5: How fast is search?</h3>FROM traces-generic.otel-default
| WHERE attributes.search.query IS NOT NULL
  AND name == "search"
| STATS
    avg_ms = ROUND(AVG(attributes.search.took_ms), 0),
    max_ms = MAX(attributes.search.took_ms)<p><strong>Result:</strong> Average 81ms, max 153ms.</p><p><code>search.took_ms</code> captures Elasticsearch's self-reported execution time, the <code>took</code> field from the search response. This is different from <em>span duration</em>, the wall-clock time from when the span started to when it ended (as we covered in the primer above). Span duration measures end-to-end time, including network round trips, serialization, and application logic. You want both: Comparing them tells you where overhead lives. If <code>took_ms</code> is 50ms but the span duration is 200ms, the extra 150ms is network or application overhead, not a query problem.</p><p>This attribute also keeps your analytics portable. If you're using OTel log records instead of spans (a lighter-weight alternative we mention in <a href="https://www.elastic.co/search-labs/blog/search-analytics-opentelemetry">the first blog in the series</a>), there's no span duration. <code>took_ms</code> is the only timing signal you have.</p><p>We'll go deeper on search performance monitoring (Service Level Objectives [SLOs], alerting on latency regressions, and using this data for operational dashboards) in the last blog in the series focussing on Search Reliability Engineering.</p><p>Want to find the slow queries specifically?</p>FROM traces-generic.otel-default
| WHERE attributes.search.query IS NOT NULL
  AND name == "search"
| STATS
    avg_ms = ROUND(AVG(attributes.search.took_ms), 0),
    max_ms = MAX(attributes.search.took_ms),
    search_count = COUNT(*)
  BY attributes.search.query
| SORT avg_ms DESC
| LIMIT 20<p>A query with high average latency and high result count is hitting many documents; consider query optimization. High latency with low results might mean complex filters or slow aggregations. Outlier max values are often cold caches or cluster issues.</p><h3>Query 6: How does search volume change over time?</h3><p>Counts, rates, and latencies tell you the <em>what</em>. Volume over time tells you the <em>when</em>: W<em>hen did traffic spike, when did it drop, and when did that zero-results rate jump?</em></p>FROM traces-generic.otel-default
| WHERE attributes.search.query IS NOT NULL
  AND name == "search"
| EVAL bucket = DATE_TRUNC(5 minutes, @timestamp)
| STATS searches = COUNT(*) BY bucket
| SORT bucket<p><code>DATE_TRUNC(5 minutes, @timestamp)</code> rounds each timestamp down to the nearest 5-minute boundary. The result is a time series that Kibana's Lens can render as a bar chart or line, showing your search traffic pattern for any time window.</p><p>Narrow the bucket for higher granularity (<code>1 minute</code>), widen it for trend analysis (<code>1 hour</code>, <code>1 day</code>). When you add this to a dashboard alongside your zero-results rate, you can answer: <em>Did zero-results spike because traffic changed or because something broke?</em></p><h2>Verify that your search API instrumentation is working</h2><p>If you're using the reference project, the full setup is:</p>git clone https://github.com/elastic/elasticsearch-labs.git
cd elasticsearch-labs/supporting-blog-content/search-analytics-otel
cp .env.example .env           # fill in ELASTICSEARCH_URL, ELASTIC_API_KEY,
                               # OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_EXPORTER_OTLP_HEADERS
python3 -m venv venv &amp;&amp; source venv/bin/activate
pip install -r requirements.txt
python load_data.py             # index products into Elasticsearch
python app.py                   # starts on http://localhost:8000<p>Then trigger a search:</p>curl -X POST http://localhost:8000/api/search \
  -H "Content-Type: application/json" \
  -d '{"query":"laptop"}'<p>Wait 5–10 seconds for the <code>BatchSpanProcessor</code> to flush, and then open Kibana → Discover → switch to ES|QL mode and run:</p>FROM traces-generic.otel-default
| WHERE attributes.search.query IS NOT NULL
| LIMIT 5<p>You should see rows with <code>attributes.search.query</code>, <code>attributes.search.result_count</code>, and <code>attributes.search.took_ms</code>.</p><p>If no rows appear, check in order:</p><ol><li><p><code>OTEL_EXPORTER_OTLP_ENDPOINT</code> points to the mOTLP endpoint, not your Elasticsearch URL.</p></li><li><p><code>OTEL_EXPORTER_OTLP_HEADERS</code> includes <code>Authorization=ApiKey &lt;your-key&gt;</code>.</p></li><li><p><code>OTEL_TRACES_SAMPLER=always_on</code> is set (default sampler may drop spans).</p></li><li><p>Kibana → Observability → APM → Services shows <code>search-analytics-demo</code> (confirms export is working).</p></li></ol><h2>Turn ES|QL results into Kibana visualizations</h2><p>The bar chart that Discover auto-generates from your ES|QL results is a good start, but you can customize it. Click the <strong>pencil icon</strong> in the top-right corner of the chart to open the inline Lens editor.</p><p>From here you can:</p><ul><li><p>Change chart type (bar, line, area, pie, table, metric).</p></li><li><p>Adjust axes and add breakdown dimensions.</p></li><li><p>Save the visualization to a dashboard.</p></li></ul><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt0e8869398e3cb98b/6a6a3404c40efb00b9d30954/e3db0028b0dff2ce5a524b62f600bfcfd8820f22-1440x708.png" alt="Kibana Lens configuration panel for a bar chart visualization of ES|QL search analytics data" /><p>This is the path from ad hoc ES|QL exploration to a persistent dashboard panel. You don't need to build visualizations from scratch; Discover and Lens handle the chart rendering from your query results.</p><p>Lens is Elastic's drag-and-drop visualization editor, and it's more capable than this quick workflow suggests. You can build multilayer charts, combine metrics with breakdowns, add reference lines, and design full dashboards that mix ES|QL panels with traditional aggregation-based visualizations. For search analytics, that means you can put top queries, zero-results trends, and latency percentiles side by side in a single view.</p><p>To go deeper:</p><ul><li><p><a href="https://www.elastic.co/guide/en/kibana/current/lens.html">Lens documentation</a>: A full guide to the visualization editor.</p></li><li><p><a href="https://www.elastic.co/docs/explore-analyze/visualize/esorql">ES|QL in Lens</a>: Using ES|QL queries as data sources for dashboard panels.</p></li><li><p><a href="https://www.elastic.co/guide/en/kibana/current/dashboard.html">Kibana Dashboards</a>: Building and sharing operational dashboards.</p></li><li><p>Use an <a href="https://www.elastic.co/docs/explore-analyze/ai-features/agent-builder/agent-builder-dashboards-and-visualizations">Agent built in Kibana to make visualizations for you</a></p></li></ul><p>We'll build a full search analytics dashboard in a later post.</p><h2>How sampling affects search analytics accuracy</h2><p>Most application performance monitoring (APM) configurations sample traces to control costs, capturing 10% or 25% of requests. For application monitoring, that's fine. For search analytics, it's a problem.</p><p>If you're sampling at 10%, your "total searches" count is 90% lower than reality. Your zero-results rate is still accurate (it's a ratio), but volume counts are off.</p><p>Two approaches:</p><p><strong>Approach 1: Configure 100% sampling for search endpoints.</strong> Your search API probably handles far fewer requests than your main application, so the data volume increase is manageable. The simplest way is through environment variables:</p># 100% sampling (capture every trace)
export OTEL_TRACES_SAMPLER=always_on

# Or sample a percentage (e.g. 50%)
export OTEL_TRACES_SAMPLER=traceidratio
export OTEL_TRACES_SAMPLER_ARG=0.5<p>These are head-based sampling decisions made at the start of each trace. They apply globally to the service, which is fine if your search API is a dedicated service. If search shares a service with other endpoints and you need per-endpoint sampling rules, you can implement a custom <code>Sampler</code> in the OTel SDK that inspects the span name or attributes before deciding.</p><p>More sophisticated routing (sampling differently per endpoint, dropping noisy spans, or making decisions after a trace completes [tail-based sampling]) typically involves deploying an OTel Collector (such as the <a href="https://www.elastic.co/docs/reference/edot-collector">EDOT Collector</a>) as an intermediary between your application and Elastic. That's a valuable architecture pattern, but it's beyond the scope of this post. See the <a href="https://opentelemetry.io/docs/collector/">OTel Collector documentation</a> and <a href="https://www.elastic.co/docs/reference/edot-collector/modes">Elastic's EDOT Deployment</a> for more on collector-based sampling and routing architectures.</p><p><strong>Approach 2: Upscale in your queries.</strong> If you know the sampling rate, multiply: <code>EVAL estimated_total = total_searches * 10</code>. Ratios and averages stay correct; only absolute counts need adjustment.</p><p>For more on sampling strategies generally, see the <a href="https://opentelemetry.io/docs/concepts/sampling/">OTel sampling documentation</a>.</p><p>For the queries in this post, we used 100% sampling.</p><h2>What's next: Adding click tracking to search analytics</h2><p>The six ES|QL queries in this post answer: <em>What do users search for, what returns nothing, how fast is search, and when does traffic spike?</em> They're all derived from a single instrumentation point: the search span.</p><p>But they can't tell you whether users are finding what they need. A search that returns 15 results looks healthy from the server side. But if nobody clicks any of those results, your ranking has a problem.</p><p>In the next post, we add <em>click tracking</em>, a second span that captures which result the user clicked and where it appeared in the list. If you've been running the reference project, you already have 62 search spans; the next post builds directly on that data. With searches and clicks linked together, we'll calculate:</p><ul><li><p><strong>Click-through rate (CTR):</strong> What percentage of searches result in a click.</p></li><li><p><strong>Mean Reciprocal Rank (MRR):</strong> How far down the results users have to scroll.</p></li><li><p><strong>Click position distribution:</strong> The shape of where users click.</p></li></ul><p>The pattern is the same: You add attributes to spans and query them with ES|QL. You get a richer view of the same data without introducing new infrastructure.</p><h2>Resources to get started with search analytics on OpenTelemetry</h2><ul><li><p><a href="https://github.com/elastic/elasticsearch-labs/tree/main/supporting-blog-content/search-analytics-otel">Reference project</a>: Working code for the entire blog series; clone, configure, run.</p></li><li><p><a href="https://github.com/elastic/elastic-otel-python">EDOT Python</a>: Elastic distribution of OpenTelemetry for Python.</p></li><li><p><a href="https://www.elastic.co/docs/solutions/observability/apm/opentelemetry">OpenTelemetry with Elastic</a>: How to send OTel data to Elastic APM.</p></li><li><p><a href="https://opentelemetry.io/docs/languages/python/">OpenTelemetry Python SDK</a>: Upstream SDK documentation and instrumentation guides.</p></li><li><p><a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/esql.html">ES|QL documentation</a>: Query language reference.</p></li><li><p><a href="https://www.ubisearch.dev/">UBI Standard</a>: Reference schema for search event structure.</p></li></ul><p><em>This is the second post in a series on search analytics with OpenTelemetry and Elastic. Next up: Measuring search quality: Click tracking, CTR, MRR, and click position analysis.</em></p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/search-analytics-opentelemetry-esql</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/search-analytics-opentelemetry-esql</guid>
    <category><![CDATA[Analytics]]></category>
    <category><![CDATA[ES|QL]]></category>
    <category><![CDATA[Python]]></category>
    <dc:creator><![CDATA[Matthew Adams]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf69e1ba73bd5d402/6a6a340599442cd9e0df1d94/1794a179d9536e693a0982634da59aff209c9d68-1280x720.png" length="0" type="image/png"/>
    <pubDate>Wed, 22 Jul 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[AI shopping agents: Why context comes before the query]]></title>
    <description><![CDATA[AI shopping agents that guess at your vocabulary make expensive mistakes. Pre-computed catalog context stops the guessing before the first tool call.]]></description>
    <content:encoded><![CDATA[<p>The race is on for retailers to match the evolving expectations of their customers to provide significantly richer online shopping experiences. Customers want to go beyond searching for products; they want interactive, personalized, and proactive guidance powered by AI. The challenge is that, although large language models (LLMs) can be extremely powerful, how do you construct a system that acts like a knowledgeable employee of your store in a fast, accurate, and cost-effective way? We’ll discuss the challenges of building these AI shopping assistants as well as emerging context engineering approaches to optimize how they work.</p><p>AI shopping agents fail not because the model is wrong but because the agent arrives at every conversation not knowing your catalog, vocabulary, or business rules. It has to discover all of this context through tool calls, and that discovery is the cost. Precomputing a structured context layer from signals you already hold (vocabulary, policies, user profiles, session behavior) cuts the exploratory work the agent does before it can answer and makes its behavior governed and predictable.</p><p>In the analogous document-retrieval case, precomputing context reduced input tokens by up to 75% on a controlled benchmark; we expect comparable savings in ecommerce because the exploration pattern is the same, though the exact figure will vary by catalog and query mix. The signals are richer in ecommerce than in almost any other domain, and most retailers already have them. The question is whether or not these signals are assembled in a form that the agent can use before it starts reasoning. Elastic’s broad mix of search capabilities, semantic, hybrid, keyword, filtering, and aggregations make it a compelling choice for not only building your core retrieval tools but also as the vital context engine.</p><h2><strong>Why AI shopping agents fail in production</strong></h2><p>Retailers investing in AI shopping assistants are discovering an uncomfortable gap between what the demos promise and what the first few months in production deliver.</p><p>The assistant takes four seconds to respond. It confidently recommends a product in a size range that doesn't exist for that item. It tells a returning customer about a coat style they bought 18 months ago and returned. It filters by a category name that doesn't match the internal taxonomy and returns zero results. The customer gives up and abandons the chat altogether.</p><p>These aren't model failures. The frontier models powering these agents are capable of extraordinary reasoning when they have the right information in front of them. The problem is that the agent arrives at the conversation knowing nothing about the retailer's catalog, customer, or business rules that govern what should and shouldn't be recommended. It has to learn all of this through the conversation itself, making exploratory tool calls to discover what departments exist, what filter values are valid, and what the brand's policies are on certain product types. Every one of those discovery calls costs latency and tokens before the agent has said a single useful thing to the customer.</p><p>Latency matters in ecommerce in a way it doesn't in many other contexts. Shoppers expect response times measured in seconds, not the minutes that enterprise knowledge-base agents routinely take. It’s well established that slower responses reduce engagement and conversion in online retail. An AI shopping assistant that thinks visibly for five seconds before answering a question about gift ideas isn't a feature; it's friction.</p><p>The fix isn't a faster model or a bigger context window. <strong>The agent's latency and cost problem is a context problem.</strong> This can be solved by carefully computing context before the agent call, not during it.</p><h3><strong>What an AI shopping agent knows before it searches</strong></h3><p>Imagine you’re the agent. You have a search tool connected to the catalog, and this query arrives:</p><p><code>"an outfit for an autumn wedding"</code></p><p>What do you actually do with that?</p><p>Start with what you don't know. An outfit for a man or a woman? Is "autumn" a color, a season, a style of fabric, or just when the wedding happens? And who is this shopper, someone who has bought from you for years, or a stranger? Do they buy expensive designer brands or do they always hunt out a bargain on sale? You have none of these answers. So you do what an agent does when it's working blind: You ask loads of follow-up questions, guess, or fire off a series of exploratory searches to find out what departments and filter values even exist, watching the seconds tick by before you've offered the customer anything at all.</p><p>Hold onto that feeling of working blind. The rest of this article is about what changes when the agent is handed the answers first.</p><h2><strong>Why ecommerce is different from general RAG</strong></h2><p>Most of the published work on reducing agent costs focuses on document retrieval: question-answering over corpora of articles, reports, or knowledge-base entries. A recent experiment from the Elastic team (<a href="https://www.elastic.co/search-labs/blog/pre-computed-context-llm-agent-costs">Cutting agent costs with pre-computed context</a>) demonstrated that pre-extracting structured facts from documents before the agent call reduced input token consumption by up to 75% and improved answer accuracy from 60% to 92% on a hard factual benchmark. That improvement came in stages, with the largest jump driven by feeding the agent's own wrong answers back into the extraction step rather than by precomputing context alone, which is a distinction we'll return to when we discuss governance.</p><p>Ecommerce applies the same principle to a fundamentally different structure. A product catalog isn't a document corpus. It's a highly structured index of items with strict field semantics, a domain-specific vocabulary of brand names, color codes, and category hierarchies, and a layer of business rules that override pure relevance in specific situations.</p><p>The failure modes that result are distinct from document retrieval augmented generation (RAG) failures:</p><ul><li><p><strong>Vocabulary mismatch:</strong> A customer asks for a "navy jumper." The agent constructs a filter against a field where the canonical value is NAVY and the category is stored as Knitwear &amp; Jumpers. Without a vocabulary mapping, the agent either guesses colors and categories and gets it wrong, or makes multiple exploratory calls to discover what values exist before it can filter correctly.</p></li><li><p><strong>Hallucinated filter values:</strong> Without knowing which filter dimensions are valid for a given query, agents can construct queries against fields that don't exist or with values that return zero results. A filter like category: knitwear looks reasonable; <code>masterCategoryNames: "Knitwear &amp; Jumpers"</code> is what the index actually contains. If the agent doesn’t know, it either hallucinates or has to do a separate tool call to find out, causing another LLM loop, which costs time and tokens.</p></li><li><p><strong>Context-free personalization:</strong> The same query from two different customers, one who typically shops in the premium range and dresses for formal occasions, and one who buys primarily casualwear under £40, should return different results. Without profile context, the agent treats every query identically, which is worse than a well-tuned keyword search because it creates the impression of a personal assistant while delivering generic answers.</p></li></ul><h2><strong>The context layer: What signals it needs</strong></h2><p>The reason ecommerce is particularly well suited to precomputed context is that retailers already hold an unusually rich set of signals. The challenge isn't data availability; it's assembly.</p><p>The signals fall into two groups. Two of them, the catalog vocabulary and the business policies, are the genuinely original work and the heart of this approach. The rest, live facet state, user profiles, and session history, are valuable but closer to table stakes, signals that most teams already understand how to fetch. Here's what most mid-to-large retailers have and what each signal prevents.</p><p>Signal</p><p>What it prevents</p><p>Effort to build</p><p>Catalog vocabulary</p><p>Vocabulary mismatch and hallucinated filter values; the agent guessing at colors, categories, or brand names instead of resolving them to canonical field values</p><p>One-time engineering effort (full-catalog aggregation); incremental to maintain as new categories and brands are added</p><p>Business policies</p><p>Recommendations that ignore legal or trading requirements, for example, missing age verification on alcohol queries or routing that misses a gluten-free range</p><p>Human-authored and governed, not automated; ongoing review as new policy types are added</p><p>Live facet state</p><p>Recommending filters that return zero results or out-of-stock options for the current query</p><p>Runs in parallel with vocabulary and policy lookups; leans on existing catalog and retrieval infrastructure</p><p>User profile</p><p>Making a returning customer restate sizes, budget, or brand preferences they've already given</p><p>Fastest signal to retrieve, a single document lookup by user ID</p><p>Session and purchase history</p><p>Re-recommending an item the customer already dismissed or bought and returned</p><p>Most aspirational layer; depends on customer relationship management (CRM) and analytics integration, best added once the agent is already live</p><p>The reason we say <em>semantic metadata</em> and not just <em>metadata</em> is that we’re trying to match the semantic (meaning) of the intent rather than the exact words. If a user is searching for “teal,” we should be able to understand that this is a color and which colors exist in our products that are semantically similar to teal, even if none of them are actually teal. So, if we search for “teal,” we might want to return:</p><p><code>ProductColours = “aquamarine, turquoise”</code></p><p>Hopefully, you can see how this semantic metadata is bridging the gap between user intent and the agent’s knowledge of the products.</p><h3><strong>Catalog vocabulary: The foundation of context engineering</strong></h3><p>Catalog vocabulary is the layer that does the most work, and it's the one most worth getting right first.</p><p>A vocabulary index maps natural language to the exact field values and category paths used in the product index. It answers questions like: <em>What does "navy" map to?</em> <em>Which categories fall under "knitwear"?</em> <em>Is "Autograph" a brand or a range?</em> <em>What's the correct spelling of a competitor brand the agent might encounter in a query?</em></p><p>What makes this more than a synonym list is how it's queried. The interesting thing a shopper says is rarely an exact field value. They say "something cozy for fall," not <code>colour: NAVY</code> and <code>masterCategoryNames: "Knitwear &amp; Jumpers"</code>. So the vocabulary index needs to resolve fuzzy, natural language intent into precise, exact-match filters, and that requires both kinds of matching at once: semantic search to understand that "cozy" leans toward knitwear and fleece, and exact keyword matching to pin the result to the canonical values the product index actually stores. A metadata index that supports both on the same documents is, in effect, a translation layer between how customers talk and how the catalog is structured.</p><p>This is also where the index earns the description "semantic metadata layer" rather than "lookup table." Each entry is a small natural language description of a facet value or schema concept, so the agent can match against meaning and then read back the exact filter to use. For a typical fashion retailer, this covers hundreds of color values, brand aliases, category synonyms, and size-range conventions. Building the semantic metadata layer from a full-catalog aggregation is a one-time engineering effort; maintaining it is incremental as new categories and brands are added. Without it, an agent encountering an unfamiliar term must either guess or make exploratory tool calls to discover what's there.</p><h3><strong>Business policies in the context layer</strong></h3><p>The second original layer is policy. Some queries carry implicit business requirements that pure relevance cannot handle. A query for "wine gift for a friend" should trigger an age-verification reminder in markets where it's legally required. A query for "gluten-free food gift" should route away from general confectionery toward the specific gluten-free range. A query mentioning "wedding guest outfit" in spring should apply different weighting than the same query in November.</p><p>These are policies, and most retail search teams already write them. They just call them boost rules, merchandising overlays, or synonym configurations. The difference in an agentic context is that instead of being applied silently as query modifications, they're surfaced as readable hints the agent can use when deciding how to frame its answer and which products to surface. The agent doesn't have to infer your trading rules from the catalog; it's handed them.</p><p>The critical point: These policies encode business intent, not just relevance. A policy that routes alcohol queries through an age-appropriate flow isn't a retrieval optimization; it's a trading requirement. That's why this layer must be human-authored and governed, not generated automatically from traffic patterns, a point we return to in the governance section.</p><p>Together, vocabulary and policy are what make the agent behave like it understands your business rather than just your data. The remaining three signals sharpen the experience, but they're more familiar engineering.</p><h3><strong>Facet, profile, and session signals in the context layer</strong></h3><ul><li><p><strong>Live facet state:</strong> Before the agent recommends filters, it should know which filters are available and how many results each returns for this specific query. An agent that suggests "filter by size 8" without knowing that size 8 is out of stock for this query undermines the customer's trust immediately. A facet state query against the product index, run in parallel with the vocabulary and policy lookups, returns the counts, ranges, and available values specific to the current query.</p></li><li><p><strong>User profile:</strong> A persistent profile (sizes, color preferences, budget range, brand affinities) lets a returning customer skip restating what they've already told you. It's typically the fastest signal to retrieve, a single document lookup by user ID.</p></li><li><p><strong>Session and purchase history:</strong> Within a session, the agent should know what the customer has already seen, dismissed, or added to their basket, so it doesn't re-recommend a dismissed item or repeat itself. Longer-term purchase history extends this, and signals like return history are richer still, but using them well depends on data most retailers hold in systems that aren't yet wired into their search path. This is the most aspirational layer and the one best approached last, once the earlier layers are delivering value. There must also be balance when building the initial context not to overinflate the size, which will slow down the first reply and increase token costs. There is, therefore, a careful balance to strike between providing information like purchase history in the initial context or providing it as a tool for the agent to use during conversation, but users will expect that, if they’re logged in, the agent should know what they’ve purchased. The precise optimal context is likely to be specific to each implementation and customer experience and will require careful testing.</p></li></ul><h2><strong>How context engineering works before the LLM call</strong></h2><p>The pattern that makes this work is simple to describe and moderately involved to implement:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt017dd46a26cf7bc6/6a6119f6f81792b87a07f605/e814160fc7e599aa3cd1603be366cc726114948f-1024x559.png" alt="Diagram of context engineering for AI shopping agents: a context layer resolves vocabulary, policy, facets, profile and session signals in parallel before the LLM call" /><p>Without this context build, the agent makes the same discoveries on its own, but through LLM-driven tool calls that each cost a full inference round trip. As a rough rule of thumb, an exploratory tool (for example, GetFilterValues) call tends to land somewhere in the region of 600ms to 1 second of latency in practice; an agent that discovers the vocabulary, checks policy hints, and retrieves facet state through three separate tool calls before it even begins answering can, therefore, add two to three seconds to the response time, and that's before it performs the actual product search. These are order-of-magnitude estimates, not benchmarked figures, and the real numbers depend heavily on the model, the network path, and how the tools are implemented.</p><p>Replacing those discovery calls with a parallel fetch (multiple context-building queries can be done in parallel) that runs before the LLM is invoked removes most of that cost. The context build takes roughly the same wall time as a single LLM tool call, but it replaces three or four of them. The LLM would either need to repeat failed searches or do its own context-building tool calls sequentially to get enough context to be successful. Precomputing context is also deterministic rather than subject to the model's tool-selection choices, which gives the business more control to fine-tune the experience.</p><p>The token reduction follows the same logic: Each exploratory tool call returns raw data the model must process. A preassembled context summary replaces that raw data with structured facts the model can consume in one pass. At the scale of usage possible in public-facing retail websites, this token cost saving can be significant. This work on document search (<a href="https://www.elastic.co/search-labs/blog/pre-computed-context-llm-agent-costs">Cutting agent costs with pre-computed context</a>) showed up to 75% input token reduction on a controlled benchmark. That benchmark was document retrieval rather than ecommerce, and its authors are explicit that the multiplier isn't a fixed number you can expect everywhere. We expect the direction to hold in ecommerce, because the exploration pattern is the same; it just runs against a structured catalog rather than a document corpus. The magnitude is something each team should measure against its own traffic.</p><h3><strong>The AI shopping agent with full context</strong></h3><p>Remember the query that left you guessing: an outfit for an autumn wedding. Run it again, but this time, before you have to think, you’re handed a precomputed context as a short brief:</p><ul><li><p>This shopper’s name is Sarah, female, age 32, and she buys womenswear, size 12.</p></li><li><p>She typically buys your mid-tier ranges.</p></li><li><p>"Autumn" here matches these specific color labels: “rust”, “burgundy”, “forest green”, “camel”.</p></li><li><p>Matching departments: “Womenswear”, “Menswear”.</p></li><li><p>Matching tags: "occasion dresses”, “trouser suits”, “wedding”.</p></li><li><p>She already has a burgundy bag in her basket.</p></li><li><p>House rule for wedding-guest looks: Complete the outfit. Show hats and accessories, not just the dress.</p></li></ul><p>Suddenly, you’re not guessing; you’re styling for this customer. And the interesting part is how those facts combine rather than just stack. The season proposes a whole autumn palette; the burgundy bag already in her basket narrows that palette to the few tones that coordinate with it; the house rule tells you to finish the look with a matching fascinator rather than stopping at the dress. Together, these facts let you answer like someone who knows both this customer and this shop, in a single pass, with nothing invented.</p><p>That short brief is exactly what the signal stack produces: the shopper's profile, the resolved vocabulary, the live basket, and the business policy. These are assembled in parallel and placed in front of the agent before its first move, so the "<em>What do I even do with this?</em>" problem never has to be solved one expensive tool call at a time.</p><h2><strong>Governing the context layer without automated drift</strong></h2><p>One difference between the approach described here and automated knowledge extraction systems is worth addressing directly: In ecommerce, the context index cannot self-update without human review.</p><p>The policies that govern how an agent responds to gift queries, alcohol queries, or queries from customers in certain age brackets aren't just relevance configurations; they're trading decisions with potential legal and brand implications. An automated system that generates new policies from traffic patterns, without review, is a compliance risk before it's a technical asset.</p><p>This is actually the right constraint for most retail organizations, and it aligns with how search teams already work. Merchandisers write boost rules. Search teams maintain synonym configurations. Content teams approve what language appears in automated recommendations. The context policy layer is the same kind of governed configuration; it just serves a different consumer, namely, the agent's reasoning step rather than the query pipeline.</p><p>It's worth noting where this differs from the document-retrieval work referenced earlier. In that experiment, the biggest accuracy gain came from an automated feedback loop that fed the agent's wrong answers straight back into the extractor. That works well for factual question-answering, where "right" and "wrong" are unambiguous. In ecommerce, the equivalent signals still surface automatically, but a human decides what to do with them, because the changes carry trading and compliance weight. The loop is the same shape; the publication step has a person in it.</p><p>The governance loop that works in practice has two tiers:</p><ul><li><p><strong>Automatic signal surfacing:</strong> Zero-result queries, repeated reformulations on the same topic, and sessions that end without a purchase after an agent interaction are all signals that something in the context layer is missing or wrong. These surface automatically as candidates for improving the experience, for example: a vocabulary term that didn't resolve or a policy that didn't fire on a query type it should have covered. To do this, you need a thorough log of conversations, including the reasoning and tool call trace in a platform like Elastic. This allows you to analyze the performance of the agent using both structured tools, for example, percentage increase in thumbs-down conversations and semantically. You could also run an automated review of conversations about "gifts" around December to characterize the thumbs-up/down ratio across an AB test of two agents.</p></li><li><p><strong>Human authorship and review:</strong> The search or merchandising team reviews candidates and authors the appropriate vocabulary entry or policy. Policies go through approval before publication. This typically mirrors the workflow that already exists for synonym changes or boost rule modifications; the tooling is the only new part.</p></li></ul><h2><strong>How to implement context engineering in phases</strong></h2><ul><li><p><strong>Phase 1: Vocabulary layer</strong> (highest leverage, bounded engineering task).</p></li><li><p><strong>Phase 2: Facet state and initial policies</strong> (leans on the same catalog and retrieval primitives).</p></li><li><p><strong>Phase 3: User profiles and session signals</strong> (requires CRM and analytics integration; best added when the agent is active).</p></li><li><p><strong>Phase 4: Governed feedback loop</strong> (shifts to organizational alignment; surfaces gaps for merchandising teams).</p></li></ul><p>The full signal stack described above doesn't need to be built at once, and the order isn’t arbitrary. The highest-leverage starting point is also the lowest in implementation complexity: the vocabulary layer. A semantic metadata index built from a full-catalog aggregation (canonical color values, brand aliases, category paths, field names) is a bounded engineering task, and an agent that can resolve "navy jumper" to color: NAVY, masterCategoryNames: "Knitwear &amp; Jumpers" before its first tool call is materially better than one that discovers this through trial and error. If you build nothing else, build this.</p><p>Facet state and the first set of policies can follow close behind, often in parallel, because they lean on the same catalog and the same retrieval primitives. The later layers, such as user profiles, session signals, and the governed feedback loop, are where the work shifts from search engineering to organizational alignment. To implement CRM system integration, merchandising workflow changes, and the analytics needed to surface gaps can take a significant amount of work. Those layers are more valuable once the agent is already in regular use and generating the traffic signals that make the governed loop worth running. The important feature is that each layer stands on its own, so a retailer gets real value from phase one without committing to phase six.</p><h2><strong>What infrastructure does a context layer need?</strong></h2><p>Precomputing context at the depth described here places specific requirements on the underlying platform. It's worth being explicit about these, because the temptation in early agent builds is to reach for the simplest available tool for each capability.</p><ul><li><p><strong>Semantic search</strong> to match natural language queries against the vocabulary index and surface the right canonical values. Fuzzy keyword matching alone won't resolve ambiguity between similar brand names or color terms.</p></li><li><p><strong>A percolator</strong> to implement the policy layer. A percolator reverses the usual search direction: Instead of matching a query against stored documents, it stores the queries and matches an incoming piece of text (here, the customer's message) against them. That's exactly what policy matching needs, because each policy is essentially a saved pattern that says "When a query looks like this, surface this hint."</p></li><li><p><strong>Real-time aggregations</strong> over the full product catalog to produce accurate facet state at query time. Precomputed facet snapshots go stale quickly in active catalogs; query-time aggregations are the more reliable source.</p></li><li><p><strong>Document retrieval by key</strong> for user profiles: fast, single-document lookups by user ID that must complete within the context build window.</p></li><li><p><strong>Structured and semantic logging and analytics</strong> over query traces and agent interactions, which are the raw material for the governed loop's automatic signal surfacing.</p></li></ul><p>These are standard capabilities of a mature search and analytics platform, rather than six separate systems, and Elasticsearch provides all of them in one place. That matters less as a procurement point than as an architectural one: When semantic matching, percolation, aggregations, profile lookups, and analytics all run against the same catalog in the same cluster, the context layer stays consistent with the search layer by construction. Splitting these across a separate vector store and a separate analytics platform is a legitimate choice, but it adds operational surface and introduces a consistency problem between two systems that are reasoning about the same products. The context infrastructure is simplest to run when it lives where the product data already lives and can be updated without a separate extract, transform, load (ETL) step.</p><h2><strong>Conclusion: Context engineering is a search team's job to own</strong></h2><p>The retailers who run effective AI shopping experiences at scale aren't the ones with the largest models or the most generous token budgets. They're the ones who have done the work to make their catalog, vocabulary, and tpolicies legible to an agent before it starts reasoning.</p><p>The good news is that most of the work is already done. The vocabulary is implicit in the catalog. The policies exist as merchandising rules and compliance guidelines. The user profiles are in the CRM. The session signals are in the analytics stream. The gap isn't data; it's the assembly layer that turns those signals into a structured context the agent can consume before it starts reasoning.</p><p>The search team already owns the vocabulary, policies, and merchandising workflows. The context layer is the right home for work the search team is already doing, in a form that serves the agent as well as the query pipeline. And, because it grows every time a gap is found and filled, it behaves less like a setup cost and more like an asset that compounds.</p><p>To begin building a context layer in Elastic, you can start a <a href="https://www.elastic.co/cloud?utm_campaign=G-TXT-EMEA-UK+CA-Core-EN-Lead_Gen-CloudTrials-BR&amp;utm_content=Brand-Cloud&amp;utm_source=google&amp;utm_medium=cpc&amp;device=c&amp;utm_term=elastic%20cloud%20trial&amp;utm_id=701610000005lJVAAY&amp;gad_source=1&amp;gad_campaignid=22979576770&amp;gbraid=0AAAAADrDgoJnVYpNJwbmfVxoTcZSmr4S8&amp;gclid=CjwKCAjwx7LSBhB3EiwAjcodxPrvWRgCciehjf-6cu_sOb7FxbwDEJiS8Dpl95oQo7D2J61zXLJrgRoCtgQQAvD_BwE">trial of Elastic Cloud</a> or <a href="https://www.elastic.co/docs/deploy-manage/deploy/self-managed/local-development-installation-quickstart">run locally</a>. You should become familiar with configuring <a href="https://www.elastic.co/docs/solutions/search/semantic-search">semantic search</a>, and if you’re interested in how to build, store, and match search policies at query time, you will enjoy <a href="https://www.elastic.co/search-labs/blog/elasticsearch-percolator-search-governance">this blog</a>.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/ai-shopping-agents-context-engineering</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/ai-shopping-agents-context-engineering</guid>
    <category><![CDATA[Agentic AI]]></category>
    <category><![CDATA[Hybrid Search]]></category>
    <category><![CDATA[Relevance]]></category>
    <dc:creator><![CDATA[Matthew Adams]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt360319cdeac21a74/6ab3e9caf3b1262ef3650cdb/diagram-customer-query-query-input-context-build.webp" length="0" type="image/webp"/>
    <pubDate>Mon, 20 Jul 2026 00:00:00 GMT</pubDate>
  </item>
  </channel>
</rss>