从搜索到结账只需 20 行代码:使用 OpenTelemetry 构建四阶段转化漏斗 作者来自 Elastic Matthew Adams将加入购物车和购买跟踪添加到你的搜索分析管道中并使用 ES|QL 回答每个产品经理都会问的问题哪些搜索查询带来了最高收入你的产品经理想知道哪些搜索带来了最多收入。你可以告诉他们用户搜索了什么查看我们的第二篇博客也可以告诉他们用户点击了什么查看我们的第三篇博客但你还无法知道他们购买了什么。新增的两种 span 类型 —— 加入购物车 add-to-cart 和购买 purchase —— 补全了从搜索查询到结账的四阶段漏斗。它们基于你从第二篇博客开始一直使用的相同search.*属性和 ES|QL 查询。只需大约 20 行代码你做出的每一个相关性决策都可以附带一个收入数值。你将发现什么在本文中你将学习如何使用能够关联到原始搜索的search.*属性添加转化跟踪加入购物车和购买 span。构建完整的搜索到收入漏斗搜索 → 点击 → 加入购物车 → 购买。编写 Elasticsearch Query Language ES|QL 查询用于计算转化率、每次查询带来的收入以及平均订单价值。识别用户在哪些环节流失以及每个流失点应该由哪个团队负责。将收入归因到具体搜索查询从而优先处理相关性优化工作。你需要准备什么来自第三篇博客的点击跟踪能力搜索和点击 span 通过基于 OTel 原生的数据采集方式流入 Elastic。用于加入购物车和结账事件的后端接口提供示例代码。对电商转化漏斗有基础理解。为什么搜索收入归因很重要你的产品经理走进会议室问“哪些搜索带来了最多收入”你可以告诉他们用户搜索了什么第二篇博客也可以告诉他们用户点击了什么第三篇博客。但你无法告诉他们用户最终购买了什么。“点击了一个结果”和“购买了一个产品”之间的差距正是搜索投入价值所在而目前这部分信息是不可见的。本文将补全这一闭环。通过增加两种 span 类型加入购物车和购买你可以构建一个完整的搜索到收入漏斗并且基于从第二篇博客开始一直使用的相同search.*属性和 ES|QL 查询。搜索转化跟踪可以为你的团队回答哪些问题下面是转化跟踪能够帮助你回答的问题以及为什么这些问题对团队中的不同角色很重要。哪些搜索带来了收入这是产品经理最关心的问题。当你能够将收入归因到具体查询时就可以根据业务影响来优先安排相关性优化工作。一个点击率 CTR 一般但转化价值很高的查询比一个点击率很高但从未带来购买的查询更重要。用户在哪些环节流失从搜索到购买的漏斗包含四个阶段搜索、点击、加入购物车和购买。每个流失点都指向不同的问题高点击到加入购物车流失率说明产品页面可能没有足够吸引力。高购物车到购买流失率说明问题可能出在结账流程而不是搜索。了解用户在哪里放弃可以帮助你确定哪个团队应该负责解决问题。哪些查询需要保护一旦你知道“ laptop bag ”这个查询每月产生 12,000 美元的归因收入你对它的处理方式就会不同。任何影响高收入查询的相关性调整都应该经过更严格的审查。你还可以设置监控即将发布的第六篇博客当收入最高的搜索查询转化率下降时发出告警。新增的两个插桩点总共大约需要 20 行代码并且遵循之前使用的相同模式。你只需要向 span 添加属性然后使用 ES|QL 查询这些数据。想跟着代码一起操作参考项目已经准备好了转化跟踪功能。取消app.py和app.js中 Blog 4 部分的注释重启应用然后使用以下命令生成流量python generate_traffic.py --blog 4四阶段搜索转化漏斗在编写任何代码之前先了解我们正在构建的结构。每个阶段都是一个插桩点并且每个阶段都会在traces-generic.otel-default中创建 spansearch → click → cart.add → checkout.complete (Blog 2) (Blog 3) (this post) (this post) query_idabc query_idabc query_idabc query_idabc user_query... click_position1 product_id... order_total$149 result_count15 product_id... quantity1 item_count2贯穿整个链路的核心是search.query_id。你在第二篇博客中根据 trace ID 推导出的同一个标识符并在第三篇博客中用于将点击关联到搜索现在会继续传递到加入购物车和购买事件中。这正是实现收入归因的关键你可以追踪一次购买回溯到最初开启整个购买过程的搜索。使用 OpenTelemetry 添加转化跟踪 span加入购物车 span 插桩当用户从搜索结果页面添加商品到购物车时或者从通过搜索进入的商品详情页面添加商品你需要创建一个cart.addspan。该 span 用于记录用户意图转变为实际操作的时刻。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)这遵循第三篇博客中点击跟踪的相同模式也就是说这是一个独立的 span通过query_id与原始搜索关联。新增的属性是cart.quantity和cart.price它们可以让你在查询时聚合收入。购买 span 插桩当用户完成结账时你需要创建一个checkout.completespan。这是收入事件也是能够回答产品经理问题的关键事件。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)Span 会自动捕获错误。这是使用 OTel span 记录转化事件带来的额外好处。如果加入购物车或结账调用抛出未处理异常span 状态会自动设置为ERROR并记录异常详细信息。这些错误对业务至关重要结账流程故障意味着收入损失并且会立即显示在 Elastic APM 的错误跟踪、服务地图和告警中。你可以通过同一套插桩同时获得转化分析和运行监控无需额外代码。checkout.total_amount是收入数值。这是你将在 ES|QL 中进行聚合的字段用于获取按查询统计的收入。它表示订单总金额而不是单个商品的价格。search.query_id是有条件的。并非每次购买都来自搜索。用户可能浏览商品分类、点击促销链接或者几天后返回购物车完成购买。if event.query_id:判断确保只有在存在真实关联时才会将购买归因到搜索。没有query_id的购买仍然会被记录只是不会出现在搜索归因查询中。search.query会同时设置在购物车和购买 span 上。这是经过设计的数据冗余。你可以通过query_id回查搜索记录来获取查询文本但虽然 ES|QL 支持LOOKUP JOIN由于需要优化查询索引这里可能并不是最佳选择。通过直接将查询文本放入转化 span 中你的“按查询统计收入”和“按查询统计购物车数据”的查询就可以变成简单直接的聚合操作。购物车和购买事件的 Span 属性加入购物车属性属性类型必需用途search.actionstring是add_to_cartsearch.result_click_idstring是商品文档 IDsearch.result_click_positionint是添加商品时在搜索结果中的位置search.query_idstring是关联到原始搜索enduser.pseudo.idstring是客户端/设备标识符 OTel 语义约定 [SemConv]cart.quantityint是添加的商品数量cart.pricefloat可选商品价格search.querystring推荐搜索查询文本用于按查询分析购物车数据Purchase attributes:属性类型必需用途search.actionstring是purchasecheckout.order_idstring是唯一订单标识符checkout.total_amountfloat是订单总金额checkout.item_countint是购买商品数量enduser.pseudo.idstring是客户端/设备标识符 OTel SemConv search.query_idstring推荐关联到原始搜索用户旅程中的最后一次搜索search.querystring推荐搜索查询文本用于按查询分析收入这些属性继续沿用了第 2 篇和第 3 篇博客中的search.*命名空间并为转化相关数据新增了cart.*和checkout.*前缀。通过基于 OTel 原生的数据采集方式所有属性都会存储在attributes.*下并保留点号表示法。这意味着不再需要将字符串映射到labels.*也不需要将数字映射到numeric_labels.*。search.query属性可以通过attributes.search.query查询。同样checkout.total_amount可以通过attributes.checkout.total_amount查询。用户身份跨会话连接搜索与购买你会注意到在本系列中每种 span 类型都包含enduser.pseudo.id。它代表最低身份级别也就是存储在浏览器 localStorage 中的持久化标识符用于跨多个会话将事件关联到同一个设备。对于转化跟踪身份信息变得更加重要。你需要将周一发生的搜索与周二发生的购买关联起来或者关联跨多个标签页产生的购物车添加事件。我们的 schema 支持三个身份级别并与 OTel 语义约定保持一致属性持久性用途enduser.pseudo.id永久 localStorage 设备/浏览器标识符 OTel SemConv session.id单次访问 sessionStorage 将单次访问中的事件进行分组 OTel SemConv user.id账户级别认证系统已认证用户 OTel SemConv 对于本文中的漏斗查询enduser.pseudo.id已经足够。它可以在浏览器范围内将用户从搜索到购买的完整旅程关联起来。如果你的用户进行了身份认证添加user.id可以实现跨设备归因例如在手机上搜索在桌面设备上购买并支持更丰富的个性化能力。session.id可以帮助区分同一个客户端存在多个活跃会话时的不同访问过程。这三个属性在交互 span 上都是可选的。你可以从enduser.pseudo.id开始并在使用场景需要时添加其他属性。关键在于保持一致性在搜索、点击和转化 span 中使用相同的标识符这样关联查询 join 才能正常工作。前端集成前端需要在整个用户旅程中传递query_id。当用户点击搜索结果时你已经可以从搜索响应中获得query_id第三篇博客。关键是继续将它传递下去。// 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, }) });前端将client_id作为 HTTP 字段名发送后端在设置 span 属性时将其映射到 OTel 语义约定中的enduser.pseudo.id。query_id的传递是关键部分。需要将它与商品一起存储在购物车数据结构中使其能够在页面之间跳转时继续保留。我们将在下面的归因部分讨论其中的设计挑战。正在使用参考项目参考应用中的frontend/app.js已经接入了加入购物车按钮但浏览器 UI 中尚未实现结账流程。结账流程通过流量生成器完成。要模拟转化事件请运行python generate_traffic.py --blog 4 --sessions 100该命令会向三个后端接口发送真实场景中的搜索、点击、加入购物车和购买事件组合。验证转化事件是否正在到达在构建漏斗查询之前请确认两种 span 类型都已经流入 Elastic加入购物车事件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购买事件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如果这些查询返回数据行说明你的完整漏斗已经完成插桩。如果没有返回结果请检查一直以来需要检查的内容OpenTelemetry Protocol OTLP 端点、认证 token 以及 span 导出。使用 ES|QL 进行漏斗分析在单个查询中统计搜索、点击、购物车和购买事件你可以使用与第三篇博客中 CTR 查询相同的COUNT(CASE(...))模式在一个 ES|QL 查询中统计完整的四个漏斗阶段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)示例输出searchesclickedcartspurchasesclick_ratecart_ratepurchase_rate14641281228.1%19.2%8.2%你可以通过一个查询获得四个阶段的计数以及三个转化率。WHERE子句会将四种 span 类型提取到同一个结果集中而COUNT(CASE(...))则分别统计每种类型。这与第三篇博客中让 CTR 查询保持简洁的技术相同。注意我们在点击阶段使用search.first_click而不是统计所有点击事件。这样可以得到至少收到一次点击的搜索数量与第三篇博客中 CTR 的定义一致。如果不这样处理一个产生三次点击的搜索会将点击数量增加到三次而搜索数量仍然只计算一次从而导致漏斗数据失真。现在每个阶段都代表一个唯一的转化过程发生了多少次搜索。其中有多少次获得了点击。其中有多少次导致了加入购物车。其中有多少次最终产生购买。你可以使用 Kibana Lens 将其转换为漏斗可视化。分别运行每个阶段的计数作为独立的 ES|QL 查询将它们保存为仪表板面板然后排列成水平柱状图Y 轴显示四个阶段。X 轴显示数量。每一步的流失情况都会立即变得清晰可见。上面的仪表板来自该系列第一篇博客展示了实际效果左下角的Conversion Funnel转化漏斗面板使用水平柱状图展示四个阶段而旁边的Top Queries by Revenue按收入排名的热门查询表格展示收入归因。你可以直接根据本文中的 ES|QL 查询构建这些内容。流失率告诉你什么以及每个瓶颈由谁负责漏斗中的每个转化阶段都会告诉你一些具体信息搜索到点击 CTR 你已经在第三篇博客中进行了测量。较低的 CTR 表示搜索结果缺乏吸引力。这通常是相关性问题。点击到加入购物车用户与搜索结果进行了交互但没有将商品加入购物车。这可能意味着商品详情页缺乏说服力。价格缺乏竞争力。商品缺货。这通常不是搜索问题。搜索可能已经成功了用户点击了结果但后续环节导致用户流失。购物车到购买用户已经决定购买但没有完成结账。复杂的表单、意外的配送费用以及支付问题都会造成这种结账阻力 checkout friction 。这几乎从来不是搜索问题但了解漏斗在哪里出现流失非常有价值这样你就不会在结账环节才是瓶颈时浪费时间优化搜索相关性。诊断模式非常直接流失点可能原因负责团队搜索到点击相关性 / 排名搜索团队点击到加入购物车商品页面 / 定价 / 库存情况商品 / 运营团队购物车到购买结账体验 / 支付 / 配送结账 / 增长团队这是完整漏斗数据能够带来的最有价值的能力之一它让你能够定位正确的问题。当 VP 问“为什么搜索没有带来转化”时你可以明确指出瓶颈到底是在相关性、商品页面还是结账流程。按搜索查询进行收入归因下面是你的产品经理真正想要的查询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这会根据搜索查询产生的收入为你提供一个排名列表。购买 span 上的search.query属性我们之前讨论过的数据冗余设计使其成为一个单独的聚合查询无需使用 join 或子查询。如何利用搜索收入数据采取行动保护高收入查询。如果“laptop bag”带来了最高收入那么任何影响该查询的相关性调整都需要更加严格的审查。你可以将它加入回归测试集合或者通过查询规则固定特定结果。你甚至可以在其转化率下降时设置告警第 6 篇博客。优先投入相关性优化。这个列表顶部的查询是相关性改进能够带来最大业务影响的地方。一个每月产生 500 美元收入的查询即使 CTR 提升 10%其价值也高于一个仅产生 20 美元收入但 CTR 提升 50% 的查询。发现错失机会。将其与第二篇博客中的热门查询数据进行交叉分析。一个搜索量很高但没有购买归因的查询可能是浏览型查询信息需求。值得进一步调查的转化缺口。热门收入查询相关性投入应该关注的地方为了让相关性团队能够集中精力优化可以获取产生最高收入的查询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将其与第 3 篇博客中的按查询统计的 CTR 进行交叉分析。一个高收入但低 CTR 的查询表示表现不佳即使是小幅度的相关性改进也可能带来巨大的业务影响。一个高 CTR 但没有购买归因的查询可能属于信息型查询例如用户浏览但没有购买。同时出现在两个列表顶部的查询应该获得你的相关性团队最多的关注。按搜索查询统计平均订单价值平均订单价值 AOV 除了告诉你哪些查询能够带来购买之外还能告诉你这些购买的价值。具有高 AOV 的查询属于高价值意图搜索在这些查询上的排名优化能够带来最大的单次购买影响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一个具有高 AOV 但低搜索量的查询与高搜索量但低 AOV 的查询代表不同的机会。前者表示来自小众用户群体的高价值意图可以考虑推荐结果或专门的落地页后者表示面向大众用户的广泛覆盖价格敏感性可能比相关性更限制转化。搜索收入归因限制与解决方法搜索收入归因很有价值但它并不完美。了解这些限制有助于你设定合理预期并围绕这些限制进行设计。多次搜索旅程中的最后触点归因用户很少只搜索一次就购买。一个典型旅程可能如下搜索 “laptop bag”浏览结果并点击几个商品。搜索 “laptop bag leather”细化搜索。搜索 “laptop sleeve 15 inch”尝试不同方向。从第三次搜索的结果中加入购物车。完成购买。通过上述插桩方式这次购买会归因到第三次搜索也就是购物车商品中保存了其query_id的那次搜索。前两次搜索参与了整个购买旅程但不会获得任何归因。这就是最后触点归因 last-touch attribution 它是在单个query_id关联机制下能够工作的最简单模型。它并不完美但具有明确且无歧义的特点。另一种方式即跟踪用户会话中的每个query_id并分配贡献比例会显著增加插桩和分析的复杂度。对于大多数团队来说最后触点归因是一个很好的起点。如果未来需要多触点归因原始数据仍然存在。你可以在指定时间窗口内根据某个client_id查询所有搜索和点击事件并重建完整的用户旅程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这会按照时间顺序重建用户完整的搜索旅程。即使你不构建自动化的多触点归因它对于调试单个会话也很有用。使用query_id进行跨会话归因的限制用户周一搜索 “wireless headphones”点击了几个结果离开然后周三回来购买。周一搜索产生的query_id已经不存在了因为它只是那个特定搜索请求的属性。这是基于query_id的归因模型的一个基本限制。它可以在单个会话内工作更准确地说是在前端保留query_id的范围内工作。它无法跨会话工作。对于跨会话归因你需要采用不同的方法通常是建立一个用户级事件存储将商品浏览、加入购物车和购买事件与持久化用户 ID 关联然后回溯查找最初触发这些行为的搜索。这是一个更复杂的分析管道不属于我们当前构建范围。实际影响是你通过搜索归因得到的收入会出现低估。一些实际上受到搜索影响的购买事件可能不会携带query_id。这对于相对比较来说没有问题例如哪些查询比其他查询产生更多收入即使绝对数值会比较保守。如何将query_id从搜索持久化到结账一些实际设计决策会影响你的query_id传递范围在购物车中存储query_id。当用户将商品加入购物车时将query_id与商品一起持久化保存。这样即使用户离开页面并在稍后同一会话内返回结账归因信息仍然可以保留。不要在重新搜索时覆盖query_id。如果用户从搜索 A 添加了一个商品然后再次搜索并从搜索 B 添加另一个商品那么每个购物车商品都应该保留自己的query_id。购买事件会携带最后一次搜索的query_id作为汇总信息但基于商品项的归因可以提供更丰富的数据。接受这些限制。并不是每次购买都会有搜索归因。直接访问、分类浏览、促销链接以及直接返回购物车购买的老用户都会产生没有query_id的购买事件。这是正确的行为而不是数据缺失。使用 Span 还是日志事件进行转化跟踪转化事件可以同时发送 OTel span 和兼容 UBI 的日志事件这与第 3 篇博客中用于点击跟踪的双信号模式相同。实际上对于转化场景日志事件包含的信息更丰富。它可以包含完整的商品列表以及每个商品对应的query_id归因而这些信息无法很好地映射到扁平化的 span 属性中。span 提供简单、可聚合的视图例如按查询统计总收入而日志提供详细的、基于商品项的视图例如哪些具体商品来自哪些具体搜索。对于本文中的漏斗查询span 已经足够。如果你需要商品级别的归因分析那么logs-generic.otel-default中的日志事件提供了所需的详细信息。ES|QL 查询这些日志的方式与查询 span 相同只是查询的是不同的索引模式。下一步将转化数据转化为相关性改进现在我们已经拥有完整的插桩体系包括四种 span 类型search第 2 篇博客search.result.click第 3 篇博客cart.addcheckout.complete本文这些 span 捕获了从查询到购买的完整用户旅程。每个 span 都存在于traces-generic.otel-default中并且每个指标都可以通过 ES|QL 查询。search.query_id这条关联链将整个漏斗连接在一起。但是测量漏斗只是整个过程的一半。真正的价值在于使用这些数据来改进搜索。在本系列后续的一篇博客中我们会利用目前构建的一切并将其转化为相关性改进。点击位置和转化数据会成为 Learning To Rank 的判断列表而每个查询的 CTR 和收入会成为用于提升排序的 rank 特征。此外高收入查询还可以获得保护性监控。同时像 Elasticsearch Relevance Studio 这样的工具可以为最重要的搜索提供可视化调优界面并使用你现在正在收集的数据进行优化。你在第 2–4 篇博客中构建的插桩体系不仅仅用于分析它还形成了一个反馈循环衡量、改进、再次衡量。开始使用搜索转化跟踪参考项目elasticsearch-labs/supporting-blog-content/search-analytics-otel at main · elastic/elasticsearch-labs · GitHub整个博客系列的完整代码克隆、配置并运行。Elastic Distribution of OpenTelemetry for PythonGitHub - elastic/elastic-otel-python · GitHub用于 Python 的 EDOT。OpenTelemetry with ElasticUse OpenTelemetry with Elastic APM | Elastic Docs了解如何将 OTel 数据发送到 Elastic APM。ES|QL 文档ES|QL reference | Elasticsearch Reference查询语言参考。UBI StandardUser Behavior Insights搜索事件结构的参考 schema。查询规则https://www.elastic.co/guide/en/elasticsearch/reference/current/query-rules.html针对特定查询固定、提升或排除结果。原文Search conversion tracking with OpenTelemetry and ES|QL | Elasticsearch Labs