gorm v1.25+ 集成 opentelemetry 必须显式启用 gorm.withopentelemetry(),需注册插件、透传 context、配置 span 名格式化,并避免与 otelsql 冲突。

GORM v1.25+ 必须显式启用 gorm.WithOpentelemetry()
旧版 GORM(v1.24 及以前)不支持 OpenTelemetry,升级到 v1.25+ 是前提。启用不是自动的——即使你已全局注册了 TracerProvider,GORM 仍默认走原生 SQL 路径,完全绕过 OTel。必须在 gorm.Open() 时传入 gorm.WithOpentelemetry() 选项:
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
// 其他配置...
Plugins: []gorm.Plugin{opentelemetry.NewPlugin(opentelemetry.WithTracerProvider(tp))},
})
注意:opentelemetry.NewPlugin 来自 go.opentelemetry.io/contrib/instrumentation/gorm,不是 GORM 自带的;若漏掉这个 import 和 plugin 注册,所有 SQL 操作都不会生成 span。
- 别用
gorm.Open(...).Use(opentelemetry.NewPlugin(...))——Use()在连接建立后才执行,部分初始化 SQL(如迁移)已逃逸追踪 - 若同时用了
otelsql.InjectDriver,二者会冲突:GORM 插件内部已封装驱动层 hook,重复注入会导致 span 重复、duration 异常或 panic -
WithTracerProvider(tp)中的tp必须是你在main()开头创建并设置的全局TracerProvider,不能临时 new
SQL span 名默认高基数,必须用 WithSpanNameFormatter 固定
GORM 默认把完整 SQL(含参数值)作为 span name,比如 SELECT * FROM users WHERE email = 'a@b.com'。这会导致 backend(Jaeger/Tempo)因高基数崩溃或查询极慢。
正确做法是用 opentelemetry.WithSpanNameFormatter 抽象为模板名:
opentelemetry.NewPlugin(
opentelemetry.WithTracerProvider(tp),
opentelemetry.WithSpanNameFormatter(func(ctx context.Context, operation string, query string) string {
return "gorm." + operation // 如 "gorm.SELECT"
}),
)
- 避免在 formatter 中拼接参数值、ID 或用户输入字段
- 保留
operation(SELECT/UPDATE/EXEC等)足够区分行为,又不引入变量 - 若需进一步区分表,可用正则从
query提取表名,但务必加白名单校验,防注入
context 必须透传到 GORM 方法调用链中
GORM 不读 context.Background(),也不自动从调用栈继承 context。如果你写 db.First(&u, 1),它实际用的是 context.TODO(),span 会断成 root。
所有查询、事务、预加载操作都必须显式传入带 span 的 ctx:
ctx := r.Context() // 从 HTTP handler 拿
db.WithContext(ctx).First(&u, 1)
db.WithContext(ctx).Model(&User{}).Where("age > ?", 18).Select("name").Find(&users)
tx := db.WithContext(ctx).Begin()
tx.Create(&order)
tx.Commit()
- 别在 goroutine 里直接用
db.First()—— 即使外层有 ctx,goroutine 不继承,必须go doWork(db.WithContext(ctx))或go func(ctx context.Context) { ... }(ctx) - 事务内嵌套查询也得保持同一 ctx,否则子 span parent_id 为空
- 日志打点若想带 trace_id,也要用
log.WithContext(ctx)(需适配 otellog)
本地调试时用 stdoutexporter 直接看 span 输出
别一上来就配 OTLP collector。GORM 埋点是否生效,最快验证方式是让 span 打到 stdout:
exp, _ := stdouttrace.NewUnstarted(stdouttrace.WithPrettyPrint())
tp := sdktrace.NewTracerProvider(
sdktrace.WithSampler(sdktrace.AlwaysSample()),
sdktrace.WithSyncer(exp),
)
otel.SetTracerProvider(tp)
otel.SetTextMapPropagator(propagation.TraceContext{})
exp.Start() // 必须手动 start,否则无输出
发起一个带 SQL 的请求,终端立刻看到类似:
{
"Name": "gorm.SELECT",
"TraceID": "0123456789abcdef0123456789abcdef",
"SpanID": "abcdef0123456789",
"ParentSpanID": "1234567890abcdef",
"Attributes": {"db.statement":"SELECT * FROM `users` WHERE `users`.`id` = ? ORDER BY `users`.`id` LIMIT 1"}
}
- 没看到 JSON?说明
TracerProvider没注册,或 GORM 插件没启用,或WithContext()漏了 - 看到
"TraceID": "00000000000000000000000000000000"?说明 context 未透传,span 是孤立的 - 看到
"db.statement"含敏感值?说明WithSpanNameFormatter没生效,或 formatter 返回了原始 query
GORM 接入 OpenTelemetry 最容易被忽略的点,是以为“装了插件就完事”,结果 span 全是空 trace_id 或 duration 为 0——问题往往出在 WithContext() 漏传、formatter 配错,或者和 otelsql 驱动混用。调试时别猜,先让 stdout 把 span 打出来,一眼定位断在哪一环。











