defmodule RingLogger.Client do
use GenServer
require Logger
@moduledoc """
Interact with the RingLogger
"""
alias RingLogger.Server
defmodule State do
@moduledoc false
defstruct io: :stdio,
colors: %{
debug: :cyan,
info: :normal,
warn: :yellow,
error: :red,
enabled: IO.ANSI.enabled?()
},
metadata: [],
format: Logger.Formatter.compile(nil),
level: :debug,
index: 0,
module_levels: %{}
end
@doc """
Start up a client GenServer. Except for just getting the contents of the ring buffer, you'll
need to create one of these. See `configure/2` for information on options.
"""
@spec start_link(keyword()) :: GenServer.on_start()
def start_link(config \\ []) do
GenServer.start_link(__MODULE__, config)
end
@doc """
Stop a client.
"""
@spec stop(GenServer.server()) :: :ok
def stop(client_pid) do
GenServer.stop(client_pid)
end
@doc """
Fetch the current client configuration.
"""
@spec config(pid()) :: [RingLogger.client_option()]
def config(client_pid) do
GenServer.call(client_pid, :config)
end
@doc """
Update the client configuration.
Options include:
* `:io` - Defaults to `:stdio`
* `:colors` -
* `:metadata` - A KV list of additional metadata
* `:format` - A custom format string, or a {module, function} tuple (see
https://hexdocs.pm/logger/master/Logger.html#module-custom-formatting)
* `:level` - The minimum log level to report.
* `:module_levels` - a map of log level overrides per module. For example,
%{MyModule => :error, MyOtherModule => :none}
* `:application_levels` - a map of log level overrides per application. For example,
%{:my_app => :error, :my_other_app => :none}. Note log levels set in `:module_levels`
will take precedence.
"""
@spec configure(GenServer.server(), [RingLogger.client_option()]) :: :ok
def configure(client_pid, config) do
GenServer.call(client_pid, {:configure, config})
end
@doc """
Attach the current IEx session to the logger. It will start printing log messages.
"""
@spec attach(GenServer.server()) :: :ok
def attach(client_pid) do
GenServer.call(client_pid, :attach)
end
@doc """
Detach the current IEx session from the logger.
"""
@spec detach(GenServer.server()) :: :ok
def detach(client_pid) do
GenServer.call(client_pid, :detach)
end
@doc """
Get the last n messages.
Supported options:
* `:pager` - an optional 2-arity function that takes an IO device and what to print
"""
@spec tail(GenServer.server(), non_neg_integer()) :: :ok | {:error, term()}
def tail(client_pid, n, opts \\ []) do
{io, to_print} = GenServer.call(client_pid, {:tail, n})
pager = Keyword.get(opts, :pager, &IO.binwrite/2)
pager.(io, to_print)
end
@doc """
Get the next set of the messages in the log.
Supported options:
* `:pager` - an optional 2-arity function that takes an IO device and what to print
"""
@spec next(GenServer.server(), keyword()) :: :ok | {:error, term()}
def next(client_pid, opts \\ []) do
{io, to_print} = GenServer.call(client_pid, :next)
pager = Keyword.get(opts, :pager, &IO.binwrite/2)
pager.(io, to_print)
end
@doc """
Reset the index into the log for `tail/1` to the oldest entry.
"""
@spec reset(GenServer.server()) :: :ok
def reset(client_pid) do
GenServer.call(client_pid, :reset)
end
@doc """
Helper method for formatting log messages per the current client's
configuration.
"""
@spec format(GenServer.server(), RingLogger.entry()) :: :ok
def format(client_pid, message) do
GenServer.call(client_pid, {:format, message})
end
@doc """
Format and save all log messages to the specified path.
"""
@spec save(GenServer.server(), Path.t()) :: :ok | {:error, term()}
def save(client_pid, path) do
GenServer.call(client_pid, {:save, path})
end
@doc """
Run a regular expression on each entry in the log and print out the matchers.
Supported options:
* `:pager` - an optional 2-arity function that takes an IO device and what to print
* `:before` - Number of lines before the match to include
* `:after` - NUmber of lines after the match to include
"""
@spec grep(GenServer.server(), String.t() | Regex.t(), [RingLogger.client_option()]) ::
:ok | {:error, term()}
def grep(client_pid, regex_or_string, opts \\ [])
def grep(client_pid, regex_string, opts) when is_binary(regex_string) do
with {:ok, regex} <- Regex.compile(regex_string) do
grep(client_pid, regex, opts)
end
end
def grep(client_pid, %Regex{} = regex, opts) do
{io, to_print} = GenServer.call(client_pid, {:grep, regex, opts})
pager = Keyword.get(opts, :pager, &IO.binwrite/2)
pager.(io, to_print)
end
def grep(_client_pid, _regex, _opts) do
{:error, :invalid_regex}
end
@impl GenServer
def init(config) do
{:ok, configure_state(config)}
end
@impl GenServer
def handle_info({:log, msg}, state) do
_ = maybe_print(msg, state)
{:noreply, state}
end
@impl GenServer
def handle_call(:config, _from, state) do
config =
Map.from_struct(state)
|> Map.delete(:index)
|> Map.to_list()
{:reply, config, state}
end
def handle_call({:configure, config}, _from, state) do
{:reply, :ok, configure_state(config, state)}
end
def handle_call(:attach, _from, state) do
{:reply, Server.attach_client(self()), state}
end
def handle_call(:detach, _from, state) do
{:reply, Server.detach_client(self()), state}
end
def handle_call(:next, _from, state) do
case Server.get(state.index, 0) do
[] ->
# No messages
{:reply, {state.io, "No new messages.\n"}, state}
messages ->
to_return =
messages
|> Enum.filter(&should_print?(&1, state))
|> Enum.map(&format_message(&1, state))
last_message = List.last(messages)
next_index = message_index(last_message) + 1
rc = [to_return, summary(messages, to_return)]
{:reply, {state.io, rc}, %{state | index: next_index}}
end
end
def handle_call({:tail, n}, _from, state) do
to_return =
Server.tail(n)
|> Enum.filter(fn message -> should_print?(message, state) end)
|> Enum.map(fn message -> format_message(message, state) end)
{:reply, {state.io, to_return}, state}
end
def handle_call(:reset, _from, state) do
{:reply, :ok, %{state | index: 0}}
end
def handle_call({:grep, regex, opts}, _from, state) do
formatted_buff =
for {message, i} <- Enum.with_index(Server.get(0, 0)),
should_print?(message, state),
formatted = format_message(message, state),
bin = IO.chardata_to_string(formatted),
do: {bin, Regex.match?(regex, bin), i}
extras = determine_extra_grep_lines(formatted_buff, opts)
to_return =
for {bin, matched?, i} <- formatted_buff, matched? or i in extras do
if matched?, do: maybe_color_grep(bin, regex, state), else: bin
end
{:reply, {state.io, to_return}, state}
end
def handle_call({:format, msg}, _from, state) do
item = format_message(msg, state)
{:reply, item, state}
end
def handle_call({:save, path}, _from, state) do
rc =
try do
Server.get(0, 0)
|> Stream.map(&format_message(&1, state))
|> Stream.into(File.stream!(path))
|> Stream.run()
rescue
error in File.Error ->
{:error, error.reason}
end
{:reply, rc, state}
end
defp message_index({_level, {_, _msg, _ts, md}}), do: Keyword.get(md, :index)
defp format_message({level, {_, msg, ts, md}}, state) do
metadata = take_metadata(md, state.metadata)
state.format
|> apply_format(level, msg, ts, metadata)
|> color_event(level, state.colors, md)
end
## Helpers
defp apply_format({mod, fun}, level, msg, ts, metadata) do
apply(mod, fun, [level, msg, ts, metadata])
end
defp apply_format(format, level, msg, ts, metadata) do
Logger.Formatter.format(format, level, msg, ts, metadata)
end
defp configure_state(config, state \\ %State{}) do
defaults = build_defaults()
config =
Keyword.merge(defaults, config)
|> Keyword.drop([:index])
|> Enum.map(&configure_option(&1))
config = Keyword.put(config, :module_levels, configure_module_levels(config))
struct(state, config)
end
defp build_defaults do
deprecated_defaults = Application.get_all_env(:ring_logger)
defaults =
Application.get_env(:logger, RingLogger, [])
|> Keyword.put_new(:colors, [])
merge_deprecated_defaults(deprecated_defaults, defaults)
end
defp merge_deprecated_defaults([], defaults), do: defaults
defp merge_deprecated_defaults(deprecated_defaults, defaults) do
message = """
Setting RingLogger configuration under `:ring_logger` is deprecated. Instead configuration should be set under :logger, RingLogger
In your config.exs or other configuration file change:
config :ring_logger,
<configurations>
To:
config :logger, RingLogger,
<configurations>
"""
IO.warn(message)
Keyword.merge(deprecated_defaults, defaults)
end
defp configure_option({:colors, colors}) do
{:colors, configure_colors(colors)}
end
defp configure_option({:metadata, metadata}) do
{:metadata, configure_metadata(metadata)}
end
defp configure_option({:format, format}) do
{:format, configure_formatter(format)}
end
defp configure_option(opt), do: opt
defp configure_metadata(:all), do: :all
defp configure_metadata(metadata), do: Enum.reverse(metadata)
defp configure_colors(colors) when is_list(colors) do
%{
debug: Keyword.get(colors, :debug, :cyan),
info: Keyword.get(colors, :info, :normal),
warn: Keyword.get(colors, :warn, :yellow),
error: Keyword.get(colors, :error, :red),
enabled: Keyword.get(colors, :enabled, IO.ANSI.enabled?())
}
end
defp configure_colors(colors) when is_map(colors) do
configure_colors(Map.to_list(colors))
end
defp configure_colors(colors) do
_ =
Logger.warn("""
unknown RingLogger.Client colors option:
#{inspect(colors)}
Using defaults...
""")
configure_colors([])
end
defp meet_level?(_lvl, nil), do: true
defp meet_level?(_lvl, :none), do: false
defp meet_level?(lvl, min) do
Logger.compare_levels(lvl, min) != :lt
end
defp take_metadata(metadata, :all), do: metadata
defp take_metadata(metadata, keys) do
Enum.reduce(keys, [], fn key, acc ->
case Keyword.fetch(metadata, key) do
{:ok, val} -> [{key, val} | acc]
:error -> acc
end
end)
end
defp color_event(data, _level, %{enabled: false}, _md), do: data
defp color_event(data, level, %{enabled: true} = colors, md) do
color = md[:ansi_color] || Map.fetch!(colors, level)
[IO.ANSI.format_fragment(color, true), data, IO.ANSI.reset()]
end
defp configure_formatter({mod, fun}), do: {mod, fun}
defp configure_formatter(format) do
Logger.Formatter.compile(format)
end
def configure_module_levels(config) do
module_levels = Keyword.get(config, :module_levels, %{})
Keyword.get(config, :application_levels, %{})
|> Enum.reduce(module_levels, &add_module_levels_for_application/2)
end
defp add_module_levels_for_application({app, level}, module_levels) do
modules_for_application(app)
|> Enum.reduce(module_levels, &Map.put_new(&2, &1, level))
end
defp modules_for_application(app), do: Application.spec(app, :modules) || []
defp maybe_print(msg, state) do
if should_print?(msg, state) do
item = format_message(msg, state)
IO.binwrite(state.io, item)
end
end
defp get_module_from_msg({_, {_, _, _, meta}}) do
Keyword.get(meta, :module)
end
defp should_print?({level, _} = msg, %State{module_levels: module_levels} = state) do
module = get_module_from_msg(msg)
with module_level when not is_nil(module_level) <- Map.get(module_levels, module),
true <- meet_level?(level, module_level) do
true
else
nil ->
meet_level?(level, state.level)
_ ->
false
end
end
defp summary(messages, []) do
"All #{Enum.count(messages)} new messages filtered out.\n"
end
defp summary(messages, to_return) do
"\n#{Enum.count(to_return)} out of #{Enum.count(messages)} new messages shown.\n"
end
defp maybe_color_grep(bin, regex, %{colors: %{enabled: true}}) do
Regex.replace(regex, bin, &IO.ANSI.format_fragment([:inverse, &1, :inverse_off], true))
end
defp maybe_color_grep(bin, _regex, _state), do: bin
defp determine_extra_grep_lines(buff, opts) do
if Keyword.has_key?(opts, :before) or Keyword.has_key?(opts, :after) do
before = opts[:before] || 0
aft = opts[:after] || 0
for({_, true, i} <- buff, do: Enum.to_list((i - before)..(i + aft)))
|> List.flatten()
|> Enum.uniq()
else
[]
end
end
end