Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 20 additions & 8 deletions sql/conn.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,45 +26,57 @@ func (c *sentryConn) Ping(ctx context.Context) error {

// QueryContext implements driver.QueryerContext with fallback to the legacy
// driver.Queryer path.
func (c *sentryConn) QueryContext(ctx context.Context, query string, args []driver.NamedValue) (driver.Rows, error) {
//
// nolint: dupl // we don't want to use a helper for Query/Exec Context.
func (c *sentryConn) QueryContext(ctx context.Context, query string, args []driver.NamedValue) (rows driver.Rows, err error) {
if qc, ok := c.conn.(driver.QueryerContext); ok {
span := startSpan(ctx, c.cfg, opQuery, query)
defer func() { finishSpan(span, err) }()
return qc.QueryContext(ctx, query, args)
Comment thread
sentry[bot] marked this conversation as resolved.
}
qr, ok := c.conn.(driver.Queryer) //nolint:staticcheck // legacy driver.Queryer fallback is intentional.
if !ok {
return nil, driver.ErrSkip
}
values, err := namedValuesToValues(args)
if err != nil {
return nil, err
values, cerr := namedValuesToValues(args)
if cerr != nil {
return nil, cerr
}
select {
default:
case <-ctx.Done():
return nil, ctx.Err()
}
span := startSpan(ctx, c.cfg, opQuery, query)
defer func() { finishSpan(span, err) }()
return qr.Query(query, values)
}

// ExecContext implements driver.ExecerContext with fallback to the legacy
// driver.Execer path.
func (c *sentryConn) ExecContext(ctx context.Context, query string, args []driver.NamedValue) (driver.Result, error) {
//
// nolint: dupl // we don't want to use a helper for Query/Exec Context.
func (c *sentryConn) ExecContext(ctx context.Context, query string, args []driver.NamedValue) (res driver.Result, err error) {
if ec, ok := c.conn.(driver.ExecerContext); ok {
span := startSpan(ctx, c.cfg, opExec, query)
defer func() { finishSpan(span, err) }()
return ec.ExecContext(ctx, query, args)
}
ex, ok := c.conn.(driver.Execer) //nolint:staticcheck // legacy driver.Execer fallback is intentional.
if !ok {
return nil, driver.ErrSkip
}
values, err := namedValuesToValues(args)
if err != nil {
return nil, err
values, cerr := namedValuesToValues(args)
if cerr != nil {
return nil, cerr
}
select {
default:
case <-ctx.Done():
return nil, ctx.Err()
}
span := startSpan(ctx, c.cfg, opExec, query)
defer func() { finishSpan(span, err) }()
return ex.Exec(query, values)
}

Expand Down
1 change: 1 addition & 0 deletions sql/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ require (

require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/google/go-cmp v0.5.9 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
golang.org/x/sys v0.18.0 // indirect
Expand Down
307 changes: 307 additions & 0 deletions sql/integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,307 @@
package sentrysql_test

import (
"context"
"database/sql"
"database/sql/driver"
"errors"
"fmt"
"testing"

"github.com/getsentry/sentry-go"
"github.com/getsentry/sentry-go/internal/sentrytest"
sentrysql "github.com/getsentry/sentry-go/sql"
"github.com/getsentry/sentry-go/sql/internal/fakedriver"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

type driverShape struct {
name string
newDB func(t *testing.T) *sql.DB
}

func driverShapes(t *testing.T) []driverShape {
t.Helper()

ctxDrv := fakedriver.NewCtx()
legacyDrv := fakedriver.NewLegacy()
minDrv := fakedriver.NewMinimal()

ctxName := fmt.Sprintf("fake-ctx-integration-%p", ctxDrv)
legacyName := fmt.Sprintf("fake-legacy-integration-%p", legacyDrv)
minName := fmt.Sprintf("fake-minimal-integration-%p", minDrv)
fakedriver.Register(ctxName, ctxDrv)
fakedriver.Register(legacyName, legacyDrv)
fakedriver.Register(minName, minDrv)

return []driverShape{
{
name: "CtxDriver",
newDB: func(t *testing.T) *sql.DB {
db, err := sentrysql.Open(ctxName, "",
sentrysql.WithDatabaseSystem(sentrysql.SystemPostgreSQL),
sentrysql.WithDatabaseName("appdb"),
sentrysql.WithServerAddress("localhost", 5432),
)
require.NoError(t, err)
t.Cleanup(func() { _ = db.Close() })
return db
},
},
{
name: "LegacyDriver",
newDB: func(t *testing.T) *sql.DB {
db, err := sentrysql.Open(legacyName, "",
sentrysql.WithDatabaseSystem(sentrysql.SystemMySQL),
sentrysql.WithDatabaseName("appdb"),
sentrysql.WithServerAddress("localhost", 3306),
)
require.NoError(t, err)
t.Cleanup(func() { _ = db.Close() })
return db
},
},
{
name: "MinimalDriver",
newDB: func(t *testing.T) *sql.DB {
db, err := sentrysql.Open(minName, "",
sentrysql.WithDatabaseSystem(sentrysql.SystemSQLite),
)
require.NoError(t, err)
t.Cleanup(func() { _ = db.Close() })
return db
},
},
}
}

func tracingOpts() sentrytest.Option {
return sentrytest.WithClientOptions(sentry.ClientOptions{
EnableTracing: true,
TracesSampleRate: 1.0,
})
}

func transactionEvents(f *sentrytest.Fixture) []*sentry.Event {
var out []*sentry.Event
for _, e := range f.Events() {
if e.Type == "transaction" {
out = append(out, e)
}
}
return out
}

func TestIntegration_EmitsQueryAndExecSpans(t *testing.T) {
t.Parallel()

for _, shape := range driverShapes(t) {
t.Run(shape.name, func(t *testing.T) {
sentrytest.Run(t, func(t *testing.T, f *sentrytest.Fixture) {
db := shape.newDB(t)

ctx := f.NewContext(context.Background())
parent := sentry.StartSpan(ctx, "root",
sentry.WithTransactionName("root"))
ctx = parent.Context()

_, err := db.ExecContext(ctx, "INSERT INTO t VALUES (1)")
require.NoError(t, err)

rows, err := db.QueryContext(ctx, "SELECT * FROM t")
require.NoError(t, err)
_ = rows.Close()

parent.Finish()

f.Flush()

txns := transactionEvents(f)
require.Len(t, txns, 1)
spans := txns[0].Spans
require.GreaterOrEqual(t, len(spans), 2,
"expected at least exec + query spans, got %d", len(spans))

var gotExec, gotQuery *sentry.Span
for _, s := range spans {
switch s.Op {
case "db.sql.exec":
gotExec = s
case "db.sql.query":
gotQuery = s
}
}
require.NotNil(t, gotExec, "missing db.sql.exec span")
require.NotNil(t, gotQuery, "missing db.sql.query span")

assert.Equal(t, parent.SpanID, gotExec.ParentSpanID,
"exec span must be a direct child of the root transaction")
assert.Equal(t, parent.SpanID, gotQuery.ParentSpanID,
"query span must be a direct child of the root transaction")

assert.Equal(t, sentrysql.SpanOrigin, gotExec.Origin)
assert.Equal(t, sentrysql.SpanOrigin, gotQuery.Origin)
assert.Equal(t, "INSERT INTO t VALUES (1)", gotExec.Description)
assert.Equal(t, "SELECT * FROM t", gotQuery.Description)
assert.Equal(t, sentry.SpanStatusOK, gotExec.Status)
assert.Equal(t, sentry.SpanStatusOK, gotQuery.Status)

assert.NotEmpty(t, gotExec.Data["db.system.name"])
assert.Equal(t, "INSERT INTO t VALUES (1)", gotExec.Data["db.query.text"])
assert.Equal(t, "SELECT * FROM t", gotQuery.Data["db.query.text"])
}, tracingOpts())
})
}
}

func TestIntegration_ErrSkipDoesNotDuplicateSpans(t *testing.T) {
t.Parallel()

sentrytest.Run(t, func(t *testing.T, f *sentrytest.Fixture) {
drv := fakedriver.NewSkip()
name := fmt.Sprintf("fake-skip-integration-%p", drv)
fakedriver.Register(name, drv)

db, err := sentrysql.Open(name, "",
sentrysql.WithDatabaseSystem(sentrysql.SystemPostgreSQL),
sentrysql.WithDatabaseName("appdb"),
)
require.NoError(t, err)
t.Cleanup(func() { _ = db.Close() })

ctx := f.NewContext(context.Background())
parent := sentry.StartSpan(ctx, "root",
sentry.WithTransactionName("root"))
ctx = parent.Context()

_, err = db.ExecContext(ctx, "INSERT INTO t VALUES (1)")
require.NoError(t, err)

rows, err := db.QueryContext(ctx, "SELECT * FROM t")
require.NoError(t, err)
_ = rows.Close()

parent.Finish()
f.Flush()

txns := transactionEvents(f)
require.Len(t, txns, 1)
require.Len(t, txns[0].Spans, 2, "ErrSkip fallback must record exactly one span per operation")

var execCount, queryCount int
for _, span := range txns[0].Spans {
assert.Equal(t, parent.SpanID, span.ParentSpanID)
assert.Equal(t, sentry.SpanStatusOK, span.Status)

switch span.Op {
case "db.sql.exec":
execCount++
case "db.sql.query":
queryCount++
}
}

assert.Equal(t, 1, execCount, "exec fallback must not create a duplicate span")
assert.Equal(t, 1, queryCount, "query fallback must not create a duplicate span")
}, tracingOpts())
}

func TestIntegration_ErrorStatusPropagates(t *testing.T) {
t.Parallel()

shapes := []struct {
name string
newDrv func() driver.Driver
setFail func(d driver.Driver, err error)
system sentrysql.DatabaseSystem
}{
{
name: "CtxDriver",
newDrv: func() driver.Driver { return fakedriver.NewCtx() },
setFail: func(d driver.Driver, err error) {
d.(*fakedriver.CtxDriver).SetFailure(err)
},
system: sentrysql.SystemPostgreSQL,
},
{
name: "LegacyDriver",
newDrv: func() driver.Driver { return fakedriver.NewLegacy() },
setFail: func(d driver.Driver, err error) {
d.(*fakedriver.LegacyDriver).SetFailure(err)
},
system: sentrysql.SystemMySQL,
},
{
name: "MinimalDriver",
newDrv: func() driver.Driver { return fakedriver.NewMinimal() },
setFail: func(d driver.Driver, err error) {
d.(*fakedriver.MinimalDriver).SetFailure(err)
},
system: sentrysql.SystemSQLite,
},
}

for _, shape := range shapes {
t.Run(shape.name, func(t *testing.T) {
sentrytest.Run(t, func(t *testing.T, f *sentrytest.Fixture) {
drv := shape.newDrv()
shape.setFail(drv, fakedriver.ErrDriver)
name := fmt.Sprintf("fake-err-%s-%p", shape.name, drv)
fakedriver.Register(name, drv)

db, err := sentrysql.Open(name, "",
sentrysql.WithDatabaseSystem(shape.system),
)
require.NoError(t, err)
t.Cleanup(func() { _ = db.Close() })

ctx := f.NewContext(context.Background())
parent := sentry.StartSpan(ctx, "root",
sentry.WithTransactionName("root"))
ctx = parent.Context()

_, err = db.ExecContext(ctx, "INSERT INTO t VALUES (1)")
require.True(t, errors.Is(err, fakedriver.ErrDriver),
"driver error must propagate: %v", err)

parent.Finish()
f.Flush()

txns := transactionEvents(f)
require.Len(t, txns, 1)
require.NotEmpty(t, txns[0].Spans)

var execSpan *sentry.Span
for _, s := range txns[0].Spans {
if s.Op == "db.sql.exec" {
execSpan = s
break
}
}
require.NotNil(t, execSpan, "missing db.sql.exec span")
assert.Equal(t, sentry.SpanStatusInternalError, execSpan.Status)
}, tracingOpts())
})
}
}

func TestIntegration_NoParentSpanEmitsNothing(t *testing.T) {
t.Parallel()
sentrytest.Run(t, func(t *testing.T, f *sentrytest.Fixture) {
fakedriver.Register("fake-ctx-nops", fakedriver.NewCtx())
db, err := sentrysql.Open("fake-ctx-nops", "",
sentrysql.WithDatabaseSystem(sentrysql.SystemPostgreSQL),
)
require.NoError(t, err)
t.Cleanup(func() { _ = db.Close() })

ctx := f.NewContext(context.Background())
_, err = db.ExecContext(ctx, "SELECT 1")
require.NoError(t, err)

f.Flush()
assert.Empty(t, transactionEvents(f),
"no spans must be captured when ctx has no parent span")
}, tracingOpts())
}
Loading
Loading