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
+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