Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Audit logs

Every admin mutation writes an audit row: who did it, what they did, to which entity, and when. Nothing else writes to that table, and nothing but the retention job removes from it. This guide reads the trail, labels a multi-step operation, and bounds what is kept.

Before you start

You need an admin credential for the instance, or orion-cli configured with one. The endpoint is GET /api/v1/admin/audit-logs; the CLI takes the same filters as flags.

Read the trail

Filter by action, resource, principal or time:

curl -s http://localhost:8080/api/v1/admin/audit-logs

curl -s "http://localhost:8080/api/v1/admin/audit-logs?action=status_active&resource_type=workflow"

curl -s "http://localhost:8080/api/v1/admin/audit-logs?resource_id=wf-orders&start_time=2026-07-01T00:00:00Z"

The CLI renders a table from the same filters:

orion-cli audit-logs list

orion-cli audit-logs list --action status_active --resource-type workflow

orion-cli audit-logs list --resource-id wf-orders --start-time 2026-07-01T00:00:00Z

Each entry carries the principal, the action, the resource type and id, a details object, and a timestamp. Filters are applied in the database and combine with AND. action, resource_type, resource_id and principal match exactly; start_time (inclusive) and end_time (exclusive) are RFC 3339; offset and limit page.

Note

An unrecognized parameter is rejected with 400. A mistyped filter can never come back as an unfiltered 200 that looks like a clean answer. If you are scripting against this endpoint, that refusal is the feature: a compliance query cannot silently widen.

Because action and resource_type match exactly, a filter is only as good as the vocabulary behind it. This is all of it:

actionresource_typeWritten by
createworkflow, channel, connector, plugin, modelPOST /{kind}
createbackupPOST /backups
create_versionworkflow, channel, plugin, modelPOST /{kind}/{id}/versions
updateworkflow, channel, connector, plugin, modelPUT /{kind}/{id}
deleteworkflow, channel, connector, plugin, modelDELETE /{kind}/{id}
importworkflow, channel, connector, plugin, modelPOST /{kind}/import — one row per entity written, plus a batch summary row
status_active, status_archivedworkflow, channel, plugin, modelPATCH /{kind}/{id}/status, named for the status requested. There is no status_draft: a transition to draft is refused before anything is written
update_rolloutworkflowPATCH /workflows/{id}/rollout
admitmodelPOST /models/{id}/admit — admission runs (or is queued) on the node that answered, and the verdict lands on the row
testworkflow, connectorPOST /workflows/{id}/test, POST /connectors/{id}/test — both reach live backends, so both are recorded
resetcircuit_breakerPOST /connectors/circuit-breakers/{key}
purge, requeuetrace_dlqThe trace DLQ endpoints
package_staged, package_appliedpackagePUT /packages/{name}, named for the receipt state
reloadenginePOST /engine/reload
retrycron_occurrencePOST /cron/occurrences/{id}/retry
triggerchannelPOST /channels/{id}/trigger — a manual run of a cron channel

Reads are not recorded: only mutations, and the two test calls that behave like one.

Group a multi-step operation

A promotion is many API calls. Send the same X-Orion-Change-Context header on each one, and every row it produces carries it under details.change_context:

curl -s -X POST http://localhost:8080/api/v1/admin/workflows \
  -H 'X-Orion-Change-Context: ticket=OPS-4412' \
  -H 'Content-Type: application/json' --data @workflow.json

The CLI sends the same header from --change-context, or from ORION_CHANGE_CONTEXT. Export it once and every command in a deploy script is labelled without touching the script:

export ORION_CHANGE_CONTEXT='ticket=OPS-4412'
orion-cli workflows activate order-processing --defer-reload
orion-cli channels activate orders --defer-reload
orion-cli engine reload

orion-cli audit-logs list --start-time 2026-07-01T00:00:00Z --output json \
  | jq '.data[] | select((.details | fromjson).change_context == "ticket=OPS-4412")'

The value is free-form and truncated at 256 bytes. Use it for a change ticket, a release name, or an operator’s identity, whatever your audit questions are phrased in. There is no server-side filter on it. It is stored inside details, so narrow with the indexed filters first and match the context client-side, as above.

orion-server package apply sets it to package=<name>@<version>, so a promotion’s rows filter back into the promotion that caused them without you doing anything. Imports also write one row per entity written, beside the batch summary row.

Bound retention

Set a retention window; nothing else trims the table:

[audit]
retention_days = 90            # 0 keeps rows forever
cleanup_interval_secs = 3600

The cleanup job runs on its own cadence and deletes rows older than retention_days. In cluster mode it is lease-gated, so one replica performs the delete. retention_days = 0 is a legitimate choice for an estate with a retention obligation; know that nothing else trims the table.

Do not lose events

A change to the active set is audited in the same transaction that makes it. Activating, archiving, deleting and re-splitting a rollout each write their audit row beside the entity row and commit both together. Either the change and its record are there or neither is. There is no window in which a definition is live and unrecorded, and the row is queryable the moment the request returns.

That is also a refusal. If the audit row cannot be written, the mutation is rolled back and the request fails. A change you cannot account for is worse than a change that did not happen.

Everything else, including test, reload, backup, and the per-row events of a bulk import, has no single entity write to commit with. It goes through a bounded queue instead. A full queue drops rows rather than blocking the admin request that produced them:

[audit]
max_pending = 1000           # rows accepted but not yet written
drain_timeout_secs = 5       # how long shutdown waits for the queue

Every drop is counted in orion_audit_events_dropped_total{reason="queue_full"}.

Warning

Alert on that counter existing at all, not on a rate. A dropped audit event is a hole in the trail, and no later query can tell you what was in it. Raise max_pending if a bursty admin plane, such as a large import or a fleet-wide promotion, is enough to fill it. A drop is never a lost record of an activation, archive, delete or rollout, because those do not use the queue.

drain_timeout_secs bounds how long shutdown waits for the queue to empty. It is rejected at startup if set to 0. Unlike the other timeouts, zero here would mean “skip the drain” rather than “wait forever”. Losing the last few rows on every restart is not a default worth offering.

Verify

Make a change with a context, then find it:

orion-cli --change-context "ticket=TEST-1" engine reload
orion-cli audit-logs list --action reload --output json \
  | jq '.data[0].details | fromjson | .change_context'

The newest reload row carries "ticket=TEST-1".

What is not in the audit log

  • Data-plane requests. Calls to /api/v1/data/** are recorded as traces, not audit rows. The audit log is about who changed the service, not who used it.
  • Reads. Listing workflows is not a mutation and does not write a row.
  • The old content of an updated entity. The audit row names what changed; the entity’s own version history holds what it was. Both are needed to reconstruct a change, which is one more reason active versions are immutable.

Next steps

Last verified 14 September 2026