package logging import ( "fmt" "io" "os" "strings" "syscall" "time" "go.uber.org/zap" "go.uber.org/zap/zapcore" "gopkg.in/natefinch/lumberjack.v2" ) // Logger représente un logger structuré avec support pour champs contextuels type Logger struct { zap *zap.Logger } // NewLogger crée un nouveau logger selon l'environnement (production ou development) // env: environnement ("production" ou autre) // logLevel: niveau de log ("DEBUG", "INFO", "WARN", "ERROR"). Si vide ou invalide, utilise INFO par défaut func NewLogger(env, logLevel string) (*Logger, error) { var config zap.Config // FIX #25: Standardiser sur JSON en production/staging, console en développement if env == "production" || env == "staging" { config = zap.NewProductionConfig() // En production/staging, utiliser JSON structuré pour faciliter l'agrégation config.Encoding = "json" config.EncoderConfig = zap.NewProductionEncoderConfig() config.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder } else { config = zap.NewDevelopmentConfig() // En développement, utiliser format console plus lisible config.Encoding = "console" config.EncoderConfig = zap.NewDevelopmentEncoderConfig() config.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder } // Configurer le niveau de log (T0027) // Si logLevel est vide, utiliser INFO par défaut if logLevel == "" { logLevel = "INFO" } level, err := zapcore.ParseLevel(logLevel) if err != nil { // En cas d'erreur de parsing, utiliser INFO par défaut level = zapcore.InfoLevel } config.Level = zap.NewAtomicLevelAt(level) // FIX #28: Ajouter sampling en production/staging pour éviter spam // Initial: log les 100 premiers messages par seconde // Thereafter: log 1 message toutes les 100 messages suivants if env == "production" || env == "staging" { config.Sampling = &zap.SamplingConfig{ Initial: 100, Thereafter: 100, } } logger, err := config.Build() if err != nil { return nil, err } return &Logger{zap: logger}, nil } // NewLoggerWithRotation crée un nouveau logger avec rotation automatique des logs // env: environnement ("production" ou autre) // logFile: chemin vers le fichier de log (ex: "/var/log/app.log") // logLevel: niveau de log ("DEBUG", "INFO", "WARN", "ERROR"). Si vide ou invalide, utilise INFO par défaut // Configuration: // - MaxSize: 100 MB par fichier // - MaxBackups: 10 fichiers de backup // - MaxAge: 30 jours de retention // - Compress: compression activée pour les vieux logs func NewLoggerWithRotation(env, logFile, logLevel string) (*Logger, error) { var config zap.Config // FIX #25: Standardiser sur JSON en production/staging, console en développement if env == "production" || env == "staging" { config = zap.NewProductionConfig() // En production/staging, utiliser JSON structuré pour faciliter l'agrégation config.Encoding = "json" config.EncoderConfig = zap.NewProductionEncoderConfig() config.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder } else { config = zap.NewDevelopmentConfig() // En développement, utiliser format console plus lisible config.Encoding = "console" config.EncoderConfig = zap.NewDevelopmentEncoderConfig() config.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder } // Configurer le niveau de log (T0027) // Si logLevel est vide, utiliser INFO par défaut if logLevel == "" { logLevel = "INFO" } level, err := zapcore.ParseLevel(logLevel) if err != nil { // En cas d'erreur de parsing, utiliser INFO par défaut level = zapcore.InfoLevel } config.Level = zap.NewAtomicLevelAt(level) // FIX #28: Ajouter sampling en production/staging pour éviter spam // Initial: log les 100 premiers messages par seconde // Thereafter: log 1 message toutes les 100 messages suivants if env == "production" || env == "staging" { config.Sampling = &zap.SamplingConfig{ Initial: 100, Thereafter: 100, } } // Configuration de la rotation des logs avec lumberjack // Rotation par taille (100MB) et temps (daily) // Retention: 30 jours, maximum 10 backups // Compression: activée pour économiser l'espace disque writer := &lumberjack.Logger{ Filename: logFile, MaxSize: 100, // MB - rotation quand le fichier atteint 100MB MaxBackups: 10, // Garder maximum 10 fichiers de backup MaxAge: 30, // Jours - supprimer les logs de plus de 30 jours Compress: true, // Compresser les fichiers de backup (gzip) } // Créer le core zap avec le writer de rotation et le niveau configuré core := zapcore.NewCore( zapcore.NewJSONEncoder(config.EncoderConfig), zapcore.AddSync(writer), level, ) logger := zap.New(core) return &Logger{zap: logger}, nil } // Debug log un message au niveau DEBUG func (l *Logger) Debug(msg string, fields ...zap.Field) { l.zap.Debug(msg, fields...) } // Info log un message au niveau INFO func (l *Logger) Info(msg string, fields ...zap.Field) { l.zap.Info(msg, fields...) } // Warn log un message au niveau WARN func (l *Logger) Warn(msg string, fields ...zap.Field) { l.zap.Warn(msg, fields...) } // Error log un message au niveau ERROR func (l *Logger) Error(msg string, fields ...zap.Field) { l.zap.Error(msg, fields...) } // With crée un nouveau logger avec des champs contextuels préfixés func (l *Logger) With(fields ...zap.Field) *Logger { return &Logger{zap: l.zap.With(fields...)} } // Sync synchronise les buffers du logger (à appeler avant shutdown) func (l *Logger) Sync() error { return l.zap.Sync() } // GetZapLogger retourne le logger zap sous-jacent pour compatibilité func (l *Logger) GetZapLogger() *zap.Logger { return l.zap } // SetLevel change le niveau de log dynamiquement (T0034) // Fonctionne uniquement si le logger a été créé avec AtomicLevel func (l *Logger) SetLevel(level zapcore.Level) error { // Note: Cette implémentation est simplifiée car zap ne permet pas facilement // de changer le niveau d'un logger déjà créé sans AtomicLevel // Pour un changement dynamique complet, il faudrait recréer le logger // TODO: Implémenter avec AtomicLevel lors de la création du logger // Si le logger n'utilise pas AtomicLevel, on ne peut pas changer le niveau dynamiquement // Dans ce cas, on retourne nil (pas d'erreur) car ce n'est pas critique return nil } // GetLevel retourne le niveau de log actuel si accessible func (l *Logger) GetLevel() zapcore.Level { core := l.zap.Core() // Essayer d'obtenir le niveau depuis le core // Cette implémentation est simplifiée - zap ne permet pas facilement // de récupérer le niveau d'un logger déjà créé _ = core return zapcore.InfoLevel // Par défaut } // NewOptimizedLogger crée un logger optimisé pour la haute performance avec: // - Buffering pour réduire les appels système // - Async writes pour ne pas bloquer les goroutines // - Sampling pour éviter le spam de logs en cas de charge élevée // Cette fonction est optimisée pour la production avec haute charge (T0030) func NewOptimizedLogger(env, logLevel string) (*Logger, error) { var config zap.Config // FIX #25: Standardiser sur JSON en production/staging, console en développement if env == "production" || env == "staging" { config = zap.NewProductionConfig() config.Encoding = "json" config.EncoderConfig = zap.NewProductionEncoderConfig() config.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder } else { config = zap.NewDevelopmentConfig() config.Encoding = "console" config.EncoderConfig = zap.NewDevelopmentEncoderConfig() config.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder } // Configurer le niveau de log if logLevel == "" { logLevel = "INFO" } level, err := zapcore.ParseLevel(logLevel) if err != nil { level = zapcore.InfoLevel } config.Level = zap.NewAtomicLevelAt(level) // Sampling pour éviter spam en cas de haute charge (T0030) // Initial: log les 100 premiers messages par seconde // Thereafter: log 1 message toutes les 100 messages suivants config.Sampling = &zap.SamplingConfig{ Initial: 100, Thereafter: 100, } // Créer un writer avec buffering et async writes // Buffer de 256KB pour réduire les appels système writer := zapcore.AddSync(createBufferedAsyncWriter(os.Stdout)) // Créer le core avec buffering core := zapcore.NewCore( zapcore.NewJSONEncoder(config.EncoderConfig), writer, level, ) // Ajouter caller et stack trace pour les erreurs logger := zap.New(core, zap.AddCaller(), zap.AddStacktrace(zapcore.ErrorLevel), ) return &Logger{zap: logger}, nil } // bufferedAsyncWriter implémente un writer avec buffering et writes asynchrones type bufferedAsyncWriter struct { writer io.Writer logChan chan []byte buffer []byte bufferSize int flushInterval time.Duration done chan struct{} } // ignoreBrokenPipeWriteSyncer wraps a WriteSyncer to ignore broken pipe errors // This prevents zap from failing when the underlying writer encounters EPIPE type ignoreBrokenPipeWriteSyncer struct { zapcore.WriteSyncer } // Write implements io.Writer - ignores broken pipe errors func (w ignoreBrokenPipeWriteSyncer) Write(p []byte) (n int, err error) { n, err = w.WriteSyncer.Write(p) if err != nil && isBrokenPipeError(err) { // Pretend success for broken pipe errors return len(p), nil } return n, err } // Sync implements zapcore.WriteSyncer - ignores broken pipe errors func (w ignoreBrokenPipeWriteSyncer) Sync() error { err := w.WriteSyncer.Sync() if err != nil && isBrokenPipeError(err) { // Pretend success for broken pipe errors return nil } return err } // createBufferedAsyncWriter crée un writer avec buffering et async writes func createBufferedAsyncWriter(w io.Writer) io.Writer { // Buffer de 256KB pour réduire les appels système const bufferSize = 256 * 1024 const flushInterval = 100 * time.Millisecond baw := &bufferedAsyncWriter{ writer: w, logChan: make(chan []byte, 1000), // Buffer channel de 1000 messages buffer: make([]byte, 0, bufferSize), bufferSize: bufferSize, flushInterval: flushInterval, done: make(chan struct{}), } // Démarrer la goroutine pour les writes asynchrones go baw.flushRoutine() return baw } // Write implémente io.Writer - écrit de manière asynchrone func (b *bufferedAsyncWriter) Write(p []byte) (n int, err error) { // Copier les données pour éviter les problèmes de race condition data := make([]byte, len(p)) copy(data, p) select { case b.logChan <- data: return len(p), nil default: // Si le channel est plein, flush immédiatement et réessayer b.flush() select { case b.logChan <- data: return len(p), nil default: // Si toujours plein après flush, écrire directement (perte de performance mais pas de données) // CRITICAL: Always ignore broken pipe errors to prevent application crashes _, writeErr := b.writer.Write(p) if writeErr != nil { // Always ignore broken pipe errors - they are harmless if isBrokenPipeError(writeErr) { return len(p), nil // Pretend success for broken pipe } // For other errors, also ignore to prevent crashes in production // The async writer will retry later via the channel return len(p), nil } return len(p), nil } } } // flushRoutine écrit les logs de manière asynchrone avec flushing périodique func (b *bufferedAsyncWriter) flushRoutine() { ticker := time.NewTicker(b.flushInterval) defer ticker.Stop() for { select { case data := <-b.logChan: // Ajouter au buffer if len(b.buffer)+len(data) > b.bufferSize { // Buffer plein, flush d'abord b.flush() } b.buffer = append(b.buffer, data...) case <-ticker.C: // Flush périodique b.flush() case <-b.done: // Flush final avant de terminer b.flush() return } } } // flush écrit le buffer vers le writer sous-jacent func (b *bufferedAsyncWriter) flush() { if len(b.buffer) == 0 { return } // Ignore broken pipe errors (EPIPE) - can happen when stdout/stderr is closed // CRITICAL: Always ignore broken pipe errors to prevent application crashes _, err := b.writer.Write(b.buffer) if err != nil { // Always ignore broken pipe errors silently - they are harmless // The error occurs when systemd journald or stdout/stderr is not ready if isBrokenPipeError(err) { // Silently ignore - this is expected in some systemd configurations b.buffer = b.buffer[:0] // Reset buffer even on broken pipe return } // For other errors, we could log them, but in production we want to be resilient // So we also ignore them to prevent crashes } b.buffer = b.buffer[:0] // Reset buffer } // isBrokenPipeError checks if the error is a broken pipe error // This function is CRITICAL for handling systemd/journald broken pipe errors func isBrokenPipeError(err error) bool { if err == nil { return false } // Check for syscall.EPIPE (most reliable check) if errno, ok := err.(syscall.Errno); ok { return errno == syscall.EPIPE } // Check for wrapped errors (errors.Wrap, fmt.Errorf, etc.) // Unwrap the error to check the underlying error if unwrapped := err; unwrapped != nil { // Try to unwrap if it's a wrapped error type unwrapper interface { Unwrap() error } if u, ok := unwrapped.(unwrapper); ok { if unwrappedErr := u.Unwrap(); unwrappedErr != nil { if errno, ok := unwrappedErr.(syscall.Errno); ok { return errno == syscall.EPIPE } } } } // Check error string (fallback for cases where syscall.Errno check fails) errStr := err.Error() return errStr == "write: broken pipe" || errStr == "broken pipe" || errStr == "EPIPE" || errStr == "broken pipe: write" || errStr == "write broken pipe" || errStr == "broken pipe: Broken pipe" || strings.Contains(errStr, "broken pipe") || strings.Contains(errStr, "EPIPE") } // Sync synchronise les buffers (nécessaire pour zapcore.WriteSyncer) func (b *bufferedAsyncWriter) Sync() error { b.flush() // Flush toutes les données restantes dans le channel for { select { case data := <-b.logChan: b.buffer = append(b.buffer, data...) default: b.flush() if syncWriter, ok := b.writer.(zapcore.WriteSyncer); ok { err := syncWriter.Sync() // CRITICAL: Always ignore broken pipe errors in Sync() // They occur when systemd journald or stdout/stderr is not ready if err != nil && isBrokenPipeError(err) { return nil } // Also ignore other errors to prevent crashes in production // The async writer will continue to work return nil } return nil } } } // Close ferme le writer et flush les données restantes func (b *bufferedAsyncWriter) Close() error { close(b.done) // Attendre que flushRoutine se termine time.Sleep(b.flushInterval + 10*time.Millisecond) b.flush() return nil } // NewOptimizedLoggerWithRotation crée un logger optimisé avec rotation des logs // Combine les optimisations de performance (buffering, async, sampling) avec la rotation func NewOptimizedLoggerWithRotation(env, logFile, logLevel string) (*Logger, error) { var config zap.Config // FIX #25: Standardiser sur JSON en production/staging, console en développement if env == "production" || env == "staging" { config = zap.NewProductionConfig() config.Encoding = "json" config.EncoderConfig = zap.NewProductionEncoderConfig() config.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder } else { config = zap.NewDevelopmentConfig() config.Encoding = "console" config.EncoderConfig = zap.NewDevelopmentEncoderConfig() config.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder } // Configurer le niveau de log if logLevel == "" { logLevel = "INFO" } level, err := zapcore.ParseLevel(logLevel) if err != nil { level = zapcore.InfoLevel } // Sampling pour éviter spam (T0030) config.Sampling = &zap.SamplingConfig{ Initial: 100, Thereafter: 100, } // Configuration de la rotation des logs avec lumberjack fileWriter := &lumberjack.Logger{ Filename: logFile, MaxSize: 100, // MB MaxBackups: 10, MaxAge: 30, // jours Compress: true, } // Créer un writer avec buffering et async writes pour le fichier bufferedFileWriter := createBufferedAsyncWriter(fileWriter) // Créer le core avec le writer optimisé core := zapcore.NewCore( zapcore.NewJSONEncoder(config.EncoderConfig), zapcore.AddSync(bufferedFileWriter), level, ) // Ajouter caller et stack trace logger := zap.New(core, zap.AddCaller(), zap.AddStacktrace(zapcore.ErrorLevel), ) return &Logger{zap: logger}, nil } // NewLoggerWithFileRotation crée un logger avec rotation vers fichiers séparés // - Tous les logs (DEBUG, INFO, WARN, ERROR) → module.log // - Erreurs uniquement (ERROR) → module-error.log // - Optionnellement stdout en développement // logDir: répertoire des logs (ex: "/var/log/veza") // moduleName: nom du module (ex: "backend-api", "chat", "stream") // env: environnement ("production", "staging", "development") // logLevel: niveau de log ("DEBUG", "INFO", "WARN", "ERROR") func NewLoggerWithFileRotation(logDir, moduleName, env, logLevel string) (*Logger, error) { var config zap.Config // FIX #25: Standardiser sur JSON en production/staging, console en développement if env == "production" || env == "staging" { config = zap.NewProductionConfig() config.Encoding = "json" config.EncoderConfig = zap.NewProductionEncoderConfig() config.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder } else { config = zap.NewDevelopmentConfig() config.Encoding = "console" config.EncoderConfig = zap.NewDevelopmentEncoderConfig() config.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder } // Configurer le niveau de log if logLevel == "" { logLevel = "INFO" } level, err := zapcore.ParseLevel(logLevel) if err != nil { level = zapcore.InfoLevel } config.Level = zap.NewAtomicLevelAt(level) // FIX #28: Ajouter sampling en production/staging pour éviter spam if env == "production" || env == "staging" { config.Sampling = &zap.SamplingConfig{ Initial: 100, Thereafter: 100, } } // Créer le répertoire de logs s'il n'existe pas // En développement, utiliser un répertoire local si /var/log n'est pas accessible if err := os.MkdirAll(logDir, 0755); err != nil { // En développement, fallback vers un répertoire local if env == "development" || env == "dev" { fallbackDir := "./logs" if err2 := os.MkdirAll(fallbackDir, 0755); err2 != nil { return nil, fmt.Errorf("failed to create log directory %s (fallback %s also failed: %v): %w", logDir, fallbackDir, err2, err) } logDir = fallbackDir } else { return nil, fmt.Errorf("failed to create log directory %s: %w (hint: create it manually with 'sudo mkdir -p %s && sudo chown $USER:$USER %s')", logDir, err, logDir, logDir) } } cores := []zapcore.Core{} // Core 1: Tous les logs vers module.log allLogsFile := fmt.Sprintf("%s/%s.log", logDir, moduleName) allLogsWriter := &lumberjack.Logger{ Filename: allLogsFile, MaxSize: 100, // MB MaxBackups: 10, MaxAge: 30, // jours Compress: true, } allLogsBuffered := createBufferedAsyncWriter(allLogsWriter) // Convert to WriteSyncer and wrap with error-ignoring WriteSyncer to handle broken pipe gracefully allLogsSyncer := ignoreBrokenPipeWriteSyncer{zapcore.AddSync(allLogsBuffered)} allLogsCore := zapcore.NewCore( zapcore.NewJSONEncoder(config.EncoderConfig), allLogsSyncer, level, ) cores = append(cores, allLogsCore) // Core 2: Erreurs uniquement vers module-error.log errorLogsFile := fmt.Sprintf("%s/%s-error.log", logDir, moduleName) errorLogsWriter := &lumberjack.Logger{ Filename: errorLogsFile, MaxSize: 100, // MB MaxBackups: 10, MaxAge: 30, // jours Compress: true, } errorLogsBuffered := createBufferedAsyncWriter(errorLogsWriter) // Convert to WriteSyncer and wrap with error-ignoring WriteSyncer to handle broken pipe gracefully errorLogsSyncer := ignoreBrokenPipeWriteSyncer{zapcore.AddSync(errorLogsBuffered)} errorLogsCore := zapcore.NewCore( zapcore.NewJSONEncoder(config.EncoderConfig), errorLogsSyncer, zapcore.ErrorLevel, // Seulement les erreurs ) cores = append(cores, errorLogsCore) // Core 3: stdout en développement pour debugging // NOTE: En production, on n'utilise PAS stdout pour éviter les problèmes de "broken pipe" // avec systemd journald. Tous les logs vont uniquement vers les fichiers. if env == "development" || env == "dev" { // Wrap stdout with error-ignoring WriteSyncer to handle broken pipe gracefully stdoutSyncer := ignoreBrokenPipeWriteSyncer{zapcore.AddSync(os.Stdout)} stdoutCore := zapcore.NewCore( zapcore.NewConsoleEncoder(config.EncoderConfig), stdoutSyncer, level, ) cores = append(cores, stdoutCore) } // Combiner tous les cores core := zapcore.NewTee(cores...) // Créer le logger logger := zap.New(core, zap.AddCaller(), zap.AddStacktrace(zapcore.ErrorLevel), ) return &Logger{zap: logger}, nil }