defmodule CanonicalLogs do
@moduledoc """
Top-level API for CanonicalLogs.
"""
require Logger
@handler_id "canonical-logs-request-stop"
@doc """
Attaches CanonicalLogs handlers to `Plug.Telemetry` events to gather and log metadata at the end of each request.
## Options
* `:event_prefix` - The event prefix for `Plug.Telemetry` events. Defaults to `[:phoenix, :endpoint]`.
* `:filter_metadata_recursively` - A list of strings to filter out of the metadata. Defaults to `[]`. Any atoms passed to this option will be converted to strings.
## Examples
iex> CanonicalLogs.attach()
:ok
"""
@spec attach(
event_prefix: [atom(), ...],
conn_metadata: [atom()],
absinthe_metadata: [atom()],
filter_metadata_recursively: [String.t()]
) :: :ok
def attach(options \\ []) do
{event_prefix, opts} =
options
|> Keyword.update(:filter_metadata_recursively, [], fn filtered_keys ->
Enum.map(filtered_keys, &to_string/1)
end)
|> Keyword.pop(:event_prefix, [:phoenix, :endpoint])
# We don't care if it is already attached, so we ignore the return value.
:telemetry.attach(
@handler_id,
event_prefix ++ [:stop],
&__MODULE__.handle_plug_stop/4,
opts
)
end
def detach do
:telemetry.detach(@handler_id)
end
def handle_plug_stop(
_event_name,
%{duration: duration},
%{conn: conn} = event_metadata,
options
) do
%{duration: System.convert_time_unit(duration, :native, :millisecond)}
|> Map.merge(
get_conn_metadata(
event_metadata.conn,
Keyword.get(options, :conn_metadata, [:request_path, :method, :status, :params])
)
)
|> Map.merge(Logger.metadata() |> Enum.into(%{}))
|> filter_metadata(Keyword.fetch!(options, :filter_metadata_recursively))
|> Logger.metadata()
Logger.info([conn.method, ?\s, conn.request_path])
end
defp get_conn_metadata(%Plug.Conn{} = conn, retrieveFields) do
metadata =
conn
|> Map.take(retrieveFields)
metadata
end
@doc """
Filters metadata recursively by replacing values of keys that contain any of the given strings.
## Examples
iex> CanonicalLogs.filter_metadata(%{foo: "bar", baz: %{qux: "quux"}}, ["qux"])
%{baz: %{qux: "[FILTERED]"}, foo: "bar"}
"""
def filter_metadata(%{} = metadata, filtered_keys) do
Map.new(metadata, &filter_metadata(&1, filtered_keys))
end
def filter_metadata(metadata, filtered_keys) when is_list(metadata) do
if Keyword.keyword?(metadata) do
metadata
|> Map.new(&filter_metadata(&1, filtered_keys))
else
Enum.map(metadata, &filter_metadata(&1, filtered_keys))
end
end
def filter_metadata({key, value}, filtered_keys) do
string_key = to_string(key)
if Enum.any?(filtered_keys, &String.contains?(string_key, &1)) do
{key, "[FILTERED]"}
else
{key, filter_metadata(value, filtered_keys)}
end
end
def filter_metadata(metadata, _filtered_keys), do: metadata
end