faz
Reference

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 serve
FAZ_AUDIT_LOG=/var/log/faz/audit.jsonl uv run faz serve
FAZ_AUDIT_LOG=/var/log/faz/audit.jsonl python -m faz serve

To opt out of file logging entirely (events still go to Python logging):

FAZ_AUDIT_LOG="" faz serve
FAZ_AUDIT_LOG="" uv run faz serve
FAZ_AUDIT_LOG="" python -m faz serve

The 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.jsonl

Each 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 -c

Entry 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 ran faz serve --host 0.0.0.0 or similar.
  • "cli" — the engine was invoked directly by faz 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 if outcome == "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 of database.table sources the request asked to touch.
  • stripped — list of sources removed under DenyPolicy.PARTIAL (federated queries only). Empty for PASS and BLOCK outcomes.
  • 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-closed BLOCK); usually null.

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 from rbac.requested for 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.0 for single-step.
  • iteration_count — usually 1. 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 in rbac.table_access_decisions.
  • The merge_sql is 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 device

On this page