querytrace

package module
v0.0.0-...-de5c544 Latest Latest
Warning

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

Go to latest
Published: Jun 16, 2026 License: MIT Imports: 12 Imported by: 0

README

querytrace (experimental)

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 experimental and lives under x/ with its own module.

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 Warnings.MaxParams bind arguments, the signature of an oversized IN list or batch.

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.
  • querytest/ — a test spy and mock 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).
  • slog/ — a summary exporter to log/slog.
  • otel/ — 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. DebugConfig wires one automatically from its dialect name, provided the matching frontend is blank-imported (for example github.com/aita/sqlkit/x/sqlparse/postgres); you can also set Config.Parser explicitly to override it. A parse failure or a missing parser falls back to lightweight string analysis and never aborts execution. The parser produces a sql.Query, which sql.Describe 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/x/querytrace"
    qtdriver "github.com/aita/sqlkit/x/querytrace/driver"

    // Blank-imported so its PostgreSQL parser registers itself; DebugConfig
    // then wires it from the "postgres" dialect name.
    _ "github.com/aita/sqlkit/x/sqlparse/postgres"
)

// Pick the dialect by name; the matching parser is wired automatically.
cfg := querytrace.DebugConfig("postgres")

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

To override the parser (for example a custom one), 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 querytest subpackage 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)
    }

    querytest.Expect(t, trace).
        MaxQueries(5).
        NoNPlusOne().
        Query(querytest.Select(), querytest.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)
    }
    querytest.Expect(t, trace).Warns(querytrace.WarnQueryForbidden)
}

For a unit test without a real database, querytest.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 (querytest.Lenient() opts out):

func TestLoadUser(t *testing.T) {
    db, mock := querytest.NewDB(t)
    mock.ExpectQuery(querytest.Contains("FROM users")).
        Return(querytest.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)
    }

    querytest.Expect(t, trace).
        NoWrites().
        Query(querytest.Select(), querytest.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(querytest.AnyColumn()).
        From(sql.Tbl("users")).
        Where(sql.Eq(sql.Col("id"), querytest.Arg(42))),
).Return(querytest.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.

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/x/querytrace/otel) so the core querytrace module carries no OpenTelemetry dependency.

import oteltrace "github.com/aita/sqlkit/x/querytrace/otel"

exporter := oteltrace.New(tp.Tracer("querytrace"), oteltrace.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 := oteltrace.New(tp.Tracer("querytrace"), oteltrace.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. DebugConfig(dialect) is the recommended starting point for development and tests; it captures raw SQL, argument types (never values), and callers, and enables analysis, fingerprinting, and N+1 detection. Production setups should capture less — leave raw SQL off and capture callers only on error or slow queries.

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

Status / not yet here

Out of scope for this first cut: strict transaction-to-query linking, sampling, and the more advanced analyzer warnings (tenant/soft-delete predicates, SELECT *, large IN). Only PostgreSQL has a parser today; other dialects use the string-analysis fallback.

Documentation

Overview

Package querytrace is an experimental, 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

This section is empty.

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 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 WithOperation

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

WithOperation 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). It is a no-op when ctx carries no Trace.

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 Config.SlowQueryThreshold.
	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 {
	// Enabled turns tracing on. A disabled Config makes the wrapped driver a
	// thin pass-through.
	Enabled bool

	// Dialect is the SQL dialect used to recompile a parsed query into its
	// fingerprint. It defaults to PostgreSQL when nil.
	Dialect sql.Dialect

	// Parser analyzes SQL text into a sql.Query for structural analysis and
	// fingerprinting. It is best-effort: a parse failure or a nil Parser falls
	// back to lightweight string analysis and never aborts execution.
	//
	// DebugConfig wires this automatically from its dialect argument, provided a
	// frontend such as github.com/aita/sqlkit/x/sqlparse/postgres is
	// blank-imported. Set it explicitly to override that, for example to use a
	// custom parser.
	Parser sqlparse.Parser

	// CaptureRawSQL keeps the executed SQL text on each QueryInfo. Leave it off
	// for production exporters; raw SQL can carry sensitive shapes.
	CaptureRawSQL bool
	// CaptureArgs controls how much of the bind arguments are kept.
	CaptureArgs ArgCaptureMode
	// CaptureCaller controls when the calling code is captured.
	CaptureCaller CallerMode

	// Fingerprint enables SQL fingerprinting, the basis of repeated-query and
	// N+1 detection.
	Fingerprint bool
	// AnalyzeSQL enables structural analysis (kind, tables, WHERE/JOIN/...),
	// the basis of write/where warnings.
	AnalyzeSQL bool

	// SlowQueryThreshold is the duration above which a query counts as slow,
	// for CallerOnSlowQuery.
	SlowQueryThreshold time.Duration

	// NPlusOne configures repeated-query and loop detection.
	NPlusOne NPlusOneConfig
	// Warnings configures the threshold-based warnings.
	Warnings WarningConfig
}

Config configures a wrapped driver and the Traces it records. The zero value is usable but minimal; DebugConfig is the recommended starting point for development and tests.

func DebugConfig

func DebugConfig(dialect string) Config

DebugConfig returns a Config tuned for development and tests for the named dialect ("postgres", "mysql", or "sqlite"; "" and unknown names default to postgres): raw SQL and argument types captured, callers always captured, fingerprinting and analysis on, and N+1 detection enabled. It captures more than is appropriate for production.

The matching sql.Dialect is stored in Dialect, and a parser registered for that dialect — via a blank-imported frontend such as github.com/aita/sqlkit/x/sqlparse/postgres — is wired into Parser automatically. Without such an import Parser stays nil and analysis falls back to lightweight string analysis.

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.

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 Config.CaptureCaller.
	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 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 NPlusOneConfig

type NPlusOneConfig struct {
	// Enabled turns repeated-query and loop detection on.
	Enabled bool
	// MinCount is how many times one fingerprint must repeat before it is
	// reported. It defaults to 10 when zero.
	MinCount int
	// IgnoreOperations skips fingerprints of these kinds; repeated INSERTs into
	// a log table, for example, are usually expected.
	IgnoreOperations []StmtKind
}

NPlusOneConfig configures detection of repeated query shapes within one Trace.

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.

type QueryInfo

type QueryInfo struct {
	// RawSQL is the executed SQL text, kept only when Config.CaptureRawSQL is
	// set; otherwise empty.
	RawSQL string
	// Fingerprint is the normalized SQL with literals and placeholders
	// collapsed, used to group repeated queries. It is empty when
	// fingerprinting is disabled.
	Fingerprint string

	// Operation is the statement kind, from analysis or a best-effort guess.
	Operation StmtKind
	// Tables are the tables the statement references, when analysis succeeded.
	Tables []string

	// HasWhere, HasJoin, HasLimit, HasReturning mirror Shape; they are
	// false when analysis could not determine them.
	HasWhere     bool
	HasJoin      bool
	HasLimit     bool
	HasReturning bool

	// ParamCount is the number of bind arguments.
	ParamCount int
	// ParamTypes are the Go type names of the bind arguments, kept when
	// Config.CaptureArgs is ArgsTypesOnly or higher.
	ParamTypes []string

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

	// AnalyzeErr records why structural analysis fell back to a guess, for
	// diagnostics. It is never an execution error.
	AnalyzeErr error
	// contains filtered or unexported fields
}

QueryInfo holds the SQL details of an EventQuery or EventExec. Raw SQL and argument values are captured only when configuration allows; the structural fields (kind, tables, shape, parameter count) 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 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 Shape

type Shape struct {
	// Kind is the statement kind.
	Kind StmtKind
	// Tables are the base tables the statement references anywhere in its tree,
	// in source order with duplicates removed. A reference to a CTE is not a base
	// table, so CTE names are excluded.
	Tables []string
	// HasWhere reports whether the top-level SELECT/UPDATE/DELETE carries a WHERE
	// clause. It backs the update_without_where / delete_without_where checks. It
	// is always false for a set operation, which has no top-level WHERE.
	HasWhere bool
	// HasJoin reports whether the top-level statement carries a JOIN clause.
	HasJoin bool
	// HasLimit reports whether the top-level SELECT or set operation carries a
	// LIMIT. A LIMIT on a UNION arm belongs to that arm and is not reflected here.
	HasLimit bool
	// HasReturning reports whether a write carries a RETURNING clause.
	HasReturning bool
}

Shape is a read-only description of a parsed query's structure, used to drive tracing and analysis without recompiling the query or re-parsing its SQL.

Two scopes are at play, by design:

  • Tables is aggregated across the whole tree: it follows FROM/JOIN subqueries, set-operation arms, CTE bodies, and the subqueries embedded in expressions, because "what does this statement touch?" wants every base table.
  • The boolean flags describe the top-level statement node only. A WHERE, JOIN, or LIMIT inside a subquery, a CTE body, or one arm of a UNION is that nested query's property, not the outer statement's.

So a UNION reports HasWhere/HasJoin false and HasLimit only for a LIMIT on the UNION itself, even when its arms filter, join, or limit.

func Describe

func Describe(q sql.Query) (Shape, bool)

Describe returns the Shape of a parsed query. ok is false when q is nil or of an unrecognized type. It reads the query's structure only, through the sql package's read-only introspection seam (Query.Parts, sql.WalkSubqueries), never compiling or mutating q.

Tables follows FROM/JOIN subqueries, set-operation arms, CTE bodies, and the subqueries embedded in WHERE/SELECT/HAVING expressions (correlated ones included). A reference to a CTE is not a base table, so CTE names are excluded.

type StmtKind

type StmtKind int

StmtKind classifies a SQL statement by the kind of statement it represents. It is a read-only label for tracing and analysis (query budgets, N+1 detection, read-only enforcement) derived from a parsed query's shape.

const (
	// StmtUnknown is a statement of an unrecognized kind, or one that could not
	// be analyzed.
	StmtUnknown StmtKind = iota
	// StmtSelect is a row-returning read: SELECT, a set operation (UNION etc.),
	// or a standalone VALUES.
	StmtSelect
	// StmtInsert is an INSERT.
	StmtInsert
	// StmtUpdate is an UPDATE.
	StmtUpdate
	// StmtDelete is a DELETE.
	StmtDelete
	// StmtDDL is a schema statement (CREATE/ALTER/DROP/TRUNCATE/COMMENT).
	StmtDDL
)

func (StmtKind) IsRead

func (k StmtKind) IsRead() bool

IsRead reports whether the statement only reads rows.

func (StmtKind) IsWrite

func (k StmtKind) IsWrite() bool

IsWrite reports whether the statement modifies data (INSERT/UPDATE/DELETE).

func (StmtKind) String

func (k StmtKind) String() string

String renders the kind for logs and reports.

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) Operation

func (t *Trace) Operation() string

Operation returns the trace's operation name.

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) 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"
)

type WarningConfig

type WarningConfig struct {
	// MaxQueries is the per-Trace query count above which too_many_queries
	// fires. Zero disables the check.
	MaxQueries int
	// MaxParams is the bind-parameter count at or above which a statement raises
	// too_many_parameters. Zero disables the check.
	MaxParams int
}

WarningConfig configures the threshold-based warnings.

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.
Package querytest turns a querytrace.Trace into a test spy: it records the SQL one operation actually issued, and this package asserts on it after the fact.
Package querytest turns a querytrace.Trace into a test spy: it records the SQL one operation actually issued, and this package asserts on it after the fact.
Package slogtrace exports a querytrace.Trace to a log/slog logger.
Package slogtrace exports a querytrace.Trace to a log/slog logger.

Jump to

Keyboard shortcuts

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