// package rotatelogs is a port of File-RotateLogs from Perl // (https://metacpan.org/release/File-RotateLogs), and it allows // you to automatically rotate output files when you write to them // according to the filename pattern that you can specify. package rotatelogs import ( "fmt" "io" "os" "path/filepath" "regexp" "strings" "sync" "time" strftime "github.com/lestrrat-go/strftime" "github.com/pkg/errors" ) func (c clockFn) Now() time.Time { return c() } // New creates a new RotateLogs object. A log filename pattern // must be passed. Optional `Option` parameters may be passed func New(p string, options ...Option) (*RotateLogs, error) { globPattern := p for _, re := range patternConversionRegexps { globPattern = re.ReplaceAllString(globPattern, "*") } pattern, err := strftime.New(p) if err != nil { return nil, errors.Wrap(err, `invalid strftime pattern`) } var clock Clock = Local rotationTime := 24 * time.Hour var rotationCount uint var linkName string var maxAge time.Duration var handler Handler var forceNewFile bool for _, o := range options { switch o.Name() { case optkeyClock: clock = o.Value().(Clock) case optkeyLinkName: linkName = o.Value().(string) case optkeyMaxAge: maxAge = o.Value().(time.Duration) if maxAge < 0 { maxAge = 0 } case optkeyRotationTime: rotationTime = o.Value().(time.Duration) if rotationTime < 0 { rotationTime = 0 } case optkeyRotationCount: rotationCount = o.Value().(uint) case optkeyHandler: handler = o.Value().(Handler) case optkeyForceNewFile: forceNewFile = true } } if maxAge > 0 && rotationCount > 0 { return nil, errors.New("options MaxAge and RotationCount cannot be both set") } if maxAge == 0 && rotationCount == 0 { // if both are 0, give maxAge a sane default maxAge = 7 * 24 * time.Hour } return &RotateLogs{ clock: clock, eventHandler: handler, globPattern: globPattern, linkName: linkName, maxAge: maxAge, pattern: pattern, rotationTime: rotationTime, rotationCount: rotationCount, forceNewFile: forceNewFile, }, nil } func (rl *RotateLogs) genFilename() string { now := rl.clock.Now() // XXX HACK: Truncate only happens in UTC semantics, apparently. // observed values for truncating given time with 86400 secs: // // before truncation: 2018/06/01 03:54:54 2018-06-01T03:18:00+09:00 // after truncation: 2018/06/01 03:54:54 2018-05-31T09:00:00+09:00 // // This is really annoying when we want to truncate in local time // so we hack: we take the apparent local time in the local zone, // and pretend that it's in UTC. do our math, and put it back to // the local zone var base time.Time if now.Location() != time.UTC { base = time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), now.Minute(), now.Second(), now.Nanosecond(), time.UTC) base = base.Truncate(time.Duration(rl.rotationTime)) base = time.Date(base.Year(), base.Month(), base.Day(), base.Hour(), base.Minute(), base.Second(), base.Nanosecond(), base.Location()) } else { base = now.Truncate(time.Duration(rl.rotationTime)) } return rl.pattern.FormatString(base) } // Write satisfies the io.Writer interface. It writes to the // appropriate file handle that is currently being used. // If we have reached rotation time, the target file gets // automatically rotated, and also purged if necessary. func (rl *RotateLogs) Write(p []byte) (n int, err error) { // Guard against concurrent writes rl.mutex.Lock() defer rl.mutex.Unlock() out, err := rl.getWriter_nolock(false, false) if err != nil { return 0, errors.Wrap(err, `failed to acquite target io.Writer`) } return out.Write(p) } // must be locked during this operation func (rl *RotateLogs) getWriter_nolock(bailOnRotateFail, useGenerationalNames bool) (io.Writer, error) { generation := rl.generation previousFn := rl.curFn // This filename contains the name of the "NEW" filename // to log to, which may be newer than rl.currentFilename baseFn := rl.genFilename() filename := baseFn var forceNewFile bool if baseFn != rl.curBaseFn { generation = 0 // even though this is the first write after calling New(), // check if a new file needs to be created if rl.forceNewFile { forceNewFile = true } } else { if !useGenerationalNames { // nothing to do return rl.outFh, nil } forceNewFile = true generation++ } if forceNewFile { // A new file has been requested. Instead of just using the // regular strftime pattern, we create a new file name using // generational names such as "foo.1", "foo.2", "foo.3", etc var name string for { if generation == 0 { name = filename } else { name = fmt.Sprintf("%s.%d", filename, generation) } if _, err := os.Stat(name); err != nil { filename = name break } generation++ } } // make sure the dir is existed, eg: // ./foo/bar/baz/hello.log must make sure ./foo/bar/baz is existed dirname := filepath.Dir(filename) if err := os.MkdirAll(dirname, 0755); err != nil { return nil, errors.Wrapf(err, "failed to create directory %s", dirname) } // if we got here, then we need to create a file fh, err := os.OpenFile(filename, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) if err != nil { return nil, errors.Errorf("failed to open file %s: %s", rl.pattern, err) } if err := rl.rotate_nolock(filename); err != nil { err = errors.Wrap(err, "failed to rotate") if bailOnRotateFail { // Failure to rotate is a problem, but it's really not a great // idea to stop your application just because you couldn't rename // your log. // // We only return this error when explicitly needed (as specified by bailOnRotateFail) // // However, we *NEED* to close `fh` here if fh != nil { // probably can't happen, but being paranoid fh.Close() } return nil, err } fmt.Fprintf(os.Stderr, "%s\n", err.Error()) } rl.outFh.Close() rl.outFh = fh rl.curBaseFn = baseFn rl.curFn = filename rl.generation = generation if h := rl.eventHandler; h != nil { go h.Handle(&FileRotatedEvent{ prev: previousFn, current: filename, }) } return fh, nil } // CurrentFileName returns the current file name that // the RotateLogs object is writing to func (rl *RotateLogs) CurrentFileName() string { rl.mutex.RLock() defer rl.mutex.RUnlock() return rl.curFn } var patternConversionRegexps = []*regexp.Regexp{ regexp.MustCompile(`%[%+A-Za-z]`), regexp.MustCompile(`\*+`), } type cleanupGuard struct { enable bool fn func() mutex sync.Mutex } func (g *cleanupGuard) Enable() { g.mutex.Lock() defer g.mutex.Unlock() g.enable = true } func (g *cleanupGuard) Run() { g.fn() } // Rotate forcefully rotates the log files. If the generated file name // clash because file already exists, a numeric suffix of the form // ".1", ".2", ".3" and so forth are appended to the end of the log file // // Thie method can be used in conjunction with a signal handler so to // emulate servers that generate new log files when they receive a // SIGHUP func (rl *RotateLogs) Rotate() error { rl.mutex.Lock() defer rl.mutex.Unlock() if _, err := rl.getWriter_nolock(true, true); err != nil { return err } return nil } func (rl *RotateLogs) rotate_nolock(filename string) error { lockfn := filename + `_lock` fh, err := os.OpenFile(lockfn, os.O_CREATE|os.O_EXCL, 0644) if err != nil { // Can't lock, just return return err } var guard cleanupGuard guard.fn = func() { fh.Close() os.Remove(lockfn) } defer guard.Run() if rl.linkName != "" { tmpLinkName := filename + `_symlink` if err := os.Symlink(filename, tmpLinkName); err != nil { return errors.Wrap(err, `failed to create new symlink`) } if err := os.Rename(tmpLinkName, rl.linkName); err != nil { return errors.Wrap(err, `failed to rename new symlink`) } } if rl.maxAge <= 0 && rl.rotationCount <= 0 { return errors.New("panic: maxAge and rotationCount are both set") } matches, err := filepath.Glob(rl.globPattern) if err != nil { return err } cutoff := rl.clock.Now().Add(-1 * rl.maxAge) var toUnlink []string for _, path := range matches { // Ignore lock files if strings.HasSuffix(path, "_lock") || strings.HasSuffix(path, "_symlink") { continue } fi, err := os.Stat(path) if err != nil { continue } fl, err := os.Lstat(path) if err != nil { continue } if rl.maxAge > 0 && fi.ModTime().After(cutoff) { continue } if rl.rotationCount > 0 && fl.Mode()&os.ModeSymlink == os.ModeSymlink { continue } toUnlink = append(toUnlink, path) } if rl.rotationCount > 0 { // Only delete if we have more than rotationCount if rl.rotationCount >= uint(len(toUnlink)) { return nil } toUnlink = toUnlink[:len(toUnlink)-int(rl.rotationCount)] } if len(toUnlink) <= 0 { return nil } guard.Enable() go func() { // unlink files on a separate goroutine for _, path := range toUnlink { os.Remove(path) } }() return nil } // Close satisfies the io.Closer interface. You must // call this method if you performed any writes to // the object. func (rl *RotateLogs) Close() error { rl.mutex.Lock() defer rl.mutex.Unlock() if rl.outFh == nil { return nil } rl.outFh.Close() rl.outFh = nil return nil }