一行 "checkout failed: " + error 在本机很直观,上线后却很难回答:哪个版本开始失败、集中在哪个依赖、同一请求经过了哪些服务、是否影响真实订单?更糟的是,字符串拼接可能顺手把 token、邮箱或完整请求体送进长期保存的日志系统。

结构化日志不是把句子包进 JSON,而是为生产事件建立一份带安全边界的接口契约。

先记录事件,再写说明

每类事件需要稳定、低基数的名称,例如 checkout.payment_failed,而不是把订单号塞进事件名。字段表达一次发生的事实,正文只提供人类可读摘要:

{
  "event.name": "checkout.payment_failed",
  "event.version": 1,
  "severity_text": "ERROR",
  "service.name": "checkout-api",
  "service.version": "2026.10.13",
  "deployment.environment": "production",
  "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
  "span_id": "00f067aa0ba902b7",
  "payment.provider": "example-pay",
  "error.type": "ProviderTimeout",
  "retryable": true,
  "body": "Payment provider timed out"
}

OpenTelemetry 日志数据模型区分发生时间与采集器观察时间、严重级别文本与数字、正文、资源、埋点范围、属性,以及可选的 TraceId 和 SpanId。固定在进程或部署上的服务名、版本和环境属于 Resource;每次事件变化的提供商、结果和重试属性属于 Attributes。混在一起会造成重复存储,也让查询语义漂移。

严重级别描述行动优先级

ERROR 应表示当前操作失败且需要调查或已触发降级;WARN 表示仍完成但靠近风险边界;INFO 记录重要生命周期与业务结果;DEBUG 用于临时诊断。不要用 ERROR 记录所有被正确处理的用户输入错误,否则值班人员会学会忽略红色告警。

Trace 负责关联,不负责身份

接收 HTTP 请求时解析并验证 W3C traceparent,为当前工作创建 span,再把更新后的上下文传给下游。日志写入当前 trace 与 span 标识后,一次请求跨服务的事件可以与分布式追踪互相跳转。异步消息也应显式传播或链接上下文,不能依赖线程局部变量碰巧存在。

Trace ID 不是用户 ID、幂等键或授权凭据。W3C 明确要求 traceparent 与 tracestate 不携带个人或敏感信息。来自公网的上下文是不可信输入:验证长度与格式、限制 vendor state,并在安全边界按策略重新开始 trace。采样标志也只是提示,不能让外部调用者决定内部成本或绕过记录策略。

在日志 API 入口执行脱敏

禁止记录密码、访问与刷新 token、会话 cookie、私钥、完整支付数据,以及未经评审的请求或响应体。默认允许少量明确字段,比维护永远不完整的敏感键黑名单更可靠。确需关联用户时使用内部不可逆引用,并设置保留周期;普通哈希仍可能被枚举或跨事件关联,不能自动等同匿名化。

脱敏必须发生在事件进入队列或 exporter 之前。依赖采集平台事后遮盖意味着秘密已经穿过内存缓冲、标准输出或 sidecar。异常消息和堆栈也要过相同策略,因为 URL、数据库错误和第三方 SDK 经常把凭据混入文本。

外部输入还可能包含换行、分隔符或终端控制字符。序列化器应正确转义,文本兼容出口要清理 CR/LF 等控制字符,避免伪造第二条日志。永远不要手工拼 JSON。

给字段基数和体积设预算

事件名、服务、环境、结果和错误类型适合作为常用索引字段;订单号、用户标识、URL 原始查询串和 stack trace 具有高基数,应留在按需属性或受控存储中。把动态值放进字段名或指标标签,会迅速增加索引成本并拖慢查询。

采样应按事件价值决定。可以对大量成功健康检查做确定性抽样,但错误、发布事件和安全审计通常不能随意丢弃。审计日志还需要独立的访问控制与可靠交付;它不是应用 DEBUG 流量的另一个级别。

让失败路径也可观察

异步日志管道需要有界队列。队列满时,是阻塞请求、丢弃低优先级事件还是写入本地应急通道,必须提前决定并暴露计数器。无界缓冲会把可观测性故障变成内存故障;静默丢弃则让事故恰好失去证据。

记录采集延迟、被丢弃条数、序列化失败、脱敏命中、导出重试和后端拒绝。应用日志系统本身也需要指标与告警,但不要在 exporter 失败时递归写回同一 exporter。

把日志契约纳入测试

为关键事件做 schema 测试:必需字段存在、类型和枚举稳定、时间可解析、严重级别符合策略。安全测试注入 token、cookie、邮箱、换行与超长字符串,断言最终出口中没有原值且结构仍可解析。传播测试跨两个服务和一个队列,确认 trace 关联不断裂。

负载测试则关闭或拖慢收集端,验证队列上限、丢弃优先级和应用延迟。最后用实际故障问题验收查询能力:能否在几分钟内按版本比较失败率,从告警跳到 trace,找到最终错误,又不读取任何用户秘密?

上线前应确认:事件名与版本稳定;资源字段和事件属性分层;级别对应明确行动;异常只在有决策信息的边界记录;Trace Context 经过验证且不承载身份;敏感字段在发送前脱敏;动态值不进入字段名;大小、基数、采样与保留均有预算;管道过载可见且经过测试。

好的日志不是越多越好,而是每条都能回答一个生产问题。稳定 schema 让机器可靠查询,trace 让事件连接成因果链,脱敏与预算则确保获得可见性时不会制造新的安全和成本事故。