Skip to content

Commit 07d0c6e

Browse files
authored
Merge pull request #32 from staticfloat/sf/rotating_logger
Add `DatetimeRotatingFileLogger`
2 parents 6526084 + 2ee741e commit 07d0c6e

File tree

5 files changed

+183
-11
lines changed

5 files changed

+183
-11
lines changed

Project.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ uuid = "e6f89c97-d47a-5376-807f-9c37f3926c36"
33
authors = ["Lyndon White <[email protected]>"]
44
version = "0.4.1"
55

6+
[deps]
7+
Dates = "ade2ca70-3891-5945-98fb-dc099432e06a"
8+
69
[compat]
710
julia = "0.7, 1"
811

README.md

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ logger = global_logger()
8181

8282

8383
# Loggers introduced by this package:
84-
This package introduces 6 new loggers.
84+
This package introduces 7 new loggers.
8585
The `TeeLogger`, the `TransformerLogger`, 3 types of filtered logger, and the `FileLogger`.
8686
All of them just wrap existing loggers.
8787
- The `TeeLogger` sends the logs to multiple different loggers.
@@ -91,6 +91,7 @@ All of them just wrap existing loggers.
9191
- The `EarlyFilteredLogger` lets you write filter rules based on the `level`, `module`, `group` and `id` of the log message
9292
- The `ActiveFilteredLogger` lets you filter based on the full content
9393
- The `FileLogger` is a simple logger sink that writes to file.
94+
- The `DatetimeRotatingFileLogger` is a logger sink that writes to file, rotating logs based upon a user-provided `DateFormat`.
9495

9596
By combining `TeeLogger` with filter loggers you can arbitrarily route log messages, wherever you want.
9697

@@ -290,6 +291,30 @@ It can also be used to do things such as change the log level of messages from a
290291
Or to set common properties for all log messages within the `with_logger` block,
291292
for example to set them all to the same `group`.
292293
294+
## `DatetimeRotatingFileLogger`
295+
Use this sink to rotate your logs based upon a given `DateFormat`, automatically closing one file and opening another
296+
when the `DateFormat` would change the filename. Note that if you wish to have static portions of your filename, you must
297+
escape them so they are not interpreted by the `DateFormat` code. Example:
298+
299+
```julia
300+
julia> using Logging, LoggingExtras
301+
302+
julia> rotating_logger = DatetimeRotatingFileLogger(pwd(), raw"\a\c\c\e\s\s-YYYY-mm-dd-HH-MM-SS.\l\o\g");
303+
304+
julia> with_logger(rotating_logger) do
305+
@info("This goes in one file")
306+
sleep(1.1)
307+
@info("This goes in another file")
308+
end
309+
310+
julia> filter(f -> endswith(f, ".log"), readdir(pwd()))
311+
2-element Array{String,1}:
312+
"access-2020-07-13-13-24-13.log"
313+
"access-2020-07-13-13-24-14.log"
314+
```
315+
316+
The user implicitly controls when the files will be rolled over based on the `DateFormat` given.
317+
293318
# More Examples
294319
295320
## Filter out any overly long messages

src/LoggingExtras.jl

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ import Base.CoreLogging:
99
handle_message, shouldlog, min_enabled_level, catch_exceptions
1010

1111
export TeeLogger, TransformerLogger, FileLogger,
12-
ActiveFilteredLogger, EarlyFilteredLogger, MinLevelLogger
12+
ActiveFilteredLogger, EarlyFilteredLogger, MinLevelLogger,
13+
DatetimeRotatingFileLogger
1314

1415

1516
######
@@ -37,6 +38,7 @@ include("activefiltered.jl")
3738
include("earlyfiltered.jl")
3839
include("minlevelfiltered.jl")
3940
include("filelogger.jl")
41+
include("datetime_rotation.jl")
4042
include("deprecated.jl")
4143

4244
end # module

src/datetime_rotation.jl

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
using Dates
2+
import Base: isless
3+
4+
raw"""
5+
DatetimeRotatingFileLogger
6+
7+
Constructs a FileLogger that rotates its file based on the current date.
8+
The filename pattern given is interpreted through the `Dates.format()` string formatter,
9+
allowing for yearly all the way down to millisecond-level log rotation. Note that if you
10+
wish to have a filename portion that is not interpreted as a format string, you may need
11+
to escape portions of the filename, as shown below:
12+
13+
Usage example:
14+
15+
logger = DatetimeRotatingFileLogger(log_dir, raw"\a\c\c\e\s\s-YYYY-mm-dd.\l\o\g")
16+
"""
17+
mutable struct DatetimeRotatingFileLogger <: AbstractLogger
18+
logger::SimpleLogger
19+
dir::String
20+
filename_pattern::DateFormat
21+
next_reopen_check::DateTime
22+
always_flush::Bool
23+
end
24+
25+
function DatetimeRotatingFileLogger(dir, filename_pattern; always_flush=true)
26+
format = DateFormat(filename_pattern)
27+
return DatetimeRotatingFileLogger(
28+
SimpleLogger(open(calc_logpath(dir, filename_pattern), "a"), BelowMinLevel),
29+
dir,
30+
format,
31+
next_datetime_transition(format),
32+
always_flush,
33+
)
34+
end
35+
36+
function reopen!(drfl::DatetimeRotatingFileLogger)
37+
drfl.logger = SimpleLogger(open(calc_logpath(drfl.dir, drfl.filename_pattern), "a"), BelowMinLevel)
38+
drfl.next_reopen_check = next_datetime_transition(drfl.filename_pattern)
39+
return nothing
40+
end
41+
42+
"""
43+
next_datetime_transition(fmt::DateFormat)
44+
45+
Given a DateFormat that is being applied to our filename, what is the next
46+
time at which our filepath will need to change?
47+
"""
48+
function next_datetime_transition(fmt::DateFormat)
49+
extract_token(x::Dates.DatePart{T}) where {T} = T
50+
token_timescales = Dict(
51+
# Milliseconds is the smallest timescale
52+
's' => Millisecond(1),
53+
# Seconds
54+
'S' => Second(1),
55+
# Minutes
56+
'M' => Minute(1),
57+
# Hours
58+
'I' => Hour(1),
59+
'H' => Hour(1),
60+
# Days
61+
'd' => Day(1),
62+
'e' => Day(1),
63+
'E' => Day(1),
64+
# Month
65+
'm' => Month(1),
66+
'u' => Month(1),
67+
'U' => Month(1),
68+
# Year
69+
'y' => Year(1),
70+
'Y' => Year(1),
71+
)
72+
73+
# Dates for some reason explicitly does not define equality between the smaller
74+
# timescales (Second, Minute, Day, etc..) and the larger, non-constant timescales
75+
# (Month, Year). We do so explicitly here, without committing type piracy:
76+
custom_isless(x, y) = isless(x, y)
77+
custom_isless(x::Union{Millisecond,Second,Minute,Hour,Day}, y::Union{Month, Year}) = true
78+
custom_isless(x::Union{Month, Year}, y::Union{Millisecond,Second,Minute,Hour,Day}) = false
79+
80+
tokens = filter(t -> isa(t, Dates.DatePart), collect(fmt.tokens))
81+
minimum_timescale = first(sort(map(t -> token_timescales[extract_token(t)], tokens), lt=custom_isless))
82+
return ceil(now(), minimum_timescale) - Second(1)
83+
end
84+
85+
calc_logpath(dir, filename_pattern) = joinpath(dir, Dates.format(now(), filename_pattern))
86+
87+
function handle_message(drfl::DatetimeRotatingFileLogger, args...; kwargs...)
88+
if now() >= drfl.next_reopen_check
89+
flush(drfl.logger.stream)
90+
reopen!(drfl)
91+
end
92+
handle_message(drfl.logger, args...; kwargs...)
93+
drfl.always_flush && flush(drfl.logger.stream)
94+
end
95+
96+
shouldlog(drfl::DatetimeRotatingFileLogger, arg...) = true
97+
min_enabled_level(drfl::DatetimeRotatingFileLogger) = BelowMinLevel
98+
catch_exceptions(drfl::DatetimeRotatingFileLogger) = catch_exceptions(drfl.logger)

test/runtests.jl

Lines changed: 53 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using LoggingExtras
22
using Test
33
using Test: collect_test_logs, TestLogger
4+
using Dates
45

56
using Base.CoreLogging
67
using Base.CoreLogging: BelowMinLevel, Debug, Info, Warn, Error
@@ -121,19 +122,62 @@ end
121122
@test length(testlogger.logs) == 2
122123
end
123124

125+
@testset "DatetimeRotatingFileLogger" begin
126+
mktempdir() do dir
127+
drfl_sec = DatetimeRotatingFileLogger(dir, raw"\s\e\c-YYYY-mm-dd-HH-MM-SS.\l\o\g")
128+
drfl_min = DatetimeRotatingFileLogger(dir, raw"\m\i\n-YYYY-mm-dd-HH-MM.\l\o\g")
129+
sink = TeeLogger(drfl_sec, drfl_min)
130+
with_logger(sink) do
131+
while millisecond(now()) < 100 || millisecond(now()) > 200
132+
sleep(0.001)
133+
end
134+
@info "first"
135+
@info "second"
136+
sleep(0.9)
137+
@info("third")
138+
end
139+
140+
# Drop anything that's not a .log file or empty
141+
files = sort(map(f -> joinpath(dir, f), readdir(dir)))
142+
files = filter(f -> endswith(f, ".log") && filesize(f) > 0, files)
143+
sec_files = filter(f -> startswith(basename(f), "sec-"), files)
144+
@test length(sec_files) == 2
145+
146+
min_files = filter(f -> startswith(basename(f), "min-"), files)
147+
@test length(min_files) == 1
148+
149+
sec1_data = String(read(sec_files[1]))
150+
@test occursin("first", sec1_data)
151+
@test occursin("second", sec1_data)
152+
sec2_data = String(read(sec_files[2]))
153+
@test occursin("third", sec2_data)
154+
155+
min_data = String(read(min_files[1]))
156+
@test occursin("first", min_data)
157+
@test occursin("second", min_data)
158+
@test occursin("third", min_data)
159+
end
160+
end
161+
124162

125163
@testset "Deprecations" begin
126164
testlogger = TestLogger(min_level=BelowMinLevel)
127165

128-
demux_logger = DemuxLogger(testlogger)
129-
@test demux_logger isa TeeLogger
130-
@test Set(demux_logger.loggers) == Set([testlogger, global_logger()])
166+
@test_logs (:warn, r"deprecated") match_mode=:any begin
167+
demux_logger = DemuxLogger(testlogger)
168+
@test demux_logger isa TeeLogger
169+
@test Set(demux_logger.loggers) == Set([testlogger, global_logger()])
170+
end
131171

132-
demux_logger = DemuxLogger(testlogger; include_current_global=true)
133-
@test demux_logger isa TeeLogger
134-
@test Set(demux_logger.loggers) == Set([testlogger, global_logger()])
172+
@test_logs (:warn, r"deprecated") match_mode=:any begin
173+
demux_logger = DemuxLogger(testlogger; include_current_global=true)
174+
@test demux_logger isa TeeLogger
175+
@test Set(demux_logger.loggers) == Set([testlogger, global_logger()])
176+
end
135177

136-
demux_logger = DemuxLogger(testlogger; include_current_global=false)
137-
@test demux_logger isa TeeLogger
138-
@test Set(demux_logger.loggers) == Set([testlogger])
178+
@test_logs (:warn, r"deprecated") match_mode=:any begin
179+
demux_logger = DemuxLogger(testlogger; include_current_global=false)
180+
@test demux_logger isa TeeLogger
181+
@test Set(demux_logger.loggers) == Set([testlogger])
182+
end
139183
end

0 commit comments

Comments
 (0)