Skip to content

Commit b0cd6f3

Browse files
committed
Add DatetimeRotatingFileLogger
This new logger type takes in a directory and a filename pattern that is passed through `Dates.format()`. It automatically reopens its log when the date format would change, allowing for a rolling log kept on anything from a yearly to millisecond timescale.
1 parent f1a52d1 commit b0cd6f3

File tree

4 files changed

+162
-1
lines changed

4 files changed

+162
-1
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

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: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
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+
# I kind of wish these were defined in Dates
43+
isless(::Type{Millisecond}, ::Type{Millisecond}) = false
44+
isless(::Type{Millisecond}, ::Type{T}) where {T <: Dates.Period} = true
45+
46+
isless(::Type{Second}, ::Type{Millisecond}) = false
47+
isless(::Type{Second}, ::Type{Second}) = false
48+
isless(::Type{Second}, ::Type{T}) where {T <: Dates.Period} = true
49+
50+
isless(::Type{Minute}, ::Type{Millisecond}) = false
51+
isless(::Type{Minute}, ::Type{Second}) = false
52+
isless(::Type{Minute}, ::Type{Minute}) = false
53+
isless(::Type{Minute}, ::Type{T}) where {T <: Dates.Period} = true
54+
55+
isless(::Type{Hour}, ::Type{Day}) = true
56+
isless(::Type{Hour}, ::Type{Month}) = true
57+
isless(::Type{Hour}, ::Type{Year}) = true
58+
isless(::Type{Hour}, ::Type{T}) where {T <: Dates.Period} = false
59+
60+
isless(::Type{Day}, ::Type{Month}) = true
61+
isless(::Type{Day}, ::Type{Year}) = true
62+
isless(::Type{Day}, ::Type{T}) where {T <: Dates.Period} = false
63+
64+
isless(::Type{Month}, ::Type{Year}) = true
65+
isless(::Type{Month}, ::Type{T}) where {T <: Dates.Period} = false
66+
67+
isless(::Type{Year}, ::Type{T}) where {T <: Dates.Period} = false
68+
69+
"""
70+
next_datetime_transition(fmt::DateFormat)
71+
72+
Given a DateFormat that is being applied to our filename, what is the next
73+
time at which our filepath will need to change?
74+
"""
75+
function next_datetime_transition(fmt::DateFormat)
76+
extract_token(x::Dates.DatePart{T}) where {T} = T
77+
token_timescales = Dict(
78+
# Milliseconds is the smallest timescale
79+
's' => Millisecond,
80+
# Seconds
81+
'S' => Second,
82+
# Minutes
83+
'M' => Minute,
84+
# Hours
85+
'I' => Hour,
86+
'H' => Hour,
87+
# Days
88+
'd' => Day,
89+
'e' => Day,
90+
'E' => Day,
91+
# Month
92+
'm' => Month,
93+
'u' => Month,
94+
'U' => Month,
95+
# Year
96+
'y' => Year,
97+
'Y' => Year,
98+
)
99+
100+
tokens = filter(t -> isa(t, Dates.DatePart), collect(fmt.tokens))
101+
minimum_timescale = minimum(map(t -> token_timescales[extract_token(t)], tokens))
102+
return Dates.ceil(now(), minimum_timescale) - Second(1)
103+
end
104+
105+
calc_logpath(dir, filename_pattern) = joinpath(dir, Dates.format(now(), filename_pattern))
106+
107+
function handle_message(drfl::DatetimeRotatingFileLogger, args...; kwargs...)
108+
if now() >= drfl.next_reopen_check
109+
flush(drfl.logger.stream)
110+
reopen!(drfl)
111+
end
112+
handle_message(drfl.logger, args...; kwargs...)
113+
drfl.always_flush && flush(drfl.logger.stream)
114+
end
115+
116+
shouldlog(drfl::DatetimeRotatingFileLogger, arg...) = true
117+
min_enabled_level(drfl::DatetimeRotatingFileLogger) = BelowMinLevel
118+
catch_exceptions(drfl::DatetimeRotatingFileLogger) = catch_exceptions(drfl.logger)

test/runtests.jl

Lines changed: 38 additions & 0 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,6 +122,43 @@ 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)

0 commit comments

Comments
 (0)