Audit log
Wirebench Server records who did what, on the server and, when a workspace turns it on, in the desktop app. Recording is on for every edition. Reading and exporting the log
in the app or over the API needs the Enterprise edition (see
Editions and licenses). Server admins can read everything; a team
admin can read their own team’s events (see Who can read it, below).
The wirebench-server admin audit export console command, below, works on any edition, for whoever can
run it against the server’s database. Forwarding to a collector, below, is Enterprise.
An event says who did it, what they did, which target it touched, when, and from where. Events are never edited and no route deletes one. The only way an event leaves is retention, below.
What is recorded
Section titled “What is recorded”| Group | What is recorded |
|---|---|
| Sign-ins | Sign-ins, failed sign-ins, sign-outs and password changes |
| Users | Invitations and their revocation, new accounts, disabling and enabling, admin rights, reset links |
| Teams | A team created, renamed or deleted, and members added, removed or given another role |
| Workspaces | A workspace created, renamed or deleted, its default role, role grants, and every push |
| Team secrets | A secret shared or rotated, and a change to who can read it. Never a value |
| Catch URLs | A catch URL created, changed, rotated, deleted or cleared, and its signature set or cleared |
| CI tokens | A token created or revoked |
| License | A license installed or removed |
| Exports | Each export of the audit log itself |
| Verification | Each run of admin audit verify, with its result (see Tamper evidence) |
| Desktop | Requests sent and test-suite runs in a workspace that records them, and a gap in that trail |
A push and a license change are recorded right after they complete. Everything else is recorded in the same transaction as the change, so a change that is not saved leaves no event, and an event that cannot be saved stops the change.
Desktop events arrive from the app, not from the server’s own actions. See Desktop activity, below.
Not recorded: reads, captures arriving at a catch URL, sync fetches and live sockets.
An event carries a few details, such as the previous and new role. It never carries a secret, a token, a
password hash, or the body of a request or response. A change to a workspace’s Record desktop activity
setting is recorded as workspace.desktop_recording_changed, with the new and previous value.
Desktop activity
Section titled “Desktop activity”A team that has to show what was sent where, for example to a production host, can have the desktop app report each request and each test-suite run to the server. It is off for every workspace until a workspace admin turns it on.
What is recorded, only for a workspace that is shared to the team server and has recording on:
desktop.request_sent: one per request sent from the editor, History, the HTTP log or a multi-environment send, whether it worked or failed on the wire. It carries the protocol, the method (the gRPCservice/method, or none), the URL, the status, whether it worked, how long it took, the environment name, the request’s id and name, and the time on the person’s machine.desktop.run_finished: one per test-suite run that ends, including a run the person stopped. It carries the suite’s id and name (sequenceName), the outcome, the counts of passed, failed, errored and skipped steps, the duration, the environment, the hosts the steps reached (up to 64) and the start and send times.desktop.events_dropped: a count of events the app had to discard (see below), so a gap in the trail is itself on the record.
The person who sent the request is the actor. The time of an event is when the server received it; the
time on the person’s machine stays in the details as sentAt.
Not recorded: headers, bodies, responses, and anything else a request carries besides its URL. Steps of a test-suite run are not listed one by one, the run’s event covers them. Sends that belong to no project in the workspace, local workspaces, folder or Git shares, and requests made by the command line or the MCP server are not recorded. Wirebench has no load runs.
The URL is masked before it leaves the app, whatever the show-secrets setting says. A password in
the URL and the values of secret-like query parameters become <redacted>. Any secret value the app
knows from the session is masked too, wherever it appears in the URL. A URL longer than 2048 characters
is cut.
Turning it on. A workspace admin opens the Workspaces tab in Account: Manage teams and switches on Record desktop activity. Everyone else sees the line Desktop activity is recorded. Both show only on a server that supports desktop activity. Each desktop learns of a change on its next sync fetch. Turning it off stops it on the server at once. Recording is on for every edition, like the rest of the log.
The notice. While the open workspace records, the status bar shows Recorded. Its tooltip says that requests and test runs in this workspace are recorded in the team server’s audit log, so nobody is recorded without knowing.
Offline and signed out. The app writes each event to a queue in the workspace’s folder first, then
sends the queue in batches of at most 100. If the server cannot be reached the app tries again later,
starting at 5 seconds and doubling up to 5 minutes, and keeps the queue. A server that asks it to wait
(429) is obeyed when its Retry-After is longer, up to the same 5 minutes. Signed out, it keeps the queue
until the next sign-in. The queue holds 5000 events; beyond that the oldest are discarded and their
count is sent with the next batch as desktop.events_dropped, at most 1,000,000 to a batch and the rest
in the next. A batch the server can never accept
(a 400 answer) is discarded the same way and counted. If the server answers that the workspace no
longer records (409), or that the workspace is closed to the person (403 or 404), the app empties
its queue and stops recording: after a 409, until the workspace is fetched and records again; after a
403 or 404, until the person’s role in the workspace changes, the workspace is reopened, or the desktop restarts. Delivery is at least once, so if the server’s reply to a batch is
lost, the app sends that batch again and its events can appear twice. Events queued while one person
was signed in are sent only as that person: if someone else signs in to the same server, they are
discarded and counted instead.
Who can read it
Section titled “Who can read it”- Server admins read every event, with any filter, or none.
- Team admins read the events of the teams they administer, one team at a time, and nothing else. They choose the team in Account: Manage teams; the Audit tab shows that team’s events and exports only those.
- A plain team member, a CI token and anyone not signed in cannot read the log.
An event about a workspace (a push, a team-secret change, a catch URL, a CI token) belongs to the workspace’s team. Events that belong to no team stay with server admins: sign-ins, password changes, user accounts, the server itself and the license are never shown to a team admin.
Over the API, GET /audit and GET /audit/export take a teamId. A server admin may leave it out. Anyone
else must name a team they administer, and the read is pinned to that team, whatever other filters they add:
- no
teamId:403 audit-team-required; - a team that does not exist, or that they are not on:
404 teams-team-not-found; - a team they are on but do not administer:
403 teams-forbidden.
A team admin’s export is recorded as audit.exported on their own team, so it shows in that team’s log.
Reading the log
Section titled “Reading the log”A server admin on an Enterprise server opens Account: Manage teams and chooses the Audit tab. It works with no team selected. A team admin sees the Audit tab once they select a team they administer, and it lists that team’s events only.
- Range picks the last 24 hours, 7 days, 30 days, or all time.
- Kind picks one group from the table above,
desktopincluded. - Workspace appears when a team is selected and narrows the list to one of its workspaces.
Events are listed newest first. Click one to see its detail: when, who, what, the target, workspace and team, the address and client it came from, its event id and its details. Load more fetches the next page.
On a server without the Enterprise edition the audit routes refuse with a message that names the feature.
Exporting
Section titled “Exporting”Click Export… in the Audit tab. Wirebench asks where to save the file and writes the events that match the current filters as newline-delimited JSON, one event per line, oldest first.
On the machine that runs the server, the same export is a command:
wirebench-server admin audit export [--from <iso>] [--to <iso>] [--action <name or group.>] [--workspace <id>]It writes to standard output. --from is inclusive and --to exclusive. --action is one action name,
such as team.deleted, or a group followed by a dot, such as secret.. A nightly copy for an archive can
be one cron line, which takes exactly the previous day, so two runs never overlap:
15 2 * * * docker compose -f /srv/wirebench/compose.yaml exec -T server node /app/dist/bin.js admin audit export --from "$(date -u -d yesterday +\%Y-\%m-\%dT00:00:00Z)" --to "$(date -u +\%Y-\%m-\%dT00:00:00Z)" >> /var/archive/wirebench-audit.ndjsonAn export is itself recorded as audit.exported, once the whole export has been written. An export that
the reader abandons halfway leaves no event.
Tamper evidence
Section titled “Tamper evidence”An audit log is only worth showing an auditor if edits to it show. Wirebench Server can link its events into a keyed hash chain, so that a changed, removed or reordered event is found by a command. It is on for every edition, needs no license, and is off until you set a key.
Turning it on. Set WIREBENCH_SERVER_AUDIT_CHAIN_KEY to a secret of at least 32 bytes (counted in
UTF-8), kept outside the database, for example in the same secret store as the other server secrets. Keep
it: rotation is not supported, a changed key is refused, and without the key old events can no longer be
verified. Every server instance on one database must have the same key: an instance without it keeps the
sealed events instead of retiring them (see Retention with the chain below).
Sealing. A background sealer links each committed event to the one before it, with an HMAC-SHA256 that uses the key, up to 500 events a pass, oldest first. After a pass that sealed a full 500 it passes again at once, so a backlog drains as fast as the database allows; after a smaller pass it waits 2 seconds, and 5 seconds when there was nothing to seal. Events recorded before the key was set are sealed the same way on the first passes. An event is unprotected from the moment it commits until the pass that seals it: normally under 5 seconds. While a backlog lasts (the first passes over an existing table, or after the sealer could not run), new events wait behind it and stay unsealed longer. After each pass that sealed something the server logs the new head:
audit chain sealed to 18342:9f1c0b7e…That line is the copy of the chain’s end that does not live in the database. Keep the server’s logs somewhere the database’s admins cannot edit.
Verify. On the machine that runs the server, with the key in the environment:
wirebench-server admin audit verify [--head <seq>:<hex>] [--json]checked 18342 sealed rows, seq 1 to 18342; 3 unsealedhead 18342 matchesintact--headtakes a head copied from the log line,18342:9f1c0b7e…. The chain must hold that sequence number with exactly that hash.--jsonprints the summary as one JSON object:checked,firstSeq,lastSeq,unsealedandresult(intactorbroken). When broken, it addsbrokenSeqand abrokenobject:seq,id(the event at fault, when there is one),reasonandmessage, the line the plain output prints afterbroken:. With--headon an intact chain, it adds aheadobject:seqandolderThanKeptChain, true when retention has already moved past that head.- Exit codes:
0intact,1broken,2no key set or the wrong one.
Verify stops at the first broken link and says why:
| Reason | Meaning |
|---|---|
edited |
An event no longer matches its hash |
missing |
A sequence number is skipped, or the chain ends below the highest one ever sealed |
out of order |
A sequence number repeats or goes backwards |
anchor edited |
The chain’s starting point fails its check |
head <seq> not found |
--head names a sequence number the chain no longer has: newer events were removed |
head <seq> does not match |
--head names a sequence number that has a different hash |
Unsealed events are counted and never treated as broken. A run is recorded as audit.verified, with the
counts and the result.
The exit code 1 means a broken chain, but an unexpected error in the command can also end with 1.
A script should read result in the --json output and not rely on the exit code alone.
What it detects. With the database but not the key, someone cannot get past verify by:
- editing, deleting or reordering sealed events;
- deleting the oldest events or moving the chain’s starting point (the anchor): the anchor is keyed, so a moved one fails its check;
- deleting the anchor while events stay sealed: the sealer never starts a new chain over sealed events,
and verify reports the first one as
edited; - backdating events so that retention deletes them: retention checks each event’s link before deleting it, so it stops at an edited event and the edit stays for verify to find;
- deleting the newest events: the anchor remembers the highest sequence number ever sealed, and
--headchecks a head taken from the log.
What it does not detect.
- Someone who has both the database and the key. They can rewrite the whole chain.
- Events inserted or edited before they are sealed. The chain proves that nothing changed after sealing, not who wrote an event: a row inserted straight into the table is sealed like any other.
- Rolling the anchor and the events back to an earlier genuine state, when retention has not run since.
Only a
--headtaken from the log afterwards catches this. A sequence number that appears twice in the head log lines is a sign of it. - Clearing the seals of every event, editing events and deleting the anchor. The sealer then starts a new
chain and seals every event afresh, so verify reports it intact. Only a
--headtaken from the log catches it, and the head log then shows sequence numbers sealed twice. - Deleting every event and the anchor together. Verify then reports an empty chain; only a
--headtaken from the log catches it. - An edited key id in the anchor. It looks exactly like a wrong key. If you are sure the key is right,
treat
wrong keyas tampering.
Retention with the chain. Retention deletes only sealed events, oldest first, and moves the anchor to the last one it deleted, so the kept chain stays verifiable. It checks each event’s link before deleting it and never trusts an event’s time alone. At an edited event or a missing sequence number it stops: nothing from there on is deleted, and the server logs one error per sequence number:
audit chain retention stopped at seq 18342: run wirebench-server admin audit verifyRun verify when you see it. While the break is there, the events from that point on are kept past the age limit. An unsealed event past the age limit waits until it is sealed. A retention batch is skipped while a sealing pass runs, and tried again at the next sweep. It also stops just before an event the forwarder is sending at that moment, and takes the rest at the next sweep.
With the key unset on a database that already holds a chain, retention deletes only unsealed events past the age limit and keeps every sealed one, so the chain never gets a gap. The table then grows until the key is set again. On a database that never had a chain, retention without the key deletes by age as before.
Forwarding
Section titled “Forwarding”An Enterprise server can push every audit event to one collector as it is recorded, so a SIEM does not have to poll the export. Three variables set it up; with WIREBENCH_SERVER_AUDIT_FORWARD_URL unset, nothing is forwarded.
| Variable | What it does |
|---|---|
WIREBENCH_SERVER_AUDIT_FORWARD_URL |
syslog+tcp://host:port, syslog+tls://host:port or https://…. A syslog URL must name a port, and no URL may carry credentials. http:// is accepted only for localhost, 127.0.0.0/8 or ::1. |
WIREBENCH_SERVER_AUDIT_FORWARD_TOKEN |
Sent as Authorization: Bearer … with each HTTPS batch. Needs an https:// or loopback http:// URL; refused with a syslog URL. Never logged. |
WIREBENCH_SERVER_AUDIT_FORWARD_CA_FILE |
A PEM bundle added to the system roots for syslog+tls and https. Certificates are always verified. Refused with an http:// URL. |
A token or a CA file without the URL, or a combination listed above, is a configuration error at start-up. So is a CA file that cannot be read or holds no certificate: the server does not start.
Events are queued, in the same transaction that records them, only while WIREBENCH_SERVER_AUDIT_FORWARD_URL is set in the process that records them, so events from before forwarding was turned on are never sent. Rows queued earlier stay after the URL is unset until retention removes them, and go out (to whatever destination is then set) if it is set again. wirebench-server admin … commands record audit events too, so they need the same forward variables in their environment. The forwarder sends the oldest 100 queued events at a time, and removes them from the queue only after the collector accepts them.
Syslog (RFC 5424 over TCP or TLS, RFC 6587 octet-counting framing, one connection reused between batches) carries one message per event:
<110>1 2026-10-03T09:12:44.318Z wb-server-1 wirebench-server - team.deleted - {"id":"evt_8f2c…","at":"2026-10-03T09:12:44.318Z","actor":{"kind":"user","userId":"usr_41ab…","email":"ada@example.com"},"action":"team.deleted","target":{"kind":"team","id":"team_77d0…"},"workspaceId":null,"teamId":"team_77d0…","ip":"203.0.113.9","userAgent":"Wirebench/1.4","details":{}}The priority is facility 13 (log audit) and severity 6 (informational), the application name is wirebench-server, and there is no process id or structured data. HOSTNAME is the operating system’s host name, kept to printable ASCII and at most 255 characters, or -. MSGID is the action cut to 32 characters, the RFC 5424 limit; the JSON always carries the full action. The message is plain UTF-8 JSON with no byte-order mark.
HTTPS sends one POST per batch with Content-Type: application/json and this body, each event in the shape of an exported line:
{ "events": [ { "id": "evt_8f2c…", "at": "2026-10-03T09:12:44.318Z", "action": "team.deleted", "…": "…" } ] }Any 2xx answer accepts the batch. Any other status does not, and redirects are never followed, so a token never travels to another origin.
When a batch counts as sent. Over HTTPS, on a 2xx. Over syslog, once its bytes are in the operating system’s socket buffer: TCP has no application acknowledgement, so a collector that fails after that point can lose them. One 10 second deadline covers a whole send: the connect and the writes, or the whole HTTPS request. A reused HTTPS keep-alive connection that the collector has reset is retried once, inside the same deadline.
At least once. A batch that fails, or that was sent just before a crash, goes out again, so an event can arrive more than once. Receivers should de-duplicate on the event’s id.
Failures. A failed batch stays queued and is tried again after 5 seconds, doubling up to 300 seconds. The server logs one warn line, audit forwarding failing, when forwarding starts failing, and one info line when it recovers. It never logs one line per attempt, and never the token or an event.
Without the Enterprise audit-log feature, forwarding pauses (one info line) and the queue keeps filling. Retention still deletes old events and their queue rows. When a license returns, the backlog goes out.
To see how far behind the collector is, count the queue:
select count(*) from audit_forward_queue;Retention
Section titled “Retention”Events older than a set age are deleted. The age is WIREBENCH_SERVER_AUDIT_MAX_AGE_DAYS: a whole number
of days from 30 to 3650, and 365 when unset. The server sweeps every ten minutes.
If a regime asks you to keep events longer, export them before they age out. An event that ages out before it was forwarded is never sent (see Forwarding, above).
Personal data
Section titled “Personal data”The log holds personal data, under the same retention as everything else.
actor.emailis the email address the person had when the event happened. It stays in the event after the account is renamed or deleted.- The address an event came from is an IP address.
- A failed sign-in keeps the email that was typed, lower-cased, so that repeated attempts are readable.
To answer a request to erase one person, export the log first, then purge that person’s events. A person
appears in an event as its actor, as its target (a user target holds the user id), or by email in the
details of an invitation, an invitation revoked, a new account or a failed sign-in (emailLower). The
operator runs this in the database, with the person’s user id as $1 and their email, lower-cased, as $2:
delete from audit_events where actor_user_id = $1 or (target_kind = 'user' and target_id = $1) or details->>'emailLower' = $2;Nothing in the server removes events one person at a time, so this is a deliberate step by the operator.
Related
Section titled “Related”- Wirebench Server — running the server, accounts and sign-in.
- Editions and licenses — seats, editions and installing a license.