Repository: elixir-error-tracker/error-tracker (elixir)

Issue:
Delete or prune errors
I'm unsure if that's already on your roadmap, but it would be great to have the option to:

* delete errors manually
* have errors being deleted automatically based on a pruning configuration (age and status)

Pull request description:
Prune resolved errors
This pull request adds a new `ErrorTracker.Plugins.Pruner` which automatically prunes resolved errors. This plugin can be used automatically or manually.

ℹ️ I've also added a new priv/repo/seeds.exs script that populates the db with some fake errors and occurrences. May be useful for testing the dashboard, pruning and other features easily.

## Automatic use

Register the plugin under the ErrorTracker configuration. The pruner will run in the background every 30 minutes and remove up to 1000 resolved errors older than 5 minutes (this options can be overriden)

```elixir
config :error_tracker,
  repo: MyApp.Repo,
  otp_app: :my_app,
  plugins: [ErrorTracker.Plugins.Pruner]
```

## Manual use

If you want to have more fine-grained control over when the pruner runs you can use the `ErrorTracker.Plugins.Pruner.prune_errors/1` function. In this case you must pass the `:limit` and `:max_age` options explicitly.

You may want to call this function from an Oban job to leverage its cron-like capabilities.

```elixir
defmodule MyApp.ErrorPruner do
  use Oban.Job

  def perform(%Job{}) do
    ErrorTracker.Plugins.Pruner.prune_errors(limit: 10_000, max_age: :timer.minutes(60))
  end
end
```

Closes #64 

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..45c0502
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,3 @@
+# Changelog
+
+Please see [our GitHub "Releases" page](https://github.com/elixir-error-tracker/error-tracker/releases).
diff --git a/guides/Getting Started.md b/guides/Getting Started.md
index a8c462d..7793e14 100644
--- a/guides/Getting Started.md
+++ b/guides/Getting Started.md
@@ -32,13 +32,16 @@ ErrorTracker needs a few configuration options to work. This configuration shoul
 ```elixir
 config :error_tracker,
   repo: MyApp.Repo,
-  otp_app: :my_app
+  otp_app: :my_app,
+  enabled: true
 ```
 
 The `:repo` option specifies the repository that will be used by ErrorTracker. You can use your regular application repository or a different one if you prefer to keep errors in a different database.
 
 The `:otp_app` option specifies your application name. When an error occurs, ErrorTracker will use this information to understand which parts of the stack trace belong to your application and which parts belong to third-party dependencies. This allows you to filter in-app vs third-party frames when viewing errors.
 
+The `:enabled` option (defaults to `true` if not present) allows to disable the ErrorTracker on certain environments. This is useful to avoid filling your dev database with errors, for example.
+
 ## Setting up the database
 
 Since ErrorTracker stores errors in the database you must create a database migration to add the required tables:
@@ -53,7 +56,7 @@ Open the generated migration and call the `up` and `down` functions on `ErrorTra
 defmodule MyApp.Repo.Migrations.AddErrorTracker do
   use Ecto.Migration
 
-  def up, do: ErrorTracker.Migration.up(version: 2)
+  def up, do: ErrorTracker.Migration.up(version: 3)
 
   # We specify `version: 1` in `down`, to ensure we remove all migrations.
   def down, do: ErrorTracker.Migration.down(version: 1)
@@ -138,3 +141,11 @@ 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.
+
+## Pruning resolved errors
+
+By default errors are kept in the database indefinitely. This is not ideal for production
+environments where you may want to prune old errors that have been resolved.
+
+The `ErrorTracker.Plugins.Pruner` module provides automatic pruning functionality with a configurable
+interval and error age.
diff --git a/lib/error_tracker.ex b/lib/error_tracker.ex
index 292569f..7477138 100644
--- a/lib/error_tracker.ex
+++ b/lib/error_tracker.ex
@@ -70,12 +70,16 @@ defmodule ErrorTracker do
   import Ecto.Query
 
   alias ErrorTracker.Error
+  alias ErrorTracker.Occurrence
   alias ErrorTracker.Repo
   alias ErrorTracker.Telemetry
 
   @doc """
   Report an exception to be stored.
 
+  Returns the occurrence stored or `:noop` if the ErrorTracker is disabled by
+  configuration the exception has not been stored.
+
   Aside from the exception, it is expected to receive the stack trace and,
   optionally, a context map which will be merged with the current process
   context.
@@ -110,9 +114,12 @@ defmodule ErrorTracker do
 
     context = Map.merge(get_context(), given_context)
 
-    {_error, occurrence} = upsert_error!(error, stacktrace, context, reason)
-
-    occurrence
+    if enabled?() do
+      {_error, occurrence} = upsert_error!(error, stacktrace, context, reason)
+      occurrence
+    else
+      :noop
+    end
   end
 
   @doc """
@@ -184,6 +191,10 @@ defmodule ErrorTracker do
     Process.get(:error_tracker_context, %{})
   end
 
+  defp enabled? do
+    !!Application.get_env(:error_tracker, :enabled, true)
+  end
+
   defp normalize_exception(%struct{} = ex, _stacktrace) when is_exception(ex) do
     {to_string(struct), Exception.message(ex)}
   end
@@ -212,11 +223,12 @@ defmodule ErrorTracker do
 
         occurrence =
           error
-          |> Ecto.build_assoc(:occurrences,
+          |> Ecto.build_assoc(:occurrences)
+          |> Occurrence.changeset(%{
             stacktrace: stacktrace,
             context: context,
             reason: reason
-          )
+          })
           |> Repo.insert!()
 
         {error, occurrence}
diff --git a/lib/error_tracker/application.ex b/lib/error_tracker/application.ex
index 1e89ada..ba1e7b2 100644
--- a/lib/error_tracker/application.ex
+++ b/lib/error_tracker/application.ex
@@ -4,7 +4,7 @@ defmodule ErrorTracker.Application do
   use Application
 
   def start(_type, _args) do
-    children = []
+    children = Application.get_env(:error_tracker, :plugins, [])
 
     attach_handlers()
 
diff --git a/lib/error_tracker/integrations/oban.ex b/lib/error_tracker/integrations/oban.ex
index 77acd5a..be637d5 100644
--- a/lib/error_tracker/integrations/oban.ex
+++ b/lib/error_tracker/integrations/oban.ex
@@ -59,7 +59,8 @@ defmodule ErrorTracker.Integrations.Oban do
 
   def handle_event([:oban, :job, :exception], _measurements, metadata, :no_config) do
     %{reason: exception, stacktrace: stacktrace} = metadata
+    state = Map.get(metadata, :state, :failure)
 
-    ErrorTracker.report(exception, stacktrace)
+    ErrorTracker.report(exception, stacktrace, %{state: state})
   end
 end
diff --git a/lib/error_tracker/integrations/phoenix.ex b/lib/error_tracker/integrations/phoenix.ex
index 1a51b4f..8bc393b 100644
--- a/lib/error_tracker/integrations/phoenix.ex
+++ b/lib/error_tracker/integrations/phoenix.ex
@@ -58,9 +58,10 @@ defmodule ErrorTracker.Integrations.Phoenix do
     [:phoenix, :live_view, :mount, :exception],
     [:phoenix, :live_view, :handle_params, :start],
     [:phoenix, :live_view, :handle_params, :exception],
-    [:phoenix, :live_view, :handle_event, :start],
     [:phoenix, :live_view, :handle_event, :exception],
-    [:phoenix, :live_view, :render, :exception]
+    [:phoenix, :live_view, :render, :exception],
+    [:phoenix, :live_component, :update, :exception],
+    [:phoenix, :live_component, :handle_event, :exception]
   ]
 
   @doc false
@@ -101,8 +102,8 @@ defmodule ErrorTracker.Integrations.Phoenix do
     })
   end
 
-  def handle_event([:phoenix, :live_view, :handle_event, :start], _, metadata, :no_config) do
-    ErrorTracker.set_context(%{
+  def handle_event([:phoenix, :live_view, :handle_event, :exception], _, metadata, :no_config) do
+    ErrorTracker.report({metadata.kind, metadata.reason}, metadata.stacktrace, %{
       "live_view.event" => metadata.event,
       "live_view.event_params" => metadata.params
     })
@@ -111,4 +112,23 @@ defmodule ErrorTracker.Integrations.Phoenix do
   def handle_event([:phoenix, :live_view, _action, :exception], _, metadata, :no_config) do
     ErrorTracker.report({metadata.kind, metadata.reason}, metadata.stacktrace)
   end
+
+  def handle_event([:phoenix, :live_component, :update, :exception], _, metadata, :no_config) do
+    ErrorTracker.report({metadata.kind, metadata.reason}, metadata.stacktrace, %{
+      "live_view.component" => metadata.component
+    })
+  end
+
+  def handle_event(
+        [:phoenix, :live_component, :handle_event, :exception],
+        _,
+        metadata,
+        :no_config
+      ) do
+    ErrorTracker.report({metadata.kind, metadata.reason}, metadata.stacktrace, %{
+      "live_view.component" => metadata.component,
+      "live_view.event" => metadata.event,
+      "live_view.event_params" => metadata.params
+    })
+  end
 end
diff --git a/lib/error_tracker/migration/postgres.ex b/lib/error_tracker/migration/postgres.ex
index 8e871b2..54af369 100644
--- a/lib/error_tracker/migration/postgres.ex
+++ b/lib/error_tracker/migration/postgres.ex
@@ -7,7 +7,7 @@ defmodule ErrorTracker.Migration.Postgres do
   alias ErrorTracker.Migration.SQLMigrator
 
   @initial_version 1
-  @current_version 2
+  @current_version 3
   @default_prefix "public"
 
   @impl ErrorTracker.Migration
diff --git a/lib/error_tracker/migration/postgres/v03.ex b/lib/error_tracker/migration/postgres/v03.ex
new file mode 100644
index 0000000..468293a
--- /dev/null
+++ b/lib/error_tracker/migration/postgres/v03.ex
@@ -0,0 +1,13 @@
+defmodule ErrorTracker.Migration.Postgres.V03 do
+  @moduledoc false
+
+  use Ecto.Migration
+
+  def up(%{prefix: prefix}) do
+    create_if_not_exists index(:error_tracker_errors, [:last_occurrence_at], prefix: prefix)
+  end
+
+  def down(%{prefix: prefix}) do
+    drop_if_exists index(:error_tracker_errors, [:last_occurrence_at], prefix: prefix)
+  end
+end
diff --git a/lib/error_tracker/migration/sqlite.ex b/lib/error_tracker/migration/sqlite.ex
index 84861c5..04fd7a7 100644
--- a/lib/error_tracker/migration/sqlite.ex
+++ b/lib/error_tracker/migration/sqlite.ex
@@ -7,7 +7,7 @@ defmodule ErrorTracker.Migration.SQLite do
   alias ErrorTracker.Migration.SQLMigrator
 
   @initial_version 2
-  @current_version 2
+  @current_version 3
 
   @impl ErrorTracker.Migration
   def up(opts) do
diff --git a/lib/error_tracker/migration/sqlite/v03.ex b/lib/error_tracker/migration/sqlite/v03.ex
new file mode 100644
index 0000000..082ee92
--- /dev/null
+++ b/lib/error_tracker/migration/sqlite/v03.ex
@@ -0,0 +1,13 @@
+defmodule ErrorTracker.Migration.SQLite.V03 do
+  @moduledoc false
+
+  use Ecto.Migration
+
+  def up(_opts) do
+    create_if_not_exists index(:error_tracker_errors, [:last_occurrence_at])
+  end
+
+  def down(_opts) do
+    drop_if_exists index(:error_tracker_errors, [:last_occurrence_at])
+  end
+end
diff --git a/lib/error_tracker/plugins/pruner.ex b/lib/error_tracker/plugins/pruner.ex
new file mode 100644
index 0000000..d8b2fe7
--- /dev/null
+++ b/lib/error_tracker/plugins/pruner.ex
@@ -0,0 +1,142 @@
+defmodule ErrorTracker.Plugins.Pruner do
+  @moduledoc """
+  Periodically delete resolved errors based on their age.
+
+  Pruning allows you to keep your database size under control by removing old errors that are not
+  needed anymore.
+
+  ## Using the pruner
+
+  To enable the pruner you must register the plugin in the ErrorTracker configuration. This will use
+  the default options, which is to prune errors resolved after 5 minutes.
+
+      config :error_tracker,
+        plugins: [ErrorTracker.Plugins.Pruner]
+
+  You can override the default options by passing them as an argument when registering the plugin.
+
+      config :error_tracker,
+        plugins: [{ErrorTracker.Plugins.Pruner, max_age: :timer.minutes(30)}]
+
+  ## Options
+
+  - `:limit`  - the maximum number of errors to prune on each execution. Occurrences are removed
+    along the errors. The default is 200 to prevent timeouts and unnecesary database load.
+
+  - `:max_age` - the number of milliseconds after a resolved error may be pruned. The default is 24
+    hours.
+
+  - `:interval` - the interval in milliseconds between pruning runs. The default is 30 minutes.
+
+  You may find the `:timer` module functions useful to pass readable values to the `:max_age` and
+  `:interval` options.
+
+  ## Manual pruning
+
+  In certain cases you may prefer to run the pruner manually. This can be done by calling the
+  `prune_errors/2` function from your application code. This function supports the `:limit` and
+  `:max_age` options as described above.
+
+  For example, you may call this function from an Oban worker so you can leverage Oban's cron
+  capabilities and have a more granular control over when pruning is run.
+
+      defmodule MyApp.ErrorPruner do
+        use Oban.Job
+
+        def perform(%Job{}) do
+          ErrorTracker.Plugins.Pruner.prune_errors(limit: 10_000, max_age: :timer.minutes(60))
+        end
+      end
+  """
+  use GenServer
+
+  import Ecto.Query
+
+  alias ErrorTracker.Error
+  alias ErrorTracker.Occurrence
+  alias ErrorTracker.Repo
+
+  @doc """
+  Prunes resolved errors.
+
+  You do not need to use this function if you activate the Pruner plugin. This function is exposed
+  only for advanced use cases and Oban integration.
+
+  ## Options
+
+  - `:limit`  - the maximum number of errors to prune on each execution. Occurrences are removed
+    along the errors. The default is 200 to prevent timeouts and unnecesary database load.
+
+  - `:max_age` - the number of milliseconds after a resolved error may be pruned. The default is 24
+    hours. You may find the `:timer` module functions useful to pass readable values to this option.
+  """
+  @spec prune_errors(keyword()) :: {:ok, list(Error.t())}
+  def prune_errors(opts \\ []) do
+    limit = opts[:limit] || raise ":limit option is required"
+    max_age = opts[:max_age] || raise ":max_age option is required"
+    time = DateTime.add(DateTime.utc_now(), -max_age, :millisecond)
+
+    errors =
+      Repo.all(
+        from error in Error,
+          select: [:id, :kind, :source_line, :source_function],
+          where: error.status == :resolved,
+          where: error.last_occurrence_at < ^time,
+          limit: ^limit
+      )
+
+    if Enum.any?(errors) do
+      _pruned_occurrences_count =
+        errors
+        |> Ecto.assoc(:occurrences)
+        |> prune_occurrences()
+        |> Enum.sum()
+
+      Repo.delete_all(from error in Error, where: error.id in ^Enum.map(errors, & &1.id))
+    end
+
+    {:ok, errors}
+  end
+
+  defp prune_occurrences(occurrences_query) do
+    Stream.unfold(occurrences_query, fn occurrences_query ->
+      occurrences_ids =
+        Repo.all(from occurrence in occurrences_query, select: occurrence.id, limit: 1000)
+
+      case Repo.delete_all(from o in Occurrence, where: o.id in ^occurrences_ids) do
+        {0, _} -> nil
+        {deleted, _} -> {deleted, occurrences_query}
+      end
+    end)
+  end
+
+  def start_link(state \\ []) do
+    GenServer.start_link(__MODULE__, state, name: __MODULE__)
+  end
+
+  @impl GenServer
+  @doc false
+  def init(state \\ []) do
+    state = %{
+      limit: state[:limit] || 200,
+      max_age: state[:max_age] || :timer.hours(24),
+      interval: state[:interval] || :timer.minutes(30)
+    }
+
+    {:ok, schedule_prune(state)}
+  end
+
+  @impl GenServer
+  @doc false
+  def handle_info(:prune, state) do
+    {:ok, _pruned} = prune_errors(state)
+
+    {:noreply, schedule_prune(state)}
+  end
+
+  defp schedule_prune(state = %{interval: interval}) do
+    Process.send_after(self(), :prune, interval)
+
+    state
+  end
+end
diff --git a/lib/error_tracker/repo.ex b/lib/error_tracker/repo.ex
index fe9748a..e6a8e9b 100644
--- a/lib/error_tracker/repo.ex
+++ b/lib/error_tracker/repo.ex
@@ -25,6 +25,10 @@ defmodule ErrorTracker.Repo do
     dispatch(:all, [queryable], opts)
   end
 
+  def delete_all(queryable, opts \\ []) do
+    dispatch(:delete_all, [queryable], opts)
+  end
+
   def aggregate(queryable, aggregate, opts \\ []) do
     dispatch(:aggregate, [queryable, aggregate], opts)
   end
diff --git a/lib/error_tracker/schemas/error.ex b/lib/error_tracker/schemas/error.ex
index 26265d4..2c4ab45 100644
--- a/lib/error_tracker/schemas/error.ex
+++ b/lib/error_tracker/schemas/error.ex
@@ -29,12 +29,21 @@ defmodule ErrorTracker.Error do
   @doc false
   def new(kind, reason, stacktrace = %ErrorTracker.Stacktrace{}) do
     source = ErrorTracker.Stacktrace.source(stacktrace)
-    source_line = if source.file, do: "#{source.file}:#{source.line}", else: "nofile"
+
+    {source_line, source_function} =
+      if source do
+        source_line = if source.line, do: "#{source.file}:#{source.line}", else: "nofile"
+        source_function = "#{source.module}.#{source.function}/#{source.arity}"
+
+        {source_line, source_function}
+      else
+        {"-", "-"}
+      end
 
     params = [
       kind: to_string(kind),
       source_line: source_line,
-      source_function: "#{source.module}.#{source.function}/#{source.arity}"
+      source_function: source_function
     ]
 
     fingerprint = :crypto.hash(:sha256, params |> Keyword.values() |> Enum.join())
@@ -46,4 +55,14 @@ defmodule ErrorTracker.Error do
     |> Ecto.Changeset.put_change(:last_occurrence_at, DateTime.utc_now())
     |> Ecto.Changeset.apply_action(:new)
   end
+
+  @doc """
+  Returns if the Error has information of the source or not.
+
+  Errors usually have information about in which line and function occurred, but
+  in some cases (like an Oban job ending with `{:error, any()}`) we cannot get
+  that information and no source is stored.
+  """
+  def has_source_info?(%__MODULE__{source_function: "-", source_line: "-"}), do: false
+  def has_source_info?(%__MODULE__{}), do: true
 end
diff --git a/lib/error_tracker/schemas/occurrence.ex b/lib/error_tracker/schemas/occurrence.ex
index f36c583..4729e2e 100644
--- a/lib/error_tracker/schemas/occurrence.ex
+++ b/lib/error_tracker/schemas/occurrence.ex
@@ -8,6 +8,9 @@ defmodule ErrorTracker.Occurrence do
 
   use Ecto.Schema
 
+  require Logger
+  import Ecto.Changeset
+
   schema "error_tracker_occurrences" do
     field :context, :map
     field :reason, :string
@@ -17,4 +20,54 @@ defmodule ErrorTracker.Occurrence do
 
     timestamps(type: :utc_datetime_usec, updated_at: false)
   end
+
+  @doc false
+  def changeset(occurrence, attrs) do
+    occurrence
+    |> cast(attrs, [:context, :reason])
+    |> maybe_put_stacktrace()
+    |> validate_required([:reason, :stacktrace])
+    |> validate_context()
+    |> foreign_key_constraint(:error)
+  end
+
+  # This function validates if the context can be serialized to JSON before
+  # storing it to the DB.
+  #
+  # If it cannot be serialized a warning log message is emitted and an error
+  # is stored in the context.
+  #
+  defp validate_context(changeset) do
+    if changeset.valid? do
+      context = get_field(changeset, :context, %{})
+
+      json_encoder =
+        ErrorTracker.Repo.with_adapter(fn
+          :postgres -> Application.get_env(:postgrex, :json_library, Jason)
+          :sqlite -> Application.get_env(:ecto_sqlite3, :json_library, Jason)
+        end)
+
+      case json_encoder.encode_to_iodata(context) do
+        {:ok, _} ->
+          put_change(changeset, :context, context)
+
+        {:error, _} ->
+          Logger.warning(
+            "[ErrorTracker] Context has been ignored: it is not serializable to JSON."
+          )
+
+          put_change(changeset, :context, %{
+            error: "Context not stored because it contains information not serializable to JSON."
+          })
+      end
+    else
+      changeset
+    end
+  end
+
+  defp maybe_put_stacktrace(changeset) do
+    if stacktrace = Map.get(changeset.params, "stacktrace"),
+      do: put_embed(changeset, :stacktrace, stacktrace),
+      else: changeset
+  end
 end
diff --git a/lib/error_tracker/web/live/dashboard.html.heex b/lib/error_tracker/web/live/dashboard.html.heex
index 6d6cbb3..68807fd 100644
--- a/lib/error_tracker/web/live/dashboard.html.heex
+++ b/lib/error_tracker/web/live/dashboard.html.heex
@@ -69,7 +69,7 @@
           <p class="whitespace-nowrap text-ellipsis w-full overflow-hidden">
             (<%= sanitize_module(error.kind) %>) <%= error.reason %>
           </p>
-          <p class="font-normal text-gray-400">
+          <p :if={ErrorTracker.Error.has_source_info?(error)} class="font-normal text-gray-400">
             <%= sanitize_module(error.source_function) %>
             <br />
             <%= error.source_line %>
diff --git a/lib/error_tracker/web/live/show.html.heex b/lib/error_tracker/web/live/show.html.heex
index 795b7fb..16ecb87 100644
--- a/lib/error_tracker/web/live/show.html.heex
+++ b/lib/error_tracker/web/live/show.html.heex
@@ -19,13 +19,13 @@
       <pre class="overflow-auto p-4 rounded-lg bg-gray-300/10 border border-gray-900"><%= @occurrence.reason %></pre>
     </.section>
 
-    <.section title="Source">
+    <.section :if={ErrorTracker.Error.has_source_info?(@error)} title="Source">
       <pre class="overflow-auto text-sm p-4 rounded-lg bg-gray-300/10 border border-gray-900">
         <%= sanitize_module(@error.source_function) %>
         <%= @error.source_line %></pre>
     </.section>
 
-    <.section title="Stacktrace">
+    <.section :if={@occurrence.stacktrace.lines != []} title="Stacktrace">
       <div class="p-4 bg-gray-300/10 border border-gray-900 rounded-lg">
         <div class="w-full mb-4">
           <label class="flex justify-end">
diff --git a/mix.exs b/mix.exs
index ed4a9e1..d60779b 100644
--- a/mix.exs
+++ b/mix.exs
@@ -4,7 +4,7 @@ defmodule ErrorTracker.MixProject do
   def project do
     [
       app: :error_tracker,
-      version: "0.2.3",
+      version: "0.2.5",
       elixir: "~> 1.15",
       elixirc_paths: elixirc_paths(Mix.env()),
       start_permanent: Mix.env() == :prod,
@@ -65,6 +65,9 @@ defmodule ErrorTracker.MixProject do
         ErrorTracker.Integrations.Phoenix,
         ErrorTracker.Integrations.Plug
       ],
+      Plugins: [
+        ErrorTracker.Plugins.Pruner
+      ],
       Schemas: [
         ErrorTracker.Error,
         ErrorTracker.Occurrence,
diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs
new file mode 100644
index 0000000..6c674d2
--- /dev/null
+++ b/priv/repo/seeds.exs
@@ -0,0 +1,45 @@
+adapter =
+  case Application.get_env(:error_tracker, :ecto_adapter) do
+    :postgres -> Ecto.Adapters.Postgres
+    :sqlite3 -> Ecto.Adapters.SQLite3
+  end
+
+defmodule ErrorTrackerDev.Repo do
+  use Ecto.Repo, otp_app: :error_tracker, adapter: adapter
+end
+
+ErrorTrackerDev.Repo.start_link()
+
+ErrorTrackerDev.Repo.delete_all(ErrorTracker.Error)
+
+errors =
+  for i <- 1..100 do
+    %{
+      kind: "Error #{i}",
+      reason: "Reason #{i}",
+      source_line: "line",
+      source_function: "function",
+      status: :unresolved,
+      fingerprint: "#{i}",
+      last_occurrence_at: DateTime.utc_now(),
+      inserted_at: DateTime.utc_now(),
+      updated_at: DateTime.utc_now()
+    }
+  end
+
+{_, errors} = dbg(ErrorTrackerDev.Repo.insert_all(ErrorTracker.Error, errors, returning: [:id]))
+
+for error <- errors do
+  occurrences =
+    for _i <- 1..200 do
+      %{
+        context: %{},
+        reason: "REASON",
+        stacktrace: %ErrorTracker.Stacktrace{},
+        error_id: error.id,
+        inserted_at: DateTime.utc_now()
+      }
+    end
+
+  ErrorTrackerDev.Repo.insert_all(ErrorTracker.Occurrence, occurrences)
+end
diff --git a/priv/static/app.css b/priv/static/app.css
index 5aeab4b..f2ce984 100644
--- a/priv/static/app.css
+++ b/priv/static/app.css
@@ -849,6 +849,10 @@ select {
   display: inline-block;
 }
 
+.inline {
+  display: inline;
+}
+
 .flex {
   display: flex;
 }


Test patch:
diff --git a/test/error_tracker/schemas/occurrence_test.exs b/test/error_tracker/schemas/occurrence_test.exs
new file mode 100644
index 0000000..3695d2a
--- /dev/null
+++ b/test/error_tracker/schemas/occurrence_test.exs
@@ -0,0 +1,39 @@
+defmodule ErrorTracker.OccurrenceTest do
+  use ErrorTracker.Test.Case
+
+  import Ecto.Changeset
+
+  alias ErrorTracker.Occurrence
+  alias ErrorTracker.Stacktrace
+
+  describe inspect(&Occurrence.changeset/2) do
+    test "works as expected with valid data" do
+      attrs = %{context: %{foo: :bar}, reason: "Test reason", stacktrace: %Stacktrace{}}
+      changeset = Occurrence.changeset(%Occurrence{}, attrs)
+
+      assert changeset.valid?
+    end
+
+    test "validates required fields" do
+      changeset = Occurrence.changeset(%Occurrence{}, %{})
+
+      refute changeset.valid?
+      assert {_, [validation: :required]} = changeset.errors[:reason]
+      assert {_, [validation: :required]} = changeset.errors[:stacktrace]
+    end
+
+    @tag capture_log: true
+    test "if context is not serializable, an error messgae is stored" do
+      attrs = %{
+        context: %{foo: %ErrorTracker.Error{}},
+        reason: "Test reason",
+        stacktrace: %Stacktrace{}
+      }
+
+      changeset = Occurrence.changeset(%Occurrence{}, attrs)
+
+      assert %{error: err} = get_field(changeset, :context)
+      assert err =~ "not serializable to JSON"
+    end
+  end
+end
diff --git a/test/error_tracker_test.exs b/test/error_tracker_test.exs
index 3afa438..f8018b3 100644
--- a/test/error_tracker_test.exs
+++ b/test/error_tracker_test.exs
@@ -7,6 +7,16 @@ defmodule ErrorTrackerTest do
   @relative_file_path Path.relative_to(__ENV__.file, File.cwd!())
 
   describe inspect(&ErrorTracker.report/3) do
+    setup context do
+      if Map.has_key?(context, :enabled) do
+        Application.put_env(:error_tracker, :enabled, context[:enabled])
+        # Ensure that the application env is restored after each test
+        on_exit(fn -> Application.delete_env(:error_tracker, :enabled) end)
+      end
+
+      []
+    end
+
     test "reports exceptions" do
       %Occurrence{error: error = %Error{}} =
         report_error(fn -> raise "This is a test" end)
@@ -64,6 +74,31 @@ defmodule ErrorTrackerTest do
       assert error.reason == "This is a test"
       assert error.source_line =~ @relative_file_path
     end
+
+    @tag capture_log: true
+    test "reports errors with invalid context" do
+      # It's invalid because cannot be serialized to JSON
+      invalid_context = %{foo: %ErrorTracker.Error{}}
+
+      assert %Occurrence{} = report_error(fn -> raise "test" end, invalid_context)
+    end
+
+    test "without enabled flag it works as expected" do
+      # Ensure no value is set
+      Application.delete_env(:error_tracker, :enabled)
+
+      assert %Occurrence{} = report_error(fn -> raise "Sample error" end)
+    end
+
+    @tag enabled: true
+    test "with enabled flag to true it works as expected" do
+      assert %Occurrence{} = report_error(fn -> raise "Sample error" end)
+    end
+
+    @tag enabled: false
+    test "with enabled flag to false it does not store the exception" do
+      assert report_error(fn -> raise "Sample error" end) == :noop
+    end
   end
 
   describe inspect(&ErrorTracker.resolve/1) do
diff --git a/test/support/case.ex b/test/support/case.ex
index 2c61a4f..d23d451 100644
--- a/test/support/case.ex
+++ b/test/support/case.ex
@@ -16,19 +16,22 @@ defmodule ErrorTracker.Test.Case do
   @doc """
   Reports the error produced by the given function.
   """
-  def report_error(fun) do
+  def report_error(fun, context \\ %{}) do
     occurrence =
       try do
         fun.()
       rescue
         exception ->
-          ErrorTracker.report(exception, __STACKTRACE__)
+          ErrorTracker.report(exception, __STACKTRACE__, context)
       catch
         kind, reason ->
-          ErrorTracker.report({kind, reason}, __STACKTRACE__)
+          ErrorTracker.report({kind, reason}, __STACKTRACE__, context)
       end
 
-    repo().preload(occurrence, :error)
+    case occurrence do
+      %ErrorTracker.Occurrence{} -> repo().preload(occurrence, :error)
+      other -> other
+    end
   end
 
   @doc """