可观测性理念:宽事件实践指南
采用宽事件(Wide Event)式的监控手段,是我职业生涯中所做的最高收益的改变之一。这一改变缩短了我对所有变更的反馈周期,让系统调试变得异常轻松,那些曾让人望而却步的系统,也突然变得易于管理。
最近有很多优质博文都在探讨“宽事件”的定义及其重要性,以下是我近期最喜欢的几篇:
- 伊万·布尔米斯特罗夫(Ivan Burmistrov)的《你只需要宽事件,而非“指标、日志与追踪数据”》
- 鲍里斯·塔内(Boris Tane)的《可观测性宽事件入门》
- 查里蒂·梅杰斯(Charity Majors)的《是时候为可观测性制定版本了吗?(答案似乎是肯定的)》
简单来说,对于系统中的每个工作单元(通常是HTTP请求/响应,但并非绝对),你只需输出一个“事件”,并在其中包含该工作单元相关的所有可收集信息。在遥测领域,“事件”是一个含义宽泛的术语,你也可以根据喜好将其替换为“日志行”或“追踪跨度(span)”,它们在本质上是相同的概念。
查里蒂·梅杰斯(Charity Majors)近期将这种方法称为“可观测性2.0”(Observability 2.0)并大力推广,为该概念注入了新的活力,但实际上这并非全新理念。布兰德·利奇(Brandur Leach)早在2016年就在其个人博客中提及“标准日志行”(Canonical Log Lines),2019年又介绍了Stripe公司对该概念的应用。此外,亚马逊云服务(AWS)多年来也一直将其列为最佳实践。
好的……我大概理解了这个概念,但该如何实践“宽事件”呢?
这正是许多开发者感到困惑的地方。从理论上看,这个理念很棒,我们确实应该尝试!但现实是,还有一堆功能等着开发上线,有个bug让我夜不能寐,昨天还冒出30款新的人工智能工具需要学习。而且,到底该从哪里入手?又该添加哪些数据呢?
与软件领域的其他事物一样,实现宽事件的方法有很多种。接下来,我将分享一种对我行之有效的实践方案,内容涵盖工具与代码层面的实现思路、一份详尽的属性添加清单,以及对该方法常见质疑的解答。本文将重点聚焦于Web服务场景,但这套思路同样适用于其他各类工作负载。
第一步:选择合适的工具
我们需要借助工具来为代码添加监控(通过追踪数据或结构化日志行),同时还需要一个接收这些监控数据的平台,以便进行查询和可视化分析。
理想的工具应支持快速迭代式的数据查询。我个人推荐使用Honeycomb,不过,只要是基于现代OLAP(在线分析处理)数据库构建的可观测性工具,在紧急情况下都能满足需求,例如:
- Honeycomb:配备Retriever功能
- DataDog:配备Husky功能
- New Relic:配备NRDB功能
- Baselime:基于ClickHouse构建
- SigNoz:基于ClickHouse构建
Honeycomb、New Relic和DataDog均自行研发了列式OLAP数据存储。如今,随着ClickHouse、InfluxDB IOx、Apache Pinot和DuckDB等工具的普及,各类新的可观测性工具也在不断涌现。
若不受环境限制,我强烈建议优先选择OpenTelemetry搭配Honeycomb,这会让后续工作更高效。即便你身处企业环境,且对2010年后出现的技术有所抵触,也可以在紧急情况下使用ElasticSearch这类日志搜索工具。Stripe的博文中还介绍了如何使用Splunk实现类似功能。
无论选择哪种工具,都需要熟练掌握以下3种核心技巧,以便筛选事件数据。你运用这些技巧进行迭代查询、挖掘数据价值的速度越快,就越能高效排查问题,深入了解系统的实际运行状态。当可观测性领域的从业者提及“数据切片分析”时,通常指的就是这一系列操作。下文将使用一种自定义的SQL方言来示例查询语句,你可以在所用工具的查询语言中找到对应的实现方式。
1. 可视化(Visualizing)
人类的身体存在诸多局限,但视觉皮层在识别模式方面能力极强。要充分发挥这一优势,需熟练掌握系统数据的可视化呈现方法,常用的统计函数包括COUNT(计数)、COUNT_DISTINCT(去重计数)、HEATMAP(热力图)、P90(90分位数)、MAX(最大值)、MIN(最小值)以及直方图(Histogram)。请务必熟悉所用工具提供的各类图表功能,勤加练习,提升操作速度。
示例:Honeycomb热力图截图

示例:Splunk直方图截图

2. 分组(Grouping)
为宽事件添加的每一个新标注,都会成为一个可用于数据切片的维度。通过GROUP BY语句,我们可以沿着该维度分析数据,验证数据值是否符合预期。
GROUP BY instance.id -- 按实例ID分组
GROUP BY client.OS, client.version -- 按客户端操作系统和版本分组
3. 筛选(Filtering)
当锁定某个感兴趣的维度后,通常需要进一步深入分析数据。通过筛选,可将关注点缩小到特定的流量片段,例如某一个端点、某一个IP地址、来自iOS应用的请求,或是开启了特定功能标志的用户请求。
WHERE http.route = "/user/account" -- 筛选HTTP路由为“/user/account”的请求
WHERE http.route != "/health" -- 排除HTTP路由为“/health”的请求
WHERE http.user_agent_header contains "Android" -- 筛选用户代理头包含“Android”的请求
第二步:编写中间件提供支持
若你使用的是OpenTelemetry SDK,它会自动为请求和响应创建一个包裹式的追踪跨度(span)。在请求处理过程中的任意时刻,都可以通过获取当前活跃的跨度来访问它:
let span = opentelemetry.trace.getActiveSpan();
span.setAttributes({
"user_agent.original": c.req.header("User-Agent"), // 设置用户代理原始信息属性
});
但需要注意的是,若有代码将你的代码包裹在子跨度中,“当前活跃跨度”会自动切换为这个新的包裹式子跨度!在OpenTelemetry中,目前并没有直接定位到原始“主跨度”的官方方法。不过,我们可以通过一个变通方案解决:在上下文(context)中保存对该特定主跨度的引用,确保随时都能访问到“主包裹跨度”。
// 创建一个上下文键,用于在OpenTelemetry上下文中存储主跨度
const MAIN_SPAN_CONTEXT_KEY = createContextKey("main_span_context_key");
function mainSpanMiddleware(req, res, next) {
// 获取HTTP监控自动创建的当前活跃跨度
let span = trace.getActiveSpan();
// 获取当前上下文
let ctx = context.active();
// 为主跨度设置固定属性(标识其为主跨度)
span.setAttribute("main", true);
// OpenTelemetry上下文是不可变的,需创建新上下文并添加主跨度
let newCtx = ctx.setValue(MAIN_SPAN_CONTEXT_KEY, span);
// 在新上下文生效的范围内执行后续请求处理逻辑
context.with(newCtx, () => {
next();
});
}
// 封装一个便捷函数,用于为主跨度添加属性
function setMainSpanAttributes(attributes) {
let mainSpan = context.active().getValue(MAIN_SPAN_CONTEXT_KEY);
if (mainSpan) {
mainSpan.setAttributes(attributes);
}
}
这样一来,添加属性的代码会更加简洁,且能确保属性始终设置在主包裹跨度上:
setMainSpanAttributes({
"user.id": "123", // 用户ID
"user.type": "enterprise", // 用户类型(企业用户)
"user.auth_method": "oauth", // 认证方式(OAuth)
});
你可以查看这个最小化的运行示例,了解实际效果。在Heroku,我们内部的OpenTelemetry发行版会自动完成上述配置,并为主跨度自动添加尽可能多的标注。
若你未使用OpenTelemetry,这份代码片段或许能帮你快速上手,我的上一篇博文也提供了相关逻辑的整合思路。
第三步:为“主跨度”添加哪些内容?
以及,你计划在宽事件中包含多少维度的数据呢?
答案是:越多越好,甚至可以达到数百个维度!维度越丰富,就越能精准地检测和关联各类罕见情况。当你体验到借助丰富上下文调试问题的便捷后,会自然而然地希望在所有场景中都能应用这种方式。
我们需要为请求添加各类属性,其数量往往会超出你的预期。轻松列出十几个属性并不难,而在一个监控完善的代码库中,属性数量可达数百个。
需要说明的是,下文列出的清单虽已十分详尽,但并非毫无遗漏。OpenTelemetry定义了多组属性名称作为语义规范(Semantic Conventions),也可为属性设计提供参考。在命名时,我会尽可能遵循这些规范。
用于筛选的标识规范
追踪数据中包含大量跨度,因此需要一套规范来识别和搜索这些“宽事件”。曾有人提议使用“root”(根)或“canon”(标准)作为标识,但我最终选择将这类跨度命名为“main”(主)跨度。
| 属性(Attribute) | 示例(Examples) | 描述(Description) |
|---|---|---|
main |
true |
仅在“宽事件”对应的跨度中存在,通常用于包裹请求/响应或后台任务 |
借助这一规范,只需一条查询语句,就能快速了解“某个服务的流量情况”:
SELECT
COUNT(*) -- 统计请求总数
WHERE
main = true -- 筛选主跨度(宽事件)
GROUP BY http.route -- 按HTTP路由分组
示例:按路由分组的一周流量图表,其中存在异常波动

1. 服务元数据(Service metadata)
首先,需要添加服务相关的基础信息。此外,还可考虑补充团队归属、团队Slack沟通渠道等元数据,但需注意:若公司频繁进行组织架构调整,这类信息的维护会较为繁琐。至于如何将这些信息与Backstage等服务目录关联,可根据实际需求自行探索。
| 属性(Attribute) | 示例(Examples) | 描述(Description) |
|---|---|---|
service.name |
api、shoppingcart |
服务名称 |
service.environment |
production(生产环境)、staging(预发环境)、development(开发环境) |
服务运行环境 |
service.team |
web-services、dev-ex |
服务归属团队,便于故障时确定联系人 |
service.slack_channel |
web-services、dev-ex |
服务相关问题的沟通渠道(Slack频道) |
查询示例:每个团队负责多少个服务?
SELECT
COUNT_DISTINCT(service.name) -- 统计唯一服务数量
WHERE
service.environment = "production" -- 限定生产环境
GROUP BY service.team -- 按团队分组
2. 实例信息(Instance info)
当你查看系统负载时,是否曾疑惑“当前负载与服务器配置是否匹配”?以往可能需要切换到其他工具或查阅配置文件才能获取服务器信息,而将这些上下文信息添加到宽事件中,就能在需要时随时查看。
| 属性(Attribute) | 示例(Examples) | 描述(Description) |
|---|---|---|
instance.id |
656993bd-40e1-4c76-baff-0e50e158c6eb |
服务实例的唯一标识ID |
instance.memory_mb |
12336 |
服务实例可用内存大小(单位:MB) |
instance.cpu_count |
4、8、196 |
服务实例可用CPU核心数 |
instance.type |
m6i.xlarge |
云服务提供商对实例类型的命名(如AWS实例类型) |
查询示例:哪些服务占用内存最多?它们使用的是哪种实例类型?
SELECT
service.name, -- 服务名称
instance.memory_mb, -- 实例内存
instance.type -- 实例类型
ORDER BY instance.memory_mb DESC -- 按内存降序排列
GROUP BY service.name, instance.type
LIMIT 10 -- 取前10条结果
3. 容器/编排环境信息(Orchestration info)
无论采用何种系统编排方式,都需确保添加所有相关信息。下文列出了Kubernetes语义规范中的部分示例,供你参考:
| 属性(Attribute) | 示例(Examples) | 描述(Description) |
|---|---|---|
container.id |
a3bf90e006b2 |
Docker容器的唯一ID |
container.name |
nginx-proxy、wordpress-app |
容器运行时使用的容器名称 |
k8s.cluster.name |
api-cluster |
服务所在的Kubernetes集群名称 |
k8s.pod.name |
nginx-2723453542-065rx |
服务所在的Kubernetes Pod名称 |
cloud.availability_zone |
us-east-1c |
服务运行所在的可用区(AZ) |
cloud.region |
us-east-1 |
服务运行所在的区域(Region) |
即便你使用的是PaaS(平台即服务),也能提取到许多有用信息,例如Heroku相关信息:
| 属性(Attribute) | 示例(Examples) | 描述(Description) |
|---|---|---|
heroku.dyno |
web.1、worker.3 |
应用运行时的Dyno标识(来自环境变量DYNO) |
heroku.dyno_type |
web、worker |
Dyno类型(取DYNO环境变量中“.”之前的部分,便于查询) |
heroku.dyno_index |
1、3 |
Dyno序号(取DYNO环境变量中“.”之后的部分,便于查询) |
heroku.dyno_size |
performance-m |
所选Dyno的规格大小 |
heroku.space |
my-private-space |
应用部署所在的私有空间名称 |
heroku.region |
virginia、oregon |
应用所在的区域 |
查询示例:当前运行着多少个Dyno?它们属于哪些类型?分别对应哪些服务?
SELECT
COUNT_DISTINCT(heroku.dyno_index) -- 统计唯一Dyno序号数量
GROUP BY service.name, heroku.dyno_type, instance.type -- 按服务、Dyno类型、实例类型分组
4. 构建信息(Build info)
在故障排查中,人们常会问“是否刚发布过新版本?”或“发生了哪些变更?”。无需切换到部署工具或查阅GitHub仓库,只需将构建相关数据添加到遥测信息中,即可快速获取答案。
将构建系统中的数据传递到生产环境并在运行时可用,可能需要编写不少衔接代码,但在故障排查时,这些信息的价值不可估量。
| 属性(Attribute) | 示例(Examples) | 描述(Description) |
|---|---|---|
service.version |
v123、9731945429d3d083eb78666c565c61bcef39a48f |
服务版本标识(如版本号或镜像哈希值) |
service.build.id |
acd8bb57-fb9f-4b2d-a750-4315e99dac64 |
构建ID(便于故障时审计构建过程) |
service.build.git_hash |
6f6466b0e693470729b669f3745358df29f97e8d |
部署代码的Git提交SHA值(精确定位运行代码版本) |
service.build.pull_request_url |
https://github.com/your-company/api-service/pull/121 |
触发部署的合并请求(PR)链接 |
service.build.diff_url |
https://github.com/your-company/api-service/compare/c9d9380..05e5736 |
新旧部署版本代码对比链接 |
service.build.deployment.at |
2024-10-14T19:47:38Z |
部署流程开始的时间戳 |
service.build.deployment.user |
keanu.reeves@your-company.com |
触发构建的认证用户(可能是机器人) |
service.build.deployment.trigger |
merge-to-main(合并到主分支)、slack-bot(Slack机器人)、api-request(API请求)、config-change(配置变更) |
部署触发原因(对部署相关故障排查至关重要) |
service.build.deployment.age_minutes |
1、10230 |
部署版本的存活时间(单位:分钟),可快速回答“是否刚发布过新版本” |
疑问:这些数据会不会存在大量重复?毕竟它们只会在部署时发生变化!(详见“常见质疑”部分解答)
查询示例1:近期有哪些系统进行了部署?
SELECT
service.name, -- 服务名称
MIN(service.build.deployment.age_minutes) as age -- 部署版本的最短存活时间(即最新部署)
WHERE
service.build.deployment.age_minutes < 20 -- 筛选20分钟内的部署
GROUP BY service.name
ORDER BY age ASC -- 按存活时间升序排列(最新部署在前)
LIMIT 10
查询示例2:上一次部署后,500错误为何突然激增?
SELECT
COUNT(*) -- 统计请求总数
WHERE
service.name = "api-service" AND -- 限定服务名称
main = true -- 筛选宽事件
GROUP BY http.status_code, service.version -- 按HTTP状态码和服务版本分组
示例:按HTTP状态码和版本分组的请求图表,v1版本停止服务时500错误激增

5. HTTP相关信息(HTTP)
大部分HTTP相关属性可通过追踪库的监控功能自动获取,但如果你的组织使用非标准请求头,仍可手动添加更多属性。不要满足于OpenTelemetry默认提供的基础属性!
| 属性(Attribute) | 示例(Examples) | 描述(Description) |
|---|---|---|
server.address |
example.com、localhost |
接收请求的HTTP服务器名称 |
url.path |
/checkout、/account/123/features |
域名后的URI路径 |
url.scheme |
http、https |
URI协议方案(如HTTP或HTTPS) |
url.query |
q=test、ref=#### |
URI的查询参数部分 |
http.request.id |
79104EXAMPLEB723 |
平台请求ID(如x-request-id、x-amz-request-id) |
http.request.method |
GET、PUT、POST、OPTIONS |
HTTP请求方法 |
http.request.body_size |
3495 |
HTTP请求体大小(单位:字节) |
http.request.header.content-type |
application/json |
特定请求头的值(此处以“content-type”为例,可根据服务需求选择重要请求头) |
http.response.status_code |
200、404、500 |
HTTP响应状态码 |
http.response.body_size |
1284、2202009 |
HTTP响应体大小(单位:字节) |
http.response.header.content-type |
text/html |
特定响应头的值(此处以“content-type”为例,可根据服务需求选择重要响应头) |
查询示例:
SELECT
HEATMAP(http.response.body_size), -- 生成响应体大小的热力图
WHERE
main = true AND
service.name = "api-service" -- 限定服务名称
示例:响应体大小热力图,大部分响应体大小处于固定区间,但存在明显异常值,需进一步排查

6. 用户代理信息(User-Agent)
User-Agent请求头包含丰富信息,无需在后续分析中通过正则表达式解析,应从一开始就将其解析为结构化数据。
| 属性(Attribute) | 示例(Examples) | 描述(Description) |
|---|---|---|
user_agent.original |
Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.3 |
HTTP请求头中User-Agent的原始值 |
user_agent.device |
computer(电脑)、tablet(平板)、phone(手机) |
从User-Agent解析出的设备类型 |
user_agent.OS |
Windows、MacOS |
从User-Agent解析出的操作系统 |
user_agent.browser |
Chrome、Safari、Firefox |
从User-Agent解析出的浏览器 |
user_agent.browser_version |
129、18.0 |
从User-Agent解析出的浏览器版本 |
查询示例:用户使用的浏览器分布情况如何?
SELECT
COUNT(*) -- 统计请求总数
GROUP BY user_agent.browser, user_agent.browser_version -- 按浏览器和版本分组
若你的组织内部有自定义用户代理或约定使用特定请求头,也应将其解析为结构化属性:
| 属性(Attribute) | 示例(Examples) | 描述(Description) |
|---|---|---|
user_agent.service |
api-gateway、auth-service |
在分布式架构中,每个服务需在自定义User-Agent头中包含自身名称和版本 |
user_agent.service_version |
v123、6f6466b0e693470729b669f3745358df29f97e8d |
服务版本(与上述user_agent.service对应) |
user_agent.app |
iOS、android |
若请求来自移动应用,需包含应用类型和版本 |
user_agent.app_version |
v123、6f6466b0e693470729b669f3745358df29f97e8d |
移动应用版本(与上述user_agent.app对应) |
7. 路由信息(Route info)
HTTP相关属性还未完全覆盖!其中最重要的信息之一是请求匹配的API端点。OpenTelemetry SDK通常会自动获取该信息,但并非在所有场景下都能生效。此外,还可考虑提取路由参数和查询参数作为额外属性。
| 属性(Attribute) | 示例(Examples) | 描述(Description) |
|---|---|---|
http.route |
/team/{team_id}/user/{user_id} |
URL路径匹配的路由模式 |
http.route.param.team_id |
14739、team-name-slug |
从URL路径中解析出的路由参数值(此处以team_id为例) |
http.route.query.sort_dir |
asc |
与服务响应相关的查询参数(示例:?sort_dir=asc&...中的sort_dir参数) |
查询示例:
SELECT
P99(duration_ms) -- 统计请求耗时的99分位数
WHERE
main = true AND
service.name = "api-service" -- 限定服务名称
GROUP BY http.route -- 按路由分组
示例:按路由分组的P99耗时图表,部分路由耗时出现激增,需结合版本信息排查是否由部署导致

8. 用户与客户信息(User and customer info)
掌握基础属性后,用户与客户信息是最重要的元数据之一。没有任何自动化SDK能精准适配你的用户模型,因此需要手动添加。
在实际业务中,单个用户或账户贡献的收入可能占企业总收入的10%以上,且他们的使用模式往往与普通用户存在显著差异。这类高价值用户可能拥有更多子用户、存储更多数据,还可能触发普通用户(如每月支付10美元的用户)不会遇到的限制和边缘场景。因此,必须能够将他们的流量与普通用户的流量区分开来。
| 属性(Attribute) | 示例(Examples) | 描述(Description) |
|---|---|---|
user.id |
2147483647、user@example.com |
用户的唯一标识(若为邮箱,需根据组织政策判断是否可存储到外部服务,避免PII泄露) |
user.type |
free(免费用户)、premium(付费用户)、enterprise(企业用户)、vip(VIP用户) |
业务层面的用户类型划分(高价值用户需单独追踪) |
user.auth_method |
token(令牌)、basic-auth(基础认证)、jwt(JWT认证)、sso-github(GitHub单点登录) |
用户的系统认证方式 |
user.team.id |
5387、web-services |
若存在团队架构,记录用户所属团队ID |
user.org.id |
278、enterprise-name |
若用户属于企业合约客户,记录其所属企业ID |
user.age_days |
0、637 |
账户创建天数(非用户实际年龄),用于区分新用户与老用户的问题差异(如老用户可能因数据量大触发问题) |
user.assumed |
true |
标记是否通过内部方式模拟用户身份进行调试 |
user.assumed_by |
engineer-3@your-company.com |
记录模拟用户身份的实际操作人员 |
查询示例:
SELECT
P99(duration_ms) -- 统计请求耗时的99分位数
WHERE
main = true AND
service.name = "api-service" -- 限定服务名称
GROUP BY user.type -- 按用户类型分组
9. 速率限制信息(Rate limits)
无论采用何种速率限制策略,都需添加当前速率限制相关信息。通过这些信息,可快速定位被速率限制的用户案例。
| 属性(Attribute) | 示例(Examples) | 描述(Description) |
|---|---|---|
ratelimit.limit |
200000 |
该请求对应的速率限制上限(未来可能为不同用户配置不同限制,需提前记录) |
ratelimit.remaining |
130000 |
用户当前剩余的速率限制额度 |
ratelimit.used |
70000 |
当前速率限制周期内已使用的请求次数 |
ratelimit.reset_at |
2024-10-14T19:47:38Z |
速率限制重置时间(若适用) |
查询示例1:某用户提交了速率限制相关的支持工单,需查看其近期操作:
SELECT
COUNT(*) -- 统计请求总数
WHERE
main = true AND
service.name = "api-service" AND -- 限定服务名称
user.id = 5838 -- 限定用户ID
GROUP BY http.route -- 按路由分组
示例:某用户的请求活动图表,末尾存在大量重复调用同一路由的峰值,为排查提供方向

查询示例2:速率限制额度所剩无几的用户,主要在访问哪些路由?这些行为是否存在异常?
SELECT
COUNT(*) -- 统计请求总数
WHERE
main = true AND
service.name = "api-service" AND -- 限定服务名称
ratelimit.remaining < 100 -- 筛选剩余额度不足100的请求
GROUP BY http.route -- 按路由分组
10. 缓存信息(Caching)
对于所有可通过缓存缩短响应时间的代码路径,都需添加缓存是否命中的标识。
| 属性(Attribute) | 示例(Examples) | 描述(Description) |
|---|---|---|
cache.session_info |
true、false |
会话信息是否从缓存获取(true为命中,false为重新获取) |
cache.feature_flags |
true、false |
用户的功能标志是否从缓存获取(true为命中,false为重新获取) |
11. 本地化信息(Localization info)
用户选择的本地化配置可能是故障的常见诱因,需记录相关信息:
| 属性(Attribute) | 示例(Examples) | 描述(Description) |
|---|---|---|
localization.language_dir |
rtl(从右到左)、ltr(从左到右) |
用户语言的文本排版方向 |
localization.country |
mexico(墨西哥)、uk(英国) |
用户所在国家 |
localization.currency |
USD(美元)、CAD(加拿大元) |
用户选择的交易货币 |
12. 运行时长信息(Uptime)
记录服务处理请求时的运行时长,有助于排查以下几类故障:
- 服务重启后出现的问题
- 服务运行一段时间后触发的内存泄漏
- 服务自动重启时的频繁崩溃问题
建议同时记录运行时长的对数(log10),或通过其他方式优化可视化效果。对数形式可在同一图表中清晰展示服务启动初期的状态,避免因长期运行实例(如运行数天的实例)的存在,导致短期数据被压缩到图表底部无法识别。
| 属性(Attribute) | 示例(Examples) | 描述(Description) |
|---|---|---|
uptime_sec |
1533 |
服务实例的运行时长(单位:秒),便于可视化查看重启情况 |
uptime_sec_log_10 |
3.185 |
运行时长的对数(10为底),可在同一图表中兼顾短期和长期运行实例的展示 |
查询示例:
SELECT
HEATMAP(uptime_sec), -- 运行时长热力图(原始值)
HEATMAP(uptime_sec_log_10) -- 运行时长热力图(对数值)
WHERE
main = true AND
service.name = "api-service" -- 限定服务名称
示例:服务崩溃循环时的运行时长热力图,对数形式更易区分异常

13. 指标信息(Metrics)
这一做法可能存在争议,但我发现在跨度中添加系统处理请求时的状态指标,对调试很有帮助。我们可以每10秒获取一次这些指标并缓存,然后将其添加到该时间段内产生的所有主跨度中。
需要说明的是,这种方式捕获的指标在数学严谨性上存在不足。由于仅在有流量时才会记录数据,无法通过它计算出具有严格统计学意义的CPU负载P90值,但在实际调试场景中,这一数据已足够提供初步线索,无需切换到其他工具。尤其当你使用热力图可视化而非进行复杂计算时,效果更佳。不过,不建议基于这类数据设置告警,标准指标仍是告警的理想选择。
杰西卡·克尔(Jessica Kerr)近期在Honeycomb博客中也介绍了这种方法。
| 属性(Attribute) | 示例(Examples) | 描述(Description) |
|---|---|---|
metrics.memory_mb |
153、2593 |
服务处理请求时的内存使用量(单位:MB) |
metrics.cpu_load |
0.57、5.89 |
服务处理请求时的CPU负载(以活跃核心数为单位) |
metrics.gc_count |
5390 |
最近观测到的垃圾回收(GC)次数(可为累计值,即服务启动至今的总次数;也可为差值,如最近一分钟的次数,需明确记录统计方式) |
metrics.gc_pause_time_ms |
14、325 |
垃圾回收耗时(单位:毫秒,统计方式同上,需明确是累计值还是差值) |
metrics.go_routines_count |
3、3000 |
当前运行的Go协程数量 |
metrics.event_loop_latency_ms |
0、340 |
事件循环等待下一个tick的累计耗时(单位:毫秒),是Node.js应用的重要指标 |
查询示例:请求变慢是否由内存或CPU资源不足导致?
SELECT
HEATMAP(duration_ms), -- 请求耗时热力图
HEATMAP(metrics.memory_mb), -- 内存使用量热力图
HEATMAP(metrics.cpu_load) -- CPU负载热力图
WHERE
main = true AND
service.name = "api-service" -- 限定服务名称
GROUP BY instance.id -- 按实例ID分组
示例:通过跨度中的指标数据,获取系统运行状态上下文

14. 异步请求汇总(Async request summaries)
在追踪系统中,异步请求应拥有独立的跨度,但将部分统计信息汇总到主跨度中,仍有助于识别异常值和快速定位关键追踪数据。
| 属性(Attribute) | 示例(Examples) | 描述(Description) |
|---|---|---|
stats.http_requests_count |
1、140 |
处理当前请求过程中触发的HTTP请求总数 |
stats.http_requests_duration_ms |
849 |
上述HTTP请求的累计耗时(单位:毫秒) |
stats.postgres_query_count |
7、742 |
处理当前请求过程中触发的PostgreSQL查询总数 |
stats.postgres_query_duration_ms |
1254 |
上述PostgreSQL查询的累计耗时(单位:毫秒) |
stats.redis_query_count |
3、240 |
处理当前请求过程中触发的Redis查询总数 |
stats.redis_query_duration_ms |
43 |
上述Redis查询的累计耗时(单位:毫秒) |
stats.twilio_calls_count |
1、4 |
处理当前请求过程中调用Twilio API的次数(可替换为其他第三方服务) |
stats.twilio_calls_duration_ms |
2153 |
上述第三方API调用的累计耗时(单位:毫秒) |
查询示例:服务对数据库的调用次数是否在合理范围内?
SELECT
HEATMAP(stats.postgres_query_count) -- PostgreSQL查询次数热力图
WHERE
main = true AND
service.name = "api-service" -- 限定服务名称
示例:单请求数据库查询次数热力图,呈现双峰分布且存在大量查询的异常值

疑问:能否不主动添加这些汇总信息,而是通过查询整个追踪数据来聚合获取?(详见“常见质疑”部分解答)
15. 采样信息(Sampling)
当系统规模扩大,细粒度遥测数据会面临采样难题。运行中的系统可能产生海量数据,工程师通常希望存储并查询所有数据以获得精确结果,但这需要在成本、速度和精度之间权衡。遥测数据与用户交易数据存在本质差异,需以不同思路对待。
幸运的是,我们只需获取具有统计显著性的子集数据即可。即使仅采样1/1000的请求,也能为系统整体流量模式提供足够详细的参考。
采样是一个深奥的话题,若你刚起步,建议从简单的均匀随机头部采样开始,并为每个跨度记录采样率,以便后续采用更复杂的采样策略。
优质工具会根据每个跨度的采样率对计算结果进行加权,无需手动将COUNT结果乘以sample_rate来获取准确值。相关参考文章如下:
- 我最初在《Scuba论文》中了解到这一理念
- Honeycomb支持按事件设置采样率
- Cloudflare的Analytics Engine会根据流量自动调整采样率
| 属性(Attribute) | 示例(Examples) | 描述(Description) |
|---|---|---|
sample_rate |
1、500 |
采样比例标识,即每N个事件中存储1个,其余丢弃。例如,若采样1%的请求,sample_rate值为100 |
16. 耗时分解(Timings)
将请求处理过程拆分为多个关键阶段,并在主跨度中记录每个阶段的耗时,对调试非常有帮助。
疑问:这不是子跨度的用途吗?
很多工程师初次使用追踪工具时,容易陷入“为所有操作创建子跨度”的误区。实际上,数据结构设计应与查询需求相匹配:子跨度适合展示单个请求的瀑布流耗时,但在跨所有请求进行查询和可视化时会非常繁琐。将耗时信息集中到单个主跨度中,不仅便于查询,还能配合Honeycomb的BubbleUp等功能,快速定位问题(例如某类请求变慢是因为认证环节耗时10秒)。
| 属性(Attribute) | 示例(Examples) | 描述(Description) |
|---|---|---|
auth.duration_ms |
52.2、0.2 |
请求处理过程中认证环节的耗时(单位:毫秒) |
payload_parse.duration_ms |
22.1、0.1 |
识别服务的核心工作负载,并为其添加耗时记录(此处以请求体解析为例) |
17. 错误信息(Errors)
当遇到错误并需要终止操作时,需在跨度中添加错误相关信息,如错误类型、堆栈跟踪等。
我发现一种非常有效的做法:为每个错误抛出位置添加唯一的错误标识(slug)。若该标识在代码库中唯一,只需通过简单搜索就能定位到具体位置,从而快速从仪表盘上的错误峰值定位到代码中抛出错误的具体行数。同时,这一标识也可作为低基数字段,方便使用GROUP BY进行聚合分析。
虽然无法覆盖所有可能的错误,但如果某个失败请求缺少exception.slug标识,就说明代码中的错误处理存在遗漏。通过这种方式,能轻松发现未预期的失败场景。
if (isNotRecoverable(err)) {
// 使用固定字符串作为错误标识,不使用变量或动态生成
// 可通过自定义代码检查规则强制遵循此规范
setErrorAttributes(err, "err-stripe-call-failed-exhausted-retries");
throw err;
}
| 属性(Attribute) | 示例(Examples) | 描述(Description) |
|---|---|---|
error |
true、false |
标记请求是否失败的专用字段 |
exception.message |
Can't convert 'int' object to str(无法将int对象转换为字符串)、undefined is not a function(未定义的函数) |
错误中的异常信息 |
exception.type |
IOError(IO错误)、java.net.ConnectException(连接异常) |
异常的程序类型 |
exception.stacktrace |
ReferenceError: user is not defined(引用错误:user未定义)、at myFunction (/path/to/file.js:12:2) |
异常的堆栈跟踪信息(便于定位错误抛出位置) |
exception.expected |
true、false |
标记异常是否为预期内场景(如机器人访问不存在的URL),便于过滤无需关注的不可预防异常 |
exception.slug |
auth-error(认证错误)、invalid-route(无效路由)、github-api-unavailable(GitHub API不可用) |
为可预见的错误创建唯一可搜索标识,用于定位代码中的错误抛出位置 |
查询示例1:上周企业用户遇到的错误类型分布如何?哪些错误最频繁?
SELECT
COUNT_DISTINCT(user.id) -- 统计遇到错误的唯一用户数
WHERE
main = true AND
service.name = "api-service" AND -- 限定服务名称
user.type = "enterprise" -- 限定企业用户
GROUP BY exception.slug -- 按错误标识分组
查询示例2:查找需要优化错误处理的追踪数据(即缺少错误标识的失败请求):
SELECT
trace.trace_id -- 追踪ID
WHERE
main = true AND
service.name = "api-service" AND -- 限定服务名称
error = true AND -- 筛选失败请求
exception.slug = NULL -- 筛选缺少错误标识的请求
GROUP BY trace.trace_id
18. 功能标志信息(Feature flags)
细粒度功能标志是开发者的强大工具,可在生产环境中仅向部分用户或流量开放代码变更。为每个请求添加功能标志信息,能在逐步扩大新代码覆盖范围时,对比新旧代码的运行效果。结合宽事件的全面可视性,即使是复杂的系统迁移也能更可控,让代码发布更有信心。
需要注意的是,语义规范中建议将功能标志信息作为事件添加到跨度中。从长期来看,遵循这一标准更有利于获得工具厂商的支持(若该规范进入稳定阶段)。但在当前阶段,我建议同时将功能标志信息添加到主跨度中,以便快速查询。
| 属性(Attribute) | 示例(Examples) | 描述(Description) |
|---|---|---|
feature_flag.auth_v2 |
true、false |
当前请求中“auth_v2”功能标志的启用状态 |
feature_flag.double_write_to_new_db |
true、false |
当前请求中“双写新数据库”功能标志的启用状态 |
查询示例:使用新认证流程的用户遇到了哪些错误?与旧流程(对照组)相比情况如何?
SELECT
COUNT -- 统计错误数量
WHERE
main = true AND
service.name = "api-service" AND -- 限定服务名称
GROUP BY feature_flag.auth_v2, exception.slug -- 按功能标志状态和错误标识分组
19. 关键组件版本(Versions of important things)
运行时、框架及核心库的版本信息,能为故障排查提供重要上下文:
| 属性(Attribute) | 示例(Examples) | 描述(Description) |
|---|---|---|
go.version |
go1.23.2 |
编程语言运行时版本(此处以Go为例) |
rails.version |
7.2.1.1 |
核心框架版本(此处以Rails为例,可根据使用的框架替换) |
postgres.version |
16.4 |
数据存储服务版本(此处以PostgreSQL为例,可根据使用的数据库替换) |
查询示例1:Rails曝出安全漏洞,我们的服务正在使用哪些版本的Rails?
SELECT
COUNT_DISTINCT(service.name) -- 统计使用各版本的唯一服务数
WHERE
service.environment = "production" -- 限定生产环境
GROUP BY rails.version -- 按Rails版本分组
查询示例2:内存使用量比以往更高,是否与最近的运行时升级有关?两者是否存在关联?
SELECT
HEATMAP(metrics.memory_mb) -- 内存使用量热力图
WHERE
main = true AND
service.name = "api-service" -- 限定服务名称
GROUP BY go.version -- 按Go运行时版本分组
20. 业务特有信息(Your specific application)
这部分是最能体现宽事件价值的地方,因为每个应用都有其独特性或特定业务领域。例如,若你的应用面向牙医,可能需要追踪牙医的专业资质;若涉及物流,可能需要记录包裹所在的仓储位置;若为宠物追踪应用,可能需要记录嵌入式追踪设备的芯片信息。
没有任何框架能自动识别你的业务核心关注点并完成监控配置,这需要你根据业务需求手动添加。
| 属性(Attribute) | 示例(Examples) | 描述(Description) |
|---|---|---|
asset_upload.s3_bucket_path |
s3://bucket-name/path/to/asset.jpg |
资源上传的目标S3存储路径(适用于文件上传场景) |
email_vendor.transaction_id |
62449c60-b51e-4d5c-8464-49217d91c441 |
第三方邮件服务商的交易ID(便于后续与服务商对接排查问题) |
vcs_integration.vendor |
github、gitlab、bitbucket |
版本控制系统集成的服务商(若某类请求因Bitbucket故障失败,可快速定位原因) |
process_submission.queue_length |
153、1 |
提交任务时的队列长度(适用于涉及队列的场景) |
实践注意事项
1. 不确定时,优先添加属性
当你犹豫“这些数据未来是否有用”时,建议优先添加该属性。额外添加一个属性的边际成本极低;若后续数据量增长过快,相比减少属性、降低采样率,更推荐保留丰富上下文的宽事件并提高采样率。
2. 善用热力图
Honeycomb的热力图在识别异常值、呈现多峰分布和感知数据整体特征方面表现出色,我希望有更多工具支持这一功能。如今,热力图已成为我开发过程中不可或缺的工具。
3. 重视反馈循环
修改代码时,同步调整遥测配置,以便观察新代码的运行效果。代码发布后,及时验证结果是否符合预期。必要时,可为某个版本临时添加特定字段,版本稳定后再删除,无需长期保留。
紧凑的反馈循环如同自行车加速,既能提升系统稳定性,又能让你更有信心地快速推进开发。
4. 语义规范与命名一致性
我会尽量遵循语义规范命名属性,但难免存在疏漏——命名本身就是一件困难的事!在团队或跨系统间保持命名一致性同样具有挑战。建议以语义规范为参考,但优先保证数据能顺利输出并获得初步价值,而非严格遵守仍在演进的规范。当数据在组织内部证明其价值后,再投入精力优化一致性。
从长期来看,语义规范有助于可观测性工具厂商基于遥测数据提供更深入的分析能力,但这一领域的发展仍处于初期阶段。
常见质疑(Frequent Objections)
1. 这种方法真的有效吗?
我已在数十个生产系统中应用过宽事件,每次这些数据都能在深入理解系统运行状态和排查问题时发挥关键作用,甚至连长期维护系统的工程师都能发现意想不到的情况,例如:
- 某系统90%的流量竟来自单个用户
- 某个工作进程莫名运行着一个月前的旧版本代码
- 某API端点的请求体大小通常为1-2KB,但存在一个边缘场景:某用户的请求体达40MB以上,导致其页面加载时间比P99值还长数分钟
- 为认证中间件添加监控后发现,约20%的请求仍缺少用户信息——原来存在一个已多年未维护的备用认证系统
- 计划废弃的某端点支持A、B、C三种数据格式,但实际流量中从未出现C格式请求,因此可直接移除对C格式的支持
2. 我不喜欢这种方式,感觉不对劲
如果你现在有这种想法,我建议先尝试5分钟再下结论。
当一条日志在终端窗口中折行多次时,很多开发者会本能地产生抵触情绪。例如,传统日志格式看起来更“正常”:
[2024-09-18 22:48:32.990] Request started http_path=/v1/charges request_id=req_123
[2024-09-18 22:48:32.991] User authenticated auth_type=api_key key_id=mk_123 user_id=usr_123
[2024-09-18 22:48:32.992] Rate limiting ran rate_allowed=true rate_quota=100 rate_remaining=99
[2024-09-18 22:48:32.998] Charge created charge_id=ch_123 permissions_used=account_write request_id=req_123
[2024-09-18 22:48:32.999] Request finished http_status=200 request_id=req_123
而宽事件的日志格式则显得“怪异”:
[2024-10-20T14:43:36.851Z] duration_ms=1266.1819686777117 main=true http.ip_address=92.21.101.252 instance.id=api-1 instance.memory_mb=12336
instance.cpu_count=4 instance.type=t3.small http.request.method=GET http.request.path=/api/categories/substantia-trado
http.route=/api/categories/:slug http.request.body.size=293364 http.request.header.content_type=application/xml
user_agent.original="Mozilla/5.0 (X11; Linux i686 AppleWebKit/535.1.2 (KHTML, like Gecko) Chrome/39.0.826.0 Safari/535.1.2" user_agent.device=phone
user_agent.os=Windows user_agent.browser=Edge user_agent.browser_version=3.0 url.scheme=https url.host=api-service.com service.name=api-service
service.version=1.0.0 build.id=1234567890 go.version=go1.23.2 rails.version=7.2.1.1 service.environment=production service.team=api-team
service.slack_channel=#api-alerts service.build.deployment.at=2024-10-14T19:47:38Z
service.build.diff_url=https://github.com/your-company/api-service/compare/c9d9380..05e5736
service.build.pull_request_url=https://github.com/your-company/api-service/pull/123
service.build.git_hash=05e5736 service.build.deployment.user=keanu.reeves@your-company.com
service.build.deployment.trigger=manual container.id=1234567890 container.name=api-service-1234567890 cloud.availability_zone=us-east-1
cloud.region=us-east-1 k8s.pod.name=api-service-1234567890 k8s.cluster.name=api-service-cluster feature_flag.auth_v2=true
http.response.status_code=401 user.id=Samanta27@gmail.com user.type=vip user.auth_method=sso-google user.team_id=team-1
但需明确:宽事件的设计目标是让机器高效读取数据,而非方便人类直接阅读。系统产生的数据量如此庞大,不应浪费宝贵的人力去逐行扫描日志寻找规律——让工具和算法来完成这类工作。
3. 这看起来工作量很大
若要完全实现本文提及的所有内容,确实需要大量工作。但即便只实现最简单的子集,也能带来显著价值。反之,若不采用宽事件,你需要花费更多精力构建系统的心智模型,通过阅读代码推测问题,却无法验证模型是否与实际系统一致。
很多逻辑可封装到组织内部的共享库中,但推动库的 adoption、维护更新以及帮助工程师熟练使用,又会带来新的挑战。未来,我期待有更多 opinionated( opinionated 指工具或框架有明确设计理念和约束,减少用户决策成本 )的平台或框架能原生支持宽事件,降低实践门槛。
4. 数据量会不会太大?成本会不会很高?
Hacker News上有评论称:“这并非Meta(元宇宙)之外无人知晓的理念,只是实施成本太高,尤其是使用第三方工具而非自研工具时,即便采样也难以承受。”
首先,建议将宽事件的数据量与你当前每请求的日志量对比——在很多系统中,宽事件反而能减少总体日志量。
其次,若为每个请求存储完整宽事件数据,在大规模场景下成本确实可能过高,此时采样就成为关键手段。通过采样,你可以在成本和数据价值之间找到平衡,自主决定投入成本与期望获得的价值。
此外,实时OLAP系统的成本正不断降低。过去,Scuba需将所有数据存入内存以实现快速查询;如今,大多数OLAP系统已演进为基于云对象存储的列式文件存储,配合临时计算资源处理查询,成本降低了多个数量级。下一部分将具体展示成本差异。
5. 数据存在大量重复,会不会很低效?
人类的直觉有时会误导判断,我们通过一个具体示例来分析:
我编写了一个脚本[1],生成包含上述多数属性的NDJSON(换行分隔的JSON)文件,并填充了相对合理的模拟数据。假设某服务每秒处理1000个请求,全天24小时运行,且采样率为1%,那么每月约产生100万条事件数据。生成100万条宽事件示例数据后,文件大小约为1.6GB:
http_logs.ndjson 1607.61 MB
由于JSON格式中键名会重复出现,若转换为CSV格式,文件大小可减少50%以上:
http_logs.csv 674.72 MB
而通过gzip压缩后,文件大小大幅缩减,这表明数据实际占用空间远小于直观感受:
http_logs.ndjson.gz 101.67 MB
采用Parquet、DuckDB等列式存储格式,压缩效果更佳:
http_logs.parquet 88.83 MB
http_logs.duckdb 80.01 MB
列式存储将同一列的数据连续存储,便于采用更高效的压缩策略。例如,若某一列所有值相同,只需存储一次该值;若某一列仅有2-3个不同值,可通过字典编码(dictionary-encoding)进行位压缩,同时还能加快该列的查询速度。
示例:DuckDB中全列常量值的压缩示意图、DuckDB中基于字典编码的压缩示意图


DuckDB的官方文档对这一机制有详细解读,所有数据仍保持可访问性,且查询便捷高效:
❯ duckdb http_logs.duckdb
D SELECT COUNT(*) FROM http_logs; -- 统计事件总数
┌──────────────┐
│ count_star() │
│ int64 │
├──────────────┤
│ 1000000 │
└──────────────┘
Run Time (s): real 0.002 user 0.002350 sys 0.000946
D SELECT SUM(duration_ms) FROM http_logs; -- 计算总耗时
┌───────────────────┐
│ sum(duration_ms) │
│ double │
├───────────────────┤
│ 999938387.7714149 │
└───────────────────┘
Run Time (s): real 0.003 user 0.008020 sys 0.000415
此外,还有专门的内存和传输格式可进一步减少内存占用和网络传输量,例如OpenTelemetry正采用Arrow格式作为数据载体,正是出于这一考虑。
若想深入了解这一领域,推荐收听关于FDAP技术栈的播客。
6. 能否通过关联多个跨度的数据,或查询整个追踪数据来获取这些信息?
从技术角度看,这确实可行。例如,Honeycomb已支持根据同一追踪中其他跨度的字段进行筛选,但这种方式属于高阶用法。
我们的目标是“让正确的操作更简单”——若查询变得复杂,人们自然会减少查询频率。日常工作中已有太多事务分散精力,因此应尽量简化操作,提升效率。
7. 这是否意味着不需要指标(Metrics)了?
通常情况下,仍建议生成高层级指标,但所需数量可能会大幅减少。
指标的优势在于,当你提前明确需要精确回答某个特定问题时(例如“昨天服务处理了多少请求?”或“上个月CPU使用率如何?”),能快速给出答案。
[1] 注:实际上,这段脚本大部分是由Cursor(AI代码编辑器)生成的。
原文:https://jeremymorrell.dev/blog/a-practitioners-guide-to-wide-events/