Go 基础体系 · 第 90/113 篇。示例统一基于 Go 1.26.4;核心片段可能省略 package 与 import,完整程序可直接按文中结构运行。

Go OpenTelemetry 实战:Trace、Metric、Log、Context 与 OTLP

本文以 Go 1.26.4 为基准,固定使用 otel v1.38.0OTLP Exporter v1.38.0otelhttp v0.63.0otel/log 与 sdk/log v0.14.0Collector Contrib 0.133.0。core、SDK、Exporter 与 contrib 应成组升级,禁止 latest

OpenTelemetry(OTel)提供遥测 API、SDK、语义约定和 OTLP。SDK 负责采样、聚合与批量,Exporter 发送,Collector 处理;它不是存储系统。

1. 三类信号

Trace 描述请求的因果路径,Span 含时间、父子关系、属性、事件和状态。Metric 聚合数量、分布或当前值。Log 保存离散故障上下文。三者通过 resource、trace ID、span ID 和统一字段关联。

典型排障由指标发现异常,用 trace ID 找慢链路,再查相关日志。请求值不能做 metric label。

2. API、SDK 与 Provider

库代码用 OTel API 创建 tracer/meter,不安装 SDK 或 exporter;应用入口拥有 provider、processor、reader 和关闭。未配置 SDK 时 API 使用 no-op。

var tracer = otel.Tracer(
	"example.com/article/internal/article",
	trace.WithInstrumentationVersion("v1.8.0"),
)

func Publish(ctx context.Context, id string) error {
	ctx, span := tracer.Start(ctx, "ArticleService.Publish")
	defer span.End()
	return publishToStore(ctx, id)
}

instrumentation scope 通常采用包路径,版本表示埋点库版本。全局 Provider 方便自动埋点;需要隔离的组件可注入 trace.TracerProvider

3. Resource 标识产生遥测的实体

Resource 属性附在所有信号上,至少定义 service.name,并统一 namespace、instance、version 与 deployment environment:

resourceValue, err := resource.Merge(
	resource.Default(),
	resource.NewWithAttributes(
		semconv.SchemaURL,
		semconv.ServiceName("article-api"),
		semconv.ServiceVersion("1.8.0"),
		attribute.String("deployment.environment.name", "production"),
	),
)
if err != nil {
	return fmt.Errorf("merge OpenTelemetry resource: %w", err)
}

Resource 在 provider 内不可变。升级 semconv 时检查字段和 schema URL,避免 dashboard 混用 key。

4. 初始化 OTLP Trace 管线

生产常通过 OTLP/gRPC 把数据发给本机或集群 Collector:

func newTracerProvider(ctx context.Context, res *resource.Resource) (*sdktrace.TracerProvider, error) {
	exporter, err := otlptracegrpc.New(ctx,
		otlptracegrpc.WithEndpoint("otel-collector.observability.svc:4317"),
		otlptracegrpc.WithTLSCredentials(credentials.NewTLS(&tls.Config{
			MinVersion: tls.VersionTLS12,
		})),
	)
	if err != nil {
		return nil, fmt.Errorf("new OTLP trace exporter: %w", err)
	}
	provider := sdktrace.NewTracerProvider(
		sdktrace.WithResource(res),
		sdktrace.WithSampler(sdktrace.ParentBased(sdktrace.TraceIDRatioBased(0.1))),
		sdktrace.WithBatcher(exporter,
			sdktrace.WithMaxQueueSize(2048),
			sdktrace.WithBatchTimeout(5*time.Second),
		),
	)
	return provider, nil
}

生产应校验证书和服务身份。BatchSpanProcessor 用有界队列隔离请求与网络;队列满会丢 span,不能改成无界队列或让请求等待后端恢复。

5. Span 名称、属性、事件与状态

Span 名称应是低基数操作,如 ArticleService.PublishGET /articles/{id},不能包含真实 ID。属性记录可聚合维度;事件描述 span 内某一时刻:

ctx, span := tracer.Start(ctx, "ArticleService.Publish",
	trace.WithSpanKind(trace.SpanKindInternal),
	trace.WithAttributes(attribute.String("article.state", "draft")),
)
defer span.End()

if err := repository.Publish(ctx, id); err != nil {
	span.RecordError(err)
	span.SetStatus(codes.Error, "repository publish")
	return fmt.Errorf("publish article %q: %w", id, err)
}
span.AddEvent("article published")

RecordError 不会自动设置 Error status。状态描述应稳定且不含秘密;404 是否为 Error 按协议统一约定。

6. Context 传播 Span 所有权

tracer.Start 返回含新 Span 的 context,必须传给下游;结束 Span 的函数拥有 defer span.End()。Context 是请求范围参数,不能保存在长期结构体。goroutine 若属于同一操作可继承 ctx,但必须响应取消并被等待;真正脱离请求的任务应建立独立有界生命周期和 link,而不是偷偷继续使用已取消 context。

Span 不是 error transport,业务函数仍返回 error,由边界记录一次。优先埋点网络、存储、队列和重要业务阶段,避免给纯内存小函数创建 span。

7. HTTP 自动埋点与路由名称

otelhttp v0.63.0 同时负责 W3C context 提取/注入和 HTTP span。服务端包装路由,客户端包装长期复用的 Transport:

handler := otelhttp.NewHandler(mux, "article-api",
	otelhttp.WithSpanNameFormatter(func(_ string, request *http.Request) string {
		return request.Method + " " + request.Pattern
	}),
)

client := &http.Client{
	Transport: otelhttp.NewTransport(http.DefaultTransport),
	Timeout:   5 * time.Second,
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
	return err
}
response, err := client.Do(request)

路由模板如 /articles/{id} 是低基数,原始路径不是。中间件顺序要保证路由模板已确定,同时认证拒绝也能产生合适 span。响应 body 仍由调用方关闭;instrumentation 不接管业务资源所有权。

8. Propagation 与信任边界

默认跨进程传播 W3C traceparenttracestate,需要行李时加 baggage

propagator := propagation.NewCompositeTextMapPropagator(
	propagation.TraceContext{},
	propagation.Baggage{},
)
otel.SetTextMapPropagator(propagator)

ctx = propagator.Extract(ctx, propagation.HeaderCarrier(request.Header))
propagator.Inject(ctx, propagation.HeaderCarrier(outbound.Header))

远端父上下文可伪造,不是认证凭据。Baggage 不能放 token、个人信息或大对象;对外边界删除未允许 key 并限制 header 大小。

迁移 B3 时可组合 propagator,但应规定兼容期和唯一输出格式,避免长期注入多套 header。

9. 异步消息的传播与 Span Link

生产者注入消息 header,消费者提取:

carrier := propagation.MapCarrier(message.Headers)
otel.GetTextMapPropagator().Inject(ctx, carrier)

consumerCtx := otel.GetTextMapPropagator().Extract(context.Background(), carrier)
consumerCtx, span := tracer.Start(consumerCtx, "article.events process",
	trace.WithSpanKind(trace.SpanKindConsumer),
)
defer span.End()

批量消费多个上游消息时使用 trace.WithLinks 关联多个 SpanContext 并限制数量。重试记录 attempt 和结果,不生成无限事件。

提取消息 header 失败时开始新 root,不能拒绝业务消息。trace 上下文也不能充当幂等键。

10. Metric Provider、Reader 与 Exporter

指标 SDK 通过 Instrument 记录测量,由 View 决定聚合,由 Reader 周期导出。OTLP push 示例:

exporter, err := otlpmetricgrpc.New(ctx,
	otlpmetricgrpc.WithEndpoint("otel-collector.observability.svc:4317"),
	otlpmetricgrpc.WithTLSCredentials(credentials.NewTLS(tlsConfig)),
)
if err != nil {
	return nil, fmt.Errorf("new OTLP metric exporter: %w", err)
}
reader := sdkmetric.NewPeriodicReader(exporter,
	sdkmetric.WithInterval(30*time.Second),
	sdkmetric.WithTimeout(10*time.Second),
)
provider := sdkmetric.NewMeterProvider(
	sdkmetric.WithResource(res),
	sdkmetric.WithReader(reader),
)

Prometheus 拉取模式使用另一 Reader,temporality 可能不同。Provider 属于进程;请求只使用 instrument 记录,不能重复注册同名指标。

11. Counter、Histogram、Gauge 与回调

Counter 记录单调增加数量,Histogram 记录延迟/大小分布,UpDownCounter 表示可增减在途量,Gauge 表示当前观测值:

meter := otel.Meter("example.com/article/internal/httpapi")
requests, err := meter.Int64Counter("article.http.server.requests")
if err != nil {
	return err
}
duration, err := meter.Float64Histogram("article.http.server.duration",
	metric.WithUnit("s"),
)
if err != nil {
	return err
}

started := time.Now()
requests.Add(ctx, 1, metric.WithAttributes(attribute.String("http.route", route)))
duration.Record(ctx, time.Since(started).Seconds(),
	metric.WithAttributes(attribute.String("http.route", route)),
)

单位使用 UCUM 约定,时间常用 s。异步 observable 回调可能被并发调用,不能做慢网络 I/O,也不能持有应用热锁;它只读取已维护的原子或受控快照。注册回调返回的 Registration 有生命周期,组件停止时应注销。

12. Cardinality 是指标设计的硬边界

每个属性组合都是时间序列。user_idarticle_id、原始 URL、错误文本、SQL 和 request ID 不能作为 metric attribute;使用路由模板、状态码类别、稳定错误码和有限枚举。高基数 ID 仅进入受控 span 或日志。

SDK View 可删除属性、改聚合和 histogram 桶,Collector 也能过滤;但进入 SDK 后已消耗内存,最好在源头治理。关键指标应预估维度笛卡尔积。

Histogram 边界应对应 SLO;变更会影响历史可比性,应同步 dashboard 和告警。

13. Log 信号与 Trace 关联

Go 应用可通过 OTel Log Bridge 把 slog Record 转换为 OTel log record,或者继续输出结构化 stdout,由 Collector 的 file/stdout receiver 采集。无论路径如何,日志中加入当前 trace/span ID 才能可靠跳转:

func traceAttrs(ctx context.Context) []slog.Attr {
	spanContext := trace.SpanContextFromContext(ctx)
	if !spanContext.IsValid() {
		return nil
	}
	return []slog.Attr{
		slog.String("trace_id", spanContext.TraceID().String()),
		slog.String("span_id", spanContext.SpanID().String()),
	}
}

logger.LogAttrs(ctx, slog.LevelError, "publish article",
	append(traceAttrs(ctx), slog.String("error_code", "repository_unavailable"))...,
)

日志版本线可能与 trace/metric 不同,升级前确认稳定级别。应用 OTLP 与 Collector 采 stdout 二选一,避免重复;日志和 span 不复制整个 payload。

14. Sampling:头采样、父采样与尾采样

AlwaysOn 适合低流量开发,TraceIDRatioBased 按 trace ID 比例稳定采样,ParentBased 遵循入口决策并让一条链保持完整。头采样在请求开始时决定,成本低,但尚不知道最终是否错误或缓慢。采样率 10% 不意味着每分钟每个小租户都精确 10%。

尾采样由 Collector 等待完整或超时的 trace 后按结果选择,可保留错误、慢请求和少量正常基线,但需要内存保存候选 spans,还要让同一 trace 的数据路由到同一决策点。尾采样前应用通常保持 AlwaysOn 或足够高的头采样,否则源头丢掉的数据无法恢复。

采样位会传播,但应防止不可信上游强制全采。记录接收、丢弃和决策延迟;指标不能按 trace 采样,日志采样须保留审计与严重错误。

15. Collector 的接收、处理和导出管线

Collector 集中处理 batching、内存保护、重试、属性治理和路由:

receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
      http:
        endpoint: 0.0.0.0:4318

processors:
  memory_limiter:
    check_interval: 1s
    limit_mib: 512
    spike_limit_mib: 128
  batch:
    send_batch_size: 8192
    timeout: 5s
  attributes/redact:
    actions:
      - key: http.request.header.authorization
        action: delete

exporters:
  otlp/backend:
    endpoint: telemetry.example.com:4317
    tls:
      insecure: false

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, attributes/redact, batch]
      exporters: [otlp/backend]
    metrics:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [otlp/backend]
    logs:
      receivers: [otlp]
      processors: [memory_limiter, attributes/redact, batch]
      exporters: [otlp/backend]

处理器顺序有语义:内存限制靠前,batch 靠后。配置用 0.133.0 镜像验证,并监控 Collector 队列、拒收、发送失败和内存。

16. Shutdown、Flush 与失败隔离

入口拥有 provider。先停流量并等待业务,再用新的有界 context 反向关闭:

func shutdownTelemetry(tp *sdktrace.TracerProvider, mp *sdkmetric.MeterProvider) error {
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	var errs []error
	if err := tp.Shutdown(ctx); err != nil {
		errs = append(errs, fmt.Errorf("shutdown tracer provider: %w", err))
	}
	if err := mp.Shutdown(ctx); err != nil {
		errs = append(errs, fmt.Errorf("shutdown meter provider: %w", err))
	}
	return errors.Join(errs...)
}

ForceFlush 不应每请求调用。崩溃无法保证清空队列;普通观测通常 fail open 并告警,审计场景可按策略 fail closed。

17. 测试埋点而不依赖真实后端

SDK 内存 exporter/reader 可测试 span 和父子关系;传播可用 carrier 做 round trip:

func TestTraceContextPropagation(t *testing.T) {
	propagator := propagation.TraceContext{}
	spanContext := trace.NewSpanContext(trace.SpanContextConfig{
		TraceID:    trace.TraceID{1},
		SpanID:     trace.SpanID{2},
		TraceFlags: trace.FlagsSampled,
		Remote:     true,
	})
	ctx := trace.ContextWithRemoteSpanContext(context.Background(), spanContext)
	carrier := propagation.MapCarrier{}
	propagator.Inject(ctx, carrier)

	got := trace.SpanContextFromContext(propagator.Extract(context.Background(), carrier))
	if got.TraceID() != spanContext.TraceID() {
		t.Fatalf("trace ID = %s, want %s", got.TraceID(), spanContext.TraceID())
	}
}

CI 验证 Collector 配置并做 OTLP smoke test。测试必须关闭 provider,避免 batch goroutine 泄漏。

18. 性能与故障诊断

埋点成本来自属性分配、聚合、队列和序列化。用 benchmark/profile 测量;昂贵属性仅在 span.IsRecording() 时构造。

“没有 trace”依次检查 provider、propagator、采样、SDK drop、Exporter TLS/DNS、Collector receiver/processor/exporter 和查询时间。断链检查新 ctx、Transport 与消息 header;指标爆炸先找新增属性,再用 View 止血。

19. 安全、CI 与生产发布

遥测可能含 query、SQL、header 和用户数据。源头按允许列表采集,Collector 再删除;OTLP 使用 TLS/mTLS、最小权限和网络策略,管理端口不对公网开放。

CI 固定所有模块和 Collector 镜像,运行:

go test ./...
go test -race ./...
go vet ./...
go mod verify
otelcol-contrib validate --config=./deploy/otel-collector.yaml

发布先在 canary 比较延迟、资源、三类信号发送率、拒绝和费用。可用系统应能跨服务关联,采样和基数有预算,Collector 故障不拖垮业务,且敏感数据不离开信任边界。


系列导航与关联阅读

官方资料

本文依据 Go 官方规范、标准库文档和 Go 官方博客重新梳理;正文与示例由 WR BLOG 编写。