Packages

FlexLogger adds module/application specific log levels to Elixir's Logger.

Current section

Files

Jump to
flex_logger lib flex_logger.ex
Raw

lib/flex_logger.ex

defmodule FlexLogger do
@moduledoc """
`FlexLogger` is a flexible logger (backend) that adds module/application specific log levels to Elixir's `Logger`.
`FlexLogger` brings the following additions to the table:
* Configuration of log levels per application, module or even function
* Possibility of having multiple logger configurations for different applications or modules
## Configuration
`FlexLogger` is configured as a named backend to `Logger`. Following is an example configuration
of a single `FlexLogger` in combination with a :console logger
config :logger,
backends: [{FlexLogger, :logger_name}]
config :logger, :logger_name,
logger: :console,
default_level: :debug, # this is the loggers default level
level_config: [ # override default levels
[module: Foo, level: :info]
],
format: "DEV $message" # backend specific configuration
The configuration for `FlexLogger` as well as the underlying actual log backend are under the
named config. `FlexLogger` knows the following configuration options:
* `logger:` The actual logger backend to use. In case of `Logger.Backend.Console` you can also use the :console shortcut.
* `default_level:` The default log level to use. This should be one of [:off, :debug, :info, :warn, :error]. In addition
to the standard four log levels the :off level allows to turn of logging for either individual modules or if used
as default_level to turn of logging per default to then only enable logging for individual modules or applications
* `level_config:` A list of log level configurations for modules and applications. Each entry should be a keyword list.
If only a single entry is present the config can be simplified to only a single keyword list like
level_config: [application: :my_app, level: :info]
Possible configuration options are `:application`, to match the application, `:module` to match a prefix of a module,
`:function` to match a particular function or `:message` to match a particular message (see below).
The level is set via `:level`. The following configuration
level_config: [
[application: :my_app, module: Foo.Bar, level: :debug]
[function: "some_function/1", level: :error]
]
would set the log level for any module that starts with `Foo.Bar` in application `:my_app` to :debug. In addition
the log level for any function called `some_function` and that has arity 1 is set to `:error`. Note that if a key
(ie., :application, :module or :function) is not present then it matches anything.
Via the `:message` key you can define specific log levels based on the content of the logged message. This is
particularly useful in case of filtering out log messages coming from modules that use Erlang's `:error_logger`
in which case no other metadata is available. In case a string is provided for `:message` then `FlexLogger` checks
whether the log message contains the provided string. In case a regular expression is given the log message is matched
against the regular expression. In case a function with arity 1 is provided, the message is passed to that function
which should return a boolean value. Following is an example config that matches the log message against
a regular expression
level_config: [
[message: ~r/foo/, level: :debug]
]
### Backend specific configuration
The entire configuration is passed onto the actual logger for configuration. For example, if you configure
the `LoggerFileBackend` which takes a `path` parmameter you can do this as follows:
config :logger,
backends: [{FlexLogger, :foo_file_logger}]
config :logger, :foo_file_logger,
logger: LoggerFileBackend, # The actual backend to use (for example :console or LoggerFileBackend)
default_level: :off, # this is the loggers default level
level_config: [ # override default levels
[module: Foo, level: :info] # available keys are :application, :module, :function
],
path: "/tmp/foo.log", # backend specific configuration
format: "FOO $message" # backend specific configuration
### Logger Specific Configuration
`Logger` specific configuration, i.e., not backend specific configuration needs to be specified at the usual place,
for example
config :logger,
handle_otp_reports: true,
handle_sasl_reports: true
## Supported Backends
`FlexLogger` has been tested with :console and `LoggerFileBackend` but should also work with other logging backends.
"""
@behaviour :gen_event
defmodule State do
@moduledoc false
defstruct name: nil, logger: nil, logger_state: nil, level: :info, level_config: [], metadata_filter: []
end
def init({__MODULE__, name}) do
{:ok, configure(name, [])}
end
@doc """
Updates configuration of flex_logger and underlying logger.
Underlying logger may not be changed.
"""
def handle_call({:configure, opts}, %State{name: name} = state) do
{:ok, :ok, configure(name, opts, state)}
end
def handle_call(_opts, %{logger: nil} = state) do
{:ok, :no_logger, state}
end
def handle_call(opts, %{logger: logger, logger_state: logger_state} = state) do
# forward to logger
{flag, reply, updated_logger_state} =
logger.handle_call(opts, logger_state)
{flag, reply, %State{state| logger_state: updated_logger_state}}
end
def handle_event(_opts, %{logger: nil} = state) do
# ignore, no logger set
{:ok, state}
end
def handle_event({level, gl, {Logger, msg, ts, md}}, %{logger: logger, logger_state: logger_state} = state) do
if should_log?(md, msg, level, state.level, state.level_config) do
{flag, updated_logger_state} =
logger.handle_event({level, gl, {Logger, msg, ts, md}}, logger_state)
{flag, %State{state | logger_state: updated_logger_state}}
else
{:ok, state}
end
end
def handle_event(opts, %{logger: logger, logger_state: logger_state} = state) do
# we forward to logger
{flag, updated_logger_state} =
logger.handle_event(opts, logger_state)
{flag, %State{state | logger_state: updated_logger_state}}
end
def handle_info(_opts, %{logger: nil} = state), do: {:ok, state}
def handle_info(opts, %{logger: logger, logger_state: logger_state} = state) do
{flag, updated_logger_state} =
logger.handle_info(opts, logger_state)
{flag, %State{state | logger_state: updated_logger_state}}
end
def handle_info(_, state) do
# ignore
{:ok, state}
end
def code_change(_old_vsn, state, _extra) do
# ignore
{:ok, state}
end
def terminate(_reason, _state) do
# ignore
:ok
end
# helper
defp should_log?(md, msg, level, default_level, level_config) do
case check_level_configs(md, msg, level, level_config) do
{:match, do_log?} -> do_log?
:no_match -> meet_level?(level, default_level)
end
end
defp meet_level?(_lvl, nil), do: true
defp meet_level?(_lvl, :off), do: false
defp meet_level?(lvl, min) do
Logger.compare_levels(lvl, min) != :lt
end
# tests the metadata against the level_config configuration.
# returns
# {:match, false} - in case the config matches and the log call should not be passed on
# {:match, true} - in case the config matches and the log call should be passed on
# {:no_match} - in case no config matches
defp check_level_configs(_md, _msg, _level, nil), do: :no_match
defp check_level_configs(_md, _msg, _level, []), do: :no_match
defp check_level_configs(md, msg, level, [config | level_configs]) do
case check_module_against_config(md, msg, level, config) do
:no_match ->
check_level_configs(md, msg, level, level_configs)
{:match, level_matches} ->
{:match, level_matches}
end
end
defp check_module_against_config(md, msg, level, config) do
app = Keyword.get(md, :application, nil)
module = Keyword.get(md, :module, nil)
function = Keyword.get(md, :function, nil)
allowed_app = Keyword.get(config, :application, nil)
allowed_module = Keyword.get(config, :module, nil)
allowed_function = Keyword.get(config, :function, nil)
msg_matcher = Keyword.get(config, :message, nil)
if (not matches?(app, allowed_app) or
not matches_prefix?(module, allowed_module) or
not matches?(function, allowed_function) or
not message_matches?(msg, msg_matcher)) do
:no_match
else
min_level = Keyword.get(config, :level, :debug)
{:match, meet_level?(level, min_level)}
end
end
defp matches?(_, nil), do: true
defp matches?(nil, _), do: false
defp matches?(a, b), do: a == b
defp matches_prefix?(_, nil), do: true
defp matches_prefix?(nil, _), do: false
defp matches_prefix?(module, module_prefix) when is_atom(module) do
matches_prefix?(Atom.to_string(module), module_prefix)
end
defp matches_prefix?(module, module_prefix) when is_atom(module_prefix) do
matches_prefix?(module, Atom.to_string(module_prefix))
end
defp matches_prefix?(module, module_prefix) do
String.starts_with?(module, module_prefix)
end
defp message_matches?(_, nil), do: true
defp message_matches?(msg, msg_matcher) when is_binary(msg_matcher) do
String.contains?(msg, msg_matcher)
end
defp message_matches?(msg, %Regex{}=msg_matcher) do
Regex.match?(msg_matcher, msg)
end
defp message_matches?(msg, msg_matcher) when is_function(msg_matcher) do
msg_matcher.(msg)
end
defp configure(name, opts), do: configure(name, opts, %State{})
defp configure(name, opts, %State{} = state) do
env = Application.get_env(:logger, name, [])
opts = Keyword.merge(env, opts)
old_logger = state.logger
logger = translate_logger(Keyword.get(opts, :logger, nil))
logger_state = cond do
is_nil(logger) ->
nil
old_logger == logger ->
update_logger_config(logger, opts, state.logger_state)
true ->
{:ok, logger_state} = init_logger(logger, name)
update_logger_config(logger, opts, logger_state)
end
%State{state |
name: name,
logger: logger,
logger_state: logger_state,
level: Keyword.get(opts, :default_level, :debug),
level_config: clean_level_config(Keyword.get(opts, :level_config, [])),
}
end
defp update_logger_config(logger, opts, logger_state) do
{:ok, :ok, updated_logger_state} = logger.handle_call({:configure, opts}, logger_state)
updated_logger_state
end
defp clean_level_config([]), do: []
defp clean_level_config(cnf) do
if Keyword.keyword?(cnf) do
[cnf]
else
cnf
end
end
defp translate_logger(:console), do: Logger.Backends.Console
defp translate_logger(logger), do: logger
defp init_logger(nil), do: nil
defp init_logger(Logger.Backends.Console), do: Logger.Backends.Console.init(:console)
defp init_logger(logger), do: logger.init(logger)
defp init_logger(logger, name) do
try do
logger.init({logger, name})
rescue
_ -> init_logger(logger)
end
end
end