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