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
+ }
+}