OpenTelemetry 三信号关联 用 Trace 串起日志与指标
指标告诉你"变慢了",日志告诉你"报了什么错",链路告诉你"慢在哪一跳"。三者分开时,一次故障要在三个系统之间来回跳,每次跳转都在丢上下文。OpenTelemetry 的核心价值不是采集,而是用同一套上下文把三者绑在一起。
一、关联的物理载体
能串起三信号的上下文只有三个字段:trace_id(16 字节,标识一次调用链)、span_id(8 字节,标识链路中的一个操作)、trace_flags(采样决策位)。
落地要求极其具体:日志里必须打出 trace_id 与 span_id。做不到这一点,其他一切手段都是装饰。
import "go.opentelemetry.io/otel/trace"
func Log(ctx context.Context, msg string, kv ...any) {
sc := trace.SpanContextFromContext(ctx)
base := []any{
"trace_id", sc.TraceID().String(),
"span_id", sc.SpanID().String(),
"trace_sampled", sc.IsSampled(),
}
logger.InfoContext(ctx, msg, append(base, kv...)...)
}
字段名统一成 trace_id、span_id(Grafana 的 trace to logs 默认识别这两个)。不同服务混用 traceId、trace-id 会让跳转直接失效。
二、让指标能定位回 trace
指标是聚合数据,本身不带 trace_id,关联要靠 exemplar:Prometheus 允许在直方图 bucket 上挂样本点并附带 trace_id。
prometheus.MustRegister(prometheus.NewHistogramVec(prometheus.HistogramOpts{
Name: "http_request_duration_seconds",
Buckets: prometheus.ExponentialBuckets(0.005, 2, 12),
}, []string{"route"}))
# prometheus.yml
storage:
tsdb:
out_of_order_time_window: 30m
# 启动参数: --enable-feature=exemplar-storage
效果是在 Grafana 的 P99 曲线上直接点击某个点,跳到那一次真实慢请求的 Trace。这是尾延迟排查中效率最高的一条通路。
三、Go 侧最小可信配置
func InitTracing(ctx context.Context, name, endpoint string) (func(), error) {
res, err := otlptracegrpc.New(ctx,
otlptracegrpc.WithEndpoint(endpoint),
otlptracegrpc.WithInsecure(),
otlptracegrpc.WithCompressor(gzip.Name))
if err != nil { return nil, err }
bsp := sdktrace.NewBatchSpanProcessor(res,
sdktrace.WithMaxQueueSize(4096),
sdktrace.WithMaxExportBatchSize(512),
sdktrace.WithBatchTimeout(3*time.Second))
tp := sdktrace.NewTracerProvider(
sdktrace.WithSpanProcessor(bsp),
sdktrace.WithResource(resource.NewWithAttributes(semconv.SchemaURL,
semconv.ServiceName(name),
attribute.String("deployment.environment", env))),
sdktrace.WithSampler(sdktrace.ParentBased(sdktrace.TraceIDRatioBased(0.1))))
otel.SetTracerProvider(tp)
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{}, propagation.Baggage{}))
return func() { _ = tp.Shutdown(context.Background()) }, nil
}
四点必须注意:采样用 ParentBased(入口决定,下游一律跟随,否则链路断在半路);propagator 必须注册,否则跨服务不传播;BatchSpanProcessor 队列满会丢 span;Shutdown 要挂在进程退出路径上,否则最后一批 span 丢失。
四、下游插桩别漏
手写 span 只覆盖入口,DB 与 HTTP 调用要靠 instrumentation。
sqlDB, _ := db.DB()
otelsql.RegisterDBStatsMetrics(sqlDB, otelsql.WithAttributes(
attribute.String("db.system", "mysql")))
client := otelhttp.NewClient(http.DefaultClient,
otelhttp.WithSpanOptions(trace.WithSpanKind(trace.SpanKindClient)))
插桩三件套:入口 HTTP server、出口 HTTP client、DB。补齐 Redis 与 MQ 客户端后,一次请求的瀑布图才真正展开。
五、异步与消息的传播边界
异步任务和消息队列是链路断裂的高发区。规则很简单:生产端把 context 注入消息 header,消费端提取后续接。
// producer
carr := propagation.MapCarrier{}
otel.GetTextMapPropagator().Inject(ctx, carr)
for k, v := range carr { msg.Headers[k] = []byte(v) }
// consumer
ctx := otel.GetTextMapPropagator().Extract(context.Background(),
propagation.MapCarrier(headerMap(msg.Headers)))
ctx, span := tracer.Start(ctx, "consume.order",
trace.WithSpanKind(trace.SpanKindConsumer),
trace.WithLinks(trace.Link{SpanContext: extractSC(msg.Headers)}))
defer span.End()
重试场景要用 SpanLink 而不是父子关系,因为每次重试逻辑上是独立调用。
六、后端拼装与验证清单
Tempo、Loki、Prometheus 三者关联靠 data source 的 derivedFields 与 trace to logs 跳转配置:
{ "name": "traceID", "matcherType": "label", "matcher": "trace_id",
"internalLink": { "datasourceUid": "tempo", "urlDisplay": "查看链路" } }
配置完成后按三条验收:错误日志能一键跳到 Trace;P99 曲线上有可点击的 exemplar 点;Trace 的 span 能反向跳到对应实例该时段的日志。三个方向都通才算真正打通。
小结
落地顺序是固定的:统一日志里的 trace 字段 → 注册 propagator 与 ParentBased 采样 → 补齐 server/client/DB 插桩 → 用 exemplar 把指标点接回 Trace → 配双向跳转。Collector 与后端选型可以换,但"日志必须带 trace_id""采样必须 parent-based""消息必须注入 carrier"这三条是地基。三信号分开看只是三份数据,串起来才是可诊断的系统。
评论(0)
登录 后参与评论。