loggs

5474077 · gmorell · 2026-06-11 18:42

21 files +205 -5

Files changed

modified lib/oh_my_ex_oh_ircd/scrobbles.ex
+59 −1
@@ -15,7 +15,10 @@ defmodule OhMyExOhIrcd.Scrobbles do
15 15 alias OhMyExOhIrcd.Accounts.User
16 16 alias OhMyExOhIrcd.Channels
17 17 alias OhMyExOhIrcd.Channels.ChannelRecord
18 − alias OhMyExOhIrcd.Scrobbles.{ChannelConfig, Scrobble, Token}
18 + alias OhMyExOhIrcd.Scrobbles.{ChannelConfig, RequestLog, Scrobble, Token}
19 +
20 + # How many request-log rows to keep per channel.
21 + @log_retention 200
19 22
20 23 ## Config
21 24
@@ -212,4 +215,59 @@ defmodule OhMyExOhIrcd.Scrobbles do
212 215 end
213 216
214 217 defp normalize_who(who), do: who |> String.trim() |> String.downcase()
218 +
219 + ## Request logs (debugging)
220 +
221 + @doc """
222 + Record the outcome of an authenticated scrobble request. `attrs` carries
223 + `who`, `status` (e.g. "ok", "ignored", "not_joined", "invalid_payload",
224 + "error") and an optional `detail`. Prunes to the most recent
225 + `#{@log_retention}` rows per channel. Best-effort: never raises.
226 + """
227 + def log_request(channel_id, attrs) when is_integer(channel_id) do
228 + %RequestLog{}
229 + |> RequestLog.changeset(Map.put(attrs, :channel_id, channel_id))
230 + |> Repo.insert()
231 + |> case do
232 + {:ok, _} = ok ->
233 + prune_logs(channel_id)
234 + ok
235 +
236 + other ->
237 + other
238 + end
239 + rescue
240 + _ -> :error
241 + end
242 +
243 + @doc "Most recent `n` request-log rows for a channel, newest first."
244 + def recent_logs(channel_name, n \\ 50) when is_binary(channel_name) do
245 + case Channels.get(channel_name) do
246 + nil ->
247 + []
248 +
249 + %ChannelRecord{id: id} ->
250 + Repo.all(
251 + from l in RequestLog,
252 + where: l.channel_id == ^id,
253 + order_by: [desc: l.inserted_at, desc: l.id],
254 + limit: ^n
255 + )
256 + end
257 + end
258 +
259 + defp prune_logs(channel_id) do
260 + keep_ids =
261 + from(l in RequestLog,
262 + where: l.channel_id == ^channel_id,
263 + order_by: [desc: l.inserted_at, desc: l.id],
264 + limit: @log_retention,
265 + select: l.id
266 + )
267 +
268 + Repo.delete_all(
269 + from l in RequestLog,
270 + where: l.channel_id == ^channel_id and l.id not in subquery(keep_ids)
271 + )
272 + end
215 273 end
added lib/oh_my_ex_oh_ircd/scrobbles/request_log.ex
+32 −0
@@ -0,0 +1,32 @@
1 +defmodule OhMyExOhIrcd.Scrobbles.RequestLog do
2 + @moduledoc """
3 + Append-only log of authenticated scrobble API requests and their
4 + outcome, for debugging from the channel's scrobble page. Only requests
5 + that pass token auth are logged (we know the channel then); a request
6 + with a bad URL or unknown/revoked token never reaches the controller,
7 + so an empty log points at the URL or token rather than the channel.
8 + """
9 +
10 + use Ecto.Schema
11 + import Ecto.Changeset
12 +
13 + alias OhMyExOhIrcd.Channels.ChannelRecord
14 +
15 + schema "scrobble_request_logs" do
16 + field :who, :string
17 + field :status, :string
18 + field :detail, :string
19 +
20 + belongs_to :channel, ChannelRecord
21 +
22 + timestamps(type: :utc_datetime, updated_at: false)
23 + end
24 +
25 + def changeset(record, attrs) do
26 + record
27 + |> cast(attrs, [:channel_id, :who, :status, :detail])
28 + |> validate_required([:channel_id, :status])
29 + |> validate_length(:who, max: 64)
30 + |> validate_length(:detail, max: 256)
31 + end
32 +end
modified lib/oh_my_ex_oh_ircd_web/controllers/scrobble_controller.ex
+16 −0
@@ -9,6 +9,7 @@ defmodule OhMyExOhIrcdWeb.ScrobbleController do
9 9 use OhMyExOhIrcdWeb, :controller
10 10
11 11 alias OhMyExOhIrcd.IRC.ScrobbleServ
12 + alias OhMyExOhIrcd.Scrobbles
12 13
13 14 @required ~w(artist track)
14 15 @optional ~w(album)
@@ -31,15 +32,30 @@ defmodule OhMyExOhIrcdWeb.ScrobbleController do
31 32 fields["album"]
32 33 )
33 34
35 + log(channel, who, result)
34 36 respond(conn, result)
35 37
36 38 {:error, detail} ->
39 + log(channel, who, {:invalid_payload, detail})
40 +
37 41 conn
38 42 |> put_status(:unprocessable_entity)
39 43 |> json(%{error: "invalid_payload", detail: detail})
40 44 end
41 45 end
42 46
47 + # Best-effort debug log of each authenticated request's outcome.
48 + defp log(channel, who, outcome) do
49 + {status, detail} = log_fields(outcome)
50 + Scrobbles.log_request(channel.id, %{who: who, status: status, detail: detail})
51 + end
52 +
53 + defp log_fields(:ok), do: {"ok", nil}
54 + defp log_fields({:ignored, reason}), do: {"ignored", to_string(reason)}
55 + defp log_fields({:error, :not_joined}), do: {"not_joined", nil}
56 + defp log_fields({:error, reason}), do: {"error", to_string(reason)}
57 + defp log_fields({:invalid_payload, detail}), do: {"invalid_payload", detail}
58 +
43 59 defp respond(conn, :ok), do: json(conn, %{status: "ok"})
44 60
45 61 defp respond(conn, {:ignored, reason}),
modified lib/oh_my_ex_oh_ircd_web/live/user_live/scrobble.ex
+44 −1
@@ -11,6 +11,7 @@ defmodule OhMyExOhIrcdWeb.UserLive.Scrobble do
11 11 alias OhMyExOhIrcd.Scrobbles
12 12
13 13 @recent_limit 25
14 + @log_limit 50
14 15 @manage_roles [:founder, :admin, :op]
15 16
16 17 @impl true
@@ -59,6 +60,7 @@ defmodule OhMyExOhIrcdWeb.UserLive.Scrobble do
59 60 |> assign(:config_form, to_form(config_params(config), as: :config))
60 61 |> assign(:tokens, Scrobbles.list_tokens(name))
61 62 |> assign(:recent, Scrobbles.recent(name, @recent_limit))
63 + |> assign(:logs, Scrobbles.recent_logs(name, @log_limit))
62 64 end
63 65
64 66 defp config_params(nil), do: %{}
@@ -275,7 +277,8 @@ defmodule OhMyExOhIrcdWeb.UserLive.Scrobble do
275 277 Each token is bound to you and a fixed display name. POST scrobbles to
276 278 <code>/api/v1/scrobble</code>
277 279 with header <code>Authorization: Bearer &lt;token&gt;</code>
278 − and a JSON body containing <code>artist</code> and <code>track</code>
280 + and a JSON body containing <code>artist</code>
281 + and <code>track</code>
279 282 (optional <code>album</code>).
280 283 </p>
281 284 </section>
@@ -306,11 +309,51 @@ defmodule OhMyExOhIrcdWeb.UserLive.Scrobble do
306 309 </table>
307 310 </div>
308 311 </section>
312 +
313 + <section class="space-y-3">
314 + <h2 class="font-mono text-lg">Request log</h2>
315 + <p class="text-xs text-base-content/60">
316 + Authenticated API requests and their outcome. If this stays empty while a client is
317 + posting, the request isn't reaching the endpoint or the token isn't valid — check the
318 + URL (<code>/api/v1/scrobble</code>) and that the token hasn't been revoked.
319 + </p>
320 + <div class="overflow-x-auto rounded-box border border-base-300">
321 + <table class="table table-sm">
322 + <thead>
323 + <tr>
324 + <th>When</th>
325 + <th>Who</th>
326 + <th>Status</th>
327 + <th>Detail</th>
328 + </tr>
329 + </thead>
330 + <tbody>
331 + <tr :for={l <- @logs}>
332 + <td class="text-xs whitespace-nowrap">{format_ts(l.inserted_at)}</td>
333 + <td class="font-mono">{l.who}</td>
334 + <td>
335 + <span class={"badge badge-sm " <> log_badge(l.status)}>{l.status}</span>
336 + </td>
337 + <td class="text-xs">{l.detail}</td>
338 + </tr>
339 + <tr :if={@logs == []}>
340 + <td colspan="4" class="text-center text-base-content/60">
341 + No authenticated requests yet.
342 + </td>
343 + </tr>
344 + </tbody>
345 + </table>
346 + </div>
347 + </section>
309 348 </div>
310 349 </Layouts.app>
311 350 """
312 351 end
313 352
353 + defp log_badge("ok"), do: "badge-success"
354 + defp log_badge("ignored"), do: "badge-warning"
355 + defp log_badge(_), do: "badge-error"
356 +
314 357 defp format_ts(nil), do: "never"
315 358 defp format_ts(%DateTime{} = dt), do: Calendar.strftime(dt, "%Y-%m-%d %H:%M")
316 359 end
added priv/repo/migrations/20260529000000_create_scrobble_request_logs.exs
+16 −0
@@ -0,0 +1,16 @@
1 +defmodule OhMyExOhIrcd.Repo.Migrations.CreateScrobbleRequestLogs do
2 + use Ecto.Migration
3 +
4 + def change do
5 + create table(:scrobble_request_logs) do
6 + add :channel_id, references(:channels, on_delete: :delete_all), null: false
7 + add :who, :string
8 + add :status, :string, null: false
9 + add :detail, :string
10 +
11 + timestamps(type: :utc_datetime, updated_at: false)
12 + end
13 +
14 + create index(:scrobble_request_logs, [:channel_id, :inserted_at])
15 + end
16 +end
modified test/oh_my_ex_oh_ircd/scrobbles_test.exs
+21 −0
@@ -117,4 +117,25 @@ defmodule OhMyExOhIrcd.ScrobblesTest do
117 117 assert hd(recent).track == "t5"
118 118 end
119 119 end
120 +
121 + describe "request logs" do
122 + test "log + recent newest-first", %{chan: chan} do
123 + %{id: id} = Channels.get(chan)
124 + {:ok, _} = Scrobbles.log_request(id, %{who: "gmp", status: "ok"})
125 + {:ok, _} = Scrobbles.log_request(id, %{who: "gmp", status: "ignored", detail: "rate_limited"})
126 +
127 + logs = Scrobbles.recent_logs(chan, 10)
128 + assert length(logs) == 2
129 + assert hd(logs).status == "ignored"
130 + assert hd(logs).detail == "rate_limited"
131 + end
132 +
133 + test "prunes beyond retention", %{chan: chan} do
134 + %{id: id} = Channels.get(chan)
135 + for n <- 1..205, do: {:ok, _} = Scrobbles.log_request(id, %{who: "w", status: "ok", detail: "#{n}"})
136 +
137 + # Retention caps stored rows at 200.
138 + assert length(Scrobbles.recent_logs(chan, 1000)) == 200
139 + end
140 + end
120 141 end
modified test/oh_my_ex_oh_ircd_web/controllers/scrobble_controller_test.exs
+17 −3
@@ -43,11 +43,17 @@ defmodule OhMyExOhIrcdWeb.ScrobbleControllerTest do
43 43 assert body["error"] == "invalid_payload"
44 44 end
45 45
46 − test "ignores a client-supplied who (who is token-bound)", %{conn: conn, token: token, chan: chan} do
46 + test "ignores a client-supplied who (who is token-bound)", %{
47 + conn: conn,
48 + token: token,
49 + chan: chan
50 + } do
47 51 {:ok, _} = Scrobbles.ensure_config(chan)
48 52 {:ok, _} = Scrobbles.set_joined(chan, true)
49 53
50 − conn = conn |> auth(token) |> post(~p"/api/v1/scrobble", Map.put(payload(), "who", "someone-else"))
54 + conn =
55 + conn |> auth(token) |> post(~p"/api/v1/scrobble", Map.put(payload(), "who", "someone-else"))
56 +
51 57 assert json_response(conn, 200) == %{"status" => "ok"}
52 58
53 59 assert [scrobble] = Scrobbles.recent(chan, 5)
@@ -67,10 +73,18 @@ defmodule OhMyExOhIrcdWeb.ScrobbleControllerTest do
67 73 assert [_scrobble] = Scrobbles.recent(chan, 5)
68 74 end
69 75
70 − test "valid token but ScrobbleServ not joined returns 404", %{conn: conn, token: token} do
76 + test "valid token but ScrobbleServ not joined returns 404 and is logged", %{
77 + conn: conn,
78 + token: token,
79 + chan: chan
80 + } do
71 81 # No invite happened → config (if any) is joined: false.
72 82 conn = conn |> auth(token) |> post(~p"/api/v1/scrobble", payload())
73 83 assert json_response(conn, 404) == %{"error" => "not_joined"}
84 +
85 + assert [log] = Scrobbles.recent_logs(chan, 5)
86 + assert log.status == "not_joined"
87 + assert log.who == "gmp"
74 88 end
75 89
76 90 test "joined + enabled channel announces and records", %{conn: conn, token: token, chan: chan} do

Parents: da770be