diff --git a/.env.example b/.env.example index 937f193..bd2c7e7 100644 --- a/.env.example +++ b/.env.example @@ -18,3 +18,19 @@ POSTGRES_SSL_MODE="disable" # CORS ## Allowed Frontend Origins ALLOWED_ORIGINS=http://localhost:3000,http://example.com + +# OTEL Configuration +## Example for New Relic +OTEL_ENABLED=false + +## Application name which will be shown in OTEL Frontend (e.g NewRelic, Dynatrace) +OTEL_SERVICE_NAME=my-service +OTEL_SERVICE_VERSION=1.0.0 + +## OTEL Exporter Configuration +OTEL_EXPORTER_OTLP_ENDPOINT=otlp.eu01.nr-data.net:4317 +OTEL_EXPORTER_OTLP_HEADERS="api-key=XXXX" +OTEL_EXPORTER_OTLP_COMPRESSION=gzip +OTEL_EXPORTER_OTLP_QUEUE_SIZE=4094 +OTEL_EXPORTER_OTLP_MAX_EXPORT_BATCH_SIZE=1024 +OTEL_INSECURE=false diff --git a/README.md b/README.md index 377a171..989c67a 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ This is a template for a Go API project. It includes the following features: - API Security - API Logging - API Testing +- Platform agnostic OpenTelemetry ## Requirements diff --git a/bin/load-test.js b/bin/load-test.js new file mode 100644 index 0000000..049ea67 --- /dev/null +++ b/bin/load-test.js @@ -0,0 +1,72 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { randomString } from 'https://jslib.k6.io/k6-utils/1.2.0/index.js'; + +export const options = { + stages: [ + { duration: '30s', target: 5 }, // Ramp up to 5 users + { duration: '1m', target: 5 }, // Stay at 5 users + { duration: '20s', target: 10 }, // Ramp up to 10 users + { duration: '1m', target: 10 }, // Stay at 10 users + { duration: '20s', target: 0 }, // Scale down to 0 users + ], + thresholds: { + http_req_duration: ['p(95)<2000'], // 95% of requests must complete below 2s + http_req_failed: ['rate<0.1'], // Less than 10% can fail + }, +}; + +const BASE_URL = 'http://localhost:8080/api/v1'; + +// Generates a random UUID v4 +function uuidv4() { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + const r = Math.random() * 16 | 0; + const v = c == 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); + }); +} + +export default function () { + const userId = uuidv4(); + const authToken = `Bearer ${randomString(32)}`; + + // Add trace context headers + const headers = { + 'Authorization': authToken, + 'traceparent': `00-${randomString(32)}-${randomString(16)}-01`, + 'Content-Type': 'application/json', + }; + + // Test user endpoint with trace context + const userResponse = http.get(`${BASE_URL}/users/${userId}`, { + headers: headers, + tags: { name: 'GetUserByID' }, + }); + + // Check response and add custom span attributes via tags + check(userResponse, { + 'status is 401 or 422': (r) => r.status === 401 || r.status === 422, + 'response time OK': (r) => r.timings.duration < 2000, + }); + + // Add some variation in the test pattern + if (Math.random() < 0.3) { + // Simulate slow requests occasionally + sleep(2); + } else { + sleep(1); + } + + // Test invalid UUID to generate error traces + if (Math.random() < 0.1) { + const invalidResponse = http.get(`${BASE_URL}/users/invalid-uuid`, { + headers: headers, + tags: { name: 'GetUserByIDInvalid' }, + }); + + check(invalidResponse, { + 'invalid uuid returns 400': (r) => r.status === 400, + }); + } +} \ No newline at end of file diff --git a/configuration/config.go b/configuration/env.go similarity index 95% rename from configuration/config.go rename to configuration/env.go index 77e8430..398a3be 100644 --- a/configuration/config.go +++ b/configuration/env.go @@ -19,6 +19,7 @@ type Env struct { PostgresPassword string PostgresSSLMode string AllowedOrigins []string + Telemetry *Telemetry } func Load() (*Env, error) { @@ -51,5 +52,6 @@ func Load() (*Env, error) { PostgresPassword: viper.GetString("POSTGRES_PASSWORD"), PostgresSSLMode: viper.GetString("POSTGRES_SSL_MODE"), AllowedOrigins: origins, + Telemetry: TelemetryNew(), }, nil } diff --git a/configuration/telemetry.go b/configuration/telemetry.go new file mode 100644 index 0000000..00a1fb8 --- /dev/null +++ b/configuration/telemetry.go @@ -0,0 +1,56 @@ +package configuration + +import ( + "strings" + + "github.com/spf13/viper" +) + +type Telemetry struct { + Enabled bool + Environment string + Endpoint string + Headers map[string]string + Insecure bool + QueueSize int + MaxExportBatchSize int + Compression string + ServiceName string + ServiceVersion string +} + +// TelemetryNew creates a new Telemetry configuration from environment +func TelemetryNew() *Telemetry { + viper.SetDefault("OTEL_ENABLED", false) + viper.SetDefault("OTEL_SERVICE_NAME", "go-api-template") + viper.SetDefault("OTEL_SERVICE_VERSION", "1.0.0") + viper.SetDefault("OTEL_EXPORTER_OTLP_ENDPOINT", "localhost:4317") + viper.SetDefault("OTEL_INSECURE", true) + viper.SetDefault("OTEL_EXPORTER_OTLP_HEADERS", "") + viper.SetDefault("OTEL_EXPORTER_OTLP_QUEUE_SIZE", 4096) + viper.SetDefault("OTEL_EXPORTER_OTLP_MAX_EXPORT_BATCH_SIZE", 512) + viper.SetDefault("OTEL_EXPORTER_OTLP_COMPRESSION", "gzip") + + headers := make(map[string]string) + if headerStr := viper.GetString("OTEL_EXPORTER_OTLP_HEADERS"); headerStr != "" { + for _, header := range strings.Split(headerStr, ",") { + parts := strings.SplitN(header, "=", 2) + if len(parts) == 2 { + headers[parts[0]] = parts[1] + } + } + } + + return &Telemetry{ + Enabled: viper.GetBool("OTEL_ENABLED"), + ServiceName: viper.GetString("OTEL_SERVICE_NAME"), + ServiceVersion: viper.GetString("OTEL_SERVICE_VERSION"), + Environment: viper.GetString("APP_ENV"), + Endpoint: viper.GetString("OTEL_EXPORTER_OTLP_ENDPOINT"), + Headers: headers, + Insecure: viper.GetBool("OTEL_INSECURE"), + QueueSize: viper.GetInt("OTEL_EXPORTER_OTLP_QUEUE_SIZE"), + MaxExportBatchSize: viper.GetInt("OTEL_EXPORTER_OTLP_MAX_EXPORT_BATCH_SIZE"), + Compression: viper.GetString("OTEL_EXPORTER_OTLP_COMPRESSION"), + } +} diff --git a/controller/user.go b/controller/user.go index 2a2ed17..0465429 100644 --- a/controller/user.go +++ b/controller/user.go @@ -6,6 +6,8 @@ import ( "github.com/gin-gonic/gin" "github.com/go-openapi/strfmt" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/trace" ) type IUser interface { @@ -14,11 +16,13 @@ type IUser interface { type user struct { service service.IUser + tracer trace.Tracer } func NewUser(service service.IUser) IUser { return &user{ service: service, + tracer: otel.Tracer("controller/user"), } } @@ -36,15 +40,19 @@ func NewUser(service service.IUser) IUser { // @Failure 500 {object} model.InternalErrorResponse // @Router /users/{user_id} [get] func (controller *user) UserByID(ctx *gin.Context) { - // Validate path params + spanCtx, span := controller.tracer.Start(ctx.Request.Context(), "UserByID") + defer span.End() + userID := ctx.Param("user_id") if !strfmt.IsUUID4(userID) { + span.RecordError(commonerrors.ErrInvalidUserID) StatusBadRequest(ctx, commonerrors.ErrInvalidUserID) return } - response, err := controller.service.UserByID(ctx, strfmt.UUID4(userID)) + response, err := controller.service.UserByID(spanCtx, strfmt.UUID4(userID)) if err != nil { + span.RecordError(err) HandleCommonErrors(ctx, err) return } diff --git a/go.mod b/go.mod index cf04756..a2041c9 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module go-api-template go 1.23.5 require ( + github.com/XSAM/otelsql v0.37.0 github.com/gin-gonic/gin v1.10.0 github.com/go-openapi/strfmt v0.23.0 github.com/go-playground/validator/v10 v10.25.0 @@ -16,7 +17,15 @@ require ( github.com/spf13/viper v1.20.0 github.com/stretchr/testify v1.10.0 github.com/swaggo/swag v1.16.4 + go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.60.0 + go.opentelemetry.io/contrib/instrumentation/runtime v0.60.0 + go.opentelemetry.io/otel v1.35.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 + go.opentelemetry.io/otel/sdk v1.35.0 + go.opentelemetry.io/otel/trace v1.35.0 go.uber.org/zap v1.27.0 + google.golang.org/grpc v1.71.0 gopkg.in/DATA-DOG/go-sqlmock.v1 v1.3.0 gorm.io/driver/postgres v1.5.11 ) @@ -26,14 +35,16 @@ require ( github.com/PuerkitoBio/purell v1.1.1 // indirect github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect - github.com/bytedance/sonic v1.11.6 // indirect - github.com/bytedance/sonic/loader v0.1.1 // indirect - github.com/cloudwego/base64x v0.1.4 // indirect - github.com/cloudwego/iasm v0.2.0 // indirect + github.com/bytedance/sonic v1.12.10 // indirect + github.com/bytedance/sonic/loader v0.2.3 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cloudwego/base64x v0.1.5 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/fsnotify/fsnotify v1.8.0 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect - github.com/gin-contrib/sse v0.1.0 // indirect + github.com/gin-contrib/sse v1.0.0 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/errors v0.22.0 // indirect github.com/go-openapi/jsonpointer v0.19.5 // indirect github.com/go-openapi/jsonreference v0.19.6 // indirect @@ -43,6 +54,7 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-viper/mapstructure/v2 v2.2.1 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect github.com/jackc/pgx/v5 v5.5.5 // indirect @@ -51,7 +63,7 @@ require ( github.com/jinzhu/now v1.1.5 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mailru/easyjson v0.7.6 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -71,15 +83,21 @@ require ( github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect go.mongodb.org/mongo-driver v1.14.0 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/otel/metric v1.35.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.35.0 // indirect + go.opentelemetry.io/proto/otlp v1.5.0 // indirect go.uber.org/multierr v1.10.0 // indirect - golang.org/x/arch v0.8.0 // indirect + golang.org/x/arch v0.14.0 // indirect golang.org/x/crypto v0.35.0 // indirect golang.org/x/net v0.36.0 // indirect golang.org/x/sync v0.11.0 // indirect golang.org/x/sys v0.30.0 // indirect golang.org/x/text v0.22.0 // indirect golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect - google.golang.org/protobuf v1.36.1 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a // indirect + google.golang.org/protobuf v1.36.5 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect gorm.io/gorm v1.25.10 // indirect diff --git a/go.sum b/go.sum index 0baf45f..959e63b 100644 --- a/go.sum +++ b/go.sum @@ -6,15 +6,19 @@ github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tN github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= +github.com/XSAM/otelsql v0.37.0 h1:ya5RNw028JW0eJW8Ma4AmoKxAYsJSGuNVbC7F1J457A= +github.com/XSAM/otelsql v0.37.0/go.mod h1:LHbCu49iU8p255nCn1oi04oX2UjSoRcUMiKEHo2a5qM= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= -github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= -github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= -github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= +github.com/bytedance/sonic v1.12.10 h1:uVCQr6oS5669E9ZVW0HyksTLfNS7Q/9hV6IVS4nEMsI= +github.com/bytedance/sonic v1.12.10/go.mod h1:uVvFidNmlt9+wa31S1urfwwthTWteBgG0hWuoKAXTx8= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= -github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= -github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= -github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/bytedance/sonic/loader v0.2.3 h1:yctD0Q3v2NOGfSWPLPvG2ggA2kV6TS6s4wioyEqssH0= +github.com/bytedance/sonic/loader v0.2.3/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4= +github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -27,10 +31,15 @@ github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/ github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= -github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= -github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E= +github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0= github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +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/go-openapi/errors v0.22.0 h1:c4xY/OLxUBSTiepAg3j/MHuAv5mJhnf53LLMWFB+u/w= github.com/go-openapi/errors v0.22.0/go.mod h1:J3DmZScxCDufmIMsdOuDHxJbdOGC0xtUynjIx092vXE= github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= @@ -59,11 +68,15 @@ github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIx github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 h1:e9Rjr40Z98/clHv5Yg79Is0NtosR5LXRvdr7o/6NwbA= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1/go.mod h1:tIxuGz/9mpox++sgp9fJjHO0+q1X9/UOWd798aAm22M= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= @@ -85,8 +98,8 @@ github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFF github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= -github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -126,8 +139,8 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= @@ -153,6 +166,7 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= @@ -165,15 +179,36 @@ github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65E github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= go.mongodb.org/mongo-driver v1.14.0 h1:P98w8egYRjYe3XDjxhYJagTokP/H6HzlsnojRgZRd80= go.mongodb.org/mongo-driver v1.14.0/go.mod h1:Vzb0Mk/pa7e6cWw85R4F/endUC3u0U9jGcNU603k65c= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.60.0 h1:jj/B7eX95/mOxim9g9laNZkOHKz/XCHG0G410SntRy4= +go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.60.0/go.mod h1:ZvRTVaYYGypytG0zRp2A60lpj//cMq3ZnxYdZaljVBM= +go.opentelemetry.io/contrib/instrumentation/runtime v0.60.0 h1:0NgN/3SYkqYJ9NBlDfl/2lzVlwos/YQLvi8sUrzJRBE= +go.opentelemetry.io/contrib/instrumentation/runtime v0.60.0/go.mod h1:oxpUfhTkhgQaYIjtBt3T3w135dLoxq//qo3WPlPIKkE= +go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= +go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 h1:1fTNlAIJZGWLP5FVu0fikVry1IsiUnXjf7QFvoNN3Xw= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0/go.mod h1:zjPK58DtkqQFn+YUMbx0M2XV3QgKU0gS9LeGohREyK4= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 h1:m639+BofXTvcY1q8CGs4ItwQarYtJPOWmVobfM1HpVI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0/go.mod h1:LjReUci/F4BUyv+y4dwnq3h/26iNOeC3wAIqgvTIZVo= +go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= +go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= +go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= +go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= +go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o= +go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= +go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= +go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= +go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4= +go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= -golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= -golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= -golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/arch v0.14.0 h1:z9JUEZWr8x4rR0OU6c4/4t6E6jOZ8/QBS2bBYBm4tx4= +golang.org/x/arch v0.14.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= @@ -185,7 +220,6 @@ golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= @@ -197,8 +231,14 @@ golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk= -google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a h1:nwKuGPlUAt+aR+pcrkfFRrTU1BVrSmYyYMxYbUIVHr0= +google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a/go.mod h1:3kWAYMk1I75K4vykHtKt2ycnOgpA6974V7bREqbsenU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a h1:51aaUVRocpvUOSQKM6Q7VuoaktNIaMCLuhZB6DKksq4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a/go.mod h1:uRxBH1mhmO8PGhU89cMcHaXKZqO+OfakD8QQO0oYwlQ= +google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg= +google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= +google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= +google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/DATA-DOG/go-sqlmock.v1 v1.3.0 h1:FVCohIoYO7IJoDDVpV2pdq7SgrMH6wHnuTyrdrxJNoY= gopkg.in/DATA-DOG/go-sqlmock.v1 v1.3.0/go.mod h1:OdE7CF6DbADk7lN8LIKRzRJTTZXIjtWgA5THM5lhBAw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -218,4 +258,3 @@ gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSk gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s= gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= -rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/main.go b/main.go index 17e188b..415bc44 100644 --- a/main.go +++ b/main.go @@ -7,6 +7,7 @@ import ( "go-api-template/middleware" "go-api-template/model" "go-api-template/pkg/logger" + "go-api-template/pkg/telemetry" "go-api-template/repository" "go-api-template/service" "log" @@ -56,6 +57,18 @@ func main() { // Initialize the controllers controllers := controller.NewControllers(services) + // Initialize OpenTelemetry + if cfg.Telemetry.Enabled { + otel := configuration.TelemetryNew() + otel.ServiceName = cfg.Telemetry.ServiceName + otel.Environment = cfg.Telemetry.Environment + otel.Endpoint = cfg.Telemetry.Endpoint + otel.Headers = cfg.Telemetry.Headers + + cleanup := telemetry.InitTracer(otel) + defer cleanup() + } + // Initialize router router, err := middleware.NewRouter(cfg, controllers) if err != nil { diff --git a/middleware/router.go b/middleware/router.go index 3af4931..cfa17ba 100644 --- a/middleware/router.go +++ b/middleware/router.go @@ -7,6 +7,7 @@ import ( "go-api-template/pkg/logger" "github.com/gin-gonic/gin" + "go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin" ) func NewRouter(cfg *configuration.Env, controllers *controller.Controllers) (*gin.Engine, error) { @@ -15,6 +16,9 @@ func NewRouter(cfg *configuration.Env, controllers *controller.Controllers) (*gi } router := gin.Default() + if cfg.Telemetry.Enabled { + router.Use(otelgin.Middleware(cfg.Telemetry.ServiceName)) + } // Initialize middlewares middleware := NewMiddlewares(cfg) diff --git a/pkg/telemetry/collector.go b/pkg/telemetry/collector.go new file mode 100644 index 0000000..4a6906b --- /dev/null +++ b/pkg/telemetry/collector.go @@ -0,0 +1,104 @@ +package telemetry + +import ( + "context" + "crypto/tls" + "fmt" + "go-api-template/configuration" + "go-api-template/pkg/logger" + "time" + + "go.opentelemetry.io/contrib/instrumentation/runtime" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" + "go.opentelemetry.io/otel/propagation" + "go.opentelemetry.io/otel/sdk/resource" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + semconv "go.opentelemetry.io/otel/semconv/v1.24.0" + "google.golang.org/grpc/credentials" +) + +func InitTracer(cfg *configuration.Telemetry) func() { + ctx := context.Background() + shutdown, err := newOtelCollector(ctx, cfg) + if err != nil { + logger.Fatalf("failed to setup OpenTelemetry: %v", err) + } + + return func() { + logger.Infof("Shutting down OpenTelemetry") + if err := shutdown(context.Background()); err != nil { + logger.Errorf("failed to shutdown OpenTelemetry: %v", err) + } + } +} + +func newOtelCollector(ctx context.Context, cfg *configuration.Telemetry) (func(context.Context) error, error) { + // Cleanup functions which need to be executed when the OpenTelemetry SDK is shutting down. + // When shutdown is called (typically when application is terminating), + // it executes all these cleanup functions in order and combines their errors + shutdownHandler := NewShutdownHandler() + + // Setup OpenTelemetry SDK + resources, err := resource.New(ctx, + resource.WithFromEnv(), + resource.WithSchemaURL(semconv.SchemaURL), + resource.WithAttributes( + semconv.ServiceNameKey.String(cfg.ServiceName), + semconv.ServiceVersionKey.String(cfg.ServiceVersion), + semconv.DeploymentEnvironmentKey.String(cfg.Environment), + ), + ) + if err != nil { + return nil, fmt.Errorf("failed to create Otel SDK resource: %w", err) + } + + // Configure OTEL Exporter + opts := []otlptracegrpc.Option{ + otlptracegrpc.WithEndpoint(cfg.Endpoint), + otlptracegrpc.WithHeaders(cfg.Headers), + otlptracegrpc.WithTLSCredentials(credentials.NewTLS(&tls.Config{ + MinVersion: tls.VersionTLS12, + })), + otlptracegrpc.WithTimeout(30 * time.Second), + otlptracegrpc.WithCompressor(cfg.Compression), + } + + client := otlptracegrpc.NewClient(opts...) + traceExporter, err := otlptrace.New(ctx, client) + if err != nil { + return nil, fmt.Errorf("failed to create trace exporter: %w", err) + } + + // Create BatchSpanProcessor + batchSpanProcessor := sdktrace.NewBatchSpanProcessor(traceExporter, + sdktrace.WithMaxQueueSize(cfg.QueueSize), + sdktrace.WithBatchTimeout(2*time.Second), + sdktrace.WithMaxExportBatchSize(cfg.MaxExportBatchSize), + ) + + tracerProvider := sdktrace.NewTracerProvider( + sdktrace.WithResource(resources), + sdktrace.WithSpanProcessor(batchSpanProcessor), + sdktrace.WithSampler(sdktrace.AlwaysSample()), + ) + shutdownHandler.AddFunction(tracerProvider.Shutdown) + + // Set global TracerProvider + otel.SetTracerProvider(tracerProvider) + + // Set global Propagator + otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator( + propagation.TraceContext{}, + propagation.Baggage{}, + )) + + // Enable runtime metrics + if err := runtime.Start(runtime.WithMinimumReadMemStatsInterval(time.Second)); err != nil { + return nil, fmt.Errorf("failed to start runtime metrics: %w", err) + } + + logger.Infof("Successfully initialized OpenTelemetry") + return shutdownHandler.Shutdown, nil +} diff --git a/pkg/telemetry/shutdown.go b/pkg/telemetry/shutdown.go new file mode 100644 index 0000000..d338f92 --- /dev/null +++ b/pkg/telemetry/shutdown.go @@ -0,0 +1,37 @@ +package telemetry + +import ( + ctx "context" + "errors" + "go-api-template/pkg/logger" +) + +type ShutdownHandler struct { + funcs []func(ctx.Context) error +} + +// NewShutdownHandler creates a new shutdown handler +func NewShutdownHandler() *ShutdownHandler { + return &ShutdownHandler{ + funcs: make([]func(ctx.Context) error, 0), + } +} + +// AddFunction adds a shutdown function to the handler +func (handle *ShutdownHandler) AddFunction(function func(ctx.Context) error) { + handle.funcs = append(handle.funcs, function) +} + +// Shutdown executes all registered shutdown functions +func (handle *ShutdownHandler) Shutdown(ctx ctx.Context) error { + var err error + for _, functions := range handle.funcs { + if ferr := functions(ctx); ferr != nil { + err = errors.Join(err, ferr) + logger.Errorf("shutdown function failed: %v", ferr) + } + } + + handle.funcs = nil + return err +} diff --git a/repository/connection.go b/repository/connection.go index 0a81ea3..925bf95 100644 --- a/repository/connection.go +++ b/repository/connection.go @@ -1,12 +1,17 @@ package repository import ( + "context" "fmt" "go-api-template/configuration" "go-api-template/pkg/logger" + "github.com/XSAM/otelsql" "github.com/jmoiron/sqlx" _ "github.com/lib/pq" // driver for PostgreSQL + "go.opentelemetry.io/otel" + + semconv "go.opentelemetry.io/otel/semconv/v1.24.0" ) type Connection struct { @@ -23,13 +28,28 @@ func NewConnection(cfg *configuration.Env) *Connection { cfg.PostgresSSLMode, ) - db, err := sqlx.Open("postgres", psqlURL) + // Create an instrumented sql.DB + sqlDB, err := otelsql.Open("postgres", psqlURL, + otelsql.WithAttributes( + semconv.DBSystemPostgreSQL, + semconv.DBNameKey.String(cfg.PostgresDB), + ), + otelsql.WithTracerProvider(otel.GetTracerProvider()), + otelsql.WithSpanOptions(otelsql.SpanOptions{ + Ping: true, + RowsNext: true, + }), + ) if err != nil { - logger.Fatalf("connecting to database failed. %v", err) + logger.Fatalf("failed to create database connection: %v", err) } - if err = db.Ping(); err != nil { - logger.Fatalf("connecting to database failed. %v", err) + // Wrap sql.DB with sqlx + db := sqlx.NewDb(sqlDB, "postgres") + + // Test the connection with context + if err = db.PingContext(context.Background()); err != nil { + logger.Fatalf("connecting to database failed: %v", err) } logger.Infof("database connection established") diff --git a/repository/user.go b/repository/user.go index e884aac..f4acf1b 100644 --- a/repository/user.go +++ b/repository/user.go @@ -1,6 +1,7 @@ package repository import ( + "context" "database/sql" "go-api-template/model/commonerrors" repositorymodel "go-api-template/repository/model" @@ -8,20 +9,25 @@ import ( "github.com/jmoiron/sqlx" "github.com/pkg/errors" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" ) type IUser interface { Begin() (*sqlx.Tx, error) - SelectUserByFilter(filter repositorymodel.UsersFilter) (*repositorymodel.User, error) + SelectUserByFilter(ctx context.Context, filter repositorymodel.UsersFilter) (*repositorymodel.User, error) } type user struct { - db *sqlx.DB + db *sqlx.DB + tracer trace.Tracer } func NewUser(db *sqlx.DB) IUser { return &user{ - db: db, + db: db, + tracer: otel.Tracer("repository/user"), } } @@ -29,25 +35,28 @@ func (repository *user) Begin() (*sqlx.Tx, error) { return repository.db.Beginx() } -func (repository *user) SelectUserByFilter(filter repositorymodel.UsersFilter) (*repositorymodel.User, error) { +func (repository *user) SelectUserByFilter(ctx context.Context, filter repositorymodel.UsersFilter) (*repositorymodel.User, error) { + ctx, span := repository.tracer.Start(ctx, "SelectUserByFilter") + defer span.End() + var user repositorymodel.User whereCondition, args := buildUsersWhereCondition(filter) - query := ` - SELECT - * - FROM - users - ` + query := `SELECT * FROM users` if whereCondition != "" { query += " WHERE " + whereCondition } - if err := repository.db.Get(&user, query, args...); err != nil { + span.SetAttributes( + attribute.String("db.statement", query), + attribute.String("db.system", "postgresql"), + ) + + if err := repository.db.GetContext(ctx, &user, query, args...); err != nil { + span.RecordError(err) if errors.Is(err, sql.ErrNoRows) { return nil, commonerrors.ErrUserNotFound } - return nil, errors.Wrap(err, "selecting user by filter failed") } diff --git a/repository/user_mock.go b/repository/user_mock.go index 4f4bdfa..581ea32 100644 --- a/repository/user_mock.go +++ b/repository/user_mock.go @@ -1,6 +1,7 @@ package repository import ( + "context" repositorymodel "go-api-template/repository/model" "github.com/jmoiron/sqlx" @@ -18,15 +19,13 @@ func (mock *UserMock) Begin() (*sqlx.Tx, error) { if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).(*sqlx.Tx), args.Error(1) } -func (mock *UserMock) SelectUserByFilter(filter repositorymodel.UsersFilter) (*repositorymodel.User, error) { - args := mock.Called(filter) +func (mock *UserMock) SelectUserByFilter(ctx context.Context, filter repositorymodel.UsersFilter) (*repositorymodel.User, error) { + args := mock.Called(ctx, filter) if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).(*repositorymodel.User), args.Error(1) } diff --git a/repository/user_test.go b/repository/user_test.go index dc8663e..fef632b 100644 --- a/repository/user_test.go +++ b/repository/user_test.go @@ -1,6 +1,7 @@ package repository import ( + "context" "database/sql" "errors" "go-api-template/model/commonerrors" @@ -78,7 +79,7 @@ func (suite *UserTestSuite) Test_SelectUserByFilter_ReturnsError_InCaseOfSelectF suite.dbMock.ExpectQuery("SELECT").WillReturnError(errors.New("failed")) // Act - _, err := suite.repository.SelectUserByFilter(usersFilter) + _, err := suite.repository.SelectUserByFilter(context.Background(), usersFilter) // Assert suite.Error(err) @@ -93,7 +94,7 @@ func (suite *UserTestSuite) Test_SelectUserByFilter_ReturnsError_InCaseOfUserNot suite.dbMock.ExpectQuery("SELECT").WillReturnError(sql.ErrNoRows) // Act - _, err := suite.repository.SelectUserByFilter(usersFilter) + _, err := suite.repository.SelectUserByFilter(context.Background(), usersFilter) // Assert suite.Error(err) @@ -107,11 +108,10 @@ func (suite *UserTestSuite) Test_SelectUserByFilter_ReturnsUser_InCaseOfSuccess( } rows := sqlmock.NewRows([]string{"id"}).AddRow(string(suite.userID)) - suite.dbMock.ExpectQuery("SELECT").WillReturnRows(rows) // Act - user, err := suite.repository.SelectUserByFilter(usersFilter) + user, err := suite.repository.SelectUserByFilter(context.Background(), usersFilter) // Assert suite.NoError(err) diff --git a/service/user.go b/service/user.go index 75ed6bf..f7affbe 100644 --- a/service/user.go +++ b/service/user.go @@ -1,21 +1,25 @@ package service import ( + "context" "go-api-template/model" "go-api-template/repository" repositorymodel "go-api-template/repository/model" "go-api-template/service/mapper" - "github.com/gin-gonic/gin" "github.com/go-openapi/strfmt" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" ) type IUser interface { - UserByID(ctx *gin.Context, userID strfmt.UUID4) (*model.UserByIDResponse, error) + UserByID(ctx context.Context, userID strfmt.UUID4) (*model.UserByIDResponse, error) } type user struct { userRepository repository.IUser + tracer trace.Tracer } func NewUser( @@ -23,16 +27,23 @@ func NewUser( ) IUser { return &user{ userRepository: userRepository, + tracer: otel.Tracer("service/user"), } } -func (service *user) UserByID(_ *gin.Context, userID strfmt.UUID4) (*model.UserByIDResponse, error) { +func (service *user) UserByID(ctx context.Context, userID strfmt.UUID4) (*model.UserByIDResponse, error) { + ctx, span := service.tracer.Start(ctx, "UserByID") + defer span.End() + + span.SetAttributes(attribute.String("user.id", userID.String())) + filter := repositorymodel.UsersFilter{ ID: &userID, } - user, err := service.userRepository.SelectUserByFilter(filter) + user, err := service.userRepository.SelectUserByFilter(ctx, filter) if err != nil { + span.RecordError(err) return nil, err } diff --git a/service/user_mock.go b/service/user_mock.go index 2d6f7c2..b1a0e0c 100644 --- a/service/user_mock.go +++ b/service/user_mock.go @@ -1,9 +1,9 @@ package service import ( + "context" "go-api-template/model" - "github.com/gin-gonic/gin" "github.com/go-openapi/strfmt" "github.com/stretchr/testify/mock" ) @@ -14,11 +14,10 @@ type UserMock struct { var _ IUser = &UserMock{} -func (mock *UserMock) UserByID(ctx *gin.Context, userID strfmt.UUID4) (*model.UserByIDResponse, error) { +func (mock *UserMock) UserByID(ctx context.Context, userID strfmt.UUID4) (*model.UserByIDResponse, error) { args := mock.Called(ctx, userID) if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).(*model.UserByIDResponse), args.Error(1) } diff --git a/service/user_test.go b/service/user_test.go index 5911f3b..19f2fed 100644 --- a/service/user_test.go +++ b/service/user_test.go @@ -50,12 +50,12 @@ func (suite *UserTestSuite) Test_UserByID_ReturnsError_InCaseOfSelectUserByFilte } suite.userRepositoryMock. - On("SelectUserByFilter", filter). + On("SelectUserByFilter", suite.ctx.Request.Context(), filter). Return(nil, suite.failedError). Once() // Act - response, err := suite.service.UserByID(suite.ctx, suite.userID) + response, err := suite.service.UserByID(suite.ctx.Request.Context(), suite.userID) // Assert suite.Nil(response) @@ -71,12 +71,12 @@ func (suite *UserTestSuite) Test_UserByID_ReturnsError_InCaseOfUserNotFound() { } suite.userRepositoryMock. - On("SelectUserByFilter", filter). + On("SelectUserByFilter", suite.ctx.Request.Context(), filter). Return(nil, commonerrors.ErrUserNotFound). Once() // Act - response, err := suite.service.UserByID(suite.ctx, suite.userID) + response, err := suite.service.UserByID(suite.ctx.Request.Context(), suite.userID) // Assert suite.Nil(response) @@ -96,12 +96,12 @@ func (suite *UserTestSuite) Test_UserByID_ReturnsUser_InCaseOfSuccess() { } suite.userRepositoryMock. - On("SelectUserByFilter", filter). + On("SelectUserByFilter", suite.ctx.Request.Context(), filter). Return(user, nil). Once() // Act - response, err := suite.service.UserByID(suite.ctx, suite.userID) + response, err := suite.service.UserByID(suite.ctx.Request.Context(), suite.userID) // Assert suite.Nil(err)