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..10_000, 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.t()) :: 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.t(), Keyword.t()) ::
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
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})
# optimize this?
|> 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