FlightSQL Query Audit Log¶
The FlightSQL service emits one structured JSON log line per query, at completion, under the
dedicated flightsql_query_audit log target. It ties together attribution (who ran a query) and
cost (how expensive it was) in a single self-contained record, so you can answer questions like
"which clients/users are responsible for the slowest and most expensive queries?" without
correlating separate log lines and metrics.
This complements two other signals already emitted by execute_query:
- A free-text
info!line at query start, useful for in-flight visibility. - Untagged
imetric!cost metrics (query_duration_total,query_setup_duration, ...), useful for dashboards but not filterable/groupable by client or user, since theirPropertySetis empty.
The audit record is the structured, completion-time superset for cost attribution: it carries both the high-cardinality attribution (full SQL, email) and the per-stage cost (durations, output rows, bytes scanned) as one row per query.
Querying the audit log¶
The audit record lands in log_entries like any other log line,
with target = 'flightsql_query_audit' and the JSON payload in msg. Always query it with a
bounded time range plus the target filter — like any log_entries query, an unbounded scan over
this high-frequency target is expensive.
Parse msg with the JSON/JSONB functions
(jsonb_parse, jsonb_get, jsonb_as_string, jsonb_as_f64, jsonb_as_i64, ...):
SELECT time, jsonb_parse(msg) AS j
FROM log_entries
WHERE target = 'flightsql_query_audit'
AND time >= NOW() - INTERVAL '1 hour'
ORDER BY time DESC
LIMIT 20;
Attribution and cost, grouped by client and user¶
WITH q AS (
SELECT time, jsonb_parse(msg) AS j
FROM log_entries
WHERE target = 'flightsql_query_audit'
AND time >= NOW() - INTERVAL '1 hour'
)
SELECT
jsonb_as_string(jsonb_get(j, 'client')) AS client,
jsonb_as_string(jsonb_get(j, 'email')) AS email,
count(*) AS queries,
sum(jsonb_as_f64(jsonb_get(j, 'total_ms'))) AS total_ms,
approx_percentile_cont(jsonb_as_f64(jsonb_get(j, 'total_ms')), 0.95) AS p95_ms,
sum(jsonb_as_i64(jsonb_get(j, 'bytes_scanned'))) AS bytes_scanned
FROM q
GROUP BY client, email
ORDER BY total_ms DESC;
Slowest individual queries, with SQL for drill-down¶
WITH q AS (
SELECT time, jsonb_parse(msg) AS j
FROM log_entries
WHERE target = 'flightsql_query_audit'
AND time >= NOW() - INTERVAL '1 hour'
)
SELECT
time,
jsonb_as_string(jsonb_get(j, 'email')) AS email,
jsonb_as_f64(jsonb_get(j, 'total_ms')) AS total_ms,
jsonb_as_i64(jsonb_get(j, 'bytes_scanned')) AS bytes_scanned,
jsonb_as_string(jsonb_get(j, 'sql')) AS sql
FROM q
ORDER BY total_ms DESC
LIMIT 20;
Most memory-hungry individual queries, with SQL for drill-down¶
WITH q AS (
SELECT time, jsonb_parse(msg) AS j
FROM log_entries
WHERE target = 'flightsql_query_audit'
AND time >= NOW() - INTERVAL '1 hour'
)
SELECT
time,
jsonb_as_string(jsonb_get(j, 'email')) AS email,
jsonb_as_i64(jsonb_get(j, 'peak_memory_bytes')) AS peak_memory_bytes,
jsonb_as_i64(jsonb_get(j, 'spilled_bytes')) AS spilled_bytes,
jsonb_as_string(jsonb_get(j, 'sql')) AS sql
FROM q
ORDER BY peak_memory_bytes DESC
LIMIT 20;
Top offenders by fingerprint, for the query deny list¶
Groups by sql_hash (the normalized fingerprint, literals stripped) rather than raw sql, so a
dashboard re-issuing the same query with a different time-range literal on every refresh
collapses into one row instead of one per refresh -- the workflow the admin-managed query deny
list is built around: find the fingerprint
here, then SELECT * FROM deny_queries('sql_hash = ''<fingerprint>''', '<reason>').
WITH q AS (
SELECT time, jsonb_parse(msg) AS j
FROM log_entries
WHERE target = 'flightsql_query_audit'
AND time >= NOW() - INTERVAL '15 minutes'
)
SELECT
jsonb_as_string(jsonb_get(j, 'sql_hash')) AS sql_hash,
count(*) AS queries,
sum(jsonb_as_f64(jsonb_get(j, 'total_ms'))) AS total_ms,
sum(jsonb_as_i64(jsonb_get(j, 'bytes_scanned'))) AS bytes_scanned,
min(jsonb_as_string(jsonb_get(j, 'sql'))) AS sample_sql,
min(jsonb_as_string(jsonb_get(j, 'client'))) AS sample_client,
min(jsonb_as_string(jsonb_get(j, 'entrypoint'))) AS sample_entrypoint
FROM q
GROUP BY sql_hash
ORDER BY total_ms DESC
LIMIT 20;
Failed queries grouped by error_class¶
error_class distinguishes "the caller's SQL/input was bad" ("user"), "the query exceeded a
resource budget" ("resource"), "the query was rejected by an admin-managed deny
list rule" ("denied"), and "a genuine
server-side failure" ("internal") -- useful for telling how much of your error rate is
actually actionable by the caller versus a real service problem.
WITH q AS (
SELECT time, jsonb_parse(msg) AS j
FROM log_entries
WHERE target = 'flightsql_query_audit'
AND time >= NOW() - INTERVAL '1 hour'
)
SELECT
jsonb_as_string(jsonb_get(j, 'error_class')) AS error_class,
count(*) AS failures
FROM q
WHERE jsonb_as_string(jsonb_get(j, 'status')) = 'error'
GROUP BY error_class
ORDER BY failures DESC;
Fields¶
| Field | Type | Present | Description |
|---|---|---|---|
query_id |
string (UUID) | always | Unique id minted at the start of the request; also embedded in the client-facing error message and the server-side log line for the same failure, so the three can be correlated by grepping this id |
client_ip |
string | always | Network-level truth, not self-reported attribution, for requests that actually traversed the trusted proxy: the rightmost X-Forwarded-For entry (the address the trusted proxy/ALB in front of this service observed), falling back to X-Real-IP and then the gRPC peer address for a direct connection. unknown if none is available. For a direct connection with no proxy in front, both X-Forwarded-For and X-Real-IP are caller-chosen. Matches the client_ip on flight-sql-srv's generic per-call uri=... client_ip=... log line (both come from the same get_client_ip), so the two can be cross-referenced |
client |
string | always | Client type from the x-client-type metadata header (e.g. python, grafana), unknown if absent |
agent |
string | always | Who is driving the client, from the x-client-agent metadata header (e.g. claude-code, none), unknown if absent |
entrypoint |
string | always | How the client was invoked, from the x-client-entrypoint metadata header (e.g. script, jupyter, repl, cli-query), unknown if absent |
session |
string | if the caller sent x-client-session |
Opaque id correlating every query issued through one client instance/session |
notebook |
string | if the query originated from a notebook cell | Saved name of the originating notebook (screen), from the x-client-notebook metadata header |
cell |
string | if the query originated from a notebook cell | Name of the originating cell within the notebook, from the x-client-cell metadata header |
user |
string | always | Resolved user id |
email |
string | always | Resolved user email |
name |
string | if known | Display name from the x-user-name header |
service_account |
bool | always | true when the request was made by a service account delegating on behalf of a user |
service_account_name |
string | if delegated | Name of the delegating service account |
sql |
string | always | The full SQL text of the query |
sql_hash |
string | always | Normalized fingerprint of sql: the first 16 hex chars of the SHA-256 of the literal-stripped, whitespace-collapsed token stream. Two queries differing only in a time-range literal or a LIMIT value share a fingerprint -- this is what a top-offenders-by-fingerprint query groups by, and what you paste into deny_queries (see admin functions) |
range_begin |
string (RFC3339) | if the request specified a time range | Requested query range start |
range_end |
string (RFC3339) | if the request specified a time range | Requested query range end |
limit |
integer | if the request specified a row limit | Requested row limit |
context_init_ms |
float | always | Time spent creating the session context |
planning_ms |
float | always | Time spent building the logical plan (ctx.sql(...)) |
execution_ms |
float | always | Time spent constructing the physical plan and the response stream (not the full drain) |
setup_ms |
float | always | Total setup time: parsing, attribution, context creation, planning, and stream construction |
total_ms |
float | always | End-to-end duration, including draining the response stream to the client |
status |
string | always | "ok", "error", or "incomplete" (stream abandoned mid-drain, e.g. client disconnect or cancellation) |
error |
string | on error | Error message, when status is "error" |
error_class |
string | on error | "user" (bad SQL/input), "resource" (query exceeded a resource budget), "denied" (rejected by a query deny list rule), or "internal" (a genuine server-side failure). The first three are derived from the gRPC status code the query failed with, except "denied", which the deny-list check stamps directly (it also fails with ResourceExhausted, so it cannot be told apart from "resource" by status code alone) |
output_rows |
integer | if available | Rows produced by the query's physical plan root |
bytes_scanned |
integer | always | Bytes read from the lakehouse's parquet reader (object-store bytes requested, which may be served from the in-process L1 cache rather than fetched from origin) |
peak_memory_bytes |
integer | always | Peak tracked DataFusion reservation for this query alone |
spilled_bytes |
integer | always | Total bytes spilled to disk by this query's plan; nonzero only once the query actually spills — the exceptional safety-valve path, not the common case |
spill_count |
integer | always | Number of spill events for this query's plan; nonzero only once the query actually spills |
context_init_ms / planning_ms / execution_ms / setup_ms / total_ms are measured with
std::time::Instant, independently of the raw-TSC-tick imetric! timings emitted elsewhere in
execute_query — so they don't depend on the process's TSC-frequency calibration and are reliable
on their own. On a record with status = "error" emitted during setup, stage fields for stages
that were never reached read 0.0; total_ms still covers the full request.
Notes¶
agent/entrypoint/sessiondistinguish three states, not two.unknownmeans the client didn't report that header at all (e.g. Grafana, or any client older than this feature).none(foragent) orscript(forentrypoint) means the Python client actively resolved the value and found nothing distinctive — a real, reported answer, not an absence. A detected value (claude-code,jupyter,repl,cli-query, ...) means the client found and reported a specific signal. Don't conflateunknownwithnone/scriptwhen grouping or filtering.agentmeasures environment provenance, not SQL authorship. It reflects whether the client process ran inside a known agent harness's environment (detected from that harness's marker environment variable), not whether an LLM actually wrote the SQL text. Environment variables are inherited by child processes, so a human runningmicromegas-queryfrom a shell nested inside an agent session (e.g. a terminal opened from within Claude Code) is labelled with that agent too, even though a person typed the query.notebook/cellare reported by the web app only, and both are keyed by name, not a stable id. They're set byanalytics-web-srv's/api/query-streamendpoint from the originating screen's saved name and the executing cell's name, and are absent for any query not issued from a notebook cell (the standalone query editor, other clients). Cell (and notebook) names are mutable — renaming a cell after it has already produced audit records means historical rows under the old name won't be grouped with new ones under the new name. This is accepted as adequate for analytics; a stable per-cell id is a larger, separate change to the notebook format.- One row per query, at completion. The record can only be assembled once the response stream
has been fully drained (or has errored), since
total_ms,status,output_rows, andbytes_scannedonly settle at that point. If a client abandons the stream mid-drain (disconnect or cancellation), a record is still emitted withstatus = "incomplete"; its cost fields reflect the work done up to that point. bytes_scannedis a per-query, cache-aware signal. It counts bytes the lakehouse parquet reader requested from its (possibly L1-cache-backed) object store, i.e. the bytes the query logically needed — not necessarily bytes fetched from origin storage. Therange_cache_origin_block_bytesobject-cache metric remains the process-global origin-fetch signal; the two are complementary, not interchangeable.client_ipreports the nearest proxy's address, not the original caller's, for queries proxied through a server-side hop. For FlightSQL calls made through the HTTP gateway's/gateway/queryor throughanalytics-web-srv's/api/query-stream(the web app's notebook/query-editor path),client_ipis that proxy's own address, since neither proxy forwards the original caller'sX-Forwarded-Forchain today. This meansclient="web"queries — by far the highest-volume source of audit records — all reportanalytics-web-srv's address. Direct FlightSQL access (e.g. the Python client talking straight toflight-sql-srv, no gateway or web app in between) is unaffected and reports a true client IP.sql_hashis computed on every query, whether or not the deny list is in active use. It costs about 1.2 µs on a ~130-character statement -- accepted because the audit log needs it for the "paste it intodeny_queries" workflow regardless of whether any rule currently stands.peak_memory_bytesis a per-query lower bound on process cost, not the full picture. It's the peak of tracked DataFusion reservation — the same mechanism DataFusion's own memory-limit enforcement uses — but it doesn't count in-flightRecordBatches and parquet decode buffers (DataFusion documents this as deliberate), the L1 byte cache or micromegas' metadata cache (separately accounted), orAsyncArrowWriterrow-group buffers used by JIT materialization inside a query (bounded, since row groups are capped at 128K rows). It is a monotonic high-water mark, so it stays valid onerrorandincompleterecords too — even a record whose query failed or was abandoned reports the real peak reached before that point. It is the signal to use when judging whether the deployedMICROMEGAS_DATAFUSION_MEMORY_BUDGET_MBis set correctly and which queries are pushing against it;spilled_bytes/spill_countare the alarm for when a query actually leans on the disk-spill safety valve rather than merely coming close.peak_memory_bytesandspilled_bytes/spill_countdon't share the same scope. The peak naturally includes nested session contexts built during execution (Perfetto trace queries, JIT materialization), since it comes from the query's own memory-pool instance. The spill counters instead come from summing the outer physical plan tree, so a nested session context built inside a leaf node is opaque to that sum — a query can legitimately showpeak_memory_bytes > 0withspilled_bytes == 0even when nested work spilled. Also, a query that runsmaterialize_partitions()/regenerate_partitions()reports the merge's peak against the calling query, understated by the row-group-buffer caveat above.