defmodule LogfmtEx do @moduledoc ~S""" A convenience for formatting logs in logfmt, to be used with the `Logger.Backends.Console` backend. To use, specify the `{LogfmtEx, :format}` tuple in the backend's configuration, replacing the format string: config :logger, :console, format: {LogfmtEx, :format} ## Logfmt In logfmt, each line consists of a single level of key=value pairs, densely packed together. For example: Logger.info("I am a message", user_id: 123) given the configuration config :logger, :console, format: {LogfmtEx, :format}, metadata: [:user_id, :pid, :file] config :logfmt_ex, :opts, message_key: "msg", timestamp_key: "ts", timestamp_format: :iso8601 would emit: level=info msg="I am a message" ts="12:38:38.055 1973-03-12" user_id=123 pid=#PID<0.223.0> file=myapp/some_module.exs ## Configuration Several aspects of the format function can be customized via the application env in a `config/config.exs` file, under `config :logfmt_ex, :opts`: * `:delimiter` - defaults to `=`. * `:format` - A list of atoms that defines the order in which key/value pairs will written to the log line. Defaults to `[:timestamp, :level, :message, :metadata]`. Valid parameters are * `:timestamp` - the timestamp of the log message * `:level` - the log level * `:message` - the log message itself * `:metadata` - metadata as key=value paris * `:node` - the node name * `timestamp_key` - changes the key used for the timestamp field. Defaults to `timestamp`. * `timestamp_format` - How the timestamp is formatted. Defaults to `:elixir`. The options are * `:elixir` - Uses the same formatting functions found in the standard elixir log formatter. Example: `"12:38:38.055 1973-03-12"` * `:epoch_seconds` - outputs an integer representing the number of seconds elapsed since January 1, 1970. Only useful for applications that emit logs sporadically. * `:iso8601` - Formats the timestamp according to ISO8601-2019. Example: `2000-02-29T23:00:07` * `level_key` - the key used for the log level. Defaults to `level`. * `message_key` - the key used for the message field. Defaults to `message`, but `msg` is a popular alternative. For encoding your own structs and types, see the `LogfmtEx.ValueEncoder` protocol. """ alias LogfmtEx.Encoder @unix_epoch 62_167_219_200 @default_level_key "level" @default_message_key "message" @default_timestamp_key "timestamp" @default_timestamp_format :elixir @default_format [:timestamp, :level, :message, :metadata] @node "node" @typedoc """ Valid pattern keys. These mostly mimic the pattern keys in `Logger.Formatter`, though :time and :date are merged into :timestamp. """ @type pattern_keys :: :timestamp | :level | :message | :metadata | :node @typedoc """ A pattern is a list of valid pattern keys that determines in which order the key=value pairs are printed. It defaults to #{inspect(@default_format)}. """ @type pattern :: list(pattern_keys()) @doc """ The main formatting function. It is invoked by the console backend with four arguments: * the log level: an atom (`t:atom/0`) * the message: this is usually `t:IO.chardata/0` * the current timestamp: a term of type `t:Logger.Formatter.time/0` * the metadata: a keyword list (`t:keyword/0`) May optionally be passed a list of options that is merged into the Application environment. """ @spec format(Logger.level(), any(), Logger.Formatter.time(), Keyword.t()) :: IO.chardata() def format(level, message, {date, time}, metadata, opts \\ []) do opts = :logfmt_ex |> Application.get_env(:opts, []) |> Keyword.merge(opts) 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(:iso8601, date_and_time) do date_and_time |> to_datetime() |> NaiveDateTime.to_iso8601() end defp encode_timestamp(:epoch_seconds, date_and_time) do {seconds, _microseconds} = date_and_time |> to_datetime() |> NaiveDateTime.to_gregorian_seconds() seconds - @unix_epoch end defp to_datetime({{hour, minute, second, millisecond}, {year, month, day}}) do date = Date.new!(year, month, day) time = Time.new!(hour, minute, second, {millisecond * 1000, 3}) NaiveDateTime.new!(date, time) 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}) 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) # find a better way to handle IOData input for message. # Maybe all these encode functions should live in encoder.ex |> Encoder.encode(message |> IO.iodata_to_binary()) 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