lib/logfmt_ex.ex

import Kernel, except: [inspect: 2]

defmodule LogfmtEx do
  @moduledoc ~S"""
  Formats logs in logfmt format.

  This module allows developers to specify a list of atoms that
  serves as template for log messages, for example:

  `[:timestamp, :level, :message, :metadata]`

  Will print an error message as:
    ```
    timestamp="18:43:12 2022-5-22" level=error message="oh no spaghettio" user_id=13
    ```

  The valid parameters you can use are:
    * `:timestamp` - the time and date the log message was sent
    * `:message` - the log message
    * `:level` - the log level
    * `:node` - the node that printed the message
    * `:metadata` - the metadata attached to the log

  """

  alias LogfmtEx.Encoder

  @type time :: {{1970..10000, 1..12, 1..31}, {0..23, 0..59, 0..59, 0..99}}
  @default_level_key "level"
  @default_message_key "message"
  @default_timestamp_key "timestamp"
  @default_timestamp_format :elixir
  @default_format [:timestamp, :level, :message, :metadata]
  @node "node"

  @type pattern_keys :: :timestamp | :level | :message | :metadata | :node
  @type pattern :: list(pattern_keys())

  @spec format(Logger.level(), any(), Logger.Formatter.time(), keyword()) :: iodata()
  def format(level, message, {date, time}, metadata) do
    opts = Application.get_env(__MODULE__, :opts, [])

    format(level, message, {date, time}, metadata, opts)
  end

  @spec format(Logger.level(), any(), Logger.Formatter.time(), keyword(), keyword()) :: iodata()
  def format(level, message, {date, time}, metadata, opts) do
    opts
    |> Keyword.get(:format, @default_format)
    |> Enum.map(&encode(&1, level, message, {date, time}, metadata, opts))
    |> Enum.intersperse(" ")
    |> add_newline()
  end

  @spec format_time({0..23, 0..59, 0..59, 0..999}) :: IO.chardata()
  defp format_time({hh, mi, ss, ms}) do
    [pad2(hh), ?:, pad2(mi), ?:, pad2(ss), ?., pad3(ms)]
  end

  defp format_date({yy, mm, dd}) do
    [Integer.to_string(yy), ?-, pad2(mm), ?-, pad2(dd)]
  end

  defp pad2(int) when int < 10, do: [?0, Integer.to_string(int)]
  defp pad2(int), do: Integer.to_string(int)

  defp pad3(int) when int < 10, do: [?0, ?0, Integer.to_string(int)]
  defp pad3(int) when int < 100, do: [?0, Integer.to_string(int)]
  defp pad3(int), do: Integer.to_string(int)

  defp add_newline(log) do
    [log | "\n"]
  end

  defp encode_timestamp(:elixir, {time, date}) do
    [format_time(time), " ", format_date(date)]
  end

  # TODO: optimize this - but implementing a protocol for iolist/iodata seems hard.
  defp encode(:timestamp, _level, _message, {date, time}, _metadata, opts) do
    timestamp_key = opts |> Keyword.get(:timestamp_key, @default_timestamp_key)

    timestamp =
      opts
      |> Keyword.get(:timestamp_format, @default_timestamp_format)
      |> encode_timestamp({time, date})
      |> IO.iodata_to_binary()

    Encoder.encode(timestamp_key, timestamp)
  end

  defp encode(:level, level, _message, _date_time, _metadata, opts) do
    opts
    |> Keyword.get(:level_key, @default_level_key)
    |> Encoder.encode(level)
  end

  defp encode(:message, _level, message, _date_time, _metadata, opts) do
    opts
    |> Keyword.get(:message_key, @default_message_key)
    |> Encoder.encode(message)
  end

  defp encode(:node, _level, _message, _date_time, _metadata, _opts),
    do: Encoder.encode(@node, node())

  defp encode(:metadata, _level, _message, _date_time, metadata, opts) do
    metadata
    |> Enum.map(fn {key, value} -> Encoder.encode(key, value, opts) end)
    |> Enum.intersperse(" ")
  end
end