Skip to main content

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.SaveChangesAsync writes one Insert/Update/Delete row per tracked entity change: EntityType is the CLR type name, EntityId the primary key, UserId the DfE Sign-In subject (system outside a request, rules-engine-worker in the worker). Excluded as telemetry or bulk-derived data: queue metrics, search events and hit rows, organisation logins, the egress learner rows and EgressRunOutput. Not excluded: every AppLog row 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/Insert row (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.MarkTransferredAsync and MarkTransferFailedAsync each write one EgressRun row — Transfer or TransferFailed — 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) carry outcome, windowId, outputTypes and transferredBy; success adds the files, hashes, container and time; failure adds the reason. 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.CloseExerciseEarlyAsync writes one WindowAdmin/ClosedEarly row 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. EntityId is the window id, UserId the admin's subject, Timestamp the UTC instant of the close. The payload (NewValues, camelCase JSON) carries windowId, windowTitle, exerciseType, scheduledEnd (the end date it had), newEndDate, closedEarly, closedBy and closedAtUtc. The end-date move itself is an ExecuteUpdate, so the generic capture writes no CheckingExercise/Update row for it — this row is the record.
  • Automatic hand-over of a checking exercise's requests (AB#302158). WindowAdminAuditWriter writes one WindowAdmin/RequestsSentAutomatically row 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. EntityId is the window id, UserId is null (nobody did it; the screen says "System"), Timestamp the UTC instant of the run. The payload carries windowId, windowTitle, exerciseId, exerciseType, extensionId, exerciseEnd, requestsSent, draftsCancelled, automatic and ranAtUtc. extensionId (AB#307773) names the extension handed over and is null for the exercise's own hand-over; exerciseEnd is 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 WindowAdmin row from ExerciseExtensionRepository. EntityId is the window id, UserId the admin's subject, Timestamp the UTC instant of the change. Every payload (camelCase JSON) carries windowId, windowTitle, exerciseId, exerciseType, extensionId, endDate and actorName. 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: schoolCount and schools (digits). endDate is the extension's end.
    • ExtensionEdited: previousEndDate, newEndDate, schoolCount, schoolsAdded and schoolsRemoved. endDate is the new end.
    • ExtensionClosedEarly: scheduledEnd, newEndDate, closedEarly, closedBy and closedAtUtc. endDate is the new end; closedBy and actorName are the same name.
    • ExtensionRequestsSent: extensionEnd, requestsSent and draftsCancelled. endDate is extensionEnd. 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 DELETE trigger on AuditEntries raises, so nothing — including the app — can amend or delete a row; the page has no such control. Pinned by AuditEntryActionLengthTests.AuditEntries_AreImmutable_UpdateAndDeleteRaise.
  • Nothing purges AuditEntries; there is no retention job for them.
  • OldValues/NewValues/ChangedColumns are never rendered or exported: the generic capture stores pupil-bearing entities (ChangeRequest) in them. The query projects NewValues only for EgressRun and WindowAdmin rows (two hand-written payloads with no pupil data), and AuditLogRow has no payload member. The export shows a window-admin row's exercise in the Action column, as ClosedEarly (PupilData) or RequestsSentAutomatically (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/cleanup deletes 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 through egress_runs. Production never deletes runs.