Skip to content
Last9
Book demo

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

  1. Install the agent and the upstream tracing plugin

    go get github.com/last9/go-agent
    go get gorm.io/plugin/opentelemetry
  2. 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"
  3. Wire the two-layer instrumentation

    package main
    import (
    "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).
    }
  4. 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 []User
    if 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

AttributeSource
db.system.namedialector — postgresql, mysql, sqlite, mssql, clickhouse
db.operation.nameparsed from the SQL — select, insert, update, delete
db.collection.nameStatement.Table (the table name)
db.query.textrendered SQL, with bound variables by default
db.query.summary<operation> <table> (also used as the span name)
db.rows_affectedStatement.RowsAffected (only when ≥ 0)
server.addressextracted 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 extras
tracing.WithDBSystem(name string) // override db.system.name
tracing.WithoutQueryVariables() // strip bound vars
tracing.WithQueryFormatter(fn func(string) string) // redact / transform SQL
tracing.WithoutMetrics() // suppress connection-pool gauges
tracing.WithoutServerAddress() // suppress server.address
tracing.WithRecordStackTrace() // attach a stack trace to error spans

Trace 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.text parameterized rather than rendered. That’s tracing.WithoutQueryVariables() taking effect — remove it (or your WithQueryFormatter) 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 uses context.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.