Skip to content

Observability

LiteORM logs every statement through log/slog (see logging), and for tracing, metrics, or audit it exposes one seam: an observer invoked around every statement, on a *liteorm.DB and on any transaction started from it. LiteORM’s own statement logging rides on the same event, so an observer sees exactly what the logger sees — the SQL, the bind arguments, the elapsed time, the rows affected, and the error.

An observer implements two methods:

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 trace span, a start marker — which is threaded into the executed statement and back to AfterQuery. AfterQuery runs after, with the event’s Duration, Rows, and Err filled in. Register observers at construction; they compose as an onion (before in order, after in reverse):

db, _ := sqlite.Open("app.db", liteorm.WithObserver(metrics, tracing))

The QueryEvent carries Op (liteorm.MsgQuery for a query, liteorm.MsgExec for a statement), SQL, Args, Start, Duration, Rows (rows affected for an Exec; -1 for a query), and Err.

AfterQuery is a natural metrics hook: it fires once per statement with the operation, the elapsed time, and the error already filled in. Define two vectors — a counter for throughput/errors and a histogram for latency — and label both by Op so you can slice queries from execs:

type metrics struct {
count *prometheus.CounterVec
latency *prometheus.HistogramVec
}
func newMetrics(reg prometheus.Registerer) metrics {
m := metrics{
count: prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "liteorm_statements_total",
Help: "Statements executed, by operation and status.",
}, []string{"op", "status"}),
latency: prometheus.NewHistogramVec(prometheus.HistogramOpts{
Name: "liteorm_statement_duration_seconds",
Help: "Statement execution latency.",
Buckets: prometheus.DefBuckets,
}, []string{"op"}),
}
reg.MustRegister(m.count, m.latency)
return m
}

BeforeQuery has nothing to do here — return the context unchanged. Do all the work in AfterQuery:

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())
}

ev.Op is one of two low-cardinality constants — liteorm.MsgQuery (a SELECT, or a RETURNING write read back) or liteorm.MsgExec (an INSERT/UPDATE/DELETE/DDL) — so it is safe as a label; never label by ev.SQL or ev.Args, which are unbounded. Register it at open time:

db, _ := sqlite.Open("app.db", liteorm.WithObserver(newMetrics(prometheus.DefaultRegisterer)))

If you also want rows-affected as a metric, ev.Rows holds the count for an exec (and -1 for a query — skip it then).

Tracing is what the threaded context is for. Open a span in BeforeQuery, return the span-bearing context, and end that span in AfterQuery — LiteORM hands you back the exact context you returned, so the span is trivially recoverable with trace.SpanFromContext:

type tracing struct{ tracer trace.Tracer }
func (t tracing) BeforeQuery(ctx context.Context, ev *liteorm.QueryEvent) context.Context {
ctx, span := t.tracer.Start(ctx, "db."+ev.Op,
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(
semconv.DBSystemKey.String("sql"),
semconv.DBStatementKey.String(ev.SQL),
),
)
return ctx // the returned ctx carries the span forward to AfterQuery
}
func (t tracing) AfterQuery(ctx context.Context, ev *liteorm.QueryEvent) {
span := trace.SpanFromContext(ctx)
if ev.Rows >= 0 {
span.SetAttributes(attribute.Int64("db.rows_affected", ev.Rows))
}
if ev.Err != nil {
span.RecordError(ev.Err)
span.SetStatus(codes.Error, ev.Err.Error())
}
span.End()
}

Because the span lives on the context and that context is threaded into the statement’s own execution, the DB span nests under whatever request span is already on the incoming ctx, and any child spans started by the driver — or by the AfterQuery of an outer observer — nest correctly under it. Mind the redaction note below before attaching ev.SQL/ev.Args to a span in production — ev.Args is never redacted for observers — and see security for the broader audit picture.

  • Zero overhead when idle. With no observer registered and statement logging off, the statement runs straight through with no event allocated.
  • Observers run regardless of log level. They are independent of the slog logger — registering one does not require enabling debug logging, and logging does not require an observer.
  • Bind arguments are never redacted for observers. WithSQLArgs(false) redacts arguments in the log only; an observer always sees the real ev.Args. If your observer forwards arguments to a tracing/metrics/audit sink, redact sensitive values in the observer itself.
  • Transactions inherit the DB’s observers, so a unit of work traces as one tree across its statements.
  • Logging — the built-in slog statement log that rides this same seam.
  • Security — auditing via observers/changesets and redacting secrets before they reach a sink.
  • Performance — the zero-overhead fast path and pool-stats sampling.
  • API reference: pkg.go.dev/liteorm.org (Observer, QueryEvent, WithObserver).