tenseleyflow/shithub / 653dfcb

Browse files

Plumb config + observability into web server (logger from config, /metrics route, panic counter, error report from recover)

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
653dfcb935cfdeb0d6e2f74f8ad1d888e533665d
Parents
40877b4
Tree
c1c4701

3 changed files

StatusFile+-
M internal/web/handlers/handlers.go 6 0
M internal/web/middleware/recover.go 5 0
M internal/web/server.go 98 55
internal/web/handlers/handlers.gomodified
@@ -32,6 +32,9 @@ type Deps struct {
3232
 	// ReadyCheck is optionally invoked by /readyz. Returning a non-nil
3333
 	// error makes /readyz report 503. If nil, /readyz always reports ready.
3434
 	ReadyCheck func(context.Context) error
35
+	// MetricsHandler, when non-nil, is mounted at /metrics. Caller is
36
+	// responsible for any access control (e.g. HTTP Basic auth wrapping).
37
+	MetricsHandler http.Handler
3538
 }
3639
 
3740
 // panicHandler implements middleware.PanicHandler. The recover middleware
@@ -79,6 +82,9 @@ func RegisterChi(r *chi.Mux, deps Deps) (*chi.Mux, middleware.PanicHandler, http
7982
 		r.Handle("/static/*", http.StripPrefix("/static/", staticFileServer(deps.StaticFS)))
8083
 		r.Get("/healthz", healthz)
8184
 		r.Handle("/readyz", readinessHandler(deps.ReadyCheck, deps.Logger))
85
+		if deps.MetricsHandler != nil {
86
+			r.Handle("/metrics", deps.MetricsHandler)
87
+		}
8288
 	})
8389
 
8490
 	// Application routes — CSRF protected.
internal/web/middleware/recover.gomodified
@@ -7,6 +7,9 @@ import (
77
 	"log/slog"
88
 	"net/http"
99
 	"runtime/debug"
10
+
11
+	"github.com/tenseleyFlow/shithub/internal/infra/errrep"
12
+	"github.com/tenseleyFlow/shithub/internal/infra/metrics"
1013
 )
1114
 
1215
 // PanicHandler renders a styled error response when the request handler
@@ -27,6 +30,7 @@ func Recover(logger *slog.Logger, handler PanicHandler) func(http.Handler) http.
2730
 					if rec == http.ErrAbortHandler {
2831
 						panic(rec)
2932
 					}
33
+					metrics.PanicsTotal.Inc()
3034
 					reqID := RequestIDFromContext(r.Context())
3135
 					if logger != nil {
3236
 						logger.ErrorContext(
@@ -37,6 +41,7 @@ func Recover(logger *slog.Logger, handler PanicHandler) func(http.Handler) http.
3741
 							slog.String("stack", string(debug.Stack())),
3842
 						)
3943
 					}
44
+					errrep.CapturePanic(rec, reqID)
4045
 					if handler != nil {
4146
 						handler.HandlePanic(w, r, reqID, rec)
4247
 						return
internal/web/server.gomodified
@@ -1,10 +1,10 @@
11
 // SPDX-License-Identifier: AGPL-3.0-or-later
22
 
3
-// Package web boots the shithub HTTP server. S02 lights up the full
4
-// middleware stack (recover, request_id, logging, real-IP, timeout,
5
-// compress, secure headers, CSRF, session, CORS), the chi router, the
6
-// session store, and the styled error pages. Every later sprint adds
7
-// routes via internal/web/handlers.
3
+// Package web boots the shithub HTTP server. The full middleware stack
4
+// (recover, request_id, logging, real-IP, timeout, compress, secure
5
+// headers, CSRF, session, CORS, metrics, tracing), the chi router, the
6
+// session store, the styled error pages, and the observability sinks
7
+// (logging, metrics, tracing, error reporting) are composed here.
88
 package web
99
 
1010
 import (
@@ -20,62 +20,110 @@ import (
2020
 	"time"
2121
 
2222
 	"github.com/go-chi/chi/v5"
23
+	"github.com/jackc/pgx/v5/pgxpool"
2324
 	"golang.org/x/crypto/chacha20poly1305"
2425
 
2526
 	"github.com/tenseleyFlow/shithub/internal/auth/session"
27
+	"github.com/tenseleyFlow/shithub/internal/infra/config"
2628
 	"github.com/tenseleyFlow/shithub/internal/infra/db"
29
+	"github.com/tenseleyFlow/shithub/internal/infra/errrep"
30
+	infralog "github.com/tenseleyFlow/shithub/internal/infra/log"
31
+	"github.com/tenseleyFlow/shithub/internal/infra/metrics"
32
+	"github.com/tenseleyFlow/shithub/internal/infra/tracing"
2733
 	"github.com/tenseleyFlow/shithub/internal/web/handlers"
2834
 	"github.com/tenseleyFlow/shithub/internal/web/middleware"
2935
 )
3036
 
31
-// Options configures the web server.
37
+// Options configures the web server. Addr overrides config when non-empty
38
+// (preserves the existing --addr CLI flag behavior).
3239
 type Options struct {
3340
 	Addr string
3441
 }
3542
 
3643
 // Run boots the web server and blocks until shutdown.
37
-//
38
-// It listens for SIGINT/SIGTERM and gracefully drains in-flight requests on
39
-// exit. The full middleware stack is composed here; handlers register their
40
-// routes via internal/web/handlers.RegisterChi.
4144
 func Run(ctx context.Context, opts Options) error {
42
-	if opts.Addr == "" {
43
-		opts.Addr = ":8080"
45
+	cfg, err := config.Load(nil)
46
+	if err != nil {
47
+		return err
48
+	}
49
+	if opts.Addr != "" {
50
+		cfg.Web.Addr = opts.Addr
4451
 	}
4552
 
46
-	logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
47
-		Level: slog.LevelInfo,
48
-	}))
53
+	logger := infralog.New(infralog.Options{
54
+		Level:  cfg.Log.Level,
55
+		Format: cfg.Log.Format,
56
+		Writer: os.Stderr,
57
+	})
58
+
59
+	// Error reporting (no-op when DSN empty).
60
+	flushErrRep, err := errrep.Init(errrep.Config{
61
+		DSN:         cfg.ErrorReporting.DSN,
62
+		Environment: cfg.ErrorReporting.Environment,
63
+		Release:     cfg.ErrorReporting.Release,
64
+	})
65
+	if err != nil {
66
+		return fmt.Errorf("errrep: %w", err)
67
+	}
68
+	defer func() { _ = flushErrRep(context.Background()) }()
69
+	if cfg.ErrorReporting.DSN != "" {
70
+		// Wrap the slog handler so error-level records are reported.
71
+		// We rebuild the logger so every component that pulls it from
72
+		// here gets the wrapped chain.
73
+		logger = slog.New(&errrep.SlogHandler{Inner: logger.Handler()})
74
+	}
75
+
76
+	// Tracing (no-op when disabled).
77
+	flushTracing, err := tracing.Init(ctx, tracing.Config{
78
+		Enabled:     cfg.Tracing.Enabled,
79
+		Endpoint:    cfg.Tracing.Endpoint,
80
+		SampleRate:  cfg.Tracing.SampleRate,
81
+		ServiceName: cfg.Tracing.ServiceName,
82
+	})
83
+	if err != nil {
84
+		return fmt.Errorf("tracing: %w", err)
85
+	}
86
+	defer func() { _ = flushTracing(context.Background()) }()
4987
 
5088
 	logoBytes, err := LogoSVG()
5189
 	if err != nil {
5290
 		return fmt.Errorf("load logo: %w", err)
5391
 	}
5492
 
55
-	sessionStore, err := buildSessionStore(logger)
93
+	sessionStore, err := buildSessionStore(cfg.Session, logger)
5694
 	if err != nil {
5795
 		return err
5896
 	}
5997
 
60
-	// Optional DB pool (carried over from S01).
61
-	var pool *pgxpoolHandle
62
-	if cfg := db.Defaults().Resolve(); cfg.URL != "" {
63
-		p, err := db.Open(ctx, cfg)
98
+	// Optional DB pool (carried over from S01); now driven by config.
99
+	var pool *pgxpool.Pool
100
+	if cfg.DB.URL != "" {
101
+		//nolint:gosec // G115: max_conns is operator-configured with small numeric values (typ. 10–100).
102
+		p, err := db.Open(ctx, db.Config{
103
+			URL:            cfg.DB.URL,
104
+			MaxConns:       int32(cfg.DB.MaxConns),
105
+			MinConns:       int32(cfg.DB.MinConns),
106
+			ConnectTimeout: cfg.DB.ConnectTimeout,
107
+		})
64108
 		if err != nil {
65109
 			logger.Warn("db: open failed; /readyz will report unhealthy", "error", err)
66110
 		} else {
67
-			pool = &pgxpoolHandle{p: p}
111
+			pool = p
68112
 			defer p.Close()
113
+			metrics.ObserveDBPool(ctx, pool, 10*time.Second)
69114
 		}
70115
 	}
71116
 
72117
 	r := chi.NewRouter()
73118
 
74
-	// Middleware stack — outermost first. Recover wraps the whole pipeline
75
-	// AFTER routes register so its panic handler has a renderer ready.
119
+	// Middleware stack — outermost first.
76120
 	r.Use(middleware.RequestID)
77121
 	r.Use(middleware.RealIP(middleware.RealIPConfig{}))
78122
 	r.Use(middleware.AccessLog(logger))
123
+	r.Use(middleware.Metrics)
124
+	if cfg.Tracing.Enabled {
125
+		r.Use(tracing.Middleware)
126
+	}
79127
 	r.Use(middleware.SecureHeaders(middleware.DefaultSecureHeaders()))
80128
 	r.Use(middleware.Compress)
81129
 	r.Use(middleware.Timeout(30 * time.Second))
@@ -89,7 +137,10 @@ func Run(ctx context.Context, opts Options) error {
89137
 		SessionStore: sessionStore,
90138
 	}
91139
 	if pool != nil {
92
-		deps.ReadyCheck = pool.healthcheck
140
+		deps.ReadyCheck = func(ctx context.Context) error { return pool.Ping(ctx) }
141
+	}
142
+	if cfg.Metrics.Enabled {
143
+		deps.MetricsHandler = metrics.Handler(cfg.Metrics.BasicAuthUser, cfg.Metrics.BasicAuthPass)
93144
 	}
94145
 
95146
 	_, panicHandler, notFoundHandler, err := handlers.RegisterChi(r, deps)
@@ -101,17 +152,25 @@ func Run(ctx context.Context, opts Options) error {
101152
 	rootHandler := middleware.Recover(logger, panicHandler)(r)
102153
 
103154
 	srv := &http.Server{
104
-		Addr:              opts.Addr,
155
+		Addr:              cfg.Web.Addr,
105156
 		Handler:           rootHandler,
106157
 		ReadHeaderTimeout: 10 * time.Second,
107
-		ReadTimeout:       30 * time.Second,
108
-		WriteTimeout:      30 * time.Second,
158
+		ReadTimeout:       cfg.Web.ReadTimeout,
159
+		WriteTimeout:      cfg.Web.WriteTimeout,
109160
 		IdleTimeout:       120 * time.Second,
110161
 	}
111162
 
112163
 	errCh := make(chan error, 1)
113164
 	go func() {
114
-		logger.Info("shithub web server starting", "addr", opts.Addr)
165
+		logger.Info(
166
+			"shithub web server starting",
167
+			"addr", srv.Addr,
168
+			"env", cfg.Env,
169
+			"db", pool != nil,
170
+			"metrics", cfg.Metrics.Enabled,
171
+			"tracing", cfg.Tracing.Enabled,
172
+			"errrep", cfg.ErrorReporting.DSN != "",
173
+		)
115174
 		if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
116175
 			errCh <- err
117176
 		}
@@ -134,7 +193,7 @@ func Run(ctx context.Context, opts Options) error {
134193
 		logger.Info("context canceled, shutting down")
135194
 	}
136195
 
137
-	shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
196
+	shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.Web.ShutdownTimeout)
138197
 	defer cancel()
139198
 	if err := srv.Shutdown(shutdownCtx); err != nil {
140199
 		return fmt.Errorf("shutdown: %w", err)
@@ -142,11 +201,13 @@ func Run(ctx context.Context, opts Options) error {
142201
 	return nil
143202
 }
144203
 
145
-// buildSessionStore constructs the cookie session store. The key comes from
146
-// SHITHUB_SESSION_KEY (base64 32-byte). When unset (dev), a random key is
147
-// generated and the operator is warned — sessions don't survive restart.
148
-func buildSessionStore(logger *slog.Logger) (session.Store, error) {
204
+// buildSessionStore constructs the cookie session store from the config's
205
+// session block. SHITHUB_SESSION_KEY (env) overrides cfg.KeyB64 when set.
206
+func buildSessionStore(cfg config.SessionConfig, logger *slog.Logger) (session.Store, error) {
149207
 	keyB64 := os.Getenv("SHITHUB_SESSION_KEY")
208
+	if keyB64 == "" {
209
+		keyB64 = cfg.KeyB64
210
+	}
150211
 	var key []byte
151212
 	if keyB64 != "" {
152213
 		decoded, err := base64.StdEncoding.DecodeString(keyB64)
@@ -165,35 +226,17 @@ func buildSessionStore(logger *slog.Logger) (session.Store, error) {
165226
 		}
166227
 		key = generated
167228
 		logger.Warn(
168
-			"session: SHITHUB_SESSION_KEY not set; generated an ephemeral key (sessions will not survive restart)",
169
-			"hint", "set SHITHUB_SESSION_KEY=<base64 32-byte key> in production",
229
+			"session: no key configured; generated an ephemeral key (sessions will not survive restart)",
230
+			"hint", "set SHITHUB_SESSION_KEY=<base64 32-byte> or session.key_b64 in production",
170231
 		)
171232
 	}
172233
 	store, err := session.NewCookieStore(session.CookieStoreConfig{
173234
 		Key:    key,
174
-		Secure: false, // S37 deploy enables this under TLS
235
+		MaxAge: cfg.MaxAge,
236
+		Secure: cfg.Secure,
175237
 	})
176238
 	if err != nil {
177239
 		return nil, fmt.Errorf("session: build store: %w", err)
178240
 	}
179241
 	return store, nil
180242
 }
181
-
182
-// pgxpoolHandle adapts *pgxpool.Pool's lifecycle to the small interface
183
-// /readyz needs. Defined here (not in the db package) so internal/web stays
184
-// the boundary that owns runtime wiring.
185
-type pgxpoolHandle struct {
186
-	p interface {
187
-		Close()
188
-	}
189
-}
190
-
191
-func (h *pgxpoolHandle) healthcheck(ctx context.Context) error {
192
-	type pinger interface {
193
-		Ping(context.Context) error
194
-	}
195
-	if p, ok := h.p.(pinger); ok {
196
-		return p.Ping(ctx)
197
-	}
198
-	return nil
199
-}