Chapter 04

日志不再孤岛

日志是最古老的观测手段,也最难和其他数据关联。OTel Logs 解法简单粗暴——把 trace_id 塞进每条日志,让你在 Jaeger 看 span 时一键跳 Loki 看原文。关联,是 OTel Logs 存在的唯一理由。

OTel Logs 的独特定位

Traces 和 Metrics 是 OTel 原创的 API,Logs 不是——每门语言早就有成熟的日志库(log4j、winston、pino、logrus、zap)。OTel 不要重新发明,它做的是:

定义 LogRecord 数据模型
timestamp / severity / body / attributes / trace_id / span_id —— 统一格式
定义 OTLP Logs 协议
Protobuf 编码,和 Traces/Metrics 同一套传输通道
提供 Log Bridge API
现有日志库(winston/pino/...)通过 bridge 接入 OTel,你继续用熟悉的 API

LogRecord 结构

{
  "timestamp": "2026-05-06T10:23:41.123Z",
  "observed_timestamp": "2026-05-06T10:23:41.125Z",
  "severity_number": 17,           // 17 = ERROR
  "severity_text": "ERROR",
  "body": "payment failed: card declined",
  "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
  "span_id": "00f067aa0ba902b7",
  "trace_flags": "01",
  "attributes": {
    "user.id": "u_123",
    "payment.method": "card",
    "error.type": "CardDeclined"
  },
  "resource": {
    "service.name": "payment-svc",
    "service.version": "1.2.0"
  }
}

关键字段:trace_id + span_id——这俩要么你手动塞,要么由 bridge 自动注入(首选)。

Severity 级别

severity_numberseverity_text含义
1-4TRACE / TRACE2-4最细粒度跟踪
5-8DEBUG / DEBUG2-4调试信息
9-12INFO / INFO2-4常规事件
13-16WARN / WARN2-4警告
17-20ERROR / ERROR2-4错误
21-24FATAL / FATAL2-4致命,进程应退出

OTel 的级别是 syslog 风格的 1-24。大多数日志库用 5-6 级就够——后端会显示 severity_text。

Node.js:Pino + OTel Bridge

pnpm add pino @opentelemetry/api-logs \
  @opentelemetry/sdk-logs @opentelemetry/instrumentation-pino \
  @opentelemetry/exporter-logs-otlp-http
import { logs } from "@opentelemetry/api-logs";
import {
  LoggerProvider, BatchLogRecordProcessor,
} from "@opentelemetry/sdk-logs";
import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http";

const provider = new LoggerProvider();
provider.addLogRecordProcessor(
  new BatchLogRecordProcessor(new OTLPLogExporter({
    url: "http://localhost:4318/v1/logs",
  }))
);
logs.setGlobalLoggerProvider(provider);

// Pino Instrumentation 自动把 pino 日志桥到 OTel
import { PinoInstrumentation } from "@opentelemetry/instrumentation-pino";
registerInstrumentations({
  instrumentations: [
    new PinoInstrumentation({
      logHook: (span, record) => {
        record["service.name"] = "payment-svc";
      },
    }),
  ],
});
// 业务代码继续用 pino
import pino from "pino";
const log = pino();

tracer.startActiveSpan("pay", (span) => {
  log.error({ user: u.id }, "payment failed");
  // ↑ trace_id/span_id 被自动注入
  // 同时上报到 stdout 和 OTel Logs pipeline
});
零侵入
这套方案的漂亮之处:业务代码一行不改,仍然用 pino.info/error,拿到的日志既在 stdout(Docker 收集),又在 OTel Collector,trace_id 自动挂钩。

Python:logging 标准库

from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter
import logging

provider = LoggerProvider()
provider.add_log_record_processor(
    BatchLogRecordProcessor(OTLPLogExporter())
)

handler = LoggingHandler(level=logging.INFO, logger_provider=provider)
logging.getLogger().addHandler(handler)

# 业务代码
log = logging.getLogger("payment")
log.error("payment failed", extra={"user_id": "u_123"})
# trace_id 自动注入(如果当前在 active span 内)

日志注入格式化(传统方式)

不想走 OTel Logs pipeline、继续让日志落地文件/stdout + Fluent Bit/Vector 采集?也可以——只要让日志里包含 trace_id,后端能聚合就行:

// Express + winston + 手动注入
import { trace } from "@opentelemetry/api";
import winston from "winston";

const injectTrace = winston.format((info) => {
  const span = trace.getActiveSpan();
  if (span) {
    const ctx = span.spanContext();
    info.trace_id = ctx.traceId;
    info.span_id = ctx.spanId;
  }
  return info;
});

const logger = winston.createLogger({
  format: winston.format.combine(
    injectTrace(),
    winston.format.json()
  ),
  transports: [new winston.transports.Console()],
});

输出样例:

{
  "level": "error",
  "message": "payment failed",
  "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
  "span_id": "00f067aa0ba902b7"
}

Loki 或 Elasticsearch 把 trace_id 当标签索引——你在 Grafana Tempo 上查到 trace 后,点一下就能过滤出相关日志。

两种方案的取舍

OTel Logs pipelinestdout + 采集
改造成本SDK + exporter 配置加个 format 中间件
协议OTLPJSON + Fluent/Vector/Filebeat
容器崩溃可能丢缓冲中数据落盘更安全
关联度trace_id 自动需手动注入
适合新项目、云原生老项目、ELK 已有栈

中立建议:生产上两者并存——stdout 留底,OTel pipeline 做实时分析。

日志内容四原则

结构化
永远 JSON,永远别 console.log(`user ${id} did ${action}`)——否则无法查询。
语义约定
attribute key 用 OTel SemConv:user.id / http.method / error.type,不要自造 uid / method
控制量
DEBUG 级开发用,生产只留 INFO/WARN/ERROR;热路径里的 INFO 要收敛——一条请求打 30 条 INFO 日志是灾难。
不记 PII
邮箱、手机、地址、身份证都是 PII,默认不记原文——要记就哈希或脱敏。Collector 层也能配 attributesprocessor 统一删除。

LogRecord 到 SpanEvent

一个常见纠结:日志应该写成独立的 LogRecord还是span 的 event?

经验法则
· SpanEvent:和这个 span 强绑定的瞬间事件("cache_miss"、"retry #2")——放 span 里看 timeline 更直观
· LogRecord:通用日志,可能没有对应 span、或跨多个 span、或就是后台 worker 的输出——放 Logs pipeline

两者可以共存:关键节点两边都打(span.addEvent + logger.info),给自己和运维多一条线索。

Collector 处理日志

Collector 的 logsreceiverlogsexporter 让日志处理变得强大:

receivers:
  otlp:            # 从 SDK 接收 OTLP 日志
    protocols:
      grpc:
  filelog:         # 直接读日志文件
    include: [/var/log/app/*.log]
    start_at: end

processors:
  transform:       # 结构化、提取字段
    log_statements:
      - 'set(attributes["http.status_code"], Int(attributes["status"]))'
  attributes:      # 删敏感信息
    actions:
      - key: email
        action: delete

exporters:
  loki:                 # 送去 Loki
    endpoint: http://loki:3100/loki/api/v1/push
  elasticsearch:
    endpoints: [http://es:9200]

service:
  pipelines:
    logs:
      receivers: [otlp, filelog]
      processors: [transform, attributes, batch]
      exporters: [loki, elasticsearch]

这样无论应用用 OTel SDK 还是老老实实写文件,Collector 都能统一处理,加工后分发多个后端。

Loki 端的体验

在 Grafana + Loki + Tempo 组合下,你的查询会长这样:

{service_name="payment-svc"} |= "error" | json | trace_id != ""点一条日志里的 trace_idTempo 打开这条 trace看到完整调用链点某个 span回到 Loki 看这个 span 对应的所有日志

这个"三向跳转"是 OTel 生态里最爽的体验——也是第 9 章重点。

本章小结