Skip to content

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.

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.

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 gRPC service/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.

  • 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.

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, desktop included.
  • 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.

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:

Terminal window
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:

Terminal window
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.ndjson

An export is itself recorded as audit.exported, once the whole export has been written. An export that the reader abandons halfway leaves no event.

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:

Terminal window
wirebench-server admin audit verify [--head <seq>:<hex>] [--json]
checked 18342 sealed rows, seq 1 to 18342; 3 unsealed
head 18342 matches
intact
  • --head takes a head copied from the log line, 18342:9f1c0b7e…. The chain must hold that sequence number with exactly that hash.
  • --json prints the summary as one JSON object: checked, firstSeq, lastSeq, unsealed and result (intact or broken). When broken, it adds brokenSeq and a broken object: seq, id (the event at fault, when there is one), reason and message, the line the plain output prints after broken:. With --head on an intact chain, it adds a head object: seq and olderThanKeptChain, true when retention has already moved past that head.
  • Exit codes: 0 intact, 1 broken, 2 no 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 --head checks 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 --head taken 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 --head taken 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 --head taken 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 key as 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 verify

Run 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.

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;

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).

The log holds personal data, under the same retention as everything else.

  • actor.email is 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.