Skip to content

feat: add QueryTracer interface for SQL statement tracing (#1716)#1753

Draft
ljluestc wants to merge 1 commit intogo-sql-driver:masterfrom
ljluestc:private-query-tracer
Draft

feat: add QueryTracer interface for SQL statement tracing (#1716)#1753
ljluestc wants to merge 1 commit intogo-sql-driver:masterfrom
ljluestc:private-query-tracer

Conversation

@ljluestc
Copy link
Copy Markdown

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:

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

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

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

# Run all tracer tests
go test -v -run Trace

# Run specific test
go test -v -run TestTraceQuery_WithTracer

# Run tests with race detection
go test -race -run Trace

Test with a Real MySQL Instance

# Start MySQL (using Docker)
docker run --name mysql-test \
  -e MYSQL_ROOT_PASSWORD=secret \
  -p 3306:3306 \
  -d mysql:8.0

# Wait for MySQL to be ready
docker exec mysql-test mysqladmin ping -u root -psecret --wait

# Run integration tests
go test -v -tags=integration

# Cleanup
docker stop mysql-test
docker rm mysql-test

Run it:

go run debug_test.go

Expected output:

=== Query Start ===
Query: CREATE DATABASE IF NOT EXISTS test
Args: []

=== Query End ===
Duration: 15.234ms
================

=== Query Start ===
Query: CREATE TABLE IF NOT EXISTS test.users (id INT, name VARCHAR(100))
Args: []

=== Query End ===
Duration: 8.456ms
================

=== Query Start ===
Query: INSERT INTO test.users VALUES (?, ?)
Args: [1 Alice]

=== Query End ===
Duration: 3.789ms
================

=== Query Start ===
Query: SELECT name FROM test.users WHERE id = ?
Args: [1]

=== Query End ===
Duration: 2.145ms
================
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:

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

…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
@methane methane requested review from Copilot and methane April 1, 2026 08:08
return nil, err
}

ctx, traceEnd := mc.traceQuery(ctx, query, args)

Check warning

Code scanning / CodeQL

Useless assignment to local variable Warning

This definition of ctx is never used.
return nil, err
}

ctx, traceEnd := stmt.mc.traceQuery(ctx, stmt.queryStr, args)

Check warning

Code scanning / CodeQL

Useless assignment to local variable Warning

This definition of ctx is never used.
@coveralls
Copy link
Copy Markdown

Coverage Status

coverage: 82.633% (+0.1%) from 82.508%
when pulling 2ae012e on ljluestc:private-query-tracer
into fed2c72 on go-sql-driver:master.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 QueryTracer interface plus a mysqlConn.traceQuery helper to wrap query execution with minimal overhead when disabled.
  • Extended Config with a tracer field and added a WithTracer(...) 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.

Comment on lines +558 to 566
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
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +622 to 630
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
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +605 to +608
// Store query string for tracing prepared statement execution.
if s, ok := stmt.(*mysqlStmt); ok {
s.queryStr = query
}
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copilot uses AI. Check for mistakes.

// Store query string for tracing prepared statement execution.
if s, ok := stmt.(*mysqlStmt); ok {
s.queryStr = query
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
s.queryStr = query
s.queryStr = query

Copilot uses AI. Check for mistakes.
Comment on lines +142 to +146
func WithTracer(tracer QueryTracer) Option {
return func(cfg *Config) error {
cfg.tracer = tracer
return nil
}
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants