Skip to main content

micromegas/servers/
query_audit.rs

1//! Structured per-query audit record for the FlightSQL service.
2//!
3//! `execute_query` (see `flight_sql_service_impl`) emits one JSON-serialized
4//! [`crate::servers::query_audit::QueryAuditRecord`] per query, at completion, under the dedicated
5//! `flightsql_query_audit` log target. Unlike the untagged `imetric!` cost
6//! metrics (whose properties are meant for bounded-cardinality dimensions, not
7//! values such as SQL text), a free-text log `msg` has no cardinality constraint, so it can
8//! carry both attribution and cost in one self-contained, queryable record.
9//!
10//! `peak_memory_bytes`/`spilled_bytes`/`spill_count` complement the cost
11//! fields above: the peak comes from the query's `ScopedMemoryPool` (see
12//! `micromegas_analytics::lakehouse::scoped_memory_pool`), while the spill
13//! counters are summed from the physical plan tree, same as `bytes_scanned`.
14
15use datafusion::physical_plan::ExecutionPlan;
16
17/// Aggregated DataFusion plan metrics for one query, read after the stream drains.
18pub struct ScanMetrics {
19    pub output_rows: Option<u64>,
20    pub bytes_scanned: u64,
21    pub spilled_bytes: u64,
22    pub spill_count: u64,
23}
24
25/// Walk the physical-plan tree: `output_rows` from the root node (final result
26/// grain), `bytes_scanned`/`spilled_bytes`/`spill_count` summed across every
27/// node (leaf `DataSourceExec` nodes carry `bytes_scanned`; spilling operators
28/// such as `ExternalSorter` carry the other two). `sum_by_name` cannot be used
29/// for the spill counters: `MetricsSet::sum_by_name` explicitly returns
30/// `false` for `MetricValue::SpillCount`/`SpilledBytes`, so it would silently
31/// report zero; the dedicated `MetricsSet::spill_count()`/`spilled_bytes()`
32/// accessors are used instead.
33pub fn aggregate_scan_metrics(plan: &dyn ExecutionPlan) -> ScanMetrics {
34    fn sum_bytes(plan: &dyn ExecutionPlan) -> u64 {
35        let mut total = plan
36            .metrics()
37            .and_then(|m| m.sum_by_name("bytes_scanned"))
38            .map(|v| v.as_usize() as u64)
39            .unwrap_or(0);
40        for child in plan.children() {
41            total += sum_bytes(child.as_ref());
42        }
43        total
44    }
45    fn sum_spills(plan: &dyn ExecutionPlan) -> (u64, u64) {
46        let metrics = plan.metrics();
47        let mut spilled_bytes = metrics
48            .as_ref()
49            .and_then(|m| m.spilled_bytes())
50            .map(|v| v as u64)
51            .unwrap_or(0);
52        let mut spill_count = metrics
53            .as_ref()
54            .and_then(|m| m.spill_count())
55            .map(|v| v as u64)
56            .unwrap_or(0);
57        for child in plan.children() {
58            let (child_bytes, child_count) = sum_spills(child.as_ref());
59            spilled_bytes += child_bytes;
60            spill_count += child_count;
61        }
62        (spilled_bytes, spill_count)
63    }
64    let (spilled_bytes, spill_count) = sum_spills(plan);
65    ScanMetrics {
66        output_rows: plan
67            .metrics()
68            .and_then(|m| m.output_rows())
69            .map(|r| r as u64),
70        bytes_scanned: sum_bytes(plan),
71        spilled_bytes,
72        spill_count,
73    }
74}
75
76/// One structured record per FlightSQL query, emitted as a JSON log line under
77/// the `flightsql_query_audit` target when the query completes, fails, or is
78/// abandoned mid-drain (client disconnect/cancel).
79#[derive(serde::Serialize)]
80pub struct QueryAuditRecord {
81    /// Minted as the first statement of `execute_query`, before any fallible
82    /// step -- always present, so a failure's server-log line (which also
83    /// carries it) and this record can be correlated by grepping the id.
84    pub query_id: String,
85    /// The rightmost `X-Forwarded-For` entry (the address the trusted proxy in front of this
86    /// service observed), falling back to `X-Real-IP` and then the gRPC peer address for a
87    /// direct connection -- see `http_utils::get_client_ip`. Unlike `client`/`agent`/`entrypoint`
88    /// below, this comes from the network / trusted-proxy layer rather than from
89    /// client-controlled attribution headers, so it's grouped with `query_id` rather than with
90    /// the self-reported fields -- but that guarantee holds only for requests that actually
91    /// traversed the trusted proxy; for a direct connection with no proxy in front, both
92    /// `X-Forwarded-For` and `X-Real-IP` are just as caller-chosen as the self-reported fields.
93    pub client_ip: String,
94    pub client: String,
95    pub agent: String,
96    pub entrypoint: String,
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub session: Option<String>,
99    /// Originating notebook name, if the query was issued from a notebook cell.
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub notebook: Option<String>,
102    /// Originating cell name within the notebook, if the query was issued from a notebook cell.
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub cell: Option<String>,
105    pub user: String,
106    pub email: String,
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub name: Option<String>,
109    pub service_account: bool,
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub service_account_name: Option<String>,
112    pub sql: String,
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub range_begin: Option<String>, // RFC3339
115    #[serde(skip_serializing_if = "Option::is_none")]
116    pub range_end: Option<String>,
117    #[serde(skip_serializing_if = "Option::is_none")]
118    pub limit: Option<u64>,
119    pub context_init_ms: f64,
120    pub planning_ms: f64,
121    pub execution_ms: f64, // stream construction (matches query_execution_duration semantics)
122    pub setup_ms: f64,     // parse+attribution+context+planning+stream-build (query_setup_duration)
123    pub total_ms: f64,     // end-to-end incl. drain
124    pub status: &'static str, // "ok" | "error" | "incomplete"
125    #[serde(skip_serializing_if = "Option::is_none")]
126    pub error: Option<String>,
127    /// "user" | "resource" | "internal" | "denied", derived from the gRPC status code
128    /// (see `flight_sql_service_impl::error_class`) -- "denied" is stamped directly by the
129    /// query-deny-list check rather than derived from a `DataFusionError`. Omitted when
130    /// `status == "ok"`, matching the `error` field's own "on error" convention.
131    #[serde(skip_serializing_if = "Option::is_none")]
132    pub error_class: Option<&'static str>,
133    #[serde(skip_serializing_if = "Option::is_none")]
134    pub output_rows: Option<u64>,
135    pub bytes_scanned: u64,
136    pub peak_memory_bytes: u64,
137    pub spilled_bytes: u64,
138    pub spill_count: u64,
139    /// Normalized SQL fingerprint -- the first 16 hex chars of the SHA-256 of the
140    /// literal-stripped, whitespace-collapsed token stream. Computed once
141    /// per query and emitted on every terminal path regardless of whether the deny list is in
142    /// active use, since it's what an operator pastes into `deny_queries` after finding an
143    /// offender in the audit log. Appended last so existing JSON consumers are unaffected.
144    pub sql_hash: String,
145}