自动埋点覆盖不到业务逻辑。本文以 Go 服务为例,演示用 OpenTelemetry SDK 手动创建 Span、传递 trace 上下文、记录错误与重试事件、顺带埋指标,把链路追踪补到业务关键路径,并附验证排查清单。
自动埋点能覆盖框架的 HTTP、gRPC、数据库驱动调用,但业务自己的逻辑它看不到:一个订单从创建到扣库存中间跑了哪些步骤、各步骤耗时多少、哪一步重试了三次——这些只有写代码的人知道。要回答"这笔订单为什么慢",就得在关键路径上手动埋点。埋点也不是越多越好,先盯住最常被投诉、最常出问题的那几条链路,收益最大。而且埋点要趁早——等线上已经出了几次事故、被投诉了几轮再回头补埋点,成本要高得多;新功能上线时顺手把关键 Span 带上,是最省事的做法。
Go 项目先装依赖:
go get go.opentelemetry.io/otel go.opentelemetry.io/otel/sdk go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp
初始化一个 TracerProvider,通过 OTLP/HTTP 上报到炬鲸 OBSERVE:
import (
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
)
func initTracer(ctx context.Context) (*sdktrace.TracerProvider, error) {
exp, err := otlptracehttp.New(ctx,
otlptracehttp.WithEndpoint("ob.example.com:4318"),
otlptracehttp.WithInsecure(),
)
if err != nil { return nil, err }
tp := sdktrace.NewTracerProvider(sdktrace.WithBatcher(exp))
otel.SetTracerProvider(tp)
return tp, nil
}
生产环境有两件事要做:一是换 HTTPS,二是给每台实例设好 service.name 和 deployment.environment 资源属性,否则数据到了平台分不清是哪台机器、哪个环境。资源属性在创建 Provider 时用 resource.NewWithAttributes 注入,一次配置全局生效。流量大的服务还要配采样:在 Provider 上加 sdktrace.WithSampler(sdktrace.ParentBased(sdktrace.TraceIDRatioBased(0.1))),正常请求按 10% 采样、出错的那条整条保留,兼顾成本和排查。改采样比例不用改代码,重启即生效。
关键是"往下传 context"。拿订单下单为例,把扣库存、写订单库两个步骤各自包成 Span,并把关键属性打上去:
func CreateOrder(ctx context.Context, o *Order) error {
tracer := otel.Tracer("order-service")
ctx, span := tracer.Start(ctx, "create_order")
defer span.End()
span.SetAttributes(
attribute.String("order.id", o.ID),
attribute.Int("order.items", len(o.Items)),
)
ctx, stockSpan := tracer.Start(ctx, "deduct_stock")
err := deductStock(ctx, o)
stockSpan.SetAttributes(attribute.Int("stock.deducted", o.TotalQty))
stockSpan.End()
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "deduct stock failed")
return err
}
return nil
}
几个要点:Span 名字用动词短语(deduct_stock 而不是 span1);错误用 RecordError + SetStatus 标记,检索时能直接过滤出失败链路;属性只在排查用得上的时候加,别把整个请求体塞进去,既浪费存储又难读。
耗时之外,Span 还能记录"过程"。比如扣库存里发生了重试,用 AddEvent 记下来,事后不用翻日志就知道这一步折腾过:
for i := 0; i < 3; i++ {
if err := deductStock(ctx, o); err == nil { break }
stockSpan.AddEvent("retry", attribute.Int("attempt", i+1))
}
事件默认只保留少量属性,适合标记"重试第几次""降级到哪个副本"这类离散信息,而不是把大段日志塞进去。
手动埋点最容易漏的是 context 传递。链路能串成一条,靠的是同一个 trace 上下文顺着 ctx 一路传下去:数据库调用、下游 HTTP 请求都要用你包出来的 ctx,而不是新开的 context.Background()。否则每一段都是孤立的 Span,拼不成完整链路。如果某个内部函数今天还不接收 ctx,给它加一个参数,两行改动,就能保住整条链路不在这里断掉。跨进程也一样:通过消息队列或下游 HTTP 调用时,把 trace 上下文塞进消息头或请求头,否则消费者那边就是一条新链路,前半段和后半段对不上。
同一套 SDK 也能埋指标,用来配告警。比如给下单耗时挂一个直方图:
import "go.opentelemetry.io/otel/metric"
orderLatency, _ := meter.Float64Histogram("order.latency",
metric.WithUnit("ms"))
func CreateOrder(ctx context.Context, o *Order) error {
start := time.Now()
defer func() {
orderLatency.Record(ctx, float64(time.Since(start).Milliseconds()))
}()
// ...
}
Trace 回答"这一次为什么慢",指标回答"整体慢不慢"。两者打上同一套属性(service、order.id 之类),就能从聚合的告警下钻到具体的一条链路,排查才能闭环。
启动服务后随便点一笔下单,到炬鲸 OBSERVE 的链路追踪页按 order.id 搜,能看到 create_order → deduct_stock 的父子结构、每段耗时,以及失败时打的错误状态和重试事件。再用日志里的 trace_id 反查,就能把"订单 12345 为什么慢"定位到具体一步。检查清单:Span 是否都挂在同一个 trace 下、有没有平铺无父子的、属性在检索面板里能不能搜到、错误链路能不能按 status=error 过滤出来。如果 Span 都平铺没有父子关系,第一个要查的就是 context 传递。另外建议在测试环境就把埋点接好,别等上线再验证——埋点问题和业务 bug 混在一起时,很难分清是代码错了还是观测错了。