Outcome
By the end of this lesson, you will be able to identify what the audit log captures and what it deliberately excludes, explain the mutations-only design choice, and describe the per-event payload that lands in storage.
| Prerequisites | M4.1: RBAC fundamentals |
| Time | 9 minutes |
1. Concept
Everything that changes something is recorded, except personal preference writes (pins and settings under /my/preferences), whether it was done in the product or through the API. The product calls the API anyway; the only difference in the row is its source tag.
What gets stored is the request and the response, up to a size cap, except in the areas that carry credentials.
Reading is not recorded. That is a decision rather than an oversight, and the reason behind it changes what the audit log can and cannot be used for.
ACTION LOGGED? WHY──────────────────────────────────────────────────────────────────Create / update / delete Yes State change schedules, recs, budgets, = audit-worthy resource-groups, etc.
Apply remediation Yes High-impact mutationSet / cancel override Yes Affects spendConnect cloud account Yes Provenance criticalRotate / revoke credentials Yes Security eventModify role / user / team Yes Authorization change
Read schedule / recommendation No Read access alreadyView dashboard / cost report No gated by RBAC; volumeBrowse resources No too high to log usefullyWhy mutations only
Three reasons converge on the same answer. First, volume: reads far outnumber mutations in any active org, so logging them at the same fidelity would multiply audit storage with little forensic value. Second, redundancy: RBAC already controls read access. If a user does not have resource:view, they cannot read the resource; recording every successful resource:view does not tell you anything policy-table + role assignment does not already say. Third, focus: in an incident or audit, the relevant question is almost always “what changed” not “who looked.” Mutations-only keeps the log readable.
There is one exception and two gaps to know about:
EXCEPTION MCP tool calls are logged, reads included. An assistant calling a read tool produces a row (stored as method GET, with the request but not the response).
GAPS A request refused by RBAC (403) is not logged: the refusal happens before the audit step. Sign-in attempts are not logged: sign-in runs in ZopNight's external auth service, outside your org.Per-org Redis Streams
The audit log uses Redis Streams as the transport layer, scoped per organization.
PATH: api request → gateway (audit step) → backend service ↓ (async) org-scoped Redis Stream ↓ (every minute) MySQL audit table ↓ Audit Logs page + APIPer-org streams give two properties: org-scoped isolation (one customer’s audit volume cannot affect another’s) and a durable buffer between the stream write and the MySQL sync (so a database hiccup does not lose events). Nothing reads the stream directly, so an entry shows up on the page after the next sync, usually within about a minute.
MySQL sync
Every minute, accumulated stream entries are copied into the MySQL audit table. After that, MySQL is the canonical store: the Audit Logs page and the API both read it.
TIME WHERE EVENTS LIVE STATUS──────────────────────────────────────────────────────────T+0 Redis Stream (org-scoped) Buffered, not yet visibleT+1 min MySQL audit table Canonical, visibleLater Still in MySQL Kept indefinitelyRows are kept indefinitely. There is no retention setting and no automatic purge. If you need your own long-term copy, export it (L3).
What gets stored per event
EVERY ROW timestamp UTC, to the second user who made the request (email) method POST / PUT / PATCH / DELETE (GET for MCP reads) path the resource path, e.g. /schedules status_code response status source ui (browser session), api (personal access token) or mcp error_message if the request failed
BODIES body the request body response_body the response body (each up to the size cap, L2)
MCP ROWS ALSO CARRY tool, session_id, resource_uidThere is no latency, trace ID, IP address, user agent, query string or header in the row.
Credentials are protected by dropping, not masking. For the areas that carry secrets (cloud accounts, connect flows, integrations, AI, app secrets and basic auth), the request and response bodies are not stored at all. For MCP calls, the specific arguments a tool declares as secret are replaced with [REDACTED]. L2 covers both.
Body capture
The request body and response body are stored for every logged mutation outside those credential areas. The audit log answers not just “what endpoint was hit” but “with what data, returning what result.” A year later when an auditor asks “what did the May 20 schedule update actually do?” you can show them the body.
There is no per-org switch to turn body capture off. The only setting is the size cap, which ZopNight sets for the whole deployment (L2).
Reasoning about what is NOT logged
Several categories of activity are absent from the audit log:
NOT LOGGED WHY──────────────────────────────────────────────────────────────────Successful reads through the app or API Volume + redundancyRequests refused by RBAC Refused before the audit stepSign-in attempts Handled by the auth serviceInternal service-to-service calls Not user-initiatedUser browser activity (page loads, clicks) Frontend telemetry separateLLM conversations (with MCP) Tool calls logged; chat is notMCP tool calls are in the same audit log as everything else, tagged source = mcp, so you can filter to them. On the Audit Logs page the Source filter offers All sources, MCP and Platform (everything except MCP); the API’s source parameter takes the raw values ui, api and mcp (internal and unknown are reserved and not produced today). The audit log is intentionally about the user-facing API surface.
How ZopNight uses it
The audit log feeds two surfaces: the Audit Logs page (Activity → Audit Logs), where admins browse and filter events, and the API at GET /orgs/{orgID}/audit-logs, which returns the same rows, 20 per page by default and up to 100. The filters are method, resource path, status (success or failed), user, a text search, source and a date range. Export is a CSV file, emailed to you (L3).
There are no pre-packaged compliance reports. For an audit you run the filter yourself (for example, every mutation on /roles or /assignments in the audit window) and export the result.
2. Demo
A typical audit log entry for a schedule creation:
AUDIT LOG ENTRY─────────────────────────────────────────────────────────────timestamp: 2026-05-20T16:42:11Zuser: jane@example.commethod: POSTpath: /schedulesstatus_code: 201source: ui
REQUEST BODY: { "name": "business-hours-eu", "timezone": "Europe/London", "crons": [ {"cronExpression": "0 8 * * 1-5", "action": "start"}, {"cronExpression": "0 20 * * 1-5", "action": "stop"} ] }
RESPONSE BODY: { "id": "sch_xyz789", "name": "business-hours-eu", "created_at": "2026-05-20T16:42:11Z", "resource_count": 14 }A year later, when finance asks “what was the EU dev schedule when we started saving $X/month?” the audit log has the canonical answer, including the cron expressions and timezone. Attaching the resource group is a separate request, so it is a separate entry.
3. Hands-on (5 min)
Open Activity → Audit Logs. Set the date range to the last 7 days and filter method = POST:
EVENTS COUNTED (last 7 days): POST events: _____ PUT events: _____ PATCH events: _____ DELETE events: _____
TOP ACTIONS (count by path): __________________________ : _____ events __________________________ : _____ events __________________________ : _____ events
PICK ONE EVENT. Read its full payload: Endpoint: __________ User: __________ What changed: __________
If your team's last 7 days has <5 mutations, RBAC is probably veryrestrictive (or the team is on vacation). If >500, that's an active org.4. Knowledge check
Q1
GET requests are:
A. Logged with full body and headers
B. Logged but summarized
C. Logged for admins only
D. Not logged
Show answer
Correct: D. Reads far outnumber mutations, and RBAC already controls who can read, so recording them adds storage cost without forensic value. Mutations-only is the deliberate design. The one kind of read that is logged is an MCP read-tool call; REST reads, including refused ones, are not.
Q2
The audit log captures:
A. The request body only, on the grounds that the response is far too sensitive to retain anywhere at all in the log
B. Headers only, with both of the bodies discarded at ingest time to keep the stream small enough to retain for the full retention window
C. Both request and response body up to a size cap, with the bodies dropped entirely for the areas that carry credentials
D. The response body only, on the grounds that it reflects what actually happened rather than what was asked for by the caller
Show answer
Correct: C. Request and response bodies are stored for every logged mutation, up to the size cap, except in credential areas such as cloud accounts, where both bodies are dropped. There is no per-org switch to turn capture off. “What was the schedule’s exact config?” is the kind of question this answers.
Q3
Redis Streams is used for:
A. Long-term durable storage of the entire audit log history itself
B. Org-scoped transport and a durable buffer until the per-minute MySQL sync
C. A backup copy of the MySQL audit tables, kept continuously in sync
D. Fan-out to the customer’s own SIEM, bypassing the MySQL write path
Show answer
Correct: B. Each org has its own stream, isolating audit volume between tenants. Nothing reads the stream directly: the page and API read MySQL, so entries appear after the next per-minute sync.
5. Apply
Browse the audit log at Activity → Audit Logs. The page filters on method, resource path, status, user, text search, source and date range. Export a CSV (it arrives by email) or call the Audit API (GET /orgs/{orgID}/audit-logs) if you want to copy entries into your own systems.
A useful habit: review audit-log volume weekly. Sudden spikes often indicate a runaway script; sudden drops often indicate a logging-pipeline issue.
Related lessons
- L2: Body capture trade-offs (next)
- L3: Filtering and export
- L4: Compliance posture
- T4.M4.1.L1: The policy table