diff --git a/backlog/tasks/ml-162 - Expose-production-errors-via-JSON-API-endpoint.md b/backlog/tasks/ml-162 - Expose-production-errors-via-JSON-API-endpoint.md index e586f354..aceeda04 100644 --- a/backlog/tasks/ml-162 - Expose-production-errors-via-JSON-API-endpoint.md +++ b/backlog/tasks/ml-162 - Expose-production-errors-via-JSON-API-endpoint.md @@ -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. + +## Implementation Notes + + +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). + + +## Final Summary + + +## 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). + diff --git a/docs/architecture.md b/docs/architecture.md index 68429a33..a5998cc0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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) | --- diff --git a/lib/music_library/errors.ex b/lib/music_library/errors.ex new file mode 100644 index 00000000..39ff9c03 --- /dev/null +++ b/lib/music_library/errors.ex @@ -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 diff --git a/lib/music_library_web/controllers/error_controller.ex b/lib/music_library_web/controllers/error_controller.ex new file mode 100644 index 00000000..fdb996fe --- /dev/null +++ b/lib/music_library_web/controllers/error_controller.ex @@ -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 diff --git a/lib/music_library_web/controllers/error_json.ex b/lib/music_library_web/controllers/error_json.ex index 329c1e2c..9c9734dd 100644 --- a/lib/music_library_web/controllers/error_json.ex +++ b/lib/music_library_web/controllers/error_json.ex @@ -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 diff --git a/lib/music_library_web/router.ex b/lib/music_library_web/router.ex index c60ca0f7..5c844e7f 100644 --- a/lib/music_library_web/router.ex +++ b/lib/music_library_web/router.ex @@ -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 diff --git a/test/music_library_web/controllers/error_controller_test.exs b/test/music_library_web/controllers/error_controller_test.exs new file mode 100644 index 00000000..47be3368 --- /dev/null +++ b/test/music_library_web/controllers/error_controller_test.exs @@ -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 diff --git a/test/support/fixtures/errors_fixtures.ex b/test/support/fixtures/errors_fixtures.ex new file mode 100644 index 00000000..7476dfb3 --- /dev/null +++ b/test/support/fixtures/errors_fixtures.ex @@ -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