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
@@ -25,3 +25,7 @@ SEND_WELCOME_EMAIL=false
# Registration Control
REGISTRATION_MODE=open
# Logging Configuration
LOG_LEVEL=info
ENVIRONMENT=production
+13 -7
View File
@@ -16,6 +16,7 @@ import (
"apocapoc-api/internal/infrastructure/crypto"
"apocapoc-api/internal/infrastructure/email"
httpInfra "apocapoc-api/internal/infrastructure/http"
"apocapoc-api/internal/infrastructure/logger"
"apocapoc-api/internal/infrastructure/persistence/sqlite"
)
@@ -43,20 +44,25 @@ func main() {
log.Fatalf("Failed to load config: %v", err)
}
logger.Init(logger.Config{
Level: cfg.LogLevel,
Environment: cfg.Environment,
})
db, err := sqlite.NewDatabase(cfg.DBPath)
if err != nil {
log.Fatalf("Failed to connect to database: %v", err)
logger.Fatal().Err(err).Msg("Failed to connect to database")
}
defer db.Close()
jwtExpiryHours, err := parseJWTExpiry(cfg.JWTExpiry)
if err != nil {
log.Fatalf("Invalid JWT_EXPIRY: %v", err)
logger.Fatal().Err(err).Msg("Invalid JWT_EXPIRY")
}
refreshTokenExpiry, err := parseDuration(cfg.RefreshTokenExpiry)
if err != nil {
log.Fatalf("Invalid REFRESH_TOKEN_EXPIRY: %v", err)
logger.Fatal().Err(err).Msg("Invalid REFRESH_TOKEN_EXPIRY")
}
jwtService := auth.NewJWTService(cfg.JWTSecret, jwtExpiryHours)
@@ -66,7 +72,7 @@ func main() {
if cfg.SMTPHost != "" {
smtpPort, err := strconv.Atoi(cfg.SMTPPort)
if err != nil {
log.Fatalf("Invalid SMTP_PORT: %v", err)
logger.Fatal().Err(err).Msg("Invalid SMTP_PORT")
}
emailService = email.NewSMTPService(email.SMTPConfig{
@@ -89,7 +95,7 @@ func main() {
translator, err := i18n.NewTranslator()
if err != nil {
log.Fatalf("Failed to create translator: %v", err)
logger.Fatal().Err(err).Msg("Failed to create translator")
}
registerHandler := commands.NewRegisterUserHandler(userRepo, passwordHasher, emailService, cfg.AppURL, cfg.RegistrationMode, sendWelcomeEmail)
@@ -124,10 +130,10 @@ func main() {
router := httpInfra.NewRouter(cfg.AppURL, habitHandlers, authHandlers, statsHandlers, healthHandlers, userHandlers, exportHandlers, jwtService, translator)
addr := fmt.Sprintf("0.0.0.0:%s", cfg.Port)
log.Printf("Server starting on %s", addr)
logger.Info().Str("address", addr).Msg("Server starting")
if err := http.ListenAndServe(addr, router); err != nil {
log.Fatalf("Server failed: %v", err)
logger.Fatal().Err(err).Msg("Server failed")
}
}
+5 -2
View File
@@ -11,9 +11,12 @@ require (
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
github.com/rs/zerolog v1.34.0
github.com/swaggo/http-swagger v1.3.4
github.com/swaggo/swag v1.16.4
golang.org/x/crypto v0.45.0
golang.org/x/text v0.31.0
gopkg.in/mail.v2 v2.3.1
modernc.org/sqlite v1.40.1
)
@@ -27,17 +30,17 @@ require (
github.com/go-openapi/swag v0.19.15 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/mailru/easyjson v0.7.6 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.0 // indirect
golang.org/x/tools v0.38.0 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
gopkg.in/mail.v2 v2.3.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
modernc.org/libc v1.66.10 // indirect
modernc.org/mathutil v1.7.1 // indirect
+15 -6
View File
@@ -2,6 +2,7 @@ github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -24,6 +25,7 @@ github.com/go-openapi/spec v0.20.6/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
@@ -43,16 +45,25 @@ github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA=
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@@ -68,18 +79,18 @@ golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
@@ -87,8 +98,6 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
+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)
}