querytrace

package
v0.0.0-...-6f40dae Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 22, 2026 License: MIT Imports: 18 Imported by: 0

README

querytrace

querytrace is an application-level SQL tracing library for database/sql. It records the queries one operation — a request, a job, a usecase — issues, together with the application context around them, and surfaces problems that database-side statistics cannot see.

It is a standalone tool. It wraps any database/sql driver and observes whatever runs through it — hand-written SQL, an ORM, another query builder, or sqlkit's. You do not need to use the rest of sqlkit (its query builder or models) to use it. Its only sqlkit touchpoint is the optional sqlparse parser used for best-effort structural analysis and fingerprinting; without it, querytrace falls back to lightweight string analysis.

It deliberately does not do execution-plan analysis, index/buffer/lock inspection, or per-query latency percentiles — those belong to the database and to your APM stack. querytrace answers a different question:

For this request/usecase, what did the code ask the database to do, and from where?

This package is part of the root github.com/aita/sqlkit module; its only optional add-on is querytrace/otelquery (OpenTelemetry export), which is a separate module so the OpenTelemetry SDK stays out of the core.

What it catches

  • N+1 / query-in-loop — the same query shape repeated within one trace.
  • Query budgets — too many queries for one operation.
  • Read-only / forbidden access — a write where only reads were expected, or any query where none was (a cache path that secretly hit the database).
  • Missing WHERE — an UPDATE or DELETE with no filter.
  • Rows not closed — a cursor opened and never closed.
  • Rows lifecycle cost — the time spent iterating a cursor versus the time the query took to return, which a plain QueryContext span hides.
  • Cancellation mid-iteration — a read abandoned through context cancellation or its deadline while its rows were still being iterated: the request gave up after the database had begun streaming rows, work it then threw away.
  • Too many parameters — a statement carrying at or above the WithMaxParams budget of bind arguments, the signature of an oversized IN list or batch.
  • Large IN list — a top-level WHERE carrying an IN (...) value list at or above the WithMaxInList budget, a shape better expressed as a join or a temporary table.
  • SELECT * — a query that projects every column (WithSelectStarWarning), which over-fetches and couples the caller to the table's column set.
  • Missing tenant / soft-delete predicate — a read or write that touches a base table without filtering on a required tenant column (WithTenantColumns), or a SELECT that does not filter on a soft-delete marker (WithSoftDeleteColumns) — the signature of a query that can cross tenant boundaries or return logically deleted rows. Each check can be scoped to the tables that actually have the column — by table-qualified specs or ScopedToSchema — so a reference table never false-positives (see below).

These last three read predicate structure from the parsed AST, so they need a parser (see below) and stay silent on the string-analysis fallback.

How it fits together

context ── carries ──▶ Trace ── records ──▶ Event(s) ──▶ Report + Warnings
   ▲                                ▲
   │ WithTrace / ReadOnly / Mark    │ driver wrapper records each statement
  • core (querytrace) — Trace, Event, Report, Warning, the context API, fingerprinting, and SQL analysis.
  • driver/ — a database/sql/driver wrapper. This is the primary integration: it works with any driver and, by wrapping the row cursor, captures the rows lifecycle.
  • sqltest (top-level github.com/aita/sqlkit/sqltest) — a test spy and mock built over a Trace: fluent assertions on what ran (Expect(t, trace).MaxQueries(5).NoWrites().Query(Select(), Table("users")).Times(1)) and a programmable database/sql double (NewDB).
  • LogExporter — a summary exporter to log/slog, built into this package (querytrace.NewLogExporter); it needs only the standard library.
  • Metrics — the numeric aggregate of a Trace (statement counts by kind, unique fingerprints, transactions, total durations, warnings by code), emitted through the two-method MetricsRecorder you implement over your backend. Built into this package; it needs only the standard library (see below).
  • otelquery/ — an exporter to OpenTelemetry: one operation-level span per trace with a child DB span per statement. It is a separate module so the core stays dependency-free.

SQL analysis (operation, tables, WHERE/JOIN presence) and fingerprinting are best-effort through a sqlparse.Parser. You wire one explicitly — pass a frontend's parser (for example postgres.New() from github.com/aita/sqlkit/sqlparse/postgres) via WithParser, or set Config.Parser directly. A parse failure or a nil parser falls back to lightweight string analysis and never aborts execution. The parser produces a sql.Query, which the analysis package (github.com/aita/sqlkit/analysis) introspects for the analysis and ToSQL recompiles into a normalized fingerprint.

Usage

Wrap a connector and open a database/sql handle over it:

import (
    "database/sql"

    querytrace "github.com/aita/sqlkit/querytrace"
    qtdriver "github.com/aita/sqlkit/querytrace/driver"
    "github.com/aita/sqlkit/sqlparse/postgres"
)

// NewConfig starts from safe defaults (PostgreSQL dialect, no raw SQL, callers
// on error); layer options to wire a parser or capture more.
cfg := querytrace.NewConfig(querytrace.WithParser(postgres.New()))

db := sql.OpenDB(qtdriver.WrapConnector(connector, cfg))

To capture everything for development, add WithVerboseCapture; to use a custom parser, pass it to WithParser or set cfg.Parser after constructing the config.

Create a trace per operation and put it in the context:

trace := querytrace.New("GET /users/:id")
ctx := querytrace.WithTrace(r.Context(), trace)

// ... run the handler; every QueryContext/ExecContext under ctx is recorded ...

fmt.Println(querytrace.TextReport(trace))
In tests

The top-level sqltest package turns a Trace into a test spy: run the code under a Trace, then assert on what it actually issued. Expect is a fluent assertion bound to the test; Query(matchers...) narrows to a set of statements and checks how many ran.

func TestUserPageQueryCount(t *testing.T) {
    trace := querytrace.New("UserPage")
    ctx := querytrace.WithTrace(context.Background(), trace)

    if err := service.RenderUserPage(ctx, userID); err != nil {
        t.Fatal(err)
    }

    sqltest.Expect(t, trace).
        MaxQueries(5).
        NoNPlusOne().
        Query(sqltest.Select(), sqltest.Table("users")).Times(1)
}

func TestCacheHitDoesNotAccessDB(t *testing.T) {
    trace := querytrace.New("CacheHit")
    ctx := querytrace.ForbidQueries(querytrace.WithTrace(context.Background(), trace))

    if _, err := service.GetUser(ctx, id); err != nil {
        t.Fatal(err)
    }
    sqltest.Expect(t, trace).Warns(querytrace.WarnQueryForbidden)
}

For a unit test without a real database, sqltest.NewDB returns a programmable mock *sql.DB already traced, so the same spy assertions apply to its statements. It registers a t.Cleanup that verifies the mock and closes the DB, so a missed Required stub — or, by default, any statement no stub answered — fails the test automatically (sqltest.Lenient() opts out):

func TestLoadUser(t *testing.T) {
    db, mock := sqltest.NewDB(t)
    mock.ExpectQuery(sqltest.Contains("FROM users")).
        Return(sqltest.NewRows("id", "name").AddRow(1, "alice"))

    trace := querytrace.New("LoadUser")
    ctx := querytrace.WithTrace(context.Background(), trace)

    user, err := repo.Load(ctx, db, 1) // db.QueryContext(ctx, ...)
    if err != nil {
        t.Fatal(err)
    }

    sqltest.Expect(t, trace).
        NoWrites().
        Query(sqltest.Select(), sqltest.Table("users")).Times(1)
    _ = user
}

An expectation can also be a sql.Query built with the sqlkit builder, with holes left for the parts a test does not pin down — AnyTable, AnyColumn, and AnyExpr for structure, and Arg, AnyArg, or ArgMatch for a bind value:

mock.ExpectQuery(
    sql.Select(sqltest.AnyColumn()).
        From(sql.Tbl("users")).
        Where(sql.Eq(sql.Col("id"), sqltest.Arg(42))),
).Return(sqltest.NewRows("id", "name").AddRow(42, "alice"))

This answers any SELECT from users filtered by id = 42, whatever columns it selects. A plain bind value (sql.Expr(...)) is left unconstrained, so only the parts you spell out with Arg/ArgMatch are checked.

Emitting metrics

Trace.Metrics() reduces a finished trace to a Metrics value — statement counts (total, writes, and per kind), unique fingerprints, transactions, the total query and row-iteration durations, and warning counts by code and severity. Inspect it directly, or push it to your metrics backend by implementing the two-method MetricsRecorder and calling Trace.RecordMetrics (the one-call form of trace.Metrics().Emit(rec)). The core package depends on nothing but the standard library, so it pulls in no instrumentation library — you adapt Counter/Duration to Prometheus, OpenTelemetry metrics, statsd, or your own counters.

// A MetricsRecorder over a Prometheus CounterVec/HistogramVec, for example.
type promRecorder struct{ /* counter *prometheus.CounterVec; hist *prometheus.HistogramVec */ }

func (r promRecorder) Counter(name string, v int64, labels ...querytrace.Label) {
    // counter.With(toLabels(labels)).Add(float64(v))
}
func (r promRecorder) Duration(name string, d time.Duration, labels ...querytrace.Label) {
    // hist.With(toLabels(labels)).Observe(d.Seconds())
}

trace := querytrace.New("GET /users/:id")
defer trace.RecordMetrics(promRecorder{ /* ... */ })
ctx := querytrace.WithTrace(r.Context(), trace)
// ... run the request's queries through the wrapped driver ...

Metrics are emitted under stable names (the Metric* constants: querytrace.statements with a kind label, querytrace.unique_fingerprints, querytrace.transactions, querytrace.warnings with a code label, querytrace.query_duration, querytrace.rows_iteration_duration), each tagged with the trace's operation when set.

For OpenTelemetry metrics specifically, the otelquery module ships a ready adapter so you do not write the MetricsRecorder yourself: otelquery.ExportMetrics records a trace's metrics on a metric.Meter (counters as Int64Counter adds, durations as Float64Histogram observations in seconds), the metrics counterpart of Exporter.ExportTrace. It lives in the separate module, so the core stays dependency-free.

import "github.com/aita/sqlkit/querytrace/otelquery"

trace := querytrace.New("GET /users/:id")
defer otelquery.ExportMetrics(ctx, meterProvider.Meter("querytrace"), trace)
// ... run the request's queries ...

(For finer control, otelquery.NewMeterRecorder(ctx, meter) returns a querytrace.MetricsRecorder you can pass to trace.RecordMetrics.)

For a zero-dependency option, querytrace.NewExpvarRecorder publishes the same metrics to an expvar.Map, exposed on the standard /debug/vars endpoint with nothing but the standard library. Labels are folded into the variable name (querytrace.statements{kind=select,operation=GET /users}); counters accumulate as integers and durations as a running total in seconds (expvar has no histogram). Publish one map and reuse the recorder across traces:

var dbMetrics = querytrace.NewExpvarRecorder(expvar.NewMap("querytrace"))

// ... per request, after the work is done:
dbMetrics.Record(trace) // or trace.RecordMetrics(dbMetrics)
Exporting to OpenTelemetry

The otel/ subpackage exports a finished Trace to OpenTelemetry as a span tree: one operation-level span with a child span per statement carrying the database semantic conventions (db.system.name, db.query.text, db.operation.name, db.collection.name), warnings as span events, and the rows lifecycle as attributes. Because a Trace already records each statement's timestamps, it replays the trace into spans rather than instrumenting live.

It is a separate Go module (github.com/aita/sqlkit/querytrace/otelquery) so the core querytrace module carries no OpenTelemetry dependency.

import "github.com/aita/sqlkit/querytrace/otelquery"

exporter := otelquery.New(tp.Tracer("querytrace"), otelquery.WithDBSystem("postgresql"))

trace := querytrace.New("GET /users/:id")
ctx := querytrace.WithTrace(r.Context(), trace)
defer exporter.ExportTrace(ctx, trace)

Each statement span carries the application-level signals a plain SQL driver span cannot — the rows lifecycle, caller, fingerprint — and warnings ride the spans they concern. A whole-trace warning such as an N+1 / query-in-loop, which belongs to no single statement, lands on the operation span as a querytrace.warning event carrying its fingerprint and repeat count. WithWarningsAsErrors raises a span's status on a forbidden/read-only violation.

The operation span becomes a child of any span already in the context (for example an otelhttp server span), or a new trace root when there is none — so querytrace alone yields a DB-centric trace without needing a separate driver instrumentation. Its spans carry more than a generic SQL driver span would, so running it alongside one (such as otelsql) is possible but produces a second, richer span per query. Spans are created with the recorded timestamps, so they render at the right place on the timeline even though export happens once at the end.

net/http middleware

Exporter.Middleware (and the single-handler Exporter.Handler) wires this into a web server: it creates a Trace per request, places it in the request context, and exports it when the request finishes. Every statement issued under the request through a querytrace-wrapped driver becomes part of the operation's span tree, with no per-handler boilerplate.

exporter := otelquery.New(tp.Tracer("querytrace"), otelquery.WithDBSystem("postgresql"))

mux := http.NewServeMux()
mux.Handle("GET /users/{id}", exporter.Handler(usersHandler))

The operation is named after the routed pattern (GET /users/{id}), so it stays low cardinality; pass WithOperationNamer to override. Place the middleware inside any HTTP-level instrumentation (such as otelhttp) and the operation span nests under the HTTP server span, joining querytrace's view to the request trace.

Configuration

Config controls what is captured. NewConfig(opts...) is the recommended starting point: its defaults are production-minded — argument types only (never values), no raw SQL, and callers only on error — with analysis, fingerprinting, and N+1 detection enabled. Layer options such as WithParser, WithRawSQL, or WithCaller to adjust; WithVerboseCapture turns on the development-grade capture (raw SQL and a caller for every statement) that the defaults leave off.

Defaults lean safe: argument values are never captured, and raw SQL is off unless you turn it on.

Warnings

Each threshold- and policy-based check has its own option, all off by default, so you opt into each one:

cfg := querytrace.NewConfig(
    querytrace.WithParser(postgres.New()),
    querytrace.WithMaxQueries(50),      // too_many_queries
    querytrace.WithMaxParams(900),      // too_many_parameters
    querytrace.WithMaxInList(100),      // large_in_list
    querytrace.WithSelectStarWarning(), // select_star
    querytrace.WithTenantCheck(querytrace.Columns("tenant_id")),         // missing_tenant_predicate
    querytrace.WithSoftDeleteCheck(querytrace.Columns("deleted_at")),    // missing_soft_delete_predicate
)

The tenant and soft-delete checks are each configured by one option that takes sub-options: Columns names the marker columns to look for, and ScopedToSchema (below) limits the check to the tables that have them. They match case-insensitively in the statement's top-level WHERE: any read or write touching a base table whose WHERE references none of the tenant columns raises missing_tenant_predicate (an INSERT is exempt — it carries its tenant value in the row, not a filter), and any SELECT whose WHERE references none of the soft-delete columns raises missing_soft_delete_predicate. Like large_in_list and select_star, they read the parsed AST and stay silent when no parser is wired.

Each Columns spec is a bare column or a table-qualified table.column (a leading schema is ignored), and both halves accept glob wildcards (* ? [..]):

querytrace.Columns("deleted_at")               // any table, column deleted_at
querytrace.Columns("deleted_at", "deactivated_at") // either marker
querytrace.Columns("*_at")                      // any column ending in _at
querytrace.Columns("orders.deleted_at")         // only the orders table
querytrace.Columns("tenant_*.tenant_id")        // tables matching tenant_*

A bare column governs every table; a table-qualified spec restricts the requirement to the matching tables and leaves the rest unchecked — a second way to scope the check alongside ScopedToSchema.

Scoping to a schema

A bare Columns spec governs every analyzed base table, which false-positives on a table that has no tenant or soft-delete column at all — a lookup or reference table (countries, currencies) that legitimately never filters on one. There are two ways to scope a check to the right tables:

// 1. By hand, with table-qualified specs: only the named tables are checked.
querytrace.WithSoftDeleteCheck(
    querytrace.Columns("users.deleted_at", "orders.deleted_at"),
)

// 2. From the resolved schema: a check applies to a table only if that table
// actually has a matching marker column, derived from the meta.Metadata your
// decl catalog builds (or the introspect produces). Reference tables are skipped
// automatically, and it stays correct as the schema evolves.
querytrace.WithSoftDeleteCheck(
    querytrace.Columns("deleted_at"),
    querytrace.ScopedToSchema(catalog), // meta.Metadata
)

Table and column names match by bare, case-insensitive name, so a schema-qualified statement (public.users) lines up with a bare declaration. A statement that touches several in-scope tables and misses the predicate on more than one raises a warning per table, naming each in Details["table"].

Transaction linking

Each statement run inside a database/sql transaction records the id of that transaction on its event (QueryInfo.TxID), so a report — and the OpenTelemetry export — can attribute every query and exec to the BEGIN/COMMIT pair around it. Statements run on a pooled connection after the transaction ends carry no tx id again. Linking follows database/sql's connection ownership, which holds one driver connection for the life of a transaction.

Sampling

In production you usually want to observe a fraction of traffic, not every request, so analysis and caller capture stay off the hot path most of the time. A Sampler decides once per Trace, at creation, whether that Trace records; a sampled-out Trace turns the wrapped driver into a thin pass-through for its whole lifetime and reports nothing. Sampling is a whole-Trace decision on purpose — a per-statement sampler would fragment a trace and make N+1 detection and query budgets meaningless.

// Record ~5% of traces; the rest are pass-throughs.
trace := querytrace.New("GET /users/:id", querytrace.WithSampler(querytrace.SampleFraction(0.05)))

AlwaysSample (the default), NeverSample, and SampleFraction(p) cover the common cases; implement Sampler for anything else (per-tenant, header-driven, …). The OpenTelemetry middleware takes the same sampler with otelquery.WithSampler, and skips exporting a sampled-out request entirely.

Dialects

Only PostgreSQL has a parser today; other dialects use the string-analysis fallback, which still recognizes the statement kind and fingerprints queries but cannot drive the AST-based checks (missing WHERE, large IN, SELECT *, and the tenant/soft-delete predicates).

Documentation

Overview

Package querytrace is an application-level SQL tracing middleware for database/sql. It records the queries a unit of work issues together with their application context — the operation, the calling code, the transaction, and how the result rows were consumed — and surfaces problems that database-side statistics cannot see: N+1 patterns, queries in a loop, query-count blow-ups, writes where only reads were expected, and rows left unclosed.

It is not an execution-plan analyzer or an APM agent. Plan/index/lock analysis belongs to the database and to OpenTelemetry/APM; querytrace answers a different question — "what did this request/usecase ask the database to do, and from where?"

The center of the design is the Trace, a per-operation recorder placed in a context. A wrapped database/sql driver (subpackage driver) records an Event per statement against the Trace it finds in the context, and a Trace turns its Events into a Report and a set of Warnings. SQL is analyzed best-effort through an injected sqlparse.Parser; analysis never aborts execution.

Index

Constants

View Source
const (
	StmtUnknown = analysis.StmtUnknown
	StmtSelect  = analysis.StmtSelect
	StmtInsert  = analysis.StmtInsert
	StmtUpdate  = analysis.StmtUpdate
	StmtDelete  = analysis.StmtDelete
	StmtDDL     = analysis.StmtDDL
)

The statement kinds, re-exported from analyze so querytrace callers (and querytest matchers) refer to them without importing analyze directly.

View Source
const (
	MetricStatements        = "querytrace.statements"              // counter, label "kind"
	MetricUniqueFingerprint = "querytrace.unique_fingerprints"     // counter
	MetricTransactions      = "querytrace.transactions"            // counter
	MetricWarnings          = "querytrace.warnings"                // counter, label "code"
	MetricQueryDuration     = "querytrace.query_duration"          // duration
	MetricRowsIteration     = "querytrace.rows_iteration_duration" // duration
)

Metric names emitted by Metrics.Emit. They are stable strings so dashboards and alerts can rely on them.

Variables

This section is empty.

Functions

func ForbidQueries

func ForbidQueries(ctx context.Context) context.Context

ForbidQueries marks ctx as forbidding all database access: any statement run under it raises query_forbidden. It asserts a path is served without touching the database, such as a cache hit.

func ForbidWrites

func ForbidWrites(ctx context.Context) context.Context

ForbidWrites marks ctx as forbidding writes: a write run under it raises write_forbidden. Reads are allowed.

func Mark

func Mark(ctx context.Context, name string, attrs ...Attr)

Mark places a named marker on the context's Trace timeline, interleaving an application step (a cache miss, an external call) with the queries around it. It is a no-op when ctx carries no Trace.

func Named

func Named(ctx context.Context, name string, fn func(ctx context.Context) error) error

Named runs fn with name attached to the statements it issues, scoping the query name to exactly one call site. It is the function-shaped form of WithQueryName:

err := querytrace.Named(ctx, "UserRepository.FindByID", func(ctx context.Context) error {
	return repo.FindByID(ctx, id)
})

func ReadOnly

func ReadOnly(ctx context.Context) context.Context

ReadOnly marks ctx as read-only: a write run under it raises write_in_read_only. Reads are allowed. It is convenient in tests that assert a code path does not mutate the database.

func SetOperation

func SetOperation(ctx context.Context, name string)

SetOperation sets the operation name on the context's Trace, naming the unit of work after it has been created (for example once an HTTP route is known). Like Mark, it acts on the Trace already carried by ctx rather than returning a new context, and is a no-op when ctx carries no Trace.

func TextReport

func TextReport(t *Trace) string

TextReport renders a human-readable report for the Trace, the form most useful in development logs and test failures.

func WantCaller

func WantCaller(mode CallerMode, err error, dur, slow time.Duration) bool

WantCaller reports whether a statement with the given outcome should have its caller captured under mode. It is part of the instrumentation contract used by wrapped drivers.

func WithLabel

func WithLabel(ctx context.Context, key, value string) context.Context

WithLabel attaches a key/value label to ctx, copied onto every Event recorded under it. Labels accumulate; later keys override earlier ones.

func WithQueryName

func WithQueryName(ctx context.Context, name string) context.Context

WithQueryName attaches a query name to ctx, applied to the statements run under it (for example "UserRepository.FindByID"). The name is sticky: it stays until overridden. Prefer Named when the name should apply to a single call.

func WithTrace

func WithTrace(ctx context.Context, trace *Trace) context.Context

WithTrace returns a context carrying trace, so a wrapped driver records the statements run under it against trace.

Types

type ArgCaptureMode

type ArgCaptureMode int

ArgCaptureMode controls how much of a statement's bind arguments are kept. Values are sensitive, so the default keeps none.

const (
	// ArgsOff keeps no argument information beyond the count.
	ArgsOff ArgCaptureMode = iota
	// ArgsTypesOnly keeps the Go type name of each argument, not its value.
	ArgsTypesOnly
)

type Attr

type Attr struct {
	Key   string
	Value string
}

Attr is a key/value detail passed to Mark.

func L

func L(key, value string) Attr

L builds an Attr, shorthand for Mark call sites.

type Caller

type Caller struct {
	// Function is the fully qualified function name.
	Function string
	// File is the source file path.
	File string
	// Line is the line number.
	Line int
}

Caller is the application code that issued a statement.

func CaptureCaller

func CaptureCaller() *Caller

CaptureCaller walks the stack and returns the first frame outside querytrace and the database/sql machinery — the application code that issued the statement. It returns nil when no such frame is found. It is part of the instrumentation contract used by wrapped drivers; call it from the driver method that runs the statement so the application frame is reached.

type CallerMode

type CallerMode int

CallerMode controls when the wrapped driver captures the calling code for an Event. Capturing a caller walks the stack, so the cost is paid only when the mode says it is worth it.

const (
	// CallerOff never captures a caller.
	CallerOff CallerMode = iota
	// CallerOnError captures a caller only for statements that errored.
	CallerOnError
	// CallerOnSlowQuery captures a caller for errors and for statements slower
	// than the slow-query threshold (WithSlowQueryThreshold).
	CallerOnSlowQuery
	// CallerAlways captures a caller for every statement. Best for development
	// and tests, where caller attribution drives N+1 and loop detection.
	CallerAlways
)

type Config

type Config struct {
	// contains filtered or unexported fields
}

Config configures a wrapped driver and the Traces it records. It is an opaque value built by NewConfig and the With* options; the zero value is a disabled, pass-through Config.

func NewConfig

func NewConfig(opts ...ConfigOption) Config

NewConfig returns a Config built from opts. With no options it is enabled with production-minded defaults: no raw SQL, argument *types* only (never values), callers captured on error, fingerprinting, structural analysis, and N+1 detection on, and the PostgreSQL dialect. Layer options to capture more for development (WithVerboseCapture) or to adjust any single setting.

// Development: capture everything, wire a parser for structural analysis.
cfg := querytrace.NewConfig(
	querytrace.WithParser(postgres.New()),
	querytrace.WithVerboseCapture(),
)

// Production: defaults are already safe; just wire the parser.
cfg := querytrace.NewConfig(querytrace.WithParser(postgres.New()))

func (Config) Analyze

func (c Config) Analyze(sqlText string, args []any) *QueryInfo

Analyze produces the structural QueryInfo for a statement from its SQL text and bind arguments. It is best-effort and never returns an error: a parse failure is recorded in AnalyzeErr and the analysis falls back to a keyword guess, so observability never interferes with execution. It is part of the instrumentation contract used by wrapped drivers.

func (Config) Dialect

func (c Config) Dialect() sql.Dialect

Dialect returns the configured SQL dialect, defaulting to PostgreSQL when none was set. It never returns nil.

func (Config) Enabled

func (c Config) Enabled() bool

Enabled reports whether the Config records traces. A disabled Config (the zero value) makes the wrapped driver a thin pass-through.

func (Config) WantCaller

func (c Config) WantCaller(err error, dur time.Duration) bool

WantCaller reports whether a statement that finished with err after dur should have its calling code captured, under the configured CallerMode and slow-query threshold. A wrapped driver consults it before walking the stack.

type ConfigOption

type ConfigOption func(*Config)

ConfigOption configures a Config built by NewConfig. Options compose, so a caller layers only the choices that differ from the defaults.

func WithArgCapture

func WithArgCapture(mode ArgCaptureMode) ConfigOption

WithArgCapture controls how much of the bind arguments are kept.

func WithCaller

func WithCaller(mode CallerMode) ConfigOption

WithCaller controls when the calling code is captured.

func WithDialect

func WithDialect(d sql.Dialect) ConfigOption

WithDialect sets the SQL dialect used to recompile parsed queries into fingerprints. A nil dialect is ignored, leaving the PostgreSQL default.

func WithMaxInList

func WithMaxInList(n int) ConfigOption

WithMaxInList sets the IN (...) value-list budget: a statement with an IN list of n or more items raises large_in_list. It reads the parsed AST, so it stays silent on the keyword-fallback path. Zero (the default) disables the check.

func WithMaxParams

func WithMaxParams(n int) ConfigOption

WithMaxParams sets the bind-parameter budget: a statement carrying n or more bind parameters raises too_many_parameters. Zero (the default) disables the check.

func WithMaxQueries

func WithMaxQueries(n int) ConfigOption

WithMaxQueries sets the per-Trace query-count budget: a Trace with more read and write statements than n raises too_many_queries. Zero (the default) disables the check.

func WithNPlusOneIgnore

func WithNPlusOneIgnore(ops ...StmtKind) ConfigOption

WithNPlusOneIgnore excludes the given statement kinds from repeated-query detection; repeated INSERTs into a log table, for example, are usually expected.

func WithNPlusOneThreshold

func WithNPlusOneThreshold(minCount int) ConfigOption

WithNPlusOneThreshold sets how many times one query shape must repeat within a Trace before it is reported as a query-in-loop or possible N+1. Detection is on by default at a threshold of 10; a value <= 0 restores that default.

func WithParser

func WithParser(p sqlparse.Parser) ConfigOption

WithParser sets the parser that drives structural analysis and fingerprinting. Pass a frontend's parser, for example postgres.New() from github.com/aita/sqlkit/sqlparse/postgres. Without a parser, analysis falls back to lightweight string heuristics.

func WithRawSQL

func WithRawSQL() ConfigOption

WithRawSQL keeps the executed SQL text on each statement. Leave it off for production exporters; raw SQL can carry sensitive shapes.

func WithSelectStarWarning

func WithSelectStarWarning() ConfigOption

WithSelectStarWarning enables select_star, which fires on a SELECT * query. It reads the parsed AST, so it stays silent on the keyword-fallback path.

func WithSlowQueryThreshold

func WithSlowQueryThreshold(d time.Duration) ConfigOption

WithSlowQueryThreshold sets the duration above which a query counts as slow.

func WithSoftDeleteCheck

func WithSoftDeleteCheck(opts ...PredicateOption) ConfigOption

WithSoftDeleteCheck enables missing_soft_delete_predicate: a SELECT touching a base table whose top-level WHERE references none of the configured marker columns. Configure it with Columns (required) and, optionally, ScopedToSchema. The check reads the parsed AST, so it stays silent on the keyword-fallback path.

querytrace.WithSoftDeleteCheck(querytrace.Columns("deleted_at"), querytrace.ScopedToSchema(md))

func WithTenantCheck

func WithTenantCheck(opts ...PredicateOption) ConfigOption

WithTenantCheck enables missing_tenant_predicate: a read or write touching a base table whose top-level WHERE references none of the configured marker columns. Configure it with Columns (required) and, optionally, ScopedToSchema. The check reads the parsed AST, so it stays silent on the keyword-fallback path.

querytrace.WithTenantCheck(querytrace.Columns("tenant_id"), querytrace.ScopedToSchema(md))

func WithVerboseCapture

func WithVerboseCapture() ConfigOption

WithVerboseCapture turns on the development-grade capture the safe defaults leave off: raw SQL and a caller for every statement. It captures more than is appropriate for production.

func WithoutAnalyze

func WithoutAnalyze() ConfigOption

WithoutAnalyze turns off structural analysis. Analysis is on by default and backs the predicate and shape warnings (large_in_list, select_star, the tenant/soft-delete predicates), so disabling it also disables those.

func WithoutFingerprint

func WithoutFingerprint() ConfigOption

WithoutFingerprint turns off SQL fingerprinting. Fingerprinting is on by default and underpins repeated-query and N+1 detection, so disabling it also disables those.

func WithoutNPlusOne

func WithoutNPlusOne() ConfigOption

WithoutNPlusOne turns off repeated-query and loop detection, which is on by default.

type Event

type Event struct {
	// ID identifies the event within its Trace.
	ID string
	// Kind is the kind of event.
	Kind EventKind
	// Name is an optional application label for the event, such as a query
	// name ("UserRepository.FindByID") set with WithQueryName or Named.
	Name string

	// StartedAt is when the operation began.
	StartedAt time.Time
	// EndedAt is when it finished; zero until then.
	EndedAt time.Time
	// Duration is EndedAt-StartedAt, the time the statement took to return
	// (for a query, the time until the rows cursor is returned — not the time
	// to iterate it; see Rows).
	Duration time.Duration

	// Query carries SQL details for EventQuery and EventExec.
	Query *QueryInfo
	// Tx carries transaction details for EventTx.
	Tx *TxInfo
	// Rows carries row-cursor lifecycle details for a read whose cursor was
	// wrapped; nil for execs and for reads that returned no wrapped cursor.
	Rows *RowsInfo

	// Caller is the application code that issued the statement, captured
	// subject to the configured CallerMode (WithCaller).
	Caller *Caller
	// Labels are context labels attached with WithLabel.
	Labels map[string]string

	// Err is the error the statement returned, if any.
	Err error
}

Event is one recorded point on a Trace's timeline: a query, an exec, a transaction control point, or a mark. Events are ordered by StartedAt.

type EventKind

type EventKind string

EventKind is the kind of thing an Event records. SQL reads and writes are the common cases; the kind is an enum so transaction and mark events can share the timeline.

const (
	// EventQuery is a row-returning read (SELECT and other reads).
	EventQuery EventKind = "query"
	// EventExec is a write returning a result (INSERT/UPDATE/DELETE/DDL).
	EventExec EventKind = "exec"
	// EventTx is a transaction control point (begin/commit/rollback).
	EventTx EventKind = "tx"
	// EventMark is an application-supplied marker on the timeline.
	EventMark EventKind = "mark"
)

type ExpvarRecorder

type ExpvarRecorder struct {
	// contains filtered or unexported fields
}

ExpvarRecorder is a MetricsRecorder that publishes a trace's metrics to an expvar.Map, exposing them on the standard /debug/vars endpoint with nothing but the standard library. Counters accumulate as expvar integers and durations as a running total in seconds (expvar has no histogram, so the distribution is not retained — only the sum, a monotonic counter of time).

Labels are folded into the variable name, sorted for stability, e.g. "querytrace.statements{kind=select,operation=GET /users}", since expvar has no label dimensions.

func NewExpvarRecorder

func NewExpvarRecorder(root *expvar.Map) *ExpvarRecorder

NewExpvarRecorder returns a recorder backed by root. Publish root once under a name of your choosing — typically expvar.NewMap("querytrace") — and reuse the recorder across traces; every trace's metrics accumulate into it.

var dbMetrics = querytrace.NewExpvarRecorder(expvar.NewMap("querytrace"))
// ... per request:
defer dbMetrics.Record(trace) // or trace.RecordMetrics(dbMetrics)

func (*ExpvarRecorder) Counter

func (r *ExpvarRecorder) Counter(name string, value int64, labels ...Label)

Counter adds value to the integer variable named by name and labels.

func (*ExpvarRecorder) Duration

func (r *ExpvarRecorder) Duration(name string, d time.Duration, labels ...Label)

Duration adds d, in seconds, to the float variable named by name and labels — a running total, since expvar cannot record a distribution.

func (*ExpvarRecorder) Record

func (r *ExpvarRecorder) Record(trace *Trace)

Record emits trace's metrics into the expvar map, the one-call form of trace.RecordMetrics(r).

type Label

type Label struct {
	Key   string
	Value string
}

Label is a metric dimension, a key/value pair attached to an emitted metric.

type LogExporter

type LogExporter struct {
	// contains filtered or unexported fields
}

LogExporter logs traces to an slog.Logger. It is the summary form of tracing output: at the end of a unit of work, log one record of the trace's counts and its warnings, rather than a line per query. For richer, span-based output use the separate otelquery module.

exporter := querytrace.NewLogExporter(logger)
defer exporter.ExportTrace(ctx, trace)

func NewLogExporter

func NewLogExporter(logger *slog.Logger, opts ...LogOption) *LogExporter

NewLogExporter returns a LogExporter that logs to logger.

func (*LogExporter) ExportTrace

func (e *LogExporter) ExportTrace(ctx context.Context, trace *Trace)

ExportTrace logs a summary of the trace: its operation and counts at info level, raised to warn when it produced warnings, followed by one record per warning. It honors the configured LogMode.

type LogMode

type LogMode int

LogMode selects what a LogExporter logs.

const (
	// LogSummary logs one record per trace with its counts and warnings.
	LogSummary LogMode = iota
	// LogWarningsOnly logs only traces that produced at least one warning.
	LogWarningsOnly
	// LogOff logs nothing.
	LogOff
)

type LogOption

type LogOption func(*LogExporter)

LogOption configures a LogExporter.

func WithLogMode

func WithLogMode(m LogMode) LogOption

WithLogMode sets the logging mode.

type MarkInfo

type MarkInfo struct {
	// Name is the marker text.
	Name string
	// At is when the mark was placed.
	At time.Time
	// Attrs are optional key/value details.
	Attrs map[string]string
}

MarkInfo is an application event placed on the timeline with Mark, such as "calling payment API" or "cache miss". Marks make a transaction's timeline readable by interleaving non-SQL steps with the queries around them.

type Metrics

type Metrics struct {
	// Operation is the unit of work the trace covers, the natural metric label.
	Operation string

	// QueryCount is the number of read and write statements.
	QueryCount int
	// WriteCount is the number of write statements (INSERT/UPDATE/DELETE).
	WriteCount int
	// UniqueFingerprints is the number of distinct query shapes.
	UniqueFingerprints int
	// TransactionCount is the number of transactions begun.
	TransactionCount int

	// TotalQueryDuration is the summed time statements took to return.
	TotalQueryDuration time.Duration
	// TotalRowsIterationDuration is the summed time spent iterating row cursors.
	TotalRowsIterationDuration time.Duration

	// ByStatement counts statements per kind (select/insert/update/delete/ddl).
	ByStatement map[StmtKind]int
	// Warnings counts warnings per code (n+1, query budget, select star, ...).
	Warnings map[WarningCode]int
	// WarningsBySeverity counts warnings per severity.
	WarningsBySeverity map[Severity]int
}

Metrics is the numeric aggregate of a Trace, ready to emit to any metrics backend. It is a plain value — compute it with Trace.Metrics, inspect it, or push it through a MetricsRecorder with Emit. It deliberately depends on nothing but the standard library, so an application wires it to Prometheus, OpenTelemetry metrics, statsd, or its own counters by implementing the two-method MetricsRecorder; querytrace does not pull in an instrumentation library.

func MetricsFromReport

func MetricsFromReport(r Report) Metrics

MetricsFromReport derives Metrics from an already-built Report, for callers that produce the Report for other purposes too.

func (Metrics) Emit

func (m Metrics) Emit(rec MetricsRecorder)

Emit pushes the metrics to rec under stable names (the Metric* constants), tagging each with the trace's operation when set. Statements are emitted per kind and warnings per code, so a backend can break them down.

type MetricsRecorder

type MetricsRecorder interface {
	Counter(name string, value int64, labels ...Label)
	Duration(name string, d time.Duration, labels ...Label)
}

MetricsRecorder is the sink Metrics.Emit pushes to: an adapter over a real metrics backend. Counter adds a delta to a named counter and Duration records a duration observation (a histogram/summary sample), each tagged with zero or more labels. Implement it over prometheus.CounterVec/HistogramVec, an OpenTelemetry meter, or any other backend.

type Option

type Option func(*Trace)

Option configures a Trace at creation.

func WithConfig

func WithConfig(cfg Config) Option

WithConfig sets the Trace's warning configuration up front. Normally a wrapped driver stamps its own Config onto the Trace on first use, so this is mainly for tests that build a Trace directly.

func WithSampled

func WithSampled(sampled bool) Option

WithSampled sets the Trace's sampling decision directly, for callers that have already decided whether to observe this unit of work. WithSampled(false) makes the wrapped driver a pass-through.

func WithSampler

func WithSampler(s Sampler) Option

WithSampler decides at creation whether the Trace records, by calling s.ShouldSample once. A sampled-out Trace makes the wrapped driver a pass-through for its lifetime: it records no statements and reports nothing. Without this option (or WithSampled) a Trace always records.

type PredicateOption

type PredicateOption func(*predicateConfig)

PredicateOption configures a tenant or soft-delete predicate check: the marker columns it requires (Columns) and, optionally, the schema scope that limits it to the tables that actually have one (ScopedToSchema). It is the argument type of WithTenantCheck and WithSoftDeleteCheck.

func Columns

func Columns(specs ...string) PredicateOption

Columns sets the marker column specs a predicate check looks for in a statement's top-level WHERE, such as "tenant_id"/"org_id" for a tenant check or "deleted_at" for a soft-delete check. Each spec is a bare column or a table-qualified "table.column" (a leading schema is ignored), and both halves accept glob wildcards (* ? [..]), matched case-insensitively:

Columns("deleted_at")              // any table, column deleted_at
Columns("deleted_at", "deactivated_at") // either marker, any table
Columns("*_at")                    // any table, any column ending in _at
Columns("orders.deleted_at")       // only the orders table
Columns("*.deleted_at")            // explicit any-table form

A bare column governs every table; a table-qualified spec restricts the requirement to the matching tables, so other tables are left unchecked. Columns is required: a check with no columns does nothing.

func ScopedToSchema

func ScopedToSchema(md meta.Metadata) PredicateOption

ScopedToSchema scopes a predicate check to the base tables that, per the resolved schema md, actually have a marker column — eliminating the false positive the check otherwise raises on a table that has no such column (a lookup or reference table). Without it the check applies to every analyzed base table. Pass the meta.Metadata a decl catalog builds or the introspect produces. To scope by hand instead, name the tables in the Columns specs ("orders.deleted_at").

type QueryInfo

type QueryInfo struct {
	// Analysis is the static SQL analysis of the statement, produced by the analyze
	// framework. Its fields are promoted onto QueryInfo.
	analysis.Analysis

	// RawSQL is the executed SQL text, kept only when raw SQL capture is on
	// (WithRawSQL); otherwise empty.
	RawSQL string

	// ParamCount is the number of bind arguments.
	ParamCount int
	// ParamTypes are the Go type names of the bind arguments, kept when argument
	// capture is ArgsTypesOnly or higher (WithArgCapture).
	ParamTypes []string

	// TxID links the statement to its transaction, empty outside one.
	TxID string
}

QueryInfo holds the SQL details of an EventQuery or EventExec. It is split into two halves: the static analysis of the statement — the embedded analysis.Analysis, the facts derived from the SQL itself (kind, tables, clause shape, fingerprint), owned and produced by the analyze framework — and the runtime details querytrace captures around execution (raw text, parameters, transaction). The Analysis fields are promoted, so qi.Kind, qi.Tables, qi.HasTopLevelWhere, and the rest read directly. Raw SQL and argument values are kept only when configuration allows; the static fields are always safe to keep.

type Report

type Report struct {
	// TraceID is the trace's identifier.
	TraceID string
	// Operation is the unit of work the trace covers.
	Operation string
	// StartedAt is when the Trace was created, the start of the unit of work.
	StartedAt time.Time

	// QueryCount is the number of read and write statements.
	QueryCount int
	// WriteCount is the number of write statements.
	WriteCount int
	// UniqueFingerprintCount is the number of distinct query shapes.
	UniqueFingerprintCount int

	// TotalQueryDuration is the summed time statements took to return.
	TotalQueryDuration time.Duration
	// TotalRowsIterationDuration is the summed time spent iterating row
	// cursors.
	TotalRowsIterationDuration time.Duration

	// Events is the recorded timeline, in record order.
	Events []Event
	// Marks is the recorded application marks.
	Marks []MarkInfo
	// Warnings is every warning for the trace.
	Warnings []Warning
}

Report is the summary of a Trace: the counts and durations of its statements, the timeline of events and marks, and the warnings. It is a plain value, safe to marshal to JSON or render with TextReport.

func (Report) String

func (r Report) String() string

String renders the Report as text: a header line of counts, the warnings, and the numbered timeline of queries and marks.

type RowsInfo

type RowsInfo struct {
	// QueryReturnedAt is when QueryContext handed back the cursor.
	QueryReturnedAt time.Time
	// FirstNextAt is when the first row was read; zero if none was.
	FirstNextAt time.Time
	// LastNextAt is when the last row was read.
	LastNextAt time.Time
	// ClosedAt is when the cursor was closed; zero if it never was.
	ClosedAt time.Time

	// NextCount is the number of rows read from the cursor.
	NextCount int
	// Closed reports whether the cursor was closed.
	Closed bool

	// IterErr is the error that ended row iteration, if any: the first non-EOF
	// error returned by Next, or — when database/sql closed the cursor in
	// response to a context cancellation rather than surfacing an error through
	// Next — the context's error captured at Close. It is nil when the cursor was
	// drained to completion. A context.Canceled or context.DeadlineExceeded here
	// means the request was abandoned mid-stream, after the database had begun
	// returning rows.
	IterErr error

	// IterationDuration is LastNextAt-QueryReturnedAt, the time spent reading
	// rows.
	IterationDuration time.Duration
	// OpenDuration is ClosedAt-QueryReturnedAt, how long the cursor stayed
	// open.
	OpenDuration time.Duration
}

RowsInfo records the lifecycle of a query's row cursor — the cost that QueryContext alone hides. The difference between the query returning, the rows being iterated, and the cursor being closed is what distinguishes a fast query with a slow scan from a genuinely slow query.

type Sampler

type Sampler interface {
	// ShouldSample reports whether a new Trace should record. It is called once
	// per Trace and may be probabilistic.
	ShouldSample() bool
}

Sampler decides whether a Trace records the statements run under it. The decision is made once, when the Trace is created (see New and WithSampler), so a sampled-out Trace turns the wrapped driver into a thin pass-through for its whole lifetime — every statement under it is cheap, and the Trace records and reports nothing. Sampling keeps the per-statement overhead of analysis, fingerprinting, and caller capture off the hot path in production while still observing a representative fraction of traffic.

Sampling is a whole-Trace decision on purpose: a fractional, per-statement sampler would fragment a trace, leaving partial statement sets that make N+1 detection and query budgets meaningless. Either a Trace is observed in full or not at all.

func AlwaysSample

func AlwaysSample() Sampler

AlwaysSample returns a Sampler that records every Trace. It is the default behavior when no sampler is set.

func NeverSample

func NeverSample() Sampler

NeverSample returns a Sampler that records no Trace, turning the wrapped driver into a pass-through. It is useful to disable observation behind a flag without changing call sites.

func SampleFraction

func SampleFraction(p float64) Sampler

SampleFraction returns a Sampler that records each Trace independently with probability p. A p at or below 0 never samples and a p at or above 1 always does, so SampleFraction(0) and SampleFraction(1) match NeverSample and AlwaysSample. The decision uses the default math/rand source; seed it (or supply a custom Sampler) when a deterministic stream is needed.

type SamplerFunc

type SamplerFunc func() bool

SamplerFunc adapts a plain function to a Sampler.

func (SamplerFunc) ShouldSample

func (f SamplerFunc) ShouldSample() bool

ShouldSample calls f.

type Severity

type Severity int

Severity ranks a Warning's importance.

const (
	// SeverityInfo is advisory.
	SeverityInfo Severity = iota
	// SeverityWarn is a likely problem.
	SeverityWarn
	// SeverityError is a strong expectation violation, such as a forbidden
	// query.
	SeverityError
)

func (Severity) String

func (s Severity) String() string

String renders the severity.

type StmtKind

type StmtKind = analysis.StmtKind

StmtKind classifies a SQL statement by the kind of statement it represents. It is an alias for analysis.StmtKind: the statement analysis lives in the analyze package, but querytrace keeps the name so QueryInfo.Kind and NPlusOne.IgnoreOperations stay typed and the StmtXxx constants below read as part of the querytrace API.

type Trace

type Trace struct {
	// contains filtered or unexported fields
}

Trace records the statements of one unit of work — a request, a job, a usecase — and the warnings querytrace derives from them. Place a Trace in a context with WithTrace; a wrapped driver then records an Event per statement against it. A Trace is safe for concurrent use, so parallel queries within one operation record correctly.

func FromContext

func FromContext(ctx context.Context) (*Trace, bool)

FromContext returns the Trace carried by ctx, if any.

func New

func New(operation string, opts ...Option) *Trace

New creates a Trace for the named operation, such as "GET /users/:id" or "CreateOrder".

func (*Trace) EnsureConfig

func (t *Trace) EnsureConfig(cfg Config)

EnsureConfig stamps cfg onto the Trace the first time it is recorded against, making the wrapping driver's Config the source of truth for warning thresholds. It is part of the instrumentation contract used by wrapped drivers; applications do not call it.

func (*Trace) Events

func (t *Trace) Events() []Event

Events returns a copy of the recorded statement and transaction events, in record order.

func (*Trace) ID

func (t *Trace) ID() string

ID returns the trace's identifier.

func (*Trace) Marks

func (t *Trace) Marks() []MarkInfo

Marks returns a copy of the recorded marks.

func (*Trace) Metrics

func (t *Trace) Metrics() Metrics

Metrics computes the aggregate for the Trace.

func (*Trace) Operation

func (t *Trace) Operation() string

Operation returns the trace's operation name.

func (*Trace) RecordMetrics

func (t *Trace) RecordMetrics(rec MetricsRecorder)

RecordMetrics computes the Trace's Metrics and emits them to rec. It is the one-call form of t.Metrics().Emit(rec), typically deferred alongside the unit of work the way otelquery.ExportTrace is.

func (*Trace) RecordStatement

func (t *Trace) RecordStatement(ctx context.Context, ev Event) string

RecordStatement records a completed query or exec event, filling its name and labels from ctx and running the immediate per-statement checks against the context's expectations. It returns the assigned event ID. It is part of the instrumentation contract used by wrapped drivers.

func (*Trace) RecordTxBegin

func (t *Trace) RecordTxBegin() string

RecordTxBegin allocates a transaction identifier, records a begin event, and returns the identifier for the wrapped transaction to carry. It is part of the instrumentation contract used by wrapped drivers.

func (*Trace) RecordTxEnd

func (t *Trace) RecordTxEnd(action TxAction, txID string)

RecordTxEnd records a commit or rollback event for txID, carrying the transaction's start time so the timeline can show its span. It is part of the instrumentation contract used by wrapped drivers.

func (*Trace) Report

func (t *Trace) Report() Report

Report builds the Report for the Trace.

func (*Trace) Sampled

func (t *Trace) Sampled() bool

Sampled reports whether the Trace records the statements run under it. It is fixed when the Trace is created (see WithSampler / WithSampled) and defaults to true. A wrapped driver consults it to skip recording for a sampled-out Trace.

func (*Trace) StartedAt

func (t *Trace) StartedAt() time.Time

StartedAt returns when the Trace was created, the start of the unit of work it records. Exporters use it as the start of the operation-level span.

func (*Trace) Warnings

func (t *Trace) Warnings() []Warning

Warnings returns every warning for the Trace: the per-statement warnings raised as it ran, plus the whole-trace warnings (N+1, query-in-loop, too-many-queries, rows-not-closed) derived from its events. It is safe to call repeatedly; derived warnings are recomputed each time, not duplicated.

type TxAction

type TxAction string

TxAction is a transaction control point.

const (
	// TxBegin marks the start of a transaction.
	TxBegin TxAction = "begin"
	// TxCommit marks a commit.
	TxCommit TxAction = "commit"
	// TxRollback marks a rollback.
	TxRollback TxAction = "rollback"
)

type TxInfo

type TxInfo struct {
	// TxID identifies the transaction within the Trace.
	TxID string
	// Action is the control point this event records.
	Action TxAction
	// StartedAt is when the transaction began; set on commit/rollback events
	// so the timeline can show the transaction's duration.
	StartedAt time.Time
}

TxInfo holds the details of an EventTx.

type Warning

type Warning struct {
	// Code identifies the kind of warning.
	Code WarningCode
	// Message is a human-readable description.
	Message string
	// Severity ranks the warning.
	Severity Severity
	// EventID links the warning to the Event that triggered it, when one did.
	EventID string
	// Caller is the application code involved, when known.
	Caller *Caller
	// Details carries structured specifics (fingerprint, count, ...).
	Details map[string]any
}

Warning is one problem querytrace found in a Trace. Warnings are advisory: a Trace records them, exporters surface them, and querytest asserts on them, but they never interrupt execution.

type WarningCode

type WarningCode string

WarningCode identifies a kind of warning. Codes are stable strings so they can be matched in tests and exporters.

const (
	// WarnPossibleNPlusOne marks a fingerprint repeated above the N+1
	// threshold within a Trace.
	WarnPossibleNPlusOne WarningCode = "possible_n_plus_one"
	// WarnQueryInLoop marks the same fingerprint repeated from the same caller,
	// the signature of a query inside a loop.
	WarnQueryInLoop WarningCode = "query_in_loop"
	// WarnTooManyQueries marks a Trace exceeding its query budget.
	WarnTooManyQueries WarningCode = "too_many_queries"
	// WarnWriteInReadOnly marks a write under a ReadOnly context.
	WarnWriteInReadOnly WarningCode = "write_in_read_only"
	// WarnQueryForbidden marks any statement under a ForbidQueries context.
	WarnQueryForbidden WarningCode = "query_forbidden"
	// WarnWriteForbidden marks a write under a ForbidWrites context.
	WarnWriteForbidden WarningCode = "write_forbidden"
	// WarnUpdateWithoutWhere marks an UPDATE with no WHERE clause.
	WarnUpdateWithoutWhere WarningCode = "update_without_where"
	// WarnDeleteWithoutWhere marks a DELETE with no WHERE clause.
	WarnDeleteWithoutWhere WarningCode = "delete_without_where"
	// WarnRowsNotClosed marks a row cursor that was never closed.
	WarnRowsNotClosed WarningCode = "rows_not_closed"
	// WarnQueryCanceled marks a row cursor abandoned through context
	// cancellation while it was still being iterated. Execution-level
	// cancellation, slow queries, and plain errors are left to OpenTelemetry
	// tracing, which records them as span duration and status; the mid-iteration
	// case is invisible to a query span that has already ended.
	WarnQueryCanceled WarningCode = "query_canceled"
	// WarnQueryDeadlineExceeded marks a row cursor that hit its context deadline
	// while it was still being iterated. See WarnQueryCanceled on scope.
	WarnQueryDeadlineExceeded WarningCode = "query_deadline_exceeded"
	// WarnTooManyParameters marks a statement carrying at or above the bind
	// parameter budget, the signature of a large IN list or oversized batch.
	WarnTooManyParameters WarningCode = "too_many_parameters"
	// WarnLargeInList marks a statement whose top-level WHERE carries an IN (...)
	// value list at or above the configured length, a shape that strains the
	// planner and the bind-parameter limit and is usually better expressed as a
	// join or a temporary table.
	WarnLargeInList WarningCode = "large_in_list"
	// WarnSelectStar marks a SELECT that projects every column with "*", which
	// over-fetches, couples the caller to the table's column set, and defeats
	// covering indexes.
	WarnSelectStar WarningCode = "select_star"
	// WarnMissingTenantPredicate marks a read or write that touches a base table
	// without referencing any of the required tenant-scoping columns in its
	// top-level WHERE, the signature of a query that can cross tenant boundaries.
	WarnMissingTenantPredicate WarningCode = "missing_tenant_predicate"
	// WarnMissingSoftDeletePredicate marks a SELECT that touches a base table
	// without referencing a soft-delete marker column in its top-level WHERE, so
	// it may return logically deleted rows.
	WarnMissingSoftDeletePredicate WarningCode = "missing_soft_delete_predicate"
	// WarnEqNull marks a comparison against a NULL literal with = or <>
	// (x = NULL / x <> NULL), which SQL evaluates to unknown rather than matching
	// NULL, so the predicate silently drops every row; IS NULL / IS NOT NULL is
	// meant.
	WarnEqNull WarningCode = "eq_null"
	// WarnWriteOutsideTransaction marks two or more writes issued outside any
	// transaction within one Trace — statements that cannot roll back together if
	// one fails, a consistency risk a single autocommit write does not carry.
	WarnWriteOutsideTransaction WarningCode = "write_outside_transaction"
	// WarnExternalCallInTransaction marks a Mark (an application step such as an
	// external API call) recorded while a transaction was open: work that holds the
	// transaction — and its locks and pinned connection — open across a
	// non-database operation.
	WarnExternalCallInTransaction WarningCode = "external_call_in_transaction"
)

Directories

Path Synopsis
Package driver wraps a database/sql/driver so that statements run through it are recorded on the querytrace.Trace carried by their context.
Package driver wraps a database/sql/driver so that statements run through it are recorded on the querytrace.Trace carried by their context.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL