1use super::*;
2use serde_json::json;
3
4pub struct OperationRun {
7 pub id: String,
8 pub trace_id: Option<String>,
9 pub op: String,
10 pub source: Option<String>,
11 pub agent: Option<String>,
12 pub status: String, pub error_kind: Option<String>,
14 pub started_at: String,
15 pub duration_ms: i64,
16 pub counts_json: Option<String>,
17 pub params_json: Option<String>,
18}
19
20pub struct OpRunRow {
22 pub op: String,
23 pub status: String,
24 pub error_kind: Option<String>,
25 pub duration_ms: i64,
26 pub source: Option<String>,
27 pub agent: Option<String>,
28 pub context: Option<String>,
29}
30
31impl Storage {
32 pub fn insert_operation_run(&self, run: &OperationRun) -> Result<()> {
33 self.conn.execute(
34 "INSERT OR IGNORE INTO operation_runs
35 (id, trace_id, op, source, agent, status, error_kind,
36 started_at, duration_ms, counts_json, params_json)
37 VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11)",
38 params![
39 run.id,
40 run.trace_id,
41 run.op,
42 run.source,
43 run.agent,
44 run.status,
45 run.error_kind,
46 run.started_at,
47 run.duration_ms,
48 run.counts_json,
49 run.params_json,
50 ],
51 )?;
52 Ok(())
53 }
54
55 pub fn operation_runs_since(&self, since_ts: &str) -> Result<Vec<OpRunRow>> {
60 let mut stmt = self.conn.prepare(
61 "SELECT op, status, error_kind, duration_ms, source, agent,
62 (SELECT el.context_key FROM episodic_log el
63 WHERE el.trace_id = operation_runs.trace_id LIMIT 1) AS context
64 FROM operation_runs WHERE started_at >= ?1",
65 )?;
66 let rows = stmt.query_map(params![since_ts], |r| {
67 Ok(OpRunRow {
68 op: r.get(0)?,
69 status: r.get(1)?,
70 error_kind: r.get::<_, Option<String>>(2)?,
71 duration_ms: r.get(3)?,
72 source: r.get::<_, Option<String>>(4)?,
73 agent: r.get::<_, Option<String>>(5)?,
74 context: r.get::<_, Option<String>>(6)?,
75 })
76 })?;
77 Ok(rows.filter_map(|r| r.ok()).collect())
78 }
79
80 pub fn count_operation_runs(&self) -> Result<i64> {
81 Ok(self
82 .conn
83 .query_row("SELECT COUNT(*) FROM operation_runs", [], |r| r.get(0))?)
84 }
85
86 pub fn purge_operation_runs(&self, before_ts: &str) -> Result<usize> {
88 Ok(self.conn.execute(
89 "DELETE FROM operation_runs WHERE started_at < ?1",
90 params![before_ts],
91 )?)
92 }
93
94 pub fn insert_metric_snapshot(&self, ts: &str, kpis_json: &str) -> Result<()> {
95 self.conn.execute(
96 "INSERT OR REPLACE INTO metric_snapshots(ts, kpis) VALUES (?1, ?2)",
97 params![ts, kpis_json],
98 )?;
99 Ok(())
100 }
101
102 pub fn latest_snapshot(&self) -> Result<Option<(String, String)>> {
104 Ok(self
105 .conn
106 .query_row(
107 "SELECT ts, kpis FROM metric_snapshots ORDER BY ts DESC LIMIT 1",
108 [],
109 |r| Ok((r.get(0)?, r.get(1)?)),
110 )
111 .optional()?)
112 }
113
114 pub fn recent_snapshots(&self, limit: usize) -> Result<Vec<serde_json::Value>> {
116 let mut stmt = self
117 .conn
118 .prepare("SELECT ts, kpis FROM metric_snapshots ORDER BY ts DESC LIMIT ?1")?;
119 let rows = stmt.query_map(params![limit as i64], |r| {
120 Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
121 })?;
122 Ok(rows
123 .filter_map(|r| r.ok())
124 .map(|(ts, kpis)| {
125 let parsed: serde_json::Value =
126 serde_json::from_str(&kpis).unwrap_or(serde_json::Value::Null);
127 json!({ "ts": ts, "kpis": parsed })
128 })
129 .collect())
130 }
131
132 pub fn snapshot_at_or_before(&self, ts: &str) -> Result<Option<(String, String)>> {
134 Ok(self
135 .conn
136 .query_row(
137 "SELECT ts, kpis FROM metric_snapshots WHERE ts <= ?1 ORDER BY ts DESC LIMIT 1",
138 params![ts],
139 |r| Ok((r.get(0)?, r.get(1)?)),
140 )
141 .optional()?)
142 }
143}
144
145pub fn classify_error(e: &crate::errors::InnateError) -> &'static str {
149 use crate::errors::InnateError as E;
150 match e {
151 E::EmbeddingUnavailable(m) => {
152 if has_arrearage(m) {
153 "embedding_arrearage"
154 } else {
155 "embedding_unavailable"
156 }
157 }
158 E::Db(_) => {
159 let s = e.to_string().to_lowercase();
160 if s.contains("locked") || s.contains("busy") {
161 "db_locked"
162 } else {
163 "db_error"
164 }
165 }
166 E::Json(_) => "json_parse",
167 E::ChunkNotFound(_) => "chunk_not_found",
168 E::InvalidState(_) => "invalid_state",
169 E::Io(_) => "io_error",
170 E::Other(m) => classify_message(m),
171 }
172}
173
174pub fn classify_message(m: &str) -> &'static str {
176 let lm = m.to_lowercase();
177 if has_arrearage(m) {
178 "embedding_arrearage"
179 } else if lm.contains("no such file") || lm.contains("text file busy") {
180 "spawn_failed"
181 } else if lm.contains("timeout") || lm.contains("deadline") {
182 "llm_timeout"
183 } else if lm.contains("status: 4") || lm.contains("status: 5") || lm.contains("http error") {
184 "llm_http_error"
185 } else if lm.contains("locked") || lm.contains("busy") {
186 "db_locked"
187 } else {
188 "other"
189 }
190}
191
192fn has_arrearage(m: &str) -> bool {
193 let lm = m.to_lowercase();
194 lm.contains("arrearage") || m.contains("欠费")
195}
196
197pub fn aggregate_ops(rows: &[OpRunRow]) -> serde_json::Value {
204 let mut err_kind: std::collections::HashMap<&str, i64> = std::collections::HashMap::new();
205 for r in rows {
206 if r.status != "ok" {
207 if let Some(k) = r.error_kind.as_deref() {
208 *err_kind.entry(k).or_insert(0) += 1;
209 }
210 }
211 }
212 let mut top: Vec<(&str, i64)> = err_kind.into_iter().collect();
213 top.sort_by_key(|entry| std::cmp::Reverse(entry.1));
214 let error_kind_top: Vec<serde_json::Value> = top
215 .into_iter()
216 .take(10)
217 .map(|(k, n)| json!({"error_kind": k, "count": n}))
218 .collect();
219 json!({
220 "by_op": group_perf(rows, |r| Some(r.op.as_str())),
221 "by_source": group_perf(rows, |r| r.source.as_deref()),
222 "by_agent": group_perf(rows, |r| r.agent.as_deref()),
223 "by_context": group_perf(rows, |r| r.context.as_deref()),
224 "error_kind_top": error_kind_top,
225 })
226}
227
228fn group_perf<'a>(
232 rows: &'a [OpRunRow],
233 key: impl Fn(&'a OpRunRow) -> Option<&'a str>,
234) -> serde_json::Value {
235 use std::collections::HashMap;
236 let mut g: HashMap<&str, (Vec<i64>, i64, i64, i64)> = HashMap::new();
237 for r in rows {
238 let Some(k) = key(r) else { continue };
239 let e = g.entry(k).or_default();
240 e.0.push(r.duration_ms);
241 match r.status.as_str() {
242 "ok" => e.1 += 1,
243 "timeout" => e.3 += 1,
244 _ => e.2 += 1,
245 }
246 }
247 let mut out = serde_json::Map::new();
248 for (k, (mut durs, ok, err, timeout)) in g {
249 durs.sort_unstable();
250 let total = ok + err + timeout;
251 out.insert(
252 k.to_string(),
253 json!({
254 "count": total,
255 "ok": ok,
256 "error": err,
257 "timeout": timeout,
258 "success_rate": if total > 0 { (ok as f64 / total as f64 * 1000.0).round() / 1000.0 } else { 0.0 },
259 "p50_ms": percentile(&durs, 50),
260 "p95_ms": percentile(&durs, 95),
261 }),
262 );
263 }
264 serde_json::Value::Object(out)
265}
266
267fn percentile(sorted: &[i64], p: usize) -> i64 {
269 if sorted.is_empty() {
270 return 0;
271 }
272 let rank = (p * sorted.len()).div_ceil(100); let idx = rank.saturating_sub(1).min(sorted.len() - 1);
274 sorted[idx]
275}