Skip to content

Commit ce1ff73

Browse files
committed
Implement a callback function for DatetimeRotatingFileLogger.
1 parent 21cd994 commit ce1ff73

File tree

3 files changed

+71
-29
lines changed

3 files changed

+71
-29
lines changed

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,9 @@ julia> filter(f -> endswith(f, ".log"), readdir(pwd()))
316316
```
317317
318318
The user implicitly controls when the files will be rolled over based on the `DateFormat` given.
319+
To post-process the newly rotated file pass `rotation_callback::Function` as a keyword argument.
320+
See the docstring with (`?DatetimeRotatingFileLogger` in the REPL) for more details.
321+
319322
To control the logging output it is possible to pass a formatter function as the first argument
320323
in the constructor. See `FormatLogger` for the requirements on the formatter function.
321324

src/datetime_rotation.jl

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@ using Dates
22
import Base: isless
33

44
raw"""
5-
DatetimeRotatingFileLogger(dir, file_pattern; always_flush=true)
6-
DatetimeRotatingFileLogger(f::Function, dir, file_pattern; always_flush=true)
5+
DatetimeRotatingFileLogger(dir, file_pattern; always_flush=true, rotation_callback=identity)
6+
DatetimeRotatingFileLogger(f::Function, dir, file_pattern; always_flush=true, rotation_callback=identity)
77
88
Construct a `DatetimeRotatingFileLogger` that rotates its file based on the current date.
99
The constructor takes a log output directory, `dir`, and a filename pattern.
1010
The filename pattern given is interpreted through the `Dates.format()` string formatter,
11-
allowing for yearly all the way down to millisecond-level log rotation. Note that if you
11+
allowing for yearly all the way down to minute-level log rotation. Note that if you
1212
wish to have a filename portion that is not interpreted as a format string, you may need
1313
to escape portions of the filename, as shown in the example below.
1414
@@ -18,6 +18,11 @@ where `log_args` has the following fields:
1818
`(level, message, _module, group, id, file, line, kwargs)`.
1919
See `?LoggingExtra.handle_message_args` for more information about what each field represents.
2020
21+
It is also possible to pass `rotation_callback::Function` as a keyword argument. This function
22+
will be called every time a file rotation is happening. The function should accept one
23+
argument which is the absolute path to the just-rotated file. The logger will block until
24+
the callback function returns. Use `@async` if the callback is expensive.
25+
2126
# Examples
2227
2328
```julia
@@ -28,19 +33,27 @@ logger = DatetimeRotatingFileLogger(log_dir, raw"\a\c\c\e\s\s-yyyy-mm-dd.\l\o\g"
2833
logger = DatetimeRotatingFileLogger(log_dir, raw"yyyy-mm-dd-HH.\l\o\g") do io, args
2934
println(io, args.level, " | ", args.message)
3035
end
36+
37+
# Example callback function to compress the recently-closed file
38+
compressor(file) = run(`gzip $(file)`)
39+
logger = DatetimeRotatingFileLogger(...; rotation_callback=compressor)
40+
```
3141
"""
3242
mutable struct DatetimeRotatingFileLogger <: AbstractLogger
3343
logger::Union{SimpleLogger,FormatLogger}
3444
dir::String
3545
filename_pattern::DateFormat
3646
next_reopen_check::DateTime
3747
always_flush::Bool
48+
reopen_lock::ReentrantLock
49+
current_file::Union{String,Nothing}
50+
rotation_callback::Function
3851
end
3952

40-
function DatetimeRotatingFileLogger(dir, filename_pattern; always_flush=true)
41-
DatetimeRotatingFileLogger(nothing, dir, filename_pattern; always_flush=always_flush)
53+
function DatetimeRotatingFileLogger(dir, filename_pattern; always_flush=true, rotation_callback=identity)
54+
DatetimeRotatingFileLogger(nothing, dir, filename_pattern; always_flush=always_flush, rotation_callback=rotation_callback)
4255
end
43-
function DatetimeRotatingFileLogger(f::Union{Function,Nothing}, dir, filename_pattern; always_flush=true)
56+
function DatetimeRotatingFileLogger(f::Union{Function,Nothing}, dir, filename_pattern; always_flush=true, rotation_callback=identity)
4457
# Construct the backing logger with a temp IOBuffer that will be replaced
4558
# by the correct filestream in the call to reopen! below
4659
logger = if f === nothing
@@ -50,15 +63,22 @@ function DatetimeRotatingFileLogger(f::Union{Function,Nothing}, dir, filename_pa
5063
end
5164
# abspath in case user constructs the logger with a relative path and later cd's.
5265
drfl = DatetimeRotatingFileLogger(logger, abspath(dir),
53-
DateFormat(filename_pattern), now(), always_flush)
66+
DateFormat(filename_pattern), now(), always_flush, ReentrantLock(), nothing, rotation_callback)
5467
reopen!(drfl)
5568
return drfl
5669
end
5770

5871
similar_logger(::SimpleLogger, io) = SimpleLogger(io, BelowMinLevel)
5972
similar_logger(l::FormatLogger, io) = FormatLogger(l.f, io, l.always_flush)
6073
function reopen!(drfl::DatetimeRotatingFileLogger)
61-
io = open(calc_logpath(drfl.dir, drfl.filename_pattern), "a")
74+
if drfl.current_file !== nothing
75+
# close the old IOStream and pass the file to the callback
76+
close(drfl.logger.stream)
77+
drfl.rotation_callback(drfl.current_file)
78+
end
79+
new_file = calc_logpath(drfl.dir, drfl.filename_pattern)
80+
drfl.current_file = new_file
81+
io = open(new_file, "a")
6282
drfl.logger = similar_logger(drfl.logger, io)
6383
drfl.next_reopen_check = next_datetime_transition(drfl.filename_pattern)
6484
return nothing
@@ -104,15 +124,19 @@ function next_datetime_transition(fmt::DateFormat)
104124

105125
tokens = filter(t -> isa(t, Dates.DatePart), collect(fmt.tokens))
106126
minimum_timescale = first(sort(map(t -> token_timescales[extract_token(t)], tokens), lt=custom_isless))
107-
return ceil(now(), minimum_timescale) - Second(1)
127+
if minimum_timescale < Minute(1)
128+
throw(ArgumentError("rotating the logger with sub-minute resolution not supported"))
129+
end
130+
return ceil(now(), minimum_timescale)
108131
end
109132

110133
calc_logpath(dir, filename_pattern) = joinpath(dir, Dates.format(now(), filename_pattern))
111134

112135
function handle_message(drfl::DatetimeRotatingFileLogger, args...; kwargs...)
113-
if now() >= drfl.next_reopen_check
114-
flush(drfl.logger.stream)
115-
reopen!(drfl)
136+
lock(drfl.reopen_lock) do
137+
if now() >= drfl.next_reopen_check
138+
reopen!(drfl)
139+
end
116140
end
117141
handle_message(drfl.logger, args...; kwargs...)
118142
drfl.always_flush && flush(drfl.logger.stream)

test/runtests.jl

Lines changed: 32 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -124,41 +124,52 @@ end
124124

125125
@testset "DatetimeRotatingFileLogger" begin
126126
mktempdir() do dir
127-
drfl_sec = DatetimeRotatingFileLogger(dir, raw"\s\e\c-YYYY-mm-dd-HH-MM-SS.\l\o\g")
128127
drfl_min = DatetimeRotatingFileLogger(dir, raw"\m\i\n-YYYY-mm-dd-HH-MM.\l\o\g")
128+
drfl_hour = DatetimeRotatingFileLogger(dir, raw"\h\o\u\r-YYYY-mm-dd-HH.\l\o\g")
129129
func = (io, args) -> println(io, reverse(args.message))
130-
drfl_fmt = DatetimeRotatingFileLogger(func, dir, raw"\f\m\t-YYYY-mm-dd-HH-MM-SS.\l\o\g")
130+
drfl_fmt = DatetimeRotatingFileLogger(func, dir, raw"\f\m\t-YYYY-mm-dd-HH-MM.\l\o\g")
131+
callback_record = []
132+
callback(f) = filesize(f) > 0 && push!(callback_record, f)
133+
drfl_cb = DatetimeRotatingFileLogger(dir, raw"\c\o\m\p-YYYY-mm-dd-HH-MM.\l\o\g";
134+
rotation_callback=callback)
131135

132-
sink = TeeLogger(drfl_sec, drfl_min, drfl_fmt)
136+
sink = TeeLogger(drfl_min, drfl_hour, drfl_fmt, drfl_cb)
133137
with_logger(sink) do
134-
while millisecond(now()) < 100 || millisecond(now()) > 200
135-
sleep(0.001)
136-
end
138+
# Make sure to trigger one minute-level rotation and no hour-level
139+
# rotation by sleeping until HH:MM:55
140+
n = now()
141+
sleeptime = mod((55 - second(n)), 60)
142+
minute(n) == 59 && (sleeptime += 60)
143+
sleep(sleeptime)
137144
@info "first"
138145
@info "second"
139-
sleep(0.9)
146+
sleep(10) # Should rotate to next minute
140147
@info("third")
141148
end
142149

143150
# Drop anything that's not a .log file or empty
144151
files = sort(map(f -> joinpath(dir, f), readdir(dir)))
145152
files = filter(f -> endswith(f, ".log") && filesize(f) > 0, files)
146-
sec_files = filter(f -> startswith(basename(f), "sec-"), files)
147-
@test length(sec_files) == 2
148-
149153
min_files = filter(f -> startswith(basename(f), "min-"), files)
150-
@test length(min_files) == 1
154+
@test length(min_files) == 2
155+
156+
hour_files = filter(f -> startswith(basename(f), "hour-"), files)
157+
@test length(hour_files) == 1
151158

152159
fmt_files = filter(f -> startswith(basename(f), "fmt-"), files)
153160
@test length(fmt_files) == 2
154161

155-
sec1_data = String(read(sec_files[1]))
156-
@test occursin("first", sec1_data)
157-
@test occursin("second", sec1_data)
158-
sec2_data = String(read(sec_files[2]))
159-
@test occursin("third", sec2_data)
162+
# Two files exist, but just one have been rotated
163+
@test length(callback_record) == 1
164+
@test occursin(r"comp-\d{4}(-\d{2}){4}\.log$", callback_record[1])
160165

161-
min_data = String(read(min_files[1]))
166+
min1_data = String(read(min_files[1]))
167+
@test occursin("first", min1_data)
168+
@test occursin("second", min1_data)
169+
min2_data = String(read(min_files[2]))
170+
@test occursin("third", min2_data)
171+
172+
min_data = String(read(hour_files[1]))
162173
@test occursin("first", min_data)
163174
@test occursin("second", min_data)
164175
@test occursin("third", min_data)
@@ -168,6 +179,10 @@ end
168179
@test occursin("dnoces", fmt_data)
169180
fmt_data = String(read(fmt_files[2]))
170181
@test occursin("driht", fmt_data)
182+
183+
# Sub-minute resolution not allowed
184+
@test_throws(ArgumentError("rotating the logger with sub-minute resolution not supported"),
185+
DatetimeRotatingFileLogger(dir, "HH-MM-SS"))
171186
end
172187
end
173188

0 commit comments

Comments
 (0)