Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Alternative approach to debug logging utilities. #64

Merged
merged 15 commits into from
May 27, 2022
56 changes: 56 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ All of them, except `FormatLogger`, just wrap existing loggers.
- The `FileLogger` is a simple logger sink that writes to file.
- The `DatetimeRotatingFileLogger` is a logger sink that writes to file, rotating logs based upon a user-provided `DateFormat`.
- The `FormatLogger` is a logger sink that simply formats the message and writes to the logger stream.
- The `LevelOverrideLogger` for overriding the log level of other loggers

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

Expand Down Expand Up @@ -364,6 +365,61 @@ Main | [Info] This is an informational message.
Main | [Warn] This is a warning, should take a look.
```

## `LevelOverrideLogger` (*Filter*)
Allows overriding the minimum log level set by the logger it wraps.
Useful when debug logging
and used in conjuction with `Logging.with_logger` or `LoggingExtras.withlevel` to
temporarily modify the current logger with a custom level.
quinnj marked this conversation as resolved.
Show resolved Hide resolved
More generally useful if you want to use the current/global logger as a _sink_ but don't know if it is going to have a problematically high min log level set (as julia's default logger sets min level to `Info`).

```julia
julia> using LoggingExtras

julia> logger = LevelOverrideLogger(Debug, global_logger())

julia> with_logger(logger) do
@debug "This message will log since we're overriding the global Info default log level"
end
┌ Debug: This message will log since we're overriding the global Info default log level
└ @ Main REPL[33]:2
```
This is roughly complementary to the `MinLevelFilterLogger`.
The `MinLevelFilterLogger` lets you effectively *raise* the level of any logger it wraps to meet the level you specify.
The `LevelOverrideLogger` lets you *lower* (or *raise*) the level of the wrapped logger as it bypasses checks on it entirely.

quinnj marked this conversation as resolved.
Show resolved Hide resolved
# Utilities

## Verbosity macros
oxinabox marked this conversation as resolved.
Show resolved Hide resolved
Sometimes when logging, it is desirable to be able to specify a verbosity level along with
the log level, and to be able to filter on verbosity levels. For example, you may want multiple levels
of verbosity for `Debug` log statements. LoggingExtras.jl exports verbosity macros that act like their
non-verbose counterparts, but allow specifying a verbosity level as well:
* `@debugv N msg`
* `@infov N msg`
* `@warnv N msg`
* `@errorv N msg`

For verbosity filtering, the `LoggingExtras.withlevel(f, Info; verbosity=0)` utlility is provided
for temporarily (i.e. while `f()` is executed) allowing log messages with `level` and `verbosity`.
This is very handy for allowing finer grained control in debug logging for long-running or complex user API function
calls. For example:

```julia
using LoggingExtras

function complex_user_call(; verbose=0)
LoggingExtras.withlevel(Debug; verbosity=verbose)
# execute complex function body
@debugv 1 "a level 1 verbosity debug message"
@debugv 2 "a more verbose level 2 debug message"
end
end
```

This allows easy control by the user to specify verbosity (by passing `verbose=2` or any > 0 value),
and convenience for the function developer by being able to sprinkle `@debugv N msg` calls as desired,
even in highly nested functions.

# More Examples

## Filter out any overly long messages
Expand Down
5 changes: 4 additions & 1 deletion src/LoggingExtras.jl
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import Base.CoreLogging:

export TeeLogger, TransformerLogger, FileLogger,
ActiveFilteredLogger, EarlyFilteredLogger, MinLevelLogger,
DatetimeRotatingFileLogger, FormatLogger
DatetimeRotatingFileLogger, FormatLogger, LevelOverrideLogger,
@debugv, @infov, @warnv, @errorv, @logmsgv

######
# Re export Logging.jl from stdlib
Expand Down Expand Up @@ -50,6 +51,8 @@ include("minlevelfiltered.jl")
include("filelogger.jl")
include("formatlogger.jl")
include("datetime_rotation.jl")
include("overridelogger.jl")
include("verbosity.jl")
include("deprecated.jl")

end # module
23 changes: 23 additions & 0 deletions src/overridelogger.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"""
LevelOverrideLogger(level, logger)

A logger that allows overriding the log level of a child level.
Useful in debugging scenarios, where it is desirable to ignore
the log level any other logger may have set.
"""
struct LevelOverrideLogger{T <: AbstractLogger} <: AbstractLogger
level::LogLevel
quinnj marked this conversation as resolved.
Show resolved Hide resolved
logger::T
end


handle_message(logger::LevelOverrideLogger, args...; kwargs...) =
handle_message(logger.logger, args...; kwargs...)

function shouldlog(logger::LevelOverrideLogger, level, args...)
# Ignore the logger.logger's own level and instead check the override level
level >= logger.level && shouldlog(logger.logger, level, args...)
quinnj marked this conversation as resolved.
Show resolved Hide resolved
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't seem to override?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 1st way it overrides is by only returning its own level from min_enabled_level (other loggers typically return the max of itself and child loggers). Also in this case, it's only checking its own level and then dispatching to it's nested logger shouldlog; as far as I understand, loggers don't typically check levels in shouldlog because it happens before w/ min_enabled_level.

We're also just overriding the level; if there are other filters from the nested logger, then those will still be respected. If the nested logger does check levels in their shouldlog, then those will be respected. It's a bit tricky because it's hard to know exactly what the nested logger is going to do in shouldlog, but from initial tests, this seems to be working as expected.

Copy link
Member

@oxinabox oxinabox May 19, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like the comp_shouldlog which is called by all the composable loggers in the package checks both level and shouldlog.

min_enabled_level(logger) <= level && shouldlog(logger, args...)

There is a comment above it saying that because the loggering system itself won't check the level of child loggers.

I think I ran into a bug before I had this with something like TeeLogger(MinLevelFilter(Debug, ...), MinLevelFilter(Warn, ...))
Which should have the TeeLogger saying it's minlevel is Debug since it needs to recieve Debug level messages.
But then those need only to be accepted by one of the loggers and not by the other.

I think this can possibly be fixed by moving the logic from the recieving logger, up into the sending logger.
Which would better match how the logging system works outside the compositional structure.
So it only forwards to children that say they can accept it.
(vs right now where the child decides that it won't log it because of it's level)


I could be wrong here and this could all work
A test would prove it one way or the other

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can a test be added to check this case please

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This shouldlog method is exercised in the tests here:

LoggingExtras.with(level=Debug, verbosity=1) do
. i.e. we're ensuring that with the LevelOverrideLogger, that the LevelOverride Debug level is "overriding" the TestLogger Info level.

Is there a more specific/different test desired here?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes,
I want to see:

LoggingExtras.with(level=Debug, verbosity=1) do 
    with_logger(MinLevelLogger(Info, test_logger)) do
        @debug "This should show up, even though it is behind 2 info level filters"
    end
end

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure this can be made to work due to the way MinLevelLogger has it's shouldlog and min_level_enabled defined; i.e. the presence of a MinLevelLogger in a stack of loggers effectively "poisons" any other kind of level filtering. It's min_enabled_level calls max of its own level and its child logger, so it's going to raise the level of a LevelOverrideLogger. And because MinLevelLogger shouldlog calls comp_shouldlog of its child logger, and because comp_shouldlog checks the min_enabled_level of the child logger, it means LevelOverrideLogger gets overridden, even if we were to get back the min_enabled check.

It's a similar issue if LevelOverride wraps a MinLevelLogger; the LevelOverride will pass the level check, but then it calls the shouldlog of its child logger and the MinLevelLogger will "take over" as mentioned above.

I don't see an immediate/easy way to detangle all that without going big with an overhaul of all the other compositional loggers here, so I'm inclined to just put a note in the docs that the LevelOverrideLogger should be used independently from other level-filtering loggers.

Copy link
Member

@oxinabox oxinabox May 27, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I know how to fix it.
Which is a big overhaul of all the other compositional loggers.
But I don't think it will take me long.
Let's put that test case in.
Mark it broken, and open an issue cross referencing it, then merge this.
Then later tonight or tomorrow I will fix it.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, added the broken test.

end

min_enabled_level(logger::LevelOverrideLogger) = logger.level
catch_exceptions(logger::LevelOverrideLogger) = catch_exceptions(logger.logger)
91 changes: 91 additions & 0 deletions src/verbosity.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
function restore_callsite_source_position!(expr, src)
@assert expr.head == :escape
@assert expr.args[1].head == :macrocall
@assert expr.args[1].args[2] isa LineNumberNode
# used to fix the logging source file + line
# since we're lowering our verbose logging macros to the
# Logging.jl macros; otherwise, they would always report this (verbosity.jl)
# file as the logging callsite
expr.args[1].args[2] = src
return expr
end
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see what you are doing here.
Can we find a nicer way to do it?
The way i have seen Keno do this is strip all line numebrs from generate code and then just interpolate __source__ as the first statement.
https://github.com/JuliaDiff/ChainRulesCore.jl/blob/2d75b4be102bb41ba3ac6df6dec8bb9617b20f0f/src/rule_definition_tools.jl#L178-L186

The way I have done it before is similar, but with creating the Expr directly so no line numbers endup there to begin with:
https://github.com/JuliaDiff/ChainRulesCore.jl/blob/2d75b4be102bb41ba3ac6df6dec8bb9617b20f0f/src/tangent_types/thunks.jl#L134

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there something problematic w/ this approach? I copied this from elsewhere; I can't remember if I wrote it or someone else.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's just very unclear what expr.args[1].args[2] is
I guess something that would make it ok would be something like

    @assert expr.head == :escape
    @assert expr.args[1].head == :macrocall
    @assert expr.args[1].args[2] isa LineNumberNode
    expr.args[1].args[2] = src

As a form of self-checking documentation.


vlogmacrodocs = """
@debugv N msg args...
@infov N msg args...
@warnv N msg args...
@errorv N msg args...
oxinabox marked this conversation as resolved.
Show resolved Hide resolved

"Verbose" logging macros. Drop in replacements of standard logging macros, but an
additional verbosity level `N` is passed to indicate differing verbosity levels
for a given log level. The verbosity argument is subtracted from the base log level when passed down
to the core logging logic, so `@debugv 1 msg` will essentially call `@logmsg Debug-1 msg`.

An `LoggingExtras.LevelOverrideLogger`can then be used to filter on the `level` argument.
For convenience, the
[`LoggintExtras.with(f; level, verbosity)`](@ref) function is provided to temporarily
wrap the current logger with a log level and verbosity subtracted to filter while `f` is executed.
"""

"$vlogmacrodocs"
macro debugv(verbosity::Int, msg, exs...)
return restore_callsite_source_position!(
esc(:($Base.@logmsg (Logging.Debug - $verbosity) $msg $(exs...))),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be

Suggested change
esc(:($Base.@logmsg (Logging.Debug - $verbosity) $msg $(exs...))),
esc(:($Base.@logmsg (Logging.Info - $verbosity) $msg $(exs...))),

right?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think so?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not? Now this will not be within the Debug level range

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or I guess, + instead of -.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For me, I think of it like this: there are really two dimensions we're talking about: severity and verbosity. Sometimes, people view the log level severity spectrum as also corresponding to verbosity, but that's not quite right; the severity is more about the type of log, and how it should be seen in various scenarios. i.e. Debug is for non-normal/production cases where you're trying to see additional development-related information that's going on. Info is general things that happen/major changes in state or milestones. Warn is there's something non-fatal that was encountered. Error is something bad happened.

Verbosity, on the other hand, is the level of detail we want to see for a given severity. Now, I'll readily admit this principle doesn't seem super applicable/useful for non-Debug log levels, but for debugging, it really does make a strong point, and indeed many systems also include a Trace log level in this vein: sometimes you want even more debug information than Debug. But I would argue even for Error or Warn, there are theoretical cases where you might want to emit more verbose messages than the default if requested by the user (e.g. for an @inbounds error, maybe I want to see the full collection being indexed, where as by default you almost surely want some kind of compact printing).

All that to say, instead of actually introducing two dimensions for severity and verbosity, we're hijacking the fact that the log levels themselves are so spread out in the integer domain to say that log levels in between the standard severity levels correspond to verbosity instead of actually different categorical/types of logs. So in the same vein of the severity spectrum at least roughly/partly ordered by verbosity (i.e. I would expect more Trace or Debug messages than Error or Warn), we're saying that Debug - 1 is like Debug, but one level more verbose. And Debug - 2 is also like debug, but 2 levels more verbose, and 1 more level verbose than Debug - 1. So the levels less than the severity level, but greater than the next lower severity level constitute the possible "verbosity" levels for that severity level.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is actually kinda unfortunate positioning.

because we want @debugv 0 to be the same as @debug and to be the "most significant debug level"
Ideally we would have that Debug +0 is in the middle of the range that highlights as Debug so that Debug+1 and Debug-1 can both be considered as more or less significant debug-levelled values.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I looked briefly at changing this, but it didn't seem like an easy/obvious fix with my first attempt, mainly because of the directionality of log levels and filtering. Filtering always relies on filtering out lower levels, so it doesn't work to just invert our verbosity to be + instead of -.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, that is why i wish Debug +0 was in the middle of the range that highlights as Debug.
Can we have this changed up-stream in Julia, and then just live with the bad highlighting until 1.9 comes out?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thinking about this some more, I think I'd like to go back to the approach of using the group variable for verbosity. I think trying to leverage log levels has just muddied the waters too much. I also realized that there's one feature that "doesn't work" with the log-level approach: if I have a scenario like here, where, in the normal case, I just want to ignore any errors, but if I'm in a "verbose" mode, I'd like to at least log those kinds of errors, it doesn't work w/ our current log-level approach. Because @errorv 2 msg just becomes LogLevel.Error - 2, and that means it will always get printed, because it's "above" the default Info level.

So I think this has clarified in my mind that verbosity is really a 2nd dimension, in addition to log level, that I want to be able to filter on separately. i.e. if my verbosity=1, then I don't want to see the overly verbose @errorv 2 msg log. It also cements in my mind that log levels, while kind of a proxy for verbosity, just aren't. They're categorical values that give specific "types" to log messages.

If no one is strongly opposed, I'm going to move the LoggingExtras.@[x]v macros back to that approach.

I think another benefit of that approach is we should "fix" the log level printing color issue? Since we're not modifying the actual log level at all?

__source__,
)
end

"$vlogmacrodocs"
macro infov(verbosity::Int, msg, exs...)
return restore_callsite_source_position!(
esc(:($Base.@logmsg (Logging.Info - $verbosity) $msg $(exs...))),
__source__,
)
end

"$vlogmacrodocs"
macro warnv(verbosity::Int, msg, exs...)
return restore_callsite_source_position!(
esc(:($Base.@logmsg (Logging.Warn - $verbosity) $msg $(exs...))),
__source__,
)
end

"$vlogmacrodocs"
macro errorv(verbosity::Int, msg, exs...)
return restore_callsite_source_position!(
esc(:($Base.@logmsg (Logging.Error - $verbosity) $msg $(exs...))),
__source__,
)
end

"$vlogmacrodocs"
macro logmsgv(verbosity::Int, level, msg, exs...)
quinnj marked this conversation as resolved.
Show resolved Hide resolved
return restore_callsite_source_position!(
esc(:($Base.@logmsg ($level - $verbosity) $msg $(exs...))),
__source__,
)
end

"""
LoggingExtras.withlevel(f, level; verbosity::Integer=0)

Convenience function like `Logging.with_logger` to temporarily wrap
the current logger with a level filter while `f` is executed.
That is, the current logger will still be used for actual logging, but
log messages will first be checked that they meet the `level`
log level before being passed on to be logged.
"""
function withlevel(f, level::Union{Int, LogLevel}=Info; verbosity::Integer=0)
lvl = Base.CoreLogging._min_enabled_level[]
try
# by default, this global filter is Debug, but for debug logging
# we want to enable sub-Debug levels
Base.CoreLogging._min_enabled_level[] = BelowMinLevel
quinnj marked this conversation as resolved.
Show resolved Hide resolved
with_logger(LevelOverrideLogger(level - verbosity, current_logger())) do
f()
end
finally
Base.CoreLogging._min_enabled_level[] = lvl
end
end
48 changes: 48 additions & 0 deletions test/runtests.jl
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,54 @@ end
@test !logger.always_flush
end

@testset "LevelOverrideLogger" begin
logger = TestLogger(min_level=Info)
with_logger(LevelOverrideLogger(Debug, logger)) do
@debug "debug message"
@info "info message"

end
@test length(logger.logs) == 2
@test map(x -> x.level, logger.logs) == [Debug, Info]
end

@testset "Verbosity utilities" begin
logger = TestLogger(min_level=Info)
with_logger(logger) do
@infov 1 "info 1 message"
end
@test isempty(logger.logs)

logger = TestLogger(min_level=Info)
with_logger(logger) do
LoggingExtras.withlevel(Debug) do
@debug "debug message"
end
end
@test logger.logs[1].level == Debug

logger = TestLogger(min_level=Info)
with_logger(logger) do
LoggingExtras.withlevel(Debug; verbosity=1) do
@debugv 0 "debug 0 message"
@debugv 1 "debug 1 message"
@debugv 2 "debug 2 message"
end
end
@test length(logger.logs) == 2
@test map(x -> x.level, logger.logs) == [Debug, Debug-1]

logger = TestLogger(min_level=Info)
with_logger(logger) do
with_logger(MinLevelLogger(current_logger(), Info)) do
LoggingExtras.withlevel(Debug; verbosity=1) do
@debug "This should show up, even though it is behind 2 info level filters"
end
end
end
@test_broken length(logger.logs) == 1
end

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

Expand Down