Skip to main content

lean_ctx/core/addons/
meter.rs

1//! Per-addon / per-tool usage metering (P5 — discovery & observability).
2//!
3//! Every gateway proxy call ([`crate::core::gateway::proxy`]) is attributed to
4//! its owning server and tool, and counted in a local ledger
5//! (`<data_dir>/addons/usage.json`). This is the foundation for marketplace
6//! analytics, builder dashboards and usage-metered billing (Track B) — without
7//! it there is no honest basis to pay a builder or show "most-used" tools.
8//!
9//! Local-only and side-channel: metering writes to a state file, never to a tool
10//! output body, so it cannot perturb output determinism (#498). Controlled by
11//! `addons.metering` (default on).
12
13use std::collections::BTreeMap;
14use std::path::PathBuf;
15use std::sync::Mutex;
16
17use serde::{Deserialize, Serialize};
18
19/// Call counters for a single downstream tool.
20#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
21pub struct ToolStat {
22    /// Total proxied calls (success + error).
23    pub calls: u64,
24    /// Subset that returned an error (transport failure or `is_error`).
25    pub errors: u64,
26}
27
28/// Aggregated usage for one gateway server (= one addon).
29#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
30pub struct ServerUsage {
31    pub calls: u64,
32    pub errors: u64,
33    /// Per-tool breakdown, keyed by tool name.
34    #[serde(default)]
35    pub tools: BTreeMap<String, ToolStat>,
36}
37
38/// The on-disk usage ledger (`<data_dir>/addons/usage.json`).
39#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
40pub struct UsageLedger {
41    /// Per-server usage, keyed by gateway server name (the addon slug).
42    #[serde(default)]
43    pub servers: BTreeMap<String, ServerUsage>,
44}
45
46/// Serialises read-modify-write so concurrent proxy calls in one process don't
47/// clobber each other's increments.
48static WRITE_LOCK: Mutex<()> = Mutex::new(());
49
50fn ledger_path() -> Result<PathBuf, String> {
51    Ok(crate::core::data_dir::lean_ctx_data_dir()?
52        .join("addons")
53        .join("usage.json"))
54}
55
56impl UsageLedger {
57    /// Load the ledger, or an empty one if it does not exist / is unreadable.
58    #[must_use]
59    pub fn load() -> Self {
60        let Ok(path) = ledger_path() else {
61            return Self::default();
62        };
63        match std::fs::read_to_string(&path) {
64            Ok(raw) if !raw.trim().is_empty() => serde_json::from_str(&raw).unwrap_or_default(),
65            _ => Self::default(),
66        }
67    }
68
69    /// Persist the ledger (creating the `addons/` dir as needed).
70    pub fn save(&self) -> Result<(), String> {
71        let path = ledger_path()?;
72        if let Some(parent) = path.parent() {
73            std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
74        }
75        let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
76        std::fs::write(&path, json).map_err(|e| e.to_string())
77    }
78
79    /// Apply one call to the in-memory ledger. Pure — the unit-testable core of
80    /// [`record`].
81    pub fn record_into(&mut self, server: &str, tool: &str, ok: bool) {
82        let su = self.servers.entry(server.to_string()).or_default();
83        su.calls += 1;
84        let ts = su.tools.entry(tool.to_string()).or_default();
85        ts.calls += 1;
86        if !ok {
87            su.errors += 1;
88            ts.errors += 1;
89        }
90    }
91
92    /// Servers sorted by total calls (descending) — the "most-used" ordering for
93    /// discovery / dashboards. Ties broken by name for determinism.
94    #[must_use]
95    pub fn by_usage(&self) -> Vec<(&String, &ServerUsage)> {
96        let mut v: Vec<_> = self.servers.iter().collect();
97        v.sort_by(|a, b| b.1.calls.cmp(&a.1.calls).then_with(|| a.0.cmp(b.0)));
98        v
99    }
100}
101
102/// Record a single proxied call for `server::tool`. No-op when
103/// `addons.metering` is off or the data dir is unavailable. Best-effort: a
104/// metering write failure never affects the proxied call's result.
105pub fn record(server: &str, tool: &str, ok: bool) {
106    if !crate::core::config::Config::load().addons.metering {
107        return;
108    }
109    let Ok(_guard) = WRITE_LOCK.lock() else {
110        return;
111    };
112    let mut ledger = UsageLedger::load();
113    ledger.record_into(server, tool, ok);
114    if let Err(e) = ledger.save() {
115        tracing::debug!("[addon-meter] could not persist usage: {e}");
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122    use crate::core::data_dir::isolated_data_dir;
123
124    #[test]
125    fn record_into_counts_calls_and_errors() {
126        let mut l = UsageLedger::default();
127        l.record_into("git", "commit", true);
128        l.record_into("git", "commit", false);
129        l.record_into("git", "status", true);
130
131        let git = &l.servers["git"];
132        assert_eq!(git.calls, 3);
133        assert_eq!(git.errors, 1);
134        assert_eq!(git.tools["commit"].calls, 2);
135        assert_eq!(git.tools["commit"].errors, 1);
136        assert_eq!(git.tools["status"].calls, 1);
137        assert_eq!(git.tools["status"].errors, 0);
138    }
139
140    #[test]
141    fn by_usage_is_descending_and_deterministic() {
142        let mut l = UsageLedger::default();
143        for _ in 0..5 {
144            l.record_into("busy", "t", true);
145        }
146        l.record_into("quiet", "t", true);
147        let order: Vec<&str> = l.by_usage().iter().map(|(n, _)| n.as_str()).collect();
148        assert_eq!(order, vec!["busy", "quiet"]);
149    }
150
151    #[test]
152    fn round_trips_through_disk() {
153        let _iso = isolated_data_dir();
154        record("demo", "tool", true);
155        record("demo", "tool", false);
156        let reloaded = UsageLedger::load();
157        assert_eq!(reloaded.servers["demo"].calls, 2);
158        assert_eq!(reloaded.servers["demo"].errors, 1);
159    }
160}