The command run journal
Updated
Every call of a command leaves one row in MetaCommandRuns: who ran it, on
what, what went in, what came out, how long it took, and — when it went
wrong — the exception. The journal is what the Command statistics screen
drills into.
Why the counter was not enough#
Each of the five command-family tables carries ExecutionCount and
LastExecutedUtc. Both are written after a successful run. That makes the
counter useless for the question people actually ask:
| Question | The counter | The journal |
|---|---|---|
| How often did this command run? | successes only | every call |
| Did it ever fail? | invisible | Success = false + ErrorDetails |
| Why did it fail? | — | the exception, as thrown |
| What was it called with? | — | ArgsJson |
| What did it answer? | — | ResultJson |
| Did the user ever finish the dialog? | — | AwaitingAnswer = true |
| How slow is it? | — | DurationMs |
A command that throws on every call sits at ExecutionCount = 0 forever, next
to a command that was never wired to a button. The journal tells them apart:
Calls 14 / Errors 14 versus Calls 0.
Worked example#
A document command PostSalesInvoice is pressed four times on the stand:
| # | What the user did | Outcome |
|---|---|---|
| 1 | Pressed it on invoice SI-0007 | asked "Post with a negative stock?" |
| 2 | Answered Yes | posted, Draft → Posted, 412 ms |
| 3 | Pressed it on SI-0008, which has no lines | refused: "Document has no lines" |
| 4 | Pressed it on SI-0009 | threw: NullReferenceException in the tx script |
Runs still counts successes only. The row also shows every call, and opens
its four runs:
| Command | Runs | Calls | Errors | Average | Last run |
|---|---|---|---|---|---|
| PostSalesInvoice | 1 | 4 | 2 | 173 ms | 14:31 |
| Started | Status | User | Object | Time | Message |
|---|---|---|---|---|---|
| 14:31 | Error | ops | SalesInvoice | 38 ms | Object reference not set… |
| 14:27 | Error | ops | SalesInvoice | 11 ms | Document has no lines |
| 14:22 | Done | ops | SalesInvoice | 412 ms | Posted |
| 14:21 | Question | ops | SalesInvoice | 230 ms | — |
Opening run #4 shows the input ({"parameters":{"Force":false}}), the output,
and the full stack trace. Opening run #2 additionally shows
Subtype: Draft → Posted.
What is stored#
ZuloOne.Metadata.MetaCommandRun — one row per invocation:
| Column | Meaning |
|---|---|
Kind |
user / dictionary / dictionarylist / document / documentlist, or legacy for the old flat pipeline |
CommandMetaId, CommandName, CommandCaption |
the command; the name is denormalized so a deleted command still reads |
UserMetaId, UserName |
who ran it; the name is denormalized, so deleting the user does not erase the history |
TargetObjectType, TargetObjectMetaId, TargetObjectName |
the dictionary / document type it ran against |
RecordId, SelectionCount |
the single record, or how many were selected on a list command |
StartSubtype, EndSubtype |
document subtype values before and after — equal when the command did not move the document |
ArgsJson |
the input: {"parameters":{…},"answers":{…}} |
ResultJson |
the output: data, validationErrors, clientActions, redirectUrl, shouldReload |
StartedUtc, DurationMs |
when and how long |
Success, AwaitingAnswer |
the outcome; AwaitingAnswer means the run stopped on a question, which is not a failure |
Message, ErrorDetails |
the message shown to the user and the exception as thrown |
Two indexes: (CommandMetaId, StartedUtc) for the drill-down and StartedUtc
for the "latest runs of anything" feed.
What is NOT stored#
Secret-looking parameters. A value whose key contains password, passwd,
pwd, secret, token, apikey, api_key, privatekey, private_key,
credential, pin, otp or cvv (case-insensitive substring) is replaced
with *** — in ArgsJson and in the Args of every client action. The key is
kept: it matters that the command was called with a password, it does not
matter which one.
Oversized payloads. ArgsJson and ResultJson are capped at 8000
characters; a longer payload is cut and ends with …[truncated]. A command that
returns a 10 MB table must not turn the journal into a second copy of the
database.
Anything older than 90 days. A set-based delete runs at most once every six hours, piggybacking on the next recorded run. The journal is operational history, not an audit trail — "who changed what" stays in the audit log.
All three limits live in CommandRunPayload (ZuloOne.Core/Services), which has
no EF dependency and is pinned by CommandRunPayloadTests.
How it is written#
CommandRunJournal.RecordAsync writes from its own DbContext, in a
finally, with CancellationToken.None. Three consequences, all intentional:
- a command whose own work was rolled back still leaves a run — that is exactly the run worth reading;
- an aborted request still leaves a run;
- a journal that cannot write never fails the command — it logs a warning and returns.
Both pipelines write through it: CommandFamilyService.ExecuteAsync
(api/commands2) and CommandService.ExecuteCommandAsync (the legacy
api/commands). The early refusals are journalled too — "Command is disabled",
"recordId is required", "No records selected", "Parameter 'X' is required" —
because those are precisely the runs that would vanish without trace.
The input is serialized before the handler runs: a script is free to mutate its own parameter bag, and the journal must show what the caller sent.
Subtype capture is narrow on purpose#
StartSubtype is read only for a document command on a known document type
with exactly one record selected, and EndSubtype only when the start was
captured. A list command over 500 documents would otherwise cost 1000 extra
reads. A document command with an empty subtype binding set resolves no target
type, so it captures no subtype either.
API#
| Endpoint | Returns |
|---|---|
GET /api/commands2/runs/summary |
per command: total, failed, asked, lastRunUtc, avgMs |
GET /api/commands2/runs?commandMetaId=&kind=&success=&userMetaId=&take= |
runs newest-first, without the heavy payloads (hasArgs / hasResult / hasError instead); take is clamped to 1…500 |
GET /api/commands2/runs/{id} |
one run in full |
All three are gated by the commandruns system registry
(SystemObjects.ByKey("commandruns"), object type System, operation Read) —
unrestricted until a permission row for it exists, like every other kernel
registry.
In the UI#
Tools → Command statistics. The grid gains three columns — Calls, Errors, Average — beside the existing Runs counter. Clicking a row opens its runs in a drawer; clicking a run shows its input, its output and its error. The action pane has Latest runs, the same drawer across all commands.
Administrator → Monitor → Command run journal is the journal as its own page:
every run of every command, newest first. All and Errors only switch the
filter, and a click on a run opens the same detail view. It is gated by the
commandruns system registry, like the API, so a role without Read on it
does not see the ribbon item.
Where next#
- Asking the user from a command: where
AwaitingAnswercomes from — a run stopped on a question is pending, not failed. - Document commands for analysts: the
subtype transition the journal records as
Draft → Posted. - Chain of Command: one run covers the whole chain, not one link — the journal wraps the outermost call.