lib/metadata_logger.ex

defmodule MetadataLogger do
  @moduledoc """
  Logging with metadata.

  ## Configuration

  ```elixir
  config :logger, :console,
    level: :debug,
    format: {MetadataLogger, :format},
    colors: [enabled: false],
    metadata: :all,
    truncate: :infinity,
    utc_log: true
  ```
  """

  @doc """
  Formatter function to print message and metadata in a single-line json.

  Make sure all metadata except known metadata encodable by `Jason.encode_to_iodata!/2`.
  See `log_to_map/4` for transformation on known metadata.

  It removes `:function`, `:file`, and `:line` from the log.

  ## Examples

      iex> MetadataLogger.format(
      ...>   :info,
      ...>   ["hello", " ", "world"],
      ...>   {{2019, 11, 22}, {12, 23, 45, 678}},
      ...>   function: "hello/1",
      ...>   file: "/my/file.ex",
      ...>   line: 11,
      ...>   foo: :bar
      ...> ) |> Jason.decode!()
      %{
        "level" => "info",
        "message" => "hello world",
        "metadata" => %{"foo" => "bar"},
        "timestamp" => "2019-11-22T12:23:45.000678"
      }

      iex> MetadataLogger.format(
      ...>   :info,
      ...>   %{hello: :world},
      ...>   {{2019, 11, 22}, {12, 23, 45, 678}},
      ...>   function: "hello/1",
      ...>   file: "/my/file.ex",
      ...>   line: 11,
      ...>   foo: :bar
      ...> ) |> Jason.decode!()
      %{
        "level" => "info",
        "message" => %{"hello" => "world"},
        "metadata" => %{"foo" => "bar"},
        "timestamp" => "2019-11-22T12:23:45.000678"
      }

  """
  @spec format(Logger.level(), Logger.message(), Logger.Formatter.time(), keyword) ::
          IO.chardata()
  def format(level, message, ts, metadata) do
    map = log_to_map(level, message, ts, metadata)

    [
      map
      |> scrub()
      |> Jason.encode_to_iodata!(map),
      "\n"
    ]
  rescue
    e ->
      [
        %{
          level: "error",
          message: "could not format json message",
          logger_data: %{
            exception: inspect(e),
            level: inspect(level),
            ts: inspect(ts),
            message: inspect(message),
            metadata: inspect(metadata)
          }
        }
        |> Jason.encode!(),
        "\n"
      ]
  end

  @doc """
  Get a map from log formatter arguments.

  It converts `t:Logger.Formatter.time/0` into `NaiveDateTime` so it is recommended to configure logger to use UTC by setting `:utc_log` to `true`.

  It converts metadata keyword into map using `Enum.into/2` therefore duplicated keys will be removed.

  It moves following known metadata to the top level. See [Logger Metadata](https://hexdocs.pm/logger/Logger.html#module-metadata) for details.

  - `:application`
  - `:module`
  - `:function`
  - `:file`
  - `:line`
  - `:pid`
  - `:gl`
  - `:crash_reason`
  - `:initial_call`
  - `:registered_name`
  - `:domain`
  - `:ancestors`
  - `:callers`

  Followings metadata will be removed:

  - `:mfa`: see `:module` and `:funtion`
  - `:report_cb`

  ## Examples

      iex> MetadataLogger.log_to_map(
      ...>   :info,
      ...>   ["hello", " ", "world"],
      ...>   {{2019, 11, 22}, {12, 23, 45, 678}},
      ...>   foo: :bar
      ...> )
      %{
        level: :info,
        message: "hello world",
        metadata: %{foo: :bar},
        timestamp: ~N[2019-11-22 12:23:45.000678]
      }

      iex> MetadataLogger.log_to_map(
      ...>   :info,
      ...>   %{hello: :world},
      ...>   {{2019, 11, 22}, {12, 23, 45, 678}},
      ...>   []
      ...> )
      %{
        level: :info,
        message: %{hello: :world},
        metadata: %{},
        timestamp: ~N[2019-11-22 12:23:45.000678]
      }

      iex> MetadataLogger.log_to_map(
      ...>   :info,
      ...>   [foo: 1, foo: 2],
      ...>   {{2019, 11, 22}, {12, 23, 45, 678}},
      ...>   []
      ...> )
      %{
        level: :info,
        message: %{foo: 2},
        metadata: %{},
        timestamp: ~N[2019-11-22 12:23:45.000678]
      }

  """
  @spec log_to_map(
          Logger.level(),
          Logger.message() | Logger.report(),
          Logger.Formatter.time(),
          keyword
        ) ::
          map()
  def log_to_map(level, message, ts, metadata) do
    with m <- Enum.into(metadata, %{}),
         m <- Map.drop(m, [:error_logger, :mfa, :report_cb]),
         {app, m} <- Map.pop(m, :application),
         {module, m} <- Map.pop(m, :module),
         {function, m} <- Map.pop(m, :function),
         {file, m} <- Map.pop(m, :file),
         {line, m} <- Map.pop(m, :line),
         {pid, m} <- Map.pop(m, :pid),
         {gl, m} <- Map.pop(m, :gl),
         {ancestors, m} <- Map.pop(m, :ancestors),
         {callers, m} <- Map.pop(m, :callers),
         {crash_reason, m} <- Map.pop(m, :crash_reason),
         {initial_call, m} <- Map.pop(m, :initial_call),
         {domain, m} <- Map.pop(m, :domain),
         {registered_name, m} <- Map.pop(m, :registered_name) do
      %{metadata: m}
      |> put_val(:app, app)
      |> put_val(:module, module)
      |> put_val(:function, function)
      |> put_val(:file, file)
      |> put_val(:line, line)
      |> put_val(:pid, nil_or_inspect(pid))
      |> put_val(:gl, nil_or_inspect(gl))
      |> put_val(:crash_reason, nil_or_inspect(crash_reason))
      |> put_val(:initial_call, nil_or_inspect(initial_call))
      |> put_val(:registered_name, nil_or_inspect(registered_name))
      |> put_val(:domain, domain)
      |> put_val(:ancestors, nil_or_inspect_list(ancestors))
      |> put_val(:callers, nil_or_inspect_list(callers))
    end
    |> Map.put(:level, level)
    |> Map.put(:message, transform_message(message))
    |> Map.put(:timestamp, transform_timestamp(ts))
  end

  defp nil_or_inspect(nil), do: nil
  defp nil_or_inspect(val), do: inspect(val)

  defp nil_or_inspect_list(nil), do: nil
  defp nil_or_inspect_list(val), do: Enum.map(val, &inspect/1)

  defp put_val(map, _key, nil), do: map
  defp put_val(map, key, val), do: Map.put(map, key, val)

  defp transform_message(%_{} = m), do: to_string(m)
  defp transform_message(m) when is_map(m), do: m
  defp transform_message([{_k, _v} | _t] = m), do: Enum.into(m, %{})
  defp transform_message(m) when is_list(m), do: IO.iodata_to_binary(m)
  defp transform_message(m), do: to_string(m)

  defp transform_timestamp({{y, month, d}, {h, minutes, s, mil}}) do
    {:ok, dt} = NaiveDateTime.new(y, month, d, h, minutes, s, mil)
    dt
  end

  defp scrub(map) do
    map
    |> Map.delete(:function)
    |> Map.delete(:file)
    |> Map.delete(:line)
  end
end