// Package server provides the HTTP server lifecycle, // including startup, routing, and graceful shutdown. The // process lifetime is owned by fx: shutdown is requested // through fx.Shutdowner so every component's OnStop hook runs // in dependency order. package server import ( "context" "fmt" "log/slog" "net/http" "time" "sneak.berlin/go/netwatch/internal/config" "sneak.berlin/go/netwatch/internal/globals" "sneak.berlin/go/netwatch/internal/handlers" "sneak.berlin/go/netwatch/internal/logger" "sneak.berlin/go/netwatch/internal/middleware" "github.com/getsentry/sentry-go" "github.com/go-chi/chi/v5" "go.uber.org/fx" ) // sentryFlushTimeout is how long shutdown waits for Sentry to send // what it still holds. const sentryFlushTimeout = 2 * time.Second // Params defines the dependencies for Server. type Params struct { fx.In Config *config.Config Globals *globals.Globals Handlers *handlers.Handlers Logger *logger.Logger Middleware *middleware.Middleware Shutdowner fx.Shutdowner } // Server is the top-level HTTP server orchestrator. type Server struct { h *handlers.Handlers httpServer *http.Server log *slog.Logger mw *middleware.Middleware params Params router *chi.Mux shutdowner fx.Shutdowner } // New creates a Server and registers lifecycle hooks for // starting and stopping it. func New( lc fx.Lifecycle, params Params, ) (*Server, error) { s := new(Server) s.params = params s.mw = params.Middleware s.h = params.Handlers s.log = params.Logger.Get() s.shutdowner = params.Shutdowner err := s.enableSentry() if err != nil { return nil, err } lc.Append(fx.Hook{ OnStart: func(_ context.Context) error { // Build the router and http.Server synchronously // here, before spawning the serving goroutine, so // httpServer is fully constructed by the time OnStop // (or an early signal) can read it. fx guarantees // OnStart returns before OnStop runs, so no // synchronization or nil check is needed at shutdown. s.SetupRoutes() s.httpServer = s.newHTTPServer() go s.listenAndServe() return nil }, OnStop: func(ctx context.Context) error { return s.shutdown(ctx) }, }) return s, nil } // ServeHTTP delegates to the chi router. func (s *Server) ServeHTTP( w http.ResponseWriter, r *http.Request, ) { s.router.ServeHTTP(w, r) } // enableSentry sets Sentry up when SENTRY_DSN is set, so that // SetupRoutes can report panics to it. With SENTRY_DSN empty it does // nothing. A DSN Sentry refuses stops the start. func (s *Server) enableSentry() error { if s.params.Config.SentryDSN == "" { return nil } err := sentry.Init(sentry.ClientOptions{ Dsn: s.params.Config.SentryDSN, Release: s.params.Globals.Appname + "-" + s.params.Globals.Version, }) if err != nil { return fmt.Errorf("SENTRY_DSN: %w", err) } s.log.Info("sentry error reporting activated") return nil } // shutdown gracefully stops the HTTP server within the // deadline of the context fx provides for OnStop, then gives // Sentry, if set up, time to send what it still holds. func (s *Server) shutdown(ctx context.Context) error { err := s.httpServer.Shutdown(ctx) if s.params.Config.SentryDSN != "" { sentry.Flush(sentryFlushTimeout) } if err != nil { s.log.Error("server clean shutdown failed", "error", err) return err } s.log.Info("server stopped") return nil }