diff --git a/.github/workflows/go-sql-tests.yaml b/.github/workflows/go-sql-tests.yaml new file mode 100644 index 00000000..f7d92a45 --- /dev/null +++ b/.github/workflows/go-sql-tests.yaml @@ -0,0 +1,45 @@ +name: go-sql package test + +on: + push: + branches: + - master + paths: + - go/go-sql/** + pull_request: + paths: + - go/go-sql/** + +jobs: + unittests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Set up Go + uses: actions/setup-go@v3 + with: + go-version: 1.19 + + - name: Build + run: go build -v ./... + working-directory: ./go/go-sql + + - name: Test + run: go test -v ./... + working-directory: ./go/go-sql + + gofmt: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Set up Go + uses: actions/setup-go@v3 + with: + go-version: 1.19 + + # Verify go fmt standards are used + - name: Format + run: if [ "$(gofmt -s -l . | wc -l)" -gt 0 ]; then exit 1; fi + diff --git a/.github/workflows/unit-tests.yaml b/.github/workflows/unit-tests.yaml index 684ee05d..bc56b27b 100644 --- a/.github/workflows/unit-tests.yaml +++ b/.github/workflows/unit-tests.yaml @@ -9,12 +9,14 @@ on: - php/sqlcommenter-php/samples/sqlcommenter-laravel/** - python/sqlcommenter-python/** - nodejs/** + - go/** pull_request: paths-ignore: - php/sqlcommenter-php/packages/sqlcommenter-laravel/** - php/sqlcommenter-php/samples/sqlcommenter-laravel/** - python/sqlcommenter-python/** - nodejs/** + - go/** jobs: unittests: diff --git a/go/go-sql/README.md b/go/go-sql/README.md new file mode 100644 index 00000000..d19afe0c --- /dev/null +++ b/go/go-sql/README.md @@ -0,0 +1,72 @@ +# Sqlcommenter [In development] + +SQLcommenter is a plugin/middleware/wrapper to augment application related information/tags with SQL Statements that can be used later to correlate user code with SQL statements. + +## Installation + +### Install from source + +* Clone the source +* In terminal go inside the client folder location where we need to import google-sqlcommenter package and enter the below commands + +```shell +go mod edit -replace google.com/sqlcommenter=path/to/google/sqlcommenter/go + +go mod tiny +``` +### Install from github [To be added] + +## Usages + +### go-sql-driver +Please use the sqlcommenter's default database driver to execute statements. \ +Due to inherent nature of Go, the safer way to pass information from framework to database driver is via `context`. So, it is recommended to use the context based methods of `DB` interface like `QueryContext`, `ExecContext` and `PrepareContext`. + +```go +db, err := sqlcommenter.Open("", "", sqlcommenter.CommenterOptions{:}) +``` + +#### Configuration + +Users are given control over what tags they want to append by using `sqlcommenter.CommenterOptions` struct. + +```go +type CommenterOptions struct { + EnableDBDriver bool + EnableTraceparent bool // OpenTelemetry trace information + EnableRoute bool // applicable for web frameworks + EnableFramework bool // applicable for web frameworks + EnableController bool // applicable for web frameworks + EnableAction bool // applicable for web frameworks + } +``` + +### net/http +Populate the request context with sqlcommenter.AddHttpRouterTags(r) function in a custom middleware. + +#### Note +* We only support the `database/sql` driver and have provided an implementation for that. +* ORM related tags are added to the driver only when the tags are enabled in the commenter's driver's config and also the request context should passed to the querying functions + +#### Example +```go +// middleware is used to intercept incoming HTTP calls and populate request context with commenter tags. +func middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := sqlcommenter.AddHttpRouterTags(r, next) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} +``` + +## Options + +With Go SqlCommenter, we have configuration to choose which tags to be appended to the comment. + +| Options | Included by default? | go-sql-orm | net/http | Notes | +| --------------- | :------------------: | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :---: | +| `DBDriver` | | [ go-sql-driver](https://pkg.go.dev/database/sql/driver) | | +| `Action` | | | [net/http handle](https://pkg.go.dev/net/http#Handle) | | +| `Route` | | | [net/http routing path](https://pkg.go.dev/github.com/gorilla/mux#Route.URLPath) | | +| `Framework` | | | [net/http](https://pkg.go.dev/net/http) | | +| `Opentelemetry` | | [W3C TraceContext.Traceparent](https://www.w3.org/TR/trace-context/#traceparent-field), [W3C TraceContext.Tracestate](https://www.w3.org/TR/trace-context/#tracestate-field) | [W3C TraceContext.Traceparent](https://www.w3.org/TR/trace-context/#traceparent-field), [W3C TraceContext.Tracestate](https://www.w3.org/TR/trace-context/#tracestate-field) | | diff --git a/go/go-sql/go-sql.go b/go/go-sql/go-sql.go new file mode 100644 index 00000000..05ddf6ca --- /dev/null +++ b/go/go-sql/go-sql.go @@ -0,0 +1,190 @@ +// Copyright 2022 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gosql + +import ( + "context" + "database/sql" + "fmt" + "net/http" + "net/url" + "reflect" + "runtime" + "sort" + "strings" + + "go.opentelemetry.io/otel/propagation" +) + +const ( + route string = "route" + controller string = "controller" + action string = "action" + framework string = "framework" + driver string = "driver" + traceparent string = "traceparent" +) + +type DB struct { + *sql.DB + options CommenterOptions +} + +type CommenterOptions struct { + EnableDBDriver bool + EnableRoute bool + EnableFramework bool + EnableController bool + EnableAction bool + EnableTraceparent bool +} + +func Open(driverName string, dataSourceName string, options CommenterOptions) (*DB, error) { + db, err := sql.Open(driverName, dataSourceName) + return &DB{DB: db, options: options}, err +} + +// ***** Query Functions ***** + +func (db *DB) Query(query string, args ...any) (*sql.Rows, error) { + return db.DB.Query(db.withComment(context.Background(), query), args...) +} + +func (db *DB) QueryRow(query string, args ...interface{}) *sql.Row { + return db.DB.QueryRow(db.withComment(context.Background(), query), args...) +} + +func (db *DB) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) { + return db.DB.QueryContext(ctx, db.withComment(ctx, query), args...) +} + +func (db *DB) Exec(query string, args ...any) (sql.Result, error) { + return db.DB.Exec(db.withComment(context.Background(), query), args...) +} + +func (db *DB) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) { + return db.DB.ExecContext(ctx, db.withComment(ctx, query), args...) +} + +func (db *DB) Prepare(query string) (*sql.Stmt, error) { + return db.DB.Prepare(db.withComment(context.Background(), query)) +} + +func (db *DB) PrepareContext(ctx context.Context, query string) (*sql.Stmt, error) { + return db.DB.PrepareContext(ctx, db.withComment(ctx, query)) +} + +// ***** Query Functions ***** + +// ***** Framework Functions ***** + +func AddHttpRouterTags(r *http.Request, next any) context.Context { // any type is set because we need to refrain from importing http-router package + ctx := context.Background() + ctx = context.WithValue(ctx, route, r.URL.Path) + ctx = context.WithValue(ctx, action, getFunctionName(next)) + ctx = context.WithValue(ctx, framework, "net/http") + return ctx +} + +// ***** Framework Functions ***** + +// ***** Commenter Functions ***** + +func (db *DB) withComment(ctx context.Context, query string) string { + var commentsMap = map[string]string{} + query = strings.TrimSpace(query) + + // Sorted alphabetically + if db.options.EnableAction && (ctx.Value(action) != nil) { + commentsMap[action] = ctx.Value(action).(string) + } + + // `driver` information should not be coming from framework. + // So, explicitly adding that here. + if db.options.EnableDBDriver { + commentsMap[driver] = "database/sql" + } + + if db.options.EnableFramework && (ctx.Value(framework) != nil) { + commentsMap[framework] = ctx.Value(framework).(string) + } + + if db.options.EnableRoute && (ctx.Value(route) != nil) { + commentsMap[route] = ctx.Value(route).(string) + } + + if db.options.EnableTraceparent { + carrier := extractTraceparent(ctx) + if val, ok := carrier["traceparent"]; ok { + commentsMap[traceparent] = val + } + } + + var commentsString string = "" + if len(commentsMap) > 0 { // Converts comments map to string and appends it to query + commentsString = fmt.Sprintf("/*%s*/", convertMapToComment(commentsMap)) + } + + // A semicolon at the end of the SQL statement means the query ends there. + // We need to insert the comment before that to be considered as part of the SQL statemtent. + if query[len(query)-1:] == ";" { + return fmt.Sprintf("%s%s;", strings.TrimSuffix(query, ";"), commentsString) + } + return fmt.Sprintf("%s%s", query, commentsString) +} + +// ***** Commenter Functions ***** + +// ***** Util Functions ***** + +func encodeURL(k string) string { + return url.QueryEscape(string(k)) +} + +func getFunctionName(i interface{}) string { + return runtime.FuncForPC(reflect.ValueOf(i).Pointer()).Name() +} + +func convertMapToComment(tags map[string]string) string { + var sb strings.Builder + i, sz := 0, len(tags) + + //sort by keys + sortedKeys := make([]string, 0, len(tags)) + for k := range tags { + sortedKeys = append(sortedKeys, k) + } + sort.Strings(sortedKeys) + + for _, key := range sortedKeys { + if i == sz-1 { + sb.WriteString(fmt.Sprintf("%s=%v", encodeURL(key), encodeURL(tags[key]))) + } else { + sb.WriteString(fmt.Sprintf("%s=%v,", encodeURL(key), encodeURL(tags[key]))) + } + i++ + } + return sb.String() +} + +func extractTraceparent(ctx context.Context) propagation.MapCarrier { + // Serialize the context into carrier + propgator := propagation.NewCompositeTextMapPropagator(propagation.TraceContext{}, propagation.Baggage{}) + carrier := propagation.MapCarrier{} + propgator.Inject(ctx, carrier) + return carrier +} + +// ***** Util Functions ***** diff --git a/go/go-sql/go-sql_test.go b/go/go-sql/go-sql_test.go new file mode 100644 index 00000000..be03518a --- /dev/null +++ b/go/go-sql/go-sql_test.go @@ -0,0 +1,87 @@ +package gosql + +import ( + "context" + "net/http" + "regexp" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "go.opentelemetry.io/otel/exporters/stdout/stdouttrace" + sdktrace "go.opentelemetry.io/otel/sdk/trace" +) + +var engine, connectionParams = "mysql", "root:root@/gotest" + +func TestDisabled(t *testing.T) { + mockDB, _, err := sqlmock.New() + + db := DB{DB: mockDB, options: CommenterOptions{}} + if err != nil { + t.Fatalf("MockSQL failed with unexpected error: %s", err) + } + + query := "SELECT 2" + if got, want := db.withComment(context.Background(), query), query; got != want { + t.Errorf("db.withComment(context.Background(), %q) = %q, want = %q", query, got, want) + } +} + +func TestHTTP_Net(t *testing.T) { + mockDB, _, err := sqlmock.New() + + db := DB{DB: mockDB, options: CommenterOptions{EnableDBDriver: true, EnableRoute: true, EnableFramework: true}} + if err != nil { + t.Fatalf("MockSQL failed with unexpected error: %s", err) + } + + r, _ := http.NewRequest("GET", "hello/1", nil) + ctx := AddHttpRouterTags(r, context.Background()) + + got := db.withComment(ctx, "Select 1") + want := "Select 1/*driver=database%2Fsql,framework=net%2Fhttp,route=hello%2F1*/" + + if got != want { + t.Errorf("got %q, wanted %q", got, want) + } +} + +func TestQueryWithSemicolon(t *testing.T) { + mockDB, _, err := sqlmock.New() + + db := DB{DB: mockDB, options: CommenterOptions{EnableDBDriver: true}} + if err != nil { + t.Fatalf("MockSQL failed with unexpected error: %s", err) + } + got := db.withComment(context.Background(), "Select 1;") + want := "Select 1/*driver=database%2Fsql*/;" + + if got != want { + t.Errorf("got %q, wanted %q", got, want) + } +} + +func TestOtelIntegration(t *testing.T) { + mockDB, _, err := sqlmock.New() + + db := DB{DB: mockDB, options: CommenterOptions{EnableTraceparent: true}} + if err != nil { + t.Fatalf("MockSQL failed with unexpected error: %s", err) + } + + exp, _ := stdouttrace.New(stdouttrace.WithPrettyPrint()) + bsp := sdktrace.NewSimpleSpanProcessor(exp) // You should use batch span processor in prod + tp := sdktrace.NewTracerProvider( + sdktrace.WithSampler(sdktrace.AlwaysSample()), + sdktrace.WithSpanProcessor(bsp), + ) + + ctx, _ := tp.Tracer("").Start(context.Background(), "parent-span-name") + + got := db.withComment(ctx, "Select 1;") + r, _ := regexp.Compile("Select 1/\\*traceparent=\\d{1,2}-[a-zA-Z0-9_]{32}-[a-zA-Z0-9_]{16}-\\d{1,2}\\*/;") + + if !r.MatchString(got) { + t.Errorf("got %q", got) + } +} diff --git a/go/go-sql/go.mod b/go/go-sql/go.mod new file mode 100644 index 00000000..201245cb --- /dev/null +++ b/go/go-sql/go.mod @@ -0,0 +1,20 @@ +module google.com/sqlcommenter/gosql + +go 1.19 + +require ( + go.opentelemetry.io/otel v1.10.0 + go.opentelemetry.io/otel/sdk v1.10.0 +) + +require ( + github.com/go-logr/logr v1.2.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + golang.org/x/sys v0.0.0-20220927170352-d9d178bc13c6 // indirect +) + +require ( + github.com/DATA-DOG/go-sqlmock v1.5.0 + go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.10.0 + go.opentelemetry.io/otel/trace v1.10.0 // indirect +) diff --git a/go/go-sql/go.sum b/go/go-sql/go.sum new file mode 100644 index 00000000..14fae557 --- /dev/null +++ b/go/go-sql/go.sum @@ -0,0 +1,24 @@ +github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60= +github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.2.3 h1:2DntVwHkVopvECVRSlL5PSo9eG+cAkDCuckLubN+rq0= +github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY= +go.opentelemetry.io/otel v1.10.0 h1:Y7DTJMR6zs1xkS/upamJYk0SxxN4C9AqRd77jmZnyY4= +go.opentelemetry.io/otel v1.10.0/go.mod h1:NbvWjCthWHKBEUMpf0/v8ZRZlni86PpGFEMA9pnQSnQ= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.10.0 h1:c9UtMu/qnbLlVwTwt+ABrURrioEruapIslTDYZHJe2w= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.10.0/go.mod h1:h3Lrh9t3Dnqp3NPwAZx7i37UFX7xrfnO1D+fuClREOA= +go.opentelemetry.io/otel/sdk v1.10.0 h1:jZ6K7sVn04kk/3DNUdJ4mqRlGDiXAVuIG+MMENpTNdY= +go.opentelemetry.io/otel/sdk v1.10.0/go.mod h1:vO06iKzD5baltJz1zarxMCNHFpUlUiOy4s65ECtn6kE= +go.opentelemetry.io/otel/trace v1.10.0 h1:npQMbR8o7mum8uF95yFbOEJffhs1sbCOfDh8zAJiH5E= +go.opentelemetry.io/otel/trace v1.10.0/go.mod h1:Sij3YYczqAdz+EhmGhE6TpTxUO5/F/AzrK+kxfGqySM= +golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7 h1:iGu644GcxtEcrInvDsQRCwJjtCIOlT2V7IRt6ah2Whw= +golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220927170352-d9d178bc13c6 h1:cy1ko5847T/lJ45eyg/7uLprIE/amW5IXxGtEnQdYMI= +golang.org/x/sys v0.0.0-20220927170352-d9d178bc13c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=