1use rusqlite::Connection;
12use serde::{Deserialize, Serialize};
13
14use super::rollup::GRANULARITIES_MS;
15use super::schema::{percentile, BUCKET_COLS};
16
17pub const DIM_COLUMNS: [&str; 6] = [
21 "listener",
22 "route",
23 "upstream",
24 "consumer",
25 "method",
26 "status_class",
27];
28
29#[derive(Debug, Serialize)]
31pub struct SeriesPoint {
32 pub window_start: i64,
34 pub requests: i64,
35 pub errors: i64,
36 pub error_rate: f64,
37 pub rate_limited: i64,
38 pub shed: i64,
39 pub avg_ms: f64,
40 pub p50_ms: f64,
41 pub p95_ms: f64,
42 pub p99_ms: f64,
43 #[serde(skip_serializing_if = "Option::is_none")]
46 pub key: Option<String>,
47}
48
49#[derive(Debug, Default, Clone)]
51pub struct Filters {
52 pub listener: Option<String>,
53 pub route: Option<String>,
54 pub upstream: Option<String>,
55 pub consumer: Option<String>,
56 pub method: Option<String>,
57 pub status_class: Option<String>,
58}
59
60impl Filters {
61 fn clauses(&self) -> (String, Vec<Box<dyn rusqlite::ToSql>>) {
64 let mut sql = String::new();
65 let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
66 for (col, val) in [
67 ("listener", &self.listener),
68 ("route", &self.route),
69 ("upstream", &self.upstream),
70 ("consumer", &self.consumer),
71 ("method", &self.method),
72 ("status_class", &self.status_class),
73 ] {
74 if let Some(v) = val {
75 sql.push_str(&format!(" AND {col} = ?"));
76 params.push(Box::new(v.clone()));
77 }
78 }
79 (sql, params)
80 }
81}
82
83struct Agg {
85 requests: i64,
86 errors: i64,
87 rate_limited: i64,
88 shed: i64,
89 duration_sum_ms: f64,
90 buckets: Vec<i64>,
91}
92
93fn read_agg(row: &rusqlite::Row<'_>, start: usize) -> rusqlite::Result<Agg> {
100 let mut i = start;
101 let requests = row.get::<_, Option<i64>>(i)?.unwrap_or(0);
102 i += 1;
103 let errors = row.get::<_, Option<i64>>(i)?.unwrap_or(0);
104 i += 1;
105 let rate_limited = row.get::<_, Option<i64>>(i)?.unwrap_or(0);
106 i += 1;
107 let shed = row.get::<_, Option<i64>>(i)?.unwrap_or(0);
108 i += 1;
109 let duration_sum_ms = row.get::<_, Option<f64>>(i)?.unwrap_or(0.0);
110 i += 1;
111 let mut buckets = Vec::with_capacity(BUCKET_COLS);
112 for _ in 0..BUCKET_COLS {
113 buckets.push(row.get::<_, Option<i64>>(i)?.unwrap_or(0));
114 i += 1;
115 }
116 Ok(Agg {
117 requests,
118 errors,
119 rate_limited,
120 shed,
121 duration_sum_ms,
122 buckets,
123 })
124}
125
126fn metrics_of(a: &Agg) -> (f64, f64, f64, f64, f64, f64, f64) {
127 let n = a.requests.max(0);
128 (
129 if n > 0 {
130 a.errors as f64 / n as f64
131 } else {
132 0.0
133 },
134 a.rate_limited as f64,
135 a.shed as f64,
136 if n > 0 {
137 a.duration_sum_ms / n as f64
138 } else {
139 0.0
140 },
141 percentile(&a.buckets, 0.50),
142 percentile(&a.buckets, 0.95),
143 percentile(&a.buckets, 0.99),
144 )
145}
146
147pub fn dashboard(
151 conn: &Connection,
152 from_ms: i64,
153 to_ms: i64,
154 gran: usize,
155 group_by: Option<&str>,
156 filters: &Filters,
157) -> rusqlite::Result<Vec<SeriesPoint>> {
158 let group_col = group_by.and_then(|g| DIM_COLUMNS.iter().find(|c| **c == g).copied());
159 let key_sel = group_col.map(|c| format!("{c}, ")).unwrap_or_default();
160 let group_key = group_col.unwrap_or("window_start");
161 let (filter_sql, bind) = filters.clauses();
162 let sql = format!(
163 "SELECT window_start, {key_sel}
164 SUM(requests), SUM(errors), SUM(rate_limited), SUM(shed),
165 SUM(duration_sum_ms),
166 SUM(b0), SUM(b1), SUM(b2), SUM(b3), SUM(b4), SUM(b5),
167 SUM(b6), SUM(b7), SUM(b8), SUM(b9), SUM(b10), SUM(b11),
168 SUM(b12)
169 FROM rollup_fixed
170 WHERE gran = ? AND window_start >= ? AND window_start < ?{filter_sql}
171 GROUP BY window_start, {group_key}
172 ORDER BY window_start ASC"
173 );
174 let mut all: Vec<Box<dyn rusqlite::ToSql>> =
175 vec![Box::new(gran as i64), Box::new(from_ms), Box::new(to_ms)];
176 all.extend(bind);
177 let mut stmt = conn.prepare(&sql)?;
178 let mut rows = stmt.query(rusqlite::params_from_iter(all.iter().map(|p| p.as_ref())))?;
179 let key_len = usize::from(group_col.is_some());
180 let mut out = Vec::new();
181 while let Some(row) = rows.next()? {
182 let window_start: i64 = row.get(0)?;
183 let key = if key_len == 1 {
184 Some(row.get::<_, String>(1)?)
185 } else {
186 None
187 };
188 let agg = read_agg(row, 1 + key_len)?;
189 let (error_rate, rate_limited, shed, avg_ms, p50, p95, p99) = metrics_of(&agg);
190 out.push(SeriesPoint {
191 window_start,
192 requests: agg.requests,
193 errors: agg.errors,
194 error_rate,
195 rate_limited: rate_limited as i64,
196 shed: shed as i64,
197 avg_ms,
198 p50_ms: p50,
199 p95_ms: p95,
200 p99_ms: p99,
201 key,
202 });
203 }
204 Ok(out)
205}
206
207#[derive(Debug, Clone, Copy, PartialEq, Eq)]
209pub enum TopKind {
210 Consumers,
211 Routes,
212 Slowest,
213 ErrorProne,
214 RateLimited,
215}
216
217impl TopKind {
218 pub fn parse(s: &str) -> Option<Self> {
220 match s {
221 "consumers" => Some(TopKind::Consumers),
222 "routes" => Some(TopKind::Routes),
223 "slowest" => Some(TopKind::Slowest),
224 "error_prone" => Some(TopKind::ErrorProne),
225 "rate_limited" => Some(TopKind::RateLimited),
226 _ => None,
227 }
228 }
229 fn as_str(&self) -> &'static str {
230 match self {
231 TopKind::Consumers => "consumers",
232 TopKind::Routes => "routes",
233 TopKind::Slowest => "slowest",
234 TopKind::ErrorProne => "error_prone",
235 TopKind::RateLimited => "rate_limited",
236 }
237 }
238}
239
240#[derive(Debug, Serialize)]
242pub struct TopEntry {
243 pub kind: &'static str,
244 pub name: String,
245 pub requests: i64,
246 pub errors: i64,
247 pub error_rate: f64,
248 pub avg_ms: f64,
249 pub p95_ms: f64,
250 pub rate_limited: i64,
251}
252
253pub fn top(
257 conn: &Connection,
258 kind: TopKind,
259 from_ms: i64,
260 to_ms: i64,
261 limit: usize,
262) -> rusqlite::Result<Vec<TopEntry>> {
263 let group = match kind {
264 TopKind::Consumers | TopKind::RateLimited => "consumer",
265 TopKind::Routes | TopKind::Slowest | TopKind::ErrorProne => "route",
266 };
267 let order = match kind {
268 TopKind::Consumers | TopKind::Routes => "SUM(requests) DESC",
269 TopKind::Slowest => "avg_ms DESC",
270 TopKind::ErrorProne => "err_rate DESC, SUM(requests) DESC",
271 TopKind::RateLimited => "SUM(rate_limited) DESC",
272 };
273 let sql = format!(
274 "SELECT {group} AS name,
275 SUM(requests), SUM(errors), SUM(rate_limited), SUM(shed),
276 SUM(duration_sum_ms),
277 SUM(b0), SUM(b1), SUM(b2), SUM(b3), SUM(b4), SUM(b5),
278 SUM(b6), SUM(b7), SUM(b8), SUM(b9), SUM(b10), SUM(b11),
279 SUM(b12),
280 (SUM(errors) * 1.0 / MAX(SUM(requests), 1)) AS err_rate,
281 (SUM(duration_sum_ms) / MAX(SUM(requests), 1)) AS avg_ms
282 FROM rollup_fixed
283 WHERE gran = 0 AND window_start >= ?1 AND window_start < ?2
284 GROUP BY {group}
285 ORDER BY {order}
286 LIMIT ?3"
287 );
288 let mut stmt = conn.prepare(&sql)?;
289 let mut rows = stmt.query(rusqlite::params![from_ms, to_ms, limit as i64])?;
290 let mut out = Vec::new();
291 while let Some(row) = rows.next()? {
292 let name: String = row.get(0)?;
293 let agg = read_agg(row, 1)?;
294 let requests = agg.requests.max(0);
295 let error_rate = if requests > 0 {
296 agg.errors as f64 / requests as f64
297 } else {
298 0.0
299 };
300 out.push(TopEntry {
301 kind: kind.as_str(),
302 name,
303 requests,
304 errors: agg.errors,
305 error_rate,
306 avg_ms: if requests > 0 {
307 agg.duration_sum_ms / requests as f64
308 } else {
309 0.0
310 },
311 p95_ms: percentile(&agg.buckets, 0.95),
312 rate_limited: agg.rate_limited,
313 });
314 }
315 Ok(out)
316}
317
318#[derive(Debug, Deserialize)]
326pub struct StructuredQuery {
327 pub from_ms: i64,
328 pub to_ms: i64,
329 pub gran: usize,
331 #[serde(default)]
334 pub group_by: Vec<String>,
335 #[serde(default, skip_serializing_if = "Option::is_none")]
340 pub dim_group_by: Option<String>,
341 #[serde(default)]
343 pub filters: FiltersBody,
344 #[serde(default)]
348 pub dim_filters: std::collections::BTreeMap<String, String>,
349 #[serde(default)]
351 pub limit: Option<usize>,
352}
353
354#[derive(Debug, Default, Deserialize)]
356pub struct FiltersBody {
357 pub listener: Option<String>,
358 pub route: Option<String>,
359 pub upstream: Option<String>,
360 pub consumer: Option<String>,
361 pub method: Option<String>,
362 pub status_class: Option<String>,
363}
364
365#[derive(Debug)]
367pub enum QueryError {
368 UnknownGroupBy(String),
369 BadGranularity(usize),
370 BadRange,
371 BadDimGroupBy(String),
375}
376
377impl std::fmt::Display for QueryError {
378 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
379 match self {
380 QueryError::UnknownGroupBy(g) => write!(
381 f,
382 "unknown group_by '{g}' (one of: {})",
383 DIM_COLUMNS.join(", ")
384 ),
385 QueryError::BadGranularity(g) => {
386 write!(f, "gran must be 0..=3 (1m/5m/1h/1d), got {g}")
387 }
388 QueryError::BadRange => write!(f, "from_ms must be < to_ms"),
389 QueryError::BadDimGroupBy(g) => {
390 write!(f, "dim_group_by '{g}' is empty or exceeds 64 characters")
391 }
392 }
393 }
394}
395
396impl std::error::Error for QueryError {}
397
398const MAX_DIM_NAME_LEN: usize = 64;
400
401impl StructuredQuery {
402 pub fn validate(&self) -> Result<(), QueryError> {
404 if self.gran >= GRANULARITIES_MS.len() {
405 return Err(QueryError::BadGranularity(self.gran));
406 }
407 if self.from_ms >= self.to_ms {
408 return Err(QueryError::BadRange);
409 }
410 for g in &self.group_by {
411 if !DIM_COLUMNS.contains(&g.as_str()) {
412 return Err(QueryError::UnknownGroupBy(g.clone()));
413 }
414 }
415 if let Some(d) = &self.dim_group_by {
416 if d.is_empty() || d.len() > MAX_DIM_NAME_LEN {
417 return Err(QueryError::BadDimGroupBy(d.clone()));
418 }
419 }
420 for k in self.dim_filters.keys() {
421 if k.is_empty() || k.len() > MAX_DIM_NAME_LEN {
422 return Err(QueryError::BadDimGroupBy(k.clone()));
423 }
424 }
425 Ok(())
426 }
427}
428
429#[derive(Debug, Serialize)]
432pub struct QueryRow {
433 pub key: Vec<String>,
434 pub requests: i64,
435 pub errors: i64,
436 pub error_rate: f64,
437 pub rate_limited: i64,
438 pub shed: i64,
439 pub avg_ms: f64,
440 pub p50_ms: f64,
441 pub p95_ms: f64,
442 pub p99_ms: f64,
443}
444
445pub fn structured(conn: &Connection, q: &StructuredQuery) -> rusqlite::Result<Vec<QueryRow>> {
454 if let Some(dim_name) = &q.dim_group_by {
456 return structured_dim(conn, q, dim_name);
457 }
458 let key_len = q.group_by.len();
459 let key_sel = if key_len == 0 {
460 String::new()
461 } else {
462 format!("{}, ", q.group_by.join(", "))
463 };
464 let filters = Filters {
465 listener: q.filters.listener.clone(),
466 route: q.filters.route.clone(),
467 upstream: q.filters.upstream.clone(),
468 consumer: q.filters.consumer.clone(),
469 method: q.filters.method.clone(),
470 status_class: q.filters.status_class.clone(),
471 };
472 let (filter_sql, bind) = filters.clauses();
473 let (dim_filter_sql, dim_bind) = dim_filter_clauses(&q.dim_filters);
475 let sql = format!(
476 "SELECT {key_sel}
477 SUM(requests), SUM(errors), SUM(rate_limited), SUM(shed),
478 SUM(duration_sum_ms),
479 SUM(b0), SUM(b1), SUM(b2), SUM(b3), SUM(b4), SUM(b5),
480 SUM(b6), SUM(b7), SUM(b8), SUM(b9), SUM(b10), SUM(b11),
481 SUM(b12)
482 FROM rollup_fixed
483 WHERE gran = ? AND window_start >= ? AND window_start < ?{filter_sql}{dim_filter_sql}
484 {group_all}
485 ORDER BY SUM(requests) DESC
486 LIMIT ?",
487 group_all = if key_len == 0 {
488 String::new()
494 } else {
495 format!("GROUP BY {}", q.group_by.join(", "))
496 }
497 );
498 let limit = q.limit.unwrap_or(1000).min(10_000);
499 let mut all: Vec<Box<dyn rusqlite::ToSql>> = vec![
500 Box::new(q.gran as i64),
501 Box::new(q.from_ms),
502 Box::new(q.to_ms),
503 ];
504 all.extend(bind);
505 all.extend(dim_bind);
506 all.push(Box::new(limit as i64));
507 let mut stmt = conn.prepare(&sql)?;
508 let mut rows = stmt.query(rusqlite::params_from_iter(all.iter().map(|p| p.as_ref())))?;
509 let mut out = Vec::new();
510 while let Some(row) = rows.next()? {
511 let mut key = Vec::with_capacity(key_len);
512 for i in 0..key_len {
513 key.push(row.get::<_, String>(i)?);
514 }
515 let agg = read_agg(row, key_len)?;
516 let (error_rate, rate_limited, shed, avg_ms, p50, p95, p99) = metrics_of(&agg);
517 out.push(QueryRow {
518 key,
519 requests: agg.requests,
520 errors: agg.errors,
521 error_rate,
522 rate_limited: rate_limited as i64,
523 shed: shed as i64,
524 avg_ms,
525 p50_ms: p50,
526 p95_ms: p95,
527 p99_ms: p99,
528 });
529 }
530 Ok(out)
531}
532
533fn dim_filter_clauses(
538 dim_filters: &std::collections::BTreeMap<String, String>,
539) -> (String, Vec<Box<dyn rusqlite::ToSql>>) {
540 if dim_filters.is_empty() {
541 return (String::new(), Vec::new());
542 }
543 let mut sql = String::new();
544 let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
545 for (dim, value) in dim_filters {
546 sql.push_str(&format!(
549 " AND EXISTS (SELECT 1 FROM rollup_dim d{idx} \
550 WHERE d{idx}.gran = rollup_fixed.gran \
551 AND d{idx}.window_start = rollup_fixed.window_start \
552 AND d{idx}.dim = ? AND d{idx}.value = ?)",
553 idx = params.len() / 2
554 ));
555 params.push(Box::new(dim.clone()));
556 params.push(Box::new(value.clone()));
557 }
558 (sql, params)
559}
560
561fn structured_dim(
568 conn: &Connection,
569 q: &StructuredQuery,
570 dim_name: &str,
571) -> rusqlite::Result<Vec<QueryRow>> {
572 let limit = q.limit.unwrap_or(1000).min(10_000);
573 let (filter_sql, bind) = dim_filter_clauses(&q.dim_filters);
574 let sql = format!(
576 "SELECT value,
577 SUM(requests), SUM(errors), 0, 0,
578 SUM(duration_sum_ms),
579 SUM(b0), SUM(b1), SUM(b2), SUM(b3), SUM(b4), SUM(b5),
580 SUM(b6), SUM(b7), SUM(b8), SUM(b9), SUM(b10), SUM(b11),
581 SUM(b12)
582 FROM rollup_dim
583 WHERE gran = ? AND window_start >= ? AND window_start < ?
584 AND dim = ?{filter_sql}
585 GROUP BY value
586 ORDER BY SUM(requests) DESC
587 LIMIT ?"
588 );
589 let mut all: Vec<Box<dyn rusqlite::ToSql>> = vec![
590 Box::new(q.gran as i64),
591 Box::new(q.from_ms),
592 Box::new(q.to_ms),
593 Box::new(dim_name.to_string()),
594 ];
595 all.extend(bind);
596 all.push(Box::new(limit as i64));
597 let mut stmt = conn.prepare(&sql)?;
598 let mut rows = stmt.query(rusqlite::params_from_iter(all.iter().map(|p| p.as_ref())))?;
599 let mut out = Vec::new();
600 while let Some(row) = rows.next()? {
601 let key = vec![row.get::<_, String>(0)?];
602 let agg = read_agg(row, 1)?;
603 let (error_rate, _rate_limited, _shed, avg_ms, p50, p95, p99) = metrics_of(&agg);
604 out.push(QueryRow {
605 key,
606 requests: agg.requests,
607 errors: agg.errors,
608 error_rate,
609 rate_limited: 0,
610 shed: 0,
611 avg_ms,
612 p50_ms: p50,
613 p95_ms: p95,
614 p99_ms: p99,
615 });
616 }
617 Ok(out)
618}
619
620pub const SPEND_GROUP_COLUMNS: [&str; 3] = ["consumer", "team", "model"];
629
630#[derive(Debug, Serialize)]
633pub struct SpendRow {
634 pub key: Vec<String>,
637 pub prompt_tokens: i64,
639 pub completion_tokens: i64,
641 pub total_tokens: i64,
643 pub cost_micros: i64,
645 pub request_count: i64,
647}
648
649#[derive(Debug, Deserialize)]
652pub struct SpendQuery {
653 pub from_ms: i64,
654 pub to_ms: i64,
655 #[serde(default)]
658 pub group_by: Vec<String>,
659 #[serde(default)]
661 pub limit: Option<usize>,
662}
663
664#[derive(Debug)]
666pub enum SpendQueryError {
667 UnknownGroupBy(String),
668 BadRange,
669}
670
671impl std::fmt::Display for SpendQueryError {
672 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
673 match self {
674 SpendQueryError::UnknownGroupBy(g) => write!(
675 f,
676 "unknown group_by '{g}' (one of: {})",
677 SPEND_GROUP_COLUMNS.join(", ")
678 ),
679 SpendQueryError::BadRange => write!(f, "from_ms must be < to_ms"),
680 }
681 }
682}
683
684impl std::error::Error for SpendQueryError {}
685
686impl SpendQuery {
687 pub fn validate(&self) -> Result<(), SpendQueryError> {
689 if self.from_ms >= self.to_ms {
690 return Err(SpendQueryError::BadRange);
691 }
692 for g in &self.group_by {
693 if !SPEND_GROUP_COLUMNS.contains(&g.as_str()) {
694 return Err(SpendQueryError::UnknownGroupBy(g.clone()));
695 }
696 }
697 Ok(())
698 }
699}
700
701pub fn spend_summary(conn: &Connection, q: &SpendQuery) -> rusqlite::Result<Vec<SpendRow>> {
708 let key_len = q.group_by.len();
709 let key_sel = if key_len == 0 {
710 String::new()
711 } else {
712 format!("{}, ", q.group_by.join(", "))
713 };
714 let group_all = if key_len == 0 {
715 String::new()
716 } else {
717 format!("GROUP BY {}", q.group_by.join(", "))
718 };
719 let sql = format!(
720 "SELECT {key_sel}
721 SUM(prompt_tokens), SUM(completion_tokens), SUM(total_tokens),
722 SUM(cost_micros), COUNT(*)
723 FROM ai_spend
724 WHERE ts_ms >= ? AND ts_ms < ?
725 {group_all}
726 ORDER BY SUM(cost_micros) DESC
727 LIMIT ?"
728 );
729 let limit = q.limit.unwrap_or(10_000).min(10_000);
730 let mut stmt = conn.prepare(&sql)?;
731 let mut rows = stmt.query(rusqlite::params![q.from_ms, q.to_ms, limit as i64])?;
732 let mut out = Vec::new();
733 while let Some(row) = rows.next()? {
734 let mut key = Vec::with_capacity(key_len);
735 for i in 0..key_len {
736 key.push(row.get::<_, String>(i)?);
737 }
738 let base = key_len;
739 out.push(SpendRow {
740 key,
741 prompt_tokens: row.get::<_, i64>(base)?,
742 completion_tokens: row.get::<_, i64>(base + 1)?,
743 total_tokens: row.get::<_, i64>(base + 2)?,
744 cost_micros: row.get::<_, i64>(base + 3)?,
745 request_count: row.get::<_, i64>(base + 4)?,
746 });
747 }
748 Ok(out)
749}
750
751#[derive(Debug, Serialize)]
760pub struct GovernanceEventRow {
761 pub ts_ms: i64,
763 pub consumer: String,
765 pub team: String,
769 pub model: String,
771 pub verdict: String,
773 pub reason: String,
775}
776
777#[derive(Debug, Deserialize)]
780pub struct GovernanceAuditQuery {
781 pub from_ms: i64,
782 pub to_ms: i64,
783 #[serde(default)]
785 pub limit: Option<usize>,
786}
787
788#[derive(Debug)]
790pub enum GovernanceAuditError {
791 BadRange,
792}
793
794impl std::fmt::Display for GovernanceAuditError {
795 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
796 match self {
797 GovernanceAuditError::BadRange => write!(f, "from_ms must be < to_ms"),
798 }
799 }
800}
801
802impl std::error::Error for GovernanceAuditError {}
803
804impl GovernanceAuditQuery {
805 pub fn validate(&self) -> Result<(), GovernanceAuditError> {
807 if self.from_ms >= self.to_ms {
808 return Err(GovernanceAuditError::BadRange);
809 }
810 Ok(())
811 }
812}
813
814pub fn governance_audit(
820 conn: &Connection,
821 q: &GovernanceAuditQuery,
822) -> rusqlite::Result<Vec<GovernanceEventRow>> {
823 let limit = q.limit.unwrap_or(10_000).min(10_000);
824 let mut stmt = conn.prepare(
825 "SELECT ts_ms, consumer, team, model, verdict, reason \
826 FROM ai_governance_events \
827 WHERE ts_ms >= ? AND ts_ms < ? \
828 ORDER BY ts_ms DESC \
829 LIMIT ?",
830 )?;
831 let mut rows = stmt.query(rusqlite::params![q.from_ms, q.to_ms, limit as i64])?;
832 let mut out = Vec::new();
833 while let Some(row) = rows.next()? {
834 out.push(GovernanceEventRow {
835 ts_ms: row.get(0)?,
836 consumer: row.get(1)?,
837 team: row.get(2)?,
838 model: row.get(3)?,
839 verdict: row.get(4)?,
840 reason: row.get(5)?,
841 });
842 }
843 Ok(out)
844}
845
846#[derive(Debug, Serialize)]
849pub struct PromptLogRow {
850 pub ts_ms: i64,
852 pub request_id: String,
854 pub consumer: String,
856 pub route: String,
858 pub provider: String,
860 pub model: String,
862 pub version: String,
864 pub prompt_json: String,
866 pub response_json: String,
868 pub stream: bool,
870}
871
872#[derive(Debug, Deserialize)]
875pub struct PromptLogQuery {
876 pub from_ms: i64,
877 pub to_ms: i64,
878 #[serde(default)]
880 pub consumer: Option<String>,
881 #[serde(default)]
883 pub limit: Option<usize>,
884}
885
886#[derive(Debug)]
888pub enum PromptLogError {
889 BadRange,
890}
891
892impl std::fmt::Display for PromptLogError {
893 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
894 match self {
895 PromptLogError::BadRange => write!(f, "from_ms must be < to_ms"),
896 }
897 }
898}
899
900impl std::error::Error for PromptLogError {}
901
902impl PromptLogQuery {
903 pub fn validate(&self) -> Result<(), PromptLogError> {
905 if self.from_ms >= self.to_ms {
906 return Err(PromptLogError::BadRange);
907 }
908 Ok(())
909 }
910}
911
912pub fn prompt_logs(conn: &Connection, q: &PromptLogQuery) -> rusqlite::Result<Vec<PromptLogRow>> {
917 let limit = q.limit.unwrap_or(1_000).min(10_000);
918 let mut out = Vec::new();
919 if let Some(consumer) = &q.consumer {
920 let mut stmt = conn.prepare(
921 "SELECT ts_ms, request_id, consumer, route, provider, model, version, \
922 prompt_json, response_json, stream \
923 FROM ai_prompt_logs \
924 WHERE ts_ms >= ? AND ts_ms < ? AND consumer = ? \
925 ORDER BY ts_ms DESC \
926 LIMIT ?",
927 )?;
928 let mut rows = stmt.query(rusqlite::params![
929 q.from_ms,
930 q.to_ms,
931 consumer,
932 limit as i64
933 ])?;
934 while let Some(row) = rows.next()? {
935 out.push(PromptLogRow {
936 ts_ms: row.get(0)?,
937 request_id: row.get(1)?,
938 consumer: row.get(2)?,
939 route: row.get(3)?,
940 provider: row.get(4)?,
941 model: row.get(5)?,
942 version: row.get(6)?,
943 prompt_json: row.get(7)?,
944 response_json: row.get(8)?,
945 stream: row.get::<_, i64>(9)? != 0,
946 });
947 }
948 } else {
949 let mut stmt = conn.prepare(
950 "SELECT ts_ms, request_id, consumer, route, provider, model, version, \
951 prompt_json, response_json, stream \
952 FROM ai_prompt_logs \
953 WHERE ts_ms >= ? AND ts_ms < ? \
954 ORDER BY ts_ms DESC \
955 LIMIT ?",
956 )?;
957 let mut rows = stmt.query(rusqlite::params![q.from_ms, q.to_ms, limit as i64])?;
958 while let Some(row) = rows.next()? {
959 out.push(PromptLogRow {
960 ts_ms: row.get(0)?,
961 request_id: row.get(1)?,
962 consumer: row.get(2)?,
963 route: row.get(3)?,
964 provider: row.get(4)?,
965 model: row.get(5)?,
966 version: row.get(6)?,
967 prompt_json: row.get(7)?,
968 response_json: row.get(8)?,
969 stream: row.get::<_, i64>(9)? != 0,
970 });
971 }
972 }
973 Ok(out)
974}
975
976#[derive(Debug, Clone, Default)]
980pub struct McpToolCallQuery {
981 pub from_ms: i64,
982 pub to_ms: i64,
983 pub session_id: Option<String>,
984 pub consumer: Option<String>,
985 pub tool_name: Option<String>,
986 pub limit: Option<i64>,
987}
988
989#[derive(Debug, Serialize)]
991pub struct McpToolCallRow {
992 pub ts_ms: i64,
993 pub request_id: String,
994 pub session_id: String,
995 pub consumer: String,
996 pub tool_name: String,
997 pub allowed: bool,
998 pub duration_ms: f64,
999 pub error_code: Option<String>,
1000 pub status: String,
1001}
1002
1003pub fn mcp_tool_calls(
1007 conn: &Connection,
1008 q: &McpToolCallQuery,
1009) -> rusqlite::Result<Vec<McpToolCallRow>> {
1010 let limit = q.limit.unwrap_or(10_000).min(10_000);
1011 let mut sql = String::from(
1012 "SELECT ts_ms, request_id, session_id, consumer, tool_name, allowed, \
1013 duration_ms, error_code, status \
1014 FROM mcp_tool_calls \
1015 WHERE ts_ms >= ? AND ts_ms < ?",
1016 );
1017 let mut params: Vec<Box<dyn rusqlite::ToSql>> = vec![Box::new(q.from_ms), Box::new(q.to_ms)];
1018 if let Some(v) = &q.session_id {
1019 sql.push_str(" AND session_id = ?");
1020 params.push(Box::new(v.clone()));
1021 }
1022 if let Some(v) = &q.consumer {
1023 sql.push_str(" AND consumer = ?");
1024 params.push(Box::new(v.clone()));
1025 }
1026 if let Some(v) = &q.tool_name {
1027 sql.push_str(" AND tool_name = ?");
1028 params.push(Box::new(v.clone()));
1029 }
1030 sql.push_str(" ORDER BY ts_ms DESC LIMIT ?");
1031 params.push(Box::new(limit));
1032 let mut stmt = conn.prepare(&sql)?;
1033 let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
1034 let mut rows = stmt.query(param_refs.as_slice())?;
1035 let mut out = Vec::new();
1036 while let Some(row) = rows.next()? {
1037 out.push(McpToolCallRow {
1038 ts_ms: row.get(0)?,
1039 request_id: row.get(1)?,
1040 session_id: row.get(2)?,
1041 consumer: row.get(3)?,
1042 tool_name: row.get(4)?,
1043 allowed: row.get::<_, i64>(5)? != 0,
1044 duration_ms: row.get(6)?,
1045 error_code: row.get(7)?,
1046 status: row.get(8)?,
1047 });
1048 }
1049 Ok(out)
1050}
1051
1052#[derive(Debug, Serialize)]
1061pub struct DimensionRow {
1062 pub window_start: i64,
1064 pub dim: String,
1066 pub value: String,
1068 pub requests: i64,
1070 pub error_count: i64,
1072 pub avg_duration_ms: f64,
1074}
1075
1076#[derive(Debug, Deserialize)]
1079pub struct DimensionQuery {
1080 pub from_ms: i64,
1081 pub to_ms: i64,
1082 pub gran: usize,
1084 pub dim: String,
1086 #[serde(default)]
1088 pub value: Option<String>,
1089 #[serde(default)]
1091 pub limit: Option<usize>,
1092}
1093
1094#[derive(Debug)]
1096pub enum DimensionQueryError {
1097 BadGranularity(usize),
1098 BadRange,
1099 EmptyDim,
1100}
1101
1102impl std::fmt::Display for DimensionQueryError {
1103 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1104 match self {
1105 DimensionQueryError::BadGranularity(g) => {
1106 write!(f, "gran must be 0..=3 (1m/5m/1h/1d), got {g}")
1107 }
1108 DimensionQueryError::BadRange => write!(f, "from_ms must be < to_ms"),
1109 DimensionQueryError::EmptyDim => write!(f, "dim must be non-empty"),
1110 }
1111 }
1112}
1113
1114impl std::error::Error for DimensionQueryError {}
1115
1116impl DimensionQuery {
1117 pub fn validate(&self) -> Result<(), DimensionQueryError> {
1119 if self.gran >= GRANULARITIES_MS.len() {
1120 return Err(DimensionQueryError::BadGranularity(self.gran));
1121 }
1122 if self.from_ms >= self.to_ms {
1123 return Err(DimensionQueryError::BadRange);
1124 }
1125 if self.dim.is_empty() {
1126 return Err(DimensionQueryError::EmptyDim);
1127 }
1128 Ok(())
1129 }
1130}
1131
1132pub fn dimension_query(
1137 conn: &Connection,
1138 q: &DimensionQuery,
1139) -> rusqlite::Result<Vec<DimensionRow>> {
1140 let limit = q.limit.unwrap_or(10_000).min(10_000);
1141 let mut sql = String::from(
1142 "SELECT window_start, dim, value, requests, errors, duration_sum_ms \
1143 FROM rollup_dim \
1144 WHERE gran = ? AND window_start >= ? AND window_start < ? AND dim = ?",
1145 );
1146 let mut params: Vec<Box<dyn rusqlite::ToSql>> = vec![
1147 Box::new(q.gran as i64),
1148 Box::new(q.from_ms),
1149 Box::new(q.to_ms),
1150 Box::new(q.dim.clone()),
1151 ];
1152 if let Some(v) = &q.value {
1153 sql.push_str(" AND value = ?");
1154 params.push(Box::new(v.clone()));
1155 }
1156 sql.push_str(" ORDER BY window_start ASC, requests DESC LIMIT ?");
1157 params.push(Box::new(limit as i64));
1158 let mut stmt = conn.prepare(&sql)?;
1159 let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
1160 let mut rows = stmt.query(param_refs.as_slice())?;
1161 let mut out = Vec::new();
1162 while let Some(row) = rows.next()? {
1163 let requests: i64 = row.get(3)?;
1164 let duration_sum_ms: f64 = row.get(5)?;
1165 out.push(DimensionRow {
1166 window_start: row.get(0)?,
1167 dim: row.get(1)?,
1168 value: row.get(2)?,
1169 requests,
1170 error_count: row.get(4)?,
1171 avg_duration_ms: if requests > 0 {
1172 duration_sum_ms / requests as f64
1173 } else {
1174 0.0
1175 },
1176 });
1177 }
1178 Ok(out)
1179}
1180
1181#[derive(Debug, Serialize)]
1185pub struct JourneyRow {
1186 pub ts_ms: i64,
1188 pub request_id: String,
1190 pub correlation_id: String,
1192 pub listener: String,
1194 pub route: String,
1196 pub consumer: String,
1198 pub upstream: String,
1200 pub method: String,
1202 pub status: u16,
1204 pub duration_ms: f64,
1206 pub dims: String,
1208}
1209
1210pub fn journey_query(
1216 conn: &Connection,
1217 correlation_id: &str,
1218 from_ms: Option<i64>,
1219 to_ms: Option<i64>,
1220) -> rusqlite::Result<Vec<JourneyRow>> {
1221 let mut sql = String::from(
1222 "SELECT ts_ms, request_id, correlation_id, listener, route, \
1223 consumer, upstream, method, status, duration_ms, dims \
1224 FROM raw_all WHERE correlation_id = ?",
1225 );
1226 let mut params: Vec<Box<dyn rusqlite::ToSql>> = vec![Box::new(correlation_id.to_string())];
1227 if let Some(from) = from_ms {
1228 sql.push_str(" AND ts_ms >= ?");
1229 params.push(Box::new(from));
1230 }
1231 if let Some(to) = to_ms {
1232 sql.push_str(" AND ts_ms < ?");
1233 params.push(Box::new(to));
1234 }
1235 sql.push_str(" ORDER BY ts_ms ASC LIMIT 10000");
1236 let mut stmt = conn.prepare(&sql)?;
1237 let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
1238 let mut rows = stmt.query(param_refs.as_slice())?;
1239 let mut out = Vec::new();
1240 while let Some(row) = rows.next()? {
1241 out.push(JourneyRow {
1242 ts_ms: row.get(0)?,
1243 request_id: row.get(1)?,
1244 correlation_id: row.get(2)?,
1245 listener: row.get(3)?,
1246 route: row.get(4)?,
1247 consumer: row.get(5)?,
1248 upstream: row.get(6)?,
1249 method: row.get(7)?,
1250 status: row.get::<_, i64>(8)? as u16,
1251 duration_ms: row.get(9)?,
1252 dims: row.get(10)?,
1253 });
1254 }
1255 Ok(out)
1256}