上次说了「别再盲接 OTel」,很多人留言问:那我们到底该怎么做?
trace、metrics、log 都接上了,可是问题一来,大家还是靠 grep + intuition(拍脑袋)。
这篇就聊聊: 可观察性的闭环,怎么从"接入"走到"洞察"。
适合已经接入 OTel 的多服务 Go 团队,目标是把这三件套从"上报数据"变成"驱动决策"。
核心概念 :
实战落地 :
高级场景 :
一个完整的可观测性闭环应该是这样流转的:
[采集] Trace/Metrics/Logs 统一标准化采集
↓
[关联] 通过 trace_id/span_id 打通三信号
↓
[告警] Metrics 触发 SLO/SLI 告警
↓
[回溯] 自动聚合相关 Trace + 关联 Logs 上下文
↓
[复盘] 生成改进项 → 代码/配置变更
↓
[验证] 复查指标/告警是否回归 → 形成证据链
关键联动点 :
只有形成这样的"数据→推理→行动→验证"循环,才算真正进入可观测性闭环。
大多数 Go 服务 trace 出来后像一盘意大利面:几百个 span,没有主次,没有业务含义。
这是因为埋点方式太"技术化"了,只围绕函数,不围绕业务。
坏例子:
span := tracer.Start(ctx, "ProcessRequest")
好例子:
span := tracer.Start(ctx, "OrderService.PlaceOrder")
区别在于:第一个告诉你调用了哪个函数,第二个告诉你系统在干什么。
所有 trace 数据必须携带标准资源属性,便于跨服务聚合与过滤:
import (
"context"
"fmt"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/sdk/resource"
semconv "go.opentelemetry.io/otel/semconv/v1.21.0"
)
res, err := resource.New(context.Background(),
resource.WithAttributes(
semconv.ServiceName("order-svc"),
semconv.ServiceVersion("1.2.3"),
semconv.DeploymentEnvironment("prod"),
attribute.String("team", "payments"),
),
)
if err != nil {
return fmt.Errorf("failed to create resource: %w", err)
}
命名规范
:采用
<Domain>.<Service>.<Action>
格式,团队内保持统一。
// 推荐格式示例
span := tracer.Start(ctx, "Payment.OrderService.CreateOrder")
span := tracer.Start(ctx, "Inventory.StockService.ReserveItem")
错误记录标准
:使用
RecordError
+
SetStatus
,区分业务校验失败与系统错误。
import "go.opentelemetry.io/otel/codes"
defer func() {
if err != nil {
span.RecordError(err)
if errors.Is(err, ErrBusinessValidation) {
span.SetStatus(codes.Error, "business_validation_failed")
} else {
span.SetStatus(codes.Error, "system_error")
}
} else {
span.SetStatus(codes.Ok, "")
}
}()
HTTP 与 gRPC 自动注入 :
import (
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
)
// HTTP Server
mux := http.NewServeMux()
handler := otelhttp.NewHandler(mux, "http.server")
// gRPC Server
grpcServer := grpc.NewServer(
grpc.UnaryInterceptor(otelgrpc.UnaryServerInterceptor()),
)
跨 Goroutine 传递
:务必显式传递
context
,否则 trace 断链。
// 错误:context 未传递
go func() {
span := tracer.Start(context.Background(), "async.task") // ❌ 断链
}()
// 正确:显式传递 context
go func(ctx context.Context) {
span := tracer.Start(ctx, "async.task") // ✅ 保持链路
defer span.End()
}(ctx)
常见边界场景 :
// 1. HTTP Client 调用(常见遗漏点)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil) // ✅
// 而不是 http.NewRequest(),后者无法传递 trace context
// 2. 定时任务场景
ticker := time.NewTicker(5 * time.Second)
for range ticker.C {
// ❌ 每次都新建 context,trace 断链
// ctx := context.Background()
// ✅ 从根 context 派生带超时的 context
taskCtx, cancel := context.WithTimeout(rootCtx, 30*time.Second)
go processTask(taskCtx)
cancel()
}
// 3. 数据库查询
// ✅ 使用带 context 的方法
rows, err := db.QueryContext(ctx, query, args...)
// 而不是 db.Query()
静态采样 (简单场景):
import "go.opentelemetry.io/otel/sdk/trace"
tp := trace.NewTracerProvider(
trace.WithSampler(trace.TraceIDRatioBased(0.1)), // 10% 采样率
)
动态采样 (进阶):
// 伪代码示例:自定义采样器
type SmartSampler struct{}
func (s *SmartSampler) ShouldSample(p trace.SamplingParameters) trace.SamplingResult {
// 错误 span 全量保留
if hasError(p.Attributes) {
return trace.SamplingResult{Decision: trace.RecordAndSample}
}
// 健康检查低采样
if isHealthCheck(p.Name) {
return trace.SamplingResult{Decision: trace.Drop}
}
// 其他路径按比例
return trace.TraceIDRatioBased(0.1).ShouldSample(p)
}
在微服务架构中,除了 trace_id,我们常需要传递业务标识(如 tenant_id、user_id)。Baggage 是 OTel 的标准解决方案。
import "go.opentelemetry.io/otel/baggage"
// 上游服务:注入业务上下文
member, _ := baggage.NewMember("tenant.id", tenantID)
bag, _ := baggage.New(member)
ctx = baggage.ContextWithBaggage(ctx, bag)
// 下游服务:提取业务上下文
bag := baggage.FromContext(ctx)
tenantID := bag.Member("tenant.id").Value()
// 实战场景:多租户 SaaS
func (s *OrderService) CreateOrder(ctx context.Context, req *OrderRequest) error {
// 从 baggage 提取租户信息
tenantID := baggage.FromContext(ctx).Member("tenant.id").Value()
// 记录到 span 属性(方便查询)
span := trace.SpanFromContext(ctx)
span.SetAttributes(attribute.String("tenant.id", tenantID))
// 记录到日志(关联分析)
logger.Ctx(ctx).Info("creating order", zap.String("tenant_id", tenantID))
// 业务逻辑...
return s.repo.Create(ctx, tenantID, req)
}
注意事项 :
tenant.id
而非
tenantId
)
在异步消息场景(Kafka、RabbitMQ、NATS 等),trace context 需要通过消息头传播。
import (
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/propagation"
"github.com/segmentio/kafka-go"
)
// Kafka Producer:注入 trace context
// writer 是已初始化的 *kafka.Writer,按你的项目实际注入
func PublishEvent(ctx context.Context, topic string, event []byte) error {
tracer := otel.Tracer("kafka-producer")
ctx, span := tracer.Start(ctx, "kafka.publish")
defer span.End()
// 创建 Kafka 消息
msg := kafka.Message{
Topic: topic,
Value: event,
}
// 注入 trace context 到消息头
propagator := otel.GetTextMapPropagator()
carrier := propagation.MapCarrier{}
propagator.Inject(ctx, carrier)
// 将 carrier 转换为 Kafka Headers
for k, v := range carrier {
msg.Headers = append(msg.Headers, kafka.Header{
Key: k,
Value: []byte(v),
})
}
// 发送消息
return writer.WriteMessages(ctx, msg)
}
// Kafka Consumer:提取 trace context
func ConsumeEvent(msg kafka.Message) error {
// 从消息头提取 trace context
carrier := propagation.MapCarrier{}
for _, h := range msg.Headers {
carrier.Set(h.Key, string(h.Value))
}
propagator := otel.GetTextMapPropagator()
ctx := propagator.Extract(context.Background(), carrier)
// 创建新的 span(继承上游 trace)
tracer := otel.Tracer("kafka-consumer")
ctx, span := tracer.Start(ctx, "kafka.consume")
defer span.End()
span.SetAttributes(
attribute.String("messaging.system", "kafka"),
attribute.String("messaging.destination", msg.Topic),
attribute.Int("messaging.partition", msg.Partition),
)
// 处理业务逻辑(传递 ctx)
return handleEvent(ctx, msg.Value)
}
RabbitMQ 场景 :
import (
"github.com/streadway/amqp"
"go.opentelemetry.io/otel/propagation"
)
// RabbitMQ Producer
// channel 是已初始化的 *amqp.Channel,按你的项目实际注入
func PublishToQueue(ctx context.Context, exchange, routingKey string, body []byte) error {
propagator := otel.GetTextMapPropagator()
carrier := propagation.MapCarrier{}
propagator.Inject(ctx, carrier)
// 转换为 AMQP Headers
headers := amqp.Table{}
for k, v := range carrier {
headers[k] = v
}
return channel.Publish(exchange, routingKey, false, false, amqp.Publishing{
Headers: headers,
Body: body,
})
}
// RabbitMQ Consumer
func HandleDelivery(d amqp.Delivery) error {
carrier := propagation.MapCarrier{}
for k, v := range d.Headers {
if str, ok := v.(string); ok {
carrier.Set(k, str)
}
}
propagator := otel.GetTextMapPropagator()
ctx := propagator.Extract(context.Background(), carrier)
// 处理消息
return processMessage(ctx, d.Body)
}
几个容易踩的坑:
span.SetAttributes(attribute.String("order.id", id))
otelhttp
/
otelgrpc
自动注入,少手动埋点
context
,不然链路断了
很多团队 metrics 量超多,但没有
决策价值
。
一个真正成熟的 metrics 系统应该有三层含义:
最关键的是第三层。
业务指标才是团队理解系统的桥梁。
光靠技术指标,你永远不知道"用户下单慢"到底是 Redis 卡了还是代码逻辑傻了。
RED 方法 (面向请求的服务):
USE 方法 (面向资源):
指标仪表(Counter/Histogram/Gauge)初始化时创建一次就够了,别在请求里反复创建。
import (
"go.opentelemetry.io/otel/metric"
)
lat := meter.Float64Histogram(
"http.server.duration",
metric.WithUnit("s"),
metric.WithDescription("HTTP request duration"),
)
start := time.Now()
// ... 处理请求 ...
lat.Record(ctx, time.Since(start).Seconds(),
metric.WithAttributes(
attribute.String("route", "/orders"),
attribute.String("method", "POST"),
attribute.Int("status", 200),
),
)
启用 Exemplars :在 Prometheus/Grafana 中,点击直方图某个桶可直接跳转到对应 Trace,实现"指标→链路"一跳到达。
错误示例 (高基数标签):
// ❌ user_id/request_id 会导致指标爆炸
metric.WithAttributes(
attribute.String("user_id", uid), // 百万级基数
attribute.String("request_id", reqID), // 无限基数
)
正确做法 :
// ✅ 只保留低基数维度
metric.WithAttributes(
attribute.String("route", "/orders"), // 有限路由
attribute.String("method", "POST"), // 有限方法
attribute.Int("status_class", 2), // 2xx/4xx/5xx
)
// user_id/request_id 应记录在 Trace 属性或 Logs 中
定义 SLO :99.9% 的请求延迟 < 500ms(30 天窗口)
烧蚀率告警 (多窗口):
这样可以在 SLO 真正耗尽前发现异常,避免告警风暴。
Prometheus 告警规则示例 :
# prometheus/alerts/slo.yml
groups:
- name: slo_burn_rate_alerts
interval: 30s
rules:
# 快速烧蚀率告警(1小时窗口)
- alert: HighErrorBurnRate_1h
expr: |
(
sum(rate(http_requests_total{status=~"5.."}[1h]))
/
sum(rate(http_requests_total[1h]))
) > 0.0144
for: 2m
labels:
severity: critical
slo: availability
annotations:
summary: "SLO 快速烧蚀 (1小时窗口)"
description: "错误率 {{ $value | humanizePercentage }},超过 14.4 倍预算消耗"
runbook_url: "https://wiki.company.com/runbook/high-error-rate"
# 慢速烧蚀率告警(6小时窗口)
- alert: MediumErrorBurnRate_6h
expr: |
(
sum(rate(http_requests_total{status=~"5.."}[6h]))
/
sum(rate(http_requests_total[6h]))
) > 0.006
for: 15m
labels:
severity: warning
slo: availability
annotations:
summary: "SLO 持续烧蚀 (6小时窗口)"
description: "错误率 {{ $value | humanizePercentage }},超过 6 倍预算消耗"
# P99 延迟 SLO 告警
- alert: HighLatencyBurnRate
expr: |
histogram_quantile(0.99,
sum(rate(http_server_duration_bucket[5m])) by (le, route)
) > 0.5
for: 5m
labels:
severity: warning
slo: latency
annotations:
summary: "P99 延迟超过 SLO (500ms)"
description: "路由 {{ $labels.route }} 的 P99 延迟: {{ $value }}s"
注意指标名映射:
http.server.duration
在 Prometheus 里会变成
http_server_duration_seconds_bucket
(点变下划线,追加单位后缀)。
几个实战要点:
Go 开发者常犯的错是"全都打 log":
fmt.Println
、
log.Printf
、
zap.Sugar()
混用。
一旦接入 OTel + Loki,结果就是日志噪音爆炸。
要做的不是 多日志 ,而是 一体化上下文 :
所有日志必须包含以下标准字段:
不推荐 (手动拼接 trace 字段):
logger := zap.L().With(
zap.String("trace_id", trace.SpanContextFromContext(ctx).TraceID().String()),
zap.String("span_id", trace.SpanContextFromContext(ctx).SpanID().String()),
)
logger.Info("user payment timeout", zap.String("order_id", oid))
推荐
(使用
otelzap
或类似库):
import "github.com/uptrace/opentelemetry-go-extra/otelzap"
// 初始化(一次)
logger := otelzap.New(zap.L())
// 使用(自动注入 trace_id/span_id)
logger.Ctx(ctx).Info("user payment timeout",
zap.String("order_id", oid),
zap.String("amount", "99.99"),
)
敏感字段必须脱敏或哈希 :
// ❌ 明文记录敏感信息
logger.Info("user login", zap.String("phone", "13800138000"))
// ✅ 哈希或部分遮蔽
logger.Info("user login", zap.String("phone_hash", hashPhone("13800138000")))
logger.Info("user login", zap.String("phone_masked", "138****8000"))
白名单机制 :只记录预定义的业务字段,禁止直接打印完整请求体/响应体。
在 Grafana/Tempo 中配置:
trace_id
的日志(Loki)
Grafana 配置示例 (datasource 关联):
# grafana datasources
- name: Tempo
type: tempo
uid: tempo
jsonData:
tracesToLogs:
datasourceUid: 'loki'
tags: ['trace_id']
生产环境日志量可能非常大,特别是高 QPS 热路径。合理的日志采样策略至关重要。
import (
"go.uber.org/zap/zapcore"
"github.com/uptrace/opentelemetry-go-extra/otelzap"
)
// zap 动态采样配置
core := zapcore.NewSamplerWithOptions(
zapcore.NewCore(encoder, writer, zapcore.InfoLevel),
time.Second, // 采样时间窗口
100, // 窗口内初始允许日志条数
10, // 之后每秒允许的日志条数
)
base := zap.New(core)
logger := otelzap.New(base)
// 实战场景:热路径降噪
func (h *HealthHandler) Check(ctx context.Context) error {
// ✅ 健康检查只记录错误日志
if err := h.checkDatabase(ctx); err != nil {
logger.Ctx(ctx).Error("health check failed", zap.Error(err))
return err
}
// 成功的健康检查不打印日志(避免噪音)
return nil
}
// 核心业务路径:全量日志
func (s *OrderService) CreateOrder(ctx context.Context, req *OrderRequest) error {
logger.Ctx(ctx).Info("order creation started", zap.String("order_id", req.ID))
// ... 业务逻辑
logger.Ctx(ctx).Info("order creation completed")
return nil
}
采样策略建议 :
落地时注意几点:
zap
+
otelzap
trace_id
/
span_id
,别手动拼
很多人以为"trace + metrics + logs = observability"。
错。
这三者只是"输入信号",真正的可观察性来自"反馈"。
也就是说:
只有形成这种「数据 → 推理 → 调整」的循环,团队才算进入真正的"可观察性文化"。
当告警触发时,系统应自动执行以下动作:
trace_id
关联)
事件发生 → 根因分析(Trace+Logs)
↓
改进项(Action Items)
↓
代码/配置变更(PR/Config)
↓
验证回归(对比指标/告警是否消失)
↓
形成证据链(Post-Mortem 文档)
场景 :订单服务 P99 延迟突增
http.server.duration
P99 从 200ms 升至 2s
OrderService.QueryInventory
span 延迟 1.8s
inventory service timeout
做好这几步就成了:
最终,一个项目的可观察性不是靠框架,而是靠文化。
你得让每个工程师都能回答三个问题:
这三问,比任何 exporter 或 dashboard 都更值钱。
Level 1:接入阶段
Level 2:关联阶段
trace_id
自动关联
Level 3:闭环阶段
以下是 Go 项目接入 OpenTelemetry 的最小化配置骨架,可直接复制使用。
package observability
import (
"context"
"fmt"
"time"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
"go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.21.0"
)
// InitObservability 初始化 Tracer 和 Meter(生产级完整示例)
func InitObservability(ctx context.Context, serviceName, version, env string) (func(), error) {
// 1. 创建资源(注意错误处理)
res, err := resource.New(ctx,
resource.WithAttributes(
semconv.ServiceName(serviceName),
semconv.ServiceVersion(version),
semconv.DeploymentEnvironment(env),
),
)
if err != nil {
return nil, fmt.Errorf("failed to create resource: %w", err)
}
// 2. 初始化 TracerProvider
traceExporter, err := otlptracehttp.New(ctx,
otlptracehttp.WithEndpoint("localhost:4318"),
otlptracehttp.WithInsecure(),
)
if err != nil {
return nil, fmt.Errorf("failed to create trace exporter: %w", err)
}
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(traceExporter),
sdktrace.WithResource(res),
sdktrace.WithSampler(sdktrace.TraceIDRatioBased(0.1)), // 10% 采样
)
otel.SetTracerProvider(tp)
// 3. 初始化 MeterProvider
metricExporter, err := otlpmetrichttp.New(ctx,
otlpmetrichttp.WithEndpoint("localhost:4318"),
otlpmetrichttp.WithInsecure(),
)
if err != nil {
return nil, fmt.Errorf("failed to create metric exporter: %w", err)
}
mp := metric.NewMeterProvider(
metric.WithReader(
metric.NewPeriodicReader(metricExporter,
metric.WithInterval(10*time.Second), // 每 10 秒导出一次
),
),
metric.WithResource(res),
)
otel.SetMeterProvider(mp)
// 4. 返回清理函数(必须调用,否则数据丢失)
return func() {
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// 先关闭 TracerProvider
if err := tp.Shutdown(shutdownCtx); err != nil {
fmt.Printf("Error shutting down tracer provider: %v\n", err)
}
// 再关闭 MeterProvider
if err := mp.Shutdown(shutdownCtx); err != nil {
fmt.Printf("Error shutting down meter provider: %v\n", err)
}
}, nil
}
package main
import (
"context"
"log"
"net/http"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
"google.golang.org/grpc"
)
func main() {
ctx := context.Background()
// 初始化可观测性(正确处理错误)
cleanup, err := observability.InitObservability(ctx, "order-service", "1.0.0", "prod")
if err != nil {
log.Fatalf("Failed to initialize observability: %v", err)
}
defer cleanup() // 确保程序退出时清理资源
// HTTP Server
mux := http.NewServeMux()
mux.HandleFunc("/orders", handleOrders)
mux.HandleFunc("/health", handleHealth)
// 包装为 OTel 中间件
handler := otelhttp.NewHandler(mux, "http.server")
log.Println("Starting HTTP server on :8080")
if err := http.ListenAndServe(":8080", handler); err != nil {
log.Fatalf("HTTP server failed: %v", err)
}
// gRPC Server(如果需要)
grpcServer := grpc.NewServer(
grpc.UnaryInterceptor(otelgrpc.UnaryServerInterceptor()),
grpc.StreamInterceptor(otelgrpc.StreamServerInterceptor()),
)
// ... 注册服务 ...
}
方案一:Grafana Stack(生产级 docker-compose)
# docker-compose.yml
version: '3.8'
services:
# Tempo - 分布式追踪
tempo:
image: grafana/tempo:latest
command: ["-config.file=/etc/tempo.yaml"]
volumes:
- ./config/tempo.yaml:/etc/tempo.yaml
- tempo-data:/tmp/tempo
ports:
- "4318:4318" # OTLP HTTP
- "4317:4317" # OTLP gRPC
- "3200:3200" # Tempo UI
networks:
- observability
# Prometheus - 指标存储
prometheus:
image: prom/prometheus:latest
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.path=/prometheus'
- '--storage.tsdb.retention.time=15d'
volumes:
- ./config/prometheus.yml:/etc/prometheus/prometheus.yml
- ./config/alerts:/etc/prometheus/alerts
- prometheus-data:/prometheus
ports:
- "9090:9090"
networks:
- observability
# Loki - 日志聚合
loki:
image: grafana/loki:latest
command: ["-config.file=/etc/loki/config.yaml"]
volumes:
- ./config/loki.yaml:/etc/loki/config.yaml
- loki-data:/loki
ports:
- "3100:3100"
networks:
- observability
# Grafana - 可视化
grafana:
image: grafana/grafana:latest
environment:
- GF_AUTH_ANONYMOUS_ENABLED=true
- GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
- GF_SECURITY_ADMIN_PASSWORD=admin
volumes:
- ./config/grafana/datasources.yml:/etc/grafana/provisioning/datasources/datasources.yml
- grafana-data:/var/lib/grafana
ports:
- "3000:3000"
networks:
- observability
depends_on:
- tempo
- prometheus
- loki
volumes:
tempo-data:
prometheus-data:
loki-data:
grafana-data:
networks:
observability:
driver: bridge
最小配置文件 :
# config/tempo.yaml
server:
http_listen_port: 3200
distributor:
receivers:
otlp:
protocols:
http:
grpc:
storage:
trace:
backend: local
local:
path: /tmp/tempo/blocks
# config/prometheus.yml
global:
scrape_interval: 15s
evaluation_interval: 15s
# 加载告警规则
rule_files:
- "/etc/prometheus/alerts/*.yml"
scrape_configs:
- job_name: 'prometheus'
static_configs:
- targets: ['localhost:9090']
# config/loki.yaml
auth_enabled: false
server:
http_listen_port: 3100
ingester:
lifecycler:
ring:
kvstore:
store: inmemory
replication_factor: 1
schema_config:
configs:
- from: 2020-10-24
store: boltdb-shipper
object_store: filesystem
schema: v11
index:
prefix: index_
period: 24h
storage_config:
boltdb_shipper:
active_index_directory: /loki/index
cache_location: /loki/cache
filesystem:
directory: /loki/chunks
# config/grafana/datasources.yml
apiVersion: 1
datasources:
- name: Tempo
type: tempo
access: proxy
url: http://tempo:3200
uid: tempo
jsonData:
tracesToLogs:
datasourceUid: loki
tags: ['trace_id']
- name: Prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
uid: prometheus
isDefault: true
- name: Loki
type: loki
access: proxy
url: http://loki:3100
uid: loki
jsonData:
derivedFields:
- datasourceUid: tempo
# 若日志为 JSON,匹配形如 "trace_id":"<hex>" 的字段
matcherRegex: "\"trace_id\":\"([a-f0-9]+)\""
name: TraceID
url: '$${__value.raw}'
# 也可将 trace_id 作为 label 存储,在 tracesToLogs 配置中使用 labels 关联
启动命令 :
# 创建配置文件目录
mkdir -p config/grafana config/alerts
# 启动所有服务
docker-compose up -d
# 查看日志
docker-compose logs -f
# 访问 Grafana: http://localhost:3000
# 访问 Prometheus: http://localhost:9090
# 访问 Tempo: http://localhost:3200
方案二 :使用商业 SaaS(Datadog/New Relic/Honeycomb)
适合团队规模 > 50 人或不想维护基础设施的场景,按量计费,开箱即用。
按照 OTel 标准规范配置环境变量:
# .env - OpenTelemetry 标准环境变量
# 服务标识(通过 OTEL_RESOURCE_ATTRIBUTES 统一设置)
OTEL_RESOURCE_ATTRIBUTES=service.name=order-service,service.version=1.2.3,deployment.environment=prod,team=payments
# OTLP Exporter 配置(必须是完整 URL)
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
# 或分别设置 Trace 和 Metric 端点
# OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=http://tempo:4318
# OTEL_EXPORTER_OTLP_METRICS_ENDPOINT=http://prometheus:4318
# 采样配置
OTEL_TRACES_SAMPLER=traceidratio # 采样器类型
OTEL_TRACES_SAMPLER_ARG=0.1 # 10% 采样率
# Propagator 配置(默认 W3C Trace Context)
OTEL_PROPAGATORS=tracecontext,baggage # 支持 trace 和 baggage 传播
# SDK 禁用(用于调试)
# OTEL_SDK_DISABLED=false
# 日志级别
OTEL_LOG_LEVEL=info
在代码中使用环境变量 :
import (
"os"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
)
func InitFromEnv(ctx context.Context) (func(), error) {
// 1. 从环境变量读取配置(约定填写 host:port,不带 http://)
endpoint := os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT")
if endpoint == "" {
endpoint = "localhost:4318" // 默认值
}
// 2. 资源属性自动从 OTEL_RESOURCE_ATTRIBUTES 读取
res, err := resource.New(ctx,
resource.WithFromEnv(), // 自动读取环境变量
resource.WithTelemetrySDK(), // 添加 SDK 信息
resource.WithHost(), // 添加主机信息
)
if err != nil {
return nil, fmt.Errorf("failed to create resource: %w", err)
}
// 3. Exporter 配置
exporter, err := otlptracehttp.New(ctx,
otlptracehttp.WithEndpoint(endpoint), // 形如 "otel-collector:4318"
otlptracehttp.WithInsecure(),
)
// ... 其他初始化逻辑
}
Docker Compose 环境变量示例 :
services:
order-service:
image: order-service:latest
environment:
- OTEL_RESOURCE_ATTRIBUTES=service.name=order-service,service.version=1.2.3,deployment.environment=prod
- OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
- OTEL_TRACES_SAMPLER=traceidratio
- OTEL_TRACES_SAMPLER_ARG=0.1
- OTEL_PROPAGATORS=tracecontext,baggage
在实际落地过程中,以下是最容易踩的坑,请逐项自查:
service.name
或
service.version
(导致无法区分服务)
span.End()
或
defer span.End()
(导致 span 泄漏)
TracerProvider.Shutdown()
(导致数据丢失)
context
(导致 trace 断链)
user_id
/
request_id
作为指标标签(导致基数爆炸)
[0.1, 1, 10]
,无法区分 10ms 和 100ms)
http.duration
应指明
s
或
ms
)
fmt.Println
+
log
+
zap
)
trace_id
易出错)
logger.With()
)
BatchSpanProcessor
)
pprof
/eBPF 画像)
OTel SDK 的核心组件都是并发安全的:
// 以下对象可以全局共享,初始化时创建好就行
var (
tracer = otel.Tracer("my-service")
meter = otel.Meter("my-service")
requestsCounter metric.Int64Counter
)
func init() {
var err error
requestsCounter, err = meter.Int64Counter("http.server.requests")
if err != nil { panic(err) }
}
// 高并发下直接用
func handler(w http.ResponseWriter, r *http.Request) {
ctx, span := tracer.Start(r.Context(), "handle_request")
defer span.End()
requestsCounter.Add(ctx, 1, metric.WithAttributes(attribute.String("route", "/orders")))
}
但 Span 本身不是并发安全的
// 错误:多个 goroutine 操作同一个 span
ctx, span := tracer.Start(ctx, "parent")
go func() {
span.SetAttributes(attribute.String("key", "value")) // 数据竞争
}()
go func() {
span.RecordError(err) // 数据竞争
}()
// 正确:每个 goroutine 创建自己的 span
ctx, parentSpan := tracer.Start(ctx, "parent")
defer parentSpan.End()
go func(ctx context.Context) {
_, childSpan := tracer.Start(ctx, "child1")
defer childSpan.End()
childSpan.SetAttributes(attribute.String("key", "value"))
}(ctx)
go func(ctx context.Context) {
_, childSpan := tracer.Start(ctx, "child2")
defer childSpan.End()
childSpan.RecordError(err)
}(ctx)
资源清理最佳实践 :
func main() {
ctx := context.Background()
// 初始化
cleanup, err := observability.InitObservability(ctx, "svc", "1.0", "prod")
if err != nil {
log.Fatal(err)
}
// 方式一:defer 清理(推荐用于短生命周期进程)
defer cleanup()
// 方式二:信号捕获清理(推荐用于长生命周期服务)
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
go func() {
<-sigCh
log.Println("Shutting down gracefully...")
cleanup() // 确保数据导出完成
os.Exit(0)
}()
// 启动服务...
}
可观察性闭环的终点,不是让面板花哨,而是让团队决策变快。
当 trace、metrics、log 三者协同,你就能从"报警应急"走向"问题预测"。
记住三个关键词 :
可观测性不是"一次性项目",而是持续演进的工程文化。
当每个工程师都能自信回答"我的代码能被观测吗?"时,你就成功了。