| name | observability |
| description | Use when instrumenting liteorm with metrics, tracing, or audit around every executed statement — the Observer seam (liteorm.WithObserver + QueryEvent). Distinct from slog statement logging (the logging skill), which rides the same seam. |
Observability (metrics / tracing / audit)
LiteORM invokes an observer around every statement, on a *liteorm.DB and on any transaction started from it. Its own slog statement logging rides the same event, so an observer sees exactly what the logger sees: the SQL, bind args, elapsed time, rows affected, and error. This is the hook for Prometheus metrics, OpenTelemetry traces, or an audit sink.
type Observer interface {
BeforeQuery(ctx context.Context, ev *liteorm.QueryEvent) context.Context
AfterQuery(ctx context.Context, ev *liteorm.QueryEvent)
}
BeforeQuery runs before the statement and may return a derived context carrying per-statement state (an open span, a start marker); LiteORM threads it into the execution and back to AfterQuery, which runs after with Duration/Rows/Err filled in. Register at construction — observers compose as an onion (before in order, after in reverse):
db, _ := sqlite.Open("app.db", liteorm.WithObserver(metrics, tracing))
WithObserver(o ...Observer) works with every backend's Open. QueryEvent carries Op (liteorm.MsgQuery for a SELECT / RETURNING read-back, liteorm.MsgExec for INSERT/UPDATE/DELETE/DDL), SQL, Args, Start, Duration, Rows (rows-affected for an exec; -1 for a query), and Err.
Metrics (do the work in AfterQuery)
BeforeQuery returns ctx unchanged; count and time in AfterQuery. Label by ev.Op only — it's one of two constants, so low-cardinality.
func (m metrics) BeforeQuery(ctx context.Context, _ *liteorm.QueryEvent) context.Context { return ctx }
func (m metrics) AfterQuery(_ context.Context, ev *liteorm.QueryEvent) {
status := "ok"
if ev.Err != nil { status = "error" }
m.count.WithLabelValues(ev.Op, status).Inc()
m.latency.WithLabelValues(ev.Op).Observe(ev.Duration.Seconds())
}
Tracing (span on the threaded context)
Open a span in BeforeQuery, return the span-bearing context, end it in AfterQuery (trace.SpanFromContext recovers it). Because the span lives on the threaded context, the DB span nests under the incoming request span automatically.
func (t tracing) BeforeQuery(ctx context.Context, ev *liteorm.QueryEvent) context.Context {
ctx, _ = t.tracer.Start(ctx, "db."+ev.Op, trace.WithSpanKind(trace.SpanKindClient))
return ctx
}
func (t tracing) AfterQuery(ctx context.Context, ev *liteorm.QueryEvent) {
span := trace.SpanFromContext(ctx)
if ev.Err != nil { span.RecordError(ev.Err) }
span.End()
}
Pitfalls
ev.Args is NEVER redacted for observers. WithSQLArgs(false) redacts the log only; an observer always gets the real bind values. Redact in the observer before forwarding to a metrics/trace/audit sink. See the security guide.
- Never label a metric (or tag a span) by
ev.SQL or ev.Args — unbounded cardinality. ev.Op is the only safe label.
- Observers are independent of the slog log level — registering one does not require enabling debug logging, and vice-versa.
- Transactions inherit the DB's observers, so a unit of work traces as one tree.
- Zero overhead when idle: with no observer and logging off, no event is allocated.
Deeper
- API: https://pkg.go.dev/liteorm.org (
Observer, QueryEvent, WithObserver). Guide: docs/guides/observability.md.
- Related: the logging skill (the built-in slog log on this same seam) and the changesets skill (SQLite-native change capture for audit).