package dugong import ( "compress/gzip" "fmt" "io" "os" "path/filepath" "sync/atomic" "time" log "github.com/sirupsen/logrus" ) // RotationScheduler determines when files should be rotated. type RotationScheduler interface { // ShouldRotate returns true if the file should be rotated. The suffix to apply // to the filename is returned as the 2nd arg. ShouldRotate() (bool, string) // ShouldGZip returns true if the file should be gzipped when it is rotated. ShouldGZip() bool } // DailyRotationSchedule rotates log files daily. Logs are only rotated // when midnight passes *whilst the process is running*. E.g: if you run // the process on Day 4 then stop it and start it on Day 7, no rotation will // occur when the process starts. type DailyRotationSchedule struct { GZip bool rotateAfter *time.Time } var currentTime = time.Now // exclusively for testing func dayOffset(t time.Time, offsetDays int) time.Time { // GoDoc: // The month, day, hour, min, sec, and nsec values may be outside their // usual ranges and will be normalized during the conversion. // For example, October 32 converts to November 1. return time.Date( t.Year(), t.Month(), t.Day()+offsetDays, 0, 0, 0, 0, t.Location(), ) } // ShouldRotate compares the current time with the rotation schedule. // If the rotation should occur, returns (true, suffix) where suffix is the // suffix for the rotated file. Else, returns (false, "") func (rs *DailyRotationSchedule) ShouldRotate() (bool, string) { now := currentTime() if rs.rotateAfter == nil { nextRotate := dayOffset(now, 1) rs.rotateAfter = &nextRotate return false, "" } if now.After(*rs.rotateAfter) { // the suffix should be actually the date of the complete day being logged actualDay := dayOffset(*rs.rotateAfter, -1) suffix := "." + actualDay.Format("2006-01-02") // YYYY-MM-DD nextRotate := dayOffset(now, 1) rs.rotateAfter = &nextRotate return true, suffix } return false, "" } func (rs *DailyRotationSchedule) ShouldGZip() bool { return rs.GZip } // NewFSHook makes a logging hook that writes formatted // log entries to info, warn and error log files. Each log file // contains the messages with that severity or higher. If a formatter is // not specified, they will be logged using a JSON formatter. If a // RotationScheduler is set, the files will be cycled according to its rules. func NewFSHook(path string, formatter log.Formatter, rotSched RotationScheduler) log.Hook { if formatter == nil { formatter = &log.JSONFormatter{} } hook := &fsHook{ entries: make(chan log.Entry, 1024), path: path, formatter: formatter, scheduler: rotSched, } go func() { for entry := range hook.entries { if err := hook.writeEntry(&entry); err != nil { fmt.Fprintf(os.Stderr, "Error writing to logfile: %v\n", err) } atomic.AddInt32(&hook.queueSize, -1) } }() return hook } type fsHook struct { entries chan log.Entry queueSize int32 path string formatter log.Formatter scheduler RotationScheduler } func (hook *fsHook) Fire(entry *log.Entry) error { atomic.AddInt32(&hook.queueSize, 1) hook.entries <- *entry return nil } func (hook *fsHook) writeEntry(entry *log.Entry) error { msg, err := hook.formatter.Format(entry) if err != nil { return nil } if hook.scheduler != nil { if should, suffix := hook.scheduler.ShouldRotate(); should { if err := hook.rotate(suffix, hook.scheduler.ShouldGZip()); err != nil { return err } } } if err := logToFile(hook.path, msg); err != nil { return err } return nil } func (hook *fsHook) Levels() []log.Level { return []log.Level{ log.PanicLevel, log.FatalLevel, log.ErrorLevel, log.WarnLevel, log.InfoLevel, log.DebugLevel, } } // rotate all the log files to the given suffix. // If error path is "err.log" and suffix is "1" then move // the contents to "err.log1". // This requires no locking as the goroutine calling this is the same // one which does the logging. Since we don't hold open a handle to the // file when writing, a simple Rename is all that is required. func (hook *fsHook) rotate(suffix string, gzip bool) error { logFilePath := hook.path + suffix if err := os.Rename(hook.path, logFilePath); err != nil { // e.g. because there were no errors in error.log for this day fmt.Fprintf(os.Stderr, "Error rotating file %s: %v\n", hook.path, err) } else if gzip { // Don't try to gzip if we failed to rotate if err := gzipFile(logFilePath); err != nil { fmt.Fprintf(os.Stderr, "Failed to gzip file %s: %v\n", logFilePath, err) } } return nil } func logToFile(path string, msg []byte) error { fd, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0600) if err != nil { return err } defer fd.Close() _, err = fd.Write(msg) return err } func gzipFile(fpath string) error { reader, err := os.Open(fpath) if err != nil { return err } filename := filepath.Base(fpath) target := filepath.Join(filepath.Dir(fpath), filename+".gz") writer, err := os.Create(target) if err != nil { return err } defer writer.Close() archiver := gzip.NewWriter(writer) archiver.Name = filename defer archiver.Close() _, err = io.Copy(archiver, reader) return err }