defmodule FluxError do @moduledoc """ Exception module to improve error management. """ @moduledoc since: "0.0.1" require Logger @typedoc """ `FluxError` exception struct. The `:reason` will be used to identify the error message. The `:metadata` will be used to inject data into the error message. """ @typedoc since: "0.0.1" @type t :: %FluxError{ reason: atom, metadata: map } defexception [:reason, :metadata] @default_options Application.compile_env(:flux_error, :default_options, []) @messages_module Application.compile_env( :flux_error, :messages_module, FluxError.DefaultMessages ) @doc """ Create `t:FluxError.t/0` from an external error. ## Parameters - `error` - An `t:atom/0` or `t:Exception.t/0`. If `t:atom/0` which will be inserted at the metadata `:detail` key. If `t:Exception.t/0`, it will try to get the `:reason` value, otherwise it will generate a `t:String.t/0` from the `t:Exception.t/0` map. - `reason` - An `t:atom/0` identifying the error. Defaults to `:unhandled_error`. - `metadata` - A `t:keyword/0` with detailed data about the error. Defaults to empty `t:list/0`. ## Examples iex> FluxError.from(:external_failure, :external_module_failed, module: :external) %FluxError{ reason: :external_module_failed, metadata: %{ detail: :external_failure, module: :external } } """ @doc since: "0.0.1" @spec from(Exception.t() | atom, atom, keyword) :: FluxError.t() def from(error, reason \\ :unhandled_error, metadata \\ []) do detail = if is_atom(error) do error else Map.get(error, :reason, inspect(error)) end new(reason, [detail: detail] ++ metadata) end @doc """ Log the error, returning the `t:FluxError.t/0`. It will create a `t:FluxError.t/0` if error is an `t:atom/0` or other `t:Exception.t/0` using `FluxError.from/3`. The log will have two metadata values: - `:error_metadata` - The inspection of the `t:FluxError.t/0` `:metadata` key. - `:error_stacktrace` - The error stacktrace. ## Parameters - `error` - An `t:atom/0` or `t:Exception.t/0`. - `stacktrace` - The `t:Exception.stacktrace/0` of the error. ## Examples iex> try do ...> throw(:failed) ...> catch ...> error -> FluxError.log(error, __STACKTRACE__) ...> end %FluxError{ reason: :unhandled_error, metadata: %{ detail: :failed } } """ @doc since: "0.0.1" @spec log(Exception.t() | atom, Exception.stacktrace()) :: FluxError.t() def log(error, stacktrace) do error = check_error(error) Logger.error("#{error.reason}: #{message(error)}", error_metadata: inspect(error.metadata), error_stacktrace: Exception.format(:error, error, stacktrace) ) error end @doc """ Extract the error message. It will create a `t:FluxError.t/0` if error is an `t:atom/0` or other `t:Exception.t/0` using `FluxError.from/3`. The message is extracted based on `:messages_module` app config. Check `FluxError` for more information. ## Parameters - `error` - An `t:atom/0` or `t:Exception.t/0`. ## Examples iex> FluxError.message(:failed) "triggered unhandled error" """ @doc since: "0.0.1" @spec message(Exception.t() | atom) :: String.t() def message(error) do error = check_error(error) Enum.reduce(error.metadata, message_raw(error), &fetch_metadata/2) end @doc """ Create `t:FluxError.t/0`. ## Parameters - `reason` - An `t:atom/0` identifying the error. - `metadata` - A `t:keyword/0` with detailed data about the error. Defaults to empty `t:list/0`. ## Examples iex> FluxError.new(:failed, level: :severe) %FluxError{ reason: :failed, metadata: %{ level: :severe } } """ @doc since: "0.0.1" @spec new(atom, keyword) :: FluxError.t() def new(reason, metadata \\ []) do %FluxError{reason: reason, metadata: Map.new(metadata)} end @doc """ Generate a `t:map/0` based on error data. It will create a `t:FluxError.t/0` if error is an `t:atom/0` or other `t:Exception.t/0` using `FluxError.from/3`. ## Parameters - `error` - An `t:atom/0` or `t:Exception.t/0`. - `opts` - A `t:keyword/0` with message options. Check `FluxError.Messages` for more information. Defaults to empty `t:list/0` ## Examples iex> FluxError.to_map(:failed) %{ error: true, id: :unhandled_error, message: "triggered unhandled error", message_raw: "triggered unhandled error", metadata: %{ detail: :failed } } """ @doc since: "0.0.1" @spec to_map(Exception.t() | atom, keyword | nil) :: map def to_map(error, opts \\ []) do opts = Keyword.merge(@default_options, opts) error = check_error(error) %{ error: true, id: error.reason, message: message(error), message_raw: message_raw(error, opts), metadata: error.metadata } end defp fetch_metadata({key, value}, message) do String.replace(message, "%{#{key}}", fn _ -> to_string(value) end) end defp message_raw(error, opts \\ []) do apply(@messages_module, :get_message, [error.reason, opts]) end defp check_error(%FluxError{} = error), do: error defp check_error(error), do: from(error) end