Add structured logging with zerolog

- Implement zerolog logger package with configurable levels
- Add contextual logging middleware (request_id, user_id, method, path, status, duration)
- Support both JSON (production) and human-readable (development) formats
- Add LOG_LEVEL and ENVIRONMENT configuration variables
- Replace standard log calls with structured logger throughout application
- Integrate logger in HTTP router and auth middleware
This commit is contained in:
2025-12-04 23:22:05 +01:00
parent 7575853355
commit 29f0f9b468
9 changed files with 225 additions and 16 deletions
+4
View File
@@ -23,6 +23,8 @@ type Config struct {
SupportEmail string
SendWelcomeEmail string
RegistrationMode string
LogLevel string
Environment string
}
func Load() (*Config, error) {
@@ -44,6 +46,8 @@ func Load() (*Config, error) {
SupportEmail: getEnvOrDefault("SUPPORT_EMAIL", "contact@apocapoc.app"),
SendWelcomeEmail: getEnvOrDefault("SEND_WELCOME_EMAIL", "false"),
RegistrationMode: getEnvOrDefault("REGISTRATION_MODE", "open"),
LogLevel: getEnvOrDefault("LOG_LEVEL", "info"),
Environment: getEnvOrDefault("ENVIRONMENT", "production"),
}
if cfg.DBPath == "" {
@@ -6,6 +6,7 @@ import (
"strings"
"apocapoc-api/internal/infrastructure/auth"
"apocapoc-api/internal/infrastructure/logger"
)
type contextKey string
@@ -35,6 +36,7 @@ func AuthMiddleware(jwtService *auth.JWTService) func(http.Handler) http.Handler
}
ctx := context.WithValue(r.Context(), UserIDKey, claims.UserID)
ctx = logger.AddUserID(ctx, claims.UserID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
+2 -1
View File
@@ -6,6 +6,7 @@ import (
"apocapoc-api/internal/i18n"
"apocapoc-api/internal/infrastructure/auth"
"apocapoc-api/internal/infrastructure/logger"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
@@ -19,7 +20,7 @@ import (
func NewRouter(appURL string, habitHandlers *HabitHandlers, authHandlers *AuthHandlers, statsHandlers *StatsHandlers, healthHandlers *HealthHandlers, userHandlers *UserHandlers, exportHandlers *ExportHandlers, jwtService *auth.JWTService, translator *i18n.Translator) *chi.Mux {
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(logger.Middleware)
r.Use(middleware.Recoverer)
r.Use(i18n.LanguageMiddleware(translator))
r.Use(cors.Handler(cors.Options{
+90
View File
@@ -0,0 +1,90 @@
package logger
import (
"io"
"os"
"strings"
"time"
"github.com/rs/zerolog"
"github.com/rs/zerolog/pkgerrors"
)
var Log zerolog.Logger
type Config struct {
Level string
Environment string
}
func Init(config Config) {
zerolog.ErrorStackMarshaler = pkgerrors.MarshalStack
zerolog.TimeFieldFormat = time.RFC3339
level := parseLogLevel(config.Level)
zerolog.SetGlobalLevel(level)
var output io.Writer = os.Stdout
if config.Environment == "development" {
output = zerolog.ConsoleWriter{
Out: os.Stdout,
TimeFormat: "15:04:05",
NoColor: false,
}
}
Log = zerolog.New(output).
With().
Timestamp().
Caller().
Logger()
Log.Info().
Str("level", level.String()).
Str("environment", config.Environment).
Msg("Logger initialized")
}
func parseLogLevel(level string) zerolog.Level {
switch strings.ToLower(level) {
case "debug":
return zerolog.DebugLevel
case "info":
return zerolog.InfoLevel
case "warn", "warning":
return zerolog.WarnLevel
case "error":
return zerolog.ErrorLevel
case "fatal":
return zerolog.FatalLevel
case "panic":
return zerolog.PanicLevel
default:
return zerolog.InfoLevel
}
}
func Debug() *zerolog.Event {
return Log.Debug()
}
func Info() *zerolog.Event {
return Log.Info()
}
func Warn() *zerolog.Event {
return Log.Warn()
}
func Error() *zerolog.Event {
return Log.Error()
}
func Fatal() *zerolog.Event {
return Log.Fatal()
}
func With() zerolog.Context {
return Log.With()
}
@@ -0,0 +1,90 @@
package logger
import (
"context"
"net/http"
"time"
"github.com/google/uuid"
"github.com/rs/zerolog"
)
type contextKey string
const (
RequestIDKey contextKey = "request_id"
UserIDKey contextKey = "user_id"
)
type responseWriter struct {
http.ResponseWriter
status int
size int
}
func (rw *responseWriter) WriteHeader(status int) {
rw.status = status
rw.ResponseWriter.WriteHeader(status)
}
func (rw *responseWriter) Write(b []byte) (int, error) {
size, err := rw.ResponseWriter.Write(b)
rw.size += size
return size, err
}
func Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
requestID := uuid.New().String()
ctx := context.WithValue(r.Context(), RequestIDKey, requestID)
logger := Log.With().
Str("request_id", requestID).
Str("method", r.Method).
Str("path", r.URL.Path).
Str("remote_addr", r.RemoteAddr).
Str("user_agent", r.UserAgent()).
Logger()
ctx = logger.WithContext(ctx)
r = r.WithContext(ctx)
rw := &responseWriter{
ResponseWriter: w,
status: http.StatusOK,
}
next.ServeHTTP(rw, r)
duration := time.Since(start)
event := logger.Info()
if rw.status >= 400 && rw.status < 500 {
event = logger.Warn()
} else if rw.status >= 500 {
event = logger.Error()
}
event.
Int("status", rw.status).
Int("size", rw.size).
Dur("duration", duration).
Msg("HTTP request")
})
}
func FromContext(ctx context.Context) *zerolog.Logger {
logger := zerolog.Ctx(ctx)
if logger == nil || logger.GetLevel() == zerolog.Disabled {
return &Log
}
return logger
}
func AddUserID(ctx context.Context, userID string) context.Context {
logger := FromContext(ctx)
updatedLogger := logger.With().Str("user_id", userID).Logger()
return updatedLogger.WithContext(ctx)
}