The activity log is never written, so the dashboard's activity panel is permanently empty #104

Closed
opened 2026-08-13 22:59:41 +02:00 by Claude · 2 comments
Collaborator

/System/ActivityLog/Entries answers an empty listing — honest, and better than a 404 — and ActivityLogs is the one table diff-db exempts outright, because every row in it is upstream's alone.

Upstream writes a row per login, per failed login, per playback start and stop, per session, per library scan outcome, and per user creation or deletion. An operator reads that panel to answer "who logged in from where, and when", which is the one question a server with no log cannot answer after the fact.

The write side is small — the table is bound already and the events all exist in the session and auth code paths. Two things to settle from the reference: the exact Type strings and severity per event, since clients group on them, and the ItemId/UserId columns' population per kind.

Once rows are written, the diff-db exemption should narrow from the whole table to whatever genuinely cannot agree (the ids and the timestamps), and CleanActivityLog becomes real work rather than a sweep over nothing.


Where to start

/System/ActivityLog/Entries answers an empty listing from
crates/jellyfin-http/src/routes/admin/system.rs; the ActivityLogs table is bound in
crates/jellyfin-db. The events all already exist as code paths —
crates/jellyfin-app/src/session/sessions.rs, the auth module, the scan, and the user
routes.

Upstream writes from ActivityLogEntryPoint and friends; find every call site
rather than the ones you remember, because a client groups on Type and a
missing kind is an empty row in a filtered view.

Two things to take from the reference rather than the C#: the exact Type
strings per event and the severity, and which of ItemId/UserId each kind
populates.

How to know it is done

  • Log in, play something, stop, and scan on both servers; the two activity
    listings carry the same kinds in the same order.
  • Then narrow the exemption. crates/jellyfin-harness/db-ignore-list.json
    exempts the whole ActivityLogs table today with the reason "nothing here
    writes one and nothing will". That reason expires with this issue: the entry
    should shrink to the ids and timestamps that genuinely cannot agree, or go. An
    ignore list that only ever grows is the smell the risk table names.

House rules

CLAUDE.md is the short version and these are the parts that bite: tests run
under cargo nextest; upstream's source is the checkout in upstream/ at the
pin
(v12.0-rc5, 6d501ba4) and not the clone beside the repository;
generated files are regenerated rather than edited; a comment says what the code
does, not how it got there; and Dn references stay out of code and comments.
ARCHITECTURE.md decides which crate a thing goes in and which dependency edges
are illegal, DECISIONS.md says why a rule exists.

The reference pair is cargo xtask reference up (and reference provision,
which is idempotent); a run is
cargo run -p jellyfin-harness -- replay --upstream URL --ours URL --scenario FILE --out DIR,
with diff-db for the two databases. crates/jellyfin-harness/README.md says
what a diff means and what an ignore entry has to carry — every entry needs a
reason and an expiry milestone, and an adds-only history is the smell the risk
table names.

`/System/ActivityLog/Entries` answers an empty listing — honest, and better than a 404 — and `ActivityLogs` is the one table `diff-db` exempts outright, because every row in it is upstream's alone. Upstream writes a row per login, per failed login, per playback start and stop, per session, per library scan outcome, and per user creation or deletion. An operator reads that panel to answer "who logged in from where, and when", which is the one question a server with no log cannot answer after the fact. The write side is small — the table is bound already and the events all exist in the session and auth code paths. Two things to settle from the reference: the exact `Type` strings and severity per event, since clients group on them, and the `ItemId`/`UserId` columns' population per kind. Once rows are written, the `diff-db` exemption should narrow from the whole table to whatever genuinely cannot agree (the ids and the timestamps), and `CleanActivityLog` becomes real work rather than a sweep over nothing. --- ## Where to start `/System/ActivityLog/Entries` answers an empty listing from `crates/jellyfin-http/src/routes/admin/system.rs`; the `ActivityLogs` table is bound in `crates/jellyfin-db`. The events all already exist as code paths — `crates/jellyfin-app/src/session/sessions.rs`, the auth module, the scan, and the user routes. Upstream writes from `ActivityLogEntryPoint` and friends; find every call site rather than the ones you remember, because a client groups on `Type` and a missing kind is an empty row in a filtered view. Two things to take from the reference rather than the C#: the exact `Type` strings per event and the severity, and which of `ItemId`/`UserId` each kind populates. ## How to know it is done - Log in, play something, stop, and scan on both servers; the two activity listings carry the same kinds in the same order. - Then **narrow the exemption**. `crates/jellyfin-harness/db-ignore-list.json` exempts the whole `ActivityLogs` table today with the reason "nothing here writes one and nothing will". That reason expires with this issue: the entry should shrink to the ids and timestamps that genuinely cannot agree, or go. An ignore list that only ever grows is the smell the risk table names. ### House rules `CLAUDE.md` is the short version and these are the parts that bite: tests run under `cargo nextest`; upstream's source is the checkout in `upstream/` **at the pin** (`v12.0-rc5`, `6d501ba4`) and not the clone beside the repository; generated files are regenerated rather than edited; a comment says what the code does, not how it got there; and `Dn` references stay out of code and comments. `ARCHITECTURE.md` decides which crate a thing goes in and which dependency edges are illegal, `DECISIONS.md` says why a rule exists. The reference pair is `cargo xtask reference up` (and `reference provision`, which is idempotent); a run is `cargo run -p jellyfin-harness -- replay --upstream URL --ours URL --scenario FILE --out DIR`, with `diff-db` for the two databases. `crates/jellyfin-harness/README.md` says what a diff means and what an ignore entry has to carry — every entry needs a reason and an expiry milestone, and an adds-only history is the smell the risk table names.
Author
Collaborator

Done, on main (3ad8cb5, plus b0e3ae7 for what the pair found). The write side, the read side, and the exemption narrowed.

What is written now

A row per login and per refused login, per session opening and closing, per account made, given a password or taken away, per download, and per scheduled task that failed.

The Type strings, the severity and which of ItemId/UserId each kind fills came off the reference's own rows — the ActivityLogs table in the fixture snapshots already held 21 of them, so the sentences are quoted rather than reconstructed. jellyfin_core::activity builds them in one place because the sentence is the contract a client renders.

Two silences are upstream's own and are kept deliberately:

  • A playback report writes nothing unless the client posted the whole Item. Upstream reads the title and the medium off what the client sent, not off the row it looked up; MediaInfo null means the logger warns and returns. The reference bears this out — 27 downloads and 181 logins recorded, and zero playback rows, because the harness posts an id alone.
  • A refused login is filed only where the credentials were wrong. An account upstream has and will not admit throws a SecurityException before the event is raised, so a disabled account or one outside its schedule writes nothing.

UserLockedOut is not written and the builder for it was deleted: nothing here locks an account out, and a builder with no caller is the same dead name the tasks module refuses.

What the pair found

Replaying handshake.json after tasks.json showed upstream writing SessionEnded, SessionStarted, AuthenticationSucceeded on the second login where this server wrote only the last. The cause was not the audit line — issuing a token already logged the account's existing one on that device out, and upstream's Logout deletes the device row and ends every session on it. Only the token half was here. A session that was never away is one /Sessions goes on listing and one the audit line never closes. Fixed in b0e3ae7; the two servers now write the same five rows in the same order.

The exemption

Narrowed from the whole table to the value of three columns, each argued on the entry:

  • DateCreated — the wall clock of whichever server answered, the same argument as BaseItems.DateLastSaved.
  • Id — an AUTOINCREMENT; one extra row on either side renumbers every row after it, so a single difference would be reported as the whole table twice over and hide the row that really differs.
  • ShortOverview — carries the caller's address, which is the docker bridge on one side and the loopback on the other. The one column that varies by how the pair was stood up rather than by what either server did.

Name, Type, UserId, ItemId, Overview and LogSeverity are diffed outright, which is what makes a missing kind, a misspelled type or the wrong severity show. Verified: diff-db after a tasks + handshake replay reports nothing for ActivityLogs, where before the run it was one of three exempt tables.

The read side

/System/ActivityLog/Entries reads real rows with every filter upstream's action offers — the four substring matches, the date range, hasUserId, itemId, username (through the join, so a deleted account's rows fall out of it exactly as upstream's do), severity, and sortBy/sortOrder. Ordering ends on the row's own id in the direction the last term asked for; upstream does not, and it cannot disagree either, because it only ever decides between rows the named columns tie on — which a paged listing otherwise leaves to the query plan.

Eight route tests in crates/jellyfin-http/tests/activity_log.rs, and MILESTONES.md's "what M7 does not do" entry for the activity log is corrected.

Done, on `main` (`3ad8cb5`, plus `b0e3ae7` for what the pair found). The write side, the read side, and the exemption narrowed. ## What is written now A row per login and per refused login, per session opening and closing, per account made, given a password or taken away, per download, and per scheduled task that failed. **The `Type` strings, the severity and which of `ItemId`/`UserId` each kind fills came off the reference's own rows** — the `ActivityLogs` table in the fixture snapshots already held 21 of them, so the sentences are quoted rather than reconstructed. `jellyfin_core::activity` builds them in one place because the sentence is the contract a client renders. Two silences are upstream's own and are kept deliberately: - **A playback report writes nothing unless the client posted the whole `Item`.** Upstream reads the title and the medium off what the client sent, not off the row it looked up; `MediaInfo` null means the logger warns and returns. The reference bears this out — 27 downloads and 181 logins recorded, and *zero* playback rows, because the harness posts an id alone. - **A refused login is filed only where the credentials were wrong.** An account upstream has and will not admit throws a `SecurityException` before the event is raised, so a disabled account or one outside its schedule writes nothing. `UserLockedOut` is not written and the builder for it was deleted: nothing here locks an account out, and a builder with no caller is the same dead name the tasks module refuses. ## What the pair found Replaying `handshake.json` after `tasks.json` showed upstream writing `SessionEnded`, `SessionStarted`, `AuthenticationSucceeded` on the second login where this server wrote only the last. The cause was not the audit line — **issuing a token already logged the account's existing one on that device out, and upstream's `Logout` deletes the device row *and* ends every session on it. Only the token half was here.** A session that was never away is one `/Sessions` goes on listing and one the audit line never closes. Fixed in `b0e3ae7`; the two servers now write the same five rows in the same order. ## The exemption Narrowed from the whole table to the *value* of three columns, each argued on the entry: - `DateCreated` — the wall clock of whichever server answered, the same argument as `BaseItems.DateLastSaved`. - `Id` — an `AUTOINCREMENT`; one extra row on either side renumbers every row after it, so a single difference would be reported as the whole table twice over and hide the row that really differs. - `ShortOverview` — carries the caller's address, which is the docker bridge on one side and the loopback on the other. The one column that varies by how the pair was stood up rather than by what either server did. `Name`, `Type`, `UserId`, `ItemId`, `Overview` and `LogSeverity` are diffed outright, which is what makes a missing kind, a misspelled type or the wrong severity show. Verified: `diff-db` after a `tasks` + `handshake` replay reports **nothing** for `ActivityLogs`, where before the run it was one of three exempt tables. ## The read side `/System/ActivityLog/Entries` reads real rows with every filter upstream's action offers — the four substring matches, the date range, `hasUserId`, `itemId`, `username` (through the join, so a deleted account's rows fall out of it exactly as upstream's do), `severity`, and `sortBy`/`sortOrder`. Ordering ends on the row's own id in the direction the last term asked for; upstream does not, and it cannot disagree either, because it only ever decides between rows the named columns tie on — which a paged listing otherwise leaves to the query plan. Eight route tests in `crates/jellyfin-http/tests/activity_log.rs`, and `MILESTONES.md`'s "what M7 does not do" entry for the activity log is corrected.
Author
Collaborator

The review round (0ff1992)

Eight findings; all eight acted on. Three were worth the round on their own:

A playback report that names only an id does write a row — my earlier claim here was wrong. Upstream fills the Item on the report from the library row before it raises the event (UpdateNowPlayingItem on the way in, the same normalising at SessionManager.cs:1082 on the way out), so the sentence names the item whether or not the client sent one. The "measured: the reference holds no playback entry" note was drawn from a reference that had simply never been sent a playback report since its snapshot. Posting one to it writes both VideoPlayback and VideoPlaybackStopped. Fixed, and re-measured with the pair: a clean playstate.json replay is 0 differences and the two servers file the same three rows —

24|VideoPlayback|jellyfin is playing Blue Field (2020) on harness
25|VideoPlaybackStopped|jellyfin has finished playing Blue Field (2020) on harness
26|VideoPlaybackStopped|jellyfin has finished playing Night Signal (2022) on harness

Without the fix ours had none of them, which the now-diffed table would have reported.

A login refused for a username nobody has writes nothing. UserManager.AuthenticateUser throws AuthenticationException at UserManager.cs:568 when no user is found, so the event is never raised; only a real account with a wrong password reaches PublishAsync(AuthenticationRequestEventArgs) — it returns null rather than throwing on that path. A bogus name was writing a row here and none upstream.

A socket outliving the session it was opened on could end the session that replaced it. Mine, from b0e3ae7: a session's id is its client and device hashed, so the one a re-login opens comes back under the same id and a stale SocketHandle matched it — disconnect then found the new entry, saw no sockets, and removed it, filing a spurious SessionEnded and dropping the caller out of /Sessions. An entry carries a generation now and a handle names the incarnation it belongs to. The test fails without the fix.

Also fixed: every session on the device goes when a login replaces the token rather than only the account's own (Logout(Device) ends them all); a sort field past the last direction given takes the first, which is what RequestHelpers.GetOrderBy does; and the device in a playback line is the register's name, so a device an operator renamed reads as itself.

One divergence kept and written down instead: a negative retention posted to /System/Configuration is refused rather than stored. Upstream stores it and lets CleanActivityLogTask throw on every run — which, now that the table is diffed, would be a TaskFailed row per run to agree with.

Verified

  • cargo nextest run --workspace: 2376 passed, 0 skipped
  • ActivityLogs diffs clean after tasks + handshake + playstate, row for row
  • No response regressions: the same scenario sequence against a build of f3b9015 gives the identical counts (2 / 5 / 73 / 142 / 8 / 864)
## The review round (`0ff1992`) Eight findings; all eight acted on. Three were worth the round on their own: **A playback report that names only an id does write a row — my earlier claim here was wrong.** Upstream fills the `Item` on the report from the library row before it raises the event (`UpdateNowPlayingItem` on the way in, the same normalising at `SessionManager.cs:1082` on the way out), so the sentence names the item whether or not the client sent one. The "measured: the reference holds no playback entry" note was drawn from a reference that had simply never been sent a playback report since its snapshot. Posting one to it writes both `VideoPlayback` and `VideoPlaybackStopped`. Fixed, and re-measured with the pair: a clean `playstate.json` replay is **0 differences** and the two servers file the same three rows — ``` 24|VideoPlayback|jellyfin is playing Blue Field (2020) on harness 25|VideoPlaybackStopped|jellyfin has finished playing Blue Field (2020) on harness 26|VideoPlaybackStopped|jellyfin has finished playing Night Signal (2022) on harness ``` Without the fix ours had none of them, which the now-diffed table would have reported. **A login refused for a username nobody has writes nothing.** `UserManager.AuthenticateUser` throws `AuthenticationException` at `UserManager.cs:568` when no user is found, so the event is never raised; only a real account with a wrong password reaches `PublishAsync(AuthenticationRequestEventArgs)` — it returns `null` rather than throwing on that path. A bogus name was writing a row here and none upstream. **A socket outliving the session it was opened on could end the session that replaced it.** Mine, from `b0e3ae7`: a session's id is its client and device hashed, so the one a re-login opens comes back under the same id and a stale `SocketHandle` matched it — `disconnect` then found the new entry, saw no sockets, and removed it, filing a spurious `SessionEnded` and dropping the caller out of `/Sessions`. An entry carries a generation now and a handle names the incarnation it belongs to. The test fails without the fix. Also fixed: every session on the device goes when a login replaces the token rather than only the account's own (`Logout(Device)` ends them all); a sort field past the last direction given takes the *first*, which is what `RequestHelpers.GetOrderBy` does; and the device in a playback line is the register's name, so a device an operator renamed reads as itself. One divergence kept and written down instead: a negative retention posted to `/System/Configuration` is refused rather than stored. Upstream stores it and lets `CleanActivityLogTask` throw on every run — which, now that the table is diffed, would be a `TaskFailed` row per run to agree with. ## Verified - `cargo nextest run --workspace`: **2376 passed, 0 skipped** - `ActivityLogs` diffs clean after `tasks` + `handshake` + `playstate`, row for row - No response regressions: the same scenario sequence against a build of `f3b9015` gives the identical counts (2 / 5 / 73 / 142 / 8 / 864)
Sign in to join this conversation.
No description provided.