mirror of
https://github.com/nprasad2077/NBA_Go.git
synced 2026-09-22 14:05:13 +00:00
PG DB reroute
This commit is contained in:
@@ -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
@@ -4,76 +4,142 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/nprasad2077/NBA_Go/models"
|
"github.com/nprasad2077/NBA_Go/models"
|
||||||
"github.com/nprasad2077/NBA_Go/utils/metrics"
|
"github.com/nprasad2077/NBA_Go/utils/metrics"
|
||||||
"gorm.io/driver/postgres"
|
"gorm.io/driver/postgres"
|
||||||
"gorm.io/gorm"
|
"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 {
|
func InitDB(shouldMigrate bool) *gorm.DB {
|
||||||
// ... (DSN setup code is unchanged) ...
|
cfg := LoadDBConfig()
|
||||||
dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=disable TimeZone=UTC",
|
writeDSN := buildDSN(cfg.WriteHost, cfg.WritePort, cfg.User, cfg.Password, cfg.DBName, cfg.SSLMode)
|
||||||
os.Getenv("DB_HOST"),
|
readDSN := buildDSN(cfg.ReadHost, cfg.ReadPort, cfg.User, cfg.Password, cfg.DBName, cfg.SSLMode)
|
||||||
os.Getenv("DB_USER"),
|
|
||||||
os.Getenv("DB_PASSWORD"),
|
slog.Info("🔌 Connecting to PostgreSQL Cluster via HAProxy",
|
||||||
os.Getenv("DB_NAME"),
|
"write_ingress", fmt.Sprintf("%s:%s", cfg.WriteHost, cfg.WritePort),
|
||||||
os.Getenv("DB_PORT"),
|
"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 {
|
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()
|
metrics.DBOperationsTotal.WithLabelValues("connect", "database").Inc()
|
||||||
|
|
||||||
|
// Start asynchronous connection pool metrics collection
|
||||||
|
metrics.StartDBPoolMetricsCollector(db)
|
||||||
|
|
||||||
if shouldMigrate {
|
if shouldMigrate {
|
||||||
// --- ADD THIS BLOCK TO DROP STALE TABLES ---
|
slog.Info("🚀 Executing database AutoMigrate on Primary (:5437)...")
|
||||||
// Drop tables in reverse order of dependency (children first).
|
modelsToMigrate := []interface{}{
|
||||||
// This ensures a clean migration every time the import process runs.
|
&models.APIKey{},
|
||||||
// log.Println("⚠️ Dropping existing game-related tables for a clean migration...")
|
&models.PlayerAdvancedStat{},
|
||||||
// if err := db.Migrator().DropTable(
|
&models.PlayerTotalStat{},
|
||||||
// &models.LineScore{},
|
&models.PlayerShotChart{},
|
||||||
// &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(
|
|
||||||
&models.Game{},
|
&models.Game{},
|
||||||
&models.LineScore{},
|
&models.LineScore{},
|
||||||
&models.PlayerGameBasicStat{},
|
&models.PlayerGameBasicStat{},
|
||||||
&models.PlayerGameAdvStat{},
|
&models.PlayerGameAdvStat{},
|
||||||
&models.TeamGameBasicStat{},
|
&models.TeamGameBasicStat{},
|
||||||
&models.TeamGameAdvStat{},
|
&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()
|
metrics.DBOperationsTotal.WithLabelValues("migrate", "database").Inc()
|
||||||
|
slog.Info("✅ Database schema migration completed successfully")
|
||||||
}
|
}
|
||||||
|
|
||||||
return db
|
return db
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getEnvOrDefault(key, fallback string) string {
|
||||||
|
if val := os.Getenv(key); val != "" {
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"github.com/nprasad2077/NBA_Go/models"
|
"github.com/nprasad2077/NBA_Go/models"
|
||||||
"github.com/nprasad2077/NBA_Go/utils/security"
|
"github.com/nprasad2077/NBA_Go/utils/security"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/plugin/dbresolver"
|
||||||
)
|
)
|
||||||
|
|
||||||
// very small middleware: require header X‑Admin‑Secret == $ADMIN_SECRET
|
// 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 {
|
admin.Get("/", func(c *fiber.Ctx) error {
|
||||||
var keys []models.APIKey
|
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)
|
return c.JSON(keys)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -45,7 +47,7 @@ func RegisterKeyAdminRoutes(app *fiber.App, db *gorm.DB) {
|
|||||||
Hash: security.HashKey(raw),
|
Hash: security.HashKey(raw),
|
||||||
Label: body.Label,
|
Label: body.Label,
|
||||||
}
|
}
|
||||||
db.Create(&key)
|
db.Clauses(dbresolver.Write).Create(&key)
|
||||||
|
|
||||||
// return ONLY the raw key once
|
// return ONLY the raw key once
|
||||||
return c.JSON(fiber.Map{
|
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 {
|
admin.Post("/:id/revoke", func(c *fiber.Ctx) error {
|
||||||
id := c.Params("id")
|
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})
|
return c.JSON(fiber.Map{"revoked": id})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -7,6 +7,7 @@ toolchain go1.24.2
|
|||||||
require (
|
require (
|
||||||
github.com/PuerkitoBio/goquery v1.10.3
|
github.com/PuerkitoBio/goquery v1.10.3
|
||||||
github.com/gofiber/fiber/v2 v2.52.6
|
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/prometheus/client_golang v1.22.0
|
||||||
github.com/stretchr/testify v1.10.0
|
github.com/stretchr/testify v1.10.0
|
||||||
github.com/swaggo/fiber-swagger v1.3.0
|
github.com/swaggo/fiber-swagger v1.3.0
|
||||||
@@ -14,7 +15,8 @@ require (
|
|||||||
golang.org/x/net v0.40.0
|
golang.org/x/net v0.40.0
|
||||||
gorm.io/driver/postgres v1.6.0
|
gorm.io/driver/postgres v1.6.0
|
||||||
gorm.io/driver/sqlite v1.5.7
|
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 (
|
require (
|
||||||
@@ -30,7 +32,6 @@ require (
|
|||||||
github.com/go-openapi/jsonreference v0.19.6 // indirect
|
github.com/go-openapi/jsonreference v0.19.6 // indirect
|
||||||
github.com/go-openapi/spec v0.20.4 // indirect
|
github.com/go-openapi/spec v0.20.4 // indirect
|
||||||
github.com/go-openapi/swag v0.19.15 // 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/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/pgx/v5 v5.7.5 // indirect
|
github.com/jackc/pgx/v5 v5.7.5 // indirect
|
||||||
|
|||||||
@@ -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.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 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
|
||||||
github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
|
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.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 h1:Rfp+ILPiYSvvVuIPvxrBns+HJp8qGLDnLJawAu27XVI=
|
||||||
github.com/gofiber/fiber/v2 v2.52.6/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
|
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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
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 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||||
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
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 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I=
|
||||||
gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
|
gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
|
||||||
gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs=
|
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||||
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
|
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=
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"log"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
@@ -32,10 +32,8 @@ import (
|
|||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
"github.com/gofiber/fiber/v2/middleware/adaptor"
|
"github.com/gofiber/fiber/v2/middleware/adaptor"
|
||||||
"github.com/gofiber/fiber/v2/middleware/cors"
|
"github.com/gofiber/fiber/v2/middleware/cors"
|
||||||
"github.com/gofiber/fiber/v2/middleware/logger"
|
|
||||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
fiberswagger "github.com/swaggo/fiber-swagger"
|
fiberswagger "github.com/swaggo/fiber-swagger"
|
||||||
// "gorm.io/gorm"
|
|
||||||
|
|
||||||
"github.com/nprasad2077/NBA_Go/config"
|
"github.com/nprasad2077/NBA_Go/config"
|
||||||
"github.com/nprasad2077/NBA_Go/controllers"
|
"github.com/nprasad2077/NBA_Go/controllers"
|
||||||
@@ -45,36 +43,40 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
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 ———
|
// ——— One-off import-data mode ———
|
||||||
if len(os.Args) > 1 && os.Args[1] == "import-data" {
|
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)
|
db := config.InitDB(true)
|
||||||
|
|
||||||
importPlayerAdvanced(db)
|
importPlayerAdvanced(db)
|
||||||
log.Println("🎉 Player Advanced Import completed successfully")
|
slog.Info("🎉 Player Advanced Import completed successfully")
|
||||||
|
|
||||||
importPlayerAdvancedPlayoffs(db)
|
importPlayerAdvancedPlayoffs(db)
|
||||||
log.Println("🎉 Player Advanced Playoffs Import completed successfully")
|
slog.Info("🎉 Player Advanced Playoffs Import completed successfully")
|
||||||
|
|
||||||
importPlayerTotalsScrape(db)
|
importPlayerTotalsScrape(db)
|
||||||
log.Println("🎉 Player Totals (scraped) Import completed successfully")
|
slog.Info("🎉 Player Totals (scraped) Import completed successfully")
|
||||||
|
|
||||||
importPlayerTotalsPlayoffsScrape(db)
|
importPlayerTotalsPlayoffsScrape(db)
|
||||||
log.Println("🎉 Player Playoffs (scraped) Import completed successfully")
|
slog.Info("🎉 Player Playoffs (scraped) Import completed successfully")
|
||||||
|
|
||||||
// importGameSchedules(db)
|
// importGameSchedules(db)
|
||||||
// log.Println("🎉 Game Imports completed successfully 🏀")
|
// slog.Info("🎉 Game Imports completed successfully 🏀")
|
||||||
|
|
||||||
// importBoxScores(db)
|
// importBoxScores(db)
|
||||||
// log.Println("🎉 Related Box Score Imports completed successfully 📦")
|
// slog.Info("🎉 Related Box Score Imports completed successfully 📦")
|
||||||
|
|
||||||
// importMarkPlayoffGames(db)
|
// importMarkPlayoffGames(db)
|
||||||
// log.Println("🎉 Playoff games marked successfully 🏆")
|
// slog.Info("🎉 Playoff games marked successfully 🏆")
|
||||||
|
|
||||||
importPlayerShotCharts(db)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,21 +99,24 @@ func main() {
|
|||||||
// — CORS Allow ALL origins (development) —
|
// — CORS Allow ALL origins (development) —
|
||||||
app.Use(cors.New())
|
app.Use(cors.New())
|
||||||
|
|
||||||
// middlewares
|
// Middlewares
|
||||||
app.Use(logger.New())
|
app.Use(middleware.StructuredLogger())
|
||||||
app.Use(middleware.RateLimiter())
|
app.Use(middleware.RateLimiter())
|
||||||
app.Use(middleware.MetricsMiddleware())
|
app.Use(middleware.MetricsMiddleware())
|
||||||
|
|
||||||
// DB connection (no migrations on API startup)
|
// DB connection (Read/Write splitting via DBResolver)
|
||||||
db := config.InitDB(false)
|
db := config.InitDB(false)
|
||||||
|
|
||||||
|
/* ---------- HEALTH & OBSERVABILITY ROUTES ---------- */
|
||||||
|
controllers.RegisterHealthRoutes(app, db)
|
||||||
|
app.Get("/metrics", adaptor.HTTPHandler(promhttp.Handler()))
|
||||||
|
|
||||||
/* ---------- PUBLIC ROUTES (no API key) ---------- */
|
/* ---------- PUBLIC ROUTES (no API key) ---------- */
|
||||||
// Redirect root to swagger docs
|
// Redirect root to swagger docs
|
||||||
app.Get("/", func(c *fiber.Ctx) error {
|
app.Get("/", func(c *fiber.Ctx) error {
|
||||||
return c.Redirect("/swagger/index.html")
|
return c.Redirect("/swagger/index.html")
|
||||||
})
|
})
|
||||||
|
|
||||||
app.Get("/metrics", adaptor.HTTPHandler(promhttp.Handler()))
|
|
||||||
app.Get("/swagger/*", fiberswagger.WrapHandler)
|
app.Get("/swagger/*", fiberswagger.WrapHandler)
|
||||||
controllers.RegisterKeyAdminRoutes(app, db)
|
controllers.RegisterKeyAdminRoutes(app, db)
|
||||||
|
|
||||||
@@ -125,14 +130,16 @@ func main() {
|
|||||||
|
|
||||||
/* ---------- START & SHUTDOWN ---------- */
|
/* ---------- START & SHUTDOWN ---------- */
|
||||||
go func() {
|
go func() {
|
||||||
|
slog.Info("🚀 Starting NBA_Go Fiber API Server on :5000")
|
||||||
if err := app.Listen(":5000"); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
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()
|
<-ctx.Done()
|
||||||
stop()
|
stop()
|
||||||
log.Println("shutting down…")
|
slog.Info("🛑 Shutting down server gracefully...")
|
||||||
_ = app.Shutdown()
|
_ = app.Shutdown()
|
||||||
log.Println("bye")
|
slog.Info("👋 Server shutdown complete. Bye!")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"gorm.io/driver/sqlite"
|
"gorm.io/driver/sqlite"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/nprasad2077/NBA_Go/controllers"
|
||||||
"github.com/nprasad2077/NBA_Go/models"
|
"github.com/nprasad2077/NBA_Go/models"
|
||||||
"github.com/nprasad2077/NBA_Go/routes"
|
"github.com/nprasad2077/NBA_Go/routes"
|
||||||
"github.com/nprasad2077/NBA_Go/utils/security"
|
"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)
|
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"`)
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,3 +11,8 @@ scrape_configs:
|
|||||||
static_configs:
|
static_configs:
|
||||||
- targets: ['api1:5000', 'api2:5000', 'api3:5000']
|
- targets: ['api1:5000', 'api2:5000', 'api3:5000']
|
||||||
metrics_path: '/metrics'
|
metrics_path: '/metrics'
|
||||||
|
|
||||||
|
- job_name: 'haproxy'
|
||||||
|
static_configs:
|
||||||
|
- targets: ['178.105.149.129:7080']
|
||||||
|
metrics_path: '/metrics'
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
package metrics
|
package metrics
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/plugin/dbresolver"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Define metrics
|
// Define metrics
|
||||||
@@ -11,7 +15,7 @@ var (
|
|||||||
HTTPRequestsTotal = promauto.NewCounterVec(
|
HTTPRequestsTotal = promauto.NewCounterVec(
|
||||||
prometheus.CounterOpts{
|
prometheus.CounterOpts{
|
||||||
Name: "nba_http_requests_total",
|
Name: "nba_http_requests_total",
|
||||||
Help: "Total number of HTTP requests",
|
Help: "Total number of HTTP requests processed",
|
||||||
},
|
},
|
||||||
[]string{"method", "endpoint", "status"},
|
[]string{"method", "endpoint", "status"},
|
||||||
)
|
)
|
||||||
@@ -20,18 +24,92 @@ var (
|
|||||||
HTTPRequestDuration = promauto.NewHistogramVec(
|
HTTPRequestDuration = promauto.NewHistogramVec(
|
||||||
prometheus.HistogramOpts{
|
prometheus.HistogramOpts{
|
||||||
Name: "nba_http_request_duration_seconds",
|
Name: "nba_http_request_duration_seconds",
|
||||||
Help: "HTTP request duration in seconds",
|
Help: "HTTP request latency in seconds",
|
||||||
Buckets: prometheus.DefBuckets,
|
Buckets: []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10},
|
||||||
},
|
},
|
||||||
[]string{"method", "endpoint"},
|
[]string{"method", "endpoint"},
|
||||||
)
|
)
|
||||||
|
|
||||||
// DBOperationsTotal counts database operations
|
// DBOperationsTotal counts database operations (connect, migrate)
|
||||||
DBOperationsTotal = promauto.NewCounterVec(
|
DBOperationsTotal = promauto.NewCounterVec(
|
||||||
prometheus.CounterOpts{
|
prometheus.CounterOpts{
|
||||||
Name: "nba_db_operations_total",
|
Name: "nba_db_operations_total",
|
||||||
Help: "Total number of database operations",
|
Help: "Total number of database lifecycle operations",
|
||||||
},
|
},
|
||||||
[]string{"operation", "entity"},
|
[]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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user