Check Performance Data - Audit log (AB#294592)
The admin Audit log (GET /admin/audit-log, root tile "Audit log", grant audit-log) lists every
row of AuditEntries newest first, with data egress transfers distinguishable among them, three
cumulative filters, 20 rows a page, and a CSV export of the filtered set.
What is audited
-
Generic capture.
PortalDbContext.SaveChangesAsyncwrites oneInsert/Update/Deleterow per tracked entity change:EntityTypeis the CLR type name,EntityIdthe primary key,UserIdthe DfE Sign-In subject (systemoutside a request,rules-engine-workerin the worker). Excluded as telemetry or bulk-derived data: queue metrics, search events and hit rows, organisation logins, the egress learner rows andEgressRunOutput. Not excluded: everyAppLogrow the database log sink persists, so "App log / Insert" rows make up most of an unfiltered log (about 70% in a dev database). Whether to exclude them is an open product question. -
Deliberate rows written by hand:
ContentBundle/Import,DlqMessage/…(redrive, purge, full-payload view),SearchSession/SearchSessionDelete,SearchAnalyticsSink/…, rules-config actions, and the two egress rows below. -
Egress pulls. The run row itself is not excluded from the generic capture, so every pull is
also an
EgressRun/Insertrow (PascalCase payload with the window id and the starter's name). The log shows it as Data egress · Run started, naming the person who pulled, with no status; the status filter never matches it. -
Egress transfers.
EgressRunRepository.MarkTransferredAsyncandMarkTransferFailedAsynceach write oneEgressRunrow —TransferorTransferFailed— inside the same transaction as the guarded status flip, so a run that reached transfer always has exactly one row per attempt and a write that lost its race has none. Both payloads (NewValues, camelCase JSON) carryoutcome,windowId,outputTypesandtransferredBy; success adds the files, hashes, container and time; failure adds thereason. A run that failed in preprocessing or was abandoned never left the service and is not an audit row — the runs history shows those. -
Early closure of a checking exercise (AB#301022).
WindowRepository.CloseExerciseEarlyAsyncwrites oneWindowAdmin/ClosedEarlyrow inside the same transaction as the guarded end-date move, so an exercise closed early always has exactly one row and a close that lost its race has none.EntityIdis the window id,UserIdthe admin's subject,Timestampthe UTC instant of the close. The payload (NewValues, camelCase JSON) carrieswindowId,windowTitle,exerciseType,scheduledEnd(the end date it had),newEndDate,closedEarly,closedByandclosedAtUtc. The end-date move itself is anExecuteUpdate, so the generic capture writes noCheckingExercise/Updaterow for it — this row is the record. -
Automatic hand-over of a checking exercise's requests (AB#302158).
WindowAdminAuditWriterwrites oneWindowAdmin/RequestsSentAutomaticallyrow when the service hands an exercise's requests over by itself, two hours after the exercise ends — but only for a run that sent or cancelled something, so the day of repeat runs that follows leaves no rows.EntityIdis the window id,UserIdis null (nobody did it; the screen says "System"),Timestampthe UTC instant of the run. The payload carrieswindowId,windowTitle,exerciseId,exerciseType,extensionId,exerciseEnd,requestsSent,draftsCancelled,automaticandranAtUtc.extensionId(AB#307773) names the extension handed over and is null for the exercise's own hand-over;exerciseEndis the end it was judged on (for an extension, the later of its own end and the exercise's); the screen prefixes "(extension)". It is written after the sweep rather than in a transaction with it, so a failed write loses the row, not the hand-over. -
Extensions (AB#307773). An extension is extra time on one checking exercise for a named list
of schools (LAESTABs, no pupil data). Four admin actions each write one
WindowAdminrow fromExerciseExtensionRepository.EntityIdis the window id,UserIdthe admin's subject,Timestampthe UTC instant of the change. Every payload (camelCase JSON) carrieswindowId,windowTitle,exerciseId,exerciseType,extensionId,endDateandactorName. The first three rows are written in the same transaction as the change, so a change that lost its compare-and-set has none. Each row adds:-
ExtensionAdded:schoolCountandschools(digits).endDateis the extension's end. -
ExtensionEdited:previousEndDate,newEndDate,schoolCount,schoolsAddedandschoolsRemoved.endDateis the new end. -
ExtensionClosedEarly:scheduledEnd,newEndDate,closedEarly,closedByandclosedAtUtc.endDateis the new end;closedByandactorNameare the same name. -
ExtensionRequestsSent:extensionEnd,requestsSentanddraftsCancelled.endDateisextensionEnd. It is written after the manual Send's sweep, in a transaction of its own.
-
The screen
| Column | Egress row | Window admin row | Any other row |
|---|---|---|---|
| User |
transferredBy from the payload (the subject id for a pull row) |
closedBy, else actorName, from the payload |
the UserId subject (there is no user directory) |
| Activity | turquoise Data egress tag ("Run started" beneath for a pull row) | orange Window admin tag | grey tag with the entity type's label, the action beneath |
| Checking window | the run's window title, output types beneath | the window title, "{exercise} closed early, before scheduled end" beneath | the window title for a CheckingWindow row; otherwise empty |
| Time |
d MMM yyyy and HH:mm:ss UTC
|
same | same |
| Status | green Success / red Failed | green Success | none |
An extension row is a window-admin row too: the actor's name, the orange Window admin tag, the window title with one sentence beneath ("{exercise} extension added for 3 schools, ending 12/11/2026 17:00", "edited: N schools added, M removed", "closed early, before its end", "requests sent for processing: N requests sent, M drafts cancelled"), and green Success.
An automatic hand-over is a window-admin row as well: System as the user, the orange Window admin tag, the window title with "{exercise} requests sent for processing automatically: N requests sent, M drafts cancelled" beneath, and green Success.
Filters (a plain GET form, no script): activity = the row's EntityType (options are the
distinct types present, plus EgressRun always; ?activity=EgressRun isolates egress); checking
window = egress rows whose run belongs to the window (resolved through egress_runs, so it never
parses JSON in SQL) or CheckingWindow and WindowAdmin rows whose id is the window; status =
egress rows whose action is Transfer (Success) or TransferFailed (Failed), plus
WindowAdmin rows whose action is ClosedEarly, RequestsSentAutomatically, ExtensionAdded,
ExtensionEdited, ExtensionClosedEarly or ExtensionRequestsSent (Success only).
Filters AND together; an unknown value is no filter. Page links and the export carry every filter.
Export
GET /admin/audit-log/export?activity=&windowId=&status= streams every matching row as
text/csv (audit-log-{yyyyMMdd-HHmmss}.csv): Timestamp (UTC),User,Activity,Action,Entity type, Entity id,Checking window,Output types,Status. No cap; rows stream straight to the response.
Immutability, retention, privacy
- A
BEFORE UPDATE OR DELETEtrigger onAuditEntriesraises, so nothing — including the app — can amend or delete a row; the page has no such control. Pinned byAuditEntryActionLengthTests.AuditEntries_AreImmutable_UpdateAndDeleteRaise. - Nothing purges
AuditEntries; there is no retention job for them. -
OldValues/NewValues/ChangedColumnsare never rendered or exported: the generic capture stores pupil-bearing entities (ChangeRequest) in them. The query projectsNewValuesonly forEgressRunandWindowAdminrows (two hand-written payloads with no pupil data), andAuditLogRowhas no payload member. The export shows a window-admin row's exercise in the Action column, asClosedEarly (PupilData)orRequestsSentAutomatically (PupilData), and the extension actions the same way.
Known limits
- Non-egress rows show the sign-in subject id, not a name.
- Egress failure rows written before AB#294592 carry no
windowId/outputTypes/transferredBy(dev and review databases only): they show "Unknown window", no files and the subject id, but still filter by window through their run. - A dev "Reset seed data" or
/dev/egress/cleanupdeletes the runs; their audit rows remain and still show the window's title (the payload carries the window id), but they no longer match the checking-window filter, which resolves throughegress_runs. Production never deletes runs.