Skip to main content
Version: 1.17.0

Observability API Reference

The four ways to see what Quark is doing: query observers (one event per statement), middleware (wrap the execution path), the OpenTelemetry integration, and the slow-query log. All four see every statement Quark sends to the engine — the query builder's, schema work, catalog reads, savepoints and raw SQL — and each statement carries a kind saying what it is for.

For a worked setup, see Caching & Observability.

SymbolSignaturePurpose
QueryObserverinterface — ObserveQuery(event QueryEvent)Receives a QueryEvent per statement.
WithQueryObserverWithQueryObserver(o QueryObserver) OptionRegisters an observer on a client.
Middlewareinterface — WrapExec / WrapQuery / WrapQueryRowWraps execution functions.
WithMiddlewareWithMiddleware(m Middleware) OptionRegisters middleware on a client; the first registered is the outermost.
StatementKindtype StatementKind stringWhat a statement is for: query, exec, ddl, introspection, savepoint, raw.
StatementKindFromContextStatementKindFromContext(ctx context.Context) StatementKindThe kind of the statement a middleware is wrapping.
WithSlowQueryThresholdWithSlowQueryThreshold(d time.Duration) OptionLogs a WARN line for operations slower than d.
quarkotel.NewNew(opts ...Option) *MiddlewareOpenTelemetry spans + metrics middleware.
quarkotel.WithSpanRedactionWithSpanRedaction(mode RedactionMode) OptionRedactArgs (default) or IncludeArgs.
quarkotel.WithDBSystemWithDBSystem(name string) OptionSets the db.system attribute.
quarkotel.StatementKindKeyconst StatementKindKey = "quark.statement.kind"The span and metric attribute that carries the kind.
NotifyNotify(ctx, provider ClientProvider, channel, payload string) errorSends a DB notification (PG only).
NewListenerFactoryNewListenerFactory(client *Client) *ListenerFactoryBuilds the inbound LISTEN/NOTIFY factory.

What is observed​

Every statement Quark sends to the engine goes through one execution path, which runs it inside the middleware chain and then hands its QueryEvent to the slow-query log and to every observer. The middleware and the observers therefore see the same statements, in the order the engine receives them. The extension bench measures this with a recording driver underneath Quark (control CON-04), and a test of the repository fails on code that sends a statement any other way.

Each statement has a StatementKind. query and exec are told apart by the database/sql method the statement goes through, which is the same split as WrapQuery/WrapQueryRow versus WrapExec. The other four kinds say what the statement is for, whichever method it uses.

KindWhat sends it
queryThe query builder through QueryContext or QueryRowContext: List, Find, First, Iter, Cursor, Count, the aggregates, preloads, the check a guarded write reads first, and a write that reads its result back (INSERT … RETURNING, SQL Server's OUTPUT, the key a Create reads after its INSERT). Also Routine and the batch reads of Backfill.
execThe query builder through ExecContext: INSERT, UPDATE, DELETE, upserts and batches. Also Call, Notify, audit rows, and the tenant variable a RowLevelSecurityNative transaction sets when it opens.
ddlSchema work: the CREATE, ALTER and DROP of Migrate, Sync, ApplyPlan, CreateIndex and AddForeignKey, and the statements that serve it — the migration lock, writes to Quark's bookkeeping tables (quark_migration_state, quark_backfill_state, quark_migrations), the PRAGMAs and row copy of SQLite's table rebuild, and the policies quarktenant installs.
introspectionReads of what the schema is: the catalog queries of the dialect's SchemaIntrospector under IntrospectSchema, PlanMigration and Sync; the catalog reads of ApplyPlan; the RowLevelSecurityNative policy checks; reads of the bookkeeping tables; and the server version a client on the mysql driver reads to tell MariaDB apart.
savepointSAVEPOINT, ROLLBACK TO SAVEPOINT and RELEASE SAVEPOINT, from Tx.Savepoint, Tx.RollbackTo, Tx.ReleaseSavepoint and the nested Tx.Tx.
rawSQL you wrote, sent with Client.Exec or Client.RawQuery.

A middleware reads the kind from the context it receives. An observer reads it from QueryEvent.Kind:

func (m *AuditMiddleware) WrapExec(next quark.ExecFunc) quark.ExecFunc {
return func(ctx context.Context, exec quark.Executor, sqlStr string, args []any) (sql.Result, error) {
if quark.StatementKindFromContext(ctx) == quark.StatementDDL {
log.Printf("schema change: %s", sqlStr)
}
return next(ctx, exec, sqlStr, args)
}
}

StatementKindFromContext returns "" for a context that did not come through a client's chain, such as a middleware function a test calls directly.

The following are not observed:

  • Transaction control. BEGIN, COMMIT and ROLLBACK are sent by the driver through database/sql's BeginTx, Commit and Rollback, not as statements.
  • What your code sends on handles Quark gives it. That covers Client.Raw() and the *sql.Tx that migrate.Migration.UpTx and DownTx receive; the migrator's own ledger statements are still observed. A migration's Up and Down get the client, and whatever they send through it is observed.
  • The set_config of RowLevelSecurityNative outside a transaction. This travels with the statement it confines, inside the same call. The middleware's timing covers both, but neither the middleware nor the observer sees set_config as a statement of its own. Inside a transaction, the variable is set once when the transaction opens, as an exec.
  • The LISTEN connection that a driver's ListenerFactory opens for inbound notifications.

QueryObserver​

type QueryObserver interface {
ObserveQuery(event QueryEvent)
}

Attach observers with WithQueryObserver:

type MetricsObserver struct{}

func (m *MetricsObserver) ObserveQuery(event quark.QueryEvent) {
metrics.Record(event.Table, event.Operation, event.Duration, event.Rows, event.Error)
}

client, err := quark.New("postgres", dsn,
quark.WithQueryObserver(&MetricsObserver{}),
)

QueryEvent​

type QueryEvent struct {
SQL string
Args []any
Duration time.Duration
Rows int64
Error error
Table string
Operation string
Kind StatementKind
}

Kind says what the statement is for (above). Operation says how it was sent:

OperationSent by
SELECT, SELECT (stream), SELECT (cursor), PRELOADThe query builder's reads: List (and Find, First), Iter, Cursor, preloads. The event is emitted once the caller has the rows; if the statement fails, the event is emitted with its error.
QUERY_ROWAny statement sent through QueryRowContext. Rows is 1, and the error surfaces on Scan, not on the event.
EXECAny statement sent through ExecContext other than raw SQL. Rows is the affected-row count when the driver reports one.
QUERYA statement sent through QueryContext outside the read families above: catalog reads, CreateBatch reading its keys back, Routine.
RAW_QUERY, RAW_EXECClient.RawQuery and Client.Exec.

Table is set when Quark knows it: a model's table, a bookkeeping table, or the audit table. It is empty for raw SQL and catalog reads.

PRELOAD covers the batched eager-loading SELECTs that Preload issues (… WHERE fk IN (…), or … WHERE fk = ANY($1) on PostgreSQL — join-table lookups included). They flow through the same observer pipeline as every other operation, so they also feed the slow-query log below. Rows is 0 for these events: the rows are consumed by the relation mapper after the event is emitted.

Middleware​

type Middleware interface {
WrapExec(next ExecFunc) ExecFunc
WrapQuery(next QueryFunc) QueryFunc
WrapQueryRow(next QueryRowFunc) QueryRowFunc
}

Function types:

type ExecFunc func(ctx context.Context, exec Executor, sqlStr string, args []any) (sql.Result, error)
type QueryFunc func(ctx context.Context, exec Executor, sqlStr string, args []any) (*sql.Rows, error)
type QueryRowFunc func(ctx context.Context, exec Executor, sqlStr string, args []any) *sql.Row

Embed BaseMiddleware to override only the paths you need:

type LoggingMiddleware struct {
quark.BaseMiddleware
}

func (l *LoggingMiddleware) WrapQuery(next quark.QueryFunc) quark.QueryFunc {
return func(ctx context.Context, exec quark.Executor, sqlStr string, args []any) (*sql.Rows, error) {
start := time.Now()
rows, err := next(ctx, exec, sqlStr, args)
log.Printf("query duration=%s err=%v sql=%s", time.Since(start), err, sqlStr)
return rows, err
}
}

Slow query log​

WithSlowQueryThreshold(d) enables a structured WARN log line for every Quark operation that takes longer than d. The line is emitted through Client.logger (the configured *slog.Logger) before any registered QueryObserver is notified.

client, _ := quark.New("pgx", dsn,
quark.WithSlowQueryThreshold(100*time.Millisecond),
)

Fields on the log record:

AttributeTypeSource
duration_msint64The observed duration in milliseconds.
threshold_msint64The configured threshold in milliseconds.
operationstringThe QueryEvent.Operation value — SELECT, SELECT (stream), SELECT (cursor), PRELOAD, QUERY_ROW, EXEC, QUERY, RAW_QUERY, or RAW_EXEC.
kindstringThe QueryEvent.Kind value — query, exec, ddl, introspection, savepoint, or raw.
tablestringThe Quark-managed table, when known (raw queries leave it empty).
rowsint64Rows affected for EXEC / rows returned for QUERY_ROW; zero otherwise.
sqlstringThe parameterised SQL. Bind arguments are NOT included — the same redaction principle as OTel spans (below).

Default: 0 (disabled). A negative value is also treated as disabled. Callers that want richer context (or argument-aware reporting under their own retention policy) should register a QueryObserver.

The threshold check is a single comparison; a Client with the feature disabled pays nothing on the observer hot path.

OpenTelemetry​

import quarkotel "github.com/jcsvwinston/quark/otel"

client, err := quark.New("postgres", dsn,
quark.WithMiddleware(quarkotel.New()),
)

The middleware emits spans and metrics for every statement Quark sends, schema work and savepoints included. Both tracer and meter are resolved lazily from the OTel global providers at query-execution time — install your TracerProvider / MeterProvider before the first query.

Spans​

ExecutionSpan name
ExecContextquark.exec
QueryContextquark.query
QueryRowContextquark.query_row

Attributes: db.statement (parameterised SQL with placeholders), db.operation (EXEC / SELECT / SELECT_ROW), quark.statement.kind (the statement's kind — query, exec, ddl, introspection, savepoint or raw), and — when WithDBSystem is set — db.system. Bind arguments are not attached to spans by default. A CREATE TABLE from Migrate is a quark.exec span with kind ddl; a SAVEPOINT is a quark.exec span with kind savepoint.

Span redaction (WithSpanRedaction)​

By default the middleware operates in RedactArgs mode: bind arguments never reach a span. Only the parameterised SQL ends up in db.statement.

For local debugging you can opt in to IncludeArgs, which attaches a db.statement.args attribute (a string slice rendered via fmt.Sprintf("%v", arg)):

quarkotel.New(quarkotel.WithSpanRedaction(quarkotel.IncludeArgs))

Use IncludeArgs only in development. A tracing backend MUST NOT see user values it has no authority to retain.

Metrics​

The middleware emits three instruments on the github.com/jcsvwinston/quark meter:

InstrumentKindUnitDescription
quark.queries.totalcounter (Int64)—Every statement Quark sends increments this.
quark.queries.durationhistogram (Float64)msWall-clock time of the operation, including middleware overhead.
quark.queries.rowshistogram (Int64)—Rows affected, Exec only — emitted when sql.Result.RowsAffected() succeeds. SELECT / SELECT_ROW do not emit (counting them would require wrapping *sql.Rows, a future enhancement).

Every data point carries db.operation, quark.statement.kind, and — when WithDBSystem is set — db.system. Filtering on quark.statement.kind keeps schema work and catalog reads out of an application's query metrics.

WithDBSystem​

The middleware sits below the query builder and does not introspect the Quark Client. If you want the db.system attribute populated, pass the dialect name explicitly:

quarkotel.New(quarkotel.WithDBSystem("postgres"))

When unset, the attribute is omitted from spans and metrics.

Hooks​

Entity-level hooks:

type BeforeCreateHook interface {
BeforeCreate(ctx context.Context) error
}

Available hook interfaces:

InterfaceMethod
BeforeCreateHookBeforeCreate(context.Context) error
AfterCreateHookAfterCreate(context.Context) error
BeforeUpdateHookBeforeUpdate(context.Context) error
AfterUpdateHookAfterUpdate(context.Context) error
BeforeDeleteHookBeforeDelete(context.Context) error
AfterDeleteHookAfterDelete(context.Context) error

Notifications​

Notify(ctx context.Context, provider ClientProvider, channel, payload string) error​

Sends a database notification where supported.

err := quark.Notify(ctx, client, "user_events", `{"type":"signup","id":123}`)

Current support:

DialectBehavior
PostgreSQLUses pg_notify($1, $2).
MySQLReturns not supported.
SQLiteReturns not supported.
Other dialectsReturns not supported.

NewListenerFactory(client *Client) *ListenerFactory​

Creates the inbound LISTEN/NOTIFY listener factory. On PostgreSQL, CreateListener returns a working inbound LISTEN/NOTIFY listener — a dedicated connection, fire-and-forget. On the other five engines it returns ErrDialectNotSupported. See inbound events for usage. This is unrelated to the outbound CRUD-event EventBus interface.