Observing the generator
On this page
On SQLite:
- the generator reports generate, its catalogue and analysis stages, and one span per migration replayed and per query; the migrator reports migrate.up and migrate.status
- Start returns a context, so the observer keeps the nesting depth in it, as a tracer keeps its parent span
- Seagull logs its internals at slog.Level(-8), below Debug; WithClock fixes the elapsed time the trace log prints
When Seagull runs inside a larger tool, a build system or a long-running service that migrates on start, you want to see what it is doing: which stage is slow, which migration failed, what it logged on the way. Seagull reports both ways Go programs usually watch themselves. An observer receives a span for each stage of a run, ready to map onto tracing or metrics, and a log/slog logger receives its log records. This example attaches one of each to a generation and a migration run and prints everything they receive.
The project
A tasks table built by two migrations, the second adding a column, and two queries. Two of each makes the per-migration and per-query stages easy to count in the output.
db/migrations/0001_tasks.up.sql
CREATE TABLE tasks (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL
);
db/migrations/0002_task_done.up.sql
ALTER TABLE tasks ADD COLUMN done INTEGER NOT NULL DEFAULT 0;
The program
example.go
package main
import (
"context"
"database/sql"
"fmt"
"io"
"io/fs"
"log/slog"
"strings"
"time"
"seagull.sh/seagull"
"seagull.sh/seagull/sdk/dialect/dialect_engine_sqlite"
"seagull.sh/seagull/sdk/migrate"
"seagull.sh/seagull/sdk/migrate/migrate_backend_sqlite"
"seagull.sh/seagull/sdk/observe"
)
// traceLevel is the level below Debug that Seagull logs its internals at.
const traceLevel = slog.Level(-8)
// depthKey keys the nesting depth a stagePrinter keeps in a stage's context.
type depthKey struct{}
// stagePrinter is an observe.ObserverPort that prints each stage as it starts, indented
// by how deeply it nests, and each stage that fails as it ends. It prints no timings, so
// its output is the same on every run; a real observer would start a tracing span or
// record a metric here.
type stagePrinter struct {
// output receives one line per stage.
output io.Writer
}
// Start prints the stage and returns a context one level deeper, so the stages it starts
// are indented beneath it.
//
// Takes ctx (context.Context) which carries the depth of the enclosing stage.
// Takes stage (observe.Stage) which names the work.
// Takes attributes (...slog.Attr) which describe it.
//
// Returns context.Context which the stage runs under.
// Returns func(err error) which the stage calls once when it ends.
func (p stagePrinter) Start(ctx context.Context, stage observe.Stage, attributes ...slog.Attr) (context.Context, func(err error)) {
depth, nested := ctx.Value(depthKey{}).(int)
if !nested {
depth = 0
}
indent := strings.Repeat(" ", depth)
var line strings.Builder
line.WriteString(indent + string(stage))
for _, attribute := range attributes {
line.WriteString(" " + attribute.String())
}
fmt.Fprintln(p.output, line.String())
return context.WithValue(ctx, depthKey{}, depth+1), func(err error) {
if err != nil {
fmt.Fprintf(p.output, "%s%s failed: %v\n", indent, stage, err)
}
}
}
// fixedClock is the clock the generator times its runs with. A fixed time makes the
// elapsed time in the trace log zero on every run.
type fixedClock struct{}
// Now returns the same moment every time.
//
// Returns time.Time which is midnight on 1 January 2026.
func (fixedClock) Now() time.Time {
return time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC)
}
// run generates the store package and migrates the database with an observer and a logger
// attached, so each stage and the trace log of both show in output.
//
// Takes database (*sql.DB) which is an empty database to migrate.
// Takes project (fs.FS) which holds db/migrations and db/queries.
// Takes output (io.Writer) which receives the stages and the log.
//
// Returns error when generation or migration fails.
func run(ctx context.Context, database *sql.DB, project fs.FS, output io.Writer) error {
observer := stagePrinter{output: output}
logger := slog.New(slog.NewTextHandler(output, &slog.HandlerOptions{
AddSource: false,
Level: traceLevel,
ReplaceAttr: tidyRecord,
}))
generator, err := seagull.New(dialect_engine_sqlite.New(),
seagull.WithObserver(observer),
seagull.WithLogger(logger),
seagull.WithClock(fixedClock{}),
)
if err != nil {
return err
}
fmt.Fprintln(output, "generating:")
result, err := generator.Generate(ctx, seagull.Input{
Migrations: project,
Queries: nil,
Package: "store",
MigrationDir: "db/migrations",
QueryDir: "db/queries",
})
if err != nil {
return err
}
if err := result.Err(); err != nil {
return err
}
fmt.Fprintln(output, "\nmigrating:")
migrator, err := migrate.NewMigrator(database, migrate_backend_sqlite.New(), project, "db/migrations",
migrate.WithObserver(observer),
migrate.WithLogger(logger),
)
if err != nil {
return err
}
if _, err := migrator.Up(ctx); err != nil {
return err
}
_, err = migrator.Status(ctx)
return err
}
// tidyRecord drops the time from every log record, which would differ on every run, and
// names Seagull's trace level, which slog would print as DEBUG-4.
//
// Takes groups ([]string) which are the groups the attribute is in.
// Takes attribute (slog.Attr) which is the attribute.
//
// Returns slog.Attr which is the attribute to print, or an empty one to drop it.
func tidyRecord(groups []string, attribute slog.Attr) slog.Attr {
if len(groups) > 0 {
return attribute
}
switch attribute.Key {
case slog.TimeKey:
return slog.Attr{}
case slog.LevelKey:
if level, ok := attribute.Value.Any().(slog.Level); ok && level == traceLevel {
return slog.String(slog.LevelKey, "TRACE")
}
}
return attribute
}
The observer. observe.ObserverPort has one method:
Start(ctx context.Context, stage observe.Stage, attrs ...slog.Attr) (context.Context, func(err error))
Seagull calls Start as each stage begins, with the stage's name and any attributes, and calls the returned function once when the stage ends, with its error or nil. stagePrinter prints the stage, indented by its depth, and prints again only if the stage failed. It keeps the depth in the context it returns, and Seagull starts the stages beneath under that context, so nesting comes for free. A tracer works the same way: start a span in Start, put it in the context, end it in the returned function. A panic in an observer, or a nil end function, never fails the run it observes.
The logger. Seagull logs warnings and errors at the usual levels, and its internals at a trace level below Debug, slog.Level(-8), so a default handler stays quiet. This handler sets its level to that trace level to see everything. tidyRecord drops the time and names the level TRACE, which slog would otherwise print as DEBUG-4.
The generator takes the observer and the logger as options, seagull.WithObserver and seagull.WithLogger, and seagull.WithClock with a clock that never moves, so the elapsed time it logs is the same on every run. Without WithLogger, a run uses the logger carried by its context (seagull.ContextWithLogger), then slog.Default().
The migrator takes the same observer and logger through migrate.WithObserver and migrate.WithLogger, so migrations at run time report in the same way as generation.
What it prints
generating:
generate package=store
generate.catalogue
generate.catalogue.migration
generate.catalogue.migration
generate.analyse
generate.analyse.query
generate.resolve.columns
generate.resolve.params
generate.analyse.query
generate.resolve.columns
generate.resolve.params
generate.plan
generate.emit
generate.format
generate.format
level=TRACE msg="generation complete" package=store files=4 queries=2 partial=false elapsed=0s
migrating:
migrate.up
level=TRACE msg="applied migration" version=1 name=tasks skip_up_to=-1
level=TRACE msg="applied migration" version=2 name=task_done skip_up_to=-1
migrate.status
Generation is one generate span carrying the package name. Beneath it, generate.catalogue holds a span for each migration replayed to build the schema, and generate.analyse a span for each query, with the column and parameter resolution inside it. The trace log ends the run with what it produced: the package, four files, two queries and an elapsed time of zero, thanks to the fixed clock.
The migration run reports migrate.up, with a trace record for each migration it applied, and migrate.status for the status check that follows. Vet would report a vet span where Generate reports generate, and seeds report seed.* stages.
Embedding Seagull lists the stages, and the observe package documents the interface. To name each generated query in your own traces at run time, see Tracing queries.
Run it locally
From the directory you cloned the Seagull repository into:
cd seagull/examples/embedding && go run ./051_generator_observability
Its test runs it too, and checks what it prints against output.txt:
cd seagull/examples/embedding && go test ./051_generator_observability
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: tasks
dialect: sqlite
migrations: db/migrations
queries: db/queries
go:
package: store
out: internal/store
db/migrations/0001_tasks.up.sql
CREATE TABLE tasks (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL
);
db/migrations/0002_task_done.up.sql
ALTER TABLE tasks ADD COLUMN done INTEGER NOT NULL DEFAULT 0;
db/migrations/0001_tasks.down.sql
DROP TABLE tasks;
db/migrations/0002_task_done.down.sql
ALTER TABLE tasks DROP COLUMN done;
db/queries/tasks.sql
-- sgl.query(CreateTask, one)
INSERT INTO tasks (title) VALUES (?) RETURNING id, title, done;
-- sgl.query(ListOpenTasks, many)
SELECT id, title FROM tasks WHERE done = 0 ORDER BY id;
example.go
package main
import (
"context"
"database/sql"
"fmt"
"io"
"io/fs"
"log/slog"
"strings"
"time"
"seagull.sh/seagull"
"seagull.sh/seagull/sdk/dialect/dialect_engine_sqlite"
"seagull.sh/seagull/sdk/migrate"
"seagull.sh/seagull/sdk/migrate/migrate_backend_sqlite"
"seagull.sh/seagull/sdk/observe"
)
// traceLevel is the level below Debug that Seagull logs its internals at.
const traceLevel = slog.Level(-8)
// depthKey keys the nesting depth a stagePrinter keeps in a stage's context.
type depthKey struct{}
// stagePrinter is an observe.ObserverPort that prints each stage as it starts, indented
// by how deeply it nests, and each stage that fails as it ends. It prints no timings, so
// its output is the same on every run; a real observer would start a tracing span or
// record a metric here.
type stagePrinter struct {
// output receives one line per stage.
output io.Writer
}
// Start prints the stage and returns a context one level deeper, so the stages it starts
// are indented beneath it.
//
// Takes ctx (context.Context) which carries the depth of the enclosing stage.
// Takes stage (observe.Stage) which names the work.
// Takes attributes (...slog.Attr) which describe it.
//
// Returns context.Context which the stage runs under.
// Returns func(err error) which the stage calls once when it ends.
func (p stagePrinter) Start(ctx context.Context, stage observe.Stage, attributes ...slog.Attr) (context.Context, func(err error)) {
depth, nested := ctx.Value(depthKey{}).(int)
if !nested {
depth = 0
}
indent := strings.Repeat(" ", depth)
var line strings.Builder
line.WriteString(indent + string(stage))
for _, attribute := range attributes {
line.WriteString(" " + attribute.String())
}
fmt.Fprintln(p.output, line.String())
return context.WithValue(ctx, depthKey{}, depth+1), func(err error) {
if err != nil {
fmt.Fprintf(p.output, "%s%s failed: %v\n", indent, stage, err)
}
}
}
// fixedClock is the clock the generator times its runs with. A fixed time makes the
// elapsed time in the trace log zero on every run.
type fixedClock struct{}
// Now returns the same moment every time.
//
// Returns time.Time which is midnight on 1 January 2026.
func (fixedClock) Now() time.Time {
return time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC)
}
// run generates the store package and migrates the database with an observer and a logger
// attached, so each stage and the trace log of both show in output.
//
// Takes database (*sql.DB) which is an empty database to migrate.
// Takes project (fs.FS) which holds db/migrations and db/queries.
// Takes output (io.Writer) which receives the stages and the log.
//
// Returns error when generation or migration fails.
func run(ctx context.Context, database *sql.DB, project fs.FS, output io.Writer) error {
observer := stagePrinter{output: output}
logger := slog.New(slog.NewTextHandler(output, &slog.HandlerOptions{
AddSource: false,
Level: traceLevel,
ReplaceAttr: tidyRecord,
}))
generator, err := seagull.New(dialect_engine_sqlite.New(),
seagull.WithObserver(observer),
seagull.WithLogger(logger),
seagull.WithClock(fixedClock{}),
)
if err != nil {
return err
}
fmt.Fprintln(output, "generating:")
result, err := generator.Generate(ctx, seagull.Input{
Migrations: project,
Queries: nil,
Package: "store",
MigrationDir: "db/migrations",
QueryDir: "db/queries",
})
if err != nil {
return err
}
if err := result.Err(); err != nil {
return err
}
fmt.Fprintln(output, "\nmigrating:")
migrator, err := migrate.NewMigrator(database, migrate_backend_sqlite.New(), project, "db/migrations",
migrate.WithObserver(observer),
migrate.WithLogger(logger),
)
if err != nil {
return err
}
if _, err := migrator.Up(ctx); err != nil {
return err
}
_, err = migrator.Status(ctx)
return err
}
// tidyRecord drops the time from every log record, which would differ on every run, and
// names Seagull's trace level, which slog would print as DEBUG-4.
//
// Takes groups ([]string) which are the groups the attribute is in.
// Takes attribute (slog.Attr) which is the attribute.
//
// Returns slog.Attr which is the attribute to print, or an empty one to drop it.
func tidyRecord(groups []string, attribute slog.Attr) slog.Attr {
if len(groups) > 0 {
return attribute
}
switch attribute.Key {
case slog.TimeKey:
return slog.Attr{}
case slog.LevelKey:
if level, ok := attribute.Value.Any().(slog.Level); ok && level == traceLevel {
return slog.String(slog.LevelKey, "TRACE")
}
}
return attribute
}
main.go
// Command 051_generator_observability generates this example's store package and migrates
// a SQLite database with an observer and a logger attached, printing every stage both run
// and the trace log they write.
//
// Run it with `go run .`, or `go run . -db tasks.db` to keep the database.
package main
import (
"context"
"database/sql"
"embed"
"flag"
"log"
"os"
"path/filepath"
_ "modernc.org/sqlite"
)
// project holds the migration and query files, compiled into the binary.
//
//go:embed db
var project 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 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 opened 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() }()
return run(ctx, database, project, os.Stdout)
}
output.txt
generating:
generate package=store
generate.catalogue
generate.catalogue.migration
generate.catalogue.migration
generate.analyse
generate.analyse.query
generate.resolve.columns
generate.resolve.params
generate.analyse.query
generate.resolve.columns
generate.resolve.params
generate.plan
generate.emit
generate.format
generate.format
level=TRACE msg="generation complete" package=store files=4 queries=2 partial=false elapsed=0s
migrating:
migrate.up
level=TRACE msg="applied migration" version=1 name=tasks skip_up_to=-1
level=TRACE msg="applied migration" version=2 name=task_done skip_up_to=-1
migrate.status