diff --git a/lib/tower.ex b/lib/tower.ex index 1252e94..65d2d7d 100644 --- a/lib/tower.ex +++ b/lib/tower.ex @@ -3,16 +3,32 @@ defmodule Tower do Documentation for `Tower`. """ - @doc """ - Hello world. + @default_reporters [Tower.EphemeralReporter] - ## Examples + def attach do + :ok = Tower.LoggerHandler.attach() + end - iex> Tower.hello() - :world + def detach do + :ok = Tower.LoggerHandler.detach() + end - """ - def hello do - :world + def report_exception(exception, stacktrace, meta \\ %{}) + when is_exception(exception) and is_list(stacktrace) do + reporters() + |> Enum.each(fn reporter -> + reporter.report_exception(exception, stacktrace, meta) + end) + end + + def report(type, reason, stacktrace, meta \\ %{}) when is_atom(type) and is_list(stacktrace) do + reporters() + |> Enum.each(fn reporter -> + reporter.report(type, reason, stacktrace, meta) + end) + end + + def reporters do + Application.get_env(:tower, :reporters, @default_reporters) end end diff --git a/lib/tower/ephemeral_reporter.ex b/lib/tower/ephemeral_reporter.ex new file mode 100644 index 0000000..7136345 --- /dev/null +++ b/lib/tower/ephemeral_reporter.ex @@ -0,0 +1,46 @@ +defmodule Tower.EphemeralReporter do + use Agent + + def start_link(_opts) do + Agent.start_link(fn -> [] end, name: __MODULE__) + end + + def report_exception(exception, stacktrace, meta \\ %{}) + when is_exception(exception) and is_list(stacktrace) do + Agent.update( + __MODULE__, + fn errors -> + [ + %{ + time: Map.get(meta, :time, :logger.timestamp()), + type: exception.__struct__, + reason: Exception.message(exception), + stacktrace: stacktrace + } + | errors + ] + end + ) + end + + def report(type, reason, stacktrace, meta \\ %{}) when is_atom(type) and is_list(stacktrace) do + Agent.update( + __MODULE__, + fn errors -> + [ + %{ + time: Map.get(meta, :time, :logger.timestamp()), + type: type, + reason: reason, + stacktrace: stacktrace + } + | errors + ] + end + ) + end + + def errors do + Agent.get(__MODULE__, & &1) + end +end diff --git a/lib/tower/logger_handler.ex b/lib/tower/logger_handler.ex new file mode 100644 index 0000000..e100c6c --- /dev/null +++ b/lib/tower/logger_handler.ex @@ -0,0 +1,56 @@ +defmodule Tower.LoggerHandler do + @default_level :error + @handler_id :tower + + def attach do + :logger.add_handler(@handler_id, __MODULE__, %{level: @default_level}) + end + + def detach do + :logger.remove_handler(@handler_id) + end + + # :logger callbacks + + def adding_handler(config) do + IO.puts("[Tower.LoggerHandler] ADDING config=#{inspect(config)}") + + {:ok, config} + end + + def removing_handler(config) do + IO.puts("[Tower.LoggerHandler] REMOVING config=#{inspect(config)}") + + :ok + end + + def log(%{level: :error, meta: %{crash_reason: {exception, stacktrace}} = meta}, _config) + when is_exception(exception) and is_list(stacktrace) do + IO.puts("[Tower.LoggerHandler] EXCEPTION #{inspect(exception)}") + + Tower.report_exception(exception, stacktrace, meta) + end + + def log( + %{level: :error, meta: %{crash_reason: {{:nocatch, reason}, stacktrace}} = meta}, + _config + ) + when is_list(stacktrace) do + IO.puts("[Tower.LoggerHandler] NOCATCH #{inspect(reason)}") + + Tower.report(:nocatch, reason, stacktrace, meta) + end + + def log(%{level: :error, meta: %{crash_reason: {exit_reason, stacktrace}} = meta}, _config) + when is_list(stacktrace) do + IO.puts("[Tower.LoggerHandler] EXIT #{inspect(exit_reason)}") + + Tower.report(:exit, exit_reason, stacktrace, meta) + end + + def log(log_event, _config) do + IO.puts( + "[Tower.LoggerHandler] UNHANDLED LOG EVENT log_event=#{inspect(log_event, pretty: true)}" + ) + end +end diff --git a/test/tower_test.exs b/test/tower_test.exs index a924f43..56f03b2 100644 --- a/test/tower_test.exs +++ b/test/tower_test.exs @@ -2,7 +2,161 @@ defmodule TowerTest do use ExUnit.Case doctest Tower - test "greets the world" do - assert Tower.hello() == :world + setup do + Tower.attach() + + on_exit(fn -> + Tower.detach() + end) + end + + test "starts with 0 exceptions" do + Tower.EphemeralReporter.start_link([]) + + assert [] = Tower.EphemeralReporter.errors() + end + + test "reports arithmetic error" do + Tower.EphemeralReporter.start_link([]) + + in_unlinked_process(fn -> + 1 / 0 + end) + + assert( + [ + %{ + time: _, + type: ArithmeticError, + reason: "bad argument in arithmetic expression", + stacktrace: stacktrace + } + ] = Tower.EphemeralReporter.errors() + ) + + assert is_list(stacktrace) + end + + test "reports a raise" do + Tower.EphemeralReporter.start_link([]) + + in_unlinked_process(fn -> + raise "error inside process" + end) + + assert( + [ + %{ + time: _, + type: RuntimeError, + reason: "error inside process", + stacktrace: stacktrace + } + ] = Tower.EphemeralReporter.errors() + ) + + assert is_list(stacktrace) + end + + test "reports a thrown string" do + Tower.EphemeralReporter.start_link([]) + + in_unlinked_process(fn -> + throw("error") + end) + + assert( + [ + %{ + time: _, + type: :nocatch, + reason: "error", + stacktrace: stacktrace + } + ] = Tower.EphemeralReporter.errors() + ) + + assert is_list(stacktrace) + end + + test "reports a thrown non-string" do + Tower.EphemeralReporter.start_link([]) + + in_unlinked_process(fn -> + throw(something: "here") + end) + + assert( + [ + %{ + time: _, + type: :nocatch, + reason: [something: "here"], + stacktrace: stacktrace + } + ] = Tower.EphemeralReporter.errors() + ) + + assert is_list(stacktrace) + end + + test "doesn't report an normal exit" do + Tower.EphemeralReporter.start_link([]) + + in_unlinked_process(fn -> + exit(:normal) + end) + + assert [] = Tower.EphemeralReporter.errors() + end + + test "reports an abnormal exit" do + Tower.EphemeralReporter.start_link([]) + + in_unlinked_process(fn -> + exit(:abnormal) + end) + + assert( + [ + %{ + time: _, + type: :exit, + reason: :abnormal, + stacktrace: stacktrace + } + ] = Tower.EphemeralReporter.errors() + ) + + assert is_list(stacktrace) + end + + test "reports a kill exit" do + Tower.EphemeralReporter.start_link([]) + + in_unlinked_process(fn -> + exit(:kill) + end) + + assert( + [ + %{ + time: _, + type: :exit, + reason: :kill, + stacktrace: stacktrace + } + ] = Tower.EphemeralReporter.errors() + ) + + assert is_list(stacktrace) + end + + defp in_unlinked_process(fun) when is_function(fun, 0) do + {:ok, pid} = Task.Supervisor.start_link() + + pid + |> Task.Supervisor.async_nolink(fun) + |> Task.yield() end end