defmodule BetterstackLogger.LogParams do @moduledoc """ Parses and encodes incoming Logger messages for further serialization. """ alias BetterstackLogger.{Stacktrace, Utils} @default_metadata_keys Utils.default_metadata_keys() @doc """ Creates a LogParams struct when all fields have serializable values """ def encode(timestamp, level, message, metadata) do new(timestamp, level, message, metadata) end def new(timestamp, level, message, metadata) do message = encode_message(message) timestamp = encode_timestamp(timestamp) metadata = encode_metadata(metadata) {stacktrace, metadata} = Map.pop(metadata, "stacktrace") {system_context, user_context} = Map.split(metadata, @default_metadata_keys) system_context = system_context |> enrich(:vm) log_params = %{ "timestamp" => timestamp, "message" => message, "metadata" => user_context |> Map.put("level", Atom.to_string(level)) |> Map.put("context", system_context) } if stacktrace do put_in(log_params, ~w[metadata stacktrace], stacktrace) else log_params end end def enrich(context, :vm) do Map.merge(context, %{"vm" => %{"node" => "#{Node.self()}"}}) end @doc """ Encodes message, if is iodata converts to binary. """ def encode_message(message) do to_string(message) end @doc """ Converts erlang datetime tuple into ISO:Extended binary. """ def encode_timestamp({date, {hour, minute, second}}) do encode_timestamp({date, {hour, minute, second, 0}}) end def encode_timestamp({date, {hour, minute, second, {_micro, 6} = fractions_with_precision}}) do {date, {hour, minute, second}} |> NaiveDateTime.from_erl!(fractions_with_precision) |> NaiveDateTime.to_iso8601(:extended) |> Kernel.<>("Z") end def encode_timestamp({date, {hour, minute, second, milli}}) when is_integer(milli) do erldt = {date, {hour, minute, second}} |> :calendar.local_time_to_universal_time_dst() |> case do [] -> {date, {hour, minute, second}} [dt_utc] -> dt_utc [_, dt_utc] -> dt_utc end erldt |> NaiveDateTime.from_erl!({milli * 1000, 6}) |> NaiveDateTime.to_iso8601(:extended) |> Kernel.<>("Z") end def encode_metadata(meta) when is_list(meta) when is_map(meta) do meta |> encode_crash_reason() |> convert_mfa() |> convert_initial_call() |> traverse_convert() |> Map.drop(["report_cb", "erl_level"]) end @doc """ Adds formatted stacktrace to the metadata """ def encode_crash_reason(%{crash_reason: cr} = meta) when not is_nil(cr) do {_err, stacktrace} = cr meta |> Map.drop([:crash_reason]) |> Map.merge(%{stacktrace: Stacktrace.format(stacktrace)}) end def encode_crash_reason(meta), do: meta def convert_initial_call(%{initial_call: {m, f, a}} = meta) when is_integer(a) do %{meta | initial_call: {m, f, "#{a}"}} end def convert_initial_call(meta), do: meta def convert_mfa(%{mfa: {m, f, a}} = meta) when is_integer(a) do %{meta | mfa: {m, f, "#{a}"}} end def convert_mfa(meta), do: meta def traverse_convert(%NaiveDateTime{} = v), do: to_string(v) def traverse_convert(%DateTime{} = v), do: to_string(v) def traverse_convert(%{__struct__: _} = v) do v |> Map.from_struct() |> traverse_convert() end def traverse_convert(data) when is_map(data) do for {k, v} <- data, into: Map.new() do {traverse_convert(k), traverse_convert(v)} end end def traverse_convert(xs) when is_list(xs) do cond do Keyword.keyword?(xs) -> xs |> Enum.into(Map.new()) |> traverse_convert() length(xs) > 0 and List.ascii_printable?(xs) -> to_string(xs) true -> for x <- xs, do: traverse_convert(x) end end def traverse_convert(x) when is_tuple(x) do x |> Tuple.to_list() |> traverse_convert() end @doc """ All atoms are converted to strings for Betterstack server to be able to safely convert binary to terms using :erlang.binary_to_term(binary, [:safe]) """ def traverse_convert(x) when is_boolean(x), do: x def traverse_convert(nil), do: nil def traverse_convert(x) when is_atom(x), do: Atom.to_string(x) def traverse_convert(x) when is_function(x), do: inspect(x) def traverse_convert(x) when is_pid(x) do x |> :erlang.pid_to_list() |> to_string() end def traverse_convert(x), do: x end