feat(go-services): tracker-go — complete the Phase 1 read API

Adds the rest of the read-side endpoints to the Go tracker, all parity-checked
against the live Python service:

- DB reads: /stats/{c}, /portals, /spawns/heatmap, /server-health,
  /character-stats/{c} (stats_data JSONB merged to top level),
  /combat-stats[/{c}], /inventories, /inventory/{c}/search.
- 5-minute totals cache + /total-rares, /total-kills.
- Ingest-only state returned as Python's empty/default shapes (/quest-status,
  /vital-sharing/peers, /equipment-cantrip-state/{c}); /issues (flat file),
  /me (401 until cookie verification lands).
- Streaming reverse proxy to inventory-service (/inventory/{c},
  /inventory-characters, /search/*, /sets/list, /inv/{path...} incl. the SSE
  suitbuilder stream).
- compare/compare_endpoints.py: structural parity for all read endpoints +
  exact-match check for /character-stats and /combat-stats on OFFLINE chars
  (online chars legitimately differ — Python serves a richer live overlay that
  Phase-1 Go lacks until ingest).

Verified live: 14/14 endpoints structural-match, 8/8 rich offline chars
exact-match on /character-stats.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-24 09:38:10 +02:00
parent 1af47520c0
commit c4e8190656
9 changed files with 908 additions and 10 deletions

View file

@ -18,6 +18,7 @@ import (
"errors"
"log/slog"
"net/http"
"net/http/httputil"
"os"
"os/signal"
"syscall"
@ -32,9 +33,12 @@ var buildVersion = "dev"
// Server holds the shared dependencies for HTTP handlers.
type Server struct {
pool *pgxpool.Pool
cache *liveCache
log *slog.Logger
pool *pgxpool.Pool
cache *liveCache
totals *totalsCache
invProxy *httputil.ReverseProxy
staticDir string
log *slog.Logger
}
func main() {
@ -47,12 +51,23 @@ func main() {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
srv := &Server{cache: newLiveCache(), log: logger}
srv := &Server{
cache: newLiveCache(),
totals: newTotalsCache(),
staticDir: cfg.StaticDir,
log: logger,
}
// Inventory-service reverse proxy (independent of the DB).
if err := srv.initInvProxy(cfg.InventoryURL); err != nil {
logger.Error("inventory proxy init failed", "err", err, "target", cfg.InventoryURL)
os.Exit(1)
}
// Connect to the dereth DB (read-only). If DATABASE_URL is unset we still
// serve health/version (Phase-0 mode) so the container is observable.
if cfg.DatabaseURL == "" {
logger.Warn("DATABASE_URL unset — running without DB; /live and /trails will be empty")
logger.Warn("DATABASE_URL unset — running without DB; DB-backed endpoints will be empty")
} else {
connectCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
pool, err := newPool(connectCtx, cfg.DatabaseURL)
@ -64,7 +79,8 @@ func main() {
defer pool.Close()
srv.pool = pool
go srv.runCacheLoop(ctx)
logger.Info("db connected; live cache loop started", "interval", cacheInterval.String())
go srv.runTotalsLoop(ctx)
logger.Info("db connected; cache loops started", "live_interval", cacheInterval.String(), "totals_interval", totalsInterval.String())
}
mux := http.NewServeMux()
@ -97,14 +113,18 @@ func main() {
// config holds runtime configuration sourced from environment variables,
// matching the Python service's env var names where they overlap.
type config struct {
Addr string // listen address, e.g. ":8770"
DatabaseURL string // dereth TimescaleDB DSN (read-only use)
Addr string // listen address, e.g. ":8770"
DatabaseURL string // dereth TimescaleDB DSN (read-only use)
InventoryURL string // inventory-service base URL
StaticDir string // directory for static assets / openissues.json
}
func loadConfig() config {
return config{
Addr: ":" + envOr("PORT", "8770"),
DatabaseURL: os.Getenv("DATABASE_URL"),
Addr: ":" + envOr("PORT", "8770"),
DatabaseURL: os.Getenv("DATABASE_URL"),
InventoryURL: envOr("INVENTORY_SERVICE_URL", "http://inventory-service:8000"),
StaticDir: envOr("STATIC_DIR", "static"),
}
}
@ -119,11 +139,39 @@ func (s *Server) registerRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /health", s.handleHealth)
// Mirrors Python's GET /api-version (hyphenated so nginx never strips it).
mux.HandleFunc("GET /api-version", s.handleVersion)
// Phase 1 read-side: the 5s caches.
mux.HandleFunc("GET /live", s.handleLive)
mux.HandleFunc("GET /live/", s.handleLive)
mux.HandleFunc("GET /trails", s.handleTrails)
mux.HandleFunc("GET /trails/", s.handleTrails)
// Totals (5-minute caches).
mux.HandleFunc("GET /total-rares", s.handleTotalRares)
mux.HandleFunc("GET /total-rares/", s.handleTotalRares)
mux.HandleFunc("GET /total-kills", s.handleTotalKills)
mux.HandleFunc("GET /total-kills/", s.handleTotalKills)
// Per-character & aggregate DB reads.
mux.HandleFunc("GET /stats/{character_name}", s.handleStats)
mux.HandleFunc("GET /portals", s.handlePortals)
mux.HandleFunc("GET /spawns/heatmap", s.handleSpawnHeatmap)
mux.HandleFunc("GET /server-health", s.handleServerHealth)
mux.HandleFunc("GET /character-stats/{name}", s.handleCharacterStats)
mux.HandleFunc("GET /combat-stats", s.handleCombatStatsAll)
mux.HandleFunc("GET /combat-stats/{character_name}", s.handleCombatStatsOne)
mux.HandleFunc("GET /inventories", s.handleInventories)
mux.HandleFunc("GET /inventory/{character_name}/search", s.handleInventorySearch)
// Ingest-only state (empty/default in Phase 1).
mux.HandleFunc("GET /quest-status", s.handleQuestStatus)
mux.HandleFunc("GET /vital-sharing/peers", s.handleVitalSharingPeers)
mux.HandleFunc("GET /equipment-cantrip-state/{name}", s.handleEquipmentCantrip)
mux.HandleFunc("GET /issues", s.handleIssues)
mux.HandleFunc("GET /me", s.handleMe)
// Inventory-service reverse proxies.
s.registerProxyRoutes(mux)
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {