Audited views
Record who read a view, when, and with which parameters.
An audited view records every read of it in the sys.view_audit table: the
principal that ran the query, when the read finished, how long it took, and the
values its parameters resolved to. Use it where you have to answer who looked
at which data, for example a view that exposes trades or positions for a symbol
and time range that the caller chooses.
A view created WITH AUDIT records one row per read. Each of its
DECLARE variables marked AUDITED adds its
resolved value to the row's params column, as JSON. A caller's override, a
bind variable, or a default built on now() is recorded as the value that read
actually ran with, so the trail describes the data each read covered.
Quick start
CREATE VIEW trades_by_symbol AS (
DECLARE
OVERRIDABLE AUDITED @symbols := ('BTC-USDT', 'ETH-USDT'),
AUDITED @since := dateadd('h', -1, now())
SELECT timestamp, symbol, side, price, amount
FROM trades
WHERE symbol IN @symbols AND timestamp >= @since
) WITH AUDIT;
DECLARE @symbols := ('SOL-USDT',)
SELECT * FROM trades_by_symbol;
SELECT ts, principal, view_name, params, status
FROM 'sys.view_audit'
WHERE view_name = 'trades_by_symbol'
ORDER BY ts DESC
LIMIT 10;
| ts | principal | view_name | params | status |
|---|---|---|---|---|
| 2026-09-19T10:15:02.418331Z | analyst | trades_by_symbol | {"since":"2026-09-19T09:15:02.417950Z","symbols":["SOL-USDT"]} | ok |
Rows are written in the background and appear in the table shortly after the read.
Syntax
CREATE VIEW [ IF NOT EXISTS ] viewName AS ( query )
WITH AUDIT [ OWNED BY ownerName ]
DECLARE [ OVERRIDABLE ] [ AUDITED ] @variable := expression
[, [ OVERRIDABLE ] [ AUDITED ] @variable := expression ...]
WITH AUDITandOWNED BYmay appear in either order.WITH AUDITrequires theAUDIT VIEWpermission.AUDITEDandOVERRIDABLEare independent and may appear in either order.AUDITEDtakes effect only in the body of a view createdWITH AUDIT. Elsewhere it is accepted and has no effect.- A view created
WITH AUDITwith noAUDITEDvariables still records every read, with{}inparams.
Choose what a read records
Mark a variable AUDITED when its value describes which data a read covered:
OVERRIDABLE AUDITED: a parameter the caller can set. The row records the caller's value, or the view's default when the caller sets nothing.AUDITEDonly: a parameter the caller cannot change. Worth recording when its value changes between reads, such as a window built onnow(), because the trail is otherwise the only place that value is kept.
Only the view's own declarations count. A caller cannot add a parameter to the
row, or remove one, by declaring variables of their own, AUDITED or not.
Values are evaluated for each execution. A prepared statement that binds a view parameter to a bind variable records the values bound on each execution:
DECLARE @symbols := ($1, $2)
SELECT * FROM trades_by_symbol;
Record resolved values rather than text that resolves later. A
TICK string such as '$now-1h..$now' is recorded
as that text, not as the time range it resolved to. To keep the range in the
trail, declare the bounds as timestamps, as @since does above.
The audit table
The server creates sys.view_audit at startup. The sys. prefix follows
cairo.system.table.prefix.
It is a WAL table partitioned by day, with this schema:
| Column | Type | Description |
|---|---|---|
ts | TIMESTAMP | When the read finished and the row was recorded. The designated timestamp. |
principal | SYMBOL | The user or service account that ran the query. |
view_name | SYMBOL | The name of the audited view. |
params | VARCHAR | The resolved values of the view's AUDITED variables, as a JSON object. |
latency_micros | LONG | How long the read took, from opening it to closing it, in microseconds. |
status | SYMBOL | ok, or error when the read failed or was cancelled. A failed read is recorded because it was attempted. |
view_id | INT | The view's internal id. A view that is dropped and created again under the same name gets a new id. |
Reads that stream page frames, such as Parquet export, record the row when the
read starts, so their latency_micros covers only opening the read.
The params column
params is canonical JSON, so two reads with the same values produce the same
text and a report can group on the column directly:
- Keys are the variable names without the
@, sorted by name. - A declared list renders as a JSON array, in the order it was written. Declare
a one-member list with a trailing comma,
('SOL-USDT',), so that the value stays an array. Without the comma it is a scalar. - A
NULLvalue renders as JSONnull.
| SQL type | JSON |
|---|---|
BOOLEAN | true or false |
BYTE, SHORT, INT, LONG, FLOAT, DOUBLE | Number |
CHAR, STRING, SYMBOL, VARCHAR | String |
TIMESTAMP | ISO 8601 string, in the timestamp's own precision (micro or nanosecond) |
DATE | ISO 8601 string, with microseconds |
UUID, IPv4 | String |
A read whose AUDITED variable resolves to any other type, such as an array,
fails with audited view parameter has a type that cannot be audited, rather
than record a row with a value missing.
Query the trail
SELECT principal, view_name, params, count() AS reads
FROM 'sys.view_audit'
WHERE ts IN '$today'
GROUP BY principal, view_name, params
ORDER BY reads DESC;
SELECT ts, principal, json_extract(params, '$.since')::timestamp AS since
FROM 'sys.view_audit'
WHERE view_name = 'trades_by_symbol' AND ts IN '$today';
See json_extract() for the path
syntax.
Retention
The table is created with the storage policy that
view.audit.storage.policy
sets, TO PARQUET 1d by default, so older partitions of the trail move to
Parquet. The setting applies only when the server creates the table. After
that, change the policy with
ALTER TABLE SET STORAGE POLICY.
The table cannot be dropped, so the trail cannot be erased by whoever holds
DROP TABLE. It can be truncated with
TRUNCATE TABLE, which keeps retention the
operator's to manage.
The server writes to the table by column name, so you can add columns of your own. The seven columns above must keep their names and types. If one is missing or has another type, the server logs an error and discards audit rows until the table is repaired and the server restarted.
What counts as a read
A read is one execution of a statement that reads rows through an audited view:
SELECT, INSERT INTO ... SELECT, CREATE TABLE AS SELECT, or an UPDATE of a
non-WAL table that reads the view in its FROM clause or in a sub-query. Each
execution records its own rows, including every execution of a cached or
prepared statement.
When one statement mentions the same view more than once, in a join, a union,
or a sub-query, the view records one row for each distinct set of parameter
values. References that resolve to the same values are one read and share a
row. Values are compared as rendered, so 1 and 1.0 count as different
values.
These record nothing:
CREATE VIEW,CREATE MATERIALIZED VIEW,ALTER VIEWandCREATE OR REPLACE VIEWwhose query reads an audited view. Each opens its query only to check it, and hands no rows to anyone.- Reads the database runs on its own behalf: materialized view refreshes and WAL apply. No principal is reading data there.
- An
UPDATEof a WAL table. See Limitations.
Audited views that read other audited views
An audited view read inside the body of another audited view records no row of
its own when the outer view's row covers it. The outer view covers the inner
one when every variable the inner view declares OVERRIDABLE AUDITED is also
declared AUDITED in the outer view, by name, overridable or not. Those are the
only values a caller can change through the outer view, so the outer row then
shows everything the caller chose.
An inner view with no OVERRIDABLE AUDITED variables is always covered. So an
audited view that unions several audited views with fixed or no audited
parameters records one row, for itself.
An inner view that is not covered keeps its row, because a caller's value can reach it through the outer view without appearing on the outer row:
CREATE VIEW symbol_trades AS (
DECLARE OVERRIDABLE AUDITED @sym := 'BTC-USDT'
SELECT timestamp, symbol, side, price, amount
FROM trades
WHERE symbol = @sym
) WITH AUDIT;
CREATE VIEW buy_trades AS (
SELECT * FROM symbol_trades WHERE side = 'buy'
) WITH AUDIT;
DECLARE @sym := 'ETH-USDT' SELECT * FROM buy_trades;
| view_name | params |
|---|---|
buy_trades | {} |
symbol_trades | {"sym":"ETH-USDT"} |
To record one row, re-declare the parameter in the outer view. The view stays
audited through ALTER VIEW:
ALTER VIEW buy_trades AS (
DECLARE OVERRIDABLE AUDITED @sym := 'BTC-USDT'
SELECT * FROM symbol_trades WHERE side = 'buy'
);
The same read now records one row, buy_trades with {"sym":"ETH-USDT"}.
Declare it AUDITED without OVERRIDABLE instead to fix the value for every
caller.
The rule in full:
- Coverage is checked against the outermost audited view around the read, through any views between them, audited or not.
- A view that is not audited never covers another. An audited view read through a plain view always records its row.
- A reference to the inner view outside the outer view, in the same statement, records as usual. If it resolves the same values as an inner read that is not covered, the two share one row.
- When an inner view is covered, its
AUDITEDvariables that are notOVERRIDABLEare not recorded. Read directly, the view records them. - Coverage depends only on the view definitions, so a given statement always records the same set of views.
Delivery
Auditing is lossy by design, and there is no lossless mode. Recording a read never makes the read wait, and a read is never refused because its row cannot be recorded, except on a read-only instance. Treat the trail as a best-effort record: under overload, or when a write fails, a read can go unrecorded.
A read puts its row on a bounded in-memory queue, and a background job writes
the queue to the table. The queue holds
view.audit.queue.capacity
rows, 4096 by default.
A read goes unrecorded when:
- The queue is full when the read finishes, because audited reads outpace
the job. The server logs
view audit queue is full, dropping rowswith a running total, on the first drop and every 1024th after that. Raise the capacity if this appears during bursts of audited reads. - The job fails to write a batch. The batch, up to 1024 rows, is lost, and
the server logs
could not write view audit rows. - The audit table is missing a column, or a column has the wrong type. The server discards audit rows until the table is repaired and the server restarted. See Retention.
- The server stops or crashes with rows still in the queue. The queue is held in memory only.
Lost rows show in the server log only. No metric reports them.
Permissions
| Action | Permissions |
|---|---|
Create a view WITH AUDIT | CREATE VIEW and AUDIT VIEW |
Drop an audited view, with DROP VIEW or DROP ALL TABLES | DROP VIEW on the view and AUDIT VIEW |
Change an audited view with ALTER VIEW or CREATE OR REPLACE VIEW | ALTER VIEW on the view. The view stays audited |
| Read an audited view | SELECT on the view, as for any view |
| Read or truncate the trail | SELECT or TRUNCATE TABLE on sys.view_audit |
AUDIT VIEW is a database-level permission, included in ALL and
DATABASE ADMIN. It guards the two statements that bind a view to the trail or
release it, so that a principal who can drop and recreate a view cannot shed
its auditing unremarked.
A view's auditing is set when it is created. ALTER VIEW and
CREATE OR REPLACE VIEW over an existing view keep it, and do not accept
WITH AUDIT. To audit an existing view, or to stop auditing one, drop it and
create it again.
sys.view_audit takes ordinary table permissions. Grant SELECT on it to the
people who review the trail, and keep write permissions such as INSERT,
UPDATE and TRUNCATE TABLE to the operators who manage it.
Replication and read-only instances
Every node records the reads it serves, replicas included, into its own
sys.view_audit. The rows are not replicated between nodes, so the complete
trail is the union of the tables on all nodes.
An instance started with readonly=true cannot write the trail, so it refuses
reads of audited views with
cannot read an audited view on a read-only instance. Views that are not
audited are unaffected.
Limitations
- A materialized view over an audited view is not audited. Its refreshes
record nothing, reads of the materialized view are not audited, and
CREATE MATERIALIZED VIEWrecords nothing either. A principal who can read an audited view and create materialized views can therefore make its data readable with no audit row. GrantCREATE MATERIALIZED VIEWwith that in mind. A live view cannot be defined over a view, so it cannot be used this way. - An
UPDATEof a WAL table records nothing when it reads an audited view. Its read happens during WAL apply, on every node, rather than in the session that submitted it. A WAL table'sUPDATEcan read no other table and use no join, so the only such statement that compiles reads, in a sub-query, an audited view whose query reads only the updated table. The same statement on a non-WAL table is recorded. - Copies are recorded once.
INSERT INTO ... SELECTandCREATE TABLE AS SELECTrecord the read that made the copy. Reads of the copy are not audited. - The trail is best-effort. Auditing is lossy, and there is no lossless mode. A read goes unrecorded when the queue is full, when a write fails, or when the server stops with rows still queued. See Delivery.