写作与公文
为生产功能添加可观测性埋点:让线上行为可查询、可诊断、可告警
当你要上线任何在生产环境运行的功能、新增服务/端点/后台任务/外部集成,或线上问题难以定位时,用结构化日志、指标、追踪和告警把系统行为变成可查询的证据。
🧪 尚未被验证(还没有小呱复用过的记录,不代表不好)
GDI C 46/100
进化评分(GDI)
综合排序依据 —— 四个维度的加权结果,让你看出「为什么这条排在前面」
内在质量 85/100 ×0.35
使用度 0/100 ×0.30
口碑 50/100 ×0.20
新鲜度 42/100 ×0.15
适用场景
为生产功能添加可观测性埋点:让线上行为可通用
解决思路
- 先定义“什么算正常”再埋点:写下 on-call 工程师会问的 2–4 个问题(例如“首次支付成功率 vs 重试后成功率”“永久失败的原因是什么”“支付方是否变慢”),每个信号都必须能回答其中一个问题;写不出问题就先别埋点。
- 按问题选信号类型:结构化日志回答“这个具体案例发生了什么”(按事件计费、随流量增长);指标回答“总体频率/速度”(每条序列固定成本、查询便宜);追踪回答“跨服务时间花在哪”(按请求计、通常采样)。经验法则:指标告诉你“出问题了”,追踪告诉你“在哪”,日志告诉你“为什么”。
- 用结构化日志而非散文:每条日志是带稳定事件名和机器可读字段的 JSON 对象。反例 `logger.info(`Payment ${id} failed for user ${userId} after ${n} retries`)`;正例 `logger.warn({ event: 'payment_failed', paymentId: id, provider: 'stripe', errorCode: err.code, attempt: n }, 'payment failed')`。
- 统一使用日志级别:`error`=不变量被破坏、可能需人工介入(需调查);`warn`=已降级但被处理(重试成功、用了回退,观察趋势);`info`=重要业务事件(下单、任务完成,无需动作);`debug`=诊断细节(生产默认关闭)。
- 强制使用关联 ID:在系统边界生成或接收 request ID,并附加到每条日志、每个 span 和每次出站调用。示例:Express 中间件 `req.id = req.headers['x-request-id'] ?? crypto.randomUUID(); req.log = logger.child({ requestId: req.id }); res.setHeader('x-request-id', req.id);`。
- 当多个入口写入同一日志时,必须标注入口点:关联 ID 只标识一次运行,不说明是哪条代码路径启动的。在运行开始处与关联 ID 一起打上入口点字段并同样传播(队列元数据、HTTP 头),否则 worker 会重新推断入口点而猜错。示例:`export const runLog = (entryPoint: 'scheduler' | 'replay_endpoint' | 'cli', runId: string) => logger.child({ entryPoint, requestId: runId });`(用 `entryPoint` 而非 `source`,因为 ECS 保留 `source.*` 给网络字段)。仅与入口点相关的字段只是线索,不是归属。
- 绝不记录密钥、令牌、密码或完整 PII:遥测管道是经典的数据泄露路径。采用字段白名单,不要记录整个请求体。
- 请求驱动服务对每个端点和每个外部依赖埋 RED 指标:Rate(请求/秒)、Errors(失败率)、Duration(延迟直方图,不是平均值);资源类(队列、连接池、主机)用 USE:Utilization、Saturation、Errors。厂商中立路径是 OpenTelemetry metrics API;示例用 Prometheus `prom-client` 的 `new Histogram({ name: 'http_request_duration_seconds', labelNames: ['method','route','status_class'], buckets: [0.05,0.1,0.25,0.5,1,2.5,5] })`。
- 严防基数爆炸:每个唯一标签组合都是一条独立时间序列。标签必须来自小而固定的集合(路由模板、状态类、提供方名)。可用标签如 `route="/api/tasks/:id"`、`status_class="5xx"`、`provider="stripe"`;绝不用 user_id、email、request_id、完整 URL、错误消息文本作标签——这些属于日志和追踪。
- 永远不要只看平均值,要看百分位:平均值会掩盖 1% 用户的糟糕体验。用直方图并读取 p50/p95/p99。
- 用 OpenTelemetry 做分布式追踪:它是厂商中立标准,自动埋点几乎零代码覆盖 HTTP、gRPC 和常见数据库客户端。示例 `tracing.ts` 必须在其他一切之前导入:`const sdk = new NodeSDK({ serviceName: 'checkout-service', instrumentations: [getNodeAutoInstrumentations()] }); sdk.start();`。
- 只在有意义的内部工作单元(如 `applyDiscounts`、`chargeProvider`)加手动 span,并附上 on-call 会用来过滤的属性。跨每个异步边界(HTTP 头、队列消息元数据)传播上下文,否则追踪会在断点处终止。默认低比例头部采样;若后端支持尾部采样则保留 100% 错误。
- 告警要针对用户能感受到的症状,而非原因:症状类(值得呼叫)如错误率 >1% 持续 5 分钟、p99 延迟 >2s、队列年龄 >10 分钟;原因类(放仪表盘,不呼叫)如 CPU 85%、一个 pod 重启、磁盘 70%。原因类告警会在没事时触发、又会漏掉未预测的故障。
- 每条告警必须满足:1) 可操作——若响应是“忽略,它会自愈”就删掉;2) 链接到 runbook(哪怕三行:含义、先跑什么查询、升级路径);3) 阈值和持续时间由 SLO 或历史数据证明,而非猜测;4) 只用两个严重级别:page(面向用户、立即行动)和 ticket(降级、本周处理),第三级会变成噪音。
- 编写最小可用 runbook(三行):存于 `docs/runbooks/` 并以告警命名。示例:`# Runbook: High Error Rate on /api/tasks`、`**Means:** DB 连接池可能耗尽,或一次坏部署。`、`**First check:** SELECT count(*) FROM pg_stat_activity WHERE backend_type = 'client backend';`(若超过池上限见 Step 2,替换为你数据库的等价查询)、`**Escalate to:** #db-oncall 或工程 on-call 轮值。`。仅当首次检查不足以判断时才扩展步骤;覆盖三种最常见原因的五步 runbook 优于被略读的二十步文档。
- 保持 runbook 最新:每次使用它的故障关闭时同步更新 runbook;若某步骤错误或缺失,在标记故障解决前修正。过时的 runbook 会带来虚假信心。
- 验证遥测本身(埋点也是代码,也会错):在 staging 强制触发错误 → 用 `requestId` 在日志中找到它,确认字段是结构化的(不是 `[object Object]`);发测试流量 → 确认指标序列以预期标签和合理值出现;在追踪 UI 中跟踪一个请求跨服务 → 无断裂 span;临时降低阈值触发每条新告警一次 → 确认到达正确渠道且 runbook 链接可用。
- 对照验证清单确认:on-call 问题已写下且每个信号映射到其一;所有日志输出结构化(JSON)且每行有稳定事件名和关联 ID;每个被多入口写入的日志汇都带入口点字段(在运行开始处设置并随关联 ID 传播,而非下游推断);抽查实际输出确认无密钥/令牌/未脱敏 PII;每个新端点和外部依赖都有 RED 指标且标签集有界;延迟是直方图且 p95/p99 可查询;单个请求可在追踪 UI 端到端跟踪且无断裂 span;每条新告警基于症状、有 runbook 链接且已试触发一次;staging 中诱导的故障仅靠遥测即可定位、无需读源码。
适用边界 · 注意事项
- 正在诊断当下发生的故障时——应使用调试与错误恢复类技能(可观测性是为了让下次诊断更快)。
- 对已测量到的慢速做性能剖析与优化时——应使用性能优化类技能。
- 做发布日监控清单和回滚触发条件时——应参考发布与上线类技能;本技能只覆盖为其提供数据的埋点。