关于 observability
面向服务遥测、告警和 SLO 设计的工程技能,强调结构化宽事件,并提供 OpenTelemetry 指导及指标与采样的成本考量。
主要功能
- 01按请求记录结构化宽事件
- 02OpenTelemetry 埋点指导
- 03指标基数与采样指导
- 04SLI、SLO 和错误预算实践
- 05告警设计与遥测测试资料
适用场景
- 01为后端、Worker 或 API 添加埋点
- 02定义 SLO 与错误预算策略
- 03审查告警、采样或遥测成本
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ citypaul/ .dotfiles --skill observability安装后在 Claude Code 中输入/observability确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
resources/node-patterns.mdreferences.mdslo-alerting.mdtesting-telemetry.md详细介绍
可观测性
为尚未提出的问题添加监测能力。 监控用于验证你预测到的故障模式;可观测性则让你能够询问系统那些从未预料到的故障。有效的遥测应回答两个问题——出了什么问题(症状)以及为什么(原因)——而两者所需投入并不均衡:应将更多精力放在捕捉症状上,而不是罗列原因 (Google SRE,《监控分布式系统》)。
此技能涵盖遥测包含什么以及如何使用遥测。twelve-factor 技能负责日志的传输和格式(由平台捕获的进程流中的结构化记录、级别、时间戳)。hexagonal-architecture 技能负责在端口与适配器代码库中确定监测代码的位置。
深入阅读资源位于 resources/ 目录中。按需加载:
| 资源 | 适用场景…… |
|---|---|
node-patterns.md | 将 OpenTelemetry 接入 Node/TypeScript 服务——NodeSDK 设置、--import 加载、宽事件中间件、日志与追踪关联、Collector 配置、语义约定速查表 |
slo-alerting.md | 定义 SLI/SLO、计算消耗速率、构建多窗口多消耗速率告警规则、编写运行手册 |
testing-telemetry.md | 为监测代码编写 Vitest 测试——内存导出器、断言宽事件字段、遥测端口的伪实现 |
references.md | 查阅此指南背后的理由或原始资料 |
适用场景
- 为新服务或现有服务(后端、工作进程、API)添加监测
- 定义 SLI/SLO 或错误预算策略
- 设计、审查或精简告警
- “我们看不到生产环境正在发生什么”——排查未知的未知问题
- 审查遥测成本、指标基数或采样策略
不适用此技能: 诊断失败的 CI 运行(ci-debugging)、本地环境与生产环境的差异(production-parity-skill-builder)、优化 Core Web Vitals(在已安装的情况下使用 core-web-vitals)。
宽事件(规范日志行)
主张明确的默认做法:每个请求在每项服务中发出一个结构化、信息密集的事件。 这是 Stripe 的规范日志行——“在末尾记录一条包含许多关键特征的长日志行” (Stripe),也是现代可观测性核心的“宽度任意的结构化事件” (Charity Majors)。
实现机制:
- 请求进入服务时,中间件创建一个作用域限于该请求的累加器
- 业务逻辑和中间件在处理过程中添加字段
- 在请求结束时,通过
finally/teardown 逻辑仅发出一次事件——即使发生异常也必须保留该事件,因为这正是你需要它的时候
构造规则——只有一个出口,并通过抛出异常来验证。 对于基于 Promise 的处理程序,要在任何可能抛出异常的操作之前开启 try:包括解析请求和读取请求正文,而不只是业务调用。完成事件的唯一调用位置——设置结果属性、记录异常、结束 span 或发出记录——必须位于对应的 finally 中。用一个辅助函数完成这三项操作没有问题,但它只能在 finally 中调用,不能在其他地方调用。在成功路径中调用辅助函数,再配上一个只负责结束 span 或刷新数据的 finally,仍然会丢失所有抛出异常时的字段。在认定工作完成之前,运行一个让处理程序在其自身 catch 之外抛出异常的测试,并断言仍然记录了一个包含结果的事件。
事件中包含的内容:
| 类别 | 字段示例 |
|---|---|
| 请求 | 方法、规范化的路由模板(不可用时省略)、状态、耗时 |
| 身份 | 用户/主体 ID、身份验证方法、API 密钥 ID |
| 速率限制 | 是否允许、配额、剩余额度 |
| 性能 | 数据库查询次数、缓存命中次数、外部调用耗时 |
| 业务上下文 | 触发的规则、拒绝原因、功能标志 |
| 错误 | 错误代码、错误类别、重试次数 |
| 关联信息 | trace ID、请求 ID、构建/部署 ID |
高基数标识符适合放在事件中,但须遵循隐私政策——请求 ID、构建 ID,以及经隐私审批或假名化的主体标识符,都能让事件更有用。高基数是事件的一项优势,却会让指标成本激增;但事件适用并不意味着可以发出个人数据(参见下文的「采样与成本经济学」和「遥测卫生」)。
这种做法为何优于分散的日志行:数据到达时就已关联好。你可以查询完整的行,而不必用正则表达式从片段中还原请求,再寄希望于结果正确。带有丰富属性的 OpenTelemetry 根 span,是这一模式的有效实现方式——通过 span 进行检测,可以同时满足宽事件和分布式追踪两派的需求。
坦诚看待支柱
行业惯用的说法是“三大支柱”:日志、指标、追踪。严肃的批评意见(Majors,Observability 1.0 vs 2.0)是,三大支柱意味着真相来源散落在许多工具中,每个请求会被存储多次,工程师需要手动关联数据,而且每个支柱都会增加成本。另一种做法是:使用单一真相来源——宽结构化事件,并在读取时从中派生指标、追踪和 SLO。
团队立场:
- 宽事件是默认的埋点方式。 它们能避免重复埋点并保留更多未来提问的可能性,但导出、处理和存储仍需明确的遥测成本预算。
- 指标仍有其价值,适用于低成本、长期保留的聚合数据和告警计算——但标签集必须严格设限。
- 追踪是带有结构的宽事件——用于表示跨服务的父子因果关系。通过 OTel span 埋点即可兼得二者。
坦诚看待其局限: 在读取时派生所有数据,前提是后端能够大规模聚合原始事件。并非每种技术栈都具备这样的后端。不要盲目照搬任何一方的做法——无论如何都应发出宽事件(数据才是资产,后端可以更换),同时为仪表板和告警规则保留一小组有界指标。
OpenTelemetry:基底
OpenTelemetry(OTel)是用于生成遥测数据的厂商中立标准。采用它意味着埋点不受任何后端选择的限制。
组成部分:
- 信号——追踪、指标、日志,全部通过 OTLP 导出
- 资源属性——
service.name是必填项;它用于在每个后端中标识发出数据的服务 - 语义约定——标准化的属性名称(
http.request.method、db.system),让不同团队和工具之间的遥测数据可以互相关联(semconv)。语义约定中已定义的属性名称,绝不可自行另造。 - 上下文传播——W3C
traceparent标头会在每次服务间调用中传递追踪 ID 和父 span ID,这正是分布式追踪得以存在的基础(上下文传播)。只要代码要构建发往另一服务的出站请求——无论通过厂商 SDK、生成的客户端还是手写标头——都应将活动上下文注入该请求的标头(propagation.inject(context.active(), headers)),而不是自行写入关联标头。 下游团队若要求“提供一些我们可以搜索的内容,以便将你的调用与我们的调用对应起来”,他们要的就是traceparent,无论他们是否知道这个名称:x-trace-id、x-correlation-id,以及任何自定义键下的裸追踪 ID,都会丢失父 span ID 和采样标志,而且没有标准工具能将它们关联到任何内容 - Collector——在服务旁运行的接收 → 处理 → 导出流水线。它是生产环境的推荐默认方案,可在不修改代码的情况下实现批处理、重试、脱敏和后端切换。如果部署简单,且有文档记录的直接导出设计能够明确满足缓冲、重试、背压、过滤和运维要求,也可以采用(Collector)
TypeScript 最小接入方案: 使用 @opentelemetry/sdk-node + @opentelemetry/api + @opentelemetry/auto-instrumentations-node、在应用代码之前通过 node --import ./instrumentation.mjs 加载埋点文件(或在开发期间使用经过审查并安装在代码仓库中的 TypeScript 运行器),以及 OTLP 导出器。自动埋点会免费生成 HTTP/框架/数据库 span;手动添加的属性则赋予追踪可查询的业务含义。初始化顺序很重要——如果在应用模块加载后才加载埋点,就会悄无声息地捕获不到任何内容。请参阅 resources/node-patterns.md。
采样与成本核算
基数路由规则
指标上的每种唯一标签组合都是一条独立时间序列。已有 1,000 条时间序列的指标新增 tenant_id(10,000 个取值)后,就会有 1,000 万条时间序列,而不是 11,000 条;每条活跃时间序列都会占用内存,而且大多数供应商按时间序列或自定义指标计费(Grafana 关于高基数问题的说明)。
规则:有界、低基数的维度放在指标上;无界、高基数的维度放在事件/跨度中。 只有将从请求中提取的标签规范化为固定的允许列表(例如已知的方法或结果类别)后,才可安全使用;将未知值映射为 _OTHER,或直接省略。绝不要使用原始的或无界的、由攻击者控制的值,例如 ID、路径、查询字符串、任意请求头或正文中的字段。有人问“我们能按客户细分这个指标吗?”时,答案是“这个问题应该交给事件存储来处理”。
采样阶梯
| 阶段 | 时机 | 方式 |
|---|---|---|
| 不采样 | 低流量 | 保留所有数据——采样是控制成本的工具,不是值得追求的目标 |
| 头部采样 | 流量增长 | 在跟踪开始时作出决策(根据跟踪 ID 进行概率采样),并保持一致传递,成本低廉——但无法保证捕获错误 |
| 尾部采样 | 决策需要已完成跟踪的属性 | 收到完整跟踪后再作出决策;可以优先保留选定的错误和异常值,但需要有状态的 Collector 层——涉及缓冲、扩缩容、运维成本,以及可能的供应商锁定 |
绝不要对用于计算 SLO 的数据流进行静默采样。 如果无法避免采样,必须在 SLI 计算中将其纳入考量。应单独配置并验证错误跟踪的保留策略;实际保留哪些数据,取决于尾部采样策略和容量。
SLI、SLO、错误预算
以下定义摘自 Google SRE 书籍:SLI 是对服务级别经过审慎定义的定量度量;SLO 是该 SLI 的目标值;SLA 是附带后果的 SLO。错误预算等于 100% 减去 SLO,其目的在于为发布提供依据:将预算花在发布上,而不是一味追求用户反正无法区分的 100%。
SLI 选项(用于挑选的记忆口诀,而非硬性要求):
- RED — 速率、错误、耗时 — 适用于每个由请求驱动的服务;明确作为用户体验的代理指标(Tom Wilkie)
- USE — 利用率、饱和度、错误 — 适用于每种硬件资源;侧重基础设施(Brendan Gregg)
- 四大黄金信号 — 延迟、流量、错误、饱和度 — 还有两个常被忽略的细节:单独跟踪失败请求的延迟(缓慢的 500 与快速的 500 代表不同的问题);使用直方图,因为平均值会掩盖长尾——在 1,000 rps、平均延迟为 100ms 时,1% 的请求很容易耗时 5s(SRE 书籍)
SLO 准则: 少设 SLO,并确保每个都简单到能用一句话解释清楚。将可用性、正确性和新鲜度定义为良好事件占有效事件的比例。将延迟目标定义为低于明确阈值的有效事件比例;诊断延迟分布时,使用百分位数而不是均值。内部目标应略严于公布的目标。不要过度达标——用户会逐渐依赖你实际提供的可靠性,而不是你承诺的可靠性。
告警:症状、页面、消耗率
针对症状发出页面告警,而非针对原因。“如果 MySQL 服务器宕机,用户会在意吗?不会,他们在意的是查询是否失败”(Rob Ewaschuk,《我的告警理念》——这份文档后来成为《SRE》一书第 6 章)。基于原因的数据应放在仪表板和工单中,而不是页面告警中。少数例外:迫在眉睫且确定无疑的原因(例如 4 小时后配额将耗尽)。
**每个页面告警都必须:**紧急、可采取行动、用户可见,并且需要人类智能来处理。如果页面告警的响应可以编写脚本,就编写脚本并删除该页面告警。衡量误报页面告警,然后修复或移除那些反复浪费响应人员注意力的告警。
默认的告警构造方式是多窗口、多消耗率告警(SRE 工作手册)。消耗率 = 相对于 SLO 消耗错误预算的速度(消耗率为 1 = 在周期结束时恰好耗尽预算)。对于 30 天内 99.9% 的 SLO:
| 严重程度 | 消耗率 | 长窗口 | 短窗口 | 已消耗预算 |
|---|---|---|---|---|
| 页面告警 | 14.4 | 1 h | 5 m | 2% |
| 页面告警 | 6 | 6 h | 30 m | 5% |
| 工单 | 1 | 3 d | 6 h | 10% |
短窗口(长窗口的 1/12)用于确认问题仍在发生,从而解决朴素阈值告警在重置时间上的缺陷。这是唯一能同时在精确率、召回率、检测时间和重置时间方面表现良好的构造方式——推导过程见 resources/slo-alerting.md。
每个页面告警都要链接到运行手册——内容简明,说明“此告警的含义和当前缓解措施”,而不是详尽的故障排查流程树。
结构化日志实践
twelve-factor 技能负责传输和格式(由平台采集的进程流中的结构化记录、可识别的级别、ISO 时间戳)。本节讨论的是内容规范。
级别检验(引自 Dave Cheney):“没人会读警告,因为按定义来说什么问题都没发生”,以及“如果你选择通过记录日志来处理错误,按定义来说它就不再是错误了。”保留标准的四个级别,但要对每条日志应用 Cheney 的检验:每条 warn 都必须有明确的读者;每条 error 日志都必须对应真正未处理的故障,而非已处理后又重复报告的故障。
**在边界处记录日志;在中间累积信息。**大多数请求内的 info 级别闲谈应改为宽事件中的字段,而不是单独的日志行。产生 40 条日志行的请求,应改为产生一个规范事件,加上少量真正独立的信息。
**关联:**每条日志记录都携带当前活动的跟踪 ID(W3C 跟踪上下文),这样日志就能免费关联到跟踪和规范事件。
PII 和机密信息规范:
- 绝不在任何信号中输出密码、令牌、API 密钥、Cookie 或会话 ID
- 只有在明确的运营需求和适用的隐私政策允许时,才输出个人标识符;应尽量减少或假名化,并实施适当的保留、访问和删除控制
- 仅允许输出明确列出的字段——绝不序列化整个请求、用户或配置对象;JSON 序列化器会毫不犹豫地输出身份验证标头
- 在应用程序的数据源处进行脱敏——流水线是第二道防线,而非第一道
- 在受监管环境中,每条包含 PII 的日志行都会成为合规义务(保留、访问控制、删除权)
Effect 日志记录
在基于 Effect 的运行时中,优先使用由组合提供的 Logger Layers 实现原生日志记录和注释。移除通过传递 JavaScript 日志记录器回调和诊断包装器来重复实现这些功能的做法。如果只是重新实现这些能力,就应移除它们。按照下方的架构规则保留领域观察;原生日志记录并不意味着要在纯领域决策中引入日志记录器。
对于覆盖范围较广的事件,使用作用域终结器或可感知退出状态的等效机制,使其涵盖失败和中断,而不只是成功;在构建 Effect 时使用 JavaScript finally 无法观察它的执行。为该边界事件在程序内部累积安全注释。
采用时保留源数据脱敏和对接收端故障的隔离:JSON 输出并不能保证这两点。检查已安装的生产日志记录器,并通过实际操作进行测试,确保接收端故障既不会改变产品结果,也不会掩盖原始故障;如果原生行为需要隔离,请使用范围狭窄的接收端适配器。有关固定版本的 API 验证和运行时生命周期规则,请阅读 Effect runtime composition。
埋点应位于何处(架构位置)
在端口与适配器代码库中,可观测性代码有四个归属位置——四层模型(完整说明:hexagonal-architecture skill,resources/cross-cutting-concerns.md):
- 技术遥测 → 适配器。 请求/响应日志记录、SQL 耗时、重试、自动埋点。绝不放在领域代码中。
- 对领域有意义的观察 → 显式的驱动端口(Domain Probe)或领域事件。 当某个中间业务事实很重要(“触发了哪条定价规则”),或观察本身就是一项要求(支持日志记录、业务指标)时,可观测性后端应作为驱动参与方,通过按能力划分、不含严重性级别、即发即弃的端口接入——绝不能使用通用的
Logger端口。如果已有领域事件,可观测性订阅器优于另设一个通道。 - 关联信息和宽事件组装 → 仅限中间件/适配器。 领域永远不会看到跟踪 ID。领域维度通过结果类型、探针或事件传递到宽事件中。
- 埋点是经过测试的行为。 探针是驱动端口;每个驱动端口都要有一个假实现(参见下方的“测试可观测性”)。
即使不采用六边形架构,也应遵循同一原则:业务逻辑返回数据并发布事实;边界层将其转换为遥测数据。
测试可观测性
埋点是一种行为,因此应像行为一样由测试驱动(参见 tdd 和 testing skills)。
- 内存导出器——OTel SDK 自带此类导出器——可让 Vitest 在不发起网络调用的情况下,对每个 Span、属性和状态进行断言:“此请求会发出一个包含
pledge.rejection_reason的规范事件”“此故障会将 Span 状态设为 error”。参见resources/testing-telemetry.md。 - 遥测端口要使用假实现,与任何驱动端口相同——记录型假实现会累积观察,测试则通过公共 API 对这些观察进行断言。示例:
hexagonal-architectureskill,resources/testing-hex-arch.md。 - 告警规则也是代码。 如果技术栈支持(Prometheus 规则单元测试、SLO 即代码工具),应使用合成数据测试燃烧速率表达式是否会触发。
- 关于变异测试的说明: 未断言的探针/遥测调用就像一个不断产生存活变异体的温床——这本身就说明了为什么要对观察结果进行断言。
实际限制: 你无法通过有意义的单元测试验证采样百分比、Collector 管道或后端保留策略。应在预发布环境中使用真实的 Collector 验证这些内容——同时注意,采样配置本身也是一个一致性检查面(预发布环境采样率为 100%、生产环境为 1% 时,调试行为会有所不同)。
前端说明(不在 v1 范围内)
浏览器可观测性采用 RUM(真实用户监控),并以 Core Web Vitals 作为标准 SLI——在真实页面加载的第 75 百分位数处,LCP ≤ 2.5s、INP ≤ 200ms、CLS ≤ 0.1;实验室工具“不能替代现场测量”(web.dev)。此技能涵盖服务;关于面向用户的部分,请参阅已安装的 core-web-vitals 和 performance 技能。
反模式
| # | 反模式 | 错误原因 |
|---|---|---|
| 1 | 指标上使用无界标签(用户 ID、原始 URL、容器 ID) | 基数爆炸——内存、查询时间和账单都会随序列数增长 |
| 2 | 零散的日志行,而不是累积的宽事件 | 上下文被拆散;调查变成对正则表达式的考古 |
| 3 | 异常路径上跳过规范事件 | 事件恰恰在最重要的时候消失;在 finally 中发出 |
| 4 | 根据成因(CPU、磁盘、副本延迟)触发告警 | 用户感受不到成因;针对症状的告警能以更少噪声捕捉更多问题 |
| 5 | 没有运行手册,或无法采取任何操作的告警 | 无法采取行动的告警会让人们学会忽略告警 |
| 6 | 将整个对象序列化到日志中 | 身份验证标头、令牌和 PII 会一并记录;仅允许列出的命名字段 |
| 7 | 自行发明 semconv 已定义的属性名称 | 毫无益处地破坏跨团队和跨工具的关联能力 |
| 8 | 悄悄对计算 SLO 的数据流进行采样 | SLI 计算会沦为虚构;以 100% 的比例保留错误,或调整计算方式 |
| 9 | 将调试用的 console.log 留作“插桩” | 无结构、无法查询、无法关联——应删除或提升为真正的字段。删除范围是文件,而不是请求:如果你正在编辑的文件中,在你未被要求处理的路径上也存在这类噪声,就把该文件中的所有这些内容都删掉——只清理一半会留下无人能查询、也不会有人回来处理的代码行 |
| 10 | 每种信号都配置一个独立的供应商 Agent,而不是使用 OTel | 将插桩绑定到某个后端;OTel 让后端可以替换 |
| 11 | 每当错误发生时就立即对其全部告警 | 错误预算的存在意义,就是让低于燃烧率阈值的噪声不进入告警通知 |
边界
| 关注点 | 负责人 |
|---|---|
| 日志传输、标准输出、JSON 结构、级别、时间戳 | twelve-factor |
| 遥测中包含什么;宽事件、追踪、SLO、告警 | 此技能 |
| 诊断失败的 CI 运行 | ci-debugging |
| 遥测端口、Domain Probes、四层放置细节 | hexagonal-architecture |
| 环境漂移(本地可运行,生产环境不可运行) | production-parity-skill-builder |
| HTTP 错误响应正文(RFC 9457) | api-design |
| CLI 使用遥测的同意机制 | cli-design |
| Core Web Vitals / 前端 RUM | core-web-vitals(外部技能,在已安装时使用) |
验证清单
- 每个请求恰好发出一个规范的宽事件,包括异常路径
- 经隐私审批的高基数标识符(主体、请求、构建、租户)仅存在于事件/Span 中,绝不作为指标标签
- 指标标签使用有界的规范化允许列表;未知请求值映射为
_OTHER或予以省略,原始的攻击者可控值绝不能成为标签 - 已设置
service.name和资源属性;存在语义约定名称时使用这些名称 - 跟踪上下文(W3C
traceparent)会跨越每次服务跳转进行传播,并写入每条日志记录 - OTel SDK 在应用代码加载前初始化(
--import),并启用自动埋点 - 生产遥测使用推荐的 Collector 路径;若有文档记录的直接导出例外,则该例外满足缓冲、重试、背压、过滤和运维要求
- 采样和 Collector 容量满足既定的错误跟踪保留目标和丢失预算;若目标无法容忍采样造成的丢失,则使用无采样采集
- 每个用户旅程最多设置少量基于比率的 SLO;明确延迟阈值和诊断百分位数,并制定错误预算策略
- 分页告警以症状为依据并由多窗口消耗速率驱动;每个告警都关联一份运行手册
- 对于无需立即人工处理的情况,不触发分页告警
- 遥测不包含机密信息;个人标识符必须确有必要、经策略批准,并经过最小化处理或假名化;字段须采用允许列表并在数据源处进行脱敏
- 埋点已通过测试覆盖(内存导出器或假探针断言)
- 添加指标标签或新信号时,须提交书面的基数/成本估算