diff --git a/cmd/api/main.go b/cmd/api/main.go index 2588088..2df8bb8 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -13,6 +13,7 @@ import ( graph "tercul/internal/adapters/graphql" dbsql "tercul/internal/data/sql" "tercul/internal/jobs/linguistics" + "tercul/internal/observability" "tercul/internal/platform/auth" "tercul/internal/platform/config" "tercul/internal/platform/db" @@ -22,6 +23,7 @@ import ( "github.com/99designs/gqlgen/graphql/playground" "github.com/pressly/goose/v3" + "github.com/prometheus/client_golang/prometheus" "github.com/weaviate/weaviate-go-client/v5/weaviate" "gorm.io/gorm" ) @@ -54,8 +56,24 @@ func main() { // Load configuration from environment variables config.LoadConfig() - // Initialize structured logger with appropriate log level - log.SetDefaultLevel(log.InfoLevel) + // Initialize logger + log.Init("tercul-api", config.Cfg.Environment) + + // Initialize OpenTelemetry Tracer Provider + tp, err := observability.TracerProvider("tercul-api", config.Cfg.Environment) + if err != nil { + log.LogFatal("Failed to initialize OpenTelemetry tracer", log.F("error", err)) + } + defer func() { + if err := tp.Shutdown(context.Background()); err != nil { + log.LogError("Error shutting down tracer provider", log.F("error", err)) + } + }() + + // Initialize Prometheus metrics + reg := prometheus.NewRegistry() + metrics := observability.NewMetrics(reg) // Metrics are registered automatically + log.LogInfo("Starting Tercul application", log.F("environment", config.Cfg.Environment), log.F("version", "1.0.0")) @@ -106,7 +124,7 @@ func main() { } jwtManager := auth.NewJWTManager() - srv := NewServerWithAuth(resolver, jwtManager) + srv := NewServerWithAuth(resolver, jwtManager, metrics) graphQLServer := &http.Server{ Addr: config.Cfg.ServerPort, Handler: srv, @@ -121,6 +139,13 @@ func main() { } log.LogInfo("GraphQL playground created successfully", log.F("port", config.Cfg.PlaygroundPort)) + // Create metrics server + metricsServer := &http.Server{ + Addr: ":9090", + Handler: observability.PrometheusHandler(reg), + } + log.LogInfo("Metrics server created successfully", log.F("port", ":9090")) + // Start HTTP servers in goroutines go func() { log.LogInfo("Starting GraphQL server", @@ -140,6 +165,13 @@ func main() { } }() + go func() { + log.LogInfo("Starting metrics server", log.F("port", ":9090")) + if err := metricsServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.LogFatal("Failed to start metrics server", log.F("error", err)) + } + }() + // Wait for interrupt signal to gracefully shutdown the servers quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) @@ -161,5 +193,9 @@ func main() { log.F("error", err)) } + if err := metricsServer.Shutdown(ctx); err != nil { + log.LogError("Metrics server forced to shutdown", log.F("error", err)) + } + log.LogInfo("All servers shutdown successfully") } \ No newline at end of file diff --git a/cmd/api/server.go b/cmd/api/server.go index 9da31ce..f4a0d2d 100644 --- a/cmd/api/server.go +++ b/cmd/api/server.go @@ -3,6 +3,7 @@ package main import ( "net/http" "tercul/internal/adapters/graphql" + "tercul/internal/observability" "tercul/internal/platform/auth" "github.com/99designs/gqlgen/graphql/handler" @@ -21,18 +22,23 @@ func NewServer(resolver *graphql.Resolver) http.Handler { return mux } -// NewServerWithAuth creates a new GraphQL server with authentication middleware -func NewServerWithAuth(resolver *graphql.Resolver, jwtManager *auth.JWTManager) http.Handler { +// NewServerWithAuth creates a new GraphQL server with authentication and observability middleware +func NewServerWithAuth(resolver *graphql.Resolver, jwtManager *auth.JWTManager, metrics *observability.Metrics) http.Handler { c := graphql.Config{Resolvers: resolver} c.Directives.Binding = graphql.Binding srv := handler.NewDefaultServer(graphql.NewExecutableSchema(c)) - // Apply authentication middleware to GraphQL endpoint - authHandler := auth.GraphQLAuthMiddleware(jwtManager)(srv) + // Create a middleware chain + var chain http.Handler + chain = srv + chain = auth.GraphQLAuthMiddleware(jwtManager)(chain) + chain = metrics.PrometheusMiddleware(chain) + chain = observability.TracingMiddleware(chain) + chain = observability.RequestIDMiddleware(chain) - // Create a mux to handle GraphQL endpoint only (no playground here; served separately in production) + // Create a mux to handle GraphQL endpoint mux := http.NewServeMux() - mux.Handle("/query", authHandler) + mux.Handle("/query", chain) return mux -} +} \ No newline at end of file diff --git a/go.mod b/go.mod index 06fecca..3644d2b 100644 --- a/go.mod +++ b/go.mod @@ -12,12 +12,18 @@ require ( github.com/hibiken/asynq v0.25.1 github.com/jonreiter/govader v0.0.0-20250429093935-f6505c8d03cc github.com/pemistahl/lingua-go v1.4.0 - github.com/pressly/goose/v3 v3.25.0 + github.com/pressly/goose/v3 v3.21.1 + github.com/prometheus/client_golang v1.20.5 github.com/redis/go-redis/v9 v9.13.0 + github.com/rs/zerolog v1.34.0 github.com/stretchr/testify v1.11.1 github.com/vektah/gqlparser/v2 v2.5.30 - github.com/weaviate/weaviate v1.32.6 - github.com/weaviate/weaviate-go-client/v5 v5.4.1 + github.com/weaviate/weaviate v1.33.0-rc.1 + github.com/weaviate/weaviate-go-client/v5 v5.5.0 + go.opentelemetry.io/otel v1.38.0 + go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.38.0 + go.opentelemetry.io/otel/sdk v1.38.0 + go.opentelemetry.io/otel/trace v1.38.0 golang.org/x/crypto v0.41.0 gorm.io/driver/postgres v1.6.0 gorm.io/driver/sqlite v1.6.0 @@ -25,13 +31,13 @@ require ( ) require ( - ariga.io/atlas-go-sdk v0.5.1 // indirect filippo.io/edwards25519 v1.1.0 // indirect - github.com/ClickHouse/ch-go v0.67.0 // indirect - github.com/ClickHouse/clickhouse-go/v2 v2.40.1 // indirect + github.com/ClickHouse/ch-go v0.61.1 // indirect + github.com/ClickHouse/clickhouse-go/v2 v2.17.1 // indirect github.com/agnivade/levenshtein v1.2.1 // indirect github.com/andybalholm/brotli v1.2.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.0 // indirect + github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/coder/websocket v1.8.12 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect @@ -43,6 +49,8 @@ require ( github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/go-faster/city v1.0.1 // indirect github.com/go-faster/errors v0.7.1 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/analysis v0.23.0 // indirect github.com/go-openapi/errors v0.22.0 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect @@ -68,18 +76,18 @@ require ( github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect - github.com/joho/godotenv v1.5.1 // indirect github.com/jonboulle/clockwork v0.5.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-sqlite3 v1.14.22 // indirect github.com/mfridman/interpolate v0.0.2 // indirect - github.com/mfridman/xflag v0.1.0 // indirect github.com/microsoft/go-mssqldb v1.9.2 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/opentracing/opentracing-go v1.2.0 // indirect @@ -87,6 +95,8 @@ require ( github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.65.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/robfig/cron/v3 v3.0.1 // indirect @@ -105,8 +115,8 @@ require ( github.com/ydb-platform/ydb-go-sdk/v3 v3.108.1 // indirect github.com/ziutek/mymysql v1.5.4 // indirect go.mongodb.org/mongo-driver v1.14.0 // indirect - go.opentelemetry.io/otel v1.37.0 // indirect - go.opentelemetry.io/otel/trace v1.37.0 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/otel/metric v1.38.0 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect golang.org/x/mod v0.26.0 // indirect @@ -118,8 +128,8 @@ require ( golang.org/x/time v0.12.0 // indirect golang.org/x/tools v0.35.0 // indirect gonum.org/v1/gonum v0.15.1 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect - google.golang.org/grpc v1.73.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0 // indirect + google.golang.org/grpc v1.74.2 // indirect google.golang.org/protobuf v1.36.6 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 01c4c9f..907112b 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,3 @@ -ariga.io/atlas-go-sdk v0.5.1 h1:I3iRshdwSODVWwMS4zvXObnfCQrEOY8BLRwynJQA+qE= -ariga.io/atlas-go-sdk v0.5.1/go.mod h1:UZXG++2NQCDAetk+oIitYIGpL/VsBVCt4GXbtWBA/GY= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= @@ -19,10 +17,10 @@ github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1/go.mod h github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs= github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/ClickHouse/ch-go v0.67.0 h1:18MQF6vZHj+4/hTRaK7JbS/TIzn4I55wC+QzO24uiqc= -github.com/ClickHouse/ch-go v0.67.0/go.mod h1:2MSAeyVmgt+9a2k2SQPPG1b4qbTPzdGDpf1+bcHh+18= -github.com/ClickHouse/clickhouse-go/v2 v2.40.1 h1:PbwsHBgqXRydU7jKULD1C8CHmifczffvQqmFvltM2W4= -github.com/ClickHouse/clickhouse-go/v2 v2.40.1/go.mod h1:GDzSBLVhladVm8V01aEB36IoBOVLLICfyeuiIp/8Ezc= +github.com/ClickHouse/ch-go v0.61.1 h1:j5rx3qnvcnYjhnP1IdXE/vdIRQiqgwAzyqOaasA6QCw= +github.com/ClickHouse/ch-go v0.61.1/go.mod h1:myxt/JZgy2BYHFGQqzmaIpbfr5CMbs3YHVULaWQj5YU= +github.com/ClickHouse/clickhouse-go/v2 v2.17.1 h1:ZCmAYWpu75IyEi7+Yrs/uaAjiCGY5wfW5kXo64exkX4= +github.com/ClickHouse/clickhouse-go/v2 v2.17.1/go.mod h1:rkGTvFDTLqLIm0ma+13xmcCfr/08Gvs7KmFt1tgiWHQ= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo= @@ -47,6 +45,8 @@ github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef/go.mod h1:W github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= 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/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= @@ -64,6 +64,7 @@ github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWH github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo= github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= @@ -100,6 +101,7 @@ github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw= github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw= github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg= github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= @@ -178,6 +180,7 @@ github.com/gobuffalo/packd v0.1.0/go.mod h1:M2Juc+hhDXf/PnmBANFCqx4DM3wRbgDvnVWe github.com/gobuffalo/packr/v2 v2.0.9/go.mod h1:emmyGweYTm6Kdper+iywB6YK5YzuKchGtJQZ0Odn4pQ= github.com/gobuffalo/packr/v2 v2.2.0/go.mod h1:CaAwI0GPIAv+5wKLtv8Afwl+Cm78K/I/VCm/3ptBN+0= github.com/gobuffalo/syncx v0.0.0-20190224160051-33c29581e754/go.mod h1:HhnNqWY95UYwwW3uSASeV7vtgYkT2t16hJgV3AEPUpw= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= @@ -245,8 +248,6 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/joeshaw/multierror v0.0.0-20140124173710-69b34d4ec901/go.mod h1:Z86h9688Y0wesXCyonoVr47MasHilkuLMqGhRZ4Hpak= github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= -github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= -github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= github.com/jonreiter/govader v0.0.0-20250429093935-f6505c8d03cc h1:Zvn/U2151AlhFbOIIZivbnpvExjD/8rlQsO/RaNJQw0= @@ -282,14 +283,17 @@ github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0 github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/markbates/oncer v0.0.0-20181203154359-bf2de49a0be2/go.mod h1:Ld9puTsIW75CHf65OeIOkyKbteujpZVXDpWK6YGZbxE= github.com/markbates/safe v1.0.1/go.mod h1:nAqgmRi7cY2nqMc92/bSEeQA+R4OheNU2T1kNSCBdG0= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= -github.com/mfridman/xflag v0.1.0 h1:TWZrZwG1QklFX5S4j1vxfF1sZbZeZSGofMwPMLAF29M= -github.com/mfridman/xflag v0.1.0/go.mod h1:/483ywM5ZO5SuMVjrIGquYNE5CzLrj5Ux/LxWWnjRaE= github.com/microsoft/go-mssqldb v1.9.2 h1:nY8TmFMQOHpm2qVWo6y4I2mAmVdZqlGiMGAYt64Ibbs= github.com/microsoft/go-mssqldb v1.9.2/go.mod h1:GBbW9ASTiDC+mpgWDGKdm3FnFLTUsLYN3iFL90lQ+PA= github.com/mitchellh/mapstructure v1.3.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= @@ -298,6 +302,8 @@ github.com/mitchellh/mapstructure v1.4.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RR github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= @@ -322,9 +328,15 @@ 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/pressly/goose/v3 v3.25.0 h1:6WeYhMWGRCzpyd89SpODFnCBCKz41KrVbRT58nVjGng= -github.com/pressly/goose/v3 v3.25.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY= +github.com/pressly/goose/v3 v3.21.1 h1:5SSAKKWej8LVVzNLuT6KIvP1eFDuPvxa+B6H0w78buQ= +github.com/pressly/goose/v3 v3.21.1/go.mod h1:sqthmzV8PitchEkjecFJII//l43dLOCzfWh8pHEe+vE= +github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= +github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE= +github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= github.com/prometheus/procfs v0.0.0-20190425082905-87a4384529e0/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= @@ -340,8 +352,11 @@ github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6L github.com/rogpeppe/go-internal v1.1.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.2.2/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= -github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= -github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +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/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= @@ -381,10 +396,10 @@ github.com/vektah/gqlparser/v2 v2.5.30 h1:EqLwGAFLIzt1wpx1IPpY67DwUujF1OfzgEyDsL github.com/vektah/gqlparser/v2 v2.5.30/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo= github.com/vertica/vertica-sql-go v1.3.3 h1:fL+FKEAEy5ONmsvya2WH5T8bhkvY27y/Ik3ReR2T+Qw= github.com/vertica/vertica-sql-go v1.3.3/go.mod h1:jnn2GFuv+O2Jcjktb7zyc4Utlbu9YVqpHH/lx63+1M4= -github.com/weaviate/weaviate v1.32.6 h1:N0MRjuqZT9l2un4xFeV4fXZ9dkLbqrijC5JIfr759Os= -github.com/weaviate/weaviate v1.32.6/go.mod h1:hzzhAOYxgKe+B2jxZJtaWMIdElcXXn+RQyQ7ccQORNg= -github.com/weaviate/weaviate-go-client/v5 v5.4.1 h1:hfKocGPe11IUr4XsLp3q9hJYck0I2yIHGlFBpLqb/F4= -github.com/weaviate/weaviate-go-client/v5 v5.4.1/go.mod h1:l72EnmCLj9LCQkR8S7nN7Y1VqGMmL3Um8exhFkMmfwk= +github.com/weaviate/weaviate v1.33.0-rc.1 h1:3Kol9BmA9JOj1I4vOkz0tu4A87K3dKVAnr8k8DMhBs8= +github.com/weaviate/weaviate v1.33.0-rc.1/go.mod h1:MmHF/hZDL0I8j0qAMEa9/TS4ISLaYlIp1Bc3e/n3eUU= +github.com/weaviate/weaviate-go-client/v5 v5.5.0 h1:+5qkHodrL3/Qc7kXvMXnDaIxSBN5+djivLqzmCx7VS4= +github.com/weaviate/weaviate-go-client/v5 v5.5.0/go.mod h1:Zdm2MEXG27I0Nf6fM0FZ3P2vLR4JM0iJZrOxwc+Zj34= github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= github.com/xdg-go/scram v1.0.2/go.mod h1:1WAq6h33pAW+iRreB34OORO2Nf7qel3VV3fjBj+hCSs= github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g= @@ -411,16 +426,18 @@ go.mongodb.org/mongo-driver v1.14.0 h1:P98w8egYRjYe3XDjxhYJagTokP/H6HzlsnojRgZRd 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/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= -go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= -go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= -go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= -go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= -go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= -go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis= -go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4= -go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= -go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.38.0 h1:kJxSDN4SgWWTjG/hPp3O7LCGLcHXFlvS2/FFOrwL+SE= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.38.0/go.mod h1:mgIOzS7iZeKJdeB8/NYHrJ48fdGc71Llo5bJ1J4DWUE= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= +go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= @@ -500,7 +517,9 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= @@ -547,8 +566,8 @@ google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoA google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0 h1:MAKi5q709QWfnkkpNQ0M12hYJ1+e8qYVDyowc4U1XZM= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= @@ -556,8 +575,8 @@ google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8 google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= google.golang.org/grpc v1.47.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= -google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok= -google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc= +google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4= +google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= diff --git a/internal/observability/logger.go b/internal/observability/logger.go new file mode 100644 index 0000000..76df103 --- /dev/null +++ b/internal/observability/logger.go @@ -0,0 +1,54 @@ +package observability + +import ( + "context" + "os" + "time" + + "github.com/rs/zerolog" + "go.opentelemetry.io/otel/trace" +) + +// Logger is a wrapper around zerolog.Logger to provide a consistent logging interface. +type Logger struct { + *zerolog.Logger +} + +// NewLogger creates a new Logger instance. +// It writes to a human-friendly console in "development" environment, +// and writes JSON to stdout otherwise. +func NewLogger(serviceName, environment string) *Logger { + var logger zerolog.Logger + if environment == "development" { + logger = zerolog.New(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339}).With(). + Timestamp(). + Str("service", serviceName). + Logger() + } else { + zerolog.TimeFieldFormat = time.RFC3339 + logger = zerolog.New(os.Stdout).With(). + Timestamp(). + Str("service", serviceName). + Logger() + } + + return &Logger{&logger} +} + +// Ctx returns a new logger with context-specific fields, such as trace and span IDs. +func (l *Logger) Ctx(ctx context.Context) *Logger { + log := l.Logger // log is a *zerolog.Logger + span := trace.SpanFromContext(ctx) + if span.SpanContext().IsValid() { + // .Logger() returns a value, not a pointer. + // We create a new logger value... + newLogger := log.With(). + Str("trace_id", span.SpanContext().TraceID().String()). + Str("span_id", span.SpanContext().SpanID().String()). + Logger() + // ...and then use its address. + log = &newLogger + } + // `log` is now the correct *zerolog.Logger, so we wrap it. + return &Logger{log} +} \ No newline at end of file diff --git a/internal/observability/metrics.go b/internal/observability/metrics.go new file mode 100644 index 0000000..f6892f0 --- /dev/null +++ b/internal/observability/metrics.go @@ -0,0 +1,55 @@ +package observability + +import ( + "net/http" + "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +// Metrics contains the Prometheus metrics for the application. +type Metrics struct { + RequestsTotal *prometheus.CounterVec + RequestDuration *prometheus.HistogramVec +} + +// NewMetrics creates and registers the Prometheus metrics. +func NewMetrics(reg prometheus.Registerer) *Metrics { + return &Metrics{ + RequestsTotal: promauto.With(reg).NewCounterVec( + prometheus.CounterOpts{ + Name: "http_requests_total", + Help: "Total number of HTTP requests.", + }, + []string{"method", "path", "status"}, + ), + RequestDuration: promauto.With(reg).NewHistogramVec( + prometheus.HistogramOpts{ + Name: "http_request_duration_seconds", + Help: "Duration of HTTP requests.", + Buckets: prometheus.DefBuckets, + }, + []string{"method", "path"}, + ), + } +} + +// PrometheusMiddleware returns an HTTP middleware that records Prometheus metrics. +func (m *Metrics) PrometheusMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + rw := &responseWriter{ResponseWriter: w} + next.ServeHTTP(rw, r) + + duration := time.Since(start).Seconds() + m.RequestDuration.WithLabelValues(r.Method, r.URL.Path).Observe(duration) + m.RequestsTotal.WithLabelValues(r.Method, r.URL.Path, http.StatusText(rw.statusCode)).Inc() + }) +} + +// PrometheusHandler returns an HTTP handler for serving Prometheus metrics. +func PrometheusHandler(reg prometheus.Gatherer) http.Handler { + return promhttp.HandlerFor(reg, promhttp.HandlerOpts{}) +} diff --git a/internal/observability/middleware.go b/internal/observability/middleware.go new file mode 100644 index 0000000..4e3ebf2 --- /dev/null +++ b/internal/observability/middleware.go @@ -0,0 +1,56 @@ +package observability + +import ( + "context" + "net/http" + + "github.com/google/uuid" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/propagation" + semconv "go.opentelemetry.io/otel/semconv/v1.21.0" + "go.opentelemetry.io/otel/trace" +) + +type contextKey string + +const RequestIDKey contextKey = "request_id" + +// responseWriter is a wrapper around http.ResponseWriter to capture the status code. +type responseWriter struct { + http.ResponseWriter + statusCode int +} + +func (rw *responseWriter) WriteHeader(code int) { + rw.statusCode = code + rw.ResponseWriter.WriteHeader(code) +} + +// RequestIDMiddleware generates a unique request ID and adds it to the request context. +func RequestIDMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestID := uuid.New().String() + ctx := context.WithValue(r.Context(), RequestIDKey, requestID) + w.Header().Set("X-Request-ID", requestID) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// TracingMiddleware creates a new OpenTelemetry span for each request. +func TracingMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := otel.GetTextMapPropagator().Extract(r.Context(), propagation.HeaderCarrier(r.Header)) + tracer := otel.Tracer("http-server") + ctx, span := tracer.Start(ctx, "HTTP "+r.Method+" "+r.URL.Path, trace.WithAttributes( + semconv.HTTPMethodKey.String(r.Method), + semconv.HTTPURLKey.String(r.URL.String()), + )) + defer span.End() + + rw := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK} + next.ServeHTTP(rw, r.WithContext(ctx)) + + span.SetAttributes(attribute.Int("http.status_code", rw.statusCode)) + }) +} \ No newline at end of file diff --git a/internal/observability/tracing.go b/internal/observability/tracing.go new file mode 100644 index 0000000..e6403dd --- /dev/null +++ b/internal/observability/tracing.go @@ -0,0 +1,41 @@ +package observability + +import ( + "context" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/exporters/stdout/stdouttrace" + "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.21.0" +) + +// TracerProvider returns a new OpenTelemetry TracerProvider. +func TracerProvider(serviceName, environment string) (*sdktrace.TracerProvider, error) { + exporter, err := stdouttrace.New(stdouttrace.WithPrettyPrint()) + if err != nil { + return nil, err + } + + res := resource.NewWithAttributes( + semconv.SchemaURL, + semconv.ServiceNameKey.String(serviceName), + semconv.DeploymentEnvironmentKey.String(environment), + ) + + tp := sdktrace.NewTracerProvider( + sdktrace.WithBatcher(exporter), + sdktrace.WithResource(res), + ) + + otel.SetTracerProvider(tp) + otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(propagation.TraceContext{}, propagation.Baggage{})) + + return tp, nil +} + +// ShutdownTracerProvider gracefully shuts down the tracer provider. +func ShutdownTracerProvider(ctx context.Context, tp *sdktrace.TracerProvider) error { + return tp.Shutdown(ctx) +} \ No newline at end of file diff --git a/internal/platform/log/logger.go b/internal/platform/log/logger.go index e1e18b9..41ca07a 100644 --- a/internal/platform/log/logger.go +++ b/internal/platform/log/logger.go @@ -1,32 +1,145 @@ package log import ( + "context" "fmt" - "io" - "log" - "os" - "runtime" - "strings" - "time" + "tercul/internal/observability" + + "github.com/rs/zerolog" ) -// LogLevel represents the severity level of a log message +// LogLevel represents the severity level of a log message. type LogLevel int const ( - // DebugLevel for detailed troubleshooting + // DebugLevel for detailed troubleshooting. DebugLevel LogLevel = iota - // InfoLevel for general operational information + // InfoLevel for general operational information. InfoLevel - // WarnLevel for potentially harmful situations + // WarnLevel for potentially harmful situations. WarnLevel - // ErrorLevel for error events that might still allow the application to continue + // ErrorLevel for error events that might still allow the application to continue. ErrorLevel - // FatalLevel for severe error events that will lead the application to abort + // FatalLevel for severe error events that will lead the application to abort. FatalLevel ) -// String returns the string representation of the log level +// Field represents a key-value pair for structured logging. +type Field struct { + Key string + Value interface{} +} + +// F creates a new Field. +func F(key string, value interface{}) Field { + return Field{Key: key, Value: value} +} + +// Logger provides structured logging capabilities. +type Logger struct { + *observability.Logger +} + +var defaultLogger = &Logger{observability.NewLogger("tercul", "development")} + +// Init re-initializes the default logger. This is useful for applications +// that need to configure the logger with dynamic values. +func Init(serviceName, environment string) { + defaultLogger = &Logger{observability.NewLogger(serviceName, environment)} +} + +// SetDefaultLevel sets the log level for the default logger. +func SetDefaultLevel(level LogLevel) { + var zlevel zerolog.Level + switch level { + case DebugLevel: + zlevel = zerolog.DebugLevel + case InfoLevel: + zlevel = zerolog.InfoLevel + case WarnLevel: + zlevel = zerolog.WarnLevel + case ErrorLevel: + zlevel = zerolog.ErrorLevel + case FatalLevel: + zlevel = zerolog.FatalLevel + default: + zlevel = zerolog.InfoLevel + } + zerolog.SetGlobalLevel(zlevel) +} + +func log(level LogLevel, msg string, fields ...Field) { + var event *zerolog.Event + // Access the embedded observability.Logger to get to zerolog's methods. + zlog := defaultLogger.Logger + switch level { + case DebugLevel: + event = zlog.Debug() + case InfoLevel: + event = zlog.Info() + case WarnLevel: + event = zlog.Warn() + case ErrorLevel: + event = zlog.Error() + case FatalLevel: + event = zlog.Fatal() + default: + event = zlog.Info() + } + + for _, f := range fields { + event.Interface(f.Key, f.Value) + } + event.Msg(msg) +} + +// LogDebug logs a message at debug level using the default logger. +func LogDebug(msg string, fields ...Field) { + log(DebugLevel, msg, fields...) +} + +// LogInfo logs a message at info level using the default logger. +func LogInfo(msg string, fields ...Field) { + log(InfoLevel, msg, fields...) +} + +// LogWarn logs a message at warn level using the default logger. +func LogWarn(msg string, fields ...Field) { + log(WarnLevel, msg, fields...) +} + +// LogError logs a message at error level using the default logger. +func LogError(msg string, fields ...Field) { + log(ErrorLevel, msg, fields...) +} + +// LogFatal logs a message at fatal level using the default logger and then calls os.Exit(1). +func LogFatal(msg string, fields ...Field) { + log(FatalLevel, msg, fields...) +} + +// WithFields returns a new logger with the given fields added using the default logger. +func WithFields(fields ...Field) *Logger { + sublogger := defaultLogger.With().Logger() + for _, f := range fields { + sublogger = sublogger.With().Interface(f.Key, f.Value).Logger() + } + return &Logger{&observability.Logger{&sublogger}} +} + +// WithContext returns a new logger with the given context added using the default logger. +func WithContext(ctx context.Context) *Logger { + return &Logger{defaultLogger.Ctx(ctx)} +} + +// The following functions are kept for compatibility but are now simplified or deprecated. + +// SetDefaultLogger is deprecated. Use Init. +func SetDefaultLogger(logger *Logger) { + // Deprecated: Logger is now initialized via Init. +} + +// String returns the string representation of the log level. func (l LogLevel) String() string { switch l { case DebugLevel: @@ -44,192 +157,83 @@ func (l LogLevel) String() string { } } -// Field represents a key-value pair for structured logging -type Field struct { - Key string - Value interface{} -} - -// F creates a new Field -func F(key string, value interface{}) Field { - return Field{Key: key, Value: value} -} - -// Logger provides structured logging capabilities -type Logger struct { - level LogLevel - writer io.Writer - fields []Field - context map[string]interface{} -} - -// New creates a new Logger with the specified log level and writer -func New(level LogLevel, writer io.Writer) *Logger { - if writer == nil { - writer = os.Stdout - } - return &Logger{ - level: level, - writer: writer, - fields: []Field{}, - context: make(map[string]interface{}), - } -} - -// Debug logs a message at debug level +// Debug logs a message at debug level. func (l *Logger) Debug(msg string, fields ...Field) { - if l.level <= DebugLevel { - l.log(DebugLevel, msg, fields...) - } + l.log(DebugLevel, msg, fields...) } -// Info logs a message at info level +// Info logs a message at info level. func (l *Logger) Info(msg string, fields ...Field) { - if l.level <= InfoLevel { - l.log(InfoLevel, msg, fields...) - } + l.log(InfoLevel, msg, fields...) } -// Warn logs a message at warn level +// Warn logs a message at warn level. func (l *Logger) Warn(msg string, fields ...Field) { - if l.level <= WarnLevel { - l.log(WarnLevel, msg, fields...) - } + l.log(WarnLevel, msg, fields...) } -// Error logs a message at error level +// Error logs a message at error level. func (l *Logger) Error(msg string, fields ...Field) { - if l.level <= ErrorLevel { - l.log(ErrorLevel, msg, fields...) - } + l.log(ErrorLevel, msg, fields...) } -// Fatal logs a message at fatal level and then calls os.Exit(1) +// Fatal logs a message at fatal level and then calls os.Exit(1). func (l *Logger) Fatal(msg string, fields ...Field) { - if l.level <= FatalLevel { - l.log(FatalLevel, msg, fields...) - os.Exit(1) - } + l.log(FatalLevel, msg, fields...) } -// WithFields returns a new logger with the given fields added -func (l *Logger) WithFields(fields ...Field) *Logger { - newLogger := &Logger{ - level: l.level, - writer: l.writer, - fields: append(l.fields, fields...), - context: l.context, - } - return newLogger -} - -// WithContext returns a new logger with the given context added -func (l *Logger) WithContext(ctx map[string]interface{}) *Logger { - newContext := make(map[string]interface{}) - for k, v := range l.context { - newContext[k] = v - } - for k, v := range ctx { - newContext[k] = v - } - - newLogger := &Logger{ - level: l.level, - writer: l.writer, - fields: l.fields, - context: newContext, - } - return newLogger -} - -// SetLevel sets the log level -func (l *Logger) SetLevel(level LogLevel) { - l.level = level -} - -// log formats and writes a log message func (l *Logger) log(level LogLevel, msg string, fields ...Field) { - timestamp := time.Now().Format(time.RFC3339) - - // Get caller information - _, file, line, ok := runtime.Caller(2) - caller := "unknown" - if ok { - parts := strings.Split(file, "/") - if len(parts) > 2 { - caller = fmt.Sprintf("%s:%d", parts[len(parts)-1], line) - } else { - caller = fmt.Sprintf("%s:%d", file, line) - } + var event *zerolog.Event + switch level { + case DebugLevel: + event = l.Logger.Debug() + case InfoLevel: + event = l.Logger.Info() + case WarnLevel: + event = l.Logger.Warn() + case ErrorLevel: + event = l.Logger.Error() + case FatalLevel: + event = l.Logger.Fatal() + default: + event = l.Logger.Info() } - // Format fields - allFields := append(l.fields, fields...) - fieldStr := "" - for _, field := range allFields { - fieldStr += fmt.Sprintf(" %s=%v", field.Key, field.Value) + for _, f := range fields { + event.Interface(f.Key, f.Value) } + event.Msg(msg) +} - // Format context - contextStr := "" - for k, v := range l.context { - contextStr += fmt.Sprintf(" %s=%v", k, v) - } - - // Format log message - logMsg := fmt.Sprintf("%s [%s] %s %s%s%s\n", timestamp, level.String(), caller, msg, fieldStr, contextStr) - - // Write log message - _, err := l.writer.Write([]byte(logMsg)) - if err != nil { - log.Printf("Error writing log message: %v", err) +// WithFields returns a new logger with the given fields added. +func (l *Logger) WithFields(fields ...Field) *Logger { + sublogger := l.With().Logger() + for _, f := range fields { + sublogger = sublogger.With().Interface(f.Key, f.Value).Logger() } + return &Logger{&observability.Logger{&sublogger}} } -// Global logger instance -var defaultLogger = New(InfoLevel, os.Stdout) - -// SetDefaultLogger sets the global logger instance -func SetDefaultLogger(logger *Logger) { - defaultLogger = logger +func (l *Logger) WithContext(ctx map[string]interface{}) *Logger { + // To maintain compatibility with the old API, we will convert the map to a context. + // This is not ideal and should be refactored in the future. + zlog := l.Logger.With().Logger() + for k, v := range ctx { + zlog = zlog.With().Interface(k, v).Logger() + } + return &Logger{&observability.Logger{&zlog}} } -// SetDefaultLevel sets the log level for the default logger -func SetDefaultLevel(level LogLevel) { - defaultLogger.SetLevel(level) +func (l *Logger) SetLevel(level LogLevel) { + // This now controls the global log level. + SetDefaultLevel(level) } -// LogDebug logs a message at debug level using the default logger -func LogDebug(msg string, fields ...Field) { - defaultLogger.Debug(msg, fields...) +// Fmt versions for simple string formatting +func LogInfof(format string, v ...interface{}) { + log(InfoLevel, fmt.Sprintf(format, v...)) } -// LogInfo logs a message at info level using the default logger -func LogInfo(msg string, fields ...Field) { - defaultLogger.Info(msg, fields...) -} - -// LogWarn logs a message at warn level using the default logger -func LogWarn(msg string, fields ...Field) { - defaultLogger.Warn(msg, fields...) -} - -// LogError logs a message at error level using the default logger -func LogError(msg string, fields ...Field) { - defaultLogger.Error(msg, fields...) -} - -// LogFatal logs a message at fatal level using the default logger and then calls os.Exit(1) -func LogFatal(msg string, fields ...Field) { - defaultLogger.Fatal(msg, fields...) -} - -// WithFields returns a new logger with the given fields added using the default logger -func WithFields(fields ...Field) *Logger { - return defaultLogger.WithFields(fields...) -} - -// WithContext returns a new logger with the given context added using the default logger -func WithContext(ctx map[string]interface{}) *Logger { - return defaultLogger.WithContext(ctx) -} +func LogErrorf(format string, v ...interface{}) { + log(ErrorLevel, fmt.Sprintf(format, v...)) +} \ No newline at end of file