diff --git a/.env.example b/.env.example index a9adfee..cb45207 100644 --- a/.env.example +++ b/.env.example @@ -25,3 +25,7 @@ SEND_WELCOME_EMAIL=false # Registration Control REGISTRATION_MODE=open + +# Logging Configuration +LOG_LEVEL=info +ENVIRONMENT=production diff --git a/cmd/api/main.go b/cmd/api/main.go index f792011..7677428 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -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") } } diff --git a/go.mod b/go.mod index c702ace..6a4c9bc 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index c7c0ac7..61a6f33 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/infrastructure/config/config.go b/internal/infrastructure/config/config.go index 9546e9b..1365fc3 100644 --- a/internal/infrastructure/config/config.go +++ b/internal/infrastructure/config/config.go @@ -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 == "" { diff --git a/internal/infrastructure/http/auth_middleware.go b/internal/infrastructure/http/auth_middleware.go index 11c7084..a89dacd 100644 --- a/internal/infrastructure/http/auth_middleware.go +++ b/internal/infrastructure/http/auth_middleware.go @@ -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)) }) } diff --git a/internal/infrastructure/http/router.go b/internal/infrastructure/http/router.go index 19fbf1b..b3cd02d 100644 --- a/internal/infrastructure/http/router.go +++ b/internal/infrastructure/http/router.go @@ -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{ diff --git a/internal/infrastructure/logger/logger.go b/internal/infrastructure/logger/logger.go new file mode 100644 index 0000000..b17b8e9 --- /dev/null +++ b/internal/infrastructure/logger/logger.go @@ -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() +} diff --git a/internal/infrastructure/logger/middleware.go b/internal/infrastructure/logger/middleware.go new file mode 100644 index 0000000..d9efa90 --- /dev/null +++ b/internal/infrastructure/logger/middleware.go @@ -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) +}