GORM
Instrument GORM v2 applications with the official OpenTelemetry plugin and pair it with the Last9 Go Agent's database wrapper for two-layer tracing
For GORM v2, use the official gorm.io/plugin/opentelemetry tracing plugin. The Last9 Go Agent does not ship a GORM wrapper — the upstream plugin is maintained alongside GORM itself, ships current OpenTelemetry semantic conventions, and emits connection-pool metrics by default.
Pair the upstream plugin with integrations/database from last9/go-agent and you get a two-layer trace per query:
Gin / HTTP server span └─ select users (gorm.io/plugin/opentelemetry — db.system.name, db.collection.name, db.query.text) └─ SELECT users (integrations/database — wire-level SQL, rows affected)The GORM span (named after the operation and table — select users, insert users, etc.) carries the OTel database semantic-convention attributes; the integrations/database span underneath carries the wire-level SQL.
Prerequisites
- Go 1.22 or higher
- GORM v2 (
gorm.io/gorm) - A SQL driver (
github.com/lib/pq,github.com/jackc/pgx/v5,github.com/go-sql-driver/mysql, …) - Last9 account with OTLP credentials
Installation
-
Install the agent and the upstream tracing plugin
go get github.com/last9/go-agentgo get gorm.io/plugin/opentelemetry -
Set environment variables
export OTEL_SERVICE_NAME="your-service"export OTEL_EXPORTER_OTLP_ENDPOINT="$last9_otlp_endpoint"export OTEL_EXPORTER_OTLP_HEADERS="Authorization=$last9_otlp_auth_header"export OTEL_TRACES_SAMPLER="always_on"export OTEL_RESOURCE_ATTRIBUTES="deployment.environment=production" -
Wire the two-layer instrumentation
package mainimport ("log"agent "github.com/last9/go-agent""github.com/last9/go-agent/integrations/database"_ "github.com/lib/pq" // register the "postgres" driver"gorm.io/driver/postgres""gorm.io/gorm""gorm.io/plugin/opentelemetry/tracing")func main() {if err := agent.Start(); err != nil {log.Fatalf("agent: %v", err)}defer agent.Shutdown()// Layer 1: SQL driver instrumentation.sqlDB, err := database.Open(database.Config{DriverName: "postgres",DSN: "postgres://user:pass@host:5432/db?sslmode=disable",})if err != nil {log.Fatalf("open sql: %v", err)}defer sqlDB.Close()// Layer 2: official GORM tracing plugin. Hand the otelsql-wrapped// *sql.DB to the dialector via Conn so GORM uses the instrumented// connection pool.db, err := gorm.Open(postgres.New(postgres.Config{Conn: sqlDB}), &gorm.Config{})if err != nil {log.Fatalf("gorm open: %v", err)}if err := db.Use(tracing.NewPlugin()); err != nil {log.Fatalf("install tracing plugin: %v", err)}// … run your application. Pass request contexts via db.WithContext(ctx).} -
Propagate context per request
GORM operations only attach to the parent trace if you pass a context with the active span:
func ListUsers(c *gin.Context) {var users []Userif err := db.WithContext(c.Request.Context()).Find(&users).Error; err != nil {c.JSON(500, gin.H{"error": err.Error()})return}c.JSON(200, users)}
What you get on every span
| Attribute | Source |
|---|---|
db.system.name | dialector — postgresql, mysql, sqlite, mssql, clickhouse |
db.operation.name | parsed from the SQL — select, insert, update, delete |
db.collection.name | Statement.Table (the table name) |
db.query.text | rendered SQL, with bound variables by default |
db.query.summary | <operation> <table> (also used as the span name) |
db.rows_affected | Statement.RowsAffected (only when ≥ 0) |
server.address | extracted from the dialector for postgres / mysql / clickhouse |
Span name: <operation> <table> (e.g. select users, insert users). Span kind: Client. Span status is Error for everything except gorm.ErrRecordNotFound, sql.ErrNoRows, io.EOF, and driver.ErrSkip — a 404 lookup does not pollute your error rate.
The plugin also registers connection-pool gauges (go.sql.connections_open, _idle, _in_use, _wait_count, _wait_duration, _max_open, and others) by default.
Plugin options
The upstream plugin exposes the following functional options. See the plugin source for the authoritative list.
tracing.WithTracerProvider(tp trace.TracerProvider)tracing.WithAttributes(attrs ...attribute.KeyValue) // static extrastracing.WithDBSystem(name string) // override db.system.nametracing.WithoutQueryVariables() // strip bound varstracing.WithQueryFormatter(fn func(string) string) // redact / transform SQLtracing.WithoutMetrics() // suppress connection-pool gaugestracing.WithoutServerAddress() // suppress server.addresstracing.WithRecordStackTrace() // attach a stack trace to error spansTrace shape
A request that hits a Gin handler and runs one query produces three spans:
GET /users SERVER (gin)└─ select users CLIENT (gorm.io/plugin/opentelemetry) └─ SELECT CLIENT (integrations/database / otelsql)End-to-end example
A complete docker-compose stack with Postgres, the Go agent, GORM, and the upstream tracing plugin lives at opentelemetry-examples/go/gorm.
Troubleshooting
- Span attributes show
db.query.textparameterized rather than rendered. That’stracing.WithoutQueryVariables()taking effect — remove it (or yourWithQueryFormatter) to capture full SQL. - No GORM spans on requests, but SQL spans still appear. You probably forgot
db.WithContext(ctx)in the handler — without it, GORM usescontext.Background()and the span has no parent. - Connection-pool gauges appear unexpectedly. Upstream emits them by default. Set
tracing.WithoutMetrics()to suppress.
Please get in touch with us on Discord or Email if you have any questions.