diff --git a/README.md b/README.md index c8627621fb..7ad9abd48a 100644 --- a/README.md +++ b/README.md @@ -80,5 +80,9 @@ might be running versions different from the vendored one, creating hard to debu To run integration tests locally, you should set the `INTEGRATION` environment variable. The dependencies of the integration tests are best run via Docker. To get an idea about the versions and the set-up take a look at our [CI config](./.circleci/config.yml). -The best way to run the entire test suite is using the [CircleCI CLI](https://circleci.com/docs/2.0/local-jobs/). Simply run `circleci build` -in the repository root. Note that you might have to increase the resources dedicated to Docker to around 4GB. +The best way to run the entire test suite is using the [CircleCI CLI](https://circleci.com/docs/2.0/local-cli/). In order to run +jobs locally, you'll first need to convert the Circle CI configuration to a format accepted by the `circleci` cli tool: + * `circleci config process .circleci/config.yml > process.yml` (from the repository root) + +Once you have a converted `process.yml`, simply run `circleci local execute -c process.yml --job `. +Note that you might have to increase the resources dedicated to Docker to around 4GB. diff --git a/contrib/database/sql/conn.go b/contrib/database/sql/conn.go index d382301a5d..f2c98c57f8 100644 --- a/contrib/database/sql/conn.go +++ b/contrib/database/sql/conn.go @@ -15,6 +15,7 @@ import ( "gopkg.in/DataDog/dd-trace-go.v1/ddtrace" "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/ext" "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer" + "gopkg.in/DataDog/dd-trace-go.v1/internal/log" ) var _ driver.Conn = (*tracedConn)(nil) @@ -58,27 +59,34 @@ func (tc *tracedConn) BeginTx(ctx context.Context, opts driver.TxOptions) (tx dr func (tc *tracedConn) PrepareContext(ctx context.Context, query string) (stmt driver.Stmt, err error) { start := time.Now() + mode := tc.cfg.commentInjectionMode + if mode == tracer.SQLInjectionModeFull { + // no context other than service in prepared statements + mode = tracer.SQLInjectionModeService + } + cquery, spanID := injectComments(ctx, query, mode) if connPrepareCtx, ok := tc.Conn.(driver.ConnPrepareContext); ok { - stmt, err := connPrepareCtx.PrepareContext(ctx, query) - tc.tryTrace(ctx, queryTypePrepare, query, start, err) + stmt, err := connPrepareCtx.PrepareContext(ctx, cquery) + tc.tryTrace(ctx, queryTypePrepare, query, start, err, tracer.WithSpanID(spanID)) if err != nil { return nil, err } - return &tracedStmt{stmt, tc.traceParams, ctx, query}, nil + return &tracedStmt{Stmt: stmt, traceParams: tc.traceParams, ctx: ctx, query: query}, nil } - stmt, err = tc.Prepare(query) - tc.tryTrace(ctx, queryTypePrepare, query, start, err) + stmt, err = tc.Prepare(cquery) + tc.tryTrace(ctx, queryTypePrepare, query, start, err, tracer.WithSpanID(spanID)) if err != nil { return nil, err } - return &tracedStmt{stmt, tc.traceParams, ctx, query}, nil + return &tracedStmt{Stmt: stmt, traceParams: tc.traceParams, ctx: ctx, query: query}, nil } func (tc *tracedConn) ExecContext(ctx context.Context, query string, args []driver.NamedValue) (r driver.Result, err error) { start := time.Now() if execContext, ok := tc.Conn.(driver.ExecerContext); ok { - r, err := execContext.ExecContext(ctx, query, args) - tc.tryTrace(ctx, queryTypeExec, query, start, err) + cquery, spanID := injectComments(ctx, query, tc.cfg.commentInjectionMode) + r, err := execContext.ExecContext(ctx, cquery, args) + tc.tryTrace(ctx, queryTypeExec, query, start, err, tracer.WithSpanID(spanID)) return r, err } if execer, ok := tc.Conn.(driver.Execer); ok { @@ -91,8 +99,9 @@ func (tc *tracedConn) ExecContext(ctx context.Context, query string, args []driv return nil, ctx.Err() default: } - r, err = execer.Exec(query, dargs) - tc.tryTrace(ctx, queryTypeExec, query, start, err) + cquery, spanID := injectComments(ctx, query, tc.cfg.commentInjectionMode) + r, err = execer.Exec(cquery, dargs) + tc.tryTrace(ctx, queryTypeExec, query, start, err, tracer.WithSpanID(spanID)) return r, err } return nil, driver.ErrSkip @@ -111,8 +120,9 @@ func (tc *tracedConn) Ping(ctx context.Context) (err error) { func (tc *tracedConn) QueryContext(ctx context.Context, query string, args []driver.NamedValue) (rows driver.Rows, err error) { start := time.Now() if queryerContext, ok := tc.Conn.(driver.QueryerContext); ok { - rows, err := queryerContext.QueryContext(ctx, query, args) - tc.tryTrace(ctx, queryTypeQuery, query, start, err) + cquery, spanID := injectComments(ctx, query, tc.cfg.commentInjectionMode) + rows, err := queryerContext.QueryContext(ctx, cquery, args) + tc.tryTrace(ctx, queryTypeQuery, query, start, err, tracer.WithSpanID(spanID)) return rows, err } if queryer, ok := tc.Conn.(driver.Queryer); ok { @@ -125,8 +135,9 @@ func (tc *tracedConn) QueryContext(ctx context.Context, query string, args []dri return nil, ctx.Err() default: } - rows, err = queryer.Query(query, dargs) - tc.tryTrace(ctx, queryTypeQuery, query, start, err) + cquery, spanID := injectComments(ctx, query, tc.cfg.commentInjectionMode) + rows, err = queryer.Query(cquery, dargs) + tc.tryTrace(ctx, queryTypeQuery, query, start, err, tracer.WithSpanID(spanID)) return rows, err } return nil, driver.ErrSkip @@ -167,8 +178,28 @@ func WithSpanTags(ctx context.Context, tags map[string]string) context.Context { return context.WithValue(ctx, spanTagsKey, tags) } +// injectComments returns the query with SQL comments injected according to the comment injection mode along +// with a span ID injected into SQL comments. The returned span ID should be used when the SQL span is created +// following the traced database call. +func injectComments(ctx context.Context, query string, mode tracer.SQLCommentInjectionMode) (cquery string, spanID uint64) { + // The sql span only gets created after the call to the database because we need to be able to skip spans + // when a driver returns driver.ErrSkip. In order to work with those constraints, a new span id is generated and + // used during SQL comment injection and returned for the sql span to be used later when/if the span + // gets created. + var spanCtx ddtrace.SpanContext + if span, ok := tracer.SpanFromContext(ctx); ok { + spanCtx = span.Context() + } + carrier := tracer.SQLCommentCarrier{Query: query, Mode: mode} + if err := carrier.Inject(spanCtx); err != nil { + // this should never happen + log.Warn("contrib/database/sql: failed to inject query comments: %v", err) + } + return carrier.Query, carrier.SpanID +} + // tryTrace will create a span using the given arguments, but will act as a no-op when err is driver.ErrSkip. -func (tp *traceParams) tryTrace(ctx context.Context, qtype queryType, query string, startTime time.Time, err error) { +func (tp *traceParams) tryTrace(ctx context.Context, qtype queryType, query string, startTime time.Time, err error, spanOpts ...ddtrace.StartSpanOption) { if err == driver.ErrSkip { // Not a user error: driver is telling sql package that an // optional interface method is not implemented. There is @@ -180,11 +211,11 @@ func (tp *traceParams) tryTrace(ctx context.Context, qtype queryType, query stri return } name := fmt.Sprintf("%s.query", tp.driverName) - opts := []ddtrace.StartSpanOption{ + opts := append(spanOpts, tracer.ServiceName(tp.cfg.serviceName), tracer.SpanType(ext.SpanTypeSQL), tracer.StartTime(startTime), - } + ) if !math.IsNaN(tp.cfg.analyticsRate) { opts = append(opts, tracer.Tag(ext.EventSampleRate, tp.cfg.analyticsRate)) } diff --git a/contrib/database/sql/injection_test.go b/contrib/database/sql/injection_test.go new file mode 100644 index 0000000000..2d980d2e9e --- /dev/null +++ b/contrib/database/sql/injection_test.go @@ -0,0 +1,169 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2016 Datadog, Inc. + +package sql + +import ( + "context" + "database/sql" + "regexp" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "gopkg.in/DataDog/dd-trace-go.v1/contrib/database/sql/internal" + "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer" +) + +func TestCommentInjection(t *testing.T) { + testCases := []struct { + name string + opts []RegisterOption + callDB func(ctx context.Context, db *sql.DB) error + prepared []string + executed []*regexp.Regexp + }{ + { + name: "prepare", + opts: []RegisterOption{WithSQLCommentInjection(tracer.SQLInjectionDisabled)}, + callDB: func(ctx context.Context, db *sql.DB) error { + _, err := db.PrepareContext(ctx, "SELECT 1 from DUAL") + return err + }, + prepared: []string{"SELECT 1 from DUAL"}, + }, + { + name: "prepare-disabled", + opts: []RegisterOption{WithSQLCommentInjection(tracer.SQLInjectionDisabled)}, + callDB: func(ctx context.Context, db *sql.DB) error { + _, err := db.PrepareContext(ctx, "SELECT 1 from DUAL") + return err + }, + prepared: []string{"SELECT 1 from DUAL"}, + }, + { + name: "prepare-service", + opts: []RegisterOption{WithSQLCommentInjection(tracer.SQLInjectionModeService)}, + callDB: func(ctx context.Context, db *sql.DB) error { + _, err := db.PrepareContext(ctx, "SELECT 1 from DUAL") + return err + }, + prepared: []string{"/*dde='test-env',ddsn='test-service',ddsv='1.0.0'*/ SELECT 1 from DUAL"}, + }, + { + name: "prepare-full", + opts: []RegisterOption{WithSQLCommentInjection(tracer.SQLInjectionModeFull)}, + callDB: func(ctx context.Context, db *sql.DB) error { + _, err := db.PrepareContext(ctx, "SELECT 1 from DUAL") + return err + }, + prepared: []string{"/*dde='test-env',ddsn='test-service',ddsv='1.0.0'*/ SELECT 1 from DUAL"}, + }, + { + name: "query", + opts: []RegisterOption{WithSQLCommentInjection(tracer.SQLInjectionDisabled)}, + callDB: func(ctx context.Context, db *sql.DB) error { + _, err := db.QueryContext(ctx, "SELECT 1 from DUAL") + return err + }, + executed: []*regexp.Regexp{regexp.MustCompile("SELECT 1 from DUAL")}, + }, + { + name: "query-disabled", + opts: []RegisterOption{WithSQLCommentInjection(tracer.SQLInjectionDisabled)}, + callDB: func(ctx context.Context, db *sql.DB) error { + _, err := db.QueryContext(ctx, "SELECT 1 from DUAL") + return err + }, + executed: []*regexp.Regexp{regexp.MustCompile("SELECT 1 from DUAL")}, + }, + { + name: "query-service", + opts: []RegisterOption{WithSQLCommentInjection(tracer.SQLInjectionModeService)}, + callDB: func(ctx context.Context, db *sql.DB) error { + _, err := db.QueryContext(ctx, "SELECT 1 from DUAL") + return err + }, + executed: []*regexp.Regexp{regexp.MustCompile("/\\*dde='test-env',ddsn='test-service',ddsv='1.0.0'\\*/ SELECT 1 from DUAL")}, + }, + { + name: "query-full", + opts: []RegisterOption{WithSQLCommentInjection(tracer.SQLInjectionModeFull)}, + callDB: func(ctx context.Context, db *sql.DB) error { + _, err := db.QueryContext(ctx, "SELECT 1 from DUAL") + return err + }, + executed: []*regexp.Regexp{regexp.MustCompile("/\\*dde='test-env',ddsid='\\d+',ddsn='test-service',ddsp='1',ddsv='1.0.0',ddtid='1'\\*/ SELECT 1 from DUAL")}, + }, + { + name: "exec", + opts: []RegisterOption{WithSQLCommentInjection(tracer.SQLInjectionDisabled)}, + callDB: func(ctx context.Context, db *sql.DB) error { + _, err := db.ExecContext(ctx, "SELECT 1 from DUAL") + return err + }, + executed: []*regexp.Regexp{regexp.MustCompile("SELECT 1 from DUAL")}, + }, + { + name: "exec-disabled", + opts: []RegisterOption{WithSQLCommentInjection(tracer.SQLInjectionDisabled)}, + callDB: func(ctx context.Context, db *sql.DB) error { + _, err := db.ExecContext(ctx, "SELECT 1 from DUAL") + return err + }, + executed: []*regexp.Regexp{regexp.MustCompile("SELECT 1 from DUAL")}, + }, + { + name: "exec-service", + opts: []RegisterOption{WithSQLCommentInjection(tracer.SQLInjectionModeService)}, + callDB: func(ctx context.Context, db *sql.DB) error { + _, err := db.ExecContext(ctx, "SELECT 1 from DUAL") + return err + }, + executed: []*regexp.Regexp{regexp.MustCompile("/\\*dde='test-env',ddsn='test-service',ddsv='1.0.0'\\*/ SELECT 1 from DUAL")}, + }, + { + name: "exec-full", + opts: []RegisterOption{WithSQLCommentInjection(tracer.SQLInjectionModeFull)}, + callDB: func(ctx context.Context, db *sql.DB) error { + _, err := db.ExecContext(ctx, "SELECT 1 from DUAL") + return err + }, + executed: []*regexp.Regexp{regexp.MustCompile("/\\*dde='test-env',ddsid='\\d+',ddsn='test-service',ddsp='1',ddsv='1.0.0',ddtid='1'\\*/ SELECT 1 from DUAL")}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + tracer.Start(tracer.WithService("test-service"), tracer.WithEnv("test-env"), tracer.WithServiceVersion("1.0.0")) + defer tracer.Stop() + + d := &internal.MockDriver{} + Register("test", d, tc.opts...) + defer unregister("test") + + db, err := Open("test", "dn") + require.NoError(t, err) + + s, ctx := tracer.StartSpanFromContext(context.Background(), "test.call", tracer.WithSpanID(1)) + err = tc.callDB(ctx, db) + s.Finish() + + require.NoError(t, err) + require.Len(t, d.Prepared, len(tc.prepared)) + for i, e := range tc.prepared { + assert.Equal(t, e, d.Prepared[i]) + } + + require.Len(t, d.Executed, len(tc.executed)) + for i, e := range tc.executed { + assert.Regexp(t, e, d.Executed[i]) + // the injected span ID should not be the parent's span ID + assert.NotContains(t, d.Executed[i], "ddsid='1'") + } + }) + } +} diff --git a/contrib/database/sql/internal/mockdriver.go b/contrib/database/sql/internal/mockdriver.go new file mode 100644 index 0000000000..40f5428486 --- /dev/null +++ b/contrib/database/sql/internal/mockdriver.go @@ -0,0 +1,137 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2016 Datadog, Inc. + +package internal // import "gopkg.in/DataDog/dd-trace-go.v1/contrib/internal/sqltest" + +import ( + "context" + "database/sql/driver" + "io" +) + +// MockDriver implements a mock driver that captures and stores prepared and executed statements +type MockDriver struct { + Prepared []string + Executed []string +} + +// Open implements the Conn interface +func (d *MockDriver) Open(name string) (driver.Conn, error) { + return &mockConn{driver: d}, nil +} + +type mockConn struct { + driver *MockDriver +} + +// Prepare implements the driver.Conn interface +func (m *mockConn) Prepare(query string) (driver.Stmt, error) { + m.driver.Prepared = append(m.driver.Prepared, query) + return &mockStmt{stmt: query, driver: m.driver}, nil +} + +// QueryContext implements the QueryerContext interface +func (m *mockConn) QueryContext(ctx context.Context, query string, args []driver.NamedValue) (driver.Rows, error) { + m.driver.Executed = append(m.driver.Executed, query) + return &rows{}, nil +} + +// ExecContext implements the ExecerContext interface +func (m *mockConn) ExecContext(ctx context.Context, query string, args []driver.NamedValue) (driver.Result, error) { + m.driver.Executed = append(m.driver.Executed, query) + return &mockResult{}, nil +} + +// Close implements the Conn interface +func (m *mockConn) Close() (err error) { + return nil +} + +// Begin implements the Conn interface +func (m *mockConn) Begin() (driver.Tx, error) { + return &mockTx{driver: m.driver}, nil +} + +type rows struct{} + +// Columns implements the Rows interface +func (r *rows) Columns() []string { + return []string{} +} + +// Close implements the Rows interface +func (r *rows) Close() error { + return nil +} + +// Next implements the Rows interface +func (r *rows) Next(dest []driver.Value) error { + return io.EOF +} + +type mockTx struct { + driver *MockDriver +} + +// Commit implements the Tx interface +func (t *mockTx) Commit() error { + return nil +} + +// Rollback implements the Tx interface +func (t *mockTx) Rollback() error { + return nil +} + +type mockStmt struct { + stmt string + driver *MockDriver +} + +// Close implements the Stmt interface +func (s *mockStmt) Close() error { + return nil +} + +// NumInput implements the Stmt interface +func (s *mockStmt) NumInput() int { + return 0 +} + +// Exec implements the Stmt interface +func (s *mockStmt) Exec(args []driver.Value) (driver.Result, error) { + s.driver.Executed = append(s.driver.Executed, s.stmt) + return &mockResult{}, nil +} + +// Query implements the Stmt interface +func (s *mockStmt) Query(args []driver.Value) (driver.Rows, error) { + s.driver.Executed = append(s.driver.Executed, s.stmt) + return &rows{}, nil +} + +// ExecContext implements the StmtExecContext interface +func (s *mockStmt) ExecContext(ctx context.Context, args []driver.NamedValue) (driver.Result, error) { + s.driver.Executed = append(s.driver.Executed, s.stmt) + return &mockResult{}, nil +} + +// QueryContext implements the StmtQueryContext interface +func (s *mockStmt) QueryContext(ctx context.Context, args []driver.NamedValue) (driver.Rows, error) { + s.driver.Executed = append(s.driver.Executed, s.stmt) + return &rows{}, nil +} + +type mockResult struct{} + +// LastInsertId implements the Result interface +func (r *mockResult) LastInsertId() (int64, error) { + return 0, nil +} + +// RowsAffected implements the Result interface +func (r *mockResult) RowsAffected() (int64, error) { + return 0, nil +} diff --git a/contrib/database/sql/option.go b/contrib/database/sql/option.go index 47bd4208bd..ca887772f1 100644 --- a/contrib/database/sql/option.go +++ b/contrib/database/sql/option.go @@ -7,15 +7,18 @@ package sql import ( "math" + "os" + "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer" "gopkg.in/DataDog/dd-trace-go.v1/internal" ) type config struct { - serviceName string - analyticsRate float64 - dsn string - childSpansOnly bool + serviceName string + analyticsRate float64 + dsn string + childSpansOnly bool + commentInjectionMode tracer.SQLCommentInjectionMode } // Option represents an option that can be passed to Register, Open or OpenDB. @@ -34,6 +37,7 @@ func defaults(cfg *config) { } else { cfg.analyticsRate = math.NaN() } + cfg.commentInjectionMode = tracer.SQLCommentInjectionMode(os.Getenv("DD_TRACE_SQL_COMMENT_INJECTION_MODE")) } // WithServiceName sets the given service name when registering a driver, @@ -83,3 +87,12 @@ func WithChildSpansOnly() Option { cfg.childSpansOnly = true } } + +// WithSQLCommentInjection enables injection of tags as sql comments on traced queries. +// This includes dynamic values like span id, trace id and sampling priority which can make queries +// unique for some cache implementations. Use WithStaticTagsCommentInjection if this is a concern. +func WithSQLCommentInjection(mode tracer.SQLCommentInjectionMode) Option { + return func(cfg *config) { + cfg.commentInjectionMode = mode + } +} diff --git a/contrib/database/sql/sql.go b/contrib/database/sql/sql.go index 2383f4a499..0383d4021c 100644 --- a/contrib/database/sql/sql.go +++ b/contrib/database/sql/sql.go @@ -26,6 +26,7 @@ import ( "time" "gopkg.in/DataDog/dd-trace-go.v1/contrib/database/sql/internal" + "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer" "gopkg.in/DataDog/dd-trace-go.v1/internal/log" ) @@ -186,6 +187,9 @@ func OpenDB(c driver.Connector, opts ...Option) *sql.DB { if math.IsNaN(cfg.analyticsRate) { cfg.analyticsRate = rc.analyticsRate } + if cfg.commentInjectionMode == tracer.SQLInjectionUndefined { + cfg.commentInjectionMode = rc.commentInjectionMode + } cfg.childSpansOnly = rc.childSpansOnly tc := &tracedConnector{ connector: c, diff --git a/contrib/database/sql/sql_test.go b/contrib/database/sql/sql_test.go index 9022305927..f4dc52d3b0 100644 --- a/contrib/database/sql/sql_test.go +++ b/contrib/database/sql/sql_test.go @@ -16,14 +16,14 @@ import ( "testing" "time" - "gopkg.in/DataDog/dd-trace-go.v1/contrib/internal/sqltest" - "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/ext" - "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/mocktracer" - mssql "github.com/denisenkom/go-mssqldb" "github.com/go-sql-driver/mysql" "github.com/lib/pq" "github.com/stretchr/testify/assert" + + "gopkg.in/DataDog/dd-trace-go.v1/contrib/internal/sqltest" + "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/ext" + "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/mocktracer" ) // tableName holds the SQL table that these tests will be run against. It must be unique cross-repo. diff --git a/ddtrace/tracer/option.go b/ddtrace/tracer/option.go index f74232ad0d..a89ae19112 100644 --- a/ddtrace/tracer/option.go +++ b/ddtrace/tracer/option.go @@ -261,10 +261,13 @@ func newConfig(opts ...StartOption) *config { if c.transport == nil { c.transport = newHTTPTransport(c.agentAddr, c.httpClient) } - if c.propagator == nil { - c.propagator = NewPropagator(&PropagatorConfig{ - MaxTagsHeaderLen: internal.IntEnv("DD_TRACE_TAGS_PROPAGATION_MAX_LENGTH", defaultMaxTagsHeaderLen), - }) + pcfg := &PropagatorConfig{ + MaxTagsHeaderLen: internal.IntEnv("DD_TRACE_TAGS_PROPAGATION_MAX_LENGTH", defaultMaxTagsHeaderLen), + } + if c.propagator != nil { + c.propagator = NewPropagator(pcfg, c.propagator) + } else { + c.propagator = NewPropagator(pcfg) } if c.logger != nil { log.UseLogger(c.logger) @@ -883,7 +886,7 @@ func WithUserRole(role string) UserMonitoringOption { } } -// WithUserScope returns the option setting the scope (authorizations) of the authenticated user +// WithUserScope returns the option setting the scope (authorizations) of the authenticated user. func WithUserScope(scope string) UserMonitoringOption { return func(s Span) { s.SetTag("usr.scope", scope) diff --git a/ddtrace/tracer/spancontext.go b/ddtrace/tracer/spancontext.go index 0349ef1618..f19bc7e021 100644 --- a/ddtrace/tracer/spancontext.go +++ b/ddtrace/tracer/spancontext.go @@ -127,6 +127,13 @@ func (c *spanContext) baggageItem(key string) string { return c.baggage[key] } +func (c *spanContext) meta(key string) (val string, ok bool) { + c.mu.RLock() + defer c.mu.RUnlock() + val, ok = c.span.Meta[key] + return val, ok +} + // finish marks this span as finished in the trace. func (c *spanContext) finish() { c.trace.finishedOne(c.span) } diff --git a/ddtrace/tracer/sqlcomment.go b/ddtrace/tracer/sqlcomment.go new file mode 100644 index 0000000000..05cd0263da --- /dev/null +++ b/ddtrace/tracer/sqlcomment.go @@ -0,0 +1,152 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2016 Datadog, Inc. + +package tracer + +import ( + "strconv" + "strings" + + "gopkg.in/DataDog/dd-trace-go.v1/ddtrace" + "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/ext" + "gopkg.in/DataDog/dd-trace-go.v1/internal/globalconfig" +) + +// SQLCommentInjectionMode represents the mode of SQL comment injection. +type SQLCommentInjectionMode string + +const ( + // SQLInjectionUndefined represents the comment injection mode is not set. This is the same as SQLInjectionDisabled. + SQLInjectionUndefined SQLCommentInjectionMode = "" + // SQLInjectionDisabled represents the comment injection mode where all injection is disabled. + SQLInjectionDisabled SQLCommentInjectionMode = "disabled" + // SQLInjectionModeService represents the comment injection mode where only service tags (name, env, version) are injected. + SQLInjectionModeService SQLCommentInjectionMode = "service" + // SQLInjectionModeFull represents the comment injection mode where both service tags and tracing tags. Tracing tags include span id, trace id and sampling priority. + SQLInjectionModeFull SQLCommentInjectionMode = "full" +) + +// Key names for SQL comment tags. +const ( + sqlCommentKeySamplingPriority = "ddsp" + sqlCommentTraceID = "ddtid" + sqlCommentSpanID = "ddsid" + sqlCommentService = "ddsn" + sqlCommentVersion = "ddsv" + sqlCommentEnv = "dde" +) + +// SQLCommentCarrier is a carrier implementation that injects a span context in a SQL query in the form +// of a sqlcommenter formatted comment prepended to the original query text. +// See https://google.github.io/sqlcommenter/spec/ for more details. +type SQLCommentCarrier struct { + Query string + Mode SQLCommentInjectionMode + SpanID uint64 +} + +// Inject injects a span context in the carrier's Query field as a comment. +func (c *SQLCommentCarrier) Inject(spanCtx ddtrace.SpanContext) error { + c.SpanID = random.Uint64() + tags := make(map[string]string) + switch c.Mode { + case SQLInjectionUndefined: + fallthrough + case SQLInjectionDisabled: + return nil + case SQLInjectionModeFull: + var ( + samplingPriority int + traceID uint64 + ) + if ctx, ok := spanCtx.(*spanContext); ok { + if sp, ok := ctx.samplingPriority(); ok { + samplingPriority = sp + } + traceID = ctx.TraceID() + } + if traceID == 0 { + traceID = c.SpanID + } + tags[sqlCommentTraceID] = strconv.FormatUint(traceID, 10) + tags[sqlCommentSpanID] = strconv.FormatUint(c.SpanID, 10) + tags[sqlCommentKeySamplingPriority] = strconv.Itoa(samplingPriority) + fallthrough + case SQLInjectionModeService: + var env, version string + if ctx, ok := spanCtx.(*spanContext); ok { + if e, ok := ctx.meta(ext.Environment); ok { + env = e + } + if v, ok := ctx.meta(ext.Version); ok { + version = v + } + } + if globalconfig.ServiceName() != "" { + tags[sqlCommentService] = globalconfig.ServiceName() + } + if env != "" { + tags[sqlCommentEnv] = env + } + if version != "" { + tags[sqlCommentVersion] = version + } + } + c.Query = commentQuery(c.Query, tags) + return nil +} + +var ( + keyReplacer = strings.NewReplacer(" ", "%20", "!", "%21", "#", "%23", "$", "%24", "%", "%25", "&", "%26", "'", "%27", "(", "%28", ")", "%29", "*", "%2A", "+", "%2B", ",", "%2C", "/", "%2F", ":", "%3A", ";", "%3B", "=", "%3D", "?", "%3F", "@", "%40", "[", "%5B", "]", "%5D") + valueReplacer = strings.NewReplacer(" ", "%20", "!", "%21", "#", "%23", "$", "%24", "%", "%25", "&", "%26", "'", "%27", "(", "%28", ")", "%29", "*", "%2A", "+", "%2B", ",", "%2C", "/", "%2F", ":", "%3A", ";", "%3B", "=", "%3D", "?", "%3F", "@", "%40", "[", "%5B", "]", "%5D", "'", "\\'") +) + +// commentQuery returns the given query with the tags from the SQLCommentCarrier applied to it as a +// prepended SQL comment. The format of the comment follows the sqlcommenter spec. +// See https://google.github.io/sqlcommenter/spec/ for more details. +func commentQuery(query string, tags map[string]string) string { + if len(tags) == 0 { + return "" + } + var b strings.Builder + // the sqlcommenter specification dictates that tags should be sorted. Since we know all injected keys, + // we skip a sorting operation by specifying the order of keys statically + orderedKeys := []string{sqlCommentEnv, sqlCommentSpanID, sqlCommentService, sqlCommentKeySamplingPriority, sqlCommentVersion, sqlCommentTraceID} + first := true + for _, k := range orderedKeys { + if v, ok := tags[k]; ok { + // we need to URL-encode both keys and values and escape single quotes in values + // https://google.github.io/sqlcommenter/spec/ + key := keyReplacer.Replace(k) + val := valueReplacer.Replace(v) + if first { + b.WriteString("/*") + } else { + b.WriteRune(',') + } + b.WriteString(key) + b.WriteRune('=') + b.WriteRune('\'') + b.WriteString(val) + b.WriteRune('\'') + first = false + } + } + if b.Len() == 0 { + return query + } + b.WriteString("*/") + if query == "" { + return b.String() + } + b.WriteRune(' ') + b.WriteString(query) + return b.String() +} + +// Extract is not implemented on SQLCommentCarrier +func (c *SQLCommentCarrier) Extract() (ddtrace.SpanContext, error) { + return nil, nil +} diff --git a/ddtrace/tracer/sqlcomment_test.go b/ddtrace/tracer/sqlcomment_test.go new file mode 100644 index 0000000000..449e11c0ee --- /dev/null +++ b/ddtrace/tracer/sqlcomment_test.go @@ -0,0 +1,107 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2016 Datadog, Inc. + +package tracer + +import ( + "strconv" + "strings" + "testing" + + "gopkg.in/DataDog/dd-trace-go.v1/ddtrace" + "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/ext" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSQLCommentCarrier(t *testing.T) { + testCases := []struct { + name string + query string + mode SQLCommentInjectionMode + injectSpan bool + expectedQuery string + expectedSpanIDGen bool + }{ + { + name: "default", + query: "SELECT * from FOO", + mode: SQLInjectionModeFull, + injectSpan: true, + expectedQuery: "/*dde='test-env',ddsid='',ddsn='whiskey-service%20%21%23%24%25%26%27%28%29%2A%2B%2C%2F%3A%3B%3D%3F%40%5B%5D',ddsp='2',ddsv='1.0.0',ddtid='10'*/ SELECT * from FOO", + expectedSpanIDGen: true, + }, + { + name: "service", + query: "SELECT * from FOO", + mode: SQLInjectionModeService, + injectSpan: true, + expectedQuery: "/*dde='test-env',ddsn='whiskey-service%20%21%23%24%25%26%27%28%29%2A%2B%2C%2F%3A%3B%3D%3F%40%5B%5D',ddsv='1.0.0'*/ SELECT * from FOO", + expectedSpanIDGen: false, + }, + { + name: "no-trace", + query: "SELECT * from FOO", + mode: SQLInjectionModeFull, + expectedQuery: "/*ddsid='',ddsn='whiskey-service%20%21%23%24%25%26%27%28%29%2A%2B%2C%2F%3A%3B%3D%3F%40%5B%5D',ddsp='0',ddtid=''*/ SELECT * from FOO", + expectedSpanIDGen: true, + }, + { + name: "no-query", + query: "", + mode: SQLInjectionModeFull, + injectSpan: true, + expectedQuery: "/*dde='test-env',ddsid='',ddsn='whiskey-service%20%21%23%24%25%26%27%28%29%2A%2B%2C%2F%3A%3B%3D%3F%40%5B%5D',ddsp='2',ddsv='1.0.0',ddtid='10'*/", + expectedSpanIDGen: true, + }, + { + name: "commented", + query: "SELECT * from FOO -- test query", + mode: SQLInjectionModeFull, + injectSpan: true, + expectedQuery: "/*dde='test-env',ddsid='',ddsn='whiskey-service%20%21%23%24%25%26%27%28%29%2A%2B%2C%2F%3A%3B%3D%3F%40%5B%5D',ddsp='2',ddsv='1.0.0',ddtid='10'*/ SELECT * from FOO -- test query", + expectedSpanIDGen: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // the test service name includes all RFC3986 reserved characters to make sure all of them are url encoded + // as per the sqlcommenter spec + tracer := newTracer(WithService("whiskey-service !#$%&'()*+,/:;=?@[]"), WithEnv("test-env"), WithServiceVersion("1.0.0")) + defer tracer.Stop() + + var spanCtx ddtrace.SpanContext + if tc.injectSpan { + root := tracer.StartSpan("service.calling.db", WithSpanID(10)).(*span) + root.SetTag(ext.SamplingPriority, 2) + spanCtx = root.Context() + } + + carrier := SQLCommentCarrier{Query: tc.query, Mode: tc.mode} + err := carrier.Inject(spanCtx) + require.NoError(t, err) + + expected := strings.ReplaceAll(tc.expectedQuery, "", strconv.FormatUint(carrier.SpanID, 10)) + assert.Equal(t, expected, carrier.Query) + }) + } +} + +func BenchmarkSQLCommentSerialization(b *testing.B) { + t := map[string]string{ + sqlCommentEnv: "test-env", + sqlCommentTraceID: "0123456789", + sqlCommentSpanID: "9876543210", + sqlCommentVersion: "1.0.0", + sqlCommentService: "test-svc", + } + + b.ReportAllocs() + for n := 0; n < b.N; n++ { + commentQuery("SELECT 1 from DUAL", t) + } +} diff --git a/ddtrace/tracer/textmap.go b/ddtrace/tracer/textmap.go index c281bce69e..c762c32803 100644 --- a/ddtrace/tracer/textmap.go +++ b/ddtrace/tracer/textmap.go @@ -124,7 +124,7 @@ type PropagatorConfig struct { // NewPropagator returns a new propagator which uses TextMap to inject // and extract values. It propagates trace and span IDs and baggage. // To use the defaults, nil may be provided in place of the config. -func NewPropagator(cfg *PropagatorConfig) Propagator { +func NewPropagator(cfg *PropagatorConfig, propagators ...Propagator) Propagator { if cfg == nil { cfg = new(PropagatorConfig) } @@ -140,6 +140,12 @@ func NewPropagator(cfg *PropagatorConfig) Propagator { if cfg.PriorityHeader == "" { cfg.PriorityHeader = DefaultPriorityHeader } + if len(propagators) > 0 { + return &chainedPropagator{ + injectors: propagators, + extractors: propagators, + } + } return &chainedPropagator{ injectors: getPropagators(cfg, headerPropagationStyleInject), extractors: getPropagators(cfg, headerPropagationStyleExtract),