OTel Logs 的独特定位
Traces 和 Metrics 是 OTel 原创的 API,Logs 不是——每门语言早就有成熟的日志库(log4j、winston、pino、logrus、zap)。OTel 不要重新发明,它做的是:
timestamp / severity / body / attributes / trace_id / span_id —— 统一格式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_number | severity_text | 含义 |
|---|---|---|
| 1-4 | TRACE / TRACE2-4 | 最细粒度跟踪 |
| 5-8 | DEBUG / DEBUG2-4 | 调试信息 |
| 9-12 | INFO / INFO2-4 | 常规事件 |
| 13-16 | WARN / WARN2-4 | 警告 |
| 17-20 | ERROR / ERROR2-4 | 错误 |
| 21-24 | FATAL / 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 pipeline | stdout + 采集 | |
|---|---|---|
| 改造成本 | SDK + exporter 配置 | 加个 format 中间件 |
| 协议 | OTLP | JSON + Fluent/Vector/Filebeat |
| 容器崩溃 | 可能丢缓冲中数据 | 落盘更安全 |
| 关联度 | trace_id 自动 | 需手动注入 |
| 适合 | 新项目、云原生 | 老项目、ELK 已有栈 |
中立建议:生产上两者并存——stdout 留底,OTel pipeline 做实时分析。
日志内容四原则
console.log(`user ${id} did ${action}`)——否则无法查询。user.id / http.method / error.type,不要自造 uid / method。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 的 logsreceiver 和 logsexporter 让日志处理变得强大:
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_id → Tempo 打开这条 trace → 看到完整调用链
↓
点某个 span → 回到 Loki 看这个 span 对应的所有日志
这个"三向跳转"是 OTel 生态里最爽的体验——也是第 9 章重点。
本章小结
- OTel Logs 不替代你的日志库,而是桥接 + 格式统一 + trace 关联
- LogRecord 核心字段:severity / body / trace_id / span_id / attributes
- 两种落地路径:OTel Logs pipeline(SDK → OTLP)或 结构化 stdout + 采集
- 日志四原则:结构化、语义约定、控制量、不记 PII
- SpanEvent 和 LogRecord 各司其职——关键事件可以两边都打
- Collector 能把日志路由到 Loki/ES/Datadog,顺手做脱敏