package logger import ( "bytes" "context" "fmt" "io" "os" "path/filepath" "runtime" "sort" "strings" "sync" "time" "github.com/sirupsen/logrus" ) // localLoc adalah zona waktu yang dipakai untuk seluruh log timestamp dan rotasi // file harian. Default-nya Asia/Jakarta (WIB +07:00); fallback ke offset tetap // jika data tzdata tidak tersedia di sistem. var localLoc = func() *time.Location { if loc, err := time.LoadLocation("Asia/Jakarta"); err == nil { return loc } return time.FixedZone("WIB", 7*3600) }() // localTimeHook mengubah entry.Time ke zona waktu lokal sebelum formatter dipanggil, // sehingga timestamp di JSON formatter maupun customFormatter sama-sama WIB. type localTimeHook struct{ loc *time.Location } func (h *localTimeHook) Levels() []logrus.Level { return logrus.AllLevels } func (h *localTimeHook) Fire(entry *logrus.Entry) error { entry.Time = entry.Time.In(h.loc) return nil } // LocalLocation mengembalikan zona waktu lokal yang dipakai logger (Asia/Jakarta). // Berguna ketika kode lain perlu memformat tanggal/waktu mengikuti zona yang sama. func LocalLocation() *time.Location { return localLoc } // LocalNow mengembalikan waktu sekarang dalam zona waktu lokal (WIB). Pakai ini // daripada time.Now() ketika hasilnya dipakai untuk nama file harian/bulanan agar // pergantian hari sesuai jam lokal, bukan UTC. func LocalNow() time.Time { return time.Now().In(localLoc) } // Logger interface mendefinisikan kontrak untuk logging type Logger interface { Debug(message string, fields ...Field) Info(message string, fields ...Field) Warn(message string, fields ...Field) Error(message string, fields ...Field) Fatal(message string, fields ...Field) WithContext(ctx context.Context) Logger WithFields(fields ...Field) Logger WithError(err error) Logger } // Field mendefinisikan struktur data untuk logging fields type Field struct { Key string Value interface{} } // Helper functions untuk membuat Field func String(key, value string) Field { return Field{Key: key, Value: value} } func Int(key string, value int) Field { return Field{Key: key, Value: value} } func Int64(key string, value int64) Field { return Field{Key: key, Value: value} } func Float64(key string, value float64) Field { return Field{Key: key, Value: value} } func Bool(key string, value bool) Field { return Field{Key: key, Value: value} } func ErrorField(err error) Field { return Field{Key: "error", Value: err} } func Duration(key string, value time.Duration) Field { return Field{Key: key, Value: value} } func Time(key string, value time.Time) Field { return Field{Key: key, Value: value} } func Any(key string, value interface{}) Field { return Field{Key: key, Value: value} } // Config menyimpan konfigurasi untuk logger type Config struct { Level string Format string Output string FilePath string MaxSize int MaxBackups int MaxAge int Compress bool EnableCaller bool EnableStackTrace bool ServiceName string Environment string } // DefaultConfig mengembalikan konfigurasi default func DefaultConfig() Config { return Config{ Level: "info", Format: "json", // Format JSON menjadi default Output: "both", // Default langsung di set ke 'both' agar console & file aktif ServiceName: "service-general", Environment: "development", MaxSize: 100, // MB MaxBackups: 3, MaxAge: 7, // days Compress: true, } } type loggerImpl struct { entry *logrus.Entry enableCaller bool mu sync.RWMutex } var ( defaultLogger Logger once sync.Once ) // Default mengembalikan logger default func Default() Logger { if defaultLogger == nil { Init(DefaultConfig()) } return defaultLogger } // New membuat instance logger baru yang independen (Ideal untuk Domain spesifik / CQRS) func New(cfg Config) Logger { logrusLogger := logrus.New() // Set log level level, err := logrus.ParseLevel(cfg.Level) if err != nil { level = logrus.InfoLevel } logrusLogger.SetLevel(level) // Set formatter switch cfg.Format { case "json": logrusLogger.SetFormatter(&logrus.JSONFormatter{ TimestampFormat: time.RFC3339, DisableHTMLEscape: true, PrettyPrint: false, }) case "text": logrusLogger.SetFormatter(&customFormatter{}) default: logrusLogger.SetFormatter(&customFormatter{}) } // Set output var output io.Writer switch cfg.Output { case "file": output = newDailyFileWriter("logs") case "both": fileWriter := newDailyFileWriter("logs") output = io.MultiWriter(os.Stdout, fileWriter) default: output = os.Stdout } logrusLogger.SetOutput(output) // Pasang hook agar semua entry.Time di-convert ke WIB sebelum formatter dipanggil. logrusLogger.AddHook(&localTimeHook{loc: localLoc}) // Set default fields entry := logrusLogger.WithFields(logrus.Fields{ "service": cfg.ServiceName, "environment": cfg.Environment, }) return &loggerImpl{ entry: entry, enableCaller: cfg.EnableCaller, } } // Init menginisialisasi logger global (default) func Init(cfg Config) Logger { once.Do(func() { defaultLogger = New(cfg) }) return defaultLogger } func (l *loggerImpl) WithContext(ctx context.Context) Logger { if ctx == nil { return l } l.mu.RLock() defer l.mu.RUnlock() fields := make(logrus.Fields) // Extract standard identifiers automatically keys := []string{"trace_id", "request_id", "correlation_id", "user_id"} for _, key := range keys { if val := ctx.Value(key); val != nil { fields[key] = val } } return &loggerImpl{ entry: l.entry.WithFields(fields), enableCaller: l.enableCaller, } } func (l *loggerImpl) WithFields(fields ...Field) Logger { l.mu.RLock() defer l.mu.RUnlock() logrusFields := make(logrus.Fields) for _, field := range fields { logrusFields[field.Key] = field.Value } return &loggerImpl{ entry: l.entry.WithFields(logrusFields), enableCaller: l.enableCaller, } } func (l *loggerImpl) WithError(err error) Logger { if err == nil { return l // Mencegah Panic bila log dipanggil dengan err == nil } l.mu.RLock() defer l.mu.RUnlock() fields := logrus.Fields{ "error": err.Error(), } // Add stack trace if available if stackErr, ok := err.(interface{ StackTrace() []string }); ok { fields["stack_trace"] = stackErr.StackTrace() } return &loggerImpl{ entry: l.entry.WithFields(fields), enableCaller: l.enableCaller, } } func (l *loggerImpl) log(level logrus.Level, message string, fields ...Field) { l.mu.Lock() defer l.mu.Unlock() entry := l.entry // Add caller information (hanya jika EnableCaller dikonfigurasi aktif) if l.enableCaller { if pc, file, line, ok := runtime.Caller(2); ok { funcName := runtime.FuncForPC(pc).Name() entry = entry.WithFields(logrus.Fields{ "caller": fmt.Sprintf("%s:%d", filepath.Base(file), line), "function": funcName, }) } } // Convert fields to logrus fields if len(fields) > 0 { logrusFields := make(logrus.Fields) for _, field := range fields { logrusFields[field.Key] = field.Value } entry = entry.WithFields(logrusFields) } switch level { case logrus.DebugLevel: entry.Debug(message) case logrus.InfoLevel: entry.Info(message) case logrus.WarnLevel: entry.Warn(message) case logrus.ErrorLevel: entry.Error(message) case logrus.FatalLevel: entry.Fatal(message) } } func (l *loggerImpl) Debug(message string, fields ...Field) { l.log(logrus.DebugLevel, message, fields...) } func (l *loggerImpl) Info(message string, fields ...Field) { l.log(logrus.InfoLevel, message, fields...) } func (l *loggerImpl) Warn(message string, fields ...Field) { l.log(logrus.WarnLevel, message, fields...) } func (l *loggerImpl) Error(message string, fields ...Field) { l.log(logrus.ErrorLevel, message, fields...) } func (l *loggerImpl) Fatal(message string, fields ...Field) { l.log(logrus.FatalLevel, message, fields...) } // Helper functions untuk backward compatibility func fieldsToMap(fields ...Field) map[string]interface{} { result := make(map[string]interface{}) for _, field := range fields { result[field.Key] = field.Value } return result } // Global functions untuk backward compatibility func Debug(message string, fields map[string]interface{}) { logger := Default() var fieldSlice []Field for k, v := range fields { fieldSlice = append(fieldSlice, Any(k, v)) } logger.Debug(message, fieldSlice...) } func Info(message string, fields map[string]interface{}) { logger := Default() var fieldSlice []Field for k, v := range fields { fieldSlice = append(fieldSlice, Any(k, v)) } logger.Info(message, fieldSlice...) } func Warn(message string, fields map[string]interface{}) { logger := Default() var fieldSlice []Field for k, v := range fields { fieldSlice = append(fieldSlice, Any(k, v)) } logger.Warn(message, fieldSlice...) } func Error(message string, fields map[string]interface{}) { logger := Default() var fieldSlice []Field for k, v := range fields { fieldSlice = append(fieldSlice, Any(k, v)) } logger.Error(message, fieldSlice...) } func Fatal(message string, fields map[string]interface{}) { logger := Default() var fieldSlice []Field for k, v := range fields { fieldSlice = append(fieldSlice, Any(k, v)) } logger.Fatal(message, fieldSlice...) } // ============================================================================ // CUSTOM DAILY DIRECTORY WRITER // ============================================================================ // dailyFileWriter adalah custom io.Writer untuk menulis log ke dalam struktur folder bulanan (logs/YYYY/MM/YYYY-MM-DD.log) type dailyFileWriter struct { mu sync.Mutex basePath string currDate string file *os.File } func newDailyFileWriter(basePath string) *dailyFileWriter { if basePath == "" { basePath = "logs" } return &dailyFileWriter{basePath: basePath} } func (w *dailyFileWriter) Write(p []byte) (n int, err error) { w.mu.Lock() defer w.mu.Unlock() // Rotasi mengikuti zona waktu lokal (WIB) agar file harian berganti tepat // pada 00:00 WIB, bukan 00:00 UTC. now := time.Now().In(localLoc) dateStr := now.Format("2006-01-02") // Format harian (YYYY-MM-DD) // Cek jika hari berganti atau file belum terbuka if w.currDate != dateStr || w.file == nil { if w.file != nil { w.file.Close() } year := now.Format("2006") month := now.Format("01") // Buat struktur direktori logs/YYYY/MM dir := filepath.Join(w.basePath, year, month) if err := os.MkdirAll(dir, 0755); err != nil { return 0, err } // Buka / Buat file logs/YYYY/MM/YYYY-MM-DD.log filename := filepath.Join(dir, dateStr+".log") file, err := os.OpenFile(filename, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) if err != nil { return 0, err } w.file = file w.currDate = dateStr } return w.file.Write(p) } func (w *dailyFileWriter) Close() error { w.mu.Lock() defer w.mu.Unlock() if w.file != nil { return w.file.Close() } return nil } // ============================================================================ // CUSTOM LOG FORMATTER (READABLE TEXT FORMAT) // ============================================================================ type customFormatter struct{} func (f *customFormatter) Format(entry *logrus.Entry) ([]byte, error) { var b *bytes.Buffer if entry.Buffer != nil { b = entry.Buffer } else { b = &bytes.Buffer{} } timestamp := entry.Time.Format("2006-01-02 15:04:05.000") level := strings.ToUpper(entry.Level.String()) if len(level) > 5 { level = level[:5] } // 1. Base Format: [TIMESTAMP] [LEVEL] MESSAGE (Lebar di-fix agar sejajar) fmt.Fprintf(b, "[%s] [%-5s] %-55s", timestamp, level, entry.Message) // 2. Extract and Sort Keys agar log selalu konsisten urutannya keys := make([]string, 0, len(entry.Data)) for k := range entry.Data { keys = append(keys, k) } sort.Strings(keys) // 3. Append metadata dengan pembatas " | " for _, k := range keys { v := entry.Data[k] switch k { case "error": fmt.Fprintf(b, " | ❌ ERROR: %v", v) case "caller": fmt.Fprintf(b, " | 📍 %v", v) case "function": fmt.Fprintf(b, " | ⚙️ %v", v) default: fmt.Fprintf(b, " | %s: %v", k, v) } } b.WriteByte('\n') return b.Bytes(), nil }