Files
core/internal/api/server.go
Mikhail Chusavitin 04ad1f0568 Structured logging + request ID middleware — ingest v2.0, history v2.0, api v2.0
Migrate all log.Printf/log.Fatalf to log/slog with structured key-value
attributes per bible go-logging contract.

- Add withRequestID middleware: generates crypto/rand 8-byte hex ID per
  request, sets X-Request-ID response header, injects into context
- withErrorLogging uses slog with request_id from context
- writeError internal log calls migrated to slog.Error/slog.Warn
- All handler log calls in api, ingest, history packages use slog
- cmd/reanimator-api configures slog.NewTextHandler(os.Stdout) at startup
- cmd/reanimator-migrate, cmd/reanimator-reset migrated to slog

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 15:03:22 +03:00

172 lines
4.3 KiB
Go

package api
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"encoding/json"
"log/slog"
"net/http"
"time"
"reanimator/internal/history"
"reanimator/internal/ingest"
"reanimator/internal/repository/failures"
"reanimator/internal/repository/registry"
"reanimator/internal/repository/timeline"
)
type Server struct {
httpServer *http.Server
cancelBg context.CancelFunc
}
type statusCaptureResponseWriter struct {
http.ResponseWriter
status int
}
func (w *statusCaptureResponseWriter) WriteHeader(status int) {
w.status = status
w.ResponseWriter.WriteHeader(status)
}
func (w *statusCaptureResponseWriter) Write(b []byte) (int, error) {
if w.status == 0 {
w.status = http.StatusOK
}
return w.ResponseWriter.Write(b)
}
func generateRequestID() string {
b := make([]byte, 8)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
func withRequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := generateRequestID()
w.Header().Set("X-Request-ID", id)
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), requestIDCtxKey, id)))
})
}
func withErrorLogging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sw := &statusCaptureResponseWriter{ResponseWriter: w}
start := time.Now()
defer func() {
if rec := recover(); rec != nil {
slog.Error("http panic",
"method", r.Method,
"path", r.URL.Path,
"remote", r.RemoteAddr,
"duration_ms", time.Since(start).Milliseconds(),
"panic", rec,
"request_id", requestIDFromCtx(r.Context()),
)
if sw.status == 0 {
writeJSON(sw, http.StatusInternalServerError, map[string]string{"error": "internal server error"})
}
return
}
status := sw.status
if status == 0 {
status = http.StatusOK
}
if status >= 400 {
slog.Warn("http response error",
"method", r.Method,
"path", r.URL.Path,
"query", r.URL.RawQuery,
"status", status,
"remote", r.RemoteAddr,
"duration_ms", time.Since(start).Milliseconds(),
"request_id", requestIDFromCtx(r.Context()),
)
}
}()
next.ServeHTTP(sw, r)
})
}
func NewServer(addr string, readTimeout, writeTimeout time.Duration, db *sql.DB) *Server {
mux := http.NewServeMux()
mux.HandleFunc("/health", healthHandler)
cancelBg := func() {}
if db != nil {
bgCtx, cancel := context.WithCancel(context.Background())
cancelBg = cancel
failureRepo := failures.NewFailureRepository(db)
assetRepo := registry.NewAssetRepository(db)
componentRepo := registry.NewComponentRepository(db)
installationRepo := registry.NewInstallationRepository(db)
timelineRepo := timeline.NewEventRepository(db)
historySvc := history.NewService(db)
historySvc.StartWorker(bgCtx)
RegisterRegistryRoutes(mux, RegistryDependencies{
Assets: assetRepo,
Components: componentRepo,
History: historySvc,
})
RegisterHistoryRoutes(mux, HistoryDependencies{
Service: historySvc,
})
RegisterIngestRoutes(mux, IngestDependencies{
Service: ingest.NewService(db),
})
RegisterAssetComponentRoutes(mux, AssetComponentDependencies{
Assets: assetRepo,
Components: componentRepo,
Installations: installationRepo,
Timeline: timelineRepo,
History: historySvc,
})
RegisterFailureRoutes(mux, FailureDependencies{
Failures: failureRepo,
Assets: assetRepo,
Components: componentRepo,
Installations: installationRepo,
History: historySvc,
})
RegisterUIRoutes(mux, UIDependencies{
Assets: assetRepo,
Components: componentRepo,
Installations: installationRepo,
Timeline: timelineRepo,
Failures: failureRepo,
})
}
return &Server{
httpServer: &http.Server{
Addr: addr,
Handler: withErrorLogging(withRequestID(mux)),
ReadTimeout: readTimeout,
WriteTimeout: writeTimeout,
},
cancelBg: cancelBg,
}
}
func (s *Server) Start() error {
return s.httpServer.ListenAndServe()
}
func (s *Server) Shutdown(ctx context.Context) error {
if s.cancelBg != nil {
s.cancelBg()
}
return s.httpServer.Shutdown(ctx)
}
func healthHandler(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}