1use 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)]
35pub 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 let session_queries = stats_manager.get_session_queries(session_id, 1000);
58 let total_queries = session_queries.len() as u64;
59
60 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 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)]
104pub 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 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 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)]
237pub 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)]
378pub 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#[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}