defmodule RingLogger.Client do use GenServer @moduledoc """ Interact with the RingLogger """ alias RingLogger.Server defmodule State do @moduledoc false defstruct io: nil, colors: nil, metadata: nil, format: nil, level: nil, 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. """ def start_link(config \\ []) do GenServer.start_link(__MODULE__, config) end @doc """ Stop a client. """ def stop(client_pid) do GenServer.stop(client_pid) 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. """ @spec configure(GenServer.server(), [RingLogger.client_option()]) :: :ok def configure(client_pid, config) do GenServer.call(client_pid, {:config, 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 """ @spec grep(GenServer.server(), String.t() | Regex.t(), keyword()) :: :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}) pager = Keyword.get(opts, :pager, &IO.binwrite/2) pager.(io, to_print) end def grep(_client_pid, _regex, _opts) do {:error, :invalid_regex} end def init(config) do state = %State{ io: Keyword.get(config, :io, :stdio), colors: configure_colors(config), metadata: Keyword.get(config, :metadata, []) |> configure_metadata(), format: Keyword.get(config, :format) |> configure_formatter(), level: Keyword.get(config, :level, :debug), module_levels: Keyword.get(config, :module_levels, %{}) } {:ok, state} end def handle_info({:log, msg}, state) do _ = maybe_print(msg, state) {:noreply, state} end def handle_call({:config, config}, _from, state) do new_config = Keyword.drop(config, [:index]) {:reply, :ok, struct(state, new_config)} 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, []}, state} messages -> to_return = messages |> Enum.filter(fn message -> should_print?(message, state) end) |> Enum.map(fn message -> format_message(message, state) end) last_message = List.last(messages) next_index = message_index(last_message) + 1 {:reply, {state.io, to_return}, %{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}, _from, state) do to_return = Server.get(0, 0) |> Enum.filter(fn message -> should_print?(message, state) end) |> Enum.map(fn message -> format_message(message, state) end) |> Enum.map(&IO.iodata_to_binary/1) |> Enum.filter(&Regex.match?(regex, &1)) {: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_metadata(:all), do: :all defp configure_metadata(metadata), do: Enum.reverse(metadata) defp configure_colors(config) do colors = Keyword.get(config, :colors, []) %{ 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 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 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 end