diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..056048c --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +# ============================================================================== +# NBA_Go Environment Configuration +# Target: Hetzner Coolify Server (178.105.149.129) +# ============================================================================== + +# Database Credentials +DB_USER=postgres +DB_PASSWORD=postgrespassword +DB_NAME=appdb +DB_SSLMODE=disable + +# HAProxy Ingress Endpoints (PostgreSQL 17 Replicated Cluster) +DB_WRITE_HOST=178.105.149.129 +DB_WRITE_PORT=5437 +DB_READ_HOST=178.105.149.129 +DB_READ_PORT=5438 + +# Administrative Auth Token +ADMIN_SECRET=nba_super_secret_admin_token_2025 diff --git a/config/database.go b/config/database.go index 814e8c2..4824dea 100644 --- a/config/database.go +++ b/config/database.go @@ -4,76 +4,142 @@ package config import ( "fmt" - "log" + "log/slog" "os" + "time" "github.com/nprasad2077/NBA_Go/models" "github.com/nprasad2077/NBA_Go/utils/metrics" "gorm.io/driver/postgres" "gorm.io/gorm" + "gorm.io/gorm/logger" + "gorm.io/plugin/dbresolver" ) +type DBConfig struct { + User string + Password string + DBName string + SSLMode string + WriteHost string + WritePort string + ReadHost string + ReadPort string +} + +func LoadDBConfig() DBConfig { + user := getEnvOrDefault("DB_USER", "postgres") + pass := getEnvOrDefault("DB_PASSWORD", "postgrespassword") + dbname := getEnvOrDefault("DB_NAME", "appdb") + ssl := getEnvOrDefault("DB_SSLMODE", "disable") + + // Base fallback + baseHost := getEnvOrDefault("DB_HOST", "178.105.149.129") + basePort := os.Getenv("DB_PORT") + + writeHost := getEnvOrDefault("DB_WRITE_HOST", baseHost) + writePort := getEnvOrDefault("DB_WRITE_PORT", "5437") + if os.Getenv("DB_WRITE_PORT") == "" && basePort != "" { + writePort = basePort + } + + readHost := getEnvOrDefault("DB_READ_HOST", baseHost) + readPort := getEnvOrDefault("DB_READ_PORT", "5438") + if os.Getenv("DB_READ_PORT") == "" && basePort != "" { + readPort = basePort + } + + return DBConfig{ + User: user, + Password: pass, + DBName: dbname, + SSLMode: ssl, + WriteHost: writeHost, + WritePort: writePort, + ReadHost: readHost, + ReadPort: readPort, + } +} + +func buildDSN(host, port, user, pass, dbname, ssl string) string { + return fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=%s TimeZone=UTC", + host, port, user, pass, dbname, ssl) +} + func InitDB(shouldMigrate bool) *gorm.DB { - // ... (DSN setup code is unchanged) ... - dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=disable TimeZone=UTC", - os.Getenv("DB_HOST"), - os.Getenv("DB_USER"), - os.Getenv("DB_PASSWORD"), - os.Getenv("DB_NAME"), - os.Getenv("DB_PORT"), + cfg := LoadDBConfig() + writeDSN := buildDSN(cfg.WriteHost, cfg.WritePort, cfg.User, cfg.Password, cfg.DBName, cfg.SSLMode) + readDSN := buildDSN(cfg.ReadHost, cfg.ReadPort, cfg.User, cfg.Password, cfg.DBName, cfg.SSLMode) + + slog.Info("πŸ”Œ Connecting to PostgreSQL Cluster via HAProxy", + "write_ingress", fmt.Sprintf("%s:%s", cfg.WriteHost, cfg.WritePort), + "read_ingress", fmt.Sprintf("%s:%s", cfg.ReadHost, cfg.ReadPort), + "database", cfg.DBName, ) - db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{}) + // Primary GORM instance connects to Write Ingress (:5437) + db, err := gorm.Open(postgres.Open(writeDSN), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Warn), + NowFunc: func() time.Time { return time.Now().UTC() }, + }) if err != nil { - log.Fatalf("failed to connect database: %v", err) + slog.Error("❌ Failed to connect to Primary/Write database ingress", "error", err) + os.Exit(1) } + + // Register DBResolver for automatic Read/Write splitting + resolverCfg := dbresolver.Config{ + Sources: []gorm.Dialector{postgres.Open(writeDSN)}, + Replicas: []gorm.Dialector{postgres.Open(readDSN)}, + Policy: dbresolver.RandomPolicy{}, // HAProxy handles round-robin balancing across replicas + } + + err = db.Use(dbresolver.Register(resolverCfg). + SetConnMaxIdleTime(30 * time.Minute). + SetConnMaxLifetime(2 * time.Hour). + SetMaxIdleConns(10). + SetMaxOpenConns(50), + ) + if err != nil { + slog.Error("❌ Failed to configure DBResolver plugin", "error", err) + os.Exit(1) + } + metrics.DBOperationsTotal.WithLabelValues("connect", "database").Inc() + // Start asynchronous connection pool metrics collection + metrics.StartDBPoolMetricsCollector(db) + if shouldMigrate { - // --- ADD THIS BLOCK TO DROP STALE TABLES --- - // Drop tables in reverse order of dependency (children first). - // This ensures a clean migration every time the import process runs. - // log.Println("⚠️ Dropping existing game-related tables for a clean migration...") - // if err := db.Migrator().DropTable( - // &models.LineScore{}, - // &models.PlayerGameBasicStat{}, - // &models.PlayerGameAdvStat{}, - // &models.TeamGameBasicStat{}, - // &models.TeamGameAdvStat{}, - // &models.Game{}, // Drop parent table last - // ); err != nil { - // log.Fatalf("failed to drop tables: %v", err) - // } - // log.Println("βœ… Tables dropped successfully.") - // --- END OF ADDED BLOCK --- - - - // Your existing AutoMigrate calls will now work correctly - if err := db.AutoMigrate(&models.PlayerAdvancedStat{}); err != nil { - log.Fatalf("migrate PlayerAdvancedStat: %v", err) - } - if err := db.AutoMigrate(&models.PlayerTotalStat{}); err != nil { - log.Fatalf("migrate PlayerTotalStat: %v", err) - } - if err := db.AutoMigrate(&models.PlayerShotChart{}); err != nil { - log.Fatalf("migrate PlayerShotChart: %v", err) - } - if err := db.AutoMigrate(&models.APIKey{}); err != nil { - log.Fatalf("migrate APIKey: %v", err) - } - // Game Models will be recreated with the correct schema - if err := db.AutoMigrate( + slog.Info("πŸš€ Executing database AutoMigrate on Primary (:5437)...") + modelsToMigrate := []interface{}{ + &models.APIKey{}, + &models.PlayerAdvancedStat{}, + &models.PlayerTotalStat{}, + &models.PlayerShotChart{}, &models.Game{}, &models.LineScore{}, &models.PlayerGameBasicStat{}, &models.PlayerGameAdvStat{}, &models.TeamGameBasicStat{}, &models.TeamGameAdvStat{}, - ); err != nil { - log.Fatalf("migrate game models: %v", err) + } + for _, m := range modelsToMigrate { + if err := db.Clauses(dbresolver.Write).AutoMigrate(m); err != nil { + slog.Error("❌ Migration failed", "model", fmt.Sprintf("%T", m), "error", err) + os.Exit(1) + } } metrics.DBOperationsTotal.WithLabelValues("migrate", "database").Inc() + slog.Info("βœ… Database schema migration completed successfully") } return db +} + +func getEnvOrDefault(key, fallback string) string { + if val := os.Getenv(key); val != "" { + return val + } + return fallback } \ No newline at end of file diff --git a/controllers/api_key_controller.go b/controllers/api_key_controller.go index cbd7d01..3b2081e 100644 --- a/controllers/api_key_controller.go +++ b/controllers/api_key_controller.go @@ -8,6 +8,7 @@ import ( "github.com/nprasad2077/NBA_Go/models" "github.com/nprasad2077/NBA_Go/utils/security" "gorm.io/gorm" + "gorm.io/plugin/dbresolver" ) // very small middleware: require header X‑Admin‑Secret == $ADMIN_SECRET @@ -26,7 +27,8 @@ func RegisterKeyAdminRoutes(app *fiber.App, db *gorm.DB) { admin.Get("/", func(c *fiber.Ctx) error { var keys []models.APIKey - db.Find(&keys) + // Query write DB to guarantee immediate read-after-write consistency + db.Clauses(dbresolver.Write).Find(&keys) return c.JSON(keys) }) @@ -45,7 +47,7 @@ func RegisterKeyAdminRoutes(app *fiber.App, db *gorm.DB) { Hash: security.HashKey(raw), Label: body.Label, } - db.Create(&key) + db.Clauses(dbresolver.Write).Create(&key) // return ONLY the raw key once return c.JSON(fiber.Map{ @@ -56,7 +58,7 @@ func RegisterKeyAdminRoutes(app *fiber.App, db *gorm.DB) { admin.Post("/:id/revoke", func(c *fiber.Ctx) error { id := c.Params("id") - db.Model(&models.APIKey{}).Where("id = ?", id).Update("revoked", true) + db.Clauses(dbresolver.Write).Model(&models.APIKey{}).Where("id = ?", id).Update("revoked", true) return c.JSON(fiber.Map{"revoked": id}) }) } \ No newline at end of file diff --git a/controllers/health_controller.go b/controllers/health_controller.go new file mode 100644 index 0000000..7106285 --- /dev/null +++ b/controllers/health_controller.go @@ -0,0 +1,120 @@ +package controllers + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + "gorm.io/plugin/dbresolver" +) + +type TargetHealth struct { + Status string `json:"status"` + Target string `json:"target"` + LatencyMs float64 `json:"latencyMs"` + Error string `json:"error,omitempty"` +} + +type ReadinessResponse struct { + Status string `json:"status"` + Timestamp time.Time `json:"timestamp"` + Targets map[string]TargetHealth `json:"targets"` +} + +// RegisterHealthRoutes mounts /healthz and /readyz probes +func RegisterHealthRoutes(app *fiber.App, db *gorm.DB) { + app.Get("/healthz", func(c *fiber.Ctx) error { + return c.JSON(fiber.Map{ + "status": "UP", + "timestamp": time.Now().UTC(), + }) + }) + + app.Get("/readyz", func(c *fiber.Ctx) error { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + targets := make(map[string]TargetHealth) + overallHealthy := true + + writeHost := os.Getenv("DB_WRITE_HOST") + if writeHost == "" { + writeHost = "178.105.149.129" + } + writePort := os.Getenv("DB_WRITE_PORT") + if writePort == "" { + writePort = "5437" + } + + readHost := os.Getenv("DB_READ_HOST") + if readHost == "" { + readHost = "178.105.149.129" + } + readPort := os.Getenv("DB_READ_PORT") + if readPort == "" { + readPort = "5438" + } + + // Check Write Ingress (:5437) + writeTarget := fmt.Sprintf("%s:%s", writeHost, writePort) + writeStart := time.Now() + writeDB, err := db.Clauses(dbresolver.Write).DB() + if err != nil || writeDB == nil || writeDB.PingContext(ctx) != nil { + overallHealthy = false + errMsg := "Write ingress unreachable" + if err != nil { + errMsg = err.Error() + } + targets["db_write"] = TargetHealth{ + Status: "DOWN", + Target: writeTarget, + Error: errMsg, + } + } else { + targets["db_write"] = TargetHealth{ + Status: "UP", + Target: writeTarget, + LatencyMs: float64(time.Since(writeStart).Microseconds()) / 1000.0, + } + } + + // Check Read Ingress (:5438) + readTarget := fmt.Sprintf("%s:%s", readHost, readPort) + readStart := time.Now() + readDB, err := db.Clauses(dbresolver.Read).DB() + if err != nil || readDB == nil || readDB.PingContext(ctx) != nil { + overallHealthy = false + errMsg := "Read ingress unreachable" + if err != nil { + errMsg = err.Error() + } + targets["db_read"] = TargetHealth{ + Status: "DOWN", + Target: readTarget, + Error: errMsg, + } + } else { + targets["db_read"] = TargetHealth{ + Status: "UP", + Target: readTarget, + LatencyMs: float64(time.Since(readStart).Microseconds()) / 1000.0, + } + } + + statusCode := fiber.StatusOK + statusText := "UP" + if !overallHealthy { + statusCode = fiber.StatusServiceUnavailable + statusText = "DEGRADED" + } + + return c.Status(statusCode).JSON(ReadinessResponse{ + Status: statusText, + Timestamp: time.Now().UTC(), + Targets: targets, + }) + }) +} diff --git a/docs/new_data_arch.md b/docs/new_data_arch.md new file mode 100644 index 0000000..7ed0c5a --- /dev/null +++ b/docs/new_data_arch.md @@ -0,0 +1,48 @@ +# Database Enhancement + +Currently, the entire applicaction operates from a single DB/connection for both reads and writes. + +I am developing a new solution, decoupled from the current project, that aims to convert the single DB setup into a HA multi read-replica server architecture. + +The only changes that need to be made to this Go Fiber backend is which DB/connection to use for write operations and which oness are for read. + +The write operations should point to the write-ingress (port 5437). Read operations should use the Read Ingress (port 5438). + +How can this be seamlessly integrated into this application? + +--- + +The current method being used to collect metrics/stats/observability needs a revamp. Examine the current setup and how we can collect metrics/logs/etc... using this new architecture. + +--- + +Feel free to make use of any agents, tools, skills, etc to complete this initial research and planning task. + +```mermaid +`flowchart TD + subgraph Client_Tier["Client application / microservices"] + App["Application code
Dual connection pool"] + end + + subgraph Routing_Tier["HAProxy 2.9 β€” routing and proxy"] + WriteIngress["Write ingress
:5437 Β· TCP pass-through"] + ReadIngress["Read ingress
:5438 Β· Round-robin load balancer"] + Stats["HAProxy Stats UI
:7080 Β· Live connection metrics"] + end + + subgraph Cluster_Tier["PostgreSQL 17 replicated cluster"] + Primary[("pg_primary Β· :5434
Read/write primary Β· WAL source
Replication slots: replica1_slot, replica2_slot")] + Replica1[("pg_replica1 Β· :5435
Read-only hot standby
Uses replica1_slot Β· feedback on")] + Replica2[("pg_replica2 Β· :5436
Read-only hot standby
Uses replica2_slot Β· feedback on")] + end + + App -->|Writes / transactions| WriteIngress + App -->|Read queries| ReadIngress + + WriteIngress -->|TCP forward| Primary + ReadIngress -->|Round robin| Replica1 + ReadIngress -->|Round robin| Replica2 + + Primary -.->|Physical WAL streaming| Replica1 + Primary -.->|Physical WAL streaming| Replica2 +``` diff --git a/go.mod b/go.mod index e3c92c4..a25f5ca 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ toolchain go1.24.2 require ( github.com/PuerkitoBio/goquery v1.10.3 github.com/gofiber/fiber/v2 v2.52.6 + github.com/google/uuid v1.6.0 github.com/prometheus/client_golang v1.22.0 github.com/stretchr/testify v1.10.0 github.com/swaggo/fiber-swagger v1.3.0 @@ -14,7 +15,8 @@ require ( golang.org/x/net v0.40.0 gorm.io/driver/postgres v1.6.0 gorm.io/driver/sqlite v1.5.7 - gorm.io/gorm v1.30.0 + gorm.io/gorm v1.25.12 + gorm.io/plugin/dbresolver v1.5.3 ) require ( @@ -30,7 +32,6 @@ require ( github.com/go-openapi/jsonreference v0.19.6 // indirect github.com/go-openapi/spec v0.20.4 // indirect github.com/go-openapi/swag v0.19.15 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx/v5 v5.7.5 // indirect diff --git a/go.sum b/go.sum index aee29bd..aee83c0 100644 --- a/go.sum +++ b/go.sum @@ -33,6 +33,8 @@ github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7 github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= +github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc= +github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= github.com/gofiber/fiber/v2 v2.32.0/go.mod h1:CMy5ZLiXkn6qwthrl03YMyW1NLfj0rhxz2LKl4t7ZTY= github.com/gofiber/fiber/v2 v2.52.6 h1:Rfp+ILPiYSvvVuIPvxrBns+HJp8qGLDnLJawAu27XVI= github.com/gofiber/fiber/v2 v2.52.6/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= @@ -251,9 +253,14 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo= +gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I= gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4= -gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs= -gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= +gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= +gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= +gorm.io/plugin/dbresolver v1.5.3 h1:wFwINGZZmttuu9h7XpvbDHd8Lf9bb8GNzp/NpAMV2wU= +gorm.io/plugin/dbresolver v1.5.3/go.mod h1:TSrVhaUg2DZAWP3PrHlDlITEJmNOkL0tFTjvTEsQ4XE= diff --git a/main.go b/main.go index 81dd21e..3b5d341 100644 --- a/main.go +++ b/main.go @@ -22,7 +22,7 @@ package main import ( "context" "errors" - "log" + "log/slog" "net/http" "os" "os/signal" @@ -32,49 +32,51 @@ import ( "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/middleware/adaptor" "github.com/gofiber/fiber/v2/middleware/cors" - "github.com/gofiber/fiber/v2/middleware/logger" "github.com/prometheus/client_golang/prometheus/promhttp" fiberswagger "github.com/swaggo/fiber-swagger" - // "gorm.io/gorm" "github.com/nprasad2077/NBA_Go/config" "github.com/nprasad2077/NBA_Go/controllers" - _"github.com/nprasad2077/NBA_Go/docs" + _ "github.com/nprasad2077/NBA_Go/docs" "github.com/nprasad2077/NBA_Go/routes" "github.com/nprasad2077/NBA_Go/utils/middleware" ) func main() { + // Setup structured JSON logger + jsonHandler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}) + slog.SetDefault(slog.New(jsonHandler)) + // β€”β€”β€” One-off import-data mode β€”β€”β€” if len(os.Args) > 1 && os.Args[1] == "import-data" { - // Run all DB migrations + import steps exactly once + // Run all DB migrations + import steps exactly once on Primary (Write Ingress) db := config.InitDB(true) importPlayerAdvanced(db) - log.Println("πŸŽ‰ Player Advanced Import completed successfully") + slog.Info("πŸŽ‰ Player Advanced Import completed successfully") importPlayerAdvancedPlayoffs(db) - log.Println("πŸŽ‰ Player Advanced Playoffs Import completed successfully") + slog.Info("πŸŽ‰ Player Advanced Playoffs Import completed successfully") importPlayerTotalsScrape(db) - log.Println("πŸŽ‰ Player Totals (scraped) Import completed successfully") + slog.Info("πŸŽ‰ Player Totals (scraped) Import completed successfully") importPlayerTotalsPlayoffsScrape(db) - log.Println("πŸŽ‰ Player Playoffs (scraped) Import completed successfully") + slog.Info("πŸŽ‰ Player Playoffs (scraped) Import completed successfully") // importGameSchedules(db) - // log.Println("πŸŽ‰ Game Imports completed successfully πŸ€") + // slog.Info("πŸŽ‰ Game Imports completed successfully πŸ€") // importBoxScores(db) - // log.Println("πŸŽ‰ Related Box Score Imports completed successfully πŸ“¦") + // slog.Info("πŸŽ‰ Related Box Score Imports completed successfully πŸ“¦") // importMarkPlayoffGames(db) - // log.Println("πŸŽ‰ Playoff games marked successfully πŸ†") + // slog.Info("πŸŽ‰ Playoff games marked successfully πŸ†") importPlayerShotCharts(db) - log.Println("πŸŽ‰ Player Shot Chart Import completed successfully 🎯") + slog.Info("πŸŽ‰ Player Shot Chart Import completed successfully 🎯") - log.Println("πŸ€ ALL Imports completed successfully βœ… πŸ™Œ") + slog.Info("πŸ€ ALL Imports completed successfully βœ… πŸ™Œ") return } @@ -97,21 +99,24 @@ func main() { // β€” CORS Allow ALL origins (development) β€” app.Use(cors.New()) - // middlewares - app.Use(logger.New()) + // Middlewares + app.Use(middleware.StructuredLogger()) app.Use(middleware.RateLimiter()) app.Use(middleware.MetricsMiddleware()) - // DB connection (no migrations on API startup) + // DB connection (Read/Write splitting via DBResolver) db := config.InitDB(false) + /* ---------- HEALTH & OBSERVABILITY ROUTES ---------- */ + controllers.RegisterHealthRoutes(app, db) + app.Get("/metrics", adaptor.HTTPHandler(promhttp.Handler())) + /* ---------- PUBLIC ROUTES (no API key) ---------- */ // Redirect root to swagger docs app.Get("/", func(c *fiber.Ctx) error { return c.Redirect("/swagger/index.html") }) - app.Get("/metrics", adaptor.HTTPHandler(promhttp.Handler())) app.Get("/swagger/*", fiberswagger.WrapHandler) controllers.RegisterKeyAdminRoutes(app, db) @@ -125,14 +130,16 @@ func main() { /* ---------- START & SHUTDOWN ---------- */ go func() { + slog.Info("πŸš€ Starting NBA_Go Fiber API Server on :5000") if err := app.Listen(":5000"); err != nil && !errors.Is(err, http.ErrServerClosed) { - log.Fatalf("listen: %v", err) + slog.Error("Failed to listen on :5000", "error", err) + os.Exit(1) } }() <-ctx.Done() stop() - log.Println("shutting down…") + slog.Info("πŸ›‘ Shutting down server gracefully...") _ = app.Shutdown() - log.Println("bye") + slog.Info("πŸ‘‹ Server shutdown complete. Bye!") } diff --git a/main_test.go b/main_test.go index 53368ac..d4d00ba 100644 --- a/main_test.go +++ b/main_test.go @@ -11,6 +11,7 @@ import ( "gorm.io/driver/sqlite" "gorm.io/gorm" + "github.com/nprasad2077/NBA_Go/controllers" "github.com/nprasad2077/NBA_Go/models" "github.com/nprasad2077/NBA_Go/routes" "github.com/nprasad2077/NBA_Go/utils/security" @@ -77,3 +78,19 @@ func TestGetPlayerAdvancedStats(t *testing.T) { assert.Contains(t, string(body), tc.wantSubstring, tc.name) } } + +func TestHealthRoutes(t *testing.T) { + app := fiber.New() + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) + assert.NoError(t, err) + + controllers.RegisterHealthRoutes(app, db) + + req, _ := http.NewRequest(http.MethodGet, "/healthz", nil) + resp, err := app.Test(req, -1) + assert.NoError(t, err) + assert.Equal(t, 200, resp.StatusCode) + + body, _ := io.ReadAll(resp.Body) + assert.Contains(t, string(body), `"status":"UP"`) +} diff --git a/prometheus/prometheus.yml b/prometheus/prometheus.yml index 30dc625..7d830f0 100644 --- a/prometheus/prometheus.yml +++ b/prometheus/prometheus.yml @@ -10,4 +10,9 @@ scrape_configs: - job_name: 'nba-api' static_configs: - targets: ['api1:5000', 'api2:5000', 'api3:5000'] + metrics_path: '/metrics' + + - job_name: 'haproxy' + static_configs: + - targets: ['178.105.149.129:7080'] metrics_path: '/metrics' \ No newline at end of file diff --git a/utils/metrics/metrics.go b/utils/metrics/metrics.go index d1fc794..91788c5 100644 --- a/utils/metrics/metrics.go +++ b/utils/metrics/metrics.go @@ -1,8 +1,12 @@ package metrics import ( + "time" + "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" + "gorm.io/gorm" + "gorm.io/plugin/dbresolver" ) // Define metrics @@ -11,7 +15,7 @@ var ( HTTPRequestsTotal = promauto.NewCounterVec( prometheus.CounterOpts{ Name: "nba_http_requests_total", - Help: "Total number of HTTP requests", + Help: "Total number of HTTP requests processed", }, []string{"method", "endpoint", "status"}, ) @@ -20,18 +24,92 @@ var ( HTTPRequestDuration = promauto.NewHistogramVec( prometheus.HistogramOpts{ Name: "nba_http_request_duration_seconds", - Help: "HTTP request duration in seconds", - Buckets: prometheus.DefBuckets, + Help: "HTTP request latency in seconds", + Buckets: []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10}, }, []string{"method", "endpoint"}, ) - // DBOperationsTotal counts database operations + // DBOperationsTotal counts database operations (connect, migrate) DBOperationsTotal = promauto.NewCounterVec( prometheus.CounterOpts{ Name: "nba_db_operations_total", - Help: "Total number of database operations", + Help: "Total number of database lifecycle operations", }, []string{"operation", "entity"}, ) -) \ No newline at end of file + + // DBPoolOpenConns tracks current open connections per pool + DBPoolOpenConns = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "nba_db_pool_open_connections", + Help: "Current number of open connections in pool", + }, + []string{"pool"}, + ) + + // DBPoolInUseConns tracks connections currently executing queries + DBPoolInUseConns = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "nba_db_pool_in_use_connections", + Help: "Current number of in-use active connections executing queries", + }, + []string{"pool"}, + ) + + // DBPoolIdleConns tracks idle connections available in pool + DBPoolIdleConns = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "nba_db_pool_idle_connections", + Help: "Current number of idle connections in pool", + }, + []string{"pool"}, + ) + + // DBPoolWaitCount tracks connection wait occurrences + DBPoolWaitCount = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "nba_db_pool_wait_count_total", + Help: "Total number of connection waits due to pool saturation", + }, + []string{"pool"}, + ) + + // DBPoolWaitDuration tracks time spent waiting for a connection + DBPoolWaitDuration = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "nba_db_pool_wait_duration_seconds_total", + Help: "Total seconds blocked waiting for a connection", + }, + []string{"pool"}, + ) +) + +// StartDBPoolMetricsCollector periodically collects sql.DBStats for both pools +func StartDBPoolMetricsCollector(db *gorm.DB) { + if db == nil { + return + } + go func() { + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + for range ticker.C { + // Write Pool (Sources) + if writeDB, err := db.Clauses(dbresolver.Write).DB(); err == nil && writeDB != nil { + stats := writeDB.Stats() + DBPoolOpenConns.WithLabelValues("write").Set(float64(stats.OpenConnections)) + DBPoolInUseConns.WithLabelValues("write").Set(float64(stats.InUse)) + DBPoolIdleConns.WithLabelValues("write").Set(float64(stats.Idle)) + } + + // Read Pool (Replicas) + if readDB, err := db.Clauses(dbresolver.Read).DB(); err == nil && readDB != nil { + stats := readDB.Stats() + DBPoolOpenConns.WithLabelValues("read").Set(float64(stats.OpenConnections)) + DBPoolInUseConns.WithLabelValues("read").Set(float64(stats.InUse)) + DBPoolIdleConns.WithLabelValues("read").Set(float64(stats.Idle)) + } + } + }() +} \ No newline at end of file diff --git a/utils/middleware/logger_middleware.go b/utils/middleware/logger_middleware.go new file mode 100644 index 0000000..84569b9 --- /dev/null +++ b/utils/middleware/logger_middleware.go @@ -0,0 +1,36 @@ +package middleware + +import ( + "log/slog" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" +) + +// StructuredLogger logs incoming HTTP requests using slog with X-Request-ID correlation. +func StructuredLogger() fiber.Handler { + return func(c *fiber.Ctx) error { + start := time.Now() + reqID := c.Get("X-Request-ID") + if reqID == "" { + reqID = uuid.NewString() + c.Set("X-Request-ID", reqID) + } + c.Locals("requestId", reqID) + + err := c.Next() + duration := time.Since(start) + + slog.Info("HTTP Request", + "request_id", reqID, + "method", c.Method(), + "path", c.Path(), + "status", c.Response().StatusCode(), + "duration_ms", duration.Milliseconds(), + "ip", c.IP(), + "user_agent", c.Get("User-Agent"), + ) + return err + } +}