1use chrono::{DateTime, NaiveDate, Utc};
7use serde::{Deserialize, Serialize};
8use std::fs::{OpenOptions, create_dir_all};
9use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write};
10use std::path::{Path, PathBuf};
11
12use fs2::FileExt;
13
14const METERING_FILE: &str = "metering.jsonl";
15
16pub const NATIVE_PASSTHROUGH_TOOL: &str = "native_shell_passthrough";
19
20const TAIL_BLOCK_BYTES: usize = 64 * 1024;
22
23#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
25pub struct DayTotals {
26 pub input_tokens: u64,
27 pub savings_tokens: u64,
28 pub routed_calls: u64,
30 pub native_passthrough_calls: u64,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35pub struct MeterEntry {
36 pub timestamp: DateTime<Utc>,
37 pub tool_name: String,
38 pub input_tokens: u64,
39 pub output_tokens: u64,
40 pub savings_tokens: u64,
41 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub project: Option<String>,
45}
46
47impl MeterEntry {
48 #[must_use]
49 pub fn new(
50 tool_name: impl Into<String>,
51 input_tokens: u64,
52 output_tokens: u64,
53 savings_tokens: u64,
54 ) -> Self {
55 Self {
56 timestamp: Utc::now(),
57 tool_name: tool_name.into(),
58 input_tokens,
59 output_tokens,
60 savings_tokens,
61 project: crate::core::savings_ledger::project_scope::current_project_key(),
62 }
63 }
64}
65
66#[derive(Debug, Clone, Copy, Default, PartialEq)]
68pub struct MeterAggregate {
69 pub total_savings_tokens: u64,
70 pub total_calls: u64,
71 pub avg_compression_ratio: f64,
73}
74
75#[derive(Debug, Clone)]
77pub struct MeterStore {
78 path: PathBuf,
79}
80
81impl MeterStore {
82 #[must_use]
83 pub fn new(data_dir: impl AsRef<Path>) -> Self {
84 Self {
85 path: data_dir.as_ref().join(METERING_FILE),
86 }
87 }
88
89 pub fn from_data_dir() -> Result<Self, String> {
90 Ok(Self::new(crate::core::data_dir::lean_ctx_data_dir()?))
91 }
92
93 #[must_use]
94 pub fn path(&self) -> &Path {
95 &self.path
96 }
97
98 pub fn append(&self, entry: &MeterEntry) -> Result<(), String> {
100 let Some(parent) = self.path.parent() else {
101 return Err("metering path has no parent".to_string());
102 };
103 create_dir_all(parent).map_err(|error| error.to_string())?;
104 let line = serde_json::to_string(entry).map_err(|error| error.to_string())?;
105 let mut file = OpenOptions::new()
106 .create(true)
107 .read(true)
108 .append(true)
109 .open(&self.path)
110 .map_err(|error| error.to_string())?;
111 file.lock_exclusive().map_err(|error| error.to_string())?;
112 let write_result = writeln!(file, "{line}").map_err(|error| error.to_string());
113 let unlock_result = FileExt::unlock(&file).map_err(|error| error.to_string());
114 write_result.and(unlock_result)
115 }
116
117 pub fn append_best_effort(entry: MeterEntry) {
119 tokio::task::spawn_blocking(move || {
120 let result = Self::from_data_dir().and_then(|store| store.append(&entry));
121 if let Err(error) = result {
122 tracing::warn!(%error, "lean-ctx: failed to append local metering entry");
123 }
124 });
125 }
126
127 #[must_use]
128 pub fn aggregate(&self) -> MeterAggregate {
129 let Ok(file) = std::fs::File::open(&self.path) else {
130 return MeterAggregate::default();
131 };
132 let mut aggregate = MeterAggregate::default();
133 let mut total_input = 0_u64;
134 let mut total_output = 0_u64;
135 for line in BufReader::new(file).lines().map_while(Result::ok) {
136 let Ok(entry) = serde_json::from_str::<MeterEntry>(&line) else {
137 continue;
138 };
139 aggregate.total_calls = aggregate.total_calls.saturating_add(1);
140 aggregate.total_savings_tokens = aggregate
141 .total_savings_tokens
142 .saturating_add(entry.savings_tokens);
143 total_input = total_input.saturating_add(entry.input_tokens);
144 total_output = total_output.saturating_add(entry.output_tokens);
145 }
146 if total_input > 0 {
147 aggregate.avg_compression_ratio = total_output as f64 / total_input as f64;
148 }
149 aggregate
150 }
151
152 #[must_use]
156 pub fn day_totals(&self, day: NaiveDate) -> DayTotals {
157 self.day_totals_in_blocks(day, TAIL_BLOCK_BYTES, |_| true)
158 }
159
160 #[must_use]
162 pub fn day_totals_where(
163 &self,
164 day: NaiveDate,
165 keep: impl Fn(Option<&str>) -> bool,
166 ) -> DayTotals {
167 self.day_totals_in_blocks(day, TAIL_BLOCK_BYTES, keep)
168 }
169
170 fn day_totals_in_blocks(
171 &self,
172 day: NaiveDate,
173 block: usize,
174 keep: impl Fn(Option<&str>) -> bool,
175 ) -> DayTotals {
176 let mut totals = DayTotals::default();
177 let Ok(mut file) = std::fs::File::open(&self.path) else {
178 return totals;
179 };
180 let Ok(mut pos) = file.seek(SeekFrom::End(0)) else {
181 return totals;
182 };
183 let mut carry = Vec::new();
185 while pos > 0 {
186 let len = usize::try_from(pos).map_or(block, |p| p.min(block));
187 pos -= len as u64;
188 let mut buf = vec![0; len];
189 if file.seek(SeekFrom::Start(pos)).is_err() || file.read_exact(&mut buf).is_err() {
190 return totals;
191 }
192 buf.extend_from_slice(&carry);
193 let mut lines: Vec<&[u8]> = buf.split(|b| *b == b'\n').collect();
194 carry = if pos > 0 {
196 lines.remove(0).to_vec()
197 } else {
198 Vec::new()
199 };
200 for line in lines.into_iter().rev() {
201 let Ok(entry) = serde_json::from_slice::<MeterEntry>(line) else {
202 continue;
203 };
204 let date = entry.timestamp.date_naive();
205 if date < day {
206 return totals;
207 }
208 if date > day || !keep(entry.project.as_deref()) {
209 continue;
210 }
211 totals.input_tokens = totals.input_tokens.saturating_add(entry.input_tokens);
212 totals.savings_tokens = totals.savings_tokens.saturating_add(entry.savings_tokens);
213 if entry.tool_name == NATIVE_PASSTHROUGH_TOOL {
214 totals.native_passthrough_calls += 1;
215 } else {
216 totals.routed_calls += 1;
217 }
218 }
219 }
220 totals
221 }
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227
228 #[test]
229 fn aggregates_jsonl_entries() {
230 let dir = tempfile::tempdir().unwrap();
231 let store = MeterStore::new(dir.path());
232 store
233 .append(&MeterEntry::new("ctx_read", 100, 20, 80))
234 .unwrap();
235 store
236 .append(&MeterEntry::new("ctx_search", 200, 100, 100))
237 .unwrap();
238
239 let aggregate = store.aggregate();
240 assert_eq!(aggregate.total_calls, 2);
241 assert_eq!(aggregate.total_savings_tokens, 180);
242 assert!((aggregate.avg_compression_ratio - 0.4).abs() < f64::EPSILON);
243 }
244
245 #[test]
246 fn day_totals_reads_only_the_requested_day_across_block_boundaries() {
247 let dir = tempfile::tempdir().unwrap();
248 let store = MeterStore::new(dir.path());
249 let at = |ts: &str, tool: &str, input, saved| MeterEntry {
250 timestamp: ts.parse().unwrap(),
251 tool_name: tool.into(),
252 input_tokens: input,
253 output_tokens: input - saved,
254 savings_tokens: saved,
255 project: (tool == "ctx_search").then(|| "0123456789abcdef".to_string()),
256 };
257 store
258 .append(&at("2026-10-01T23:59:59Z", "ctx_read", 1_000, 900))
259 .unwrap();
260 store
261 .append(&at("2026-10-02T00:00:01Z", "ctx_read", 100, 80))
262 .unwrap();
263 std::fs::OpenOptions::new()
264 .append(true)
265 .open(store.path())
266 .unwrap()
267 .write_all(b"{not json\n")
268 .unwrap();
269 store
270 .append(&at("2026-10-02T09:00:00Z", NATIVE_PASSTHROUGH_TOOL, 0, 0))
271 .unwrap();
272 store
273 .append(&at("2026-10-02T10:00:00Z", "ctx_search", 50, 10))
274 .unwrap();
275
276 let day = NaiveDate::from_ymd_opt(2026, 10, 2).unwrap();
277 let expected = DayTotals {
278 input_tokens: 150,
279 savings_tokens: 90,
280 routed_calls: 2,
281 native_passthrough_calls: 1,
282 };
283 assert_eq!(store.day_totals(day), expected);
284 assert_eq!(store.day_totals_in_blocks(day, 7, |_| true), expected);
286 let project = store.day_totals_where(day, |key| key == Some("0123456789abcdef"));
288 assert_eq!(
289 project,
290 DayTotals {
291 input_tokens: 50,
292 savings_tokens: 10,
293 routed_calls: 1,
294 native_passthrough_calls: 0,
295 }
296 );
297 assert_eq!(
298 MeterStore::new(dir.path().join("missing")).day_totals(day),
299 DayTotals::default()
300 );
301 }
302
303 #[tokio::test]
304 async fn best_effort_append_does_not_block_calling_task() {
305 let entry = MeterEntry::new("ctx_read", 10, 2, 8);
306 let task = tokio::spawn(async move {
307 MeterStore::append_best_effort(entry);
308 42_u8
309 });
310 assert_eq!(task.await.unwrap(), 42);
311 }
312}