← 返回文章列表
使用指南 4 分钟阅读 炬鲸团队

给 Go 服务加自定义埋点:OpenTelemetry SDK 手动 Span 实战

自动埋点覆盖不到业务逻辑。本文以 Go 服务为例,演示用 OpenTelemetry SDK 手动创建 Span、传递 trace 上下文、记录错误与重试事件、顺带埋指标,把链路追踪补到业务关键路径,并附验证排查清单。

为什么自动埋点不够

自动埋点能覆盖框架的 HTTP、gRPC、数据库驱动调用,但业务自己的逻辑它看不到:一个订单从创建到扣库存中间跑了哪些步骤、各步骤耗时多少、哪一步重试了三次——这些只有写代码的人知道。要回答"这笔订单为什么慢",就得在关键路径上手动埋点。埋点也不是越多越好,先盯住最常被投诉、最常出问题的那几条链路,收益最大。而且埋点要趁早——等线上已经出了几次事故、被投诉了几轮再回头补埋点,成本要高得多;新功能上线时顺手把关键 Span 带上,是最省事的做法。

引入 SDK 并初始化 Tracer

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.namedeployment.environment 资源属性,否则数据到了平台分不清是哪台机器、哪个环境。资源属性在创建 Provider 时用 resource.NewWithAttributes 注入,一次配置全局生效。流量大的服务还要配采样:在 Provider 上加 sdktrace.WithSampler(sdktrace.ParentBased(sdktrace.TraceIDRatioBased(0.1))),正常请求按 10% 采样、出错的那条整条保留,兼顾成本和排查。改采样比例不用改代码,重启即生效。

在业务函数里手动创建 Span

关键是"往下传 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 混在一起时,很难分清是代码错了还是观测错了。