Skip to main content

memory_core/store/
metrics.rs

1use rusqlite::params;
2
3use crate::types::MemoryMetric;
4use crate::Result;
5
6use super::Store;
7
8impl Store {
9    pub fn log_access(&self, session_id: Option<&str>, action: &str, query: Option<&str>, memory_ids: &[i64], tokens_injected: i32) -> Result<()> {
10        let ids_json = serde_json::to_string(memory_ids).unwrap_or_default();
11        self.conn().execute(
12            "INSERT INTO access_log (session_id, action, query, memory_ids, tokens_injected) VALUES (?1, ?2, ?3, ?4, ?5)",
13            params![session_id, action, query, ids_json, tokens_injected],
14        )?;
15        Ok(())
16    }
17
18    /// Returns access_log entries for a session.
19    /// Matches entries by session_id when set (new entries), or by time window (legacy entries).
20    pub fn get_session_access_log(&self, session_id: &str, limit: Option<i32>) -> Result<Vec<crate::types::AccessLogEntry>> {
21        let started_at: String = self.conn().query_row(
22            "SELECT started_at FROM sessions WHERE id = ?1",
23            params![session_id],
24            |r| r.get(0),
25        ).map_err(|_| crate::error::Error::SessionNotFound(session_id.to_string()))?;
26
27        let ended_at: Option<String> = self.conn().query_row(
28            "SELECT ended_at FROM sessions WHERE id = ?1",
29            params![session_id],
30            |r| r.get(0),
31        ).ok().flatten();
32
33        let lim = limit.unwrap_or(1000);
34        let mut stmt = self.conn().prepare(
35            "SELECT id, session_id, action, query, memory_ids, tokens_injected, created_at
36             FROM access_log
37             WHERE session_id = ?1
38                OR (session_id IS NULL AND created_at >= ?2 AND (?3 IS NULL OR created_at <= ?3))
39             ORDER BY created_at ASC
40             LIMIT ?4",
41        )?;
42        let results = stmt.query_map(params![session_id, started_at, ended_at, lim], |row| {
43            Ok(crate::types::AccessLogEntry {
44                id: row.get(0)?,
45                session_id: row.get(1)?,
46                action: row.get(2)?,
47                query: row.get(3)?,
48                memory_ids: row.get(4)?,
49                tokens_injected: row.get(5)?,
50                created_at: row.get(6)?,
51            })
52        })?.collect::<std::result::Result<Vec<_>, _>>()?;
53        Ok(results)
54    }
55
56    /// Returns the N most recent access_log entries regardless of session (for tests).
57    pub fn get_session_access_log_all(&self, limit: i32) -> Result<Vec<crate::types::AccessLogEntry>> {
58        let mut stmt = self.conn().prepare(
59            "SELECT id, session_id, action, query, memory_ids, tokens_injected, created_at
60             FROM access_log ORDER BY created_at DESC LIMIT ?1",
61        )?;
62        let results = stmt.query_map(params![limit], |row| {
63            Ok(crate::types::AccessLogEntry {
64                id: row.get(0)?,
65                session_id: row.get(1)?,
66                action: row.get(2)?,
67                query: row.get(3)?,
68                memory_ids: row.get(4)?,
69                tokens_injected: row.get(5)?,
70                created_at: row.get(6)?,
71            })
72        })?.collect::<std::result::Result<Vec<_>, _>>()?;
73        Ok(results)
74    }
75
76    pub fn delete_access_log_entry(&self, id: i64) -> Result<()> {
77        self.conn().execute("DELETE FROM access_log WHERE id = ?1", params![id])?;
78        Ok(())
79    }
80
81    /// Deletes all access_log entries for the session.
82    /// Removes entries attributed to this session. Legacy entries with session_id IS NULL are
83    /// ambiguous (shared across concurrent sessions) and are left untouched.
84    pub fn clear_session_access_log(&self, session_id: &str) -> Result<()> {
85        // Verify session exists
86        self.conn().query_row(
87            "SELECT id FROM sessions WHERE id = ?1",
88            params![session_id],
89            |r| r.get::<_, String>(0),
90        ).map_err(|_| crate::error::Error::SessionNotFound(session_id.to_string()))?;
91
92        self.conn().execute(
93            "DELETE FROM access_log WHERE session_id = ?1",
94            params![session_id],
95        )?;
96        Ok(())
97    }
98
99    pub fn record_injection(&self, memory_ids: &[i64]) -> Result<()> {
100        let tx = self.conn().unchecked_transaction()?;
101        for id in memory_ids {
102            tx.execute(
103                "INSERT INTO metrics (memory_id, injections, last_injected_at)
104                 VALUES (?1, 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
105                 ON CONFLICT(memory_id) DO UPDATE SET
106                    injections = injections + 1,
107                    last_injected_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')",
108                params![id],
109            )?;
110        }
111        tx.commit()?;
112        Ok(())
113    }
114
115    pub fn record_hit(&self, memory_id: i64) -> Result<()> {
116        self.conn().execute(
117            "INSERT INTO metrics (memory_id, hits, last_hit_at)
118             VALUES (?1, 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
119             ON CONFLICT(memory_id) DO UPDATE SET
120                hits = hits + 1,
121                last_hit_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')",
122            params![memory_id],
123        )?;
124        Ok(())
125    }
126
127    pub fn record_hit_batch(&self, memory_ids: &[i64]) -> Result<()> {
128        let tx = self.conn().unchecked_transaction()?;
129        for id in memory_ids {
130            tx.execute(
131                "INSERT INTO metrics (memory_id, hits, last_hit_at)
132                 VALUES (?1, 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
133                 ON CONFLICT(memory_id) DO UPDATE SET
134                    hits = hits + 1,
135                    last_hit_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')",
136                params![id],
137            )?;
138        }
139        tx.commit()?;
140        Ok(())
141    }
142
143    /// Cumulative token stats across all time.
144    pub fn cumulative_stats(&self) -> Result<crate::types::TokenStats> {
145        let (injections, hits) = self.conn().query_row(
146            "SELECT COALESCE(SUM(injections), 0), COALESCE(SUM(hits), 0) FROM metrics",
147            [],
148            |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)),
149        )?;
150        let unique = self.conn().query_row(
151            "SELECT COUNT(*) FROM metrics WHERE injections > 0",
152            [],
153            |row| row.get::<_, i64>(0),
154        )?;
155        Ok(crate::types::TokenStats {
156            injections,
157            hits,
158            unique_memories_injected: unique,
159        })
160    }
161
162    /// Token stats for a specific session (by time range from session start).
163    pub fn session_token_stats(&self, session_id: &str) -> Result<crate::types::TokenStats> {
164        // Get session start time
165        let started_at: String = self.conn().query_row(
166            "SELECT started_at FROM sessions WHERE id = ?1",
167            params![session_id],
168            |row| row.get(0),
169        ).map_err(|_| crate::error::Error::SessionNotFound(session_id.to_string()))?;
170
171        // Count injection events: each access_log row with action 'context'
172        // contains a JSON array of memory_ids. Sum the array lengths.
173        let injection_rows: Vec<String> = {
174            let mut stmt = self.conn().prepare(
175                "SELECT memory_ids FROM access_log
176                 WHERE action = 'context'
177                 AND created_at >= ?1"
178            )?;
179            stmt.query_map(params![started_at], |row| row.get::<_, String>(0))?
180                .filter_map(|r| r.ok())
181                .collect()
182        };
183        let mut injections: i64 = 0;
184        let mut injected_set = std::collections::HashSet::new();
185        for ids_json in &injection_rows {
186            if let Ok(ids) = serde_json::from_str::<Vec<i64>>(ids_json) {
187                injections += ids.len() as i64;
188                injected_set.extend(ids);
189            }
190        }
191
192        // Count hit events: search, detail, context
193        let hit_rows: Vec<String> = {
194            let mut stmt = self.conn().prepare(
195                "SELECT memory_ids FROM access_log
196                 WHERE action IN ('search', 'detail', 'context')
197                 AND created_at >= ?1"
198            )?;
199            stmt.query_map(params![started_at], |row| row.get::<_, String>(0))?
200                .filter_map(|r| r.ok())
201                .collect()
202        };
203        let mut hits: i64 = 0;
204        for ids_json in &hit_rows {
205            if let Ok(ids) = serde_json::from_str::<Vec<i64>>(ids_json) {
206                hits += ids.len() as i64;
207            }
208        }
209
210        Ok(crate::types::TokenStats {
211            injections,
212            hits,
213            unique_memories_injected: injected_set.len() as i64,
214        })
215    }
216
217    pub fn access_log_stats(&self) -> Result<Vec<(String, i64)>> {
218        let mut stmt = self.conn().prepare(
219            "SELECT action, COUNT(*) FROM access_log GROUP BY action ORDER BY COUNT(*) DESC",
220        )?;
221        let results = stmt
222            .query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)))?
223            .collect::<std::result::Result<Vec<_>, _>>()?;
224        Ok(results)
225    }
226
227    pub fn access_log_total(&self) -> Result<i64> {
228        Ok(self.conn().query_row(
229            "SELECT COUNT(*) FROM access_log",
230            [],
231            |row| row.get(0),
232        )?)
233    }
234
235    pub fn dedup_total(&self) -> Result<i64> {
236        Ok(self.conn().query_row(
237            "SELECT COALESCE(SUM(duplicate_count), 0) FROM memories WHERE deleted_at IS NULL",
238            [],
239            |row| row.get(0),
240        )?)
241    }
242
243    pub fn revision_total(&self) -> Result<i64> {
244        Ok(self.conn().query_row(
245            "SELECT COALESCE(SUM(revision_count), 0) FROM memories WHERE deleted_at IS NULL",
246            [],
247            |row| row.get(0),
248        )?)
249    }
250
251    pub fn low_roi_count(&self) -> Result<i64> {
252        Ok(self.conn().query_row(
253            "SELECT COUNT(*) FROM metrics WHERE injections > 10 AND CAST(hits AS REAL) / injections < 0.1",
254            [],
255            |row| row.get(0),
256        )?)
257    }
258
259    pub fn top_searches(&self, limit: i32) -> Result<Vec<(String, i64)>> {
260        let mut stmt = self.conn().prepare(
261            "SELECT query, COUNT(*) as cnt FROM access_log
262             WHERE action = 'search' AND query IS NOT NULL
263             GROUP BY lower(query)
264             ORDER BY cnt DESC
265             LIMIT ?1",
266        )?;
267        let results = stmt
268            .query_map(rusqlite::params![limit], |row| {
269                Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
270            })?
271            .collect::<std::result::Result<Vec<_>, _>>()?;
272        Ok(results)
273    }
274
275    pub fn get_metrics(&self) -> Result<Vec<MemoryMetric>> {
276        let mut stmt = self.conn().prepare(
277            "SELECT m.id, m.key, m.scope,
278                    COALESCE(mt.injections, 0),
279                    COALESCE(mt.hits, 0),
280                    COALESCE(mt.tokens_injected, 0),
281                    CASE WHEN COALESCE(mt.injections, 0) > 0
282                         THEN CAST(COALESCE(mt.hits, 0) AS REAL) / mt.injections
283                         ELSE 0.0 END
284             FROM memories m
285             LEFT JOIN metrics mt ON mt.memory_id = m.id
286             WHERE m.deleted_at IS NULL
287             ORDER BY COALESCE(mt.injections, 0) DESC
288             LIMIT 100",
289        )?;
290        let results = stmt
291            .query_map([], |row| {
292                Ok(MemoryMetric {
293                    id: row.get(0)?,
294                    key: row.get(1)?,
295                    scope: row.get(2)?,
296                    injections: row.get(3)?,
297                    hits: row.get(4)?,
298                    tokens_injected: row.get(5)?,
299                    hit_rate: row.get(6)?,
300                })
301            })?
302            .collect::<std::result::Result<Vec<_>, _>>()?;
303        Ok(results)
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    use crate::store::Store;
310    use crate::types::SaveParams;
311
312    fn make_memory(store: &Store, key: &str) -> i64 {
313        store
314            .save(SaveParams {
315                key: key.to_string(),
316                value: "test value".to_string(),
317                ..Default::default()
318            })
319            .unwrap()
320            .id()
321    }
322
323    #[test]
324    fn test_log_access_no_query() {
325        let store = Store::open_in_memory().unwrap();
326        store.log_access(None, "search", None, &[], 0).unwrap();
327    }
328
329    #[test]
330    fn test_log_access_with_query_and_ids() {
331        let store = Store::open_in_memory().unwrap();
332        let id = make_memory(&store, "test/key");
333        store.log_access(None, "search", Some("test query"), &[id], 0).unwrap();
334    }
335
336    #[test]
337    fn test_record_injection_increments() {
338        let store = Store::open_in_memory().unwrap();
339        let id = make_memory(&store, "test/key");
340
341        store.record_injection(&[id]).unwrap();
342        store.record_injection(&[id]).unwrap();
343
344        let metrics = store.get_metrics().unwrap();
345        let m = metrics.iter().find(|m| m.id == id).unwrap();
346        assert_eq!(m.injections, 2);
347    }
348
349    #[test]
350    fn test_record_hit_increments() {
351        let store = Store::open_in_memory().unwrap();
352        let id = make_memory(&store, "test/key");
353
354        store.record_hit(id).unwrap();
355        store.record_hit(id).unwrap();
356        store.record_hit(id).unwrap();
357
358        let metrics = store.get_metrics().unwrap();
359        let m = metrics.iter().find(|m| m.id == id).unwrap();
360        assert_eq!(m.hits, 3);
361    }
362
363    #[test]
364    fn test_hit_rate_calculation() {
365        let store = Store::open_in_memory().unwrap();
366        let id = make_memory(&store, "test/key");
367
368        store.record_injection(&[id]).unwrap();
369        store.record_injection(&[id]).unwrap();
370        store.record_hit(id).unwrap();
371
372        let metrics = store.get_metrics().unwrap();
373        let m = metrics.iter().find(|m| m.id == id).unwrap();
374        assert_eq!(m.injections, 2);
375        assert_eq!(m.hits, 1);
376        assert!((m.hit_rate - 0.5).abs() < f64::EPSILON);
377    }
378
379    #[test]
380    fn test_hit_rate_zero_when_no_injections() {
381        let store = Store::open_in_memory().unwrap();
382        let id = make_memory(&store, "test/key");
383
384        let metrics = store.get_metrics().unwrap();
385        let m = metrics.iter().find(|m| m.id == id).unwrap();
386        assert_eq!(m.hit_rate, 0.0);
387    }
388
389    #[test]
390    fn test_get_metrics_excludes_deleted() {
391        let store = Store::open_in_memory().unwrap();
392        let id = make_memory(&store, "test/key");
393        store.delete("test/key", None, false).unwrap();
394
395        let metrics = store.get_metrics().unwrap();
396        assert!(metrics.iter().find(|m| m.id == id).is_none());
397    }
398
399    #[test]
400    fn test_get_metrics_ordered_by_injections_desc() {
401        let store = Store::open_in_memory().unwrap();
402        let id1 = make_memory(&store, "key/one");
403        let id2 = make_memory(&store, "key/two");
404
405        store.record_injection(&[id1]).unwrap();
406        store.record_injection(&[id1]).unwrap();
407        store.record_injection(&[id1]).unwrap();
408        store.record_injection(&[id2]).unwrap();
409
410        let metrics = store.get_metrics().unwrap();
411        let pos1 = metrics.iter().position(|m| m.id == id1).unwrap();
412        let pos2 = metrics.iter().position(|m| m.id == id2).unwrap();
413        assert!(pos1 < pos2, "id1 (3 injections) should come before id2 (1 injection)");
414    }
415
416    #[test]
417    fn test_record_injection_multiple_ids() {
418        let store = Store::open_in_memory().unwrap();
419        let id1 = make_memory(&store, "key/one");
420        let id2 = make_memory(&store, "key/two");
421
422        store.record_injection(&[id1, id2]).unwrap();
423
424        let metrics = store.get_metrics().unwrap();
425        let m1 = metrics.iter().find(|m| m.id == id1).unwrap();
426        let m2 = metrics.iter().find(|m| m.id == id2).unwrap();
427        assert_eq!(m1.injections, 1);
428        assert_eq!(m2.injections, 1);
429    }
430
431    #[test]
432    fn test_migration_006_columns_exist() {
433        let store = Store::open_in_memory().unwrap();
434        // tokens_injected column should exist on access_log
435        store.conn().execute(
436            "INSERT INTO access_log (action, tokens_injected) VALUES ('context', 42)",
437            [],
438        ).unwrap();
439        let tok: i32 = store.conn().query_row(
440            "SELECT tokens_injected FROM access_log WHERE action = 'context'",
441            [],
442            |r| r.get(0),
443        ).unwrap();
444        assert_eq!(tok, 42);
445
446        // tokens_used_input/output on sessions — just verify UPDATE doesn't fail
447        store.conn().execute(
448            "UPDATE sessions SET tokens_used_input = 100, tokens_used_output = 50 WHERE 1=0",
449            [],
450        ).unwrap();
451    }
452
453    #[test]
454    fn test_get_session_access_log_returns_entries() {
455        let store = Store::open_in_memory().unwrap();
456        let session = store.session_start("test-proj", None).unwrap();
457        store.log_access(Some(&session.id), "context", None, &[], 320).unwrap();
458        store.log_access(Some(&session.id), "search", Some("rust errors"), &[], 0).unwrap();
459        let entries = store.get_session_access_log(&session.id, None).unwrap();
460        assert_eq!(entries.len(), 2);
461        assert_eq!(entries[0].action, "context");
462        assert_eq!(entries[0].tokens_injected, 320);
463        assert_eq!(entries[1].action, "search");
464    }
465
466    #[test]
467    fn test_get_session_access_log_isolation() {
468        // Entries from a concurrent session must NOT appear in another session's log.
469        let store = Store::open_in_memory().unwrap();
470        let session_a = store.session_start("project-a", None).unwrap();
471        let session_b = store.session_start("project-b", None).unwrap();
472        store.log_access(Some(&session_a.id), "search", Some("aaa"), &[], 0).unwrap();
473        store.log_access(Some(&session_b.id), "search", Some("bbb"), &[], 0).unwrap();
474        let entries_a = store.get_session_access_log(&session_a.id, None).unwrap();
475        let entries_b = store.get_session_access_log(&session_b.id, None).unwrap();
476        assert_eq!(entries_a.len(), 1);
477        assert_eq!(entries_a[0].query.as_deref(), Some("aaa"));
478        assert_eq!(entries_b.len(), 1);
479        assert_eq!(entries_b[0].query.as_deref(), Some("bbb"));
480    }
481
482    #[test]
483    fn test_delete_access_log_entry() {
484        let store = Store::open_in_memory().unwrap();
485        store.log_access(None, "context", None, &[], 100).unwrap();
486        let entries = store.get_session_access_log_all(10).unwrap();
487        assert_eq!(entries.len(), 1);
488        let id = entries[0].id;
489        store.delete_access_log_entry(id).unwrap();
490        let entries = store.get_session_access_log_all(10).unwrap();
491        assert_eq!(entries.len(), 0);
492    }
493
494    #[test]
495    fn test_clear_session_access_log() {
496        let store = Store::open_in_memory().unwrap();
497        let session = store.session_start("proj", None).unwrap();
498        store.log_access(Some(&session.id), "context", None, &[], 100).unwrap();
499        store.log_access(Some(&session.id), "search", Some("q"), &[], 0).unwrap();
500        store.clear_session_access_log(&session.id).unwrap();
501        let entries = store.get_session_access_log(&session.id, None).unwrap();
502        assert_eq!(entries.len(), 0);
503    }
504}