bigywater.tech
微服务 · 76

日志:结构化日志与关联 trace_id

为什么日志要结构化?哪些位置写的日志会丢掉 trace_id?Collector 怎么把日志和 trace 关联起来?采样之后日志里的 trace_id 点进去还有 trace 吗?这篇用 Python logging、OpenTelemetry 和 Collector 的 filelog 逐项量出来。

3898 vs 0
2% 的错误消息正文里带 “user=…” 时,文本日志用正则提取 user 错误的行数,与 JSON 日志
0%
span 结束之后写的日志、以及没传上下文的线程池任务里写的日志,带 trace_id 的比例
76 / 1000
10% 采样时,日志里的 1000 个不同 trace_id 中,能在导出的 trace 里找到的个数(采样是按 trace_id 的哈希随机抽的,这个数每次运行不同,开发中的另一次运行是 104)

文本与 JSON

实测 · 本机执行输出
  格式             | 20 万行的体积 | 平均每行字节数 | 提取字段的耗时(CPython) | user 提取错误的行数
  ---------------+----------+---------+------------------+-------------
  文本行(key=value) | 21.2 MB  | 106     | 0.06 s           | 3898        
  JSON 行         | 34.6 MB  | 173     | 0.35 s           | 0           
  JSON 每行多了键名和引号,体积更大,但解析是确定的;文本行用正则提取,消息正文里恰好出现 “user=…” 时贪婪匹配会取错字段(这里的错误行就是那 2% 的错误消息)。结构化日志用字段而不是正文来承载信息,查询和聚合更可靠

文本日志靠正则提取字段会被正文干扰;JSON 日志字段确定,代价是体积更大、解析更慢。

trace_id 的覆盖

实测 · 本机执行输出
  日志位置                        | 日志行数 | 带有效 trace_id 的行数 | 比例  
  ----------------------------+------+------------------+-----
  (a)请求处理函数里(span 内)          | 2000 | 2000             | 100%
  (b)span 结束之后                | 2000 | 0                | 0%  
  (c)线程池任务,没传上下文              | 2000 | 0                | 0%  
  (d)线程池任务,copy_context().run | 2000 | 2000             | 100%
  日志过滤器读的是“当前 span”:在 span 内、或者把上下文传到了新线程里,日志才有 trace_id;span 结束之后、没有传递上下文的线程里写的日志,trace_id 是空的,查 trace_id 的时候这些行就找不到了

过滤器读的是当前 span,span 之外或没传上下文的线程里写的日志没有 trace_id。

Collector 关联

实测 · 本机执行输出
  记录 body='{"time": "2026-10-19T12:00:01.250Z", "le' severityText=ERROR severityNumber=17 timeUnixNano=1792411201250000000 traceId='4bf92f3577b34da6a3ce929d0e0e4736' spanId='00f067aa0ba902b7'
      attributes 里还有的键:['level', 'log.file.name', 'msg', 'service', 'span_id', 'time', 'trace_id', 'user']
  记录 body='{"time": "2026-10-19T12:00:02.500Z", "le' severityText=INFO severityNumber=9 timeUnixNano=1792411202500000000 traceId='(空)' spanId='(空)'
      attributes 里还有的键:['level', 'log.file.name', 'msg', 'service', 'time', 'user']
  trace_parser 把 trace_id 和 span_id 从属性提升到 OTLP 日志记录的 traceId / spanId 专门字段(后端据此把日志挂到 trace 上);没有 trace_id 的日志行这两个字段是空的

filelog + json_parser + trace_parser 把 trace_id 放进 OTLP 日志记录的专门字段。

采样与日志

实测 · 本机执行输出
  日志里不同的 trace_id:1000 个;导出的 span 里能找到的:76 个(8%);其余 924 个 trace_id 在追踪后端里没有对应的 trace(点日志里的 trace_id 链接会打不开)
ERRORS {}

日志里的 trace_id 不一定有对应的 trace;采样率越低,死链越多。

分步演示

结构化日志与 trace_id
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Python 3.14.8 / Go / nginx / Envoy / etcd / Consul / Nacos / OpenTelemetry / Prometheus(按各篇注明用到哪些及版本);所有服务都是本机自己起的进程,端口 9150 以上,只监听 127.0.0.1,不连任何外部服务,不碰本机已有的实例;故障用 kill -9 进程、让连接断开或在代码里注入延迟与错误来制造;计时用 time.perf_counter,数字每次运行会有小幅抖动。Python logging 与 OpenTelemetry Python SDK 1.45.1,Collector contrib 0.163.1(filelog 接收器、json_parser、trace_parser、file exporter);第一节是 CPython 里的计时(20 万行),不代表其他语言;第二节的日志位置是我设计的 4 种写法;第三节只写了 2 行日志;每节只跑了一次
    • 没有验证:Go slog、Java Logback 的 MDC 注入 trace_id(见 Go 系列的 slog 一篇);日志量很大时 filelog 的吞吐;日志采样与按级别保留;日志到 Loki / Elasticsearch 后按 trace_id 查询的速度;OpenTelemetry Logs SDK(直接导出日志而不经过文件);敏感信息在日志里的脱敏
    京ICP备2026067502号-1京公网安备11010502063487号