ML-162: enable /api/v1/errors endpoint

This commit is contained in:
Claudio Ortolina
2026-05-04 13:14:40 +01:00
parent 213ca31a42
commit 51bd24d5b4
8 changed files with 611 additions and 15 deletions
@@ -1,10 +1,10 @@
---
id: ML-162
title: Expose production errors via JSON API endpoint
status: To Do
status: Done
assignee: []
created_date: '2026-05-04 08:08'
updated_date: '2026-05-04 09:12'
updated_date: '2026-05-04 12:11'
labels:
- api
- ready
@@ -302,3 +302,64 @@ end
- The fixture module follows the existing pattern (`RecordsFixtures`, `RecordSetsFixtures`, etc.) — helper functions that return inserted structs via `MusicLibrary.Repo`.
- Include a variant fixture that produces multiple errors with different status/fingerprint/muted values to exercise filtering and pagination in tests.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Implementation completed. All 5 steps executed:
1. **Context** (`lib/music_library/errors.ex`): Created `MusicLibrary.Errors` with `list_errors/1` (filtered + paginated) and `get_error/1` (returns `{:ok, error}` or `{:error, :not_found}`). Uses `MusicLibrary.Repo` (where error_tracker tables actually live, not TelemetryRepo). Private query helpers for status/muted/search filtering.
2. **Controller** (`lib/music_library_web/controllers/error_controller.ex`): `index/2` and `show/2` actions. Parses query params (status→atom, muted→bool, limit/offset→int) following CollectionController patterns. Handles 404 for missing errors explicitly with `put_status/2` + `json/2`.
3. **JSON view** (`lib/music_library_web/controllers/error_json.ex`): Serializes errors (list) and error-with-occurrences (show). Includes catch-all `render/2` for Phoenix error templates (404, 500) since this module name collides with the configured render_errors view. Fingerprint comes as hex string from SQLite TEXT column. Stacktrace lines rendered as array of maps.
4. **Router**: Two routes added under `scope "/api/v1"`: `GET /errors` and `GET /errors/:id`.
5. **Tests + docs**: Created `test/support/fixtures/errors_fixtures.ex` (direct Ecto inserts since ErrorTracker disabled in test) and `test/music_library_web/controllers/error_controller_test.exs` (10 tests: auth required ×2, list/pagination/filter/search ×6, show/occurrences ×1, 404 ×1). All 900 tests pass. Updated `docs/architecture.md` with Errors context and ErrorController.
**Deviations from original plan:**
- Changed `get_error!/1``get_error/1` returning `{:ok, error} | {:error, :not_found}` instead of raising. This avoids relying on Phoenix's automatic Ecto.NoResultsError→404 conversion (which didn't work in tests).
- Added `render/2` catch-all to ErrorJSON for Phoenix error template rendering (404, 500) - module name collides with configured render_errors view.
- List endpoint omits `occurrence_count` and `first_occurrence_at` per plan decision (computed only in single-error endpoint).
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
## Summary
Added two JSON API endpoints under `/api/v1/errors` to expose production error data from ErrorTracker, behind the existing Bearer token authentication.
### What changed
**New files:**
- `lib/music_library/errors.ex` — Context with `list_errors/1` (filtered, paginated listing) and `get_error/1` (single error with preloaded occurrences, computed counts)
- `lib/music_library_web/controllers/error_controller.ex` — Controller with `index/2` and `show/2` actions, following CollectionController patterns
- `lib/music_library_web/controllers/error_json.ex` — JSON serializer for errors and occurrences, including stacktrace lines; also serves as Phoenix error renderer (404/500 JSON responses)
- `test/support/fixtures/errors_fixtures.ex` — Test fixture helpers using direct Ecto inserts (ErrorTracker is disabled in test)
- `test/music_library_web/controllers/error_controller_test.exs` — 10 tests: auth required (2), list/pagination/filter/search (6), single error with occurrences (1), 404 handling (1)
**Modified files:**
- `lib/music_library_web/router.ex` — Added `GET /api/v1/errors` and `GET /api/v1/errors/:id` routes
- `docs/architecture.md` — Added Errors context and ErrorController entries
### API design
- `GET /api/v1/errors` — List errors with filters (`status`, `muted`, `search`), pagination (`limit` default 50, `offset` default 0), ordered by `last_occurrence_at DESC`
- `GET /api/v1/errors/:id` — Single error detail with all occurrences (including stacktraces), `occurrence_count`, and `first_occurrence_at`
### Key decisions
- Uses `MusicLibrary.Repo` (not TelemetryRepo) — error_tracker tables live in the main database per `config/config.exs`
- Returns integer IDs (error_tracker uses auto-increment PKs, not UUIDs)
- List endpoint omits `occurrence_count`/`first_occurrence_at` to avoid correlated subqueries per row
- Context returns `{:ok, error} | {:error, :not_found}` instead of raising — explicit 404 handling more reliable than relying on Phoenix's Ecto.NoResultsError→404 conversion
### Test results
All 900 tests pass (43 doctests, 857 existing + 10 new).
<!-- SECTION:FINAL_SUMMARY:END -->
+2
View File
@@ -102,6 +102,7 @@ Last.fm schemas (separate, not Ecto-persisted to main DB):
| `ScrobbleActivity` | — | Scrobbling releases/media/tracks to Last.fm |
| `ListeningStats` | (LastFm.Track, RecordRelease, ArtistRecord, ArtistInfo) | Scrobble persistence, refresh scheduling, listening analytics, track CRUD, search, listing: scrobble counts, artist play counts (from DB), recent activity, top albums/artists by period |
| `OnlineStoreTemplates` | OnlineStoreTemplate | URL templates for buying records online; searchable by name/description |
| `Errors` | ErrorTracker.Error, ErrorTracker.Occurrence | Read-only queries for production error data tracked by ErrorTracker; filtered listing with pagination, single error with preloaded occurrences and computed counts |
| `Search` | (cross-context) | Universal search dispatcher across collection, wishlist, artists, record sets (delegates to domain contexts) |
| `Secrets` | Secret | Encrypted key-value storage (CRUD + delete) |
| `BarcodeScan` | (Result struct) | Barcode → MusicBrainz lookup workflow, async batch import for multiple new records |
@@ -337,6 +338,7 @@ All authenticated routes live inside a single `live_session` with three `on_moun
| `ArchiveController` | `/backup`, `/api/v1/backup` | Database backup download (API route requires token) |
| `AssetController` | `/assets/:transform_payload`, `/public/assets/:transform_payload`, `/api/v1/assets/:transform_payload` | Serve images with transforms (public route for emails, API route requires token) |
| `CollectionController` | `/api/v1/collection/*` | JSON API for collection queries |
| `ErrorController` | `/api/v1/errors`, `/api/v1/errors/:id` | JSON API for production error queries (requires Bearer token) |
---
+101
View File
@@ -0,0 +1,101 @@
defmodule MusicLibrary.Errors do
@moduledoc """
Queries for production errors tracked via ErrorTracker.
Reads from the error_tracker_errors and error_tracker_occurrences tables
(owned by the `ErrorTracker` library) through `MusicLibrary.Repo`. The
tables are created by ErrorTracker's own migrations and do not require
any additional schema work.
"""
import Ecto.Query, warn: false
alias ErrorTracker.{Error, Occurrence}
alias MusicLibrary.Repo
@type list_opts :: [
status: :resolved | :unresolved,
muted: boolean(),
search: String.t(),
limit: pos_integer(),
offset: non_neg_integer()
]
@type list_result :: %{
errors: [Error.t()],
total: non_neg_integer()
}
@spec list_errors(list_opts()) :: list_result()
def list_errors(opts \\ []) do
status = Keyword.get(opts, :status)
muted = Keyword.get(opts, :muted)
search = Keyword.get(opts, :search)
limit = Keyword.get(opts, :limit, 50)
offset = Keyword.get(opts, :offset, 0)
base = base_query(status: status, muted: muted, search: search)
total = Repo.aggregate(base, :count, :id)
errors =
base
|> order_by(desc: :last_occurrence_at)
|> limit(^limit)
|> offset(^offset)
|> Repo.all()
%{errors: errors, total: total}
end
@spec get_error(pos_integer()) :: {:ok, Error.t()} | {:error, :not_found}
def get_error(id) do
case Repo.get(Error, id) do
nil ->
{:error, :not_found}
error ->
occurrences =
from(o in Occurrence,
where: o.error_id == ^id,
order_by: [desc: o.inserted_at]
)
|> Repo.all()
occurrence_count = length(occurrences)
first_occurrence_at = get_first_occurrence_at(occurrences)
result =
%{error | occurrences: occurrences}
|> Map.put(:occurrence_count, occurrence_count)
|> Map.put(:first_occurrence_at, first_occurrence_at)
{:ok, result}
end
end
# -- private helpers --
defp base_query(filters) do
Error
|> maybe_filter_status(filters[:status])
|> maybe_filter_muted(filters[:muted])
|> maybe_filter_search(filters[:search])
end
defp maybe_filter_status(query, nil), do: query
defp maybe_filter_status(query, status), do: where(query, [e], e.status == ^status)
defp maybe_filter_muted(query, nil), do: query
defp maybe_filter_muted(query, muted), do: where(query, [e], e.muted == ^muted)
defp maybe_filter_search(query, nil), do: query
defp maybe_filter_search(query, ""), do: query
defp maybe_filter_search(query, search) do
where(query, [e], like(e.reason, ^"%#{search}%"))
end
defp get_first_occurrence_at([]), do: nil
defp get_first_occurrence_at(occurrences), do: List.last(occurrences).inserted_at
end
@@ -0,0 +1,65 @@
defmodule MusicLibraryWeb.ErrorController do
use MusicLibraryWeb, :controller
alias MusicLibrary.Errors
def index(conn, params) do
status = parse_status(params["status"])
muted = parse_muted(params["muted"])
search = params["search"]
limit = parse_int(params["limit"], 50)
offset = parse_int(params["offset"], 0)
opts =
[]
|> maybe_put(:status, status)
|> maybe_put(:muted, muted)
|> Keyword.put(:search, search)
|> Keyword.put(:limit, limit)
|> Keyword.put(:offset, offset)
%{errors: errors, total: total} = Errors.list_errors(opts)
render(conn, :index, errors: errors, total: total, limit: limit, offset: offset)
end
def show(conn, %{"id" => id}) do
{id_int, ""} = Integer.parse(id)
case Errors.get_error(id_int) do
{:ok, error} ->
render(conn, :show, error: error)
{:error, :not_found} ->
conn
|> put_status(:not_found)
|> json(%{error: "Not Found"})
end
end
# -- private helpers --
defp parse_status(nil), do: nil
defp parse_status("resolved"), do: :resolved
defp parse_status("unresolved"), do: :unresolved
defp parse_status(_), do: nil
defp parse_muted(nil), do: nil
defp parse_muted("true"), do: true
defp parse_muted("false"), do: false
defp parse_muted(_), do: nil
defp parse_int(nil, default), do: default
defp parse_int(value, default) when is_binary(value) do
case Integer.parse(value) do
{int, ""} -> int
_ -> default
end
end
defp parse_int(_, default), do: default
defp maybe_put(kw, _key, nil), do: kw
defp maybe_put(kw, key, value), do: Keyword.put(kw, key, value)
end
+77 -13
View File
@@ -1,21 +1,85 @@
defmodule MusicLibraryWeb.ErrorJSON do
@moduledoc """
This module is invoked by your endpoint in case of errors on JSON requests.
JSON rendering for production errors via the API and for Phoenix error responses.
See config/config.exs.
This module serves dual purpose:
- Renders error/occurrence data for the /api/v1/errors endpoints
- Renders generic error responses (404, 500, etc.) for the Phoenix endpoint
when the request accepts JSON (configured in config/config.exs render_errors)
"""
use MusicLibraryWeb, :json
# If you want to customize a particular status code,
# you may add your own clauses, such as:
#
# def render("500.json", _assigns) do
# %{errors: %{detail: "Internal Server Error"}}
# end
# By default, Phoenix returns the status message from
# the template name. For example, "404.json" becomes
# "Not Found".
# Catch-all for Phoenix error template rendering (404, 500, etc.)
def render(template, _assigns) do
%{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}}
%{error: Phoenix.Controller.status_message_from_template(template)}
end
def index(%{errors: errors, total: total, limit: limit, offset: offset}) do
%{
errors: Enum.map(errors, &error/1),
total: total,
limit: limit,
offset: offset
}
end
def show(%{error: error}) do
%{
error: error_with_occurrences(error)
}
end
defp error(e) do
%{
id: e.id,
kind: e.kind,
reason: e.reason,
source_line: e.source_line,
source_function: e.source_function,
status: atom_to_string(e.status),
fingerprint: e.fingerprint,
last_occurrence_at: datetime_to_iso8601(e.last_occurrence_at),
muted: e.muted,
inserted_at: datetime_to_iso8601(e.inserted_at),
updated_at: datetime_to_iso8601(e.updated_at)
}
end
defp error_with_occurrences(e) do
error(e)
|> Map.put(:occurrence_count, Map.get(e, :occurrence_count, 0))
|> Map.put(:first_occurrence_at, datetime_to_iso8601(Map.get(e, :first_occurrence_at)))
|> Map.put(:occurrences, Enum.map(Map.get(e, :occurrences, []), &occurrence/1))
end
defp occurrence(o) do
%{
id: o.id,
reason: o.reason,
context: o.context,
breadcrumbs: o.breadcrumbs,
stacktrace: %{
lines: Enum.map(o.stacktrace.lines, &stacktrace_line/1)
},
error_id: o.error_id,
inserted_at: datetime_to_iso8601(o.inserted_at)
}
end
defp stacktrace_line(line) do
%{
application: line.application,
module: line.module,
function: line.function,
arity: line.arity,
file: line.file,
line: line.line
}
end
defp datetime_to_iso8601(nil), do: nil
defp datetime_to_iso8601(dt), do: DateTime.to_iso8601(dt)
defp atom_to_string(nil), do: nil
defp atom_to_string(atom), do: Atom.to_string(atom)
end
+2
View File
@@ -130,6 +130,8 @@ defmodule MusicLibraryWeb.Router do
get "/collection/random", CollectionController, :random
get "/collection/on_this_day", CollectionController, :on_this_day
get "/collection", CollectionController, :index
get "/errors", ErrorController, :index
get "/errors/:id", ErrorController, :show
get "/assets/:transform_payload", AssetController, :show
get "/backup", ArchiveController, :backup
end
@@ -0,0 +1,230 @@
defmodule MusicLibraryWeb.ErrorControllerTest do
use MusicLibraryWeb.ConnCase
import MusicLibrary.ErrorsFixtures
defp api_token do
Application.get_env(:music_library, MusicLibraryWeb)
|> Keyword.fetch!(:api_token)
end
describe "authentication" do
test "GET /api/v1/errors requires a bearer token", %{conn: conn} do
assert get(conn, ~p"/api/v1/errors").status == 401
end
test "GET /api/v1/errors/:id requires a bearer token", %{conn: conn} do
assert get(conn, ~p"/api/v1/errors/1").status == 401
end
end
describe "GET /api/v1/errors" do
setup do
error1 =
error_fixture(%{
reason: "First error",
source_line: "lib/my_module.ex:10",
source_function: "MyModule.do_thing/0",
fingerprint:
error_fingerprint(:runtime_error, "lib/my_module.ex:10", "MyModule.do_thing/0")
})
error2 =
error_fixture(%{
reason: "Second error",
status: :resolved,
muted: true,
source_line: "lib/my_module.ex:20",
source_function: "MyModule.other_thing/1",
fingerprint:
error_fingerprint(:runtime_error, "lib/my_module.ex:20", "MyModule.other_thing/1")
})
error3 =
error_fixture(%{
kind: "ArgumentError",
reason: "Bad argument in MyOtherModule.call/2",
source_line: "lib/other_module.ex:10",
source_function: "MyOtherModule.call/2",
fingerprint:
error_fingerprint(:argument_error, "lib/other_module.ex:10", "MyOtherModule.call/2")
})
%{errors: [error1, error2, error3]}
end
test "returns a paginated list of errors", %{conn: conn} do
conn =
conn
|> put_req_header("authorization", "Bearer #{api_token()}")
|> get(~p"/api/v1/errors")
assert %{"errors" => returned, "total" => 3, "limit" => 50, "offset" => 0} =
json_response(conn, 200)
assert length(returned) == 3
# Verify each returned error has the expected fields
for error <- returned do
assert is_integer(error["id"])
assert is_binary(error["kind"])
assert is_binary(error["reason"])
assert is_binary(error["source_line"])
assert is_binary(error["source_function"])
assert error["status"] in ["resolved", "unresolved"]
assert is_binary(error["fingerprint"])
assert is_binary(error["last_occurrence_at"])
assert is_boolean(error["muted"])
assert is_binary(error["inserted_at"])
assert is_binary(error["updated_at"])
# List endpoint omits occurrence_count and first_occurrence_at
refute Map.has_key?(error, "occurrence_count")
refute Map.has_key?(error, "first_occurrence_at")
end
end
test "filters by status", %{conn: conn} do
conn =
conn
|> put_req_header("authorization", "Bearer #{api_token()}")
|> get(~p"/api/v1/errors?status=resolved")
assert %{"errors" => returned, "total" => 1} = json_response(conn, 200)
assert Enum.all?(returned, &(&1["status"] == "resolved"))
end
test "filters by muted", %{conn: conn} do
conn =
conn
|> put_req_header("authorization", "Bearer #{api_token()}")
|> get(~p"/api/v1/errors?muted=true")
assert %{"errors" => returned, "total" => 1} = json_response(conn, 200)
assert Enum.all?(returned, &(&1["muted"] == true))
end
test "filters by search on reason", %{conn: conn} do
conn =
conn
|> put_req_header("authorization", "Bearer #{api_token()}")
|> get(~p"/api/v1/errors?search=second")
assert %{"errors" => returned, "total" => 1} = json_response(conn, 200)
assert hd(returned)["reason"] == "Second error"
end
test "respects limit and offset", %{conn: conn} do
conn =
conn
|> put_req_header("authorization", "Bearer #{api_token()}")
|> get(~p"/api/v1/errors?limit=2&offset=1")
assert %{"errors" => returned, "total" => 3, "limit" => 2, "offset" => 1} =
json_response(conn, 200)
assert length(returned) == 2
end
test "returns empty list when no errors match", %{conn: conn} do
conn =
conn
|> put_req_header("authorization", "Bearer #{api_token()}")
|> get(~p"/api/v1/errors?search=NONEXISTENT")
assert %{"errors" => [], "total" => 0} = json_response(conn, 200)
end
end
describe "GET /api/v1/errors/:id" do
setup do
error = error_fixture(%{reason: "Something went wrong"})
occ1 = occurrence_fixture(error, %{breadcrumbs: ["first occurrence"]})
occ2 =
occurrence_fixture(error, %{
reason: "Updated reason",
context: %{user_id: 2},
breadcrumbs: ["second occurrence"],
stacktrace: %ErrorTracker.Stacktrace{
lines: [
%ErrorTracker.Stacktrace.Line{
application: "music_library",
module: "OtherModule",
function: "call",
arity: 2,
file: "lib/other_module.ex",
line: 10
},
%ErrorTracker.Stacktrace.Line{
application: "elixir",
module: "Enum",
function: "map",
arity: 2,
file: "lib/enum.ex",
line: 1500
}
]
}
})
%{error: error, occurrences: [occ1, occ2]}
end
test "returns a single error with occurrences", %{conn: conn, error: error} do
conn =
conn
|> put_req_header("authorization", "Bearer #{api_token()}")
|> get(~p"/api/v1/errors/#{error.id}")
assert %{"error" => returned} = json_response(conn, 200)
assert returned["id"] == error.id
assert returned["reason"] == "Something went wrong"
assert returned["status"] == "unresolved"
assert returned["muted"] == false
# Computed fields
assert returned["occurrence_count"] == 2
refute is_nil(returned["first_occurrence_at"])
# Occurrences
occurrences = returned["occurrences"]
assert length(occurrences) == 2
# Most recent first (desc inserted_at)
first_occ = hd(occurrences)
second_occ = List.last(occurrences)
assert first_occ["reason"] == "Updated reason"
assert first_occ["context"] == %{"user_id" => 2}
assert first_occ["breadcrumbs"] == ["second occurrence"]
assert second_occ["reason"] == "Something went wrong"
assert second_occ["context"] == %{"user_id" => 1}
assert second_occ["breadcrumbs"] == ["first occurrence"]
# Stacktrace lines
lines = first_occ["stacktrace"]["lines"]
assert length(lines) == 2
[line1, line2] = lines
assert line1["module"] == "OtherModule"
assert line1["function"] == "call"
assert line1["arity"] == 2
assert line1["file"] == "lib/other_module.ex"
assert line1["line"] == 10
assert line2["module"] == "Enum"
assert line2["application"] == "elixir"
end
test "returns 404 for non-existent error", %{conn: conn} do
conn =
conn
|> put_req_header("authorization", "Bearer #{api_token()}")
|> get(~p"/api/v1/errors/99999")
assert json_response(conn, 404) == %{"error" => "Not Found"}
end
end
end
+71
View File
@@ -0,0 +1,71 @@
defmodule MusicLibrary.ErrorsFixtures do
@moduledoc """
Fixtures for ErrorTracker error and occurrence data.
ErrorTracker is disabled in test (`enabled: false` in config), so we cannot use
`ErrorTracker.report/3` to seed data. Instead, we insert directly via `MusicLibrary.Repo`
using ErrorTracker's own schemas.
"""
alias ErrorTracker.{Error, Occurrence, Stacktrace}
alias MusicLibrary.Repo
@doc """
Inserts an error record with the given attributes merged over defaults.
"""
def error_fixture(attrs \\ []) do
defaults = %{
kind: "RuntimeError",
reason: "Something went wrong",
source_line: "lib/my_module.ex:42",
source_function: "MyModule.do_thing/0",
status: :unresolved,
fingerprint:
error_fingerprint(:runtime_error, "lib/my_module.ex:42", "MyModule.do_thing/0"),
last_occurrence_at: DateTime.utc_now(),
muted: false
}
defaults
|> Map.merge(Map.new(attrs))
|> then(&Repo.insert!(struct!(Error, &1)))
end
@doc """
Inserts an occurrence record associated with the given error.
"""
def occurrence_fixture(error, attrs \\ []) do
defaults = %{
reason: error.reason,
context: %{user_id: 1},
breadcrumbs: ["step 1"],
stacktrace: %Stacktrace{
lines: [
%Stacktrace.Line{
application: "music_library",
module: "MyModule",
function: "do_thing",
arity: 0,
file: "lib/my_module.ex",
line: 42
}
]
},
error_id: error.id
}
defaults
|> Map.merge(Map.new(attrs))
|> then(&Repo.insert!(struct!(Occurrence, &1)))
end
@doc """
Generates a fingerprint matching ErrorTracker's algorithm: hex(SHA256(joined_params)).
"""
def error_fingerprint(kind, source_line, source_function) do
[kind, source_line, source_function]
|> Enum.join()
|> then(&:crypto.hash(:sha256, &1))
|> Base.encode16()
end
end