关于 observability-and-instrumentation
面向开发团队的生产可观测性指导技能,帮助在构建功能时设计日志、指标、追踪和告警,并验证遥测数据。重点是从值班问题出发选择信号、控制指标基数并维护可操作的告警。
主要功能
- 01按值班问题定义遥测目标
- 02结构化日志与关联 ID
- 03RED、USE 指标设计
- 04OpenTelemetry 分布式追踪
- 05症状导向告警与运行手册
- 06验证遥测数据与告警
适用场景
- 01为新服务或后台任务添加监测
- 02改进生产故障诊断能力
- 03审查含重试或跨服务调用的改动
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ addyosmani/ agent-skills --skill observability-and-instrumentation安装后在 Claude Code 中输入/observability-and-instrumentation确认已加载。
详细介绍
可观测性与埋点
概述
无法观测的代码,就无法运维。可观测性是指利用代码发出的遥测数据,从外部回答“系统正在做什么,为什么?”的能力。埋点不是上线后的附加工作,而是与功能一同编写,就像测试一样。如果功能上线时没有遥测数据,第一个由用户报告的 Bug 就会变成考古,而不是查询。
适用场景
- 构建任何将在生产环境运行的功能
- 新增服务、端点、后台任务或外部集成
- 生产事故花了太久才诊断出来(“我们无法判断发生了什么”)
- 设置或审查告警规则
- 审查涉及 I/O、重试、队列或跨服务调用的 PR
不适用于:
- 诊断当前正在发生的故障——使用
debugging-and-error-recovery技能(可观测性会让下次使用该技能时更快) - 分析并优化已测得的性能问题——使用
performance-optimization技能 - 发布日监控清单和回滚触发条件——参阅
shipping-and-launch技能;本技能涵盖为这些工作提供数据的埋点
流程
1. 埋点前先定义“正常运行”的标准
没有问题要回答的遥测数据只是噪声。添加任何埋点之前,先写下值班工程师会针对此功能提出的 2–4 个问题:
FEATURE: checkout payment retry
QUESTIONS ON-CALL WILL ASK:
1. What fraction of payments succeed on first attempt vs after retry?
2. When a payment fails permanently, why? (provider error? timeout? validation?)
3. Is the payment provider slower than usual?
→ Every signal below must help answer one of these.
如果你说不出这些问题,就还没准备好埋点——否则只会记录一切,却一无所获。
2. 为每个问题选择合适的信号
| 信号 | 可回答的问题 | 成本特征 | 示例 |
|---|---|---|---|
| 结构化日志 | “这个特定案例中发生了什么?” | 按事件计费;随流量增长 | 带有提供商错误代码的 payment_failed |
| 指标 | “总体而言,发生频率是多少/速度有多快?” | 每个序列的成本固定;查询成本低 | 提供商调用的 p99 延迟 |
| 追踪 | “时间都花在了哪些服务上?” | 按请求计费;通常会采样 | 一次缓慢的结账操作,按调用节点拆分 |
经验法则:指标告诉你是否出了问题,追踪告诉你哪里出了问题,日志告诉你为什么出了问题。
3. 结构化日志
记录事件,而不是叙述文字。每条日志行都是一个 JSON 对象,包含稳定的事件名称和机器可读字段:
// BAD: string interpolation — unqueryable, inconsistent
logger.info(`Payment ${id} failed for user ${userId} after ${n} retries`);
// GOOD: stable event name + structured fields
logger.warn({
event: 'payment_failed',
paymentId: id,
provider: 'stripe',
errorCode: err.code,
attempt: n,
}, 'payment failed');
日志级别——保持使用一致:
| 级别 | 含义 | 值班处理 |
|---|---|---|
error | 不变量遭到破坏;可能需要有人采取行动 | 调查 |
warn | 状态降级但已处理(重试成功、使用了回退方案) | 留意趋势 |
info | 重要的业务事件(下单、任务完成) | 无 |
debug | 诊断细节 | 默认在生产环境中关闭 |
关联 ID 是强制要求。 在系统边界生成(或接收)请求 ID,并将其附加到每条日志行、每个 span 和每个出站调用中。没有它,你就无法从交错的日志中还原单个请求:
// Express: child logger per request, ID propagated downstream
app.use((req, res, next) => {
req.id = req.headers['x-request-id'] ?? crypto.randomUUID();
req.log = logger.child({ requestId: req.id });
res.setHeader('x-request-id', req.id);
next();
});
多个入口点写入同一份日志时,要标明入口点。 关联 ID 能识别一次运行,但不会说明是哪个代码路径启动了它。同一个任务可能由调度器、重放端点或手动 CLI 运行触发,产生的日志行在同一个接收端中无法区分;因此只能通过排除法归属某条日志——交叉查阅调度器的历史记录、进程列表和部署日志——而这种方法只有在这些外部记录仍然存在时才成立。应在运行开始处、关联 ID 旁记录入口点,并以相同方式传播两者:
// One helper for every entry point: the run's own logger carries both fields.
// `entryPoint`, not `source` — ECS reserves `source.*` for network fields.
export const runLog = (entryPoint: 'scheduler' | 'replay_endpoint' | 'cli', runId: string) =>
logger.child({ entryPoint, requestId: runId });
// scheduler tick -> runLog('scheduler', crypto.randomUUID())
// POST /jobs/:id/replay -> runLog('replay_endpoint', req.id)
// CLI invocation -> runLog('cli', process.env.RUN_ID ?? crypto.randomUUID())
这两个字段都必须跨越与关联 ID 相同的边界——例如队列元数据、HTTP 标头——否则工作进程会重新推导入口点并猜测。仅与某个入口点相关联的字段只是提示,而非归属依据:任何能够调用该任务的对象都能复现它。
绝不记录机密、令牌、密码或完整的个人身份信息(PII)。 这是 security-and-hardening 技能中的硬性规则——遥测管道是典型的数据泄漏途径。应使用允许列表限定字段;不要记录整个请求正文。
4. 指标
对于由请求驱动的服务,在每个端点和每个外部依赖项上埋点 RED:Rate(请求数/秒)、Errors(失败率)、Duration(延迟直方图,而不是平均值)。对于资源(队列、池、主机),使用 USE:Utilization(利用率)、Saturation(饱和度)、Errors(错误)。
与追踪一样,与厂商无关的方案是使用 OpenTelemetry 指标 API(与第 5 步使用相同的 SDK 和上下文)。下面的示例使用 Prometheus 的 prom-client——这是常见的后端选择之一,并非唯一选择;无论选择哪种方案,RED/USE 和基数规则都相同。
import { Histogram } from 'prom-client';
const httpDuration = new Histogram({
name: 'http_request_duration_seconds',
help: 'HTTP request duration',
labelNames: ['method', 'route', 'status_class'], // '2xx', not '200'
buckets: [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5],
});
**基数是故障根源。**每种唯一的标签组合都是一个独立的时间序列。标签必须来自范围较小且固定的集合(路由模板、状态类别、提供商名称)。切勿使用用户 ID、原始 URL、错误消息或其他无界值作为标签——这些内容应放在日志和追踪中。
OK as label: route="/api/tasks/:id" status_class="5xx" provider="stripe"
NEVER a label: user_id, email, request_id, full URL, error message text
永远不要追踪平均值,始终追踪百分位数:平均值会掩盖那 1% 体验糟糕的用户。使用直方图并查看 p50/p95/p99。
5. 分布式追踪
使用 OpenTelemetry——它是与厂商无关的标准,并且自动插桩几乎无需编写代码,就能覆盖 HTTP、gRPC 和常见的数据库客户端:
// tracing.ts — must be imported before anything else
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
const sdk = new NodeSDK({
serviceName: 'checkout-service',
instrumentations: [getNodeAutoInstrumentations()],
});
sdk.start();
仅在有意义的内部工作单元周围添加手动 span(例如,applyDiscounts、chargeProvider),并附加值班人员会用来筛选的属性。在每个异步边界之间传播上下文——HTTP 标头、队列消息元数据——否则追踪会在断点处中断。默认使用较低采样率进行基于头部的采样;如果后端支持基于尾部的采样,则保留 100% 的错误。
6. 告警
针对用户能感受到的症状告警,而不是原因:
SYMPTOM (page-worthy): CAUSE (dashboard, not a page):
error rate > 1% for 5 min CPU at 85%
p99 latency > 2s one pod restarted
queue age > 10 min disk at 70%
基于原因的告警会在没有问题时触发,也会漏掉你未曾预料到的故障。基于症状的告警会在用户恰好受到影响时触发,无论原因是什么。
创建的每条告警都必须遵守以下规则:
- **必须可执行。**如果响应是“忽略它,它会自行恢复”,就删除这条告警。
- 链接到运行手册——哪怕只有三行:告警含义、要运行的第一个查询、升级处理路径。
- 设定阈值和持续时间,并以 SLO 或历史数据为依据,而不是凭猜测。
- 只使用两个严重级别:page(面向用户的问题,立即处理)和 ticket(服务降级,本周内处理)。第三个级别会沦为噪声,让人们习惯于忽略所有告警。
编写运行手册
上面的规则 2 要求每条告警都链接到运行手册。运行手册的作用是让读者无需思考,就能找到三个问题的答案:发生了什么、首先要检查什么,以及如果问题仍未解决该联系谁。将文件存放在 docs/runbooks/ 中,并以告警名称命名。
最小可用运行手册(三行):
# Runbook: High Error Rate on /api/tasks
**Means:** DB connection pool likely exhausted, or a bad deploy.
**First check:** `SELECT count(*) FROM pg_stat_activity WHERE backend_type = 'client backend';`
— if count > pool limit, see Step 2. (Swap in the equivalent for your database.)
**Escalate to:** #db-oncall or engineering on-call rotation.
**何时扩展到三行以上:**仅当第一项检查不足以作出判断时,才添加步骤。涵盖三个最常见原因的五步运行手册,胜过涵盖所有边缘情况、却让人只能草草浏览的二十步文档。
**保持运行手册及时更新。**每次处理完使用过该运行手册的事故后,都要更新它——过时的运行手册会造成虚假的信心。如果某个步骤有误或有所遗漏,应先修正,再将事故标记为已解决。
7. 验证遥测本身
插桩也是代码,可能出错。在宣布工作完成之前,触发相关路径并检查实际输出:
- 在预发布环境中强制触发错误 → 按
requestId在日志中找到该错误,并确认字段是结构化的(而不是[object Object]) - 发送测试流量 → 确认指标序列已出现,且标签符合预期、数值合理
- 在追踪 UI 中跟踪一个请求经过多个服务 → 确认没有中断的 span
- 各触发一次新告警(暂时降低阈值)→ 确认告警发送到了正确渠道,且运行手册链接有效
常见借口
| 借口 | 事实 |
|---|---|
| “等它能用了再加日志” | “之后”往往会变成“第一次出故障之后”,而那是发现自己毫无观测能力的代价最高的时刻。构建时就添加监测。 |
| “日志越多,越容易观测” | 无结构的噪声只会让故障处理变慢,而不是更快。3 条可查询的事件胜过 300 行文字。 |
“暂时用 console.log 就行” | 无结构的输出无法筛选、关联或触发告警。结构化日志记录器只需多花 5 分钟,就能一次性解决问题。 |
| “出问题时看看仪表板就行” | 没有明确问题作为设计依据的仪表板,什么都能展示,就是给不出答案。先从值班问题入手。 |
| “所有重要情况都设告警,以后再调整” | 告警器太吵会让人习惯性忽略。调整永远不会发生;真正的告警却会被漏掉。 |
| “用用户 ID 作为指标标签更方便排查问题” | 这也会让你的指标后端不堪重负。高基数查询应该放在日志和追踪中。 |
| “我们只有两个服务,用追踪太小题大做了” | 两个服务已经足以产生无法通过日志回答的跨服务延迟问题。自动监测可以让成本微乎其微。 |
危险信号
- 一个包含重试、队列或外部调用的功能 PR,却没有新增任何遥测数据
- 日志行通过字符串插值拼接,而非使用结构化字段
- 没有关联 ID/请求 ID——每条日志都是孤立的
- 调度器、Webhook 和手动运行产生的日志进入同一个日志流,却没有字段标明是哪一种入口生成了该日志
- 指标标签中包含用户 ID、原始 URL 或错误消息文本(基数炸弹)
- 只跟踪平均延迟,没有百分位数
- 告警每天触发,却只是被确认而没有采取行动
- 因 CPU、内存等原因触发告警并通知人工值班,但没有监测面向用户的错误率
- 日志中出现密钥、令牌或完整请求正文
- 证明生产环境功能正常的唯一依据是“它在我的机器上能跑”
验证
为功能添加监测后,请确认:
- 已写下该功能的值班问题,且每个问题都对应一个信号
- 所有日志输出均为结构化格式(JSON),具有稳定的事件名称,并且每行都带有关联 ID
- 每个由多个入口写入的日志接收端都带有入口点字段;该字段在运行开始时设置,并与关联 ID 一起传递,而不是在下游推断
- 任何日志行中都没有密钥、令牌或未经脱敏的 PII(抽查实际输出)
- 每个新增端点和每个外部依赖都有 RED 指标,且标签集合有界
- 延迟使用直方图记录;p95/p99 可查询
- 可在追踪界面中端到端跟踪单个请求,且跨度不中断
- 每个新告警都基于症状,附有运行手册链接,并且至少测试触发过一次
- 在预发布环境中人为引发故障后,仅凭遥测数据即可定位,无需阅读源代码
如需快速浏览此列表(包括发布前监测检查关卡),请参阅 ../../references/observability-checklist.md。