├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── chown.go ├── chown_linux.go ├── example_test.go ├── go.mod ├── go.sum ├── linux_test.go ├── lumberjack.go ├── lumberjack_test.go ├── rotate_test.go └── testing_test.go /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # Architecture specific extensions/prefixes 11 | *.[568vq] 12 | [568vq].out 13 | 14 | *.cgo1.go 15 | *.cgo2.c 16 | _cgo_defun.c 17 | _cgo_gotypes.go 18 | _cgo_export.* 19 | 20 | _testmain.go 21 | 22 | *.exe 23 | *.test 24 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | go: 4 | - tip 5 | - 1.15.x 6 | - 1.14.x 7 | - 1.13.x 8 | - 1.12.x 9 | 10 | env: 11 | - GO111MODULE=on 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Nate Finch 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # lumberjack [![GoDoc](https://godoc.org/gopkg.in/natefinch/lumberjack.v2?status.png)](https://godoc.org/gopkg.in/natefinch/lumberjack.v2) [![Build Status](https://travis-ci.org/natefinch/lumberjack.svg?branch=v2.0)](https://travis-ci.org/natefinch/lumberjack) [![Build status](https://ci.appveyor.com/api/projects/status/00gchpxtg4gkrt5d)](https://ci.appveyor.com/project/natefinch/lumberjack) [![Coverage Status](https://coveralls.io/repos/natefinch/lumberjack/badge.svg?branch=v2.0)](https://coveralls.io/r/natefinch/lumberjack?branch=v2.0) 2 | 3 | ### Lumberjack is a Go package for writing logs to rolling files. 4 | 5 | Package lumberjack provides a rolling logger. 6 | 7 | Note that this is v2.0 of lumberjack, and should be imported using gopkg.in 8 | thusly: 9 | 10 | import "gopkg.in/natefinch/lumberjack.v2" 11 | 12 | The package name remains simply lumberjack, and the code resides at 13 | https://github.com/natefinch/lumberjack under the v2.0 branch. 14 | 15 | Lumberjack is intended to be one part of a logging infrastructure. 16 | It is not an all-in-one solution, but instead is a pluggable 17 | component at the bottom of the logging stack that simply controls the files 18 | to which logs are written. 19 | 20 | Lumberjack plays well with any logging package that can write to an 21 | io.Writer, including the standard library's log package. 22 | 23 | Lumberjack assumes that only one process is writing to the output files. 24 | Using the same lumberjack configuration from multiple processes on the same 25 | machine will result in improper behavior. 26 | 27 | 28 | **Example** 29 | 30 | To use lumberjack with the standard library's log package, just pass it into the SetOutput function when your application starts. 31 | 32 | Code: 33 | 34 | ```go 35 | log.SetOutput(&lumberjack.Logger{ 36 | Filename: "/var/log/myapp/foo.log", 37 | MaxSize: 500, // megabytes 38 | MaxBackups: 3, 39 | MaxAge: 28, //days 40 | Compress: true, // disabled by default 41 | }) 42 | ``` 43 | 44 | 45 | 46 | ## type Logger 47 | ``` go 48 | type Logger struct { 49 | // Filename is the file to write logs to. Backup log files will be retained 50 | // in the same directory. It uses -lumberjack.log in 51 | // os.TempDir() if empty. 52 | Filename string `json:"filename" yaml:"filename"` 53 | 54 | // MaxSize is the maximum size in megabytes of the log file before it gets 55 | // rotated. It defaults to 100 megabytes. 56 | MaxSize int `json:"maxsize" yaml:"maxsize"` 57 | 58 | // MaxAge is the maximum number of days to retain old log files based on the 59 | // timestamp encoded in their filename. Note that a day is defined as 24 60 | // hours and may not exactly correspond to calendar days due to daylight 61 | // savings, leap seconds, etc. The default is not to remove old log files 62 | // based on age. 63 | MaxAge int `json:"maxage" yaml:"maxage"` 64 | 65 | // MaxBackups is the maximum number of old log files to retain. The default 66 | // is to retain all old log files (though MaxAge may still cause them to get 67 | // deleted.) 68 | MaxBackups int `json:"maxbackups" yaml:"maxbackups"` 69 | 70 | // LocalTime determines if the time used for formatting the timestamps in 71 | // backup files is the computer's local time. The default is to use UTC 72 | // time. 73 | LocalTime bool `json:"localtime" yaml:"localtime"` 74 | 75 | // Compress determines if the rotated log files should be compressed 76 | // using gzip. The default is not to perform compression. 77 | Compress bool `json:"compress" yaml:"compress"` 78 | // contains filtered or unexported fields 79 | } 80 | ``` 81 | Logger is an io.WriteCloser that writes to the specified filename. 82 | 83 | Logger opens or creates the logfile on first Write. If the file exists and 84 | is less than MaxSize megabytes, lumberjack will open and append to that file. 85 | If the file exists and its size is >= MaxSize megabytes, the file is renamed 86 | by putting the current time in a timestamp in the name immediately before the 87 | file's extension (or the end of the filename if there's no extension). A new 88 | log file is then created using original filename. 89 | 90 | Whenever a write would cause the current log file exceed MaxSize megabytes, 91 | the current file is closed, renamed, and a new log file created with the 92 | original name. Thus, the filename you give Logger is always the "current" log 93 | file. 94 | 95 | Backups use the log file name given to Logger, in the form `name-timestamp.ext` 96 | where name is the filename without the extension, timestamp is the time at which 97 | the log was rotated formatted with the time.Time format of 98 | `2006-01-02T15-04-05.000` and the extension is the original extension. For 99 | example, if your Logger.Filename is `/var/log/foo/server.log`, a backup created 100 | at 6:30pm on Nov 11 2016 would use the filename 101 | `/var/log/foo/server-2016-11-04T18-30-00.000.log` 102 | 103 | ### Cleaning Up Old Log Files 104 | Whenever a new logfile gets created, old log files may be deleted. The most 105 | recent files according to the encoded timestamp will be retained, up to a 106 | number equal to MaxBackups (or all of them if MaxBackups is 0). Any files 107 | with an encoded timestamp older than MaxAge days are deleted, regardless of 108 | MaxBackups. Note that the time encoded in the timestamp is the rotation 109 | time, which may differ from the last time that file was written to. 110 | 111 | If MaxBackups and MaxAge are both 0, no old log files will be deleted. 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | ### func (\*Logger) Close 124 | ``` go 125 | func (l *Logger) Close() error 126 | ``` 127 | Close implements io.Closer, and closes the current logfile. 128 | 129 | 130 | 131 | ### func (\*Logger) Rotate 132 | ``` go 133 | func (l *Logger) Rotate() error 134 | ``` 135 | Rotate causes Logger to close the existing log file and immediately create a 136 | new one. This is a helper function for applications that want to initiate 137 | rotations outside of the normal rotation rules, such as in response to 138 | SIGHUP. After rotating, this initiates a cleanup of old log files according 139 | to the normal rules. 140 | 141 | **Example** 142 | 143 | Example of how to rotate in response to SIGHUP. 144 | 145 | Code: 146 | 147 | ```go 148 | l := &lumberjack.Logger{} 149 | log.SetOutput(l) 150 | c := make(chan os.Signal, 1) 151 | signal.Notify(c, syscall.SIGHUP) 152 | 153 | go func() { 154 | for { 155 | <-c 156 | l.Rotate() 157 | } 158 | }() 159 | ``` 160 | 161 | ### func (\*Logger) Write 162 | ``` go 163 | func (l *Logger) Write(p []byte) (n int, err error) 164 | ``` 165 | Write implements io.Writer. If a write would cause the log file to be larger 166 | than MaxSize, the file is closed, renamed to include a timestamp of the 167 | current time, and a new log file is created using the original log file name. 168 | If the length of the write is greater than MaxSize, an error is returned. 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | - - - 179 | Generated by [godoc2md](http://godoc.org/github.com/davecheney/godoc2md) 180 | -------------------------------------------------------------------------------- /chown.go: -------------------------------------------------------------------------------- 1 | // +build !linux 2 | 3 | package lumberjack 4 | 5 | import ( 6 | "os" 7 | ) 8 | 9 | func chown(_ string, _ os.FileInfo) error { 10 | return nil 11 | } 12 | -------------------------------------------------------------------------------- /chown_linux.go: -------------------------------------------------------------------------------- 1 | package lumberjack 2 | 3 | import ( 4 | "os" 5 | "syscall" 6 | ) 7 | 8 | // osChown is a var so we can mock it out during tests. 9 | var osChown = os.Chown 10 | 11 | func chown(name string, info os.FileInfo) error { 12 | f, err := os.OpenFile(name, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode()) 13 | if err != nil { 14 | return err 15 | } 16 | f.Close() 17 | stat := info.Sys().(*syscall.Stat_t) 18 | return osChown(name, int(stat.Uid), int(stat.Gid)) 19 | } 20 | -------------------------------------------------------------------------------- /example_test.go: -------------------------------------------------------------------------------- 1 | package lumberjack 2 | 3 | import ( 4 | "log" 5 | ) 6 | 7 | // To use lumberjack with the standard library's log package, just pass it into 8 | // the SetOutput function when your application starts. 9 | func Example() { 10 | log.SetOutput(&Logger{ 11 | Filename: "/var/log/myapp/foo.log", 12 | MaxSize: 500, // megabytes 13 | MaxBackups: 3, 14 | MaxAge: 28, // days 15 | Compress: true, // disabled by default 16 | }) 17 | } 18 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module gopkg.in/natefinch/lumberjack.v2 2 | 3 | go 1.13 4 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/natefinch/lumberjack/4cb27fcfbb0f35cb48c542c5ea80b7c1d18933d0/go.sum -------------------------------------------------------------------------------- /linux_test.go: -------------------------------------------------------------------------------- 1 | // +build linux 2 | 3 | package lumberjack 4 | 5 | import ( 6 | "os" 7 | "syscall" 8 | "testing" 9 | "time" 10 | ) 11 | 12 | func TestMaintainMode(t *testing.T) { 13 | currentTime = fakeTime 14 | dir := makeTempDir("TestMaintainMode", t) 15 | defer os.RemoveAll(dir) 16 | 17 | filename := logFile(dir) 18 | 19 | mode := os.FileMode(0600) 20 | f, err := os.OpenFile(filename, os.O_CREATE|os.O_RDWR, mode) 21 | isNil(err, t) 22 | f.Close() 23 | 24 | l := &Logger{ 25 | Filename: filename, 26 | MaxBackups: 1, 27 | MaxSize: 100, // megabytes 28 | } 29 | defer l.Close() 30 | b := []byte("boo!") 31 | n, err := l.Write(b) 32 | isNil(err, t) 33 | equals(len(b), n, t) 34 | 35 | newFakeTime() 36 | 37 | err = l.Rotate() 38 | isNil(err, t) 39 | 40 | filename2 := backupFile(dir) 41 | info, err := os.Stat(filename) 42 | isNil(err, t) 43 | info2, err := os.Stat(filename2) 44 | isNil(err, t) 45 | equals(mode, info.Mode(), t) 46 | equals(mode, info2.Mode(), t) 47 | } 48 | 49 | func TestMaintainOwner(t *testing.T) { 50 | fakeFS := newFakeFS() 51 | osChown = fakeFS.Chown 52 | osStat = fakeFS.Stat 53 | defer func() { 54 | osChown = os.Chown 55 | osStat = os.Stat 56 | }() 57 | currentTime = fakeTime 58 | dir := makeTempDir("TestMaintainOwner", t) 59 | defer os.RemoveAll(dir) 60 | 61 | filename := logFile(dir) 62 | 63 | f, err := os.OpenFile(filename, os.O_CREATE|os.O_RDWR, 0644) 64 | isNil(err, t) 65 | f.Close() 66 | 67 | l := &Logger{ 68 | Filename: filename, 69 | MaxBackups: 1, 70 | MaxSize: 100, // megabytes 71 | } 72 | defer l.Close() 73 | b := []byte("boo!") 74 | n, err := l.Write(b) 75 | isNil(err, t) 76 | equals(len(b), n, t) 77 | 78 | newFakeTime() 79 | 80 | err = l.Rotate() 81 | isNil(err, t) 82 | 83 | equals(555, fakeFS.files[filename].uid, t) 84 | equals(666, fakeFS.files[filename].gid, t) 85 | } 86 | 87 | func TestCompressMaintainMode(t *testing.T) { 88 | currentTime = fakeTime 89 | 90 | dir := makeTempDir("TestCompressMaintainMode", t) 91 | defer os.RemoveAll(dir) 92 | 93 | filename := logFile(dir) 94 | 95 | mode := os.FileMode(0600) 96 | f, err := os.OpenFile(filename, os.O_CREATE|os.O_RDWR, mode) 97 | isNil(err, t) 98 | f.Close() 99 | 100 | l := &Logger{ 101 | Compress: true, 102 | Filename: filename, 103 | MaxBackups: 1, 104 | MaxSize: 100, // megabytes 105 | } 106 | defer l.Close() 107 | b := []byte("boo!") 108 | n, err := l.Write(b) 109 | isNil(err, t) 110 | equals(len(b), n, t) 111 | 112 | newFakeTime() 113 | 114 | err = l.Rotate() 115 | isNil(err, t) 116 | 117 | // we need to wait a little bit since the files get compressed on a different 118 | // goroutine. 119 | <-time.After(10 * time.Millisecond) 120 | 121 | // a compressed version of the log file should now exist with the correct 122 | // mode. 123 | filename2 := backupFile(dir) 124 | info, err := os.Stat(filename) 125 | isNil(err, t) 126 | info2, err := os.Stat(filename2 + compressSuffix) 127 | isNil(err, t) 128 | equals(mode, info.Mode(), t) 129 | equals(mode, info2.Mode(), t) 130 | } 131 | 132 | func TestCompressMaintainOwner(t *testing.T) { 133 | fakeFS := newFakeFS() 134 | osChown = fakeFS.Chown 135 | osStat = fakeFS.Stat 136 | defer func() { 137 | osChown = os.Chown 138 | osStat = os.Stat 139 | }() 140 | currentTime = fakeTime 141 | dir := makeTempDir("TestCompressMaintainOwner", t) 142 | defer os.RemoveAll(dir) 143 | 144 | filename := logFile(dir) 145 | 146 | f, err := os.OpenFile(filename, os.O_CREATE|os.O_RDWR, 0644) 147 | isNil(err, t) 148 | f.Close() 149 | 150 | l := &Logger{ 151 | Compress: true, 152 | Filename: filename, 153 | MaxBackups: 1, 154 | MaxSize: 100, // megabytes 155 | } 156 | defer l.Close() 157 | b := []byte("boo!") 158 | n, err := l.Write(b) 159 | isNil(err, t) 160 | equals(len(b), n, t) 161 | 162 | newFakeTime() 163 | 164 | err = l.Rotate() 165 | isNil(err, t) 166 | 167 | // we need to wait a little bit since the files get compressed on a different 168 | // goroutine. 169 | <-time.After(10 * time.Millisecond) 170 | 171 | // a compressed version of the log file should now exist with the correct 172 | // owner. 173 | filename2 := backupFile(dir) 174 | equals(555, fakeFS.files[filename2+compressSuffix].uid, t) 175 | equals(666, fakeFS.files[filename2+compressSuffix].gid, t) 176 | } 177 | 178 | type fakeFile struct { 179 | uid int 180 | gid int 181 | } 182 | 183 | type fakeFS struct { 184 | files map[string]fakeFile 185 | } 186 | 187 | func newFakeFS() *fakeFS { 188 | return &fakeFS{files: make(map[string]fakeFile)} 189 | } 190 | 191 | func (fs *fakeFS) Chown(name string, uid, gid int) error { 192 | fs.files[name] = fakeFile{uid: uid, gid: gid} 193 | return nil 194 | } 195 | 196 | func (fs *fakeFS) Stat(name string) (os.FileInfo, error) { 197 | info, err := os.Stat(name) 198 | if err != nil { 199 | return nil, err 200 | } 201 | stat := info.Sys().(*syscall.Stat_t) 202 | stat.Uid = 555 203 | stat.Gid = 666 204 | return info, nil 205 | } 206 | -------------------------------------------------------------------------------- /lumberjack.go: -------------------------------------------------------------------------------- 1 | // Package lumberjack provides a rolling logger. 2 | // 3 | // Note that this is v2.0 of lumberjack, and should be imported using gopkg.in 4 | // thusly: 5 | // 6 | // import "gopkg.in/natefinch/lumberjack.v2" 7 | // 8 | // The package name remains simply lumberjack, and the code resides at 9 | // https://github.com/natefinch/lumberjack under the v2.0 branch. 10 | // 11 | // Lumberjack is intended to be one part of a logging infrastructure. 12 | // It is not an all-in-one solution, but instead is a pluggable 13 | // component at the bottom of the logging stack that simply controls the files 14 | // to which logs are written. 15 | // 16 | // Lumberjack plays well with any logging package that can write to an 17 | // io.Writer, including the standard library's log package. 18 | // 19 | // Lumberjack assumes that only one process is writing to the output files. 20 | // Using the same lumberjack configuration from multiple processes on the same 21 | // machine will result in improper behavior. 22 | package lumberjack 23 | 24 | import ( 25 | "compress/gzip" 26 | "errors" 27 | "fmt" 28 | "io" 29 | "io/ioutil" 30 | "os" 31 | "path/filepath" 32 | "sort" 33 | "strings" 34 | "sync" 35 | "time" 36 | ) 37 | 38 | const ( 39 | backupTimeFormat = "2006-01-02T15-04-05.000" 40 | compressSuffix = ".gz" 41 | defaultMaxSize = 100 42 | ) 43 | 44 | // ensure we always implement io.WriteCloser 45 | var _ io.WriteCloser = (*Logger)(nil) 46 | 47 | // Logger is an io.WriteCloser that writes to the specified filename. 48 | // 49 | // Logger opens or creates the logfile on first Write. If the file exists and 50 | // is less than MaxSize megabytes, lumberjack will open and append to that file. 51 | // If the file exists and its size is >= MaxSize megabytes, the file is renamed 52 | // by putting the current time in a timestamp in the name immediately before the 53 | // file's extension (or the end of the filename if there's no extension). A new 54 | // log file is then created using original filename. 55 | // 56 | // Whenever a write would cause the current log file exceed MaxSize megabytes, 57 | // the current file is closed, renamed, and a new log file created with the 58 | // original name. Thus, the filename you give Logger is always the "current" log 59 | // file. 60 | // 61 | // Backups use the log file name given to Logger, in the form 62 | // `name-timestamp.ext` where name is the filename without the extension, 63 | // timestamp is the time at which the log was rotated formatted with the 64 | // time.Time format of `2006-01-02T15-04-05.000` and the extension is the 65 | // original extension. For example, if your Logger.Filename is 66 | // `/var/log/foo/server.log`, a backup created at 6:30pm on Nov 11 2016 would 67 | // use the filename `/var/log/foo/server-2016-11-04T18-30-00.000.log` 68 | // 69 | // Cleaning Up Old Log Files 70 | // 71 | // Whenever a new logfile gets created, old log files may be deleted. The most 72 | // recent files according to the encoded timestamp will be retained, up to a 73 | // number equal to MaxBackups (or all of them if MaxBackups is 0). Any files 74 | // with an encoded timestamp older than MaxAge days are deleted, regardless of 75 | // MaxBackups. Note that the time encoded in the timestamp is the rotation 76 | // time, which may differ from the last time that file was written to. 77 | // 78 | // If MaxBackups and MaxAge are both 0, no old log files will be deleted. 79 | type Logger struct { 80 | // Filename is the file to write logs to. Backup log files will be retained 81 | // in the same directory. It uses -lumberjack.log in 82 | // os.TempDir() if empty. 83 | Filename string `json:"filename" yaml:"filename"` 84 | 85 | // MaxSize is the maximum size in megabytes of the log file before it gets 86 | // rotated. It defaults to 100 megabytes. 87 | MaxSize int `json:"maxsize" yaml:"maxsize"` 88 | 89 | // MaxAge is the maximum number of days to retain old log files based on the 90 | // timestamp encoded in their filename. Note that a day is defined as 24 91 | // hours and may not exactly correspond to calendar days due to daylight 92 | // savings, leap seconds, etc. The default is not to remove old log files 93 | // based on age. 94 | MaxAge int `json:"maxage" yaml:"maxage"` 95 | 96 | // MaxBackups is the maximum number of old log files to retain. The default 97 | // is to retain all old log files (though MaxAge may still cause them to get 98 | // deleted.) 99 | MaxBackups int `json:"maxbackups" yaml:"maxbackups"` 100 | 101 | // LocalTime determines if the time used for formatting the timestamps in 102 | // backup files is the computer's local time. The default is to use UTC 103 | // time. 104 | LocalTime bool `json:"localtime" yaml:"localtime"` 105 | 106 | // Compress determines if the rotated log files should be compressed 107 | // using gzip. The default is not to perform compression. 108 | Compress bool `json:"compress" yaml:"compress"` 109 | 110 | size int64 111 | file *os.File 112 | mu sync.Mutex 113 | 114 | millCh chan bool 115 | startMill sync.Once 116 | } 117 | 118 | var ( 119 | // currentTime exists so it can be mocked out by tests. 120 | currentTime = time.Now 121 | 122 | // os_Stat exists so it can be mocked out by tests. 123 | osStat = os.Stat 124 | 125 | // megabyte is the conversion factor between MaxSize and bytes. It is a 126 | // variable so tests can mock it out and not need to write megabytes of data 127 | // to disk. 128 | megabyte = 1024 * 1024 129 | ) 130 | 131 | // Write implements io.Writer. If a write would cause the log file to be larger 132 | // than MaxSize, the file is closed, renamed to include a timestamp of the 133 | // current time, and a new log file is created using the original log file name. 134 | // If the length of the write is greater than MaxSize, an error is returned. 135 | func (l *Logger) Write(p []byte) (n int, err error) { 136 | l.mu.Lock() 137 | defer l.mu.Unlock() 138 | 139 | writeLen := int64(len(p)) 140 | if writeLen > l.max() { 141 | return 0, fmt.Errorf( 142 | "write length %d exceeds maximum file size %d", writeLen, l.max(), 143 | ) 144 | } 145 | 146 | if l.file == nil { 147 | if err = l.openExistingOrNew(len(p)); err != nil { 148 | return 0, err 149 | } 150 | } 151 | 152 | if l.size+writeLen > l.max() { 153 | if err := l.rotate(); err != nil { 154 | return 0, err 155 | } 156 | } 157 | 158 | n, err = l.file.Write(p) 159 | l.size += int64(n) 160 | 161 | return n, err 162 | } 163 | 164 | // Close implements io.Closer, and closes the current logfile. 165 | func (l *Logger) Close() error { 166 | l.mu.Lock() 167 | defer l.mu.Unlock() 168 | return l.close() 169 | } 170 | 171 | // close closes the file if it is open. 172 | func (l *Logger) close() error { 173 | if l.file == nil { 174 | return nil 175 | } 176 | err := l.file.Close() 177 | l.file = nil 178 | return err 179 | } 180 | 181 | // Rotate causes Logger to close the existing log file and immediately create a 182 | // new one. This is a helper function for applications that want to initiate 183 | // rotations outside of the normal rotation rules, such as in response to 184 | // SIGHUP. After rotating, this initiates compression and removal of old log 185 | // files according to the configuration. 186 | func (l *Logger) Rotate() error { 187 | l.mu.Lock() 188 | defer l.mu.Unlock() 189 | return l.rotate() 190 | } 191 | 192 | // rotate closes the current file, moves it aside with a timestamp in the name, 193 | // (if it exists), opens a new file with the original filename, and then runs 194 | // post-rotation processing and removal. 195 | func (l *Logger) rotate() error { 196 | if err := l.close(); err != nil { 197 | return err 198 | } 199 | if err := l.openNew(); err != nil { 200 | return err 201 | } 202 | l.mill() 203 | return nil 204 | } 205 | 206 | // openNew opens a new log file for writing, moving any old log file out of the 207 | // way. This methods assumes the file has already been closed. 208 | func (l *Logger) openNew() error { 209 | err := os.MkdirAll(l.dir(), 0755) 210 | if err != nil { 211 | return fmt.Errorf("can't make directories for new logfile: %s", err) 212 | } 213 | 214 | name := l.filename() 215 | mode := os.FileMode(0600) 216 | info, err := osStat(name) 217 | if err == nil { 218 | // Copy the mode off the old logfile. 219 | mode = info.Mode() 220 | // move the existing file 221 | newname := backupName(name, l.LocalTime) 222 | if err := os.Rename(name, newname); err != nil { 223 | return fmt.Errorf("can't rename log file: %s", err) 224 | } 225 | 226 | // this is a no-op anywhere but linux 227 | if err := chown(name, info); err != nil { 228 | return err 229 | } 230 | } 231 | 232 | // we use truncate here because this should only get called when we've moved 233 | // the file ourselves. if someone else creates the file in the meantime, 234 | // just wipe out the contents. 235 | f, err := os.OpenFile(name, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode) 236 | if err != nil { 237 | return fmt.Errorf("can't open new logfile: %s", err) 238 | } 239 | l.file = f 240 | l.size = 0 241 | return nil 242 | } 243 | 244 | // backupName creates a new filename from the given name, inserting a timestamp 245 | // between the filename and the extension, using the local time if requested 246 | // (otherwise UTC). 247 | func backupName(name string, local bool) string { 248 | dir := filepath.Dir(name) 249 | filename := filepath.Base(name) 250 | ext := filepath.Ext(filename) 251 | prefix := filename[:len(filename)-len(ext)] 252 | t := currentTime() 253 | if !local { 254 | t = t.UTC() 255 | } 256 | 257 | timestamp := t.Format(backupTimeFormat) 258 | return filepath.Join(dir, fmt.Sprintf("%s-%s%s", prefix, timestamp, ext)) 259 | } 260 | 261 | // openExistingOrNew opens the logfile if it exists and if the current write 262 | // would not put it over MaxSize. If there is no such file or the write would 263 | // put it over the MaxSize, a new file is created. 264 | func (l *Logger) openExistingOrNew(writeLen int) error { 265 | l.mill() 266 | 267 | filename := l.filename() 268 | info, err := osStat(filename) 269 | if os.IsNotExist(err) { 270 | return l.openNew() 271 | } 272 | if err != nil { 273 | return fmt.Errorf("error getting log file info: %s", err) 274 | } 275 | 276 | if info.Size()+int64(writeLen) >= l.max() { 277 | return l.rotate() 278 | } 279 | 280 | file, err := os.OpenFile(filename, os.O_APPEND|os.O_WRONLY, 0644) 281 | if err != nil { 282 | // if we fail to open the old log file for some reason, just ignore 283 | // it and open a new log file. 284 | return l.openNew() 285 | } 286 | l.file = file 287 | l.size = info.Size() 288 | return nil 289 | } 290 | 291 | // filename generates the name of the logfile from the current time. 292 | func (l *Logger) filename() string { 293 | if l.Filename != "" { 294 | return l.Filename 295 | } 296 | name := filepath.Base(os.Args[0]) + "-lumberjack.log" 297 | return filepath.Join(os.TempDir(), name) 298 | } 299 | 300 | // millRunOnce performs compression and removal of stale log files. 301 | // Log files are compressed if enabled via configuration and old log 302 | // files are removed, keeping at most l.MaxBackups files, as long as 303 | // none of them are older than MaxAge. 304 | func (l *Logger) millRunOnce() error { 305 | if l.MaxBackups == 0 && l.MaxAge == 0 && !l.Compress { 306 | return nil 307 | } 308 | 309 | files, err := l.oldLogFiles() 310 | if err != nil { 311 | return err 312 | } 313 | 314 | var compress, remove []logInfo 315 | 316 | if l.MaxBackups > 0 && l.MaxBackups < len(files) { 317 | preserved := make(map[string]bool) 318 | var remaining []logInfo 319 | for _, f := range files { 320 | // Only count the uncompressed log file or the 321 | // compressed log file, not both. 322 | fn := f.Name() 323 | if strings.HasSuffix(fn, compressSuffix) { 324 | fn = fn[:len(fn)-len(compressSuffix)] 325 | } 326 | preserved[fn] = true 327 | 328 | if len(preserved) > l.MaxBackups { 329 | remove = append(remove, f) 330 | } else { 331 | remaining = append(remaining, f) 332 | } 333 | } 334 | files = remaining 335 | } 336 | if l.MaxAge > 0 { 337 | diff := time.Duration(int64(24*time.Hour) * int64(l.MaxAge)) 338 | cutoff := currentTime().Add(-1 * diff) 339 | 340 | var remaining []logInfo 341 | for _, f := range files { 342 | if f.timestamp.Before(cutoff) { 343 | remove = append(remove, f) 344 | } else { 345 | remaining = append(remaining, f) 346 | } 347 | } 348 | files = remaining 349 | } 350 | 351 | if l.Compress { 352 | for _, f := range files { 353 | if !strings.HasSuffix(f.Name(), compressSuffix) { 354 | compress = append(compress, f) 355 | } 356 | } 357 | } 358 | 359 | for _, f := range remove { 360 | errRemove := os.Remove(filepath.Join(l.dir(), f.Name())) 361 | if err == nil && errRemove != nil { 362 | err = errRemove 363 | } 364 | } 365 | for _, f := range compress { 366 | fn := filepath.Join(l.dir(), f.Name()) 367 | errCompress := compressLogFile(fn, fn+compressSuffix) 368 | if err == nil && errCompress != nil { 369 | err = errCompress 370 | } 371 | } 372 | 373 | return err 374 | } 375 | 376 | // millRun runs in a goroutine to manage post-rotation compression and removal 377 | // of old log files. 378 | func (l *Logger) millRun() { 379 | for range l.millCh { 380 | // what am I going to do, log this? 381 | _ = l.millRunOnce() 382 | } 383 | } 384 | 385 | // mill performs post-rotation compression and removal of stale log files, 386 | // starting the mill goroutine if necessary. 387 | func (l *Logger) mill() { 388 | l.startMill.Do(func() { 389 | l.millCh = make(chan bool, 1) 390 | go l.millRun() 391 | }) 392 | select { 393 | case l.millCh <- true: 394 | default: 395 | } 396 | } 397 | 398 | // oldLogFiles returns the list of backup log files stored in the same 399 | // directory as the current log file, sorted by ModTime 400 | func (l *Logger) oldLogFiles() ([]logInfo, error) { 401 | files, err := ioutil.ReadDir(l.dir()) 402 | if err != nil { 403 | return nil, fmt.Errorf("can't read log file directory: %s", err) 404 | } 405 | logFiles := []logInfo{} 406 | 407 | prefix, ext := l.prefixAndExt() 408 | 409 | for _, f := range files { 410 | if f.IsDir() { 411 | continue 412 | } 413 | if t, err := l.timeFromName(f.Name(), prefix, ext); err == nil { 414 | logFiles = append(logFiles, logInfo{t, f}) 415 | continue 416 | } 417 | if t, err := l.timeFromName(f.Name(), prefix, ext+compressSuffix); err == nil { 418 | logFiles = append(logFiles, logInfo{t, f}) 419 | continue 420 | } 421 | // error parsing means that the suffix at the end was not generated 422 | // by lumberjack, and therefore it's not a backup file. 423 | } 424 | 425 | sort.Sort(byFormatTime(logFiles)) 426 | 427 | return logFiles, nil 428 | } 429 | 430 | // timeFromName extracts the formatted time from the filename by stripping off 431 | // the filename's prefix and extension. This prevents someone's filename from 432 | // confusing time.parse. 433 | func (l *Logger) timeFromName(filename, prefix, ext string) (time.Time, error) { 434 | if !strings.HasPrefix(filename, prefix) { 435 | return time.Time{}, errors.New("mismatched prefix") 436 | } 437 | if !strings.HasSuffix(filename, ext) { 438 | return time.Time{}, errors.New("mismatched extension") 439 | } 440 | ts := filename[len(prefix) : len(filename)-len(ext)] 441 | return time.Parse(backupTimeFormat, ts) 442 | } 443 | 444 | // max returns the maximum size in bytes of log files before rolling. 445 | func (l *Logger) max() int64 { 446 | if l.MaxSize == 0 { 447 | return int64(defaultMaxSize * megabyte) 448 | } 449 | return int64(l.MaxSize) * int64(megabyte) 450 | } 451 | 452 | // dir returns the directory for the current filename. 453 | func (l *Logger) dir() string { 454 | return filepath.Dir(l.filename()) 455 | } 456 | 457 | // prefixAndExt returns the filename part and extension part from the Logger's 458 | // filename. 459 | func (l *Logger) prefixAndExt() (prefix, ext string) { 460 | filename := filepath.Base(l.filename()) 461 | ext = filepath.Ext(filename) 462 | prefix = filename[:len(filename)-len(ext)] + "-" 463 | return prefix, ext 464 | } 465 | 466 | // compressLogFile compresses the given log file, removing the 467 | // uncompressed log file if successful. 468 | func compressLogFile(src, dst string) (err error) { 469 | f, err := os.Open(src) 470 | if err != nil { 471 | return fmt.Errorf("failed to open log file: %v", err) 472 | } 473 | defer f.Close() 474 | 475 | fi, err := osStat(src) 476 | if err != nil { 477 | return fmt.Errorf("failed to stat log file: %v", err) 478 | } 479 | 480 | if err := chown(dst, fi); err != nil { 481 | return fmt.Errorf("failed to chown compressed log file: %v", err) 482 | } 483 | 484 | // If this file already exists, we presume it was created by 485 | // a previous attempt to compress the log file. 486 | gzf, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, fi.Mode()) 487 | if err != nil { 488 | return fmt.Errorf("failed to open compressed log file: %v", err) 489 | } 490 | defer gzf.Close() 491 | 492 | gz := gzip.NewWriter(gzf) 493 | 494 | defer func() { 495 | if err != nil { 496 | os.Remove(dst) 497 | err = fmt.Errorf("failed to compress log file: %v", err) 498 | } 499 | }() 500 | 501 | if _, err := io.Copy(gz, f); err != nil { 502 | return err 503 | } 504 | if err := gz.Close(); err != nil { 505 | return err 506 | } 507 | if err := gzf.Close(); err != nil { 508 | return err 509 | } 510 | 511 | if err := f.Close(); err != nil { 512 | return err 513 | } 514 | if err := os.Remove(src); err != nil { 515 | return err 516 | } 517 | 518 | return nil 519 | } 520 | 521 | // logInfo is a convenience struct to return the filename and its embedded 522 | // timestamp. 523 | type logInfo struct { 524 | timestamp time.Time 525 | os.FileInfo 526 | } 527 | 528 | // byFormatTime sorts by newest time formatted in the name. 529 | type byFormatTime []logInfo 530 | 531 | func (b byFormatTime) Less(i, j int) bool { 532 | return b[i].timestamp.After(b[j].timestamp) 533 | } 534 | 535 | func (b byFormatTime) Swap(i, j int) { 536 | b[i], b[j] = b[j], b[i] 537 | } 538 | 539 | func (b byFormatTime) Len() int { 540 | return len(b) 541 | } 542 | -------------------------------------------------------------------------------- /lumberjack_test.go: -------------------------------------------------------------------------------- 1 | package lumberjack 2 | 3 | import ( 4 | "bytes" 5 | "compress/gzip" 6 | "encoding/json" 7 | "fmt" 8 | "io/ioutil" 9 | "os" 10 | "path/filepath" 11 | "testing" 12 | "time" 13 | ) 14 | 15 | // !!!NOTE!!! 16 | // 17 | // Running these tests in parallel will almost certainly cause sporadic (or even 18 | // regular) failures, because they're all messing with the same global variable 19 | // that controls the logic's mocked time.Now. So... don't do that. 20 | 21 | // Since all the tests uses the time to determine filenames etc, we need to 22 | // control the wall clock as much as possible, which means having a wall clock 23 | // that doesn't change unless we want it to. 24 | var fakeCurrentTime = time.Now() 25 | 26 | func fakeTime() time.Time { 27 | return fakeCurrentTime 28 | } 29 | 30 | func TestNewFile(t *testing.T) { 31 | currentTime = fakeTime 32 | 33 | dir := makeTempDir("TestNewFile", t) 34 | defer os.RemoveAll(dir) 35 | l := &Logger{ 36 | Filename: logFile(dir), 37 | } 38 | defer l.Close() 39 | b := []byte("boo!") 40 | n, err := l.Write(b) 41 | isNil(err, t) 42 | equals(len(b), n, t) 43 | existsWithContent(logFile(dir), b, t) 44 | fileCount(dir, 1, t) 45 | } 46 | 47 | func TestOpenExisting(t *testing.T) { 48 | currentTime = fakeTime 49 | dir := makeTempDir("TestOpenExisting", t) 50 | defer os.RemoveAll(dir) 51 | 52 | filename := logFile(dir) 53 | data := []byte("foo!") 54 | err := ioutil.WriteFile(filename, data, 0644) 55 | isNil(err, t) 56 | existsWithContent(filename, data, t) 57 | 58 | l := &Logger{ 59 | Filename: filename, 60 | } 61 | defer l.Close() 62 | b := []byte("boo!") 63 | n, err := l.Write(b) 64 | isNil(err, t) 65 | equals(len(b), n, t) 66 | 67 | // make sure the file got appended 68 | existsWithContent(filename, append(data, b...), t) 69 | 70 | // make sure no other files were created 71 | fileCount(dir, 1, t) 72 | } 73 | 74 | func TestWriteTooLong(t *testing.T) { 75 | currentTime = fakeTime 76 | megabyte = 1 77 | dir := makeTempDir("TestWriteTooLong", t) 78 | defer os.RemoveAll(dir) 79 | l := &Logger{ 80 | Filename: logFile(dir), 81 | MaxSize: 5, 82 | } 83 | defer l.Close() 84 | b := []byte("booooooooooooooo!") 85 | n, err := l.Write(b) 86 | notNil(err, t) 87 | equals(0, n, t) 88 | equals(err.Error(), 89 | fmt.Sprintf("write length %d exceeds maximum file size %d", len(b), l.MaxSize), t) 90 | _, err = os.Stat(logFile(dir)) 91 | assert(os.IsNotExist(err), t, "File exists, but should not have been created") 92 | } 93 | 94 | func TestMakeLogDir(t *testing.T) { 95 | currentTime = fakeTime 96 | dir := time.Now().Format("TestMakeLogDir" + backupTimeFormat) 97 | dir = filepath.Join(os.TempDir(), dir) 98 | defer os.RemoveAll(dir) 99 | filename := logFile(dir) 100 | l := &Logger{ 101 | Filename: filename, 102 | } 103 | defer l.Close() 104 | b := []byte("boo!") 105 | n, err := l.Write(b) 106 | isNil(err, t) 107 | equals(len(b), n, t) 108 | existsWithContent(logFile(dir), b, t) 109 | fileCount(dir, 1, t) 110 | } 111 | 112 | func TestDefaultFilename(t *testing.T) { 113 | currentTime = fakeTime 114 | dir := os.TempDir() 115 | filename := filepath.Join(dir, filepath.Base(os.Args[0])+"-lumberjack.log") 116 | defer os.Remove(filename) 117 | l := &Logger{} 118 | defer l.Close() 119 | b := []byte("boo!") 120 | n, err := l.Write(b) 121 | 122 | isNil(err, t) 123 | equals(len(b), n, t) 124 | existsWithContent(filename, b, t) 125 | } 126 | 127 | func TestAutoRotate(t *testing.T) { 128 | currentTime = fakeTime 129 | megabyte = 1 130 | 131 | dir := makeTempDir("TestAutoRotate", t) 132 | defer os.RemoveAll(dir) 133 | 134 | filename := logFile(dir) 135 | l := &Logger{ 136 | Filename: filename, 137 | MaxSize: 10, 138 | } 139 | defer l.Close() 140 | b := []byte("boo!") 141 | n, err := l.Write(b) 142 | isNil(err, t) 143 | equals(len(b), n, t) 144 | 145 | existsWithContent(filename, b, t) 146 | fileCount(dir, 1, t) 147 | 148 | newFakeTime() 149 | 150 | b2 := []byte("foooooo!") 151 | n, err = l.Write(b2) 152 | isNil(err, t) 153 | equals(len(b2), n, t) 154 | 155 | // the old logfile should be moved aside and the main logfile should have 156 | // only the last write in it. 157 | existsWithContent(filename, b2, t) 158 | 159 | // the backup file will use the current fake time and have the old contents. 160 | existsWithContent(backupFile(dir), b, t) 161 | 162 | fileCount(dir, 2, t) 163 | } 164 | 165 | func TestFirstWriteRotate(t *testing.T) { 166 | currentTime = fakeTime 167 | megabyte = 1 168 | dir := makeTempDir("TestFirstWriteRotate", t) 169 | defer os.RemoveAll(dir) 170 | 171 | filename := logFile(dir) 172 | l := &Logger{ 173 | Filename: filename, 174 | MaxSize: 10, 175 | } 176 | defer l.Close() 177 | 178 | start := []byte("boooooo!") 179 | err := ioutil.WriteFile(filename, start, 0600) 180 | isNil(err, t) 181 | 182 | newFakeTime() 183 | 184 | // this would make us rotate 185 | b := []byte("fooo!") 186 | n, err := l.Write(b) 187 | isNil(err, t) 188 | equals(len(b), n, t) 189 | 190 | existsWithContent(filename, b, t) 191 | existsWithContent(backupFile(dir), start, t) 192 | 193 | fileCount(dir, 2, t) 194 | } 195 | 196 | func TestMaxBackups(t *testing.T) { 197 | currentTime = fakeTime 198 | megabyte = 1 199 | dir := makeTempDir("TestMaxBackups", t) 200 | defer os.RemoveAll(dir) 201 | 202 | filename := logFile(dir) 203 | l := &Logger{ 204 | Filename: filename, 205 | MaxSize: 10, 206 | MaxBackups: 1, 207 | } 208 | defer l.Close() 209 | b := []byte("boo!") 210 | n, err := l.Write(b) 211 | isNil(err, t) 212 | equals(len(b), n, t) 213 | 214 | existsWithContent(filename, b, t) 215 | fileCount(dir, 1, t) 216 | 217 | newFakeTime() 218 | 219 | // this will put us over the max 220 | b2 := []byte("foooooo!") 221 | n, err = l.Write(b2) 222 | isNil(err, t) 223 | equals(len(b2), n, t) 224 | 225 | // this will use the new fake time 226 | secondFilename := backupFile(dir) 227 | existsWithContent(secondFilename, b, t) 228 | 229 | // make sure the old file still exists with the same content. 230 | existsWithContent(filename, b2, t) 231 | 232 | fileCount(dir, 2, t) 233 | 234 | newFakeTime() 235 | 236 | // this will make us rotate again 237 | b3 := []byte("baaaaaar!") 238 | n, err = l.Write(b3) 239 | isNil(err, t) 240 | equals(len(b3), n, t) 241 | 242 | // this will use the new fake time 243 | thirdFilename := backupFile(dir) 244 | existsWithContent(thirdFilename, b2, t) 245 | 246 | existsWithContent(filename, b3, t) 247 | 248 | // we need to wait a little bit since the files get deleted on a different 249 | // goroutine. 250 | <-time.After(time.Millisecond * 10) 251 | 252 | // should only have two files in the dir still 253 | fileCount(dir, 2, t) 254 | 255 | // second file name should still exist 256 | existsWithContent(thirdFilename, b2, t) 257 | 258 | // should have deleted the first backup 259 | notExist(secondFilename, t) 260 | 261 | // now test that we don't delete directories or non-logfile files 262 | 263 | newFakeTime() 264 | 265 | // create a file that is close to but different from the logfile name. 266 | // It shouldn't get caught by our deletion filters. 267 | notlogfile := logFile(dir) + ".foo" 268 | err = ioutil.WriteFile(notlogfile, []byte("data"), 0644) 269 | isNil(err, t) 270 | 271 | // Make a directory that exactly matches our log file filters... it still 272 | // shouldn't get caught by the deletion filter since it's a directory. 273 | notlogfiledir := backupFile(dir) 274 | err = os.Mkdir(notlogfiledir, 0700) 275 | isNil(err, t) 276 | 277 | newFakeTime() 278 | 279 | // this will use the new fake time 280 | fourthFilename := backupFile(dir) 281 | 282 | // Create a log file that is/was being compressed - this should 283 | // not be counted since both the compressed and the uncompressed 284 | // log files still exist. 285 | compLogFile := fourthFilename + compressSuffix 286 | err = ioutil.WriteFile(compLogFile, []byte("compress"), 0644) 287 | isNil(err, t) 288 | 289 | // this will make us rotate again 290 | b4 := []byte("baaaaaaz!") 291 | n, err = l.Write(b4) 292 | isNil(err, t) 293 | equals(len(b4), n, t) 294 | 295 | existsWithContent(fourthFilename, b3, t) 296 | existsWithContent(fourthFilename+compressSuffix, []byte("compress"), t) 297 | 298 | // we need to wait a little bit since the files get deleted on a different 299 | // goroutine. 300 | <-time.After(time.Millisecond * 10) 301 | 302 | // We should have four things in the directory now - the 2 log files, the 303 | // not log file, and the directory 304 | fileCount(dir, 5, t) 305 | 306 | // third file name should still exist 307 | existsWithContent(filename, b4, t) 308 | 309 | existsWithContent(fourthFilename, b3, t) 310 | 311 | // should have deleted the first filename 312 | notExist(thirdFilename, t) 313 | 314 | // the not-a-logfile should still exist 315 | exists(notlogfile, t) 316 | 317 | // the directory 318 | exists(notlogfiledir, t) 319 | } 320 | 321 | func TestCleanupExistingBackups(t *testing.T) { 322 | // test that if we start with more backup files than we're supposed to have 323 | // in total, that extra ones get cleaned up when we rotate. 324 | 325 | currentTime = fakeTime 326 | megabyte = 1 327 | 328 | dir := makeTempDir("TestCleanupExistingBackups", t) 329 | defer os.RemoveAll(dir) 330 | 331 | // make 3 backup files 332 | 333 | data := []byte("data") 334 | backup := backupFile(dir) 335 | err := ioutil.WriteFile(backup, data, 0644) 336 | isNil(err, t) 337 | 338 | newFakeTime() 339 | 340 | backup = backupFile(dir) 341 | err = ioutil.WriteFile(backup+compressSuffix, data, 0644) 342 | isNil(err, t) 343 | 344 | newFakeTime() 345 | 346 | backup = backupFile(dir) 347 | err = ioutil.WriteFile(backup, data, 0644) 348 | isNil(err, t) 349 | 350 | // now create a primary log file with some data 351 | filename := logFile(dir) 352 | err = ioutil.WriteFile(filename, data, 0644) 353 | isNil(err, t) 354 | 355 | l := &Logger{ 356 | Filename: filename, 357 | MaxSize: 10, 358 | MaxBackups: 1, 359 | } 360 | defer l.Close() 361 | 362 | newFakeTime() 363 | 364 | b2 := []byte("foooooo!") 365 | n, err := l.Write(b2) 366 | isNil(err, t) 367 | equals(len(b2), n, t) 368 | 369 | // we need to wait a little bit since the files get deleted on a different 370 | // goroutine. 371 | <-time.After(time.Millisecond * 10) 372 | 373 | // now we should only have 2 files left - the primary and one backup 374 | fileCount(dir, 2, t) 375 | } 376 | 377 | func TestMaxAge(t *testing.T) { 378 | currentTime = fakeTime 379 | megabyte = 1 380 | 381 | dir := makeTempDir("TestMaxAge", t) 382 | defer os.RemoveAll(dir) 383 | 384 | filename := logFile(dir) 385 | l := &Logger{ 386 | Filename: filename, 387 | MaxSize: 10, 388 | MaxAge: 1, 389 | } 390 | defer l.Close() 391 | b := []byte("boo!") 392 | n, err := l.Write(b) 393 | isNil(err, t) 394 | equals(len(b), n, t) 395 | 396 | existsWithContent(filename, b, t) 397 | fileCount(dir, 1, t) 398 | 399 | // two days later 400 | newFakeTime() 401 | 402 | b2 := []byte("foooooo!") 403 | n, err = l.Write(b2) 404 | isNil(err, t) 405 | equals(len(b2), n, t) 406 | existsWithContent(backupFile(dir), b, t) 407 | 408 | // we need to wait a little bit since the files get deleted on a different 409 | // goroutine. 410 | <-time.After(10 * time.Millisecond) 411 | 412 | // We should still have 2 log files, since the most recent backup was just 413 | // created. 414 | fileCount(dir, 2, t) 415 | 416 | existsWithContent(filename, b2, t) 417 | 418 | // we should have deleted the old file due to being too old 419 | existsWithContent(backupFile(dir), b, t) 420 | 421 | // two days later 422 | newFakeTime() 423 | 424 | b3 := []byte("baaaaar!") 425 | n, err = l.Write(b3) 426 | isNil(err, t) 427 | equals(len(b3), n, t) 428 | existsWithContent(backupFile(dir), b2, t) 429 | 430 | // we need to wait a little bit since the files get deleted on a different 431 | // goroutine. 432 | <-time.After(10 * time.Millisecond) 433 | 434 | // We should have 2 log files - the main log file, and the most recent 435 | // backup. The earlier backup is past the cutoff and should be gone. 436 | fileCount(dir, 2, t) 437 | 438 | existsWithContent(filename, b3, t) 439 | 440 | // we should have deleted the old file due to being too old 441 | existsWithContent(backupFile(dir), b2, t) 442 | } 443 | 444 | func TestOldLogFiles(t *testing.T) { 445 | currentTime = fakeTime 446 | megabyte = 1 447 | 448 | dir := makeTempDir("TestOldLogFiles", t) 449 | defer os.RemoveAll(dir) 450 | 451 | filename := logFile(dir) 452 | data := []byte("data") 453 | err := ioutil.WriteFile(filename, data, 07) 454 | isNil(err, t) 455 | 456 | // This gives us a time with the same precision as the time we get from the 457 | // timestamp in the name. 458 | t1, err := time.Parse(backupTimeFormat, fakeTime().UTC().Format(backupTimeFormat)) 459 | isNil(err, t) 460 | 461 | backup := backupFile(dir) 462 | err = ioutil.WriteFile(backup, data, 07) 463 | isNil(err, t) 464 | 465 | newFakeTime() 466 | 467 | t2, err := time.Parse(backupTimeFormat, fakeTime().UTC().Format(backupTimeFormat)) 468 | isNil(err, t) 469 | 470 | backup2 := backupFile(dir) 471 | err = ioutil.WriteFile(backup2, data, 07) 472 | isNil(err, t) 473 | 474 | l := &Logger{Filename: filename} 475 | files, err := l.oldLogFiles() 476 | isNil(err, t) 477 | equals(2, len(files), t) 478 | 479 | // should be sorted by newest file first, which would be t2 480 | equals(t2, files[0].timestamp, t) 481 | equals(t1, files[1].timestamp, t) 482 | } 483 | 484 | func TestTimeFromName(t *testing.T) { 485 | l := &Logger{Filename: "/var/log/myfoo/foo.log"} 486 | prefix, ext := l.prefixAndExt() 487 | 488 | tests := []struct { 489 | filename string 490 | want time.Time 491 | wantErr bool 492 | }{ 493 | {"foo-2014-05-04T14-44-33.555.log", time.Date(2014, 5, 4, 14, 44, 33, 555000000, time.UTC), false}, 494 | {"foo-2014-05-04T14-44-33.555", time.Time{}, true}, 495 | {"2014-05-04T14-44-33.555.log", time.Time{}, true}, 496 | {"foo.log", time.Time{}, true}, 497 | } 498 | 499 | for _, test := range tests { 500 | got, err := l.timeFromName(test.filename, prefix, ext) 501 | equals(got, test.want, t) 502 | equals(err != nil, test.wantErr, t) 503 | } 504 | } 505 | 506 | func TestLocalTime(t *testing.T) { 507 | currentTime = fakeTime 508 | megabyte = 1 509 | 510 | dir := makeTempDir("TestLocalTime", t) 511 | defer os.RemoveAll(dir) 512 | 513 | l := &Logger{ 514 | Filename: logFile(dir), 515 | MaxSize: 10, 516 | LocalTime: true, 517 | } 518 | defer l.Close() 519 | b := []byte("boo!") 520 | n, err := l.Write(b) 521 | isNil(err, t) 522 | equals(len(b), n, t) 523 | 524 | b2 := []byte("fooooooo!") 525 | n2, err := l.Write(b2) 526 | isNil(err, t) 527 | equals(len(b2), n2, t) 528 | 529 | existsWithContent(logFile(dir), b2, t) 530 | existsWithContent(backupFileLocal(dir), b, t) 531 | } 532 | 533 | func TestRotate(t *testing.T) { 534 | currentTime = fakeTime 535 | dir := makeTempDir("TestRotate", t) 536 | defer os.RemoveAll(dir) 537 | 538 | filename := logFile(dir) 539 | 540 | l := &Logger{ 541 | Filename: filename, 542 | MaxBackups: 1, 543 | MaxSize: 100, // megabytes 544 | } 545 | defer l.Close() 546 | b := []byte("boo!") 547 | n, err := l.Write(b) 548 | isNil(err, t) 549 | equals(len(b), n, t) 550 | 551 | existsWithContent(filename, b, t) 552 | fileCount(dir, 1, t) 553 | 554 | newFakeTime() 555 | 556 | err = l.Rotate() 557 | isNil(err, t) 558 | 559 | // we need to wait a little bit since the files get deleted on a different 560 | // goroutine. 561 | <-time.After(10 * time.Millisecond) 562 | 563 | filename2 := backupFile(dir) 564 | existsWithContent(filename2, b, t) 565 | existsWithContent(filename, []byte{}, t) 566 | fileCount(dir, 2, t) 567 | newFakeTime() 568 | 569 | err = l.Rotate() 570 | isNil(err, t) 571 | 572 | // we need to wait a little bit since the files get deleted on a different 573 | // goroutine. 574 | <-time.After(10 * time.Millisecond) 575 | 576 | filename3 := backupFile(dir) 577 | existsWithContent(filename3, []byte{}, t) 578 | existsWithContent(filename, []byte{}, t) 579 | fileCount(dir, 2, t) 580 | 581 | b2 := []byte("foooooo!") 582 | n, err = l.Write(b2) 583 | isNil(err, t) 584 | equals(len(b2), n, t) 585 | 586 | // this will use the new fake time 587 | existsWithContent(filename, b2, t) 588 | } 589 | 590 | func TestCompressOnRotate(t *testing.T) { 591 | currentTime = fakeTime 592 | megabyte = 1 593 | 594 | dir := makeTempDir("TestCompressOnRotate", t) 595 | defer os.RemoveAll(dir) 596 | 597 | filename := logFile(dir) 598 | l := &Logger{ 599 | Compress: true, 600 | Filename: filename, 601 | MaxSize: 10, 602 | } 603 | defer l.Close() 604 | b := []byte("boo!") 605 | n, err := l.Write(b) 606 | isNil(err, t) 607 | equals(len(b), n, t) 608 | 609 | existsWithContent(filename, b, t) 610 | fileCount(dir, 1, t) 611 | 612 | newFakeTime() 613 | 614 | err = l.Rotate() 615 | isNil(err, t) 616 | 617 | // the old logfile should be moved aside and the main logfile should have 618 | // nothing in it. 619 | existsWithContent(filename, []byte{}, t) 620 | 621 | // we need to wait a little bit since the files get compressed on a different 622 | // goroutine. 623 | <-time.After(300 * time.Millisecond) 624 | 625 | // a compressed version of the log file should now exist and the original 626 | // should have been removed. 627 | bc := new(bytes.Buffer) 628 | gz := gzip.NewWriter(bc) 629 | _, err = gz.Write(b) 630 | isNil(err, t) 631 | err = gz.Close() 632 | isNil(err, t) 633 | existsWithContent(backupFile(dir)+compressSuffix, bc.Bytes(), t) 634 | notExist(backupFile(dir), t) 635 | 636 | fileCount(dir, 2, t) 637 | } 638 | 639 | func TestCompressOnResume(t *testing.T) { 640 | currentTime = fakeTime 641 | megabyte = 1 642 | 643 | dir := makeTempDir("TestCompressOnResume", t) 644 | defer os.RemoveAll(dir) 645 | 646 | filename := logFile(dir) 647 | l := &Logger{ 648 | Compress: true, 649 | Filename: filename, 650 | MaxSize: 10, 651 | } 652 | defer l.Close() 653 | 654 | // Create a backup file and empty "compressed" file. 655 | filename2 := backupFile(dir) 656 | b := []byte("foo!") 657 | err := ioutil.WriteFile(filename2, b, 0644) 658 | isNil(err, t) 659 | err = ioutil.WriteFile(filename2+compressSuffix, []byte{}, 0644) 660 | isNil(err, t) 661 | 662 | newFakeTime() 663 | 664 | b2 := []byte("boo!") 665 | n, err := l.Write(b2) 666 | isNil(err, t) 667 | equals(len(b2), n, t) 668 | existsWithContent(filename, b2, t) 669 | 670 | // we need to wait a little bit since the files get compressed on a different 671 | // goroutine. 672 | <-time.After(300 * time.Millisecond) 673 | 674 | // The write should have started the compression - a compressed version of 675 | // the log file should now exist and the original should have been removed. 676 | bc := new(bytes.Buffer) 677 | gz := gzip.NewWriter(bc) 678 | _, err = gz.Write(b) 679 | isNil(err, t) 680 | err = gz.Close() 681 | isNil(err, t) 682 | existsWithContent(filename2+compressSuffix, bc.Bytes(), t) 683 | notExist(filename2, t) 684 | 685 | fileCount(dir, 2, t) 686 | } 687 | 688 | func TestJson(t *testing.T) { 689 | data := []byte(` 690 | { 691 | "filename": "foo", 692 | "maxsize": 5, 693 | "maxage": 10, 694 | "maxbackups": 3, 695 | "localtime": true, 696 | "compress": true 697 | }`[1:]) 698 | 699 | l := Logger{} 700 | err := json.Unmarshal(data, &l) 701 | isNil(err, t) 702 | equals("foo", l.Filename, t) 703 | equals(5, l.MaxSize, t) 704 | equals(10, l.MaxAge, t) 705 | equals(3, l.MaxBackups, t) 706 | equals(true, l.LocalTime, t) 707 | equals(true, l.Compress, t) 708 | } 709 | 710 | // makeTempDir creates a file with a semi-unique name in the OS temp directory. 711 | // It should be based on the name of the test, to keep parallel tests from 712 | // colliding, and must be cleaned up after the test is finished. 713 | func makeTempDir(name string, t testing.TB) string { 714 | dir := time.Now().Format(name + backupTimeFormat) 715 | dir = filepath.Join(os.TempDir(), dir) 716 | isNilUp(os.Mkdir(dir, 0700), t, 1) 717 | return dir 718 | } 719 | 720 | // existsWithContent checks that the given file exists and has the correct content. 721 | func existsWithContent(path string, content []byte, t testing.TB) { 722 | info, err := os.Stat(path) 723 | isNilUp(err, t, 1) 724 | equalsUp(int64(len(content)), info.Size(), t, 1) 725 | 726 | b, err := ioutil.ReadFile(path) 727 | isNilUp(err, t, 1) 728 | equalsUp(content, b, t, 1) 729 | } 730 | 731 | // logFile returns the log file name in the given directory for the current fake 732 | // time. 733 | func logFile(dir string) string { 734 | return filepath.Join(dir, "foobar.log") 735 | } 736 | 737 | func backupFile(dir string) string { 738 | return filepath.Join(dir, "foobar-"+fakeTime().UTC().Format(backupTimeFormat)+".log") 739 | } 740 | 741 | func backupFileLocal(dir string) string { 742 | return filepath.Join(dir, "foobar-"+fakeTime().Format(backupTimeFormat)+".log") 743 | } 744 | 745 | // logFileLocal returns the log file name in the given directory for the current 746 | // fake time using the local timezone. 747 | func logFileLocal(dir string) string { 748 | return filepath.Join(dir, fakeTime().Format(backupTimeFormat)) 749 | } 750 | 751 | // fileCount checks that the number of files in the directory is exp. 752 | func fileCount(dir string, exp int, t testing.TB) { 753 | files, err := ioutil.ReadDir(dir) 754 | isNilUp(err, t, 1) 755 | // Make sure no other files were created. 756 | equalsUp(exp, len(files), t, 1) 757 | } 758 | 759 | // newFakeTime sets the fake "current time" to two days later. 760 | func newFakeTime() { 761 | fakeCurrentTime = fakeCurrentTime.Add(time.Hour * 24 * 2) 762 | } 763 | 764 | func notExist(path string, t testing.TB) { 765 | _, err := os.Stat(path) 766 | assertUp(os.IsNotExist(err), t, 1, "expected to get os.IsNotExist, but instead got %v", err) 767 | } 768 | 769 | func exists(path string, t testing.TB) { 770 | _, err := os.Stat(path) 771 | assertUp(err == nil, t, 1, "expected file to exist, but got error from os.Stat: %v", err) 772 | } 773 | -------------------------------------------------------------------------------- /rotate_test.go: -------------------------------------------------------------------------------- 1 | // +build linux 2 | 3 | package lumberjack 4 | 5 | import ( 6 | "log" 7 | "os" 8 | "os/signal" 9 | "syscall" 10 | ) 11 | 12 | // Example of how to rotate in response to SIGHUP. 13 | func ExampleLogger_Rotate() { 14 | l := &Logger{} 15 | log.SetOutput(l) 16 | c := make(chan os.Signal, 1) 17 | signal.Notify(c, syscall.SIGHUP) 18 | 19 | go func() { 20 | for { 21 | <-c 22 | l.Rotate() 23 | } 24 | }() 25 | } 26 | -------------------------------------------------------------------------------- /testing_test.go: -------------------------------------------------------------------------------- 1 | package lumberjack 2 | 3 | import ( 4 | "fmt" 5 | "path/filepath" 6 | "reflect" 7 | "runtime" 8 | "testing" 9 | ) 10 | 11 | // assert will log the given message if condition is false. 12 | func assert(condition bool, t testing.TB, msg string, v ...interface{}) { 13 | assertUp(condition, t, 1, msg, v...) 14 | } 15 | 16 | // assertUp is like assert, but used inside helper functions, to ensure that 17 | // the file and line number reported by failures corresponds to one or more 18 | // levels up the stack. 19 | func assertUp(condition bool, t testing.TB, caller int, msg string, v ...interface{}) { 20 | if !condition { 21 | _, file, line, _ := runtime.Caller(caller + 1) 22 | v = append([]interface{}{filepath.Base(file), line}, v...) 23 | fmt.Printf("%s:%d: "+msg+"\n", v...) 24 | t.FailNow() 25 | } 26 | } 27 | 28 | // equals tests that the two values are equal according to reflect.DeepEqual. 29 | func equals(exp, act interface{}, t testing.TB) { 30 | equalsUp(exp, act, t, 1) 31 | } 32 | 33 | // equalsUp is like equals, but used inside helper functions, to ensure that the 34 | // file and line number reported by failures corresponds to one or more levels 35 | // up the stack. 36 | func equalsUp(exp, act interface{}, t testing.TB, caller int) { 37 | if !reflect.DeepEqual(exp, act) { 38 | _, file, line, _ := runtime.Caller(caller + 1) 39 | fmt.Printf("%s:%d: exp: %v (%T), got: %v (%T)\n", 40 | filepath.Base(file), line, exp, exp, act, act) 41 | t.FailNow() 42 | } 43 | } 44 | 45 | // isNil reports a failure if the given value is not nil. Note that values 46 | // which cannot be nil will always fail this check. 47 | func isNil(obtained interface{}, t testing.TB) { 48 | isNilUp(obtained, t, 1) 49 | } 50 | 51 | // isNilUp is like isNil, but used inside helper functions, to ensure that the 52 | // file and line number reported by failures corresponds to one or more levels 53 | // up the stack. 54 | func isNilUp(obtained interface{}, t testing.TB, caller int) { 55 | if !_isNil(obtained) { 56 | _, file, line, _ := runtime.Caller(caller + 1) 57 | fmt.Printf("%s:%d: expected nil, got: %v\n", filepath.Base(file), line, obtained) 58 | t.FailNow() 59 | } 60 | } 61 | 62 | // notNil reports a failure if the given value is nil. 63 | func notNil(obtained interface{}, t testing.TB) { 64 | notNilUp(obtained, t, 1) 65 | } 66 | 67 | // notNilUp is like notNil, but used inside helper functions, to ensure that the 68 | // file and line number reported by failures corresponds to one or more levels 69 | // up the stack. 70 | func notNilUp(obtained interface{}, t testing.TB, caller int) { 71 | if _isNil(obtained) { 72 | _, file, line, _ := runtime.Caller(caller + 1) 73 | fmt.Printf("%s:%d: expected non-nil, got: %v\n", filepath.Base(file), line, obtained) 74 | t.FailNow() 75 | } 76 | } 77 | 78 | // _isNil is a helper function for isNil and notNil, and should not be used 79 | // directly. 80 | func _isNil(obtained interface{}) bool { 81 | if obtained == nil { 82 | return true 83 | } 84 | 85 | switch v := reflect.ValueOf(obtained); v.Kind() { 86 | case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Ptr, reflect.Slice: 87 | return v.IsNil() 88 | } 89 | 90 | return false 91 | } 92 | --------------------------------------------------------------------------------