lib/logster/plugs/logger.ex

defmodule Logster.Plugs.Logger do
  @moduledoc """
  A plug for logging request information in the format:

      method=GET path=/articles/some-article format=html controller=HelloPhoenix.ArticleController action=show params={"id":"some-article"} status=200 duration=0.402 state=set

  To use it, just plug it into the desired module.

      plug Logster.Plugs.Logger, log: :debug

  For Phoenix applications, replace `Plug.Logger` with `Logster.Plugs.Logger` in the `endpoint.ex` file:

      # plug Plug.Logger
      plug Logster.Plugs.Logger

  ## Options

    * `:log` - The log level at which this plug should log its request info.
      Default is `:info`.
    * `:formatter` - The formatter module to use, has to implement `format/1`.
      Default is `Logster.StringFormatter`.
    * `:renames` - Map of fields to rename, for example: `%{status: :mystatus}`.
    * `:excludes` - List of fields to exclude from the log, for example: `[:params]`.
  """

  require Logger
  alias Plug.Conn

  @default_filter_parameters ~w(password)
  @default_allowed_headers ~w()

  def init(opts) do
    opts
  end

  def call(conn, opts) do
    start_time = current_time()

    Conn.register_before_send(conn, fn conn ->
      Logger.log(log_level(conn, opts), fn ->
        formatter = Keyword.get(opts, :formatter, Logster.StringFormatter)
        renames = Keyword.get(opts, :renames, %{})
        stop_time = current_time()
        duration = time_diff(start_time, stop_time)

        []
        |> put_field(:method, renames, conn.method)
        |> put_field(:path, renames, conn.request_path)
        |> Keyword.merge(formatted_phoenix_info(conn))
        |> put_field(:params, renames, get_params(conn))
        |> put_field(:status, renames, conn.status)
        |> put_field(:duration, renames, formatted_duration(duration))
        |> put_field(:state, renames, conn.state)
        |> Keyword.merge(
          headers(
            conn.req_headers,
            Application.get_env(:logster, :allowed_headers, @default_allowed_headers)
          )
        )
        |> exclude(Keyword.get(opts, :excludes, []))
        |> formatter.format
      end)

      conn
    end)
  end

  defp put_field(keyword, default_key, renames, value) do
    Keyword.put(keyword, Map.get(renames, default_key, default_key), value)
  end

  defp headers(_, []), do: []

  defp headers(conn_headers, allowed_headers) do
    map =
      conn_headers
      |> Enum.filter(fn {k, _} -> Enum.member?(allowed_headers, k) end)
      |> Enum.into(%{}, fn {k, v} -> {k, v} end)

    [{:headers, map}]
  end

  defp exclude(keyword, excludes) do
    Keyword.drop(keyword, excludes)
  end

  defp current_time, do: :erlang.monotonic_time()

  defp time_diff(start, stop),
    do: (stop - start) |> :erlang.convert_time_unit(:native, :micro_seconds)

  defp formatted_duration(duration), do: duration / 1000

  defp formatted_phoenix_info(%{
         private: %{
           phoenix_format: format,
           phoenix_controller: controller,
           phoenix_action: action
         }
       }) do
    [
      {:format, format},
      {:controller, controller |> inspect},
      {:action, action |> Atom.to_string()}
    ]
  end

  defp formatted_phoenix_info(_), do: []

  defp get_params(%{params: _params = %Plug.Conn.Unfetched{}}), do: %{}

  defp get_params(%{params: params}) do
    params
    |> do_filter_params(
      Application.get_env(:logster, :filter_parameters, @default_filter_parameters)
    )
    |> do_format_values
  end

  def do_filter_params(%{__struct__: mod} = struct, params_to_filter) when is_atom(mod),
    do: do_filter_params(Map.from_struct(struct), params_to_filter)

  def do_filter_params(%{} = map, params_to_filter) do
    Enum.into(map, %{}, fn {k, v} ->
      if is_binary(k) && String.contains?(k, params_to_filter) do
        {k, "[FILTERED]"}
      else
        {k, do_filter_params(v, params_to_filter)}
      end
    end)
  end

  def do_filter_params([_ | _] = list, params_to_filter),
    do: Enum.map(list, &do_filter_params(&1, params_to_filter))

  def do_filter_params(other, _params_to_filter), do: other

  def do_format_values(%_{} = params), do: params |> Map.from_struct() |> do_format_values()

  def do_format_values(%{} = params), do: params |> Enum.into(%{}, &do_format_value/1)

  def do_format_value({key, value}) when is_binary(value) do
    if String.valid?(value) do
      {key, value}
    else
      {key, URI.encode(value)}
    end
  end

  def do_format_value(val), do: val

  defp log_level(%{private: %{logster_log_level: log_level}}, _opts), do: log_level
  defp log_level(_, log: log_level), do: log_level
  defp log_level(%{status: status}, _opts) when status >= 500, do: :error
  defp log_level(%{status: status}, _opts) when status >= 400, do: :warn
  defp log_level(_, _opts), do: :info
end