作者:来自 Elastic Matthew Adams

向你的 OpenTelemetry span 添加自定义属性,并运行六个 ES|QL 查询,以揭示最热门的搜索、零结果率以及最慢的查询。
Elasticsearch 提供了大量新功能,帮助你针对自己的使用场景构建最佳搜索解决方案。在我们的实践网络研讨会《构建现代 Search AI 体验》中,了解如何将这些功能付诸实践。你还可以立即开始 免费 Cloud 试用,或在你的 本地机器 上试用 Elastic。
使用大约 20 行的 OpenTelemetry ( OTel ) 代码为你的搜索 API 添加埋点,然后通过 Elasticsearch 查询 语言(ES|QL)即可了解用户在搜索什么、他们有多少次没有获得任何结果,以及搜索实际运行得有多快。这直接延续了本系列的第一篇文章,在那篇文章中,我们阐述了为什么应当使用 OpenTelemetry 而不是定制的分析管道。在本文中,我们将为一个 FastAPI 搜索 endpoint 添加带有自定义 search.* 属性的埋点,并针对生成的追踪数据运行六个 ES|QL 查询。在我们的演示集群中,17.7% 的搜索返回了空结果,而我们仅在开启埋点后的几分钟内就发现了这一问题。无需单独的日志管道。你使用的仍然是相同的 span、属性以及查询语言,而这些很可能已经在 Elastic 的其他地方运行着。
你将了解什么
在本文中,你将学习如何:
-
在一个示例 Python FastAPI 后端中设置 OpenTelemetry。
-
使用约 20 行代码向你的搜索 span 添加自定义
search.*属性。 -
理解 OTel 原生摄取如何将属性映射为可查询的数据。
-
针对真实追踪数据编写六个 ES|QL 查询:热门查询、零结果率、哪些查询没有返回任何结果、平均和最大 延迟、慢查询分析以及随时间变化的搜索量。
-
将这些查询转换为已保存的 Kibana 可视化。
你需要准备什么
- 一个 Elastic Cloud 部署(或启用了 OTel 原生摄取的自托管部署)。示例基于 Elastic Stack 9.x 测试。
从概念到代码:构建搜索分析埋点
在第一篇文章中,我们介绍了为什么要使用 OpenTelemetry 来采集搜索分析数据。核心思路是:向现有的 OTel span 添加 search.* 属性,将它们发送到 Elastic APM,然后使用 ES|QL 对它们进行查询。
现在,让我们开始构建它。
**想要可运行的代码?**本系列配套提供了一个参考项目。它是一个最小化的 FastAPI 应用,包含了下面介绍的全部埋点代码。克隆该项目,添加你的 Elastic Cloud 凭据,10 分钟内你就能开始接收搜索分析数据。博客中的每个阶段都对应着一个带注释的代码块,你可以随着阅读逐步启用它们。
面向搜索 API 开发者的 OpenTelemetry 入门
如果你一直在构建搜索系统,但之前没有接触过 OpenTelemetry,那么下面这些内容就是你需要了解的最基本知识。
OTel 是一个用于收集应用可观测性数据(包括追踪、指标和日志)的开放标准。它与厂商无关:你只需为代码埋点一次,就可以将数据发送到任何兼容的后端。
其中最核心的概念是 span 。一个 span 表示一次独立的操作,例如一次 API 调用、一次数据库查询,或者一次搜索请求。每个 span 都有开始时间、结束时间(两者之间的差值就是 span 持续时间 ),以及 属性(attributes),即用于描述此次操作的键值对。
多个 span 通过嵌套关系组成 trace 。一个 trace 是由多个 span 构成的树状结构,用于表示一次端到端请求。当用户执行一次搜索时,这个 trace 可能如下所示:浏览器请求 → API 处理器 → 搜索逻辑 → Elasticsearch 查询。每一个步骤都是一个 span,而父子关系能够准确展示时间花费在哪个环节。这就是 分布式追踪(distributed tracing),它能够跨越服务和网络边界工作,因此一个 trace 可以一路跟踪请求,从前端到后端,再到数据库,最后返回。
**为什么使用 trace,而不是日志?**你当然可以记录一条类似 "search query=headphones results=15 took=120ms" 的日志,然后稍后再进行解析。但日志是扁平的,它无法告诉你,这 120ms 的 Elasticsearch 查询实际上包含在一个耗时 250ms 的 API 调用中,从而暴露出应用层额外消耗了 130ms。Trace 提供了层级结构、时间信息以及跨服务的关联能力。对于搜索分析来说,这意味着你不仅能够看到_发生了什么_,还能知道_时间花在哪里_,以及_各个操作之间是如何关联的_。
对于本文而言,你无需理解完整的 OTel 生态系统。你只需要掌握三件事:
-
当发生搜索请求时,创建一个 span。
-
向这个 span 添加属性 ,用于描述此次搜索(例如
search.query或result_count)。 -
将 span 发送到 Elastic,然后使用 ES|QL 对它进行查询。
就是这么简单。如果你能够调用 span.set_attribute("key", value),你就能够构建搜索分析。
你将构建什么
在本文结束时,你的 API 中的每一次搜索请求都会生成一个类似下面这样的 OTel span:
yaml
`
1. span.name: "search"
2. search.query: "wireless headphones"
3. search.result_count: 15
4. search.query_id: "e2afdb85eb63382e..."
5. search.took_ms: 165
`AI写代码
然后,你将针对真实数据运行六个 ES|QL 查询,回答你的团队已经在提出的问题,而整个埋点代码总共只需要约 20 行。
安装 OTel SDK
这里我们使用 Python 和 FastAPI。相同的模式适用于任何提供 OTel SDK 的编程语言;核心概念完全一致,变化的只是导入语句。
Elastic 提供了 Elastic Distribution of OpenTelemetry Python ( EDOT ),它将标准 OTel SDK 与合理的默认配置、Elastic 提供的改进功能抢先体验,以及一个用于处理所有样板代码的 configure_opentelemetry() 调用打包在一起。我们推荐使用它:
markdown
`
1. pip install elastic-opentelemetry \
2. opentelemetry-instrumentation-fastapi \
3. opentelemetry-instrumentation-elasticsearch
`AI写代码
三个软件包,两种职责:
-
elastic-opentelemetryEDOT:将 OTel API、SDK 和 OpenTelemetry Protocol ( OTLP ) 导出器整合到一个软件包中,并针对 Elastic 完成了预配置。 -
opentelemetry-instrumentation-fastapi:自动为 HTTP endpoint 添加埋点(为每个请求自动创建 span)。 -
opentelemetry-instrumentation-elasticsearch:自动为 Elasticsearch 客户端调用添加埋点(为每个查询自动创建 span)。
这些自动埋点软件包承担了真正的工作。无需编写任何追踪代码,你就已经能够获得 HTTP 请求 span 和 Elasticsearch 查询 span。我们要做的是添加搜索相关的上下文信息,将通用的追踪数据转变为搜索分析数据。
**使用标准 OTel SDK?**将 elastic-opentelemetry 替换为 opentelemetry-api、opentelemetry-sdk 和 opentelemetry-exporter-otlp-proto-http。你需要手动配置 TracerProvider、OTLPSpanExporter 和 BatchSpanProcessor(大约多写 10 行代码)。除此之外,本文中的其他内容完全相同。完整的配置方法请参阅 Elastic OTel 指南。
配置连接
OTel 使用环境变量进行连接配置。开始之前,你需要准备以下四个变量:
| 变量 | 用途 | 示例 |
|---|---|---|
OTEL_EXPORTER_OTLP_ENDPOINT |
Managed OTLP ( mOTLP ) endpoint URL | https://my-deployment.ingest.us-central1.gcp.elastic-cloud.com |
OTEL_EXPORTER_OTLP_HEADERS |
身份验证 | Authorization=ApiKey <your-api-key> |
OTEL_SERVICE_NAME |
服务名称(显示在 Kibana APM 中) | search-analytics-demo |
OTEL_RESOURCE_ATTRIBUTES |
其他资源属性 | service.version=1.0.0 |
这些值在哪里可以找到?
在 Elastic Cloud 中,你的 mOTLP endpoint 遵循如下格式:https://<deployment>.ingest.<region>.gcp.elastic-cloud.com。你可以在 Elastic Cloud 控制台中对应部署的详情页面找到它,也可以在 Kibana 的 APM 集成页面(/app/home#/tutorial/apm)中的 OpenTelemetry 标签页找到。
你的 API Key 可以通过 Kibana 的 Stack Management > API Keys 创建,也可以通过 Elasticsearch 的 Create API Key API 创建。
对于自托管部署,你可以使用 EDOT Collector 作为中间层,负责接收 OTLP 数据并将其转发到 Elasticsearch。

在你的环境变量或 .env 文件中设置它们:
ini
`
1. export OTEL_EXPORTER_OTLP_ENDPOINT="https://my-deployment.ingest.us-central1.gcp.elastic-cloud.com"
2. export OTEL_EXPORTER_OTLP_HEADERS="Authorization=ApiKey <your-api-key>"
3. export OTEL_SERVICE_
4. export OTEL_RESOURCE_ATTRIBUTES="service.version=1.0.0"
`AI写代码
初始化 tracer
完成 EDOT 和环境变量配置后,初始化过程会配置三个部分:tracer provider,以及两个自动埋点软件包,它们会自动为每一个 HTTP 请求和每一个 Elasticsearch 查询创建 span:
python
`
1. from opentelemetry import trace
2. from elastic_opentelemetry import configure_opentelemetry
3. from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
4. from opentelemetry.instrumentation.elasticsearch import ElasticsearchInstrumentor
6. def init_otel(app):
7. configure_opentelemetry()
8. FastAPIInstrumentor.instrument_app(app)
9. ElasticsearchInstrumentor().instrument()
11. tracer = trace.get_tracer("search-api")
`AI写代码
configure_opentelemetry() 会读取 OTEL_* 环境变量,并使用针对 Elastic 优化的默认配置来设置 tracer provider、导出器和批处理处理器,其中包括自动使用 HTTP 导出器,而 mOTLP endpoint 正是需要这种导出器。如果你在使用原生 OTel 时看到连接错误,最常见的原因是误用了 gRPC 导出器,而不是 HTTP 导出器。
这两个 instrumentor 会在启动时修改 FastAPI 和 elasticsearch-py 库,因此每个请求和每次 Elasticsearch 调用都会自动生成一个 span,无需对单独的 endpoint 进行任何代码修改。
为你的搜索 API 添加埋点
这里才是关键部分。这段代码将一个通用的 API endpoint 转变为搜索分析数据源:
ini
`
1. from opentelemetry import trace
3. tracer = trace.get_tracer("search-api")
5. @app.post("/api/search")
6. def search(request: SearchRequest):
7. with tracer.start_as_current_span("search") as span:
8. # Set attributes BEFORE the query
9. # (available even if the query fails)
10. query_id = format(span.get_span_context().trace_id, "032x")
11. span.set_attribute("search.query", request.query)
12. span.set_attribute("search.query_id", query_id)
14. results = es.search(
15. index="products",
16. body=build_query(request)
17. )
19. # Set attributes AFTER the query
20. total_hits = results["hits"]["total"]["value"]
21. span.set_attribute("search.result_count", total_hits)
22. span.set_attribute("search.took_ms", results["took"])
24. # Include query_id in the response so the frontend can link
25. # click and conversion events back to this search
26. return {
27. **format_response(results),
28. "query_id": query_id,
29. }
`AI写代码
在分析搜索查询之前对其进行规范化
注意,我们目前直接存储 request.query 的原始值。这意味着当你使用 STATS ... BY attributes.search.query 进行聚合时,"Laptop Bag"、"laptop bag" 和 " laptop bag " 会被统计为三个不同的查询。
为了获得更清晰的分析结果,请在设置属性之前进行规范化处理:
less
`span.set_attribute("search.query", request.query.strip().lower())` AI写代码
对于大多数场景,只需要转换为小写并去除空格即可。如果你需要保留原始搜索词(用于展示或调试),可以将它存储在单独的属性中,例如 search.query.original。但建议从简单方案开始。如果之后发现需要原始版本,可以随时添加。
一个搜索 API trace 的结构
运行后,单个搜索请求会生成如下 trace:
markdown
`
1. HTTP POST /api/search (根节点 ------ 由 FastAPI 自动埋点)
2. └── search (我们的 span ------ search.* 属性存储在这里)
3. ├── info (ES 客户端 ------ 自动埋点)
4. ├── query_rules.get_ruleset (ES 客户端)
5. └── search (ES 客户端 ------ 实际的 Elasticsearch 查询)
`AI写代码
自动埋点的 span 会提供 HTTP 延迟和 Elasticsearch 查询详情。中间的 search span 将这些信息与业务上下文关联起来:用户搜索了什么、返回了多少结果、Elasticsearch 花费了多少时间。
你需要采集的搜索 span 属性
以下是我们在搜索 span 上采集的完整属性集合:
| 属性 | 类型 | 设置时间 | 用途 |
|---|---|---|---|
search.query |
string | 查询之前 | 用户输入的查询内容 |
search.query_id |
string | 查询之前 | 唯一标识符,根据 trace ID 派生 |
search.result_count |
int | 查询之后 | 匹配结果总数(0 表示零结果搜索) |
search.took_ms |
int | 查询之后 | Elasticsearch 执行时间,单位为毫秒 |
search.query_response_hit_ids |
string\[\] | 查询之后 | 返回的文档 ID(可选;支持基于单个结果的分析) |
feature_flag.key |
string | 查询之前 | A/B 测试标记名称(可选;与 feature_flag.result.variant 搭配使用,用于比较不同变体的点击率(CTR)) |
我们使用 search.* 命名空间,这遵循了 OTel 的约定,即使用领域特定前缀(http.*、db.*、messaging.*)。虽然 OTel 目前还没有标准化的搜索相关约定,但 search.* 具有自描述性,并且与厂商无关。这种命名方式参考了 User Behavior Insights (UBI) 标准,该标准定义了搜索事件的 schema。我们参考它的结构,但不与它绑定。
对于已经存在 OTel 约定的场景,例如 A/B 实验中的 feature_flag.key(标记名称)和 feature_flag.result.variant(分配的变体),我们直接复用这些约定,而不是创建自定义属性。
你会注意到上面的搜索 span 属性表中没有 enduser.pseudo.id。对于查询分析来说,我们不需要它,但当你在第三篇博客中引入点击追踪时,你会立即添加它;它可以将点击事件关联回特定浏览器会话,从而支持基于用户的 CTR 和平均倒数排名(MRR)分析。
第三篇博客会添加 enduser.pseudo.id(由浏览器生成,并且跨会话保持一致),用于将点击关联回搜索。我们的第四篇聚焦于收入归因的博客会介绍 session.id 和 user.id,作为已经认证用户进行跨设备归因时可选的扩展属性。
OpenTelemetry 属性如何转换为可查询的 ES|QL 字段
在开始查询之前,你需要了解 OTel 属性如何映射到 Elasticsearch 字段。通过将 OTel 原生摄取到 Elastic 中,这种映射非常简单
| OTel attribute | Type | ES|QL field |
|---|---|---|
search.query |
string | attributes.search.query |
search.result_count |
int | attributes.search.result_count |
search.took_ms |
int | attributes.search.took_ms |
search.query_id |
string | attributes.search.query_id |
feature_flag.key |
string | attributes.feature_flag.key |
通过 OTel 原生摄取,属性名称会在 attributes.* 下保留其点号表示形式。所有类型都位于相同的命名空间中;字符串字段和数值字段之间不存在拆分。布尔值会以原生布尔类型存储,而不是字符串类型。如果你之前使用过 Elastic APM 的经典摄取方式,你会喜欢这种简单性:你在代码中设置的内容,就是你查询的内容。
在 Kibana Discover 中运行 ES|QL 查询
当 span 流入 Elastic 后,打开 Kibana 并进入 Discover (在左侧边栏的 Analytics 下,或者使用全局搜索栏输入 "Discover")。默认情况下,你会看到 KQL 查询栏,这是一种 Kibana 仪表板中常用的过滤语言。点击右上角的 Try ES|QL 切换到 ES|QL 编辑器。
与 KQL(用于过滤文档)或 JSON query DSL(需要嵌套对象)不同,ES|QL 是一种管道式语言:每一个 | 步骤都会转换前一步的输出,使得 STATS count BY field 这样的聚合操作可以自然地从左到右阅读。
编辑器提供了一个全宽文本区域,你可以在其中输入管道式查询。结果会同时以表格和自动生成的图表形式展示;Kibana 会根据你的查询结构选择合适的可视化方式。对于 STATS ... BY 查询,你会自动获得柱状图。

将时间范围设置得足够宽,以捕获你的数据(右上角的日期选择器)。如果你刚开始使用,可以尝试选择 "Last 30 days/过去 30 天"。
用于搜索分析的六个 ES|QL 查询
下面所有查询都针对 traces-generic.otel-default 运行,这是 Elastic 的 OTel 原生摄取功能自动存储追踪数据的索引。你不需要创建这个索引;当第一个 OTLP span 到达时,Elastic 会自动创建它。
这些查询运行于我们的实时演示集群:62 次搜索、约 20 个不同的查询,延迟范围为 77ms--153ms。
**注意:**下面的结果仅用于示例。当你运行参考项目,并通过
python generate_traffic.py --blog 2 --sessions 50生成流量时,你看到的具体数字和热门查询会根据会话数量以及随机查询选择而有所不同。这里重要的是查询模式和 ES|QL 语法。
查询 1:span 是否正在到达?
从简单开始。统计你的搜索 span 数量。
sql
`
1. FROM traces-generic.otel-default
2. | WHERE attributes.search.query IS NOT NULL
3. AND name == "search"
4. | STATS total_searches = COUNT(*)
`AI写代码
结果:62。
如果返回 0,说明你的 span 没有到达。检查你的 OTLP endpoint 和 API Key,并确认 init_otel() 在任何请求之前已经被调用。name == "search" 过滤条件确保你统计的是自定义 span,而不是自动埋点的 Elasticsearch 客户端 span(这些 span 的名称也叫 "search")。
查询 2:用户正在搜索什么?
这是每个搜索团队都会首先提出的问题。
sql
`
1. FROM traces-generic.otel-default
2. | WHERE attributes.search.query IS NOT NULL
3. AND attributes.search.query != ""
4. AND name == "search"
5. | STATS
6. search_count = COUNT(*),
7. avg_results = ROUND(AVG(attributes.search.result_count), 0)
8. BY attributes.search.query
9. | SORT search_count DESC
10. | LIMIT 20
`AI写代码
结果:
| 查询 | 搜索次数 | 平均结果数 |
|---|---|---|
| laptop | 9 | 8 |
| headphones | 7 | 12 |
| running shoes | 6 | 5 |
"laptop" 是最热门的查询,共有 9 次搜索。总共有大约 20 个不同的查询(包括零结果查询)。
avg_results 列告诉你热门查询是否真正返回了内容。高搜索量但低结果数的查询通常是值得调查的相关性问题。如果你的查询具有高搜索量和高结果数,则需要检查用户是否实际点击了结果。我们将在下一篇聚焦于使用点击数据衡量搜索质量的博客中讨论这一点。
查询 3:多少比例的搜索没有返回任何结果?
ini
`
1. FROM traces-generic.otel-default
2. | WHERE attributes.search.query IS NOT NULL
3. AND name == "search"
4. | STATS
5. total = COUNT(*),
6. zero_results = COUNT(CASE(attributes.search.result_count == 0, 1))
7. | EVAL zero_rate_pct = ROUND(100.0 * zero_results / total, 1)
`AI写代码
**结果:**17.7%(62 次搜索中有 11 次没有返回任何结果)。
我们使用 attributes.search.result_count == 0,这是一个直接的数值比较。当你已经拥有结果数量时,不需要额外的布尔属性。
零结果率超过 10% 值得进一步调查。每一次零结果搜索都代表一个用户提出了需求,但没有得到任何返回。其中一些可能是无效查询,但另一些则会暴露真实的内容缺失或查询解析失败问题。
查询 4:哪些查询没有返回任何结果?
零结果率告诉你存在问题。这个查询告诉你问题在哪里。
ini
`
1. FROM traces-generic.otel-default
2. | WHERE attributes.search.result_count == 0
3. AND name == "search"
4. | STATS occurrences = COUNT(*) BY attributes.search.query
5. | SORT occurrences DESC
6. | LIMIT 20
`AI写代码
结果:
| 查询 | 次数 |
|---|---|
| quantum physics calculator | 4 |
| unicorn saddle | 3 |
| holographic projector | 2 |
| time machine parts | 2 |
三种不同的失败模式:
"quantum physics calculator" 和 "unicorn saddle" 属于目录之外的查询,你永远无法为它们提供结果。了解这一点很有价值,但没有什么需要修复的内容。"holographic projector" 可能是一个值得考虑的新兴类别。"time machine parts" 很可能只是噪声。
在生产环境的商品目录中,这些查询会与真正可以修复的零结果查询混合在一起,例如缺少同义词、表达方式不匹配,或者商品缺失。
重复出现的零结果查询是最高优先级的修复项。一次性失败通常只是噪声。
查询 5:搜索速度有多快?
ini
`
1. FROM traces-generic.otel-default
2. | WHERE attributes.search.query IS NOT NULL
3. AND name == "search"
4. | STATS
5. avg_ms = ROUND(AVG(attributes.search.took_ms), 0),
6. max_ms = MAX(attributes.search.took_ms)
`AI写代码
**结果:**平均 81ms,最大 153ms。
search.took_ms 记录 Elasticsearch 自己报告的执行时间,即搜索响应中的 took 字段。这与 span duration 不同,后者表示从 span 开始到结束的实际耗时(如上面的入门介绍中所述)。Span duration 衡量的是端到端时间,包括网络往返、序列化以及应用逻辑耗时。你需要同时关注这两个指标:比较它们可以帮助你定位额外开销在哪里。如果 took_ms 是 50ms,但 span duration 是 200ms,那么额外的 150ms 来自网络或应用层开销,而不是查询问题。
这个属性也让你的分析更加可移植。如果你使用 OTel 日志记录而不是 span(我们在本系列第一篇博客中提到的一种更轻量的替代方案),那么不存在 span duration。took_ms 是你唯一拥有的时间信号。
我们将在本系列最后一篇聚焦于 Search Reliability Engineering 的博客中,深入讨论搜索性能监控(服务级别目标 SLO、针对延迟回退进行告警,以及如何将这些数据用于运维仪表板)。
想要专门查找慢查询?
ini
`
1. FROM traces-generic.otel-default
2. | WHERE attributes.search.query IS NOT NULL
3. AND name == "search"
4. | STATS
5. avg_ms = ROUND(AVG(attributes.search.took_ms), 0),
6. max_ms = MAX(attributes.search.took_ms),
7. search_count = COUNT(*)
8. BY attributes.search.query
9. | SORT avg_ms DESC
10. | LIMIT 20
`AI写代码
平均延迟高且结果数量高的查询通常命中了大量文档;可以考虑进行查询优化。高延迟但低结果数可能意味着复杂过滤条件或较慢的聚合操作。异常的最大值通常来自冷缓存或集群问题。
查询 6:搜索量如何随时间变化?
数量、比例和延迟告诉你 是什么 。随时间变化的搜索量告诉你 什么时候:什么时候流量出现峰值,什么时候流量下降,以及什么时候零结果率突然升高?
less
`
1. FROM traces-generic.otel-default
2. | WHERE attributes.search.query IS NOT NULL
3. AND name == "search"
4. | EVAL bucket = DATE_TRUNC(5 minutes, @timestamp)
5. | STATS searches = COUNT(*) BY bucket
6. | SORT bucket
`AI写代码
DATE_TRUNC(5 minutes, @timestamp) 会将每个时间戳向下取整到最近的 5 分钟时间边界。结果是一个时间序列,Kibana 的 Lens 可以将其渲染为柱状图或折线图,用于展示任意时间范围内的搜索流量模式。
缩小时间桶可以获得更高粒度(1 minute),扩大时间桶可以用于趋势分析(1 hour、1 day)。当你将这个指标与零结果率一起添加到仪表板中时,你可以回答:零结果率升高是因为流量发生变化,还是因为某些功能出现故障?
验证你的搜索 API 埋点是否正常工作
如果你使用参考项目,完整配置步骤如下:
bash
`
1. git clone https://github.com/elastic/elasticsearch-labs.git
2. cd elasticsearch-labs/supporting-blog-content/search-analytics-otel
3. cp .env.example .env # fill in ELASTICSEARCH_URL, ELASTIC_API_KEY,
4. # OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_EXPORTER_OTLP_HEADERS
5. python3 -m venv venv && source venv/bin/activate
6. pip install -r requirements.txt
7. python load_data.py # index products into Elasticsearch
8. python app.py # starts on http://localhost:8000
`AI写代码
然后触发一次搜索:
json
`
1. curl -X POST http://localhost:8000/api/search \
2. -H "Content-Type: application/json" \
3. -d '{"query":"laptop"}'
`AI写代码
等待 5--10 秒,让 BatchSpanProcessor 完成刷新,然后打开 Kibana → Discover → 切换到 ES|QL 模式并运行:
sql
`
1. FROM traces-generic.otel-default
2. | WHERE attributes.search.query IS NOT NULL
3. | LIMIT 5
`AI写代码
你应该能够看到包含 attributes.search.query、attributes.search.result_count 和 attributes.search.took_ms 的数据行。
如果没有出现任何数据行,请按以下顺序检查:
-
OTEL_EXPORTER_OTLP_ENDPOINT指向 mOTLP endpoint,而不是你的 Elasticsearch URL。 -
OTEL_EXPORTER_OTLP_HEADERS包含Authorization=ApiKey <your-key>。 -
已设置
OTEL_TRACES_SAMPLER=always_on(默认采样器可能会丢弃 span)。 -
Kibana → Observability → APM → Services 中显示
search-analytics-demo(确认导出工作正常)。
将 ES|QL 结果转换为 Kibana 可视化
Discover 根据你的 ES|QL 结果自动生成的柱状图是一个不错的开始,但你还可以对其进行自定义。点击图表右上角的铅笔图标,打开内嵌的 Lens 编辑器。
从这里你可以:
-
更改图表类型(柱状图、折线图、面积图、饼图、表格、指标)。
-
调整坐标轴并添加拆分维度。
-
将可视化保存到仪表板。

这就是从临时 ES|QL 探索到持久化仪表板面板的路径。你不需要从头开始构建可视化;Discover 和 Lens 会根据你的查询结果处理图表渲染。
Lens 是 Elastic 的拖放式可视化编辑器,它的能力比这个快速工作流所展示的更多。你可以构建多层图表,将指标与拆分维度结合,添加参考线,并设计完整的仪表板,将 ES|QL 面板与传统基于聚合的可视化混合使用。对于搜索分析来说,这意味着你可以在一个视图中并排展示热门查询、零结果趋势和延迟百分位。
深入了解:
-
Lens 文档:可视化编辑器的完整指南。
-
Lens 中的 ES|QL:将 ES|QL 查询作为仪表板面板的数据源。
-
Kibana 仪表板:构建和共享运维仪表板。
我们将在之后的博客中构建一个完整的搜索分析仪表板。
采样如何影响搜索分析准确性
大多数应用性能监控(APM)配置会对 trace 进行采样以控制成本,例如只采集 10% 或 25% 的请求。对于应用监控来说,这没有问题。但对于搜索分析来说,这是一个问题。
如果你的采样率是 10%,那么你的"搜索总数"统计会比实际情况低 90%。你的零结果率仍然准确(因为它是一个比例),但数量统计会出现偏差。
有两种方法:
方法 1:为搜索 endpoint 配置 100% 采样。
你的搜索 API 处理的请求量可能远低于主应用,因此数据量增加通常是可控的。最简单的方法是通过环境变量进行配置:
ini
`
1. # 100% sampling (capture every trace)
2. export OTEL_TRACES_SAMPLER=always_on
4. # Or sample a percentage (e.g. 50%)
5. export OTEL_TRACES_SAMPLER=traceidratio
6. export OTEL_TRACES_SAMPLER_ARG=0.5
`AI写代码
这些是在每个 trace 开始时做出的基于头部的采样决策。它们会全局应用于服务,如果你的搜索 API 是一个独立服务,这没有问题。如果搜索 API 与其他 endpoint 共享同一个服务,并且你需要基于 endpoint 的采样规则,可以在 OTel SDK 中实现自定义 Sampler,在做决定之前检查 span 名称或属性。
更复杂的路由(针对不同 endpoint 使用不同采样策略、丢弃噪声 span,或者在 trace 完成后再做决策基于尾部的采样)通常需要部署一个 OTel Collector(例如 EDOT Collector)作为应用和 Elastic 之间的中间层。这是一种有价值的架构模式,但超出了本文范围。有关基于 Collector 的采样和路由架构的更多信息,请参阅 OTel Collector 文档 和 Elastic 的 EDOT Deployment。
方法 2:在查询中进行放大。
如果你知道采样率,可以进行乘法调整:EVAL estimated_total = total_searches * 10。比例和平均值仍然正确;只有绝对数量需要调整。
有关更通用的采样策略,请参阅 OTel 采样文档。
本文中的查询使用了 100% 采样。
下一步:向搜索分析添加点击追踪
本文中的六个 ES|QL 查询回答了以下问题:用户搜索什么、哪些查询没有返回结果、搜索速度如何,以及什么时候流量出现峰值?
这些数据都来自一个埋点位置:搜索 span。
但它们无法告诉你用户是否找到了他们需要的内容。一个返回 15 个结果的搜索,从服务端角度看似乎很健康。但如果没有用户点击任何结果,那么你的排序就存在问题。
在下一篇博客中,我们将添加 点击追踪,这是第二个 span,用于捕获用户点击了哪个结果,以及该结果在列表中的位置。如果你一直在运行参考项目,那么你已经有了 62 个搜索 span;下一篇博客会直接基于这些数据构建。
当搜索和点击关联起来后,我们将计算:
-
**点击率(CTR):**多少比例的搜索最终产生了点击。
-
**平均倒数排名(MRR):**用户需要向下滚动多少位置才能找到结果。
-
**点击位置分布:**用户点击位置的分布情况。
模式保持不变:你向 span 添加属性,然后使用 ES|QL 查询它们。无需引入新的基础设施,你就可以从相同的数据中获得更丰富的视角。
使用 OpenTelemetry 开始搜索分析的资源
-
参考项目:整个博客系列的可运行代码;克隆、配置并运行即可。
-
EDOT Python:Elastic 为 Python 提供的 OpenTelemetry 发行版。
-
OpenTelemetry 与 Elastic:如何将 OTel 数据发送到 Elastic APM。
-
OpenTelemetry Python SDK:上游 SDK 文档和埋点指南。
-
ES|QL 文档:查询语言参考。
-
UBI Standard:搜索事件结构的参考 schema。
这是关于使用 OpenTelemetry 和 Elastic 进行搜索分析系列文章的第二篇。下一篇:衡量搜索质量:点击追踪、CTR、MRR 和点击位置分析。
原文:Search analytics with OTel: Instrumenting your search API - Elasticsearch Labs