Audit Log
The structured JSONL log faz writes for every request, the schema of each entry, and how to query it.
faz writes one JSONL entry per request to .faz/audit.jsonl. Each entry captures who asked (transport), what they asked for (RBAC targets), every safety stage's verdict, what executed, and timing. Allowed and blocked requests both write entries. This page is the reference for the schema and the patterns for working with it.
Where the log lives
By default, faz init creates a .faz/ directory and the AuditLogger writes to .faz/audit.jsonl whenever that directory exists in the working directory.
To write somewhere else, set the FAZ_AUDIT_LOG environment variable:
FAZ_AUDIT_LOG=/var/log/faz/audit.jsonl faz serveFAZ_AUDIT_LOG=/var/log/faz/audit.jsonl uv run faz serveFAZ_AUDIT_LOG=/var/log/faz/audit.jsonl python -m faz serveTo opt out of file logging entirely (events still go to Python logging):
FAZ_AUDIT_LOG="" faz serveFAZ_AUDIT_LOG="" uv run faz serveFAZ_AUDIT_LOG="" python -m faz serveThe file is append-only. Rotate it with logrotate or your platform's equivalent — faz logs --follow survives rotation by watching the inode and reopening the file.
Reading the log
The fastest way is the bundled CLI:
faz logs # print every entry, formatted one line per request
faz logs --follow # tail -F semantics
faz logs --path /var/log/faz/audit.jsonlEach entry prints as a compact summary: trace id, transport, RBAC/AST/injection outcomes, rows returned, total latency. If result.error is non-empty, it prints on the next line.
For deeper queries, parse the JSONL directly with jq:
# Every blocked request from the last hour
jq 'select(.rbac.outcome == "BLOCK" or .ast.outcome == "BLOCK" or .injection_scan.outcome == "BLOCK")' .faz/audit.jsonl
# Slow requests (>500ms)
jq 'select(.latency.total_ms > 500) | {trace_id, transport, total_ms: .latency.total_ms}' .faz/audit.jsonl
# Every request that touched a specific table
jq 'select(.execution.sources_hit | any(. == "<database>.<table>"))' .faz/audit.jsonl
# Group blocks by stage
jq -r '
if .rbac.outcome == "BLOCK" then "rbac"
elif .ast.outcome == "BLOCK" then "ast"
elif .injection_scan.outcome == "BLOCK" then "injection"
else empty end
' .faz/audit.jsonl | sort | uniq -cEntry schema
Every entry is a JSON object with these top-level fields. Nested objects are documented in their own subsections below.
{
"trace_id": "req_a1b2c3d4e5f6",
"timestamp": "2026-04-30T12:00:00.000000+00:00",
"transport": "mcp/stdio",
"source_ip": "127.0.0.1",
"auth": { ... },
"rbac": { ... },
"ast": { ... },
"injection_scan": { ... },
"execution": { ... },
"result": { ... },
"latency": { ... }
}Top-level fields
trace_id
String. Format: req_ followed by 12 hex characters. Generated per request, used to correlate the audit entry with logs and (when applicable) the result scratchpad. Look it up via GET /v1/results/{trace_id} for paginated results from a federated query.
timestamp
ISO 8601 string with timezone offset, recorded at request entry. Example: "2026-04-30T12:00:00.123456+00:00".
transport
One of:
"mcp/stdio"— request arrived via the MCP stdio server (Claude Desktop, Cursor, etc.)."rest/local"— REST request from a loopback address (127.0.0.1,::1,localhost)."rest/remote"— REST request from a non-loopback address. This only happens if you ranfaz serve --host 0.0.0.0or similar."cli"— the engine was invoked directly byfaz query."unknown"— request shape didn't match any of the above (rare; usually a misuse of the engine API).
This is the only caller-attribution faz keeps. There is no agent identity model.
source_ip
String. The remote IP for REST requests, "127.0.0.1" for loopback, empty for non-network transports (mcp/stdio, cli).
auth
{
"method": "TRANSPORT_TRUST",
"outcome": "PASS",
"roles": [],
"error": ""
}method— currently always"TRANSPORT_TRUST". Future auth methods (JWT, OIDC, SAML) will use their own values.outcome—"PASS"or"FAIL".roles— list of role strings. Empty for v1 (no role concept yet).error— error message ifoutcome == "FAIL". Empty otherwise.
rbac
{
"requested": ["<database>.<table-1>", "<database>.<table-2>"],
"stripped": [],
"outcome": "PASS",
"table_access_decisions": [
{
"database": "<database>",
"table": "<table-1>",
"requested_op": "SELECT",
"level_required": "R",
"level_granted": "RW",
"decision": "ALLOW"
}
],
"parse_error": null
}requested— list ofdatabase.tablesources the request asked to touch.stripped— list of sources removed underDenyPolicy.PARTIAL(federated queries only). Empty forPASSandBLOCKoutcomes.outcome—"PASS","PARTIAL", or"BLOCK".table_access_decisions— one entry per evaluated(database, table, op)target. Includes the operation requested, the minimum access level that would have allowed it, what the policy actually granted, and the resulting decision. This is the most useful field for diagnosing "why did this query block?"parse_error— set when the per-language access extractor crashed (fail-closedBLOCK); usuallynull.
ast
{
"blocked_nodes": [],
"outcome": "PASS"
}blocked_nodes— DDL targets the AST checker rejected. Format:database.table.outcome—"PASS"or"BLOCK".
injection_scan
{
"patterns_matched": [],
"outcome": "PASS"
}patterns_matched— list of pattern names the injection analyser flagged (e.g."tautology","$where","apoc.load").outcome—"PASS"or"BLOCK".
execution
{
"sources_hit": ["<database>.<table>"],
"rows_loaded": { "<database>.<table>": 42 },
"merge_sql": "",
"merge_latency_ms": 0.0,
"iteration_count": 1
}sources_hit— sources actually queried after RBAC and any partial-strip. May differ fromrbac.requestedfor federated queries.rows_loaded— per-source row counts loaded into the scratchpad before merging.merge_sql— the DuckDB merge SQL, when this was a multi-step federated query. Empty for single-step requests.merge_latency_ms— time spent running the merge SQL.0.0for single-step.iteration_count— usually1. Reserved for future iterative-query patterns.
result
{
"rows_returned": 42,
"streamed_via": "SSE",
"citations_attached": false,
"error": ""
}rows_returned— final row count returned to the client.streamed_via— currently always"SSE". Reserved for future transport variations.citations_attached— reserved for a future "show me where this row came from" feature.error— error message if execution failed after safety passed (e.g. database unreachable mid-query). Empty for successful and pre-execution-blocked requests.
latency
{
"auth_ms": 0.5,
"safety_ms": 3.2,
"execution_ms": 15.0,
"response_ms": 0.3,
"total_ms": 19.0
}Per-stage timings in milliseconds. total_ms is the sum, computed at finalize time.
What is and isn't logged
faz's audit log records request shape and outcome — not the literal query body. Specifically:
- The exact list of
(database, table, operation)targets is captured inrbac.table_access_decisions. - The
merge_sqlis captured for federated queries. - The original raw query string from the request body is not persisted to the audit log. If you need it, you'll need to capture it upstream (at your reverse proxy, MCP client log, or wrapping middleware).
- Row data is not persisted. Only counts.
This is a deliberate trade-off: the audit log is safe to ship to a SIEM without leaking customer-data values.
Failure modes
If the audit file can't be written (disk full, permission denied), faz logs a warning and continues. The query is not blocked by audit failure. If you're shipping audit logs to a compliance system that requires every request be recorded, monitor for the warning:
WARNING faz.audit: Failed to write audit file: [Errno 28] No space left on deviceRelated
- How faz protects you — the pipeline whose decisions land here.
faz logsCLI — the bundled tail-and-format helper.- Runtime options — the env vars that affect logging.