feat: add QueryTracer interface for SQL statement tracing (#1716)#1753
feat: add QueryTracer interface for SQL statement tracing (#1716)#1753ljluestc wants to merge 1 commit intogo-sql-driver:masterfrom
Conversation
…river#1716) ## Summary Add a `QueryTracer` interface that allows users to trace SQL query execution for logging, metrics, or distributed tracing. This feature is inspired by the pgx driver's tracelog implementation. ## Motivation Applications often need visibility into database query execution for: - **Debugging**: Log slow queries and identify bottlenecks - **Metrics**: Track query execution times, error rates, and throughput - **Distributed Tracing**: Integrate with observability tools (OpenTelemetry, Jaeger, etc.) using context propagation - **Compliance**: Audit logging for data access ## Implementation ### New Interface (tracer.go) Defines the `QueryTracer` interface with two methods: ```go type QueryTracer interface { TraceQueryStart(ctx context.Context, query string, args []driver.NamedValue) context.Context TraceQueryEnd(ctx context.Context, err error, duration time.Duration) } ``` - `TraceQueryStart` is called before query execution with the query string and arguments, returning a context for span propagation - `TraceQueryEnd` is called after query completion with the error and wall-clock duration The `mysqlConn.traceQuery()` helper wraps query execution with automatic tracing. When no tracer is configured, overhead is a single nil check per query. ### Configuration (dsn.go) - Added `tracer QueryTracer` field to `Config` struct - Added `WithTracer(tracer QueryTracer)` functional option ### Instrumented Paths (connection.go, statement.go) - `ExecContext` - `QueryContext` - `PrepareContext` - `mysqlStmt.ExecContext` - `mysqlStmt.QueryContext` ## Usage Example ```go package main import ( "context" "database/sql" "fmt" "github.com/go-sql-driver/mysql" ) type DebugTracer struct{} func (t *DebugTracer) TraceQueryStart(ctx context.Context, query string, args []driver.NamedValue) context.Context { fmt.Printf("[QUERY START] %s | args: %v\n", query, args) return ctx } func (t *DebugTracer) TraceQueryEnd(ctx context.Context, err error, duration time.Duration) { fmt.Printf("[QUERY END] duration: %v | error: %v\n", duration, err) } func main() { config := mysql.NewConfig() config.User = "root" config.Net = "tcp" config.Addr = "127.0.0.1:3306" config.DBName = "test" config.Tracer = &DebugTracer{} db, err := sql.Open("mysql", config.FormatDSN()) if err != nil { panic(err) } defer db.Close() var result string err = db.QueryRowContext(context.Background(), "SELECT 'Hello, MySQL!'").Scan(&result) if err != nil { panic(err) } fmt.Println("Result:", result) } ``` ### OpenTelemetry Integration Example ```go type OTELTracer struct { tracer trace.Tracer } func (t *OTELTracer) TraceQueryStart(ctx context.Context, query string, args []driver.NamedValue) context.Context { ctx, span := t.tracer.Start(ctx, "mysql.query", trace.WithAttributes( attribute.String("db.statement", query), attribute.Int("db.args_count", len(args)), ), ) return ctx } func (t *OTELTracer) TraceQueryEnd(ctx context.Context, err error, duration time.Duration) { span := trace.SpanFromContext(ctx) defer span.End() span.SetAttributes( attribute.Int64("db.duration_ms", duration.Milliseconds()), ) if err != nil { span.RecordError(err) span.SetStatus(codes.Error, err.Error()) } } ``` ## How to Test ### Run Unit Tests ```bash # Run all tracer tests go test -v -run Trace # Run specific test go test -v -run TestTraceQuery_WithTracer ``` ### Test with a Real MySQL Instance ```bash # Start MySQL (using Docker) docker run --name mysql-test \ -e MYSQL_ROOT_PASSWORD=secret \ -p 3306:3306 \ -d mysql:8.0 # Run integration tests go test -v -tags=integration ``` ### Manual Testing with DebugTracer Create a test file `debug_test.go`: ```go package main import ( "context" "database/sql" "fmt" "time" _ "github.com/go-sql-driver/mysql" ) type DebugTracer struct{} func (t *DebugTracer) TraceQueryStart(ctx context.Context, query string, args []driver.NamedValue) context.Context { fmt.Printf("\n=== Query Start ===\n") fmt.Printf("Query: %s\n", query) fmt.Printf("Args: %v\n", args) return ctx } func (t *DebugTracer) TraceQueryEnd(ctx context.Context, err error, duration time.Duration) { fmt.Printf("\n=== Query End ===\n") fmt.Printf("Duration: %v\n", duration) if err != nil { fmt.Printf("Error: %v\n", err) } fmt.Printf("================\n") } func main() { config := mysql.NewConfig() config.User = "root" config.Passwd = "secret" config.Net = "tcp" config.Addr = "127.0.0.1:3306" config.Tracer = &DebugTracer{} db, err := sql.Open("mysql", config.FormatDSN()) if err != nil { panic(err) } defer db.Close() if err := db.Ping(); err != nil { panic(err) } _, err = db.ExecContext(context.Background(), "CREATE DATABASE IF NOT EXISTS test") if err != nil { panic(err) } _, err = db.ExecContext(context.Background(), "CREATE TABLE IF NOT EXISTS test.users (id INT, name VARCHAR(100))") if err != nil { panic(err) } _, err = db.ExecContext(context.Background(), "INSERT INTO test.users VALUES (?, ?)", 1, "Alice") if err != nil { panic(err) } var name string err = db.QueryRowContext(context.Background(), "SELECT name FROM test.users WHERE id = ?", 1).Scan(&name) if err != nil { panic(err) } fmt.Printf("Fetched name: %s\n", name) } ``` Run it: ```bash go run debug_test.go ``` Expected output: ``` === Query Start === Query: SELECT name FROM test.users WHERE id = ? Args: [1] === Query End === Duration: 12.345ms ================ Fetched name: Alice ``` ## Performance When no tracer is configured, overhead is a single nil check per query. The feature is designed to have minimal impact on performance when disabled. Benchmark results: ```bash go test -bench=. -benchmem ``` ## Changes ### New Files - `tracer.go` (39 lines) - `tracer_test.go` (165 lines) ### Modified Files - `connection.go` - Added tracing hooks around query execution - `statement.go` - Added query string storage for prepared statements - `dsn.go` - Extended Config to support tracer configuration **Total: 5 files changed, 235 insertions, 2 deletions** ## Test Coverage Comprehensive test coverage in `tracer_test.go`: - `TestTraceQuery_WithTracer` - Validates tracer calls and context propagation - `TestTraceQuery_ContextFlows` - Ensures context is correctly passed through - `TestTraceQuery_WithError` - Tests error handling in trace callbacks - `TestTraceQuery_NilTracer` - Verifies no-op behavior when tracer is nil - `TestWithTracerOption` - Tests functional option configuration ## Breaking Changes None. This is a pure addition to the public API. ## Checklist - [x] Code compiles correctly - [x] Created tests which fail without the change - [x] All tests passing - [ ] Extended the README / documentation, if necessary - [ ] Added myself / the copyright holder to the AUTHORS file
| return nil, err | ||
| } | ||
|
|
||
| ctx, traceEnd := mc.traceQuery(ctx, query, args) |
Check warning
Code scanning / CodeQL
Useless assignment to local variable Warning
| return nil, err | ||
| } | ||
|
|
||
| ctx, traceEnd := stmt.mc.traceQuery(ctx, stmt.queryStr, args) |
Check warning
Code scanning / CodeQL
Useless assignment to local variable Warning
There was a problem hiding this comment.
Pull request overview
This PR introduces a QueryTracer hook to allow consumers of the driver to observe SQL query execution (start/end, args, error, duration) for logging, metrics, or distributed tracing.
Changes:
- Added a
QueryTracerinterface plus amysqlConn.traceQueryhelper to wrap query execution with minimal overhead when disabled. - Extended
Configwith a tracer field and added aWithTracer(...)functional option. - Instrumented
ExecContext/QueryContext(and stmt variants) and stored the original query string on prepared statements for tracing.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
tracer.go |
Defines QueryTracer and adds mysqlConn.traceQuery helper. |
tracer_test.go |
Adds unit tests for traceQuery behavior (nil tracer, context propagation, duration, errors, option). |
connection.go |
Wires tracing into QueryContext, ExecContext, PrepareContext, and stmt context methods. |
statement.go |
Adds queryStr field to mysqlStmt to preserve original SQL for prepared-statement tracing. |
dsn.go |
Adds tracer support to Config and exposes WithTracer(...) option. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| ctx, traceEnd := mc.traceQuery(ctx, query, args) | ||
| rows, err := mc.query(query, dargs) | ||
| if err != nil { | ||
| traceEnd(err) | ||
| mc.finish() | ||
| return nil, err | ||
| } | ||
| traceEnd(nil) | ||
| rows.finish = mc.finish |
There was a problem hiding this comment.
In QueryContext, traceEnd(nil) is invoked immediately after mc.query(...) returns, but the MySQL protocol continues to stream rows during Rows.Next()/Rows.Close(). This means the traced duration excludes row fetch time and any later read/close errors will be incorrectly reported as success. Consider deferring TraceQueryEnd until the returned Rows is closed (and ideally pass the close/iteration error) instead of ending the span before returning.
| ctx, traceEnd := stmt.mc.traceQuery(ctx, stmt.queryStr, args) | ||
| rows, err := stmt.query(dargs) | ||
| if err != nil { | ||
| traceEnd(err) | ||
| stmt.mc.finish() | ||
| return nil, err | ||
| } | ||
| traceEnd(nil) | ||
| rows.finish = stmt.mc.finish |
There was a problem hiding this comment.
stmt.QueryContext has the same issue as mysqlConn.QueryContext: traceEnd(nil) is called before the caller consumes/close the returned rows. This underreports duration and can miss errors that occur while streaming rows. Please end tracing when the rows are closed (and propagate any close/stream error to TraceQueryEnd) rather than immediately after stmt.query(...) returns.
| // Store query string for tracing prepared statement execution. | ||
| if s, ok := stmt.(*mysqlStmt); ok { | ||
| s.queryStr = query | ||
| } |
There was a problem hiding this comment.
stmt.queryStr is used for tracing prepared statement execution, but it is only populated in PrepareContext. Statements created via the non-context Prepare(query) path will keep queryStr empty, resulting in traces with a blank query. Consider setting queryStr when the *mysqlStmt is constructed in Prepare() as well (or otherwise ensuring it’s always initialized).
|
|
||
| // Store query string for tracing prepared statement execution. | ||
| if s, ok := stmt.(*mysqlStmt); ok { | ||
| s.queryStr = query |
There was a problem hiding this comment.
The assignment inside the if s, ok := stmt.(*mysqlStmt); ok { ... } block is not indented (it looks like it missed a tab). This should be gofmt’d to match the surrounding Go formatting and avoid style-only diffs in future edits.
| s.queryStr = query | |
| s.queryStr = query |
| func WithTracer(tracer QueryTracer) Option { | ||
| return func(cfg *Config) error { | ||
| cfg.tracer = tracer | ||
| return nil | ||
| } |
There was a problem hiding this comment.
The PR description’s usage example shows config.Tracer = ..., but this change adds an unexported tracer field on Config (settable only via WithTracer/Apply). Please align the documented/public API with the implementation (either update examples/docs to use Apply(WithTracer(...)) or expose a public field/accessor).
Add a
QueryTracerinterface that allows users to trace SQL query execution for logging, metrics, or distributed tracing. This feature is inspired by the pgx driver's tracelog implementation.Motivation
Applications often need visibility into database query execution for:
Implementation
New Interface (tracer.go)
Defines the
QueryTracerinterface with two methods:TraceQueryStartis called before query execution with the query string and arguments, returning a context for span propagationTraceQueryEndis called after query completion with the error and wall-clock durationThe
mysqlConn.traceQuery()helper wraps query execution with automatic tracing. When no tracer is configured, overhead is a single nil check per query.Configuration (dsn.go)
tracer QueryTracerfield toConfigstructWithTracer(tracer QueryTracer)functional optionInstrumented Paths (connection.go, statement.go)
ExecContextQueryContextPrepareContextmysqlStmt.ExecContextmysqlStmt.QueryContextUsage Example
OpenTelemetry Integration Example
How to Test
Run Unit Tests
Test with a Real MySQL Instance
Run it:
Expected output:
Performance
When no tracer is configured, overhead is a single nil check per query. The feature is designed to have minimal impact on performance when disabled.
Benchmark results:
go test -bench=. -benchmemChanges
New Files
tracer.go(39 lines)tracer_test.go(165 lines)Modified Files
connection.go- Added tracing hooks around query executionstatement.go- Added query string storage for prepared statementsdsn.go- Extended Config to support tracer configuration