defmodule Triage do @moduledoc """ Documentation for `Triage`. """ alias Triage.Stacktrace alias Triage.WrappedError require Logger require Stacktrace @doc """ Wraps error results with additional context information, leaving successful results unchanged. Takes a result tuple and wraps error cases (`:error` or `{:error, reason}`) with context information and metadata, returning `{:error, %Triage.WrappedError{}}`. Success cases (`:ok` or `{:ok, value}`) are passed through unchanged. ## Parameters * `result` - The result to potentially wrap (`:ok`, `{:ok, value}`, `:error`, or `{:error, reason}`) * `context` - Either a string describing the context or a map/keyword list of metadata ## Examples iex> Triage.wrap_context({:ok, 42}, "fetching user") {:ok, 42} iex> Triage.wrap_context(:error, "fetching user") {:error, %Triage.WrappedError{}} iex> Triage.wrap_context({:error, :not_found}, "fetching user", %{user_id: 123}) {:error, %Triage.WrappedError{}} iex> Triage.wrap_context({:error, :not_found}, %{user_id: 123}) {:error, %Triage.WrappedError{}} """ def wrap_context(:ok, _meta), do: :ok def wrap_context({:ok, result}, _meta) do {:ok, result} end def wrap_context(result, context) when is_binary(context) do wrap_context(result, context, %{}) end def wrap_context(result, metadata) when is_map(metadata) or is_list(metadata) do wrap_context(result, nil, metadata) end def wrap_context(result, context, meta \\ %{}) def wrap_context(:ok, _context, _meta), do: :ok def wrap_context({:ok, result}, _context, _meta) do {:ok, result} end def wrap_context(:error, context, metadata) do stacktrace = Stacktrace.calling_stacktrace() {:error, WrappedError.new(:error, context, stacktrace, metadata)} end def wrap_context({:error, reason}, context, metadata) do stacktrace = Stacktrace.calling_stacktrace() {:error, WrappedError.new({:error, reason}, context, stacktrace, metadata)} end @doc """ Executes a function that returns a result tuple, without exception handling. Calls the provided zero-arity function and chehcks that it returns a result (`:ok`, `{:ok, value}`, `:error`, or `{:error, reason}`). If the function returns any other value, it wraps it in `{:ok, value}`. This is the "unsafe" version that doesn't catch exceptions. Use `then/1` for exception handling. ## Parameters * `func` - A zero-arity function that returns a result ## Examples iex> then!(fn -> {:ok, 42} end) {:ok, 42} iex> then!(fn -> {:error, :not_found} end) {:error, :not_found} """ def then!(func) do case func.() do :ok -> :ok {:ok, _} = result -> result :error -> :error {:error, _} = result -> result other -> {:ok, other} end end @doc """ Executes a function with a previous result value, without exception handling. Takes a result from a previous then and, if successful, passes the unwrapped value to the provided function. If the previous result was an error, short-circuits and returns the error without calling the function. This is the "unsafe" version that doesn't catch exceptions. Use `then/2` for exception handling. ## Parameters * `result` - The previous result (`:ok`, `{:ok, value}`, `:error`, or `{:error, reason}`) * `func` - A function that takes the unwrapped value and returns a result ## Examples iex> then!({:ok, 5}, fn x -> {:ok, x * 2} end) {:ok, 10} iex> then!({:error, :not_found}, fn x -> {:ok, x * 2} end) {:error, :not_found} """ def then!(:ok, func) do case func.(nil) do :ok -> :ok {:ok, _} = result -> result :error -> :error {:error, _} = result -> result other -> {:ok, other} end end def then!({:ok, value}, func) do case func.(value) do :ok -> :ok {:ok, _} = result -> result :error -> :error {:error, _} = result -> result other -> {:ok, other} end end def then!(:error, _func), do: :error def then!({:error, _} = result, _func), do: result def then!(other, _), do: validate_result!(other) @doc """ Executes a function that returns a result tuple, with exception handling. Calls the provided zero-arity function and ensures it returns a valid result. If the function raises an exception, it catches it and returns `{:error, %Triage.WrappedError{}}` with details about the exception. ## Parameters * `func` - A zero-arity function that returns a result ## Examples iex> then(fn -> {:ok, 42} end) {:ok, 42} iex> then(fn -> raise "boom" end) {:error, %Triage.WrappedError{}} """ def then(func) do try do then!(func) rescue exception -> {:error, WrappedError.new_raised(exception, func, __STACKTRACE__)} end end @doc """ Executes a function with a previous result value, with exception handling. Takes a result from a previous then and, if successful, passes the unwrapped value to the provided function. If the previous result was an error, short-circuits and returns the error. If the function raises an exception, it catches it and returns `{:error, %Triage.WrappedError{}}` with details about the exception. ## Parameters * `result` - The previous result (`:ok`, `{:ok, value}`, `:error`, or `{:error, reason}`) * `func` - A function that takes the unwrapped value and returns a result ## Examples iex> then({:ok, 5}, fn x -> {:ok, x * 2} end) {:ok, 10} iex> then({:ok, 5}, fn _x -> raise "boom" end) {:error, %Triage.WrappedError{}} """ def then(result, func) do try do then!(result, func) rescue exception -> {:error, WrappedError.new_raised(exception, func, __STACKTRACE__)} end end def handle(:error, func), do: handle({:error, nil}, func) def handle({:error, reason}, func) do case func.(reason) do :ok -> :ok {:ok, _} = result -> result other -> {:error, other} end end def handle(result, _) do validate_result!(result) result end def map!(:ok, _), do: raise(ArgumentError, "Cannot pass :ok to map!/2") def map!({:ok, enumerable}, func) do Enum.map(enumerable, &then!({:ok, &1}, func)) end def map!(:error, _), do: :error def map!({:error, _} = error, _), do: error def map!(result, _) do raise ArgumentError, "Argument must be {:ok, _} / {:error, _} / :error, got: #{inspect(result)}" end def map(:ok, _), do: raise(ArgumentError, "Cannot pass :ok to map/2") def map({:ok, enumerable}, func) do Enum.map(enumerable, &Triage.then({:ok, &1}, func)) end def map(:error, _), do: :error def map({:error, _} = error, _), do: error def map(result, _) do raise ArgumentError, "Argument must be {:ok, _} / {:error, _} / :error, got: #{inspect(result)}" end def map_unless({:ok, value}, func), do: map_unless(value, func) def map_unless(:error, _), do: :error def map_unless({:error, _} = error, _), do: error def map_unless(values, func) do {:ok, Enum.map(values, fn value -> case func.(value) do # :ok -> {:ok, value} -> value :error -> throw({:__ERRORS__, :error}) {:error, _} = error -> throw({:__ERRORS__, error}) end end)} catch {:__ERRORS__, result} -> result end def find_value({:ok, input}, func), do: find_value(input, func) def find_value(:error, _), do: :error def find_value({:error, _} = error, _), do: error def find_value(input, func) do errors = Enum.map(input, fn value -> case func.(value) do :ok -> throw({:__ERRORS__, :ok}) {:ok, _} = result -> throw({:__ERRORS__, result}) :error -> nil {:error, reason} -> reason end end) {:error, errors} catch {:__ERRORS__, result} -> result end def all({:ok, input}, func), do: all(input, func) def all(:error, _), do: :error def all({:error, _} = error, _), do: error def all(input, func) do for value <- input do case func.(value) do :ok -> nil {:ok, _} -> nil :error -> throw({:__ERRORS__, :error}) {:error, _} = error -> throw({:__ERRORS__, error}) other -> validate_result!(other, "Callback return") end end :ok catch # Wrapping throw so that callback throws will not be caught by us {:__ERRORS__, error} -> error end # def telemetry(:ok, name \\ nil), do: telemetry({:ok, nil}, name) # # def telemetry(:ok, name), do: telemetry({:ok, nil}, name) # # def telemetry({:ok, _} = result, name) do # :telemetry.execute( # [:errors, :ok], # %{count: 1}, # %{name: name} # ) # # result # end # # def telemetry(:error, name), do: telemetry({:error, nil}, name) # # def telemetry({:error, reason}, name) do # :telemetry.execute( # [:errors, :error], # %{count: 1}, # Map.merge( # %{name: name}, # result_details(reason) # ) # ) # # {:error} # end # Telemetry metadata: # result_type: :ok / :error # result_value: # * 123 # * %MyApp.Accounts.User{id: 123, ...} # * #Ecto.Changeset def result_details({:error, %WrappedError{} = exception}) do errors = WrappedError.unwrap(exception) last_error = List.last(errors) metadata = Enum.reduce(errors, %{}, fn error, metadata -> Map.merge(metadata, error.metadata) end) result_details(last_error.result) |> Map.put(:metadata, metadata) |> Map.put(:message, Exception.message(exception)) end def result_details({:error, %mod{} = exception}) when is_exception(exception) do %{ type: "error", mod: mod, reason: Triage.JSON.Shrink.shrink(exception), message: "{:error, #{Triage.Inspect.inspect(exception)}} (message: #{exception_message(exception)})" } end def result_details({:error, reason}) do %{ type: "error", message: "{:error, #{Triage.Inspect.inspect(reason)}}", reason: Triage.JSON.Shrink.shrink(reason) } end def result_details(:error) do %{ type: "error", message: Triage.Inspect.inspect(:error) } end def result_details({:ok, value}) do %{ type: "ok", message: "{:ok, #{Triage.Inspect.inspect(value)}}", value: Triage.JSON.Shrink.shrink(value) } end def result_details(:ok) do %{type: "ok", message: Triage.Inspect.inspect(:ok)} end # If `result` isn't :ok/:error/{:ok, _}/{:error, _} then it was a raised exception def result_details(%mod{} = exception) when is_exception(exception) do %{ type: "raise", message: "** (#{inspect(mod)}) #{Exception.message(exception)}", reason: Triage.JSON.Shrink.shrink(exception) } end defp exception_message(%mod{} = exception) when is_exception(exception) do if function_exported?(mod, :message, 1) or Map.has_key?(struct(mod), :message) do Exception.message(exception) else Logger.warning( "Exception module `#{inspect(mod)}` doesn't have a `message` key or implement a `message/1` callback" ) inspect(exception) end end @doc """ Generates a user-friendly error message from various error types. Converts errors into human-readable messages suitable for displaying to end users. For wrapped errors, it unwraps the error chain and includes context information in the message. For exceptions and unknown error types, it generates a unique error code and logs the full error details for debugging. ## Parameters * `reason` - The error to convert (string, exception, `%Triage.WrappedError{}`, or any value) ## Examples iex> user_message("Invalid email") "Invalid email" iex> user_message(%Triage.WrappedError{}) "not found (happened while: fetching user => validating email)" iex> user_message(%RuntimeError{message: "boom"}) "There was an error. Refer to code: ABC12345" Log generated: ABC12345: Could not generate user error message. Error was: #RuntimeError<...> (message: boom) """ def user_message(reason) when is_binary(reason), do: reason def user_message(%WrappedError{} = error) do errors = WrappedError.unwrap(error) last_error = List.last(errors) context_string = Enum.map_join(errors, " => ", & &1.context) user_message(last_error.reason) <> " (happened while: #{context_string})" end def user_message(exception) when is_exception(exception) do error_code = Triage.String.generate(8) Logger.error( "#{error_code}: Could not generate user error message. Error was: #{Triage.Inspect.inspect(exception)} (message: #{exception_message(exception)})" ) "There was an error. Refer to code: #{error_code}" end def user_message(reason) do error_code = Triage.String.generate(8) Logger.error( "#{error_code}: Could not generate user error message. Error was: #{Triage.Inspect.inspect(reason)}" ) "There was an error. Refer to code: #{error_code}" end @doc """ Logs a result tuple and returns it unchanged. Takes a result and logs it using the configured log adapter. By default, only errors are logged. Use `mode: :all` to log both successes and errors. ## Parameters * `result` - The result to log (`:ok`, `{:ok, value}`, `:error`, or `{:error, reason}`) * `mode` - Either `:errors` (default, logs only errors) or `:all` (logs all results) """ def log(result, mode \\ :errors) do validate_result!(result) if mode not in [:errors, :all] do raise ArgumentError, "mode must be either :errors or :all (got: #{inspect(mode)})" end stacktrace = Stacktrace.calling_stacktrace() {message, result_details} = Map.pop(result_details(result), :message) if result_details.type in ~w[error raise] || mode == :all do level = if(result_details.type in ~w[error raise], do: :error, else: :info) stacktrace_line = stacktrace |> Stacktrace.most_relevant_entry() |> Stacktrace.format_file_line() parts_string = [stacktrace_line, message] |> Enum.reject(&is_nil/1) |> Enum.join(": ") {metadata, result_details} = Map.pop(result_details, :metadata, %{}) metadata = Map.put(metadata, :errors_result_details, result_details) Logger.log(level, "[RESULT] #{parts_string}", metadata) end result end @doc """ Checks if a result is a success (`:ok` or `{:ok, term()}`). Returns `true` if the result is `:ok` or `{:ok, term()}`, `false` if it's `:error` or `{:error, term()}`. Raises `ArgumentError` for any other value. ## Examples iex> Triage.ok?(:ok) true iex> Triage.ok?({:ok, 42}) true iex> Triage.ok?(:error) false iex> Triage.ok?({:error, :not_found}) false """ def ok?(:ok), do: true def ok?({:ok, _}), do: true def ok?(:error), do: false def ok?({:error, _}), do: false def ok?(result), do: validate_result!(result) @doc """ Checks if a result is an error (`:error` or `{:error, term()}`). Returns `true` if the result is `:error` or `{:error, term()}`, `false` if it's `:ok` or `{:ok, term()}`. Raises `ArgumentError` for any other value. ## Examples iex> Triage.error?(:error) true iex> Triage.error?({:error, :not_found}) true iex> Triage.error?(:ok) false iex> Triage.error?({:ok, 42}) false """ def error?(:ok), do: false def error?({:ok, _}), do: false def error?(:error), do: true def error?({:error, _}), do: true def error?(result), do: validate_result!(result) defp validate_result!(:ok), do: nil defp validate_result!(:error), do: nil defp validate_result!({:ok, _}), do: nil defp validate_result!({:error, _}), do: nil defp validate_result!(result, label \\ "Argument") do raise ArgumentError, "#{label} must be {:ok, _} / :ok / {:error, _} / :error, got: #{inspect(result)}" end end