Query names for tracing

On this page

On SQLite:

  • query_names: {enabled: true} adds query_names.go with QueryName(sql) string
  • store.New and NewWithReplica keep a DBTX that implements WrapDBTX, and WithTx uses it to wrap the *sql.Tx

A trace, a log line or a latency metric is far more useful labelled CoverShift than labelled with a paragraph of SQL. Every generated method runs its query through the DBTX you hand to store.New, so a DBTX that wraps the real connection sees every query the package makes. What it sees, though, is the SQL text. query_names generates a function that turns that text back into the query's name.

The example keeps a rota of shifts for harbour keepers, and logs the name of every query it runs, inside a transaction as well as outside.

Turning it on

query_names goes in the go: section of sgl.yaml:

sgl.yaml

# Copyright 2026 PolitePixels Limited
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

# This project stands against fascism, authoritarianism, and all forms of
# oppression. We built this to empower people, not to enable those who would
# strip others of their rights and dignity.

version: 1

databases:
  - name: rota
    dialect: sqlite
    migrations: db/migrations
    queries: db/queries
    go:
      package: store
      out: internal/store
      query_names:
        enabled: true

It adds one file to the package, query_names.go, with a QueryName function:

internal/store/query_names.go

// Code generated by sgl. DO NOT EDIT.

package store

var queryNameMap = map[string]string{addShiftSQL: "AddShift", coverShiftSQL: "CoverShift", listShiftsSQL: "ListShifts"}

func QueryName(query string) string {
	return queryNameMap[query]
}

The map is keyed by the same constants the generated methods pass to their DBTX, so the lookup is exact. Text that did not come from a generated query returns an empty string. The Seagull configuration reference also lists file: and func: keys, to rename the file and the function.

A tracing DBTX

The tracer lives in the program, not in the generated code, so it can log, start a span or record a metric as you prefer. In example.go it is a tracer struct holding the connection it wraps. Each of its three DBTX methods calls trace, which looks the SQL up with store.QueryName and prints the name, then passes the call on unchanged.

The queries themselves are ordinary. Nothing in them, or in the methods generated from them, knows they are being traced:

db/queries/shifts.sql

-- sgl.query(CoverShift, execrows)
UPDATE shifts SET covered = 1 WHERE id = ?;

Inside a transaction

A transaction is where tracing usually breaks. RunInTx binds the queries to the *sql.Tx it begins, and if that were all it did, the queries inside would run on the bare transaction and never reach the tracer.

So the generated code looks for one more method. When the DBTX passed to store.New also has WrapDBTX(inner any) any, New keeps it, and WithTx, which RunInTx uses, passes the transaction through it:

internal/store

type Queries struct {
	reader  DBTX
	writer  DBTX
	wrapper dbtxWrapper
}

type DBTX interface {
	ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
	QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
	QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
}

type dbtxWrapper interface {
	WrapDBTX(inner any) any
}

func (queries *Queries) WithTx(transaction *sql.Tx) *Queries {
	if queries == nil {
		return &Queries{reader: transaction, writer: transaction, wrapper: nil}
	}
	db := queries.wrapDBTX(transaction)
	return &Queries{reader: db, writer: db, wrapper: queries.wrapper}
}

The tracer's WrapDBTX returns a new tracer around the transaction, labelled tx instead of db. The same hook is how the prepared-statement cache follows queries into a transaction.

Using it

The program adds two shifts, then covers Monday's shift and adds a Wednesday shift in one transaction, and lists the rota.

example.go

package main

import (
	"context"
	"database/sql"
	"fmt"
	"io"

	"seagull.sh/seagull/examples/sqlite/026_tracing_queries/internal/store"
)

// tracer is a store.DBTX that logs the name of every query before running it on the
// connection it wraps. A real tracer would start a span or record a metric under the same
// name.
type tracer struct {
	// inner is the connection the queries run on: a *sql.DB, or a *sql.Tx once WrapDBTX has
	// wrapped one.
	inner store.DBTX

	// output receives a line per query.
	output io.Writer

	// scope labels each line with where the query ran: db or tx.
	scope string
}

// ExecContext traces the query, then runs it on the wrapped connection.
//
// Takes query (string) which is the SQL to run.
// Takes arguments (...any) which are its bind values.
//
// Returns sql.Result which is the database's result.
// Returns error when the statement fails.
func (t tracer) ExecContext(ctx context.Context, query string, arguments ...any) (sql.Result, error) {
	t.trace(query)
	return t.inner.ExecContext(ctx, query, arguments...)
}

// QueryContext traces the query, then runs it on the wrapped connection.
//
// Takes query (string) which is the SQL to run.
// Takes arguments (...any) which are its bind values.
//
// Returns *sql.Rows which holds the result rows.
// Returns error when the query fails.
func (t tracer) QueryContext(ctx context.Context, query string, arguments ...any) (*sql.Rows, error) {
	t.trace(query)
	return t.inner.QueryContext(ctx, query, arguments...)
}

// QueryRowContext traces the query, then runs it on the wrapped connection.
//
// Takes query (string) which is the SQL to run.
// Takes arguments (...any) which are its bind values.
//
// Returns *sql.Row which holds the result row or the error.
func (t tracer) QueryRowContext(ctx context.Context, query string, arguments ...any) *sql.Row {
	t.trace(query)
	return t.inner.QueryRowContext(ctx, query, arguments...)
}

// WrapDBTX returns a tracer around another connection. The generated WithTx, which
// RunInTx uses, calls it with the *sql.Tx, so queries in a transaction are traced too.
// Without it they would run on the bare *sql.Tx and go untraced.
//
// Takes inner (any) which is the connection to wrap.
//
// Returns any which is the wrapped connection, or inner unchanged when it is not a DBTX.
func (t tracer) WrapDBTX(inner any) any {
	connection, ok := inner.(store.DBTX)
	if !ok {
		return inner
	}
	return tracer{inner: connection, output: t.output, scope: "tx"}
}

// trace writes the query's name. go.query_names generates store.QueryName, which maps the
// SQL text every generated method passes to its DBTX back to the query's name.
//
// Takes query (string) which is the SQL about to run.
func (t tracer) trace(query string) {
	name := store.QueryName(query)
	if name == "" {
		name = "(not a generated query)"
	}
	fmt.Fprintf(t.output, "%s  %s\n", t.scope, name)
}

// run adds two shifts to a rota, then covers one and adds another in a transaction,
// tracing every query by name, and prints the rota.
//
// Takes database (*sql.DB) which is a migrated database.
// Takes output (io.Writer) which receives the trace and the rota.
//
// Returns error when a query fails.
func run(ctx context.Context, database *sql.DB, output io.Writer) error {
	queries := store.New(tracer{inner: database, output: output, scope: "db"})

	monday, err := queries.AddShift(ctx, store.AddShiftParams{Keeper: "Bryn", Day: "Monday"})
	if err != nil {
		return fmt.Errorf("adding Monday's shift: %w", err)
	}
	if _, err := queries.AddShift(ctx, store.AddShiftParams{Keeper: "Morwenna", Day: "Tuesday"}); err != nil {
		return fmt.Errorf("adding Tuesday's shift: %w", err)
	}

	err = queries.RunInTx(ctx, database, func(inTransaction *store.Queries) error {
		if _, err := inTransaction.CoverShift(ctx, monday.ID); err != nil {
			return err
		}
		_, err := inTransaction.AddShift(ctx, store.AddShiftParams{Keeper: "Bryn", Day: "Wednesday"})
		return err
	})
	if err != nil {
		return fmt.Errorf("swapping shifts: %w", err)
	}

	shifts, err := queries.ListShifts(ctx)
	if err != nil {
		return fmt.Errorf("listing shifts: %w", err)
	}
	for _, shift := range shifts {
		fmt.Fprintf(output, "%d %-9s %-8s covered=%d\n", shift.ID, shift.Day, shift.Keeper, shift.Covered)
	}
	return nil
}

What it prints

Each line of the trace names the query and where it ran. The two queries in the middle ran inside the transaction, and the tracer saw them.

db  AddShift
db  AddShift
tx  CoverShift
tx  AddShift
db  ListShifts
1 Monday    Bryn     covered=1
2 Tuesday   Morwenna covered=0
3 Wednesday Bryn     covered=0

Try it

Delete the tracer's WrapDBTX method and run the example again. The rota is the same, but the two tx lines vanish from the trace: the transaction's queries ran on the bare *sql.Tx, where the tracer could not see them.

Run it locally

From the directory you cloned the Seagull repository into:

cd seagull/examples/sqlite && go run ./026_tracing_queries

Its test runs it too, and checks what it prints against output.txt:

cd seagull/examples/sqlite && go test ./026_tracing_queries

The files

Every file of the example, as it is in the Seagull repository.

sgl.yaml

# Copyright 2026 PolitePixels Limited
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

# This project stands against fascism, authoritarianism, and all forms of
# oppression. We built this to empower people, not to enable those who would
# strip others of their rights and dignity.

version: 1

databases:
  - name: rota
    dialect: sqlite
    migrations: db/migrations
    queries: db/queries
    go:
      package: store
      out: internal/store
      query_names:
        enabled: true

db/migrations/0001_shifts.up.sql

CREATE TABLE shifts (
    id      INTEGER PRIMARY KEY,
    keeper  TEXT    NOT NULL,
    day     TEXT    NOT NULL,
    covered INTEGER NOT NULL DEFAULT 0
);

db/migrations/0001_shifts.down.sql

DROP TABLE shifts;

db/queries/shifts.sql

-- sgl.query(AddShift, one)
INSERT INTO shifts (keeper, day) VALUES (?, ?) RETURNING id;

-- sgl.query(CoverShift, execrows)
UPDATE shifts SET covered = 1 WHERE id = ?;

-- sgl.query(ListShifts, many)
SELECT id, keeper, day, covered FROM shifts ORDER BY id;

example.go

package main

import (
	"context"
	"database/sql"
	"fmt"
	"io"

	"seagull.sh/seagull/examples/sqlite/026_tracing_queries/internal/store"
)

// tracer is a store.DBTX that logs the name of every query before running it on the
// connection it wraps. A real tracer would start a span or record a metric under the same
// name.
type tracer struct {
	// inner is the connection the queries run on: a *sql.DB, or a *sql.Tx once WrapDBTX has
	// wrapped one.
	inner store.DBTX

	// output receives a line per query.
	output io.Writer

	// scope labels each line with where the query ran: db or tx.
	scope string
}

// ExecContext traces the query, then runs it on the wrapped connection.
//
// Takes query (string) which is the SQL to run.
// Takes arguments (...any) which are its bind values.
//
// Returns sql.Result which is the database's result.
// Returns error when the statement fails.
func (t tracer) ExecContext(ctx context.Context, query string, arguments ...any) (sql.Result, error) {
	t.trace(query)
	return t.inner.ExecContext(ctx, query, arguments...)
}

// QueryContext traces the query, then runs it on the wrapped connection.
//
// Takes query (string) which is the SQL to run.
// Takes arguments (...any) which are its bind values.
//
// Returns *sql.Rows which holds the result rows.
// Returns error when the query fails.
func (t tracer) QueryContext(ctx context.Context, query string, arguments ...any) (*sql.Rows, error) {
	t.trace(query)
	return t.inner.QueryContext(ctx, query, arguments...)
}

// QueryRowContext traces the query, then runs it on the wrapped connection.
//
// Takes query (string) which is the SQL to run.
// Takes arguments (...any) which are its bind values.
//
// Returns *sql.Row which holds the result row or the error.
func (t tracer) QueryRowContext(ctx context.Context, query string, arguments ...any) *sql.Row {
	t.trace(query)
	return t.inner.QueryRowContext(ctx, query, arguments...)
}

// WrapDBTX returns a tracer around another connection. The generated WithTx, which
// RunInTx uses, calls it with the *sql.Tx, so queries in a transaction are traced too.
// Without it they would run on the bare *sql.Tx and go untraced.
//
// Takes inner (any) which is the connection to wrap.
//
// Returns any which is the wrapped connection, or inner unchanged when it is not a DBTX.
func (t tracer) WrapDBTX(inner any) any {
	connection, ok := inner.(store.DBTX)
	if !ok {
		return inner
	}
	return tracer{inner: connection, output: t.output, scope: "tx"}
}

// trace writes the query's name. go.query_names generates store.QueryName, which maps the
// SQL text every generated method passes to its DBTX back to the query's name.
//
// Takes query (string) which is the SQL about to run.
func (t tracer) trace(query string) {
	name := store.QueryName(query)
	if name == "" {
		name = "(not a generated query)"
	}
	fmt.Fprintf(t.output, "%s  %s\n", t.scope, name)
}

// run adds two shifts to a rota, then covers one and adds another in a transaction,
// tracing every query by name, and prints the rota.
//
// Takes database (*sql.DB) which is a migrated database.
// Takes output (io.Writer) which receives the trace and the rota.
//
// Returns error when a query fails.
func run(ctx context.Context, database *sql.DB, output io.Writer) error {
	queries := store.New(tracer{inner: database, output: output, scope: "db"})

	monday, err := queries.AddShift(ctx, store.AddShiftParams{Keeper: "Bryn", Day: "Monday"})
	if err != nil {
		return fmt.Errorf("adding Monday's shift: %w", err)
	}
	if _, err := queries.AddShift(ctx, store.AddShiftParams{Keeper: "Morwenna", Day: "Tuesday"}); err != nil {
		return fmt.Errorf("adding Tuesday's shift: %w", err)
	}

	err = queries.RunInTx(ctx, database, func(inTransaction *store.Queries) error {
		if _, err := inTransaction.CoverShift(ctx, monday.ID); err != nil {
			return err
		}
		_, err := inTransaction.AddShift(ctx, store.AddShiftParams{Keeper: "Bryn", Day: "Wednesday"})
		return err
	})
	if err != nil {
		return fmt.Errorf("swapping shifts: %w", err)
	}

	shifts, err := queries.ListShifts(ctx)
	if err != nil {
		return fmt.Errorf("listing shifts: %w", err)
	}
	for _, shift := range shifts {
		fmt.Fprintf(output, "%d %-9s %-8s covered=%d\n", shift.ID, shift.Day, shift.Keeper, shift.Covered)
	}
	return nil
}

main.go

// Command 026_tracing_queries applies its migrations to a SQLite database, then runs the
// example in example.go: a wrapping DBTX logs every query by the name go.query_names
// gives it, inside RunInTx as well as outside.
//
// Run it with `go run .`, or `go run . -db rota.db` to keep the database.
package main

import (
	"context"
	"database/sql"
	"embed"
	"flag"
	"fmt"
	"log"
	"os"
	"path/filepath"

	_ "modernc.org/sqlite"

	"seagull.sh/seagull/sdk/migrate"
	"seagull.sh/seagull/sdk/migrate/migrate_backend_sqlite"
)

// migrations holds the migration files, compiled into the binary.
//
//go:embed db/migrations
var migrations embed.FS

func main() {
	path := flag.String("db", "", "SQLite database file; a temporary one when empty")
	flag.Parse()
	if err := execute(context.Background(), *path); err != nil {
		log.Fatal(err)
	}
}

// execute opens and migrates the database at path, or a temporary one when path is empty,
// and runs the example against it.
//
// Takes path (string) which is the database file, or empty.
//
// Returns error when the database cannot be prepared or the example fails.
func execute(ctx context.Context, path string) error {
	if path == "" {
		directory, err := os.MkdirTemp("", "seagull-example-")
		if err != nil {
			return err
		}
		defer func() { _ = os.RemoveAll(directory) }()
		path = filepath.Join(directory, "example.db")
	}
	database, err := sql.Open("sqlite", "file:"+path)
	if err != nil {
		return err
	}
	defer func() { _ = database.Close() }()

	migrator, err := migrate.NewMigrator(database, migrate_backend_sqlite.New(), migrations, "db/migrations")
	if err != nil {
		return err
	}
	if _, err := migrator.Up(ctx); err != nil {
		return fmt.Errorf("migrating: %w", err)
	}
	return run(ctx, database, os.Stdout)
}

internal/store/models.go

// Code generated by sgl. DO NOT EDIT.

package store

type Shifts struct {
	ID      int64  `json:"id"`
	Keeper  string `json:"keeper"`
	Day     string `json:"day"`
	Covered int64  `json:"covered"`
}

internal/store/querier.go

// Code generated by sgl. DO NOT EDIT.

package store

import (
	"context"
	"database/sql"
)

type DBTX interface {
	ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
	QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
	QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
}

type dbtxWrapper interface {
	WrapDBTX(inner any) any
}

type Queries struct {
	reader  DBTX
	writer  DBTX
	wrapper dbtxWrapper
}

func New(db DBTX) *Queries {
	wrapper, _ := db.(dbtxWrapper)
	return &Queries{reader: db, writer: db, wrapper: wrapper}
}

func NewWithReplica(writer DBTX, reader DBTX) *Queries {
	wrapper, _ := writer.(dbtxWrapper)
	return &Queries{reader: reader, writer: writer, wrapper: wrapper}
}

func (queries *Queries) WithTx(transaction *sql.Tx) *Queries {
	if queries == nil {
		return &Queries{reader: transaction, writer: transaction, wrapper: nil}
	}
	db := queries.wrapDBTX(transaction)
	return &Queries{reader: db, writer: db, wrapper: queries.wrapper}
}

func (queries *Queries) wrapDBTX(db DBTX) DBTX {
	if queries.wrapper == nil {
		return db
	}
	wrapped, ok := queries.wrapper.WrapDBTX(db).(DBTX)
	if !ok {
		return db
	}
	return wrapped
}

func (queries *Queries) RunInTx(ctx context.Context, db *sql.DB, fn func(*Queries) error) error {
	transaction, err := db.BeginTx(ctx, nil)
	if err != nil {
		return err
	}
	defer transaction.Rollback()
	if err = fn(queries.WithTx(transaction)); err != nil {
		return err
	}
	return transaction.Commit()
}

internal/store/query_names.go

// Code generated by sgl. DO NOT EDIT.

package store

var queryNameMap = map[string]string{addShiftSQL: "AddShift", coverShiftSQL: "CoverShift", listShiftsSQL: "ListShifts"}

func QueryName(query string) string {
	return queryNameMap[query]
}

internal/store/shifts.sql.go

// Code generated by sgl. DO NOT EDIT.

package store

import "context"

const addShiftSQL = `INSERT INTO shifts (keeper, day) VALUES (?1, ?2) RETURNING id;`

type AddShiftParams struct {
	Keeper string
	Day    string
}

type AddShiftRow struct {
	ID int64 `json:"id"`
}

func (queries *Queries) AddShift(ctx context.Context, params AddShiftParams) (AddShiftRow, error) {
	var row AddShiftRow
	err := queries.writer.QueryRowContext(ctx, addShiftSQL, params.Keeper, params.Day).Scan(&row.ID)
	if err != nil {
		return AddShiftRow{}, err
	}
	return row, nil
}

const coverShiftSQL = `UPDATE shifts SET covered = 1 WHERE id = ?1;`

func (queries *Queries) CoverShift(ctx context.Context, id int64) (int64, error) {
	results, err := queries.writer.ExecContext(ctx, coverShiftSQL, id)
	if err != nil {
		return 0, err
	}
	return results.RowsAffected()
}

const listShiftsSQL = `SELECT id, keeper, day, covered FROM shifts ORDER BY id;`

type ListShiftsRow struct {
	ID      int64  `json:"id"`
	Keeper  string `json:"keeper"`
	Day     string `json:"day"`
	Covered int64  `json:"covered"`
}

func (queries *Queries) ListShifts(ctx context.Context) ([]ListShiftsRow, error) {
	rows, err := queries.reader.QueryContext(ctx, listShiftsSQL)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var results []ListShiftsRow
	if rows.Next() {
		var row ListShiftsRow
		scanArgs := []any{&row.ID, &row.Keeper, &row.Day, &row.Covered}
		for more := true; more; more = rows.Next() {
			row = ListShiftsRow{}
			if err := rows.Scan(scanArgs...); err != nil {
				return nil, err
			}
			results = append(results, row)
		}
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}
	return results, nil
}

output.txt

db  AddShift
db  AddShift
tx  CoverShift
tx  AddShift
db  ListShifts
1 Monday    Bryn     covered=1
2 Tuesday   Morwenna covered=0
3 Wednesday Bryn     covered=0