Skip to main content

linkrs_server/http/handlers/statistics/
query.rs

1//! Query and search statistics handlers.
2//!
3//! Covers session statistics, query aggregations, search metrics,
4//! single query portraits and migration counters.
5
6use axum::{
7    extract::{Path, Query, State},
8    response::Json as JsonResponse,
9};
10use serde::Deserialize;
11
12use crate::http::{error::HttpError, state::AppState};
13use crate::storage::{
14    StorageClient, StorageOperationContextOps, StorageSchemaContextOps, StorageSyncContextOps,
15};
16use linkrs_metrics::MetricType;
17use linkrs_wire::meta::{
18    CacheSection, DeleteSection, ExecutorSummaryEntry, IndexSection, LatencyPercentilesUs,
19    QueryPatternEntry, QueryProfileDetailResponse, QueryProfileExecutor, QueryStatsResponse,
20    SearchIndexEntry, SearchSection, SearchStatsResponse, SlowQueryInfo,
21};
22use linkrs_wire::query::QueryStageTimings;
23
24#[utoipa::path(
25    get,
26    path = "/v1/statistics/sessions/{id}",
27    tag = "Statistics",
28    params(("id" = i64, Path, description = "Session id")),
29    responses(
30        (status = 200, body = serde_json::Value, description = "Session statistics"),
31        (status = 404, description = "Not found"),
32        (status = 500, description = "Internal error")
33    )
34)]
35/// Obtaining session statistics
36pub async fn session<
37    S: StorageClient
38        + StorageSchemaContextOps
39        + StorageSyncContextOps
40        + StorageOperationContextOps
41        + Clone
42        + Send
43        + Sync
44        + 'static,
45>(
46    State(state): State<AppState<S>>,
47    Path(session_id): Path<i64>,
48) -> Result<JsonResponse<serde_json::Value>, HttpError> {
49    let session_manager = state.server.get_session_manager();
50    let stats_manager = state.server.get_stats_manager();
51
52    let session = session_manager
53        .find_session(session_id)
54        .ok_or_else(|| HttpError::NotFound(format!("Session does not exist: {}", session_id)))?;
55
56    // Obtain session-related query statistics
57    let session_queries = stats_manager.get_session_queries(session_id, 1000);
58    let total_queries = session_queries.len() as u64;
59
60    // Calculate the average execution time.
61    let avg_execution_time_ms = if total_queries > 0 {
62        session_queries
63            .iter()
64            .map(|q| q.total_duration_us)
65            .sum::<u64>() as f64
66            / total_queries as f64
67            / 1000.0
68    } else {
69        0.0
70    };
71
72    // Obtain session-level change statistics
73    let session_stats = session.statistics();
74    let total_changes = session_stats.total_changes();
75    let last_insert_vertex_id = session_stats.last_insert_vertex_id();
76    let last_insert_edge_id = session_stats.last_insert_edge_id();
77
78    Ok(JsonResponse(serde_json::json!({
79        "session_id": session_id,
80        "username": session.user(),
81        "statistics": {
82            "total_queries": total_queries,
83            "total_changes": total_changes,
84            "last_insert_vertex_id": last_insert_vertex_id,
85            "last_insert_edge_id": last_insert_edge_id,
86            "avg_execution_time_ms": avg_execution_time_ms,
87        },
88    })))
89}
90
91#[utoipa::path(
92    get,
93    path = "/v1/statistics/queries",
94    tag = "Statistics",
95    params(
96        ("from" = Option<String>, Query, description = "Start of the query time window"),
97        ("to" = Option<String>, Query, description = "End of the query time window")
98    ),
99    responses(
100        (status = 200, body = QueryStatsResponse, description = "Query statistics"),
101        (status = 500, description = "Internal error")
102    )
103)]
104/// Obtain query statistics
105pub async fn queries<
106    S: StorageClient
107        + StorageSchemaContextOps
108        + StorageSyncContextOps
109        + StorageOperationContextOps
110        + Clone
111        + Send
112        + Sync
113        + 'static,
114>(
115    State(state): State<AppState<S>>,
116    Query(params): Query<QueryStatsParams>,
117) -> Result<JsonResponse<QueryStatsResponse>, HttpError> {
118    let stats_manager = state.server.get_stats_manager();
119
120    // Get the total number of queries from both sources
121    let total_queries = stats_manager.get_value(MetricType::NumQueries).unwrap_or(0);
122
123    let now_secs = std::time::SystemTime::now()
124        .duration_since(std::time::UNIX_EPOCH)
125        .unwrap_or_default()
126        .as_secs();
127    let from_secs =
128        parse_time_bound(params.from.as_deref()).or(Some(now_secs.saturating_sub(3600)));
129    let to_secs = parse_time_bound(params.to.as_deref());
130
131    let window_profiles = stats_manager.profiles_in_window(from_secs, to_secs, 1000);
132    let slow_queries = window_profiles
133        .into_iter()
134        .filter(|p| p.total_duration_us >= stats_manager_slow_threshold(stats_manager))
135        .take(10)
136        .map(slow_info_from_profile)
137        .collect::<Vec<_>>();
138
139    // Obtain statistics for various types of queries
140    let match_queries = stats_manager
141        .get_value(MetricType::NumMatchQueries)
142        .unwrap_or(0);
143    let create_queries = stats_manager
144        .get_value(MetricType::NumCreateQueries)
145        .unwrap_or(0);
146    let update_queries = stats_manager
147        .get_value(MetricType::NumUpdateQueries)
148        .unwrap_or(0);
149    let delete_queries = stats_manager
150        .get_value(MetricType::NumDeleteQueries)
151        .unwrap_or(0);
152    let insert_queries = stats_manager
153        .get_value(MetricType::NumInsertQueries)
154        .unwrap_or(0);
155    let go_queries = stats_manager
156        .get_value(MetricType::NumGoQueries)
157        .unwrap_or(0);
158    let fetch_queries = stats_manager
159        .get_value(MetricType::NumFetchQueries)
160        .unwrap_or(0);
161    let lookup_queries = stats_manager
162        .get_value(MetricType::NumLookupQueries)
163        .unwrap_or(0);
164    let show_queries = stats_manager
165        .get_value(MetricType::NumShowQueries)
166        .unwrap_or(0);
167
168    let error_snapshot = stats_manager.error_snapshot();
169    let patterns = stats_manager
170        .pattern_snapshot(10)
171        .into_iter()
172        .map(|p| QueryPatternEntry {
173            normalized_query: p.normalized_query,
174            query_type: p.query_type,
175            labels: p.labels,
176            execution_count: p.execution_count,
177            avg_duration_ms: p.avg_duration_ms,
178            p95_duration_ms: p.p95_duration_ms,
179            p99_duration_ms: p.p99_duration_ms,
180            error_rate: p.error_rate,
181            error_count: p.error_count,
182        })
183        .collect::<Vec<_>>();
184    let executors = stats_manager
185        .executor_summary_snapshot()
186        .into_iter()
187        .map(|e| ExecutorSummaryEntry {
188            executor_type: e.executor_type,
189            count: e.count,
190            total_time_ms: e.total_time_ms,
191            total_rows: e.total_rows,
192            avg_time_ms: e.avg_time_ms,
193        })
194        .collect::<Vec<_>>();
195    let latency = stats_manager.query_latency_snapshot();
196
197    Ok(JsonResponse(QueryStatsResponse {
198        total_queries,
199        slow_queries,
200        query_types: linkrs_wire::meta::QueryTypeStatistics {
201            match_queries,
202            create_queries,
203            update_queries,
204            delete_queries,
205            insert_queries,
206            go_queries,
207            fetch_queries,
208            lookup_queries,
209            show_queries,
210        },
211        errors_by_type: error_snapshot.errors_by_type,
212        errors_by_phase: error_snapshot.errors_by_phase,
213        error_total: error_snapshot.total_errors,
214        top_patterns: patterns,
215        executor_summary: executors,
216        latency_percentiles_us: Some(LatencyPercentilesUs {
217            avg: latency.avg_us,
218            p50: latency.p50_us,
219            p95: latency.p95_us,
220            p99: latency.p99_us,
221        }),
222        from: params.from,
223        to: params.to,
224    }))
225}
226
227#[utoipa::path(
228    get,
229    operation_id = "get_v1_statistics_search",
230    path = "/v1/statistics/search",
231    tag = "Statistics",
232    responses(
233        (status = 200, body = SearchStatsResponse, description = "Search statistics"),
234        (status = 500, description = "Internal error")
235    )
236)]
237/// Obtain search statistics
238pub async fn search<
239    S: StorageClient
240        + StorageSchemaContextOps
241        + StorageSyncContextOps
242        + StorageOperationContextOps
243        + Clone
244        + Send
245        + Sync
246        + 'static,
247>(
248    State(state): State<AppState<S>>,
249) -> Result<JsonResponse<SearchStatsResponse>, HttpError> {
250    let stats_manager = state.server.get_stats_manager();
251
252    let num_search_queries = stats_manager
253        .get_value(MetricType::NumSearchQueries)
254        .unwrap_or(0);
255    let num_search_errors = stats_manager
256        .get_value(MetricType::NumSearchErrors)
257        .unwrap_or(0);
258    let search_latency_ms = stats_manager
259        .get_value(MetricType::SearchLatencyMs)
260        .unwrap_or(0);
261    let num_index_operations = stats_manager
262        .get_value(MetricType::NumIndexOperations)
263        .unwrap_or(0);
264    let num_index_errors = stats_manager
265        .get_value(MetricType::NumIndexErrors)
266        .unwrap_or(0);
267    let index_latency_ms = stats_manager
268        .get_value(MetricType::IndexLatencyMs)
269        .unwrap_or(0);
270    let num_delete_operations = stats_manager
271        .get_value(MetricType::NumDeleteOperations)
272        .unwrap_or(0);
273    let num_delete_errors = stats_manager
274        .get_value(MetricType::NumDeleteErrors)
275        .unwrap_or(0);
276    let delete_latency_ms = stats_manager
277        .get_value(MetricType::DeleteLatencyMs)
278        .unwrap_or(0);
279    let search_result_count = stats_manager
280        .get_value(MetricType::SearchResultCount)
281        .unwrap_or(0);
282    let cache_hit_count = stats_manager
283        .get_value(MetricType::SearchCacheHitCount)
284        .unwrap_or(0);
285    let cache_miss_count = stats_manager
286        .get_value(MetricType::SearchCacheMissCount)
287        .unwrap_or(0);
288
289    let avg_search_latency_ms = if num_search_queries > 0 {
290        search_latency_ms as f64 / num_search_queries as f64
291    } else {
292        0.0
293    };
294
295    let avg_index_latency_ms = if num_index_operations > 0 {
296        index_latency_ms as f64 / num_index_operations as f64
297    } else {
298        0.0
299    };
300
301    let avg_delete_latency_ms = if num_delete_operations > 0 {
302        delete_latency_ms as f64 / num_delete_operations as f64
303    } else {
304        0.0
305    };
306
307    let cache_hit_rate = if cache_hit_count + cache_miss_count > 0 {
308        cache_hit_count as f64 / (cache_hit_count + cache_miss_count) as f64
309    } else {
310        0.0
311    };
312
313    let (search_avg_us, search_p50_us, search_p95_us, search_p99_us) =
314        stats_manager.get_search_latency_percentiles();
315
316    let by_index = stats_manager
317        .search_index_breakdown()
318        .into_iter()
319        .map(|b| SearchIndexEntry {
320            index: b.index,
321            search_queries: b.search_queries,
322            search_errors: b.search_errors,
323            search_latency_ms: b.search_latency_ms,
324            avg_search_latency_ms: b.avg_search_latency_ms,
325            index_operations: b.index_operations,
326            index_errors: b.index_errors,
327            index_latency_ms: b.index_latency_ms,
328        })
329        .collect::<Vec<_>>();
330
331    Ok(JsonResponse(SearchStatsResponse {
332        search: SearchSection {
333            total_queries: num_search_queries,
334            total_errors: num_search_errors,
335            total_latency_ms: search_latency_ms,
336            avg_latency_ms: avg_search_latency_ms,
337            total_results: search_result_count,
338            latency_percentiles_us: LatencyPercentilesUs {
339                avg: search_avg_us,
340                p50: search_p50_us,
341                p95: search_p95_us,
342                p99: search_p99_us,
343            },
344        },
345        index: IndexSection {
346            total_operations: num_index_operations,
347            total_errors: num_index_errors,
348            total_latency_ms: index_latency_ms,
349            avg_latency_ms: avg_index_latency_ms,
350        },
351        delete: DeleteSection {
352            total_operations: num_delete_operations,
353            total_errors: num_delete_errors,
354            total_latency_ms: delete_latency_ms,
355            avg_latency_ms: avg_delete_latency_ms,
356        },
357        cache: CacheSection {
358            hit_count: cache_hit_count,
359            miss_count: cache_miss_count,
360            hit_rate: cache_hit_rate,
361        },
362        by_index,
363    }))
364}
365
366#[utoipa::path(
367    get,
368    operation_id = "get_v1_statistics_query_profile",
369    path = "/v1/statistics/queries/{trace_id}",
370    tag = "Statistics",
371    params(("trace_id" = String, Path, description = "Query trace id")),
372    responses(
373        (status = 200, body = QueryProfileDetailResponse, description = "Query portrait detail"),
374        (status = 404, description = "Not found"),
375        (status = 500, description = "Internal error")
376    )
377)]
378/// Single query portrait by trace id.
379pub async fn query_profile_detail<
380    S: StorageClient
381        + StorageSchemaContextOps
382        + StorageSyncContextOps
383        + StorageOperationContextOps
384        + Clone
385        + Send
386        + Sync
387        + 'static,
388>(
389    State(state): State<AppState<S>>,
390    Path(trace_id): Path<String>,
391) -> Result<JsonResponse<QueryProfileDetailResponse>, HttpError> {
392    let stats_manager = state.server.get_stats_manager();
393    let profile = stats_manager.get_query_profile(&trace_id).ok_or_else(|| {
394        HttpError::NotFound(format!("Query portrait does not exist: {}", trace_id))
395    })?;
396    let status = match profile.status {
397        linkrs_metrics::QueryStatus::Success => "success",
398        linkrs_metrics::QueryStatus::Failed => "failed",
399    }
400    .to_string();
401    let error = profile
402        .error_info
403        .as_ref()
404        .map(|info| info.error_message.clone())
405        .or(profile.error_message.clone());
406    Ok(JsonResponse(QueryProfileDetailResponse {
407        trace_id: profile.trace_id.clone(),
408        session_id: profile.session_id,
409        query: profile.query_text.clone(),
410        duration_ms: profile.total_duration_us as f64 / 1000.0,
411        status,
412        error,
413        stages: Some(stages_from_profile(&profile)),
414        executors: profile
415            .executor_stats
416            .iter()
417            .map(|e| QueryProfileExecutor {
418                executor_type: e.executor_type.clone(),
419                duration_ms: e.duration_ms(),
420                rows: e.rows_processed(),
421                memory_bytes: e.memory_used(),
422            })
423            .collect(),
424        result_count: Some(profile.result_count),
425        plan_node_count: Some(profile.plan_node_count),
426    }))
427}
428
429/// Query statistical parameters
430#[derive(Debug, Deserialize, utoipa::ToSchema)]
431pub struct QueryStatsParams {
432    #[serde(default)]
433    pub from: Option<String>,
434    #[serde(default)]
435    pub to: Option<String>,
436}
437
438pub(crate) fn parse_time_bound(s: Option<&str>) -> Option<u64> {
439    let s = s?.trim();
440    if s.is_empty() {
441        return None;
442    }
443    if let Ok(v) = s.parse::<u64>() {
444        return Some(v);
445    }
446    if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s) {
447        return Some(dt.timestamp().max(0) as u64);
448    }
449    None
450}
451
452pub(crate) fn stats_manager_slow_threshold(stats: &linkrs_metrics::StatsManager) -> u64 {
453    stats.slow_query_threshold_us()
454}
455
456pub(crate) fn stages_from_profile(profile: &linkrs_metrics::QueryProfile) -> QueryStageTimings {
457    QueryStageTimings {
458        parse_ms: profile.stages.parse_ms(),
459        validate_ms: profile.stages.validate_ms(),
460        plan_ms: profile.stages.plan_ms(),
461        optimize_ms: profile.stages.optimize_ms(),
462        execute_ms: profile.stages.execute_ms(),
463    }
464}
465
466pub(crate) fn slow_info_from_profile(profile: linkrs_metrics::QueryProfile) -> SlowQueryInfo {
467    let status = match profile.status {
468        linkrs_metrics::QueryStatus::Success => "success",
469        linkrs_metrics::QueryStatus::Failed => "failed",
470    }
471    .to_string();
472    SlowQueryInfo {
473        trace_id: profile.trace_id.clone(),
474        session_id: profile.session_id,
475        query: profile.query_text.clone(),
476        duration_ms: profile.total_duration_us as f64 / 1000.0,
477        status,
478        started_at_secs: Some(profile.started_at_secs),
479        stages: Some(stages_from_profile(&profile)),
480        plan_node_count: Some(profile.plan_node_count),
481    }
482}