Skip to main content

re_datafusion/
analytics.rs

1//! Per-connection analytics for dataset queries.
2//!
3//! Each connection to a Rerun Hub instance gets its own analytics sender
4//! that forwards OTLP trace events to that instance's OTEL ingest endpoint.
5//! This ensures analytics go to the correct cloud when the viewer is connected
6//! to multiple clouds simultaneously.
7//!
8//! ## One event per query
9//!
10//! A single user action (dataset query) produces exactly one analytics event,
11//! sent when the query completes. The event includes both the scan/planning
12//! phase stats and the fetch phase stats (split by gRPC vs direct fetches).
13//!
14//! ## Trace correlation
15//!
16//! When the client makes a `query_dataset` call, the server responds with an
17//! `x-request-trace-id` header containing the server-side trace ID. The client
18//! captures this and, when sending the analytics OTLP export to the server,
19//! sets the same `x-request-trace-id` header on the analytics request. This
20//! allows the server to correlate the analytics event with the original query
21//! trace in Grafana/Tempo.
22
23use std::fmt;
24use std::ops::Range;
25use std::sync::atomic::{AtomicU64, Ordering};
26use std::sync::{Arc, OnceLock};
27
28use datafusion::logical_expr::Expr;
29use datafusion::physical_plan::metrics::{ExecutionPlanMetricsSet, MetricBuilder, MetricsSet};
30use opentelemetry_proto::tonic::{
31    collector::trace::v1::ExportTraceServiceRequest,
32    common::v1::any_value::Value,
33    common::v1::{AnyValue, KeyValue},
34    resource::v1::Resource,
35    trace::v1::{ResourceSpans, ScopeSpans, Span, span::SpanKind},
36};
37use re_async::AsyncRuntimeHandle;
38use re_dataframe::QueryExpression;
39use re_protos::cloud::v1alpha1::SystemTableKind;
40use re_protos::cloud::v1alpha1::ext::ProviderDetails;
41use re_redap_client::ConnectionAnalyticsExporter;
42use re_uri::Origin;
43use web_time::{Duration, Instant, SystemTime};
44
45use crate::metrics_capture::{QueryMetrics, QuerySnapshot, build_query_snapshot};
46
47/// A per-connection analytics client that sends OTLP traces to a specific
48/// Rerun Hub's OTEL ingest endpoint.
49///
50/// Cheap to clone (wraps an `Arc`).
51///
52/// The target of these events are `PostHog`, and are aimed at user analytics.
53/// This means a single user action (e.g. a dataset query) should only
54/// trigger a single `PostHog` event, sent at the conclusion of the action.
55#[derive(Clone)]
56pub(crate) struct ConnectionAnalytics {
57    inner: Arc<Inner>,
58}
59
60impl fmt::Debug for ConnectionAnalytics {
61    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62        f.debug_struct("ConnectionAnalytics")
63            .field("origin", &self.inner.origin)
64            .finish_non_exhaustive()
65    }
66}
67
68struct Inner {
69    origin: Origin,
70    async_runtime: Option<AsyncRuntimeHandle>,
71
72    /// Analytics OTLP exporter sharing the layered tower service of the sibling
73    /// [`re_redap_client::ConnectionClient`] (same HTTP/2 transport, same auth /
74    /// version / propagate-headers stack).
75    exporter: Option<ConnectionAnalyticsExporter>,
76}
77
78impl ConnectionAnalytics {
79    /// Create a new analytics sender for the given origin.
80    ///
81    /// The analytics OTLP exports go through the same authenticated and
82    /// version-tagged HTTP/2 connection as regular REDAP RPCs.
83    pub fn new(exporter: ConnectionAnalyticsExporter, async_runtime: AsyncRuntimeHandle) -> Self {
84        let origin = exporter.origin().clone();
85        Self {
86            inner: Arc::new(Inner {
87                origin,
88                async_runtime: Some(async_runtime),
89                exporter: Some(exporter),
90            }),
91        }
92    }
93
94    #[cfg(test)]
95    pub(crate) fn disabled_for_test(origin: Origin) -> Self {
96        Self {
97            inner: Arc::new(Inner {
98                origin,
99                async_runtime: None,
100                exporter: None,
101            }),
102        }
103    }
104
105    /// Begin tracking analytics for a table scan.
106    ///
107    /// Returns a [`PendingTableQueryAnalytics`] that accumulates stats across the
108    /// scan. The analytics event is sent when the last clone is dropped.
109    pub fn begin_table_query(
110        &self,
111        info: TableQueryInfo,
112        scan_start: Instant,
113    ) -> PendingTableQueryAnalytics {
114        PendingTableQueryAnalytics {
115            inner: Arc::new(PendingTableInner {
116                connection: self.clone(),
117                info,
118                stats: SharedTableScanStats::default(),
119                scan_start,
120                time_to_first_response: OnceLock::new(),
121                time_to_first_batch: OnceLock::new(),
122                trace_id: OnceLock::new(),
123                error_kind: OnceLock::new(),
124            }),
125        }
126    }
127
128    /// Send an OTLP span in the background. Never blocks the caller.
129    fn send_span(&self, span: Span, trace_id: Option<opentelemetry::TraceId>) {
130        let this = self.clone();
131
132        let fut = async move {
133            if let Err(err) = this.send_span_impl(span, trace_id).await {
134                re_log::debug_once!(
135                    "Failed to send analytics to Rerun Hub: {} ({})",
136                    err.code(),
137                    err.message()
138                );
139            }
140        };
141
142        if let Some(async_runtime) = &self.inner.async_runtime {
143            async_runtime.spawn_future(fut);
144        }
145    }
146
147    async fn send_span_impl(
148        &self,
149        mut span: Span,
150        trace_id: Option<opentelemetry::TraceId>,
151    ) -> tonic::Result<()> {
152        let Some(exporter) = &self.inner.exporter else {
153            return Ok(());
154        };
155        assign_span_identity(&mut span, trace_id)?;
156
157        // `service.name` is the OTel resource attribute that identifies the
158        // sending service in the trace store (Grafana/Tempo etc.). We
159        // hard-code it to `"rerun-viewer"` here because this piggy-back is
160        // viewer-specific by construction — it ships `cloud_query_dataset`
161        // / `cloud_scan_table` spans from the viewer's query path,
162        // regardless of whatever `OTEL_SERVICE_NAME` the caller's process
163        // might have set for its own general tracing.
164        //
165        // The SDK-trace path in `re_perf_telemetry` (driven by
166        // `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=rerun://`) instead follows
167        // the OTel convention and uses whatever `OTEL_SERVICE_NAME` was
168        // set at init time (e.g. `rerun-py`, application-specific).
169        let mut resource_attributes = vec![kv_string("service.name", "rerun-viewer")];
170        if let Some(analytics) = re_analytics::Analytics::global_get() {
171            resource_attributes.push(kv_string("analytics_id", &analytics.config().analytics_id));
172        }
173
174        let export_request = ExportTraceServiceRequest {
175            resource_spans: vec![ResourceSpans {
176                resource: Some(Resource {
177                    attributes: resource_attributes,
178                    dropped_attributes_count: 0,
179                    entity_refs: Vec::new(),
180                }),
181                scope_spans: vec![ScopeSpans {
182                    scope: None,
183                    spans: vec![span],
184                    schema_url: String::new(),
185                }],
186                schema_url: String::new(),
187            }],
188        };
189
190        exporter.export_trace(export_request, trace_id).await
191    }
192}
193
194fn assign_span_identity(
195    span: &mut Span,
196    correlated_trace_id: Option<opentelemetry::TraceId>,
197) -> tonic::Result<()> {
198    span.trace_id = if let Some(trace_id) =
199        correlated_trace_id.filter(|trace_id| *trace_id != opentelemetry::TraceId::INVALID)
200    {
201        trace_id.to_bytes().to_vec()
202    } else {
203        random_nonzero_id::<16>()?
204    };
205    span.span_id = random_nonzero_id::<8>()?;
206    Ok(())
207}
208
209fn random_nonzero_id<const N: usize>() -> tonic::Result<Vec<u8>> {
210    loop {
211        let mut id = [0; N];
212        getrandom::fill(&mut id).map_err(|err| {
213            tonic::Status::internal(format!("failed to generate OTLP span ID: {err}"))
214        })?;
215        if id.iter().any(|byte| *byte != 0) {
216            return Ok(id.to_vec());
217        }
218    }
219}
220
221// ----------------------------------------------------------------------------
222
223/// Begin tracking analytics for a query.
224///
225/// Always returns a [`PendingQueryAnalytics`] — `connection` is `None` when
226/// the per-process telemetry stack is not active, in which case the resulting
227/// analytics struct gathers data passively (for the in-process
228/// [`crate::metrics_capture`] subscribers and DataFusion's `metrics()`) but
229/// skips the `PostHog` OTLP send at drop time.
230///
231/// Constructs the query's [`QueryMetrics`] from `query_info` and owns it —
232/// retrieve the shared handle via [`PendingQueryAnalytics::metrics`]. It's the
233/// single source of truth for fetch counters: the `PendingInner::Drop` path
234/// reads it via [`build_query_snapshot`] to construct the OTLP span attribute
235/// set, and `SegmentStreamExec` reads it for the snapshot path and
236/// `EXPLAIN ANALYZE`.
237pub fn begin_query(
238    connection: Option<ConnectionAnalytics>,
239    query_info: QueryInfo,
240    scan_start: Instant,
241    scan_start_wall: SystemTime,
242) -> PendingQueryAnalytics {
243    PendingQueryAnalytics {
244        inner: Arc::new(PendingInner {
245            connection,
246            metrics: Arc::new(QueryMetrics::new(query_info)),
247            scan_start,
248            scan_start_wall,
249            time_to_first_chunk: OnceLock::new(),
250            direct_terminal_reason: OnceLock::new(),
251            error_kind: OnceLock::new(),
252        }),
253    }
254}
255
256/// Query shape
257#[derive(Clone, Copy, Debug, PartialEq, Eq)]
258pub enum QueryType {
259    /// Query for static (timeless) data — no temporal selector applies.
260    Static,
261
262    /// Point-in-time query: a `latest_at` selector with no range.
263    LatestAt,
264
265    /// Time-range query: a range selector with no `latest_at`.
266    Range,
267
268    /// Combined dataframe query: both `latest_at` and range selectors are set.
269    Dataframe,
270
271    /// Neither `latest_at` nor range is set — an unbounded scan of all timestamps.
272    FullScan,
273}
274
275impl QueryType {
276    /// Classify the query shape into a bounded label for analytics.
277    pub(crate) fn classify(query_expression: &QueryExpression) -> Self {
278        if query_expression.is_static() {
279            Self::Static
280        } else {
281            let has_latest_at = query_expression.min_latest_at().is_some();
282            let has_range = query_expression.max_range().is_some();
283            match (has_latest_at, has_range) {
284                (true, true) => Self::Dataframe,
285                (true, false) => Self::LatestAt,
286                (false, true) => Self::Range,
287                (false, false) => Self::FullScan,
288            }
289        }
290    }
291
292    /// Stable string label emitted into the analytics span.
293    pub const fn as_str(self) -> &'static str {
294        match self {
295            Self::Static => "static",
296            Self::LatestAt => "latest_at",
297            Self::Range => "range",
298            Self::Dataframe => "dataframe",
299            Self::FullScan => "full_scan",
300        }
301    }
302}
303
304/// Information about the query planning phase, collected in `scan()`.
305#[derive(Clone, Debug, PartialEq)]
306pub struct QueryInfo {
307    /// The dataset being queried. Sent to the server so it can enrich the
308    /// analytics event with full dataset stats (total chunks, bytes, etc.).
309    pub dataset_id: String,
310
311    /// Number of unique chunks returned by `query_dataset` (subset of the dataset).
312    pub query_chunks: usize,
313
314    /// Number of distinct segments involved in the query.
315    pub query_segments: usize,
316
317    /// Number of distinct layers touched by the query.
318    pub query_layers: usize,
319
320    /// Number of columns in the query output schema.
321    pub query_columns: usize,
322
323    /// Number of entity paths in the query request.
324    pub query_entities: usize,
325
326    /// Total size of all queried chunks in bytes (from chunk metadata).
327    pub query_bytes: u64,
328
329    /// Min # chunks touched within any single segment in this query.
330    pub query_chunks_per_segment_min: u32,
331
332    /// Max number of chunks touched within any single segment in this query.
333    pub query_chunks_per_segment_max: u32,
334
335    /// Mean number of chunks touched per segment in this query.
336    pub query_chunks_per_segment_mean: f32,
337
338    /// Query shape
339    pub query_type: QueryType,
340
341    /// Name of the sort/filter index (timeline) for this query, if any.
342    pub primary_index_name: Option<String>,
343
344    /// Time from sending `query_dataset` until the first response message
345    /// arrives (the chunk metadata, not actual chunk data).
346    pub time_to_first_chunk_info: Option<Duration>,
347
348    /// Server-side trace ID from the `x-request-trace-id` response header.
349    pub trace_id: Option<opentelemetry::TraceId>,
350
351    /// Number of filter expressions the table provider was able to push down to the
352    /// server (returning `Exact` or `Inexact` from `supports_filters_pushdown`).
353    pub filters_pushed_down: usize,
354
355    /// Number of filter expressions the table provider could not push down — they
356    /// will be applied by DataFusion via a downstream `FilterExec`.
357    pub filters_applied_client_side: usize,
358
359    /// True when projection-based entity-path narrowing actually trimmed the set of
360    /// entity paths sent to `query_dataset`.
361    pub entity_path_narrowing_applied: bool,
362
363    /// Number of filter expressions DataFusion presented to
364    /// `supports_filters_pushdown` at planning time (equals
365    /// `filters_pushed_down + filters_applied_client_side`).
366    pub filters_total: u32,
367
368    /// Semicolon-delimited SQL representations of every offered filter
369    /// (e.g. `"(log_time > 0);rerun_segment_id IN ('a', 'b')"`).
370    pub filters_signatures: String,
371
372    /// Semicolon-delimited SQL signatures of filters classified as
373    /// [`datafusion::logical_expr::TableProviderFilterPushDown::Exact`] at planning time.
374    pub filters_signatures_exact: String,
375
376    /// Semicolon-delimited SQL signatures of filters classified as
377    /// [`datafusion::logical_expr::TableProviderFilterPushDown::Inexact`] at planning time.
378    pub filters_signatures_inexact: String,
379
380    /// Semicolon-delimited SQL signatures of filters classified as
381    /// [`datafusion::logical_expr::TableProviderFilterPushDown::Unsupported`] at planning time.
382    pub filters_signatures_unsupported: String,
383}
384
385/// Tracks a query in progress. Accumulates per-query state across phases.
386///
387/// Fetch counters live in the plan's [`QueryMetrics`] (shared with
388/// `SegmentStreamExec` and DataFusion `EXPLAIN ANALYZE`); this struct just
389/// holds an `Arc` clone of that handle plus timing and error state. A single
390/// combined OTLP analytics event is sent to `PostHog` when the last clone is
391/// dropped — but only if the per-process telemetry stack is active
392/// (`connection.is_some()`).
393///
394/// Cheap to clone (wraps an `Arc`).
395#[derive(Clone)]
396pub(crate) struct PendingQueryAnalytics {
397    inner: Arc<PendingInner>,
398}
399
400impl fmt::Debug for PendingQueryAnalytics {
401    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
402        f.debug_struct("PendingQueryAnalytics")
403            .finish_non_exhaustive()
404    }
405}
406
407pub(crate) struct PendingInner {
408    /// `None` when the telemetry stack is off — analytics still accumulate
409    /// passively (for `metrics_capture` subscribers and DataFusion
410    /// `metrics()`) but the drop-time OTLP send is skipped.
411    connection: Option<ConnectionAnalytics>,
412
413    /// The query's fetch counters + embedded plan-time `QueryInfo`. IO tasks
414    /// `fetch_add` into the atomics during execution; `Drop` reads them via
415    /// [`build_query_snapshot`] to build the OTLP span. Single source of
416    /// truth — there is no parallel accumulator; `SegmentStreamExec` reaches
417    /// it through [`PendingQueryAnalytics::metrics`].
418    metrics: Arc<QueryMetrics>,
419
420    /// Monotonic start time of the query, for computing elapsed durations.
421    scan_start: Instant,
422
423    /// Wall-clock start time of the query. Combined with `SystemTime::now()` at
424    /// drop time to produce the OTLP span's `start`/`end` timestamps, which then
425    /// match `total_duration_us`.
426    scan_start_wall: SystemTime,
427
428    /// Time from scan start until the first chunk is returned to datafusion.
429    time_to_first_chunk: OnceLock<Duration>,
430
431    /// First terminal direct-fetch failure reason encountered, if any.
432    /// Only set once. Stored as `&'static str` from the bounded
433    /// [`DirectFetchFailureReason`] label set.
434    direct_terminal_reason: OnceLock<DirectFetchFailureReason>,
435
436    /// Error classification, if the query failed. `None` ⇒ success.
437    /// Stored as `&'static str` from [`QueryErrorKind::as_str`] so emission is zero-copy.
438    error_kind: OnceLock<&'static str>,
439}
440
441/// Bounded set of query-failure classifications for the analytics span.
442///
443/// Kept as an enum (rather than free-form strings) so that adding a new call
444/// site cannot silently introduce a new `error_kind` value and inflate the
445/// analytics cardinality. Add a variant here if you need a new bucket.
446#[derive(Debug, Clone, Copy, PartialEq, Eq)]
447#[cfg_attr(target_arch = "wasm32", expect(dead_code))]
448pub enum QueryErrorKind {
449    /// A gRPC fetch (`FetchChunks` or the fast-path gRPC-only fetch) failed.
450    GrpcFetch,
451
452    /// A direct (HTTP Range) fetch failed, non-retryable or retries exhausted.
453    DirectFetch,
454
455    /// CPU-side decoding or execution error (chunk insertion, row materialization).
456    Decode,
457
458    /// Generic / unclassified error (e.g. IO task join failure).
459    Other,
460}
461
462impl QueryErrorKind {
463    /// Stable string label emitted into the analytics span.
464    pub fn as_str(self) -> &'static str {
465        match self {
466            Self::GrpcFetch => "grpc_fetch",
467            Self::DirectFetch => "direct_fetch",
468            Self::Decode => "decode",
469            Self::Other => "other",
470        }
471    }
472}
473
474/// Bounded set of terminal failure reasons for direct fetches.
475///
476/// These labels are emitted both into the per-process OTEL counter
477/// (`chunk_fetch.direct.result`) and into the per-query `PostHog` span as
478/// `fetch_direct_terminal_reason`.
479#[derive(Debug, Clone, Copy, PartialEq, Eq)]
480pub enum DirectFetchFailureReason {
481    Timeout,
482    Http4xx,
483    Http5xx,
484    Connection,
485    Decode,
486
487    /// The source object on the blob store changed since the dataset was
488    /// registered.
489    SourceChanged,
490    Other,
491}
492
493impl DirectFetchFailureReason {
494    /// Convert to the stable string label used in telemetry.
495    pub fn as_str(self) -> &'static str {
496        match self {
497            Self::Timeout => "timeout",
498            Self::Http4xx => "http_4xx",
499            Self::Http5xx => "http_5xx",
500            Self::Connection => "connection",
501            Self::Decode => "decode",
502            Self::SourceChanged => "source_changed",
503            Self::Other => "other",
504        }
505    }
506}
507
508impl PendingQueryAnalytics {
509    /// The shared per-query [`QueryMetrics`] handle — the single source of
510    /// truth for fetch counters and the plan-time `QueryInfo`. Also read by
511    /// `SegmentStreamExec` for the snapshot path and `EXPLAIN ANALYZE`.
512    pub(crate) fn metrics(&self) -> &Arc<QueryMetrics> {
513        &self.inner.metrics
514    }
515
516    /// Record that the first result chunk has been returned to the user.
517    /// Only the first call has any effect.
518    #[cfg_attr(target_arch = "wasm32", expect(dead_code))]
519    pub fn record_first_chunk(&self) {
520        self.inner
521            .time_to_first_chunk
522            .get_or_init(|| self.inner.scan_start.elapsed());
523    }
524
525    /// Record the terminal failure reason for a direct fetch that exhausted retries
526    /// or hit a non-retryable error. Only the first call has effect.
527    #[cfg(not(target_arch = "wasm32"))]
528    pub fn record_direct_terminal_failure(&self, reason: DirectFetchFailureReason) {
529        #[expect(clippy::let_underscore_must_use)]
530        let _ = self.inner.direct_terminal_reason.set(reason);
531    }
532
533    /// Mark the query as failed with the given error kind.
534    ///
535    /// Only the first call has effect.
536    pub fn record_error(&self, kind: QueryErrorKind) {
537        #[expect(clippy::let_underscore_must_use)]
538        let _ = self.inner.error_kind.set(kind.as_str());
539    }
540
541    /// Current error label, if any. Used by the in-process `metrics_capture`
542    /// path so cancelled / failed queries still surface an `error_kind` in
543    /// the [`crate::QuerySnapshot`].
544    pub fn error_kind(&self) -> Option<&'static str> {
545        self.inner.error_kind.get().copied()
546    }
547
548    /// Time from `scan_start` until the first chunk was returned, if recorded.
549    /// Mirrors the value the OTLP `Drop` path emits, so `metrics_capture`
550    /// subscribers see the same number.
551    pub fn time_to_first_chunk(&self) -> Option<Duration> {
552        self.inner.time_to_first_chunk.get().copied()
553    }
554
555    /// Terminal direct-fetch failure reason, if one was recorded. Mirrors the
556    /// OTLP `Drop`-time value.
557    pub fn direct_terminal_reason(&self) -> Option<DirectFetchFailureReason> {
558        self.inner.direct_terminal_reason.get().copied()
559    }
560
561    /// Elapsed time since this query was begun in [`begin_query`]. The
562    /// snapshot path uses this instead of a per-partition start so the
563    /// reported duration doesn't depend on which partition is last to
564    /// finish (or when DataFusion got around to scheduling it).
565    pub fn total_duration(&self) -> Duration {
566        self.inner.scan_start.elapsed()
567    }
568}
569
570/// Per-task accumulator for fetch counters.
571///
572/// Each outer fetch task owns one of these and mutates it without
573/// synchronization. At the end of the task it is folded into the plan's
574/// shared [`QueryMetrics`] via [`TaskFetchStats::flush_into`]. Issued-request
575/// counters are updated directly at the transport boundary so cancellation
576/// cannot discard calls that already reached the network.
577///
578/// Buffering the remaining counters avoids cross-core cache-line ping-pong
579/// during retry-heavy fetch loops.
580#[derive(Default)]
581#[must_use]
582pub(crate) struct TaskFetchStats {
583    grpc_bytes: u64,
584    direct_bytes: u64,
585    direct_retries_total: u64,
586    direct_requests_retried: u64,
587    direct_retry_sleep: Duration,
588    direct_max_attempt: u64,
589    direct_original_ranges: u64,
590    direct_merged_ranges: u64,
591}
592
593#[cfg_attr(target_arch = "wasm32", expect(dead_code))]
594impl TaskFetchStats {
595    pub fn record_grpc_bytes(&mut self, bytes: u64) {
596        self.grpc_bytes += bytes;
597    }
598
599    pub fn record_direct_bytes(&mut self, bytes: u64) {
600        self.direct_bytes += bytes;
601    }
602
603    /// Record a single direct-fetch retry on one merged request.
604    ///
605    /// `sleep` is the backoff duration actually slept before the retry attempt.
606    /// `attempt` is the attempt number about to be made (starts at 2 for the first retry).
607    pub fn record_direct_retry(&mut self, sleep: Duration, attempt: u64) {
608        self.direct_retries_total += 1;
609        self.direct_retry_sleep = self.direct_retry_sleep.saturating_add(sleep);
610        self.direct_max_attempt = self.direct_max_attempt.max(attempt);
611    }
612
613    /// Record that a single merged request needed at least one retry (call once per
614    /// retried request, regardless of how many attempts it took).
615    pub fn record_direct_request_was_retried(&mut self) {
616        self.direct_requests_retried += 1;
617    }
618
619    /// Record the range-merging efficiency for this batch.
620    pub fn record_direct_ranges(&mut self, original: u64, merged: u64) {
621        self.direct_original_ranges += original;
622        self.direct_merged_ranges += merged;
623    }
624
625    /// Merge another task-local accumulator into this one.
626    #[expect(
627        clippy::needless_pass_by_value,
628        reason = "Prevent double-counting stats"
629    )]
630    pub fn merge_from(&mut self, other: Self) {
631        let Self {
632            grpc_bytes,
633            direct_bytes,
634            direct_retries_total,
635            direct_requests_retried,
636            direct_retry_sleep: direct_retry_sleep_us,
637            direct_max_attempt,
638            direct_original_ranges,
639            direct_merged_ranges,
640        } = other;
641        self.grpc_bytes += grpc_bytes;
642        self.direct_bytes += direct_bytes;
643        self.direct_retries_total += direct_retries_total;
644        self.direct_requests_retried += direct_requests_retried;
645        self.direct_retry_sleep += direct_retry_sleep_us;
646        self.direct_max_attempt = self.direct_max_attempt.max(direct_max_attempt);
647        self.direct_original_ranges += direct_original_ranges;
648        self.direct_merged_ranges += direct_merged_ranges;
649    }
650
651    /// Fold this buffer into the shared [`QueryMetrics`].
652    ///
653    /// All counters are aggregated across partitions; `direct_max_attempt`
654    /// uses `fetch_max` so the resulting value is the true cross-partition
655    /// max rather than a sum.
656    pub fn flush_into(self, metrics: &QueryMetrics) {
657        let Self {
658            grpc_bytes,
659            direct_bytes,
660            direct_retries_total,
661            direct_requests_retried,
662            direct_retry_sleep,
663            direct_max_attempt,
664            direct_original_ranges,
665            direct_merged_ranges,
666        } = self;
667
668        // Zero-valued fields are skipped so totally-idle tasks don't touch the
669        // shared cache line at all.
670        if grpc_bytes != 0 {
671            metrics
672                .fetch_grpc_bytes
673                .fetch_add(grpc_bytes, Ordering::Relaxed);
674        }
675        if direct_bytes != 0 {
676            metrics
677                .fetch_direct_bytes
678                .fetch_add(direct_bytes, Ordering::Relaxed);
679        }
680        if direct_retries_total != 0 {
681            metrics
682                .fetch_direct_retries
683                .fetch_add(direct_retries_total, Ordering::Relaxed);
684        }
685        if direct_requests_retried != 0 {
686            metrics
687                .fetch_direct_requests_retried
688                .fetch_add(direct_requests_retried, Ordering::Relaxed);
689        }
690        if !direct_retry_sleep.is_zero() {
691            metrics
692                .fetch_direct_retry_sleep_us
693                .fetch_add(direct_retry_sleep.as_micros() as u64, Ordering::Relaxed);
694        }
695        if direct_max_attempt != 0 {
696            metrics
697                .fetch_direct_max_attempt
698                .fetch_max(direct_max_attempt, Ordering::Relaxed);
699        }
700        if direct_original_ranges != 0 {
701            metrics
702                .fetch_direct_original_ranges
703                .fetch_add(direct_original_ranges, Ordering::Relaxed);
704        }
705        if direct_merged_ranges != 0 {
706            metrics
707                .fetch_direct_merged_ranges
708                .fetch_add(direct_merged_ranges, Ordering::Relaxed);
709        }
710    }
711
712    /// Flush this buffer into `analytics`' shared [`QueryMetrics`], also
713    /// recording an error (if any) onto `analytics`.
714    pub fn try_flush_into(
715        self,
716        analytics: &PendingQueryAnalytics,
717        result: Result<(), QueryErrorKind>,
718    ) {
719        self.flush_into(analytics.metrics());
720        if let Err(err) = result {
721            analytics.record_error(err);
722        }
723    }
724}
725
726/// Build an ad-hoc [`MetricsSet`] for DataFusion `EXPLAIN ANALYZE` output.
727///
728/// Called on demand from `ExecutionPlan::metrics()`. Reads the live atomics
729/// in `metrics` plus plan-time scalars in `metrics.query_info`, plus per-call
730/// auxiliary inputs (`num_partitions`, `time_to_first_chunk`) that aren't
731/// stored on `QueryMetrics`.
732///
733/// Names match what the old `seed_plan_time_metrics` + per-partition fetch
734/// counters used, so downstream dashboards / grep targets keep working.
735/// The output is summed across partitions (not per-partition rows) — the
736/// `num_partitions` gauge surfaces the partition count separately.
737///
738/// `query_type` and `primary_index_name` are not surfaced — labels don't fit
739/// the `MetricsSet::Count` shape; they show up in `DisplayAs::Verbose` instead.
740/// `query_chunks_per_segment_mean` is `f32`, also doesn't fit `Count` — it
741/// flows through the `QuerySnapshot` to `PostHog` / `query_metrics()` but not
742/// EXPLAIN. Anyone reading EXPLAIN can divide `query_chunks / query_segments`.
743pub(crate) fn build_metrics_set_for_explain(
744    metrics: &QueryMetrics,
745    num_partitions: usize,
746    time_to_first_chunk: Option<Duration>,
747) -> MetricsSet {
748    let set = ExecutionPlanMetricsSet::new();
749    let info = &metrics.query_info;
750    let load = |a: &AtomicU64| a.load(Ordering::Relaxed) as usize;
751
752    let global = |name: &'static str| MetricBuilder::new(&set).global_counter(name);
753    global("query_chunks").add(info.query_chunks);
754    global("query_segments").add(info.query_segments);
755    global("query_layers").add(info.query_layers);
756    global("query_columns").add(info.query_columns);
757    global("query_entities").add(info.query_entities);
758    global("query_bytes").add(info.query_bytes as usize);
759    global("query_chunks_per_segment_min").add(info.query_chunks_per_segment_min as usize);
760    global("query_chunks_per_segment_max").add(info.query_chunks_per_segment_max as usize);
761    global("filters_pushed_down").add(info.filters_pushed_down);
762    global("filters_applied_client_side").add(info.filters_applied_client_side);
763    if info.entity_path_narrowing_applied {
764        global("entity_path_narrowing_applied").add(1);
765    }
766    if let Some(ttfci) = info.time_to_first_chunk_info {
767        global("time_to_first_chunk_info_us").add(ttfci.as_micros() as usize);
768    }
769    global("num_partitions").add(num_partitions);
770
771    global("fetch_grpc_requests").add(load(&metrics.fetch_grpc_requests));
772    global("fetch_grpc_bytes").add(load(&metrics.fetch_grpc_bytes));
773    global("fetch_direct_requests").add(load(&metrics.fetch_direct_requests));
774    global("fetch_direct_bytes").add(load(&metrics.fetch_direct_bytes));
775    global("fetch_direct_retries").add(load(&metrics.fetch_direct_retries));
776    global("fetch_direct_requests_retried").add(load(&metrics.fetch_direct_requests_retried));
777    global("fetch_direct_retry_sleep_us").add(load(&metrics.fetch_direct_retry_sleep_us));
778    global("fetch_direct_max_attempt").add(load(&metrics.fetch_direct_max_attempt));
779    global("fetch_direct_original_ranges").add(load(&metrics.fetch_direct_original_ranges));
780    global("fetch_direct_merged_ranges").add(load(&metrics.fetch_direct_merged_ranges));
781    global("planned_fetch_batches").add(load(&metrics.planned_fetch_batches));
782    global("planned_segment_waves").add(load(&metrics.planned_segment_waves));
783    global("segment_admission_limit").add(load(&metrics.segment_admission_limit));
784    global("segment_admission_candidate_limit")
785        .add(load(&metrics.segment_admission_candidate_limit));
786    global("segment_admission_source_code").add(load(&metrics.segment_admission_source));
787    global("segment_admission_candidate_reason_code")
788        .add(load(&metrics.segment_admission_candidate_reason));
789    global("segment_admission_adaptive_enabled")
790        .add(load(&metrics.segment_admission_adaptive_enabled));
791    global("segment_admission_profile_segment_count")
792        .add(load(&metrics.segment_admission_profile_segment_count));
793    global("segment_admission_profile_complete")
794        .add(load(&metrics.segment_admission_profile_complete));
795    global("segment_admission_p95_segment_bytes")
796        .add(load(&metrics.segment_admission_p95_segment_bytes));
797    global("segment_admission_max_segment_bytes")
798        .add(load(&metrics.segment_admission_max_segment_bytes));
799    global("segment_admission_largest_window_bytes")
800        .add(load(&metrics.segment_admission_largest_window_bytes));
801    global("max_segments_per_fetch_batch").add(load(&metrics.max_segments_per_fetch_batch));
802    global("max_segments_per_wave").add(load(&metrics.max_segments_per_wave));
803    global("peak_active_segments").add(load(&metrics.peak_active_segments));
804    global("pipeline_budget_bytes").add(load(&metrics.pipeline_budget_bytes));
805    global("pipeline_peak_decoded_bytes").add(load(&metrics.pipeline_peak_decoded_bytes));
806    global("pipeline_byte_waits").add(load(&metrics.pipeline_byte_waits));
807    global("segment_admission_waits").add(load(&metrics.segment_admission_waits));
808    global("pipeline_stall_breaker_activations")
809        .add(load(&metrics.pipeline_stall_breaker_activations));
810
811    if let Some(ttfr) = time_to_first_chunk {
812        MetricBuilder::new(&set)
813            .subset_time("time_to_first_chunk", 0)
814            .add_duration(ttfr);
815    }
816
817    set.clone_inner()
818}
819
820impl Drop for PendingInner {
821    fn drop(&mut self) {
822        // Only build + send the `PostHog` OTLP span when the per-process
823        // telemetry stack is active. When it isn't, the analytics struct is
824        // still serving its other consumers (`metrics_capture` and
825        // DataFusion's `metrics()`) — we just skip the send.
826        let Some(connection) = self.connection.as_ref() else {
827            return;
828        };
829
830        let total_duration = self.scan_start.elapsed();
831        let scan_end_wall = SystemTime::now();
832        let time_to_first_chunk = self.time_to_first_chunk.get().copied();
833        let direct_terminal_reason = self.direct_terminal_reason.get().copied();
834        let error_kind = self.error_kind.get().copied();
835        let trace_id = self.metrics.query_info.trace_id;
836
837        let snapshot = build_query_snapshot(
838            &self.metrics,
839            total_duration,
840            time_to_first_chunk,
841            error_kind,
842            direct_terminal_reason,
843        );
844
845        let span = build_query_span(&snapshot, self.scan_start_wall..scan_end_wall);
846
847        connection.send_span(span, trace_id);
848    }
849}
850
851/// Build the OTLP `cloud_query_dataset` span from a [`QuerySnapshot`].
852///
853/// Pure function — no I/O, no time reads. Takes the same `QuerySnapshot` shape
854/// that the in-process `metrics_capture` subscribers see, so `PostHog` and
855/// Python readers are guaranteed to observe identical values.
856fn build_query_span(snap: &QuerySnapshot, wall_clock_range: Range<SystemTime>) -> Span {
857    let start_time_unix_nano = nanos_since_epoch(&wall_clock_range.start);
858    let end_time_unix_nano = nanos_since_epoch(&wall_clock_range.end);
859
860    let QuerySnapshot {
861        query_info:
862            QueryInfo {
863                dataset_id,
864                query_chunks,
865                query_segments,
866                query_layers,
867                query_columns,
868                query_entities,
869                query_bytes,
870                query_chunks_per_segment_min,
871                query_chunks_per_segment_max,
872                query_chunks_per_segment_mean,
873                query_type,
874                primary_index_name,
875                time_to_first_chunk_info,
876                trace_id: _,
877                filters_pushed_down,
878                filters_applied_client_side,
879                entity_path_narrowing_applied,
880                filters_total,
881                filters_signatures,
882                filters_signatures_exact,
883                filters_signatures_inexact,
884                filters_signatures_unsupported,
885            },
886        total_duration,
887        time_to_first_chunk,
888        error_kind,
889        direct_terminal_reason,
890        fetch_grpc_requests,
891        fetch_grpc_bytes,
892        fetch_direct_requests,
893        fetch_direct_bytes,
894        fetch_direct_retries,
895        fetch_direct_requests_retried,
896        fetch_direct_retry_sleep,
897        fetch_direct_max_attempt,
898        fetch_direct_original_ranges,
899        fetch_direct_merged_ranges,
900        planned_fetch_batches,
901        planned_segment_waves,
902        segment_admission_limit,
903        segment_admission_candidate_limit,
904        segment_admission_source,
905        segment_admission_candidate_reason,
906        segment_admission_adaptive_enabled,
907        segment_admission_profile_segment_count,
908        segment_admission_profile_complete,
909        segment_admission_p95_segment_bytes,
910        segment_admission_max_segment_bytes,
911        segment_admission_largest_window_bytes,
912        max_segments_per_fetch_batch,
913        max_segments_per_wave,
914        peak_active_segments,
915        pipeline_budget_bytes,
916        pipeline_peak_decoded_bytes,
917        pipeline_byte_waits,
918        segment_admission_waits,
919        pipeline_stall_breaker_activations,
920    } = snap;
921
922    #[expect(
923        clippy::cast_possible_wrap,
924        reason = "OTLP proto uses i64 for int values"
925    )]
926    let mut attributes = vec![
927        kv_string("dataset_id", dataset_id),
928        kv_int("query_chunks", *query_chunks as i64),
929        kv_int("query_segments", *query_segments as i64),
930        kv_int("query_layers", *query_layers as i64),
931        kv_int("query_columns", *query_columns as i64),
932        kv_int("query_entities", *query_entities as i64),
933        kv_int("query_bytes", *query_bytes as i64),
934        kv_int(
935            "query_chunks_per_segment_min",
936            i64::from(*query_chunks_per_segment_min),
937        ),
938        kv_int(
939            "query_chunks_per_segment_max",
940            i64::from(*query_chunks_per_segment_max),
941        ),
942        kv_double(
943            "query_chunks_per_segment_mean",
944            f64::from(*query_chunks_per_segment_mean),
945        ),
946        kv_string("query_type", query_type.as_str()),
947        kv_int("total_duration_us", total_duration.as_micros() as i64),
948        kv_bool("is_success", error_kind.is_none()),
949        // Fetch stats: gRPC
950        kv_int("fetch_grpc_requests", *fetch_grpc_requests as i64),
951        kv_int("fetch_grpc_bytes", *fetch_grpc_bytes as i64),
952        // Fetch stats: direct (HTTP). Note: gRPC retries happen at the transport
953        // layer and are not visible here — only direct-URL retries are counted.
954        kv_int("fetch_direct_requests", *fetch_direct_requests as i64),
955        kv_int("fetch_direct_bytes", *fetch_direct_bytes as i64),
956        kv_int("fetch_direct_retries", *fetch_direct_retries as i64),
957        kv_int(
958            "fetch_direct_requests_retried",
959            *fetch_direct_requests_retried as i64,
960        ),
961        kv_int(
962            "fetch_direct_retry_sleep_us",
963            fetch_direct_retry_sleep.as_micros() as i64,
964        ),
965        kv_int("fetch_direct_max_attempt", *fetch_direct_max_attempt as i64),
966        kv_int(
967            "fetch_direct_original_ranges",
968            *fetch_direct_original_ranges as i64,
969        ),
970        kv_int(
971            "fetch_direct_merged_ranges",
972            *fetch_direct_merged_ranges as i64,
973        ),
974        kv_int("planned_fetch_batches", *planned_fetch_batches as i64),
975        kv_int("planned_segment_waves", *planned_segment_waves as i64),
976        kv_int("segment_admission_limit", *segment_admission_limit as i64),
977        kv_int(
978            "segment_admission_candidate_limit",
979            *segment_admission_candidate_limit as i64,
980        ),
981        kv_string("segment_admission_source", segment_admission_source),
982        kv_string(
983            "segment_admission_candidate_reason",
984            segment_admission_candidate_reason,
985        ),
986        kv_bool(
987            "segment_admission_adaptive_enabled",
988            *segment_admission_adaptive_enabled,
989        ),
990        kv_int(
991            "segment_admission_profile_segment_count",
992            *segment_admission_profile_segment_count as i64,
993        ),
994        kv_bool(
995            "segment_admission_profile_complete",
996            *segment_admission_profile_complete,
997        ),
998        kv_int(
999            "segment_admission_p95_segment_bytes",
1000            *segment_admission_p95_segment_bytes as i64,
1001        ),
1002        kv_int(
1003            "segment_admission_max_segment_bytes",
1004            *segment_admission_max_segment_bytes as i64,
1005        ),
1006        kv_int(
1007            "segment_admission_largest_window_bytes",
1008            *segment_admission_largest_window_bytes as i64,
1009        ),
1010        kv_int(
1011            "max_segments_per_fetch_batch",
1012            *max_segments_per_fetch_batch as i64,
1013        ),
1014        kv_int("max_segments_per_wave", *max_segments_per_wave as i64),
1015        kv_int("peak_active_segments", *peak_active_segments as i64),
1016        kv_int("pipeline_budget_bytes", *pipeline_budget_bytes as i64),
1017        kv_int(
1018            "pipeline_peak_decoded_bytes",
1019            *pipeline_peak_decoded_bytes as i64,
1020        ),
1021        kv_int("pipeline_byte_waits", *pipeline_byte_waits as i64),
1022        kv_int("segment_admission_waits", *segment_admission_waits as i64),
1023        kv_int(
1024            "pipeline_stall_breaker_activations",
1025            *pipeline_stall_breaker_activations as i64,
1026        ),
1027        kv_int("filters_pushed_down", *filters_pushed_down as i64),
1028        kv_int(
1029            "filters_applied_client_side",
1030            *filters_applied_client_side as i64,
1031        ),
1032        kv_bool(
1033            "entity_path_narrowing_applied",
1034            *entity_path_narrowing_applied,
1035        ),
1036    ];
1037
1038    if *filters_total > 0 {
1039        attributes.push(kv_int("filters_total", i64::from(*filters_total)));
1040    }
1041    if !filters_signatures.is_empty() {
1042        attributes.push(kv_string("filters_signatures", filters_signatures));
1043    }
1044    if !filters_signatures_exact.is_empty() {
1045        attributes.push(kv_string(
1046            "filters_signatures_exact",
1047            filters_signatures_exact,
1048        ));
1049    }
1050    if !filters_signatures_inexact.is_empty() {
1051        attributes.push(kv_string(
1052            "filters_signatures_inexact",
1053            filters_signatures_inexact,
1054        ));
1055    }
1056    if !filters_signatures_unsupported.is_empty() {
1057        attributes.push(kv_string(
1058            "filters_signatures_unsupported",
1059            filters_signatures_unsupported,
1060        ));
1061    }
1062
1063    if let Some(name) = primary_index_name.as_deref() {
1064        attributes.push(kv_string("primary_index_name", name));
1065    }
1066
1067    if let Some(ttfci) = time_to_first_chunk_info {
1068        attributes.push(kv_int(
1069            "time_to_first_chunk_info_us",
1070            ttfci.as_micros() as i64,
1071        ));
1072    }
1073
1074    if let Some(ttfr) = time_to_first_chunk {
1075        attributes.push(kv_int("time_to_first_chunk_us", ttfr.as_micros() as i64));
1076    }
1077
1078    if let Some(reason) = direct_terminal_reason {
1079        attributes.push(kv_string("fetch_direct_terminal_reason", reason.as_str()));
1080    }
1081
1082    if let Some(kind) = error_kind {
1083        attributes.push(kv_string("error_kind", kind));
1084    }
1085
1086    Span {
1087        name: "cloud_query_dataset".to_owned(),
1088        kind: SpanKind::Client.into(),
1089        start_time_unix_nano,
1090        end_time_unix_nano,
1091        attributes,
1092        ..Default::default()
1093    }
1094}
1095
1096// ----------------------------------------------------------------------------
1097// Table-scan analytics
1098//
1099// Mirrors the dataset-query analytics above but for `ScanTable` calls, which
1100// flow through `TableEntryTableProvider` / `GrpcStreamProvider`. Lance and
1101// other server-side scan stats (rows_scanned, fragments_*, …) are not
1102// reachable from the client today and will be added via server-side OTLP
1103// span enrichment in a follow-up.
1104
1105/// Produce a SQL representation of a DataFusion filter expression for analytics.
1106///
1107/// Falls back to `variant_name()` for expressions the SQL unparser doesn't support.
1108/// The result is escaped so that individual signatures can be safely joined with `;`.
1109pub(crate) fn expr_filter_signature(expr: &Expr) -> String {
1110    let sql = datafusion::sql::unparser::expr_to_sql(expr)
1111        .map(|e| e.to_string())
1112        .unwrap_or_else(|_| expr.variant_name().to_owned());
1113    escape_sig_str(&sql)
1114}
1115
1116/// Escape `\` and `;` so individual signatures can be safely joined with `;`.
1117fn escape_sig_str(s: &str) -> String {
1118    s.replace('\\', "\\\\").replace(';', "\\;")
1119}
1120
1121/// Underlying provider variant of a table entry.
1122///
1123/// Bounded so the analytics dimension cardinality stays low. Add a variant if
1124/// the catalog server exposes a new system or storage backend.
1125#[derive(Clone, Copy, Debug)]
1126pub enum TableKind {
1127    /// User-registered Lance-backed table.
1128    Lance,
1129
1130    /// `__entries`: the catalog's entry list.
1131    SystemEntries,
1132
1133    /// `__namespaces`: not currently used.
1134    SystemNamespaces,
1135
1136    /// Caller did not (or could not) determine the kind without an extra RPC.
1137    Unknown,
1138}
1139
1140impl TableKind {
1141    /// Stable string label emitted into the analytics span.
1142    const fn as_str(self) -> &'static str {
1143        match self {
1144            Self::Lance => "lance",
1145            Self::SystemEntries => "system_entries",
1146            Self::SystemNamespaces => "system_namespaces",
1147            Self::Unknown => "unknown",
1148        }
1149    }
1150}
1151
1152impl From<&ProviderDetails> for TableKind {
1153    fn from(details: &ProviderDetails) -> Self {
1154        match details {
1155            ProviderDetails::LanceTable(_) => Self::Lance,
1156            ProviderDetails::SystemTable(t) => match t.kind {
1157                SystemTableKind::Entries => Self::SystemEntries,
1158                SystemTableKind::Namespaces => Self::SystemNamespaces,
1159                SystemTableKind::Unspecified => Self::Unknown,
1160            },
1161        }
1162    }
1163}
1164
1165/// Where a table scan was initiated from.
1166///
1167/// Bounded enum to keep the analytics dimension cardinality low. Each new
1168/// caller (e.g. a future programmatic API) should add a variant.
1169#[derive(Clone, Copy, Debug)]
1170pub enum TableQueryCaller {
1171    /// `RedapCatalogProvider` resolving a name through DataFusion's `SessionContext`.
1172    /// Typically fires from SQL queries issued via the Python SDK.
1173    CatalogResolver,
1174
1175    /// `__entries` system-table scan (catalog browsing).
1176    EntriesTable,
1177
1178    /// Viewer table-detail UI in `re_redap_browser`.
1179    BrowserDetailView,
1180}
1181
1182impl TableQueryCaller {
1183    /// Stable string label emitted into the analytics span.
1184    const fn as_str(self) -> &'static str {
1185        match self {
1186            Self::CatalogResolver => "catalog_resolver",
1187            Self::EntriesTable => "entries_table",
1188            Self::BrowserDetailView => "browser_detail_view",
1189        }
1190    }
1191}
1192
1193/// Information about the table-scan planning phase, collected in `scan()`.
1194#[derive(Clone, Debug)]
1195pub struct TableQueryInfo {
1196    /// Server-assigned id of the table being scanned. Stringified so it
1197    /// matches `dataset_id` formatting on the dataset-query span.
1198    pub table_id: String,
1199
1200    /// Underlying table kind. See [`TableKind`].
1201    pub table_kind: TableKind,
1202
1203    /// What initiated this scan. See [`TableQueryCaller`].
1204    pub caller: TableQueryCaller,
1205
1206    /// Number of fields in the table's full schema.
1207    pub schema_total_columns: u32,
1208
1209    /// Number of columns DataFusion asked the provider to produce. Equal to
1210    /// `schema_total_columns` when no projection is requested.
1211    pub projected_columns: u32,
1212
1213    /// `true` iff DataFusion provided a `LIMIT` to the scan.
1214    pub has_limit: bool,
1215
1216    /// The `LIMIT` value, when present.
1217    pub limit_value: Option<u64>,
1218
1219    /// Wall-clock start..end of the scan. End is set at `Drop` time.
1220    pub time_range: Range<SystemTime>,
1221
1222    /// Total number of filter exprs DataFusion offered to `supports_filters_pushdown`.
1223    /// Zero when the method was never called (no filters in the query).
1224    pub filters_total: u32,
1225
1226    /// Semicolon-delimited SQL representations of every offered filter, in
1227    /// the same order DataFusion supplied them. Empty when none were offered.
1228    /// See [`expr_filter_signature`].
1229    pub filters_signatures: String,
1230}
1231
1232/// Accumulates per-scan counters from the streaming-provider IO loop.
1233///
1234/// Not contended in practice today (one stream per scan) but kept atomic for
1235/// consistency with the dataset-query side and to leave room for parallelism.
1236#[derive(Default)]
1237pub(crate) struct SharedTableScanStats {
1238    grpc_requests: AtomicU64,
1239    batches: AtomicU64,
1240    rows_returned: AtomicU64,
1241    bytes_returned: AtomicU64,
1242}
1243
1244/// Snapshot of [`SharedTableScanStats`] taken at span-build time.
1245///
1246/// Pulled out so [`build_table_query_span`] can be a pure, easily-testable
1247/// function with no atomic loads of its own.
1248#[derive(Default, Clone, Copy)]
1249pub(crate) struct TableScanStatsSnapshot {
1250    pub grpc_requests: u64,
1251    pub batches: u64,
1252    pub rows_returned: u64,
1253    pub bytes_returned: u64,
1254}
1255
1256impl SharedTableScanStats {
1257    /// Take a snapshot of the counters using relaxed atomic loads.
1258    fn snapshot(&self) -> TableScanStatsSnapshot {
1259        TableScanStatsSnapshot {
1260            grpc_requests: self.grpc_requests.load(Ordering::Relaxed),
1261            batches: self.batches.load(Ordering::Relaxed),
1262            rows_returned: self.rows_returned.load(Ordering::Relaxed),
1263            bytes_returned: self.bytes_returned.load(Ordering::Relaxed),
1264        }
1265    }
1266}
1267
1268/// Tracks a table scan in progress. Cheap to clone (wraps an `Arc`).
1269///
1270/// The analytics event is emitted when the last clone is dropped.
1271#[derive(Clone)]
1272pub(crate) struct PendingTableQueryAnalytics {
1273    inner: Arc<PendingTableInner>,
1274}
1275
1276impl fmt::Debug for PendingTableQueryAnalytics {
1277    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1278        f.debug_struct("PendingTableQueryAnalytics")
1279            .finish_non_exhaustive()
1280    }
1281}
1282
1283struct PendingTableInner {
1284    connection: ConnectionAnalytics,
1285    info: TableQueryInfo,
1286    stats: SharedTableScanStats,
1287
1288    /// Monotonic start time of the scan, for computing elapsed durations.
1289    scan_start: Instant,
1290
1291    /// Time from `scan_start` until the first `ScanTableResponse` arrives.
1292    time_to_first_response: OnceLock<Duration>,
1293
1294    /// Time from `scan_start` until the first `RecordBatch` is yielded to
1295    /// DataFusion. In today's streaming-provider path this is essentially the
1296    /// same point as `time_to_first_response`; both fields are kept so the
1297    /// analytics schema stays meaningful if batch coalescing changes.
1298    time_to_first_batch: OnceLock<Duration>,
1299
1300    /// Server-side trace id from the `x-request-trace-id` response header on
1301    /// the `ScanTable` response.
1302    trace_id: OnceLock<opentelemetry::TraceId>,
1303
1304    /// Error classification, if the scan failed. `None` ⇒ success.
1305    /// Stored as `&'static str` from [`QueryErrorKind::as_str`] so emission is zero-copy.
1306    error_kind: OnceLock<&'static str>,
1307}
1308
1309impl PendingTableQueryAnalytics {
1310    /// Record the server-side trace id from the `ScanTable` response. Only the
1311    /// first call has effect.
1312    pub fn record_trace_id(&self, trace_id: opentelemetry::TraceId) {
1313        #[expect(clippy::let_underscore_must_use)]
1314        let _ = self.inner.trace_id.set(trace_id);
1315    }
1316
1317    /// Record that the first `ScanTableResponse` has arrived from gRPC. Only
1318    /// the first call has effect.
1319    pub fn record_first_response(&self) {
1320        self.inner
1321            .time_to_first_response
1322            .get_or_init(|| self.inner.scan_start.elapsed());
1323    }
1324
1325    /// Record that the first `RecordBatch` has been yielded to DataFusion.
1326    /// Only the first call has effect.
1327    pub fn record_first_batch(&self) {
1328        self.inner
1329            .time_to_first_batch
1330            .get_or_init(|| self.inner.scan_start.elapsed());
1331    }
1332
1333    /// Record one received gRPC message and its decoded record batch.
1334    pub fn record_batch(&self, num_rows: u64, num_bytes: u64) {
1335        self.inner
1336            .stats
1337            .grpc_requests
1338            .fetch_add(1, Ordering::Relaxed);
1339        self.inner.stats.batches.fetch_add(1, Ordering::Relaxed);
1340        if num_rows != 0 {
1341            self.inner
1342                .stats
1343                .rows_returned
1344                .fetch_add(num_rows, Ordering::Relaxed);
1345        }
1346        if num_bytes != 0 {
1347            self.inner
1348                .stats
1349                .bytes_returned
1350                .fetch_add(num_bytes, Ordering::Relaxed);
1351        }
1352    }
1353
1354    /// Mark the scan as failed with the given error kind. Only the first call
1355    /// has effect.
1356    pub fn record_error(&self, kind: QueryErrorKind) {
1357        #[expect(clippy::let_underscore_must_use)]
1358        let _ = self.inner.error_kind.set(kind.as_str());
1359    }
1360
1361    /// Build the OTLP span using the current accumulated state, without
1362    /// dropping the analytics. Lets end-to-end tests inspect the post-stream
1363    /// span before the [`Drop`] impl runs.
1364    #[cfg(test)]
1365    pub(crate) fn build_span_for_test(&self) -> Span {
1366        let mut span = self.inner.build_span();
1367        assign_span_identity(&mut span, self.inner.trace_id.get().copied()).unwrap();
1368        span
1369    }
1370}
1371
1372impl PendingTableInner {
1373    /// Snapshot the inner state and produce the OTLP span. Used both by
1374    /// [`Drop`] and by tests (via `PendingTableQueryAnalytics::build_span_for_test()`).
1375    ///
1376    /// Reads `scan_start.elapsed()` for `total_duration_us` and `SystemTime::now()`
1377    /// for the span end time, so calling this twice produces slightly different
1378    /// timing values — that's fine, it's how Drop already behaves.
1379    fn build_span(&self) -> Span {
1380        let total_duration = self.scan_start.elapsed();
1381        let scan_end_wall = SystemTime::now();
1382        let stats = self.stats.snapshot();
1383        build_table_query_span(
1384            &self.info,
1385            stats,
1386            self.info.time_range.start..scan_end_wall,
1387            total_duration,
1388            self.time_to_first_response.get().copied(),
1389            self.time_to_first_batch.get().copied(),
1390            self.trace_id.get().copied(),
1391            self.error_kind.get().copied(),
1392        )
1393    }
1394}
1395
1396impl Drop for PendingTableInner {
1397    fn drop(&mut self) {
1398        let span = self.build_span();
1399        let trace_id = self.trace_id.get().copied();
1400        self.connection.send_span(span, trace_id);
1401    }
1402}
1403
1404/// Build the OTLP `cloud_scan_table` span from collected per-scan data.
1405///
1406/// Pure function — no I/O, no time reads. Extracted from `Drop for
1407/// PendingTableInner` so the exact attribute set the analytics pipeline relies
1408/// on can be locked down by unit tests; if a future change accidentally drops
1409/// or renames a field, the tests fail.
1410pub(crate) fn build_table_query_span(
1411    info: &TableQueryInfo,
1412    stats: TableScanStatsSnapshot,
1413    wall_clock_range: Range<SystemTime>,
1414    total_duration: Duration,
1415    time_to_first_response: Option<Duration>,
1416    time_to_first_batch: Option<Duration>,
1417    _trace_id: Option<opentelemetry::TraceId>,
1418    error_kind: Option<&'static str>,
1419) -> Span {
1420    let TableQueryInfo {
1421        ref table_id,
1422        table_kind,
1423        caller,
1424        schema_total_columns,
1425        projected_columns,
1426        has_limit,
1427        limit_value,
1428        time_range: _,
1429        filters_total,
1430        ref filters_signatures,
1431    } = *info;
1432
1433    let start_time_unix_nano = nanos_since_epoch(&wall_clock_range.start);
1434    let end_time_unix_nano = nanos_since_epoch(&wall_clock_range.end);
1435
1436    #[expect(
1437        clippy::cast_possible_wrap,
1438        reason = "OTLP proto uses i64 for int values"
1439    )]
1440    let mut attributes = vec![
1441        // Identification
1442        kv_string("table_id", table_id),
1443        kv_string("table_kind", table_kind.as_str()),
1444        kv_string("caller", caller.as_str()),
1445        // Schema / projection
1446        kv_int("schema_total_columns", i64::from(schema_total_columns)),
1447        kv_int("projected_columns", i64::from(projected_columns)),
1448        // Limit
1449        kv_bool("has_limit", has_limit),
1450        // Outcome
1451        kv_bool("is_success", error_kind.is_none()),
1452        // Timing
1453        kv_int("total_duration_us", total_duration.as_micros() as i64),
1454        // gRPC
1455        kv_int("fetch_grpc_requests", stats.grpc_requests as i64),
1456        // Result size
1457        kv_int("num_record_batches", stats.batches as i64),
1458        kv_int("rows_returned", stats.rows_returned as i64),
1459        kv_int("bytes_returned", stats.bytes_returned as i64),
1460    ];
1461
1462    if let Some(value) = limit_value {
1463        #[expect(
1464            clippy::cast_possible_wrap,
1465            reason = "OTLP proto uses i64 for int values"
1466        )]
1467        attributes.push(kv_int("limit_value", value as i64));
1468    }
1469
1470    if let Some(ttfr) = time_to_first_response {
1471        attributes.push(kv_int("time_to_first_response_us", ttfr.as_micros() as i64));
1472    }
1473
1474    if let Some(ttfb) = time_to_first_batch {
1475        attributes.push(kv_int("time_to_first_batch_us", ttfb.as_micros() as i64));
1476    }
1477
1478    if let Some(kind) = error_kind {
1479        attributes.push(kv_string("error_kind", kind));
1480    }
1481
1482    if filters_total > 0 {
1483        attributes.push(kv_int("filters_total", i64::from(filters_total)));
1484    }
1485    if !filters_signatures.is_empty() {
1486        attributes.push(kv_string("filters_signatures", filters_signatures));
1487    }
1488
1489    Span {
1490        name: "cloud_scan_table".to_owned(),
1491        kind: SpanKind::Client.into(),
1492        start_time_unix_nano,
1493        end_time_unix_nano,
1494        attributes,
1495        ..Default::default()
1496    }
1497}
1498
1499// ----------------------------------------------------------------------------
1500
1501fn nanos_since_epoch(time: &SystemTime) -> u64 {
1502    time.duration_since(SystemTime::UNIX_EPOCH)
1503        .unwrap_or_default()
1504        .as_nanos() as u64
1505}
1506
1507fn kv_string(key: &str, value: &str) -> KeyValue {
1508    KeyValue {
1509        key: key.to_owned(),
1510        value: Some(AnyValue {
1511            value: Some(Value::StringValue(value.to_owned())),
1512        }),
1513        key_strindex: 0,
1514    }
1515}
1516
1517fn kv_int(key: &str, value: impl Into<i64>) -> KeyValue {
1518    KeyValue {
1519        key: key.to_owned(),
1520        value: Some(AnyValue {
1521            value: Some(Value::IntValue(value.into())),
1522        }),
1523        key_strindex: 0,
1524    }
1525}
1526
1527fn kv_bool(key: &str, value: bool) -> KeyValue {
1528    KeyValue {
1529        key: key.to_owned(),
1530        value: Some(AnyValue {
1531            value: Some(Value::BoolValue(value)),
1532        }),
1533        key_strindex: 0,
1534    }
1535}
1536
1537fn kv_double(key: &str, value: f64) -> KeyValue {
1538    KeyValue {
1539        key: key.to_owned(),
1540        value: Some(AnyValue {
1541            value: Some(Value::DoubleValue(value)),
1542        }),
1543        key_strindex: 0,
1544    }
1545}
1546
1547#[cfg(test)]
1548mod tests {
1549    use std::collections::HashSet;
1550
1551    use super::*;
1552
1553    fn dummy_query_info() -> QueryInfo {
1554        QueryInfo {
1555            dataset_id: "ds-123".to_owned(),
1556            query_chunks: 42,
1557            query_segments: 5,
1558            query_layers: 2,
1559            query_columns: 7,
1560            query_entities: 3,
1561            query_bytes: 1234,
1562            query_chunks_per_segment_min: 4,
1563            query_chunks_per_segment_max: 12,
1564            query_chunks_per_segment_mean: 8.4,
1565            query_type: QueryType::LatestAt,
1566            primary_index_name: None,
1567            time_to_first_chunk_info: None,
1568            trace_id: None,
1569            filters_pushed_down: 0,
1570            filters_applied_client_side: 0,
1571            entity_path_narrowing_applied: false,
1572            filters_total: 0,
1573            filters_signatures: String::new(),
1574            filters_signatures_exact: String::new(),
1575            filters_signatures_inexact: String::new(),
1576            filters_signatures_unsupported: String::new(),
1577        }
1578    }
1579
1580    #[test]
1581    fn assign_span_identity_uses_correlated_trace_id() {
1582        let trace_id = opentelemetry::TraceId::from_bytes([7; 16]);
1583        let mut span = Span::default();
1584
1585        assign_span_identity(&mut span, Some(trace_id)).unwrap();
1586
1587        assert_eq!(span.trace_id, trace_id.to_bytes());
1588        assert_eq!(span.span_id.len(), 8);
1589        assert!(span.span_id.iter().any(|byte| *byte != 0));
1590    }
1591
1592    #[test]
1593    fn assign_span_identity_generates_trace_id_without_correlation() {
1594        let mut span = Span::default();
1595
1596        assign_span_identity(&mut span, None).unwrap();
1597
1598        assert_eq!(span.trace_id.len(), 16);
1599        assert!(span.trace_id.iter().any(|byte| *byte != 0));
1600        assert_eq!(span.span_id.len(), 8);
1601        assert!(span.span_id.iter().any(|byte| *byte != 0));
1602    }
1603
1604    fn attribute_keys(span: &Span) -> HashSet<&str> {
1605        let keys: HashSet<_> = span.attributes.iter().map(|kv| kv.key.as_str()).collect();
1606        re_log::debug_assert_eq!(
1607            keys.len(),
1608            span.attributes.len(),
1609            "span contains duplicate attribute keys"
1610        );
1611        keys
1612    }
1613
1614    fn find_int(span: &Span, key: &str) -> Option<i64> {
1615        span.attributes
1616            .iter()
1617            .find(|kv| kv.key == key)
1618            .and_then(|kv| match kv.value.as_ref()?.value.as_ref()? {
1619                Value::IntValue(i) => Some(*i),
1620                _ => None,
1621            })
1622    }
1623
1624    fn find_string<'a>(span: &'a Span, key: &str) -> Option<&'a str> {
1625        span.attributes
1626            .iter()
1627            .find(|kv| kv.key == key)
1628            .and_then(|kv| match kv.value.as_ref()?.value.as_ref()? {
1629                Value::StringValue(s) => Some(s.as_str()),
1630                _ => None,
1631            })
1632    }
1633
1634    fn find_double(span: &Span, key: &str) -> Option<f64> {
1635        span.attributes
1636            .iter()
1637            .find(|kv| kv.key == key)
1638            .and_then(|kv| match kv.value.as_ref()?.value.as_ref()? {
1639                Value::DoubleValue(d) => Some(*d),
1640                _ => None,
1641            })
1642    }
1643
1644    fn find_bool(span: &Span, key: &str) -> Option<bool> {
1645        span.attributes
1646            .iter()
1647            .find(|kv| kv.key == key)
1648            .and_then(|kv| match kv.value.as_ref()?.value.as_ref()? {
1649                Value::BoolValue(b) => Some(*b),
1650                _ => None,
1651            })
1652    }
1653
1654    /// Required attributes that must always be emitted, regardless of query state.
1655    /// Adding/removing one of these is a breaking change for the analytics pipeline.
1656    const REQUIRED_KEYS: &[&str] = &[
1657        "dataset_id",
1658        "query_chunks",
1659        "query_segments",
1660        "query_layers",
1661        "query_columns",
1662        "query_entities",
1663        "query_bytes",
1664        "query_chunks_per_segment_min",
1665        "query_chunks_per_segment_max",
1666        "query_chunks_per_segment_mean",
1667        "query_type",
1668        "total_duration_us",
1669        "is_success",
1670        "fetch_grpc_requests",
1671        "fetch_grpc_bytes",
1672        "fetch_direct_requests",
1673        "fetch_direct_bytes",
1674        "fetch_direct_retries",
1675        "fetch_direct_requests_retried",
1676        "fetch_direct_retry_sleep_us",
1677        "fetch_direct_max_attempt",
1678        "fetch_direct_original_ranges",
1679        "fetch_direct_merged_ranges",
1680        "planned_fetch_batches",
1681        "planned_segment_waves",
1682        "segment_admission_limit",
1683        "segment_admission_candidate_limit",
1684        "segment_admission_source",
1685        "segment_admission_candidate_reason",
1686        "segment_admission_adaptive_enabled",
1687        "segment_admission_profile_segment_count",
1688        "segment_admission_profile_complete",
1689        "segment_admission_p95_segment_bytes",
1690        "segment_admission_max_segment_bytes",
1691        "segment_admission_largest_window_bytes",
1692        "max_segments_per_fetch_batch",
1693        "max_segments_per_wave",
1694        "peak_active_segments",
1695        "pipeline_budget_bytes",
1696        "pipeline_peak_decoded_bytes",
1697        "pipeline_byte_waits",
1698        "segment_admission_waits",
1699        "pipeline_stall_breaker_activations",
1700        "filters_pushed_down",
1701        "filters_applied_client_side",
1702        "entity_path_narrowing_applied",
1703    ];
1704
1705    /// Build a fresh `QuerySnapshot` mirroring `dummy_query_info()`, with no
1706    /// execution data — analogous to the pre-refactor `TaskFetchStats::default`
1707    /// fixture.
1708    fn snapshot_from_info(query_info: QueryInfo) -> QuerySnapshot {
1709        QuerySnapshot {
1710            query_info,
1711            total_duration: Duration::ZERO,
1712            time_to_first_chunk: None,
1713            error_kind: None,
1714            direct_terminal_reason: None,
1715            fetch_grpc_requests: 0,
1716            fetch_grpc_bytes: 0,
1717            fetch_direct_requests: 0,
1718            fetch_direct_bytes: 0,
1719            fetch_direct_retries: 0,
1720            fetch_direct_requests_retried: 0,
1721            fetch_direct_retry_sleep: Duration::ZERO,
1722            fetch_direct_max_attempt: 0,
1723            fetch_direct_original_ranges: 0,
1724            fetch_direct_merged_ranges: 0,
1725            planned_fetch_batches: 0,
1726            planned_segment_waves: 0,
1727            segment_admission_limit: 0,
1728            segment_admission_candidate_limit: 0,
1729            segment_admission_source: "metrics_only",
1730            segment_admission_candidate_reason: "eligible",
1731            segment_admission_adaptive_enabled: false,
1732            segment_admission_profile_segment_count: 0,
1733            segment_admission_profile_complete: false,
1734            segment_admission_p95_segment_bytes: 0,
1735            segment_admission_max_segment_bytes: 0,
1736            segment_admission_largest_window_bytes: 0,
1737            max_segments_per_fetch_batch: 0,
1738            max_segments_per_wave: 0,
1739            peak_active_segments: 0,
1740            pipeline_budget_bytes: 0,
1741            pipeline_peak_decoded_bytes: 0,
1742            pipeline_byte_waits: 0,
1743            segment_admission_waits: 0,
1744            pipeline_stall_breaker_activations: 0,
1745        }
1746    }
1747
1748    #[test]
1749    fn build_query_span_minimal_emits_only_required_attributes() {
1750        let qi = dummy_query_info();
1751        let mut snap = snapshot_from_info(qi);
1752        snap.total_duration = Duration::from_micros(500);
1753
1754        let span = build_query_span(
1755            &snap,
1756            SystemTime::UNIX_EPOCH..SystemTime::UNIX_EPOCH + Duration::from_secs(1),
1757        );
1758
1759        // Span shape
1760        assert_eq!(span.name, "cloud_query_dataset");
1761        assert_eq!(span.kind, i32::from(SpanKind::Client));
1762        assert!(span.links.is_empty());
1763
1764        // Attribute key set is exactly the required keys — no optional keys leaked.
1765        let expected: HashSet<&str> = REQUIRED_KEYS.iter().copied().collect();
1766        let actual = attribute_keys(&span);
1767        assert_eq!(
1768            actual,
1769            expected,
1770            "extra/missing attribute keys: {:?}",
1771            actual.symmetric_difference(&expected).collect::<Vec<_>>()
1772        );
1773
1774        // Spot-check a few values.
1775        assert_eq!(find_string(&span, "dataset_id"), Some("ds-123"));
1776        assert_eq!(find_int(&span, "query_chunks"), Some(42));
1777        assert_eq!(find_int(&span, "query_chunks_per_segment_min"), Some(4));
1778        assert_eq!(find_int(&span, "query_chunks_per_segment_max"), Some(12));
1779        assert_eq!(
1780            find_double(&span, "query_chunks_per_segment_mean"),
1781            Some(f64::from(8.4_f32))
1782        );
1783        assert_eq!(find_string(&span, "query_type"), Some("latest_at"));
1784        assert_eq!(find_int(&span, "total_duration_us"), Some(500));
1785        assert_eq!(find_bool(&span, "is_success"), Some(true));
1786    }
1787
1788    #[test]
1789    fn build_query_span_records_fetch_stats() {
1790        let qi = dummy_query_info();
1791        let mut snap = snapshot_from_info(qi);
1792        snap.total_duration = Duration::from_millis(1);
1793        snap.fetch_grpc_requests = 2;
1794        snap.fetch_grpc_bytes = 5_000;
1795        snap.fetch_direct_requests = 1;
1796        snap.fetch_direct_bytes = 10_000;
1797        snap.fetch_direct_retries = 2;
1798        snap.fetch_direct_requests_retried = 1;
1799        snap.fetch_direct_retry_sleep = Duration::from_millis(12);
1800        snap.fetch_direct_max_attempt = 3;
1801        snap.fetch_direct_original_ranges = 8;
1802        snap.fetch_direct_merged_ranges = 4;
1803        snap.planned_fetch_batches = 16;
1804        snap.planned_segment_waves = 1_332;
1805        snap.segment_admission_limit = 3;
1806        snap.segment_admission_candidate_limit = 16;
1807        snap.segment_admission_source = "metrics_only";
1808        snap.segment_admission_candidate_reason = "eligible";
1809        snap.segment_admission_adaptive_enabled = true;
1810        snap.segment_admission_profile_segment_count = 32;
1811        snap.segment_admission_profile_complete = true;
1812        snap.segment_admission_p95_segment_bytes = 1024;
1813        snap.segment_admission_max_segment_bytes = 2048;
1814        snap.segment_admission_largest_window_bytes = 16_384;
1815        snap.max_segments_per_fetch_batch = 3;
1816        snap.max_segments_per_wave = 3;
1817        snap.peak_active_segments = 3;
1818        snap.pipeline_budget_bytes = 4 * 1024 * 1024 * 1024;
1819        snap.pipeline_peak_decoded_bytes = 96 * 1024 * 1024;
1820        snap.pipeline_byte_waits = 2;
1821        snap.segment_admission_waits = 1_329;
1822        snap.pipeline_stall_breaker_activations = 1;
1823
1824        let span = build_query_span(
1825            &snap,
1826            SystemTime::UNIX_EPOCH..SystemTime::UNIX_EPOCH + Duration::from_secs(1),
1827        );
1828
1829        assert_eq!(find_int(&span, "fetch_grpc_requests"), Some(2));
1830        assert_eq!(find_int(&span, "fetch_grpc_bytes"), Some(5_000));
1831        assert_eq!(find_int(&span, "fetch_direct_requests"), Some(1));
1832        assert_eq!(find_int(&span, "fetch_direct_bytes"), Some(10_000));
1833        assert_eq!(find_int(&span, "fetch_direct_retries"), Some(2));
1834        assert_eq!(find_int(&span, "fetch_direct_requests_retried"), Some(1));
1835        assert_eq!(find_int(&span, "fetch_direct_retry_sleep_us"), Some(12_000));
1836        assert_eq!(find_int(&span, "fetch_direct_max_attempt"), Some(3));
1837        assert_eq!(find_int(&span, "fetch_direct_original_ranges"), Some(8));
1838        assert_eq!(find_int(&span, "fetch_direct_merged_ranges"), Some(4));
1839        assert_eq!(find_int(&span, "planned_fetch_batches"), Some(16));
1840        assert_eq!(find_int(&span, "planned_segment_waves"), Some(1_332));
1841        assert_eq!(find_int(&span, "segment_admission_limit"), Some(3));
1842        assert_eq!(
1843            find_int(&span, "segment_admission_candidate_limit"),
1844            Some(16)
1845        );
1846        assert_eq!(
1847            find_string(&span, "segment_admission_source"),
1848            Some("metrics_only")
1849        );
1850        assert_eq!(
1851            find_string(&span, "segment_admission_candidate_reason"),
1852            Some("eligible")
1853        );
1854        assert_eq!(
1855            find_bool(&span, "segment_admission_adaptive_enabled"),
1856            Some(true)
1857        );
1858        assert_eq!(
1859            find_int(&span, "segment_admission_profile_segment_count"),
1860            Some(32)
1861        );
1862        assert_eq!(
1863            find_bool(&span, "segment_admission_profile_complete"),
1864            Some(true)
1865        );
1866        assert_eq!(
1867            find_int(&span, "segment_admission_p95_segment_bytes"),
1868            Some(1024)
1869        );
1870        assert_eq!(
1871            find_int(&span, "segment_admission_max_segment_bytes"),
1872            Some(2048)
1873        );
1874        assert_eq!(
1875            find_int(&span, "segment_admission_largest_window_bytes"),
1876            Some(16_384)
1877        );
1878        assert_eq!(find_int(&span, "max_segments_per_fetch_batch"), Some(3));
1879        assert_eq!(find_int(&span, "max_segments_per_wave"), Some(3));
1880        assert_eq!(find_int(&span, "peak_active_segments"), Some(3));
1881        assert_eq!(
1882            find_int(&span, "pipeline_budget_bytes"),
1883            Some(4 * 1024 * 1024 * 1024)
1884        );
1885        assert_eq!(
1886            find_int(&span, "pipeline_peak_decoded_bytes"),
1887            Some(96 * 1024 * 1024)
1888        );
1889        assert_eq!(find_int(&span, "pipeline_byte_waits"), Some(2));
1890        assert_eq!(find_int(&span, "segment_admission_waits"), Some(1_329));
1891        assert_eq!(
1892            find_int(&span, "pipeline_stall_breaker_activations"),
1893            Some(1)
1894        );
1895    }
1896
1897    #[test]
1898    fn build_query_span_emits_all_optional_attributes_when_present() {
1899        let trace_id = opentelemetry::TraceId::from_bytes([7u8; 16]);
1900        let mut qi = dummy_query_info();
1901        qi.primary_index_name = Some("log_time".to_owned());
1902        qi.time_to_first_chunk_info = Some(Duration::from_micros(123));
1903        qi.trace_id = Some(trace_id);
1904
1905        let mut snap = snapshot_from_info(qi);
1906        snap.total_duration = Duration::from_micros(999);
1907        snap.time_to_first_chunk = Some(Duration::from_micros(456));
1908        snap.direct_terminal_reason = Some(DirectFetchFailureReason::Http5xx);
1909        snap.error_kind = Some(QueryErrorKind::DirectFetch.as_str());
1910
1911        let span = build_query_span(
1912            &snap,
1913            SystemTime::UNIX_EPOCH..SystemTime::UNIX_EPOCH + Duration::from_secs(1),
1914        );
1915
1916        // Optional keys must all be present.
1917        let optional = [
1918            "primary_index_name",
1919            "time_to_first_chunk_info_us",
1920            "time_to_first_chunk_us",
1921            "fetch_direct_terminal_reason",
1922            "error_kind",
1923        ];
1924        let keys = attribute_keys(&span);
1925        for k in optional {
1926            assert!(keys.contains(k), "missing optional attribute: {k}");
1927        }
1928
1929        // is_success flips to false when error_kind is set.
1930        assert_eq!(find_bool(&span, "is_success"), Some(false));
1931
1932        assert_eq!(find_string(&span, "primary_index_name"), Some("log_time"));
1933        assert_eq!(find_int(&span, "time_to_first_chunk_info_us"), Some(123));
1934        assert_eq!(find_int(&span, "time_to_first_chunk_us"), Some(456));
1935        assert_eq!(
1936            find_string(&span, "fetch_direct_terminal_reason"),
1937            Some("http_5xx")
1938        );
1939        assert_eq!(find_string(&span, "error_kind"), Some("direct_fetch"));
1940        assert!(span.links.is_empty());
1941    }
1942
1943    /// Confirms the planning-phase metrics that `EXPLAIN ANALYZE` reads are
1944    /// all surfaced by [`build_metrics_set_for_explain`]. Regression check
1945    /// that `_min` shows up — prior to the alignment fix only `_max` was
1946    /// surfaced.
1947    #[test]
1948    fn explain_metrics_set_includes_chunks_per_segment_min_and_max() {
1949        let metrics = QueryMetrics::new(dummy_query_info());
1950        let set = build_metrics_set_for_explain(&metrics, 1, None);
1951
1952        let aggregated = set.aggregate_by_name();
1953        let names: std::collections::HashSet<_> = aggregated
1954            .iter()
1955            .filter_map(|m| match m.value() {
1956                datafusion::physical_plan::metrics::MetricValue::Count { name, .. } => {
1957                    Some(name.as_ref().to_owned())
1958                }
1959                _ => None,
1960            })
1961            .collect();
1962
1963        assert!(
1964            names.contains("query_chunks_per_segment_min"),
1965            "expected query_chunks_per_segment_min in explain metrics: {names:?}"
1966        );
1967        assert!(
1968            names.contains("query_chunks_per_segment_max"),
1969            "expected query_chunks_per_segment_max in explain metrics: {names:?}"
1970        );
1971    }
1972
1973    #[test]
1974    fn build_query_span_uses_wall_clock_range() {
1975        let qi = dummy_query_info();
1976        let snap = snapshot_from_info(qi);
1977        let start = SystemTime::UNIX_EPOCH + Duration::from_secs(2);
1978        let end = SystemTime::UNIX_EPOCH + Duration::from_millis(2_500);
1979
1980        let span = build_query_span(&snap, start..end);
1981
1982        assert_eq!(span.start_time_unix_nano, 2_000_000_000);
1983        assert_eq!(span.end_time_unix_nano, 2_500_000_000);
1984    }
1985}
1986
1987#[cfg(test)]
1988mod table_query_tests {
1989    use std::collections::HashSet;
1990
1991    use re_protos::cloud::v1alpha1::ext::{LanceTable, ProviderDetails, SystemTable};
1992
1993    use super::*;
1994
1995    fn lance_provider_details() -> ProviderDetails {
1996        // Construct via the protobuf type so we don't need a direct `url`
1997        // dependency in this crate just for tests.
1998        let proto = re_protos::cloud::v1alpha1::LanceTable {
1999            table_url: "s3://bucket/path".to_owned(),
2000        };
2001        ProviderDetails::LanceTable(LanceTable::try_from(proto).unwrap())
2002    }
2003
2004    // ---- helpers ----
2005
2006    fn dummy_table_query_info() -> TableQueryInfo {
2007        TableQueryInfo {
2008            table_id: "tbl-42".to_owned(),
2009            table_kind: TableKind::Lance,
2010            caller: TableQueryCaller::BrowserDetailView,
2011            schema_total_columns: 12,
2012            projected_columns: 5,
2013            has_limit: false,
2014            limit_value: None,
2015            time_range: SystemTime::UNIX_EPOCH..SystemTime::UNIX_EPOCH + Duration::from_secs(1),
2016            filters_total: 0,
2017            filters_signatures: String::new(),
2018        }
2019    }
2020
2021    fn empty_stats() -> TableScanStatsSnapshot {
2022        TableScanStatsSnapshot::default()
2023    }
2024
2025    fn attribute_keys(span: &Span) -> HashSet<&str> {
2026        let keys: HashSet<_> = span.attributes.iter().map(|kv| kv.key.as_str()).collect();
2027        assert_eq!(
2028            keys.len(),
2029            span.attributes.len(),
2030            "span contains duplicate attribute keys"
2031        );
2032        keys
2033    }
2034
2035    fn find_int(span: &Span, key: &str) -> Option<i64> {
2036        span.attributes
2037            .iter()
2038            .find(|kv| kv.key == key)
2039            .and_then(|kv| match kv.value.as_ref()?.value.as_ref()? {
2040                Value::IntValue(i) => Some(*i),
2041                _ => None,
2042            })
2043    }
2044
2045    fn find_string<'a>(span: &'a Span, key: &str) -> Option<&'a str> {
2046        span.attributes
2047            .iter()
2048            .find(|kv| kv.key == key)
2049            .and_then(|kv| match kv.value.as_ref()?.value.as_ref()? {
2050                Value::StringValue(s) => Some(s.as_str()),
2051                _ => None,
2052            })
2053    }
2054
2055    fn find_bool(span: &Span, key: &str) -> Option<bool> {
2056        span.attributes
2057            .iter()
2058            .find(|kv| kv.key == key)
2059            .and_then(|kv| match kv.value.as_ref()?.value.as_ref()? {
2060                Value::BoolValue(b) => Some(*b),
2061                _ => None,
2062            })
2063    }
2064
2065    /// Required attributes that must always be emitted, regardless of scan
2066    /// outcome. Adding/removing one of these is a breaking change for the
2067    /// analytics pipeline (`PostHog` dashboards, server-side enrichment, etc.).
2068    const REQUIRED_KEYS: &[&str] = &[
2069        "table_id",
2070        "table_kind",
2071        "caller",
2072        "schema_total_columns",
2073        "projected_columns",
2074        "has_limit",
2075        "is_success",
2076        "total_duration_us",
2077        "fetch_grpc_requests",
2078        "num_record_batches",
2079        "rows_returned",
2080        "bytes_returned",
2081    ];
2082
2083    // ---- builder shape ----
2084
2085    #[test]
2086    fn build_table_query_span_minimal_emits_only_required_attributes() {
2087        let info = dummy_table_query_info();
2088
2089        let span = build_table_query_span(
2090            &info,
2091            empty_stats(),
2092            SystemTime::UNIX_EPOCH..SystemTime::UNIX_EPOCH + Duration::from_secs(1),
2093            Duration::from_micros(500),
2094            None,
2095            None,
2096            None,
2097            None,
2098        );
2099
2100        // Span shape
2101        assert_eq!(span.name, "cloud_scan_table");
2102        assert_eq!(span.kind, i32::from(SpanKind::Client));
2103        assert!(span.links.is_empty());
2104
2105        // Attribute key set is exactly the required keys — no optional keys leaked.
2106        let expected: HashSet<&str> = REQUIRED_KEYS.iter().copied().collect();
2107        let actual = attribute_keys(&span);
2108        assert_eq!(
2109            actual,
2110            expected,
2111            "extra/missing attribute keys: {:?}",
2112            actual.symmetric_difference(&expected).collect::<Vec<_>>()
2113        );
2114
2115        // Spot-check key values from the dummy info.
2116        assert_eq!(find_string(&span, "table_id"), Some("tbl-42"));
2117        assert_eq!(find_string(&span, "table_kind"), Some("lance"));
2118        assert_eq!(find_string(&span, "caller"), Some("browser_detail_view"));
2119        assert_eq!(find_int(&span, "schema_total_columns"), Some(12));
2120        assert_eq!(find_int(&span, "projected_columns"), Some(5));
2121        assert_eq!(find_bool(&span, "has_limit"), Some(false));
2122        assert_eq!(find_bool(&span, "is_success"), Some(true));
2123        assert_eq!(find_int(&span, "total_duration_us"), Some(500));
2124    }
2125
2126    #[test]
2127    fn build_table_query_span_records_scan_stats() {
2128        let info = dummy_table_query_info();
2129        let stats = TableScanStatsSnapshot {
2130            grpc_requests: 7,
2131            batches: 7,
2132            rows_returned: 12_345,
2133            bytes_returned: 4_567_890,
2134        };
2135
2136        let span = build_table_query_span(
2137            &info,
2138            stats,
2139            SystemTime::UNIX_EPOCH..SystemTime::UNIX_EPOCH + Duration::from_secs(1),
2140            Duration::from_millis(2),
2141            None,
2142            None,
2143            None,
2144            None,
2145        );
2146
2147        assert_eq!(find_int(&span, "fetch_grpc_requests"), Some(7));
2148        assert_eq!(find_int(&span, "num_record_batches"), Some(7));
2149        assert_eq!(find_int(&span, "rows_returned"), Some(12_345));
2150        assert_eq!(find_int(&span, "bytes_returned"), Some(4_567_890));
2151    }
2152
2153    #[test]
2154    fn build_table_query_span_emits_optional_attributes_when_present() {
2155        let trace_id = opentelemetry::TraceId::from_bytes([3u8; 16]);
2156        let mut info = dummy_table_query_info();
2157        info.has_limit = true;
2158        info.limit_value = Some(500);
2159
2160        let span = build_table_query_span(
2161            &info,
2162            empty_stats(),
2163            SystemTime::UNIX_EPOCH..SystemTime::UNIX_EPOCH + Duration::from_secs(1),
2164            Duration::from_millis(1),
2165            Some(Duration::from_micros(50)),
2166            Some(Duration::from_micros(75)),
2167            Some(trace_id),
2168            Some(QueryErrorKind::Decode.as_str()),
2169        );
2170
2171        // All optional keys are present.
2172        let optional = [
2173            "limit_value",
2174            "time_to_first_response_us",
2175            "time_to_first_batch_us",
2176            "error_kind",
2177        ];
2178        let keys = attribute_keys(&span);
2179        for k in optional {
2180            assert!(keys.contains(k), "missing optional attribute: {k}");
2181        }
2182
2183        // is_success flips to false when error_kind is set.
2184        assert_eq!(find_bool(&span, "is_success"), Some(false));
2185
2186        assert_eq!(find_int(&span, "limit_value"), Some(500));
2187        assert_eq!(find_int(&span, "time_to_first_response_us"), Some(50));
2188        assert_eq!(find_int(&span, "time_to_first_batch_us"), Some(75));
2189        assert_eq!(find_string(&span, "error_kind"), Some("decode"));
2190        assert!(span.links.is_empty());
2191    }
2192
2193    #[test]
2194    fn build_table_query_span_uses_wall_clock_range() {
2195        let info = dummy_table_query_info();
2196        let start = SystemTime::UNIX_EPOCH + Duration::from_secs(2);
2197        let end = SystemTime::UNIX_EPOCH + Duration::from_millis(2_500);
2198
2199        let span = build_table_query_span(
2200            &info,
2201            empty_stats(),
2202            start..end,
2203            Duration::from_micros(0),
2204            None,
2205            None,
2206            None,
2207            None,
2208        );
2209
2210        assert_eq!(span.start_time_unix_nano, 2_000_000_000);
2211        assert_eq!(span.end_time_unix_nano, 2_500_000_000);
2212    }
2213
2214    #[test]
2215    fn build_table_query_span_records_table_kind_and_caller_strings() {
2216        // Walk every variant — protects the bounded-enum string mapping from
2217        // accidental changes that would silently rename PostHog dimensions.
2218        let cases = [
2219            (TableKind::Lance, "lance"),
2220            (TableKind::SystemEntries, "system_entries"),
2221            (TableKind::SystemNamespaces, "system_namespaces"),
2222            (TableKind::Unknown, "unknown"),
2223        ];
2224        for (kind, expected) in cases {
2225            let mut info = dummy_table_query_info();
2226            info.table_kind = kind;
2227            let span = build_table_query_span(
2228                &info,
2229                empty_stats(),
2230                SystemTime::UNIX_EPOCH..SystemTime::UNIX_EPOCH,
2231                Duration::ZERO,
2232                None,
2233                None,
2234                None,
2235                None,
2236            );
2237            assert_eq!(find_string(&span, "table_kind"), Some(expected));
2238        }
2239
2240        let cases = [
2241            (TableQueryCaller::CatalogResolver, "catalog_resolver"),
2242            (TableQueryCaller::EntriesTable, "entries_table"),
2243            (TableQueryCaller::BrowserDetailView, "browser_detail_view"),
2244        ];
2245        for (caller, expected) in cases {
2246            let mut info = dummy_table_query_info();
2247            info.caller = caller;
2248            let span = build_table_query_span(
2249                &info,
2250                empty_stats(),
2251                SystemTime::UNIX_EPOCH..SystemTime::UNIX_EPOCH,
2252                Duration::ZERO,
2253                None,
2254                None,
2255                None,
2256                None,
2257            );
2258            assert_eq!(find_string(&span, "caller"), Some(expected));
2259        }
2260    }
2261
2262    #[test]
2263    fn build_table_query_span_no_limit_value_when_no_limit() {
2264        // has_limit defaults to false, limit_value to None — the optional
2265        // `limit_value` attribute must NOT be emitted.
2266        let info = dummy_table_query_info();
2267        let span = build_table_query_span(
2268            &info,
2269            empty_stats(),
2270            SystemTime::UNIX_EPOCH..SystemTime::UNIX_EPOCH,
2271            Duration::ZERO,
2272            None,
2273            None,
2274            None,
2275            None,
2276        );
2277        assert!(!attribute_keys(&span).contains("limit_value"));
2278    }
2279
2280    // ---- TableKind::from(&ProviderDetails) ----
2281
2282    #[test]
2283    fn table_kind_from_lance_provider() {
2284        assert!(matches!(
2285            TableKind::from(&lance_provider_details()),
2286            TableKind::Lance
2287        ));
2288    }
2289
2290    #[test]
2291    fn table_kind_from_system_entries_provider() {
2292        let pd = ProviderDetails::SystemTable(SystemTable {
2293            kind: SystemTableKind::Entries,
2294        });
2295        assert!(matches!(TableKind::from(&pd), TableKind::SystemEntries));
2296    }
2297
2298    #[test]
2299    fn table_kind_from_system_namespaces_provider() {
2300        let pd = ProviderDetails::SystemTable(SystemTable {
2301            kind: SystemTableKind::Namespaces,
2302        });
2303        assert!(matches!(TableKind::from(&pd), TableKind::SystemNamespaces));
2304    }
2305
2306    #[test]
2307    fn table_kind_from_system_unspecified_falls_back_to_unknown() {
2308        let pd = ProviderDetails::SystemTable(SystemTable {
2309            kind: SystemTableKind::Unspecified,
2310        });
2311        assert!(matches!(TableKind::from(&pd), TableKind::Unknown));
2312    }
2313
2314    // ---- record_* idempotence ----
2315    //
2316    // All `record_*` setters are advertised as "only the first call has effect".
2317    // These tests pin that contract; subsequent calls must not overwrite.
2318
2319    fn make_pending() -> PendingTableQueryAnalytics {
2320        let origin: Origin = "rerun+http://localhost:51234".parse().unwrap();
2321        let analytics = ConnectionAnalytics::disabled_for_test(origin);
2322        analytics.begin_table_query(dummy_table_query_info(), Instant::now())
2323    }
2324
2325    #[tokio::test]
2326    async fn record_first_response_is_once_only() {
2327        let pending = make_pending();
2328        pending.record_first_response();
2329        let first = pending.inner.time_to_first_response.get().copied().unwrap();
2330        std::thread::sleep(Duration::from_millis(2));
2331        pending.record_first_response();
2332        let second = pending.inner.time_to_first_response.get().copied().unwrap();
2333        assert_eq!(first, second, "second call must not overwrite");
2334    }
2335
2336    #[tokio::test]
2337    async fn record_first_batch_is_once_only() {
2338        let pending = make_pending();
2339        pending.record_first_batch();
2340        let first = pending.inner.time_to_first_batch.get().copied().unwrap();
2341        std::thread::sleep(Duration::from_millis(2));
2342        pending.record_first_batch();
2343        let second = pending.inner.time_to_first_batch.get().copied().unwrap();
2344        assert_eq!(first, second);
2345    }
2346
2347    #[tokio::test]
2348    async fn record_error_is_once_only() {
2349        let pending = make_pending();
2350        pending.record_error(QueryErrorKind::GrpcFetch);
2351        pending.record_error(QueryErrorKind::Decode);
2352        assert_eq!(
2353            pending.inner.error_kind.get().copied(),
2354            Some(QueryErrorKind::GrpcFetch.as_str())
2355        );
2356    }
2357
2358    #[tokio::test]
2359    async fn record_trace_id_is_once_only() {
2360        let pending = make_pending();
2361        let first = opentelemetry::TraceId::from_bytes([1u8; 16]);
2362        let second = opentelemetry::TraceId::from_bytes([2u8; 16]);
2363        pending.record_trace_id(first);
2364        pending.record_trace_id(second);
2365        assert_eq!(pending.inner.trace_id.get().copied(), Some(first));
2366    }
2367
2368    #[tokio::test]
2369    async fn record_batch_accumulates_across_calls() {
2370        let pending = make_pending();
2371        pending.record_batch(100, 1_000);
2372        pending.record_batch(50, 500);
2373        pending.record_batch(0, 0); // empty batch — still counts a request/batch
2374        let stats = pending.inner.stats.snapshot();
2375        assert_eq!(stats.grpc_requests, 3);
2376        assert_eq!(stats.batches, 3);
2377        assert_eq!(stats.rows_returned, 150);
2378        assert_eq!(stats.bytes_returned, 1_500);
2379    }
2380}
2381
2382#[cfg(test)]
2383mod explain_metrics_set_tests {
2384    //! Sanity-check that [`build_metrics_set_for_explain`] populates the
2385    //! `MetricsSet` with the values from [`QueryInfo`] and the runtime
2386    //! atomics on [`QueryMetrics`]. This is the contract that backs
2387    //! `EXPLAIN ANALYZE` output for `SegmentStreamExec`.
2388
2389    use super::*;
2390
2391    fn dummy_query_info(
2392        filters_pushed_down: usize,
2393        filters_applied_client_side: usize,
2394        entity_path_narrowing_applied: bool,
2395    ) -> QueryInfo {
2396        QueryInfo {
2397            dataset_id: "ds-test".to_owned(),
2398            query_chunks: 7,
2399            query_segments: 3,
2400            query_layers: 2,
2401            query_columns: 11,
2402            query_entities: 5,
2403            query_bytes: 12_345,
2404            query_chunks_per_segment_min: 1,
2405            query_chunks_per_segment_max: 4,
2406            query_chunks_per_segment_mean: 2.5,
2407            query_type: QueryType::LatestAt,
2408            primary_index_name: Some("log_time".to_owned()),
2409            time_to_first_chunk_info: Some(Duration::from_millis(2)),
2410            trace_id: None,
2411            filters_pushed_down,
2412            filters_applied_client_side,
2413            entity_path_narrowing_applied,
2414            filters_total: 0,
2415            filters_signatures: String::new(),
2416            filters_signatures_exact: String::new(),
2417            filters_signatures_inexact: String::new(),
2418            filters_signatures_unsupported: String::new(),
2419        }
2420    }
2421
2422    /// Helper to look up the value of a single `global_counter`-style metric by name.
2423    fn metric_value_by_name(set: &MetricsSet, name: &str) -> Option<usize> {
2424        set.iter()
2425            .find(|m| m.value().name() == name)
2426            .map(|m| m.value().as_usize())
2427    }
2428
2429    #[test]
2430    fn emits_chunk_segment_byte_counts() {
2431        let metrics = QueryMetrics::new(dummy_query_info(0, 0, false));
2432        let set = build_metrics_set_for_explain(&metrics, 1, None);
2433
2434        assert_eq!(metric_value_by_name(&set, "query_chunks"), Some(7));
2435        assert_eq!(metric_value_by_name(&set, "query_segments"), Some(3));
2436        assert_eq!(metric_value_by_name(&set, "query_layers"), Some(2));
2437        assert_eq!(metric_value_by_name(&set, "query_columns"), Some(11));
2438        assert_eq!(metric_value_by_name(&set, "query_entities"), Some(5));
2439        assert_eq!(metric_value_by_name(&set, "query_bytes"), Some(12_345));
2440        assert_eq!(
2441            metric_value_by_name(&set, "query_chunks_per_segment_max"),
2442            Some(4),
2443        );
2444        assert_eq!(
2445            metric_value_by_name(&set, "time_to_first_chunk_info_us"),
2446            Some(2_000),
2447        );
2448    }
2449
2450    #[test]
2451    fn emits_filter_pushdown_counters() {
2452        let metrics = QueryMetrics::new(dummy_query_info(2, 1, false));
2453        let set = build_metrics_set_for_explain(&metrics, 1, None);
2454
2455        assert_eq!(metric_value_by_name(&set, "filters_pushed_down"), Some(2));
2456        assert_eq!(
2457            metric_value_by_name(&set, "filters_applied_client_side"),
2458            Some(1),
2459        );
2460        // Boolean: only emitted when true. With `false` here the metric is absent.
2461        assert_eq!(
2462            metric_value_by_name(&set, "entity_path_narrowing_applied"),
2463            None,
2464        );
2465    }
2466
2467    #[test]
2468    fn emits_entity_path_narrowing_when_applied() {
2469        let metrics = QueryMetrics::new(dummy_query_info(0, 0, true));
2470        let set = build_metrics_set_for_explain(&metrics, 1, None);
2471
2472        assert_eq!(
2473            metric_value_by_name(&set, "entity_path_narrowing_applied"),
2474            Some(1),
2475        );
2476    }
2477
2478    #[test]
2479    fn emits_runtime_counters_and_partition_count() {
2480        let metrics = QueryMetrics::new(dummy_query_info(0, 0, false));
2481        // Simulate two partitions each contributing to the shared atomics.
2482        metrics.fetch_grpc_bytes.fetch_add(1_000, Ordering::Relaxed);
2483        metrics.fetch_grpc_bytes.fetch_add(2_500, Ordering::Relaxed);
2484        metrics
2485            .fetch_direct_max_attempt
2486            .fetch_max(3, Ordering::Relaxed);
2487        metrics
2488            .fetch_direct_max_attempt
2489            .fetch_max(5, Ordering::Relaxed);
2490        metrics
2491            .fetch_direct_max_attempt
2492            .fetch_max(2, Ordering::Relaxed);
2493        metrics
2494            .planned_fetch_batches
2495            .fetch_add(16, Ordering::Relaxed);
2496        metrics
2497            .planned_segment_waves
2498            .fetch_add(1_332, Ordering::Relaxed);
2499        metrics
2500            .segment_admission_limit
2501            .fetch_max(3, Ordering::Relaxed);
2502        metrics.segment_admission_source.store(
2503            crate::metrics_capture::SegmentAdmissionSource::MetricsOnly as u64,
2504            Ordering::Relaxed,
2505        );
2506        metrics.segment_admission_candidate_reason.store(
2507            crate::metrics_capture::SegmentAdmissionCandidateReason::Eligible as u64,
2508            Ordering::Relaxed,
2509        );
2510        metrics
2511            .max_segments_per_fetch_batch
2512            .fetch_max(2, Ordering::Relaxed);
2513        metrics
2514            .max_segments_per_wave
2515            .fetch_max(3, Ordering::Relaxed);
2516        metrics.peak_active_segments.fetch_max(3, Ordering::Relaxed);
2517        metrics
2518            .pipeline_budget_bytes
2519            .store(4 * 1024 * 1024 * 1024, Ordering::Relaxed);
2520        metrics
2521            .pipeline_peak_decoded_bytes
2522            .fetch_max(96 * 1024 * 1024, Ordering::Relaxed);
2523        metrics.pipeline_byte_waits.fetch_add(4, Ordering::Relaxed);
2524        metrics
2525            .segment_admission_waits
2526            .fetch_add(20, Ordering::Relaxed);
2527        metrics
2528            .pipeline_stall_breaker_activations
2529            .fetch_add(1, Ordering::Relaxed);
2530
2531        let set = build_metrics_set_for_explain(&metrics, 4, None);
2532
2533        assert_eq!(metric_value_by_name(&set, "fetch_grpc_bytes"), Some(3_500));
2534        // True cross-partition max, not a sum.
2535        assert_eq!(
2536            metric_value_by_name(&set, "fetch_direct_max_attempt"),
2537            Some(5),
2538        );
2539        assert_eq!(metric_value_by_name(&set, "num_partitions"), Some(4));
2540        assert_eq!(
2541            metric_value_by_name(&set, "planned_fetch_batches"),
2542            Some(16)
2543        );
2544        assert_eq!(
2545            metric_value_by_name(&set, "planned_segment_waves"),
2546            Some(1_332)
2547        );
2548        assert_eq!(
2549            metric_value_by_name(&set, "segment_admission_limit"),
2550            Some(3)
2551        );
2552        assert_eq!(
2553            metric_value_by_name(&set, "segment_admission_source_code"),
2554            Some(crate::metrics_capture::SegmentAdmissionSource::MetricsOnly as usize)
2555        );
2556        assert_eq!(
2557            metric_value_by_name(&set, "segment_admission_candidate_reason_code"),
2558            Some(crate::metrics_capture::SegmentAdmissionCandidateReason::Eligible as usize)
2559        );
2560        assert_eq!(
2561            metric_value_by_name(&set, "max_segments_per_fetch_batch"),
2562            Some(2)
2563        );
2564        assert_eq!(metric_value_by_name(&set, "max_segments_per_wave"), Some(3));
2565        assert_eq!(metric_value_by_name(&set, "peak_active_segments"), Some(3));
2566        assert_eq!(
2567            metric_value_by_name(&set, "pipeline_budget_bytes"),
2568            Some(4 * 1024 * 1024 * 1024)
2569        );
2570        assert_eq!(
2571            metric_value_by_name(&set, "pipeline_peak_decoded_bytes"),
2572            Some(96 * 1024 * 1024)
2573        );
2574        assert_eq!(metric_value_by_name(&set, "pipeline_byte_waits"), Some(4));
2575        assert_eq!(
2576            metric_value_by_name(&set, "segment_admission_waits"),
2577            Some(20)
2578        );
2579        assert_eq!(
2580            metric_value_by_name(&set, "pipeline_stall_breaker_activations"),
2581            Some(1)
2582        );
2583    }
2584}
2585
2586#[cfg(test)]
2587mod expr_filter_signature_tests {
2588    use datafusion::logical_expr::expr::InList;
2589    use datafusion::logical_expr::{Between, col, lit};
2590
2591    use super::*;
2592
2593    #[test]
2594    fn binary_expr_column_on_left() {
2595        let expr = col("frame_nr").gt(lit(100i64));
2596        assert_eq!(expr_filter_signature(&expr), "(frame_nr > 100)");
2597    }
2598
2599    #[test]
2600    fn binary_expr_column_on_right() {
2601        let expr = lit(100i64).lt(col("frame_nr"));
2602        assert_eq!(expr_filter_signature(&expr), "(100 < frame_nr)");
2603    }
2604
2605    #[test]
2606    fn binary_expr_equality() {
2607        let expr = col("rerun_segment_id").eq(lit("some-segment"));
2608        assert_eq!(
2609            expr_filter_signature(&expr),
2610            "(rerun_segment_id = 'some-segment')"
2611        );
2612    }
2613
2614    #[test]
2615    fn between_expr() {
2616        let expr = Expr::Between(Between {
2617            expr: Box::new(col("log_time")),
2618            negated: false,
2619            low: Box::new(lit(0i64)),
2620            high: Box::new(lit(1000i64)),
2621        });
2622        assert_eq!(
2623            expr_filter_signature(&expr),
2624            "(log_time BETWEEN 0 AND 1000)"
2625        );
2626    }
2627
2628    #[test]
2629    fn in_list_expr() {
2630        let expr = Expr::InList(InList {
2631            expr: Box::new(col("rerun_segment_id")),
2632            list: vec![lit("a"), lit("b")],
2633            negated: false,
2634        });
2635        assert_eq!(
2636            expr_filter_signature(&expr),
2637            "rerun_segment_id IN ('a', 'b')"
2638        );
2639    }
2640
2641    #[test]
2642    fn alias_is_transparent() {
2643        let expr = col("frame_nr").gt(lit(5i64)).alias("my_filter");
2644        assert_eq!(expr_filter_signature(&expr), "(frame_nr > 5)");
2645    }
2646
2647    #[test]
2648    fn plain_column_reference() {
2649        let expr = col("something");
2650        assert_eq!(expr_filter_signature(&expr), "something");
2651    }
2652
2653    #[test]
2654    fn semicolon_in_column_name_is_escaped() {
2655        // SQL quotes the identifier as "my;col"; the `;` is then escaped for join safety.
2656        let expr = col("my;col").gt(lit(0i64));
2657        assert_eq!(expr_filter_signature(&expr), "(\"my\\;col\" > 0)");
2658    }
2659
2660    #[test]
2661    fn backslash_in_column_name_is_escaped() {
2662        // SQL quotes the identifier as "my\col"; the `\` is then escaped.
2663        let expr = col("my\\col").gt(lit(0i64));
2664        assert_eq!(expr_filter_signature(&expr), "(\"my\\\\col\" > 0)");
2665    }
2666}
2667
2668#[cfg(test)]
2669mod filter_capture_span_tests {
2670    use std::collections::HashSet;
2671
2672    use super::*;
2673
2674    fn dummy_info_with_filters(offered: u32, signatures: &str) -> TableQueryInfo {
2675        TableQueryInfo {
2676            table_id: "tbl-1".to_owned(),
2677            table_kind: TableKind::Lance,
2678            caller: TableQueryCaller::CatalogResolver,
2679            schema_total_columns: 4,
2680            projected_columns: 4,
2681            has_limit: false,
2682            limit_value: None,
2683            time_range: web_time::SystemTime::UNIX_EPOCH
2684                ..web_time::SystemTime::UNIX_EPOCH + web_time::Duration::from_secs(1),
2685            filters_total: offered,
2686            filters_signatures: signatures.to_owned(),
2687        }
2688    }
2689
2690    fn attribute_keys(span: &opentelemetry_proto::tonic::trace::v1::Span) -> HashSet<&str> {
2691        span.attributes.iter().map(|kv| kv.key.as_str()).collect()
2692    }
2693
2694    fn find_int(span: &opentelemetry_proto::tonic::trace::v1::Span, key: &str) -> Option<i64> {
2695        use opentelemetry_proto::tonic::common::v1::any_value::Value;
2696        span.attributes
2697            .iter()
2698            .find(|kv| kv.key == key)
2699            .and_then(|kv| match kv.value.as_ref()?.value.as_ref()? {
2700                Value::IntValue(i) => Some(*i),
2701                _ => None,
2702            })
2703    }
2704
2705    fn find_string<'a>(
2706        span: &'a opentelemetry_proto::tonic::trace::v1::Span,
2707        key: &str,
2708    ) -> Option<&'a str> {
2709        use opentelemetry_proto::tonic::common::v1::any_value::Value;
2710        span.attributes
2711            .iter()
2712            .find(|kv| kv.key == key)
2713            .and_then(|kv| match kv.value.as_ref()?.value.as_ref()? {
2714                Value::StringValue(s) => Some(s.as_str()),
2715                _ => None,
2716            })
2717    }
2718
2719    #[test]
2720    fn filters_omitted_when_none_offered() {
2721        let info = dummy_info_with_filters(0, "");
2722        let span = build_table_query_span(
2723            &info,
2724            TableScanStatsSnapshot::default(),
2725            web_time::SystemTime::UNIX_EPOCH
2726                ..web_time::SystemTime::UNIX_EPOCH + web_time::Duration::from_secs(1),
2727            web_time::Duration::ZERO,
2728            None,
2729            None,
2730            None,
2731            None,
2732        );
2733        let keys = attribute_keys(&span);
2734        assert!(!keys.contains("filters_total"), "must be absent when zero");
2735        assert!(
2736            !keys.contains("filters_signatures"),
2737            "must be absent when empty"
2738        );
2739    }
2740
2741    #[test]
2742    fn filters_emitted_when_present() {
2743        let sigs = "(frame_nr > 100);rerun_segment_id IN ('a', 'b')";
2744        let info = dummy_info_with_filters(2, sigs);
2745        let span = build_table_query_span(
2746            &info,
2747            TableScanStatsSnapshot::default(),
2748            web_time::SystemTime::UNIX_EPOCH
2749                ..web_time::SystemTime::UNIX_EPOCH + web_time::Duration::from_secs(1),
2750            web_time::Duration::ZERO,
2751            None,
2752            None,
2753            None,
2754            None,
2755        );
2756        assert_eq!(find_int(&span, "filters_total"), Some(2));
2757        assert_eq!(find_string(&span, "filters_signatures"), Some(sigs));
2758    }
2759}