difflore_core/observability/
injection_log.rs1use std::collections::BTreeMap;
2use std::path::PathBuf;
3
4use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
7#[serde(rename_all = "camelCase")]
8struct InjectionEntry {
9 ts_ms: i64,
10 path: String,
11 rules_injected: usize,
12 #[serde(default, skip_serializing_if = "Option::is_none")]
13 file_path: Option<String>,
14}
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17#[serde(rename_all = "camelCase")]
18struct InjectionLog {
19 version: u32,
20 entries: Vec<InjectionEntry>,
21}
22
23#[derive(Debug, Clone, Default)]
24pub struct InjectionPathSummary {
25 pub count_24h: usize,
26 pub by_path: BTreeMap<String, usize>,
27 pub injected_by_path: BTreeMap<String, usize>,
28 pub total_rules_injected: usize,
29 pub path: Option<PathBuf>,
30 pub detail: Option<String>,
31}
32
33fn now_ms() -> i64 {
34 use std::time::{SystemTime, UNIX_EPOCH};
35 SystemTime::now()
36 .duration_since(UNIX_EPOCH)
37 .map_or(0, |d| d.as_millis() as i64)
38}
39
40fn log_path() -> Option<PathBuf> {
41 crate::paths::data_home()
42 .ok()
43 .map(|dir| dir.join("injection-paths.json"))
44}
45
46pub fn record(path_name: &str, rules_injected: usize, file_path: Option<&str>) {
47 let Some(path) = log_path() else {
48 return;
49 };
50 let cutoff = now_ms().saturating_sub(24 * 60 * 60 * 1000);
51 let mut log = std::fs::read_to_string(&path)
52 .ok()
53 .and_then(|raw| serde_json::from_str::<InjectionLog>(&raw).ok())
54 .unwrap_or(InjectionLog {
55 version: 1,
56 entries: Vec::new(),
57 });
58 log.entries.retain(|entry| entry.ts_ms >= cutoff);
59 log.entries.push(InjectionEntry {
60 ts_ms: now_ms(),
61 path: path_name.to_owned(),
62 rules_injected,
63 file_path: file_path.map(|p| {
64 if p.len() > 200 {
65 p.chars().take(200).collect()
66 } else {
67 p.to_owned()
68 }
69 }),
70 });
71 if log.entries.len() > 2_000 {
72 let keep_from = log.entries.len().saturating_sub(2_000);
73 log.entries = log.entries.split_off(keep_from);
74 }
75 if let Some(parent) = path.parent() {
76 let _ = std::fs::create_dir_all(parent);
77 }
78 if let Ok(json) = serde_json::to_string_pretty(&log) {
79 let _ = std::fs::write(path, json);
80 }
81}
82
83pub fn summary_24h() -> InjectionPathSummary {
84 let Some(path) = log_path() else {
85 return InjectionPathSummary {
86 detail: Some("could not resolve DIFFLORE_HOME".into()),
87 ..InjectionPathSummary::default()
88 };
89 };
90 let Ok(raw) = std::fs::read_to_string(&path) else {
91 return InjectionPathSummary {
92 path: Some(path),
93 detail: Some("no injection path log yet".into()),
94 ..InjectionPathSummary::default()
95 };
96 };
97 let log = match serde_json::from_str::<InjectionLog>(&raw) {
98 Ok(log) => log,
99 Err(e) => {
100 return InjectionPathSummary {
101 path: Some(path),
102 detail: Some(format!("injection path log is unreadable: {e}")),
103 ..InjectionPathSummary::default()
104 };
105 }
106 };
107 let cutoff = now_ms().saturating_sub(24 * 60 * 60 * 1000);
108 let mut summary = InjectionPathSummary {
109 path: Some(path),
110 ..InjectionPathSummary::default()
111 };
112 for entry in log
113 .entries
114 .into_iter()
115 .filter(|entry| entry.ts_ms >= cutoff)
116 {
117 summary.count_24h += 1;
118 *summary.by_path.entry(entry.path.clone()).or_insert(0) += 1;
119 if entry.rules_injected > 0 {
120 *summary.injected_by_path.entry(entry.path).or_insert(0) += 1;
121 summary.total_rules_injected += entry.rules_injected;
122 }
123 }
124 summary
125}