diff --git a/dev.exs b/dev.exs index b09a6c6..a714943 100644 --- a/dev.exs +++ b/dev.exs @@ -149,6 +149,30 @@ defmodule ErrorTrackerDevWeb.Endpoint do def maybe_exception(conn, _), do: conn end +defmodule ErrorTrackerDev.Telemetry do + require Logger + + def start do + :telemetry.attach_many( + "error-tracker-events", + [ + [:error_tracker, :error, :new], + [:error_tracker, :error, :resolved], + [:error_tracker, :error, :unresolved], + [:error_tracker, :occurrence, :new] + ], + &__MODULE__.handle_event/4, + [] + ) + + Logger.info("Telemtry attached") + end + + def handle_event(event, measure, metadata, _opts) do + dbg([event, measure, metadata]) + end +end + defmodule Migration0 do use Ecto.Migration @@ -165,6 +189,8 @@ Task.async(fn -> ErrorTrackerDevWeb.Endpoint ] + ErrorTrackerDev.Telemetry.start() + {:ok, _} = Supervisor.start_link(children, strategy: :one_for_one) # Automatically run the migrations on boot diff --git a/guides/Getting Started.md b/guides/Getting Started.md index 64a7d5d..19745f8 100644 --- a/guides/Getting Started.md +++ b/guides/Getting Started.md @@ -126,3 +126,11 @@ You can also use `ErrorTracker.report/3` and set some custom context that will b ErrorTracker also provides a dashboard built with Phoenix LiveView that can be used to see and manage the recorded errors. This is completely optional, and you can find more information about it in the `ErrorTracker.Web` module documentation. + +## Notifications + +We currently do not support notifications out of the box. + +However, we provideo some detailed Telemetry events that you may use to implement your own notifications following your custom rules and notification channels. + +If you want to take a look at the events you can attach to, take a look at `ErrorTracker.Telemetry` module documentation. diff --git a/lib/error_tracker.ex b/lib/error_tracker.ex index 327ce86..b755c67 100644 --- a/lib/error_tracker.ex +++ b/lib/error_tracker.ex @@ -64,8 +64,11 @@ defmodule ErrorTracker do """ @type context :: %{String.t() => any()} + import Ecto.Query + alias ErrorTracker.Error alias ErrorTracker.Repo + alias ErrorTracker.Telemetry @doc """ Report an exception to be stored. @@ -104,15 +107,9 @@ defmodule ErrorTracker do context = Map.merge(get_context(), given_context) - error = - Repo.insert!(error, - on_conflict: [set: [status: :unresolved, last_occurrence_at: DateTime.utc_now()]], - conflict_target: :fingerprint - ) + {_error, occurrence} = upsert_error!(error, stacktrace, context, reason) - error - |> Ecto.build_assoc(:occurrences, stacktrace: stacktrace, context: context, reason: reason) - |> Repo.insert!() + occurrence end @doc """ @@ -124,7 +121,10 @@ defmodule ErrorTracker do def resolve(error = %Error{status: :unresolved}) do changeset = Ecto.Changeset.change(error, status: :resolved) - Repo.update(changeset) + with {:ok, updated_error} <- Repo.update(changeset) do + Telemetry.resolved_error(updated_error) + {:ok, updated_error} + end end @doc """ @@ -133,7 +133,10 @@ defmodule ErrorTracker do def unresolve(error = %Error{status: :resolved}) do changeset = Ecto.Changeset.change(error, status: :unresolved) - Repo.update(changeset) + with {:ok, updated_error} <- Repo.update(changeset) do + Telemetry.unresolved_error(updated_error) + {:ok, updated_error} + end end @doc """ @@ -180,4 +183,34 @@ defmodule ErrorTracker do {to_string(kind), to_string(other)} end end + + defp upsert_error!(error, stacktrace, context, reason) do + existing_status = + Repo.one(from e in Error, where: [fingerprint: ^error.fingerprint], select: e.status) + + error = + Repo.insert!(error, + on_conflict: [set: [status: :unresolved, last_occurrence_at: DateTime.utc_now()]], + conflict_target: :fingerprint + ) + + occurrence = + error + |> Ecto.build_assoc(:occurrences, stacktrace: stacktrace, context: context, reason: reason) + |> Repo.insert!() + + # If the error existed and was marked as resolved before this exception, + # sent a Telemetry event + # If it is a new error, sent a Telemetry event + case existing_status do + :resolved -> Telemetry.unresolved_error(error) + :unresolved -> :noop + nil -> Telemetry.new_error(error) + end + + # Always send a new occurrence Telemetry event + Telemetry.new_occurrence(occurrence) + + {error, occurrence} + end end diff --git a/lib/error_tracker/repo.ex b/lib/error_tracker/repo.ex index fa29432..ab8c448 100644 --- a/lib/error_tracker/repo.ex +++ b/lib/error_tracker/repo.ex @@ -17,6 +17,10 @@ defmodule ErrorTracker.Repo do dispatch(:get!, [queryable, id], opts) end + def one(queryable, opts \\ []) do + dispatch(:one, [queryable], opts) + end + def all(queryable, opts \\ []) do dispatch(:all, [queryable], opts) end diff --git a/lib/error_tracker/telemetry.ex b/lib/error_tracker/telemetry.ex new file mode 100644 index 0000000..94f31e7 --- /dev/null +++ b/lib/error_tracker/telemetry.ex @@ -0,0 +1,69 @@ +defmodule ErrorTracker.Telemetry do + @moduledoc """ + Telemetry events of ErrorTracker. + + ErrorTracker emits some events to allow third parties to receive information + of errors and occurrences stored. + + ### Error events + + Those occur during the life cycle of an error: + + * `[:error_tracker, :error, :new]`: is emitted when a new error is stored and + no previous occurrences were known. + + * `[:error_tracker, :error, :resolved]`: is emitted when a new error is marked + as resolved on the UI. + + * `[:error_tracker, :error, :unresolved]`: is emitted when a new error is + marked as unresolved on the UI or a new occurrence is registered, moving the + error to the unresolved state. + + ### Occurrence events + + There is only one event emitted for occurrences: + + * `[:error_tracker, :occurrence, :new]`: is emitted when a new occurrence is + stored. + + ### Measures and metadata + + Each event is emitted with some measures and metadata, which can be used to + receive information without having to query the database again: + + | event | measures | metadata | + | --------------------------------------- | -------------- | ------------- | + | `[:error_tracker, :error, :new]` | `:system_time` | `:error` | + | `[:error_tracker, :error, :unresolved]` | `:system_time` | `:error` | + | `[:error_tracker, :error, :resolved]` | `:system_time` | `:error` | + | `[:error_tracker, :occurrence, :new]` | `:system_time` | `:occurrence` | + """ + + @doc false + def new_error(error) do + measurements = %{system_time: System.system_time()} + metadata = %{error: error} + :telemetry.execute([:error_tracker, :error, :new], measurements, metadata) + end + + @doc false + def unresolved_error(error) do + measurements = %{system_time: System.system_time()} + metadata = %{error: error} + :telemetry.execute([:error_tracker, :error, :unresolved], measurements, metadata) + end + + @doc false + def resolved_error(error) do + measurements = %{system_time: System.system_time()} + metadata = %{error: error} + :telemetry.execute([:error_tracker, :error, :resolved], measurements, metadata) + end + + @doc false + def new_occurrence(occurrence) do + measurements = %{system_time: System.system_time()} + metadata = %{occurrence: occurrence} + :telemetry.execute([:error_tracker, :occurrence, :new], measurements, metadata) + end +end