ML-162: enable /api/v1/errors endpoint
This commit is contained in:
@@ -1,10 +1,10 @@
|
|||||||
---
|
---
|
||||||
id: ML-162
|
id: ML-162
|
||||||
title: Expose production errors via JSON API endpoint
|
title: Expose production errors via JSON API endpoint
|
||||||
status: To Do
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-05-04 08:08'
|
created_date: '2026-05-04 08:08'
|
||||||
updated_date: '2026-05-04 09:12'
|
updated_date: '2026-05-04 12:11'
|
||||||
labels:
|
labels:
|
||||||
- api
|
- api
|
||||||
- ready
|
- 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`.
|
- 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.
|
- Include a variant fixture that produces multiple errors with different status/fingerprint/muted values to exercise filtering and pagination in tests.
|
||||||
<!-- SECTION:PLAN:END -->
|
<!-- 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 -->
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ Last.fm schemas (separate, not Ecto-persisted to main DB):
|
|||||||
| `ScrobbleActivity` | — | Scrobbling releases/media/tracks to Last.fm |
|
| `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 |
|
| `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 |
|
| `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) |
|
| `Search` | (cross-context) | Universal search dispatcher across collection, wishlist, artists, record sets (delegates to domain contexts) |
|
||||||
| `Secrets` | Secret | Encrypted key-value storage (CRUD + delete) |
|
| `Secrets` | Secret | Encrypted key-value storage (CRUD + delete) |
|
||||||
| `BarcodeScan` | (Result struct) | Barcode → MusicBrainz lookup workflow, async batch import for multiple new records |
|
| `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) |
|
| `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) |
|
| `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 |
|
| `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) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,21 +1,85 @@
|
|||||||
defmodule MusicLibraryWeb.ErrorJSON do
|
defmodule MusicLibraryWeb.ErrorJSON do
|
||||||
@moduledoc """
|
@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,
|
# Catch-all for Phoenix error template rendering (404, 500, etc.)
|
||||||
# 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".
|
|
||||||
def render(template, _assigns) do
|
def render(template, _assigns) do
|
||||||
%{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}}
|
%{error: Phoenix.Controller.status_message_from_template(template)}
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@@ -130,6 +130,8 @@ defmodule MusicLibraryWeb.Router do
|
|||||||
get "/collection/random", CollectionController, :random
|
get "/collection/random", CollectionController, :random
|
||||||
get "/collection/on_this_day", CollectionController, :on_this_day
|
get "/collection/on_this_day", CollectionController, :on_this_day
|
||||||
get "/collection", CollectionController, :index
|
get "/collection", CollectionController, :index
|
||||||
|
get "/errors", ErrorController, :index
|
||||||
|
get "/errors/:id", ErrorController, :show
|
||||||
get "/assets/:transform_payload", AssetController, :show
|
get "/assets/:transform_payload", AssetController, :show
|
||||||
get "/backup", ArchiveController, :backup
|
get "/backup", ArchiveController, :backup
|
||||||
end
|
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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user