PG DB reroute

This commit is contained in:
2026-09-04 12:01:20 -05:00
parent f14e70b15c
commit e8078523be
12 changed files with 485 additions and 79 deletions
+19
View File
@@ -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
+111 -45
View File
@@ -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
}
+5 -3
View File
@@ -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 XAdminSecret == $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})
})
}
+120
View File
@@ -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,
})
})
}
+48
View File
@@ -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<br/>Dual connection pool"]
end
subgraph Routing_Tier["HAProxy 2.9 — routing and proxy"]
WriteIngress["Write ingress<br/>:5437 · TCP pass-through"]
ReadIngress["Read ingress<br/>:5438 · Round-robin load balancer"]
Stats["HAProxy Stats UI<br/>:7080 · Live connection metrics"]
end
subgraph Cluster_Tier["PostgreSQL 17 replicated cluster"]
Primary[("pg_primary · :5434<br/>Read/write primary · WAL source<br/>Replication slots: replica1_slot, replica2_slot")]
Replica1[("pg_replica1 · :5435<br/>Read-only hot standby<br/>Uses replica1_slot · feedback on")]
Replica2[("pg_replica2 · :5436<br/>Read-only hot standby<br/>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
```
+3 -2
View File
@@ -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
+9 -2
View File
@@ -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=
+28 -21
View File
@@ -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!")
}
+17
View File
@@ -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"`)
}
+5
View File
@@ -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'
+84 -6
View File
@@ -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"},
)
)
// 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))
}
}
}()
}
+36
View File
@@ -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
}
}