Implement structured logging and enhance middleware with context-aware logging

This commit is contained in:
Christian Galo 2025-04-27 03:03:57 -05:00
parent bfdf7bf7d2
commit 2e4b2aba45
5 changed files with 186 additions and 40 deletions

View File

@ -1,11 +1,14 @@
package cmd package cmd
import ( import (
"log" "context"
"log/slog"
"net"
"net/http" "net/http"
"time" "time"
"git.coopcloud.tech/wiki-cafe/member-console/internal/auth" "git.coopcloud.tech/wiki-cafe/member-console/internal/auth"
"git.coopcloud.tech/wiki-cafe/member-console/internal/logging"
"git.coopcloud.tech/wiki-cafe/member-console/internal/middleware" "git.coopcloud.tech/wiki-cafe/member-console/internal/middleware"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/viper" "github.com/spf13/viper"
@ -20,8 +23,18 @@ var startCmd = &cobra.Command{
The server listens on port 8080 by default, unless a different port is specified using the --port flag.`, The server listens on port 8080 by default, unless a different port is specified using the --port flag.`,
Args: cobra.NoArgs, Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
// Retrieve the port value from Viper // Create base context for the application
ctx := context.Background()
// Retrieve the configuration values from Viper
port := viper.GetString("port") port := viper.GetString("port")
env := viper.GetString("env")
// Set up structured logging
logger := logging.SetupLogger(env)
// Store logger in context
ctx = logging.WithContext(ctx, logger)
// Create a new HTTP request router // Create a new HTTP request router
httpRequestRouter := http.NewServeMux() httpRequestRouter := http.NewServeMux()
@ -29,7 +42,8 @@ var startCmd = &cobra.Command{
// Set up authentication // Set up authentication
authConfig, err := auth.Setup() authConfig, err := auth.Setup()
if err != nil { if err != nil {
log.Fatalf("Failed to set up authentication: %v", err) logger.Error("failed to set up authentication", slog.Any("error", err))
return
} }
// Register auth handlers // Register auth handlers
@ -42,7 +56,7 @@ var startCmd = &cobra.Command{
middleware.RequestID(), // Generate a unique request ID middleware.RequestID(), // Generate a unique request ID
middleware.MaxBodySize(1024*1024), // 1MB size limit middleware.MaxBodySize(1024*1024), // 1MB size limit
middleware.SecureHeaders, // Set secure headers middleware.SecureHeaders, // Set secure headers
middleware.Logging, // Log requests middleware.Logging, // Log requests with structured logging
authConfig.Middleware(), // OIDC authentication middleware authConfig.Middleware(), // OIDC authentication middleware
) )
@ -54,12 +68,22 @@ var startCmd = &cobra.Command{
WriteTimeout: 4 * time.Second, WriteTimeout: 4 * time.Second,
IdleTimeout: 8 * time.Second, IdleTimeout: 8 * time.Second,
MaxHeaderBytes: 1024 * 1024, // 1MB MaxHeaderBytes: 1024 * 1024, // 1MB
BaseContext: func(_ net.Listener) context.Context { return ctx }, // Pass base context to all requests
} }
// Serve the components directory // Serve the components directory
httpRequestRouter.Handle("/", http.FileServer(http.Dir("./components"))) httpRequestRouter.Handle("/", http.FileServer(http.Dir("./components")))
log.Println("Starting server on port", port)
log.Fatal(server.ListenAndServe()) // Log server startup with structured logging
logger.Info("starting server",
slog.String("port", port),
slog.String("environment", env),
slog.String("address", "http://localhost:"+port))
// Start server and log any errors
if err := server.ListenAndServe(); err != nil {
logger.Error("server error", slog.Any("error", err))
}
}, },
} }

View File

@ -0,0 +1,85 @@
package logging
import (
"context"
"fmt"
"log/slog"
"os"
"time"
)
// loggerKey is the context key for logger values
type loggerKey struct{}
// AppLogger is the application-wide logger instance
var AppLogger *slog.Logger
// SetupLogger initializes the structured logger based on environment settings
func SetupLogger(env string) *slog.Logger {
// Determine log level
logLevel := slog.LevelInfo
if levelStr := os.Getenv("LOG_LEVEL"); levelStr != "" {
if err := logLevel.UnmarshalText([]byte(levelStr)); err != nil {
panic(fmt.Sprintf("invalid log level: %s", levelStr))
}
}
var handler slog.Handler
// Configure handler based on environment
if env == "development" {
// In development, use text output with source information if in debug mode
opts := &slog.HandlerOptions{
Level: logLevel,
AddSource: logLevel == slog.LevelDebug,
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
// Format time for better readability in dev mode
if a.Key == slog.TimeKey {
if t, ok := a.Value.Any().(time.Time); ok {
return slog.String(slog.TimeKey, t.Format(time.RFC3339))
}
}
return a
},
}
handler = slog.NewTextHandler(os.Stdout, opts)
} else {
// In production, use JSON output
handler = slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: logLevel,
})
}
// Create and set the default logger
logger := slog.New(handler)
slog.SetDefault(logger)
AppLogger = logger
logger.Info("logger initialized",
slog.String("environment", env),
slog.String("level", logLevel.String()))
return logger
}
// FromContext retrieves a logger from the given context
// If no logger exists in the context, returns the default logger
func FromContext(ctx context.Context) *slog.Logger {
if ctx == nil {
return AppLogger
}
if logger, ok := ctx.Value(loggerKey{}).(*slog.Logger); ok {
return logger
}
return AppLogger
}
// WithContext stores a logger in the given context
func WithContext(ctx context.Context, logger *slog.Logger) context.Context {
return context.WithValue(ctx, loggerKey{}, logger)
}
// WithValues returns a new logger with additional context values
func WithValues(logger *slog.Logger, attrs ...any) *slog.Logger {
return logger.With(attrs...)
}

View File

@ -1,9 +1,11 @@
package middleware package middleware
import ( import (
"log" "log/slog"
"net/http" "net/http"
"time" "time"
"git.coopcloud.tech/wiki-cafe/member-console/internal/logging"
) )
type wrappedWriter struct { type wrappedWriter struct {
@ -16,21 +18,48 @@ func (w *wrappedWriter) WriteHeader(statusCode int) {
w.statusCode = statusCode w.statusCode = statusCode
} }
// Logging is a middleware function that logs the request method, URL path, and status code // Logging is a middleware function that logs requests with structured logging
func Logging(next http.Handler) http.Handler { func Logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now() start := time.Now()
// Prepare wrapped writer to capture status code
wrapped := &wrappedWriter{ wrapped := &wrappedWriter{
ResponseWriter: w, ResponseWriter: w,
statusCode: http.StatusOK, statusCode: http.StatusOK,
} }
// Get request ID from header (set by RequestID middleware) // Get request ID from context
requestID := r.Header.Get(RequestIDHeader) requestID := GetRequestID(r.Context())
// Get logger from the application and add request information
logger := logging.FromContext(r.Context())
reqLogger := logging.WithValues(logger,
slog.String("request_id", requestID),
slog.String("method", r.Method),
slog.String("path", r.URL.Path),
slog.String("remote_ip", r.RemoteAddr),
slog.String("user_agent", r.UserAgent()),
)
// Store the request-specific logger in context
ctx := logging.WithContext(r.Context(), reqLogger)
r = r.WithContext(ctx)
// Log request start if in debug mode
reqLogger.Debug("request started")
// Process the request with updated context
next.ServeHTTP(wrapped, r) next.ServeHTTP(wrapped, r)
log.Printf("[%s] %d %s %s %v", requestID, wrapped.statusCode, r.Method, r.URL.Path, time.Since(start)) // Calculate duration
duration := time.Since(start)
// Log request completion with status and duration
reqLogger.Info("request completed",
slog.Int("status", wrapped.statusCode),
slog.Duration("duration", duration),
slog.String("duration_human", duration.String()),
)
}) })
} }

View File

@ -1,23 +1,36 @@
package middleware package middleware
import ( import (
"log" "log/slog"
"net/http" "net/http"
"runtime/debug" "runtime/debug"
"git.coopcloud.tech/wiki-cafe/member-console/internal/logging"
) )
// Recovery middleware to catch panics, log them, and return a 500 error // Recovery middleware catches panics and logs them with structured logging
func Recovery() Middleware { func Recovery() Middleware {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() { defer func() {
if err := recover(); err != nil { if err := recover(); err != nil {
// Log the stack trace // Get logger from context
log.Printf("PANIC: %v\n%s", err, debug.Stack()) logger := logging.FromContext(r.Context())
// Return a 500 Internal Server Error response // Get request ID from context
w.WriteHeader(http.StatusInternalServerError) requestID := GetRequestID(r.Context())
w.Write([]byte("Internal Server Error"))
// Log the panic with stack trace
logger.Error("panic recovered",
slog.String("request_id", requestID),
slog.String("method", r.Method),
slog.String("path", r.URL.Path),
slog.Any("error", err),
slog.String("stack", string(debug.Stack())),
)
// Return 500 Internal Server Error to the client
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
} }
}() }()

View File

@ -2,16 +2,15 @@ package middleware
import ( import (
"context" "context"
"log"
"net/http" "net/http"
"github.com/google/uuid" "github.com/google/uuid"
) )
// RequestIDKey is the context key for the request ID // requestIDKey is the context key for request ID
type requestIDKey struct{} type requestIDKey struct{}
// RequestIDHeader is the header key for the request ID // RequestIDHeader is the HTTP header for request ID
const RequestIDHeader = "X-Request-ID" const RequestIDHeader = "X-Request-ID"
// RequestID middleware generates a unique ID for each request // RequestID middleware generates a unique ID for each request
@ -21,30 +20,26 @@ func RequestID() Middleware {
// Check if request already has an ID // Check if request already has an ID
requestID := r.Header.Get(RequestIDHeader) requestID := r.Header.Get(RequestIDHeader)
if requestID == "" { if requestID == "" {
// Generate a new UUID if none exists // Generate a new UUID for the request if not present
requestID = uuid.New().String() requestID = uuid.New().String()
} }
// Add the request ID to the response headers // Add request ID to response headers
w.Header().Set(RequestIDHeader, requestID) w.Header().Set(RequestIDHeader, requestID)
// Store the request ID in the request context // Store request ID in context
ctx := context.WithValue(r.Context(), requestIDKey{}, requestID) ctx := context.WithValue(r.Context(), requestIDKey{}, requestID)
// Log the request with its ID // Call next handler with updated context
log.Printf("[%s] %s %s", requestID, r.Method, r.URL.Path)
// Call the next handler with the updated context
next.ServeHTTP(w, r.WithContext(ctx)) next.ServeHTTP(w, r.WithContext(ctx))
}) })
} }
} }
// GetRequestID retrieves the request ID from the context // GetRequestID retrieves the request ID from context
func GetRequestID(ctx context.Context) string { func GetRequestID(ctx context.Context) string {
id, ok := ctx.Value(requestIDKey{}).(string) if id, ok := ctx.Value(requestIDKey{}).(string); ok {
if !ok {
return ""
}
return id return id
} }
return ""
}