FullstackPhoenix

Add Error Tracking to a Phoenix SaaS App

phoenixerror-trackerobservabilityadminsaas

When my Phoenix SaaS app starts to see real traffic, I want to see crashes without tailing the logs. A database-backed dashboard that groups errors by fingerprint and shows me their last occurrence is enough for most of the work, and it does not require a third-party account.

In this tutorial I install the error_tracker feature with Live SaaS Kit, raise an error on purpose from a controller, and find it on the error dashboard. The starting point is a project with admin already installed, because that is where the error dashboard link lands.

Step 1 - Install the error_tracker feature

First step is to run the installer.

mix saaskit.feature.install error_tracker
mix deps.get
mix ecto.migrate

The feature has no hard dependencies and a recommended_features = ["admin", "oban"] list. With admin installed, the dashboard mounts under the protected admin pipes; with oban installed, errors raised inside workers are captured too.

The package added to mix.exs:

# mix.exs
{:error_tracker, "~> 0.9.0"}

The migration delegates to the library, which is the same pattern Oban uses.

# priv/repo/migrations/TIMESTAMP_add_error_tracker.exs
defmodule MyApp.Repo.Migrations.AddErrorTracker do
use Ecto.Migration
def up, do: ErrorTracker.Migration.up()
def down, do: ErrorTracker.Migration.down()
end

Step 2 - Look at the config

The installer injects a config block in config/config.exs.

# config/config.exs
config :error_tracker,
otp_app: :my_app,
repo: MyApp.Repo,
enabled: Mix.env() != :test,
plugins: [ErrorTracker.Plugins.Pruner]

Note that enabled: Mix.env() != :test keeps the tracker off in the test environment. So error assertions in tests do not race against the tracker writing rows to the test database.

The Pruner plugin removes resolved errors on a schedule, which keeps the table from growing forever in a long-running app.

Step 3 - Look at the router changes

The router gets two small additions. First, the macro import.

# lib/my_app_web/router.ex
use ErrorTracker.Web, :router

Then the dashboard mount. Because admin is installed, it lands under the admin pipes.

# lib/my_app_web/router.ex
scope "/admin", MyAppWeb.Admin do
pipe_through [:browser, :require_authenticated_user, :require_admin_user]
# other admin routes
error_tracker_dashboard "/errors"
end

A link is also injected into the admin developer page.

<.link navigate={~p"/admin/errors"} class="btn btn-info">
ErrorTracker
</.link>

If admin is not installed, the dashboard mounts at /errors in the development scope instead, and I am responsible for putting it behind authentication myself.

Step 4 - Raise an error on purpose

The feature does not generate a sample endpoint, so I add a deliberate raise to confirm the tracker is wired up. I put it in the controller that already exists.

# lib/my_app_web/controllers/page_controller.ex
def boom(_conn, _params) do
raise "boom"
end

And a route to reach it.

# lib/my_app_web/router.ex
scope "/", MyAppWeb do
pipe_through :browser
get "/boom", PageController, :boom
end

With the server running, I visit http://localhost:4000/boom and let Phoenix render its error page.

mix phx.server

Step 5 - Find the error on the dashboard

Now that I have the deliberate crash, I sign in as a superuser and open /admin/errors. The dashboard shows one unresolved error grouped by its fingerprint, with the kind (RuntimeError), the reason ("boom"), the source function, and the last occurrence timestamp. Clicking through opens the recent occurrences, each with the request context that was attached at the time.

If I hit /boom a second time, the count goes up but the fingerprint stays the same. So the dashboard groups recurring errors instead of showing the same crash 50 times.

I remove the deliberate route and controller action before moving on. The tracker keeps the historical row, which I can resolve from the dashboard or leave for the Pruner.

Final Result

I now have a database-backed error dashboard at /admin/errors that captures unhandled exceptions from controllers, LiveViews, and Oban workers, and groups them by fingerprint. A natural next step is to add a healthcheck endpoint so an external uptime probe can confirm the app is up — that is covered in the next tutorial in the series.