1use std::sync::Arc;
5
6use async_trait::async_trait;
7use chrono::{DateTime, Utc};
8use futures::future::join_all;
9use parking_lot::Mutex;
10use tokio::sync::mpsc;
11
12use crate::telemetry::GatewayMetrics;
13
14pub mod connect;
15pub mod event;
16#[cfg(feature = "ledger-postgres")]
17pub mod postgres;
18#[cfg(feature = "ledger-pubsub")]
19pub mod pubsub;
20#[cfg(feature = "ledger-sns")]
21pub mod sns;
22#[cfg(feature = "ledger-sqlite")]
23pub mod sqlite;
24
25#[derive(Debug, Clone)]
26pub struct UsageEntry {
27 pub ts: DateTime<Utc>,
28 pub tenant: String,
29 pub workspace: Option<String>,
30 pub user: Option<String>,
31 pub thread: Option<String>,
32 pub message: Option<String>,
33 pub route: String,
34 pub provider: String,
35 pub model: String,
36 pub lane: String,
37 pub input_tokens: u64,
38 pub output_tokens: u64,
39 pub cost_usd: f64,
40 pub request_id: String,
41 pub status: String,
42 pub op: String,
44 pub user_task_type: Option<String>,
47 pub ai_task_type: String,
51}
52
53#[derive(Debug, thiserror::Error)]
54pub enum LedgerError {
55 #[error("ledger backend error: {0}")]
56 Backend(String),
57}
58
59#[async_trait]
60pub trait LedgerStore: Send + Sync {
61 async fn record(&self, entry: &UsageEntry) -> Result<(), LedgerError>;
62}
63
64#[derive(Default)]
66pub struct NoopLedger;
67
68#[async_trait]
69impl LedgerStore for NoopLedger {
70 async fn record(&self, _entry: &UsageEntry) -> Result<(), LedgerError> {
71 Ok(())
72 }
73}
74
75#[derive(Clone)]
77pub struct LedgerHandle {
78 tx: mpsc::Sender<UsageEntry>,
79 metrics: Arc<GatewayMetrics>,
80}
81
82impl LedgerHandle {
83 pub fn spawn(store: Arc<dyn LedgerStore>, capacity: usize) -> Self {
85 Self::spawn_with_metrics(store, capacity, GatewayMetrics::noop())
86 }
87
88 pub fn spawn_with_metrics(
91 store: Arc<dyn LedgerStore>,
92 capacity: usize,
93 metrics: Arc<GatewayMetrics>,
94 ) -> Self {
95 let (tx, mut rx) = mpsc::channel::<UsageEntry>(capacity);
96 let writer_metrics = metrics.clone();
97 tokio::spawn(async move {
98 while let Some(entry) = rx.recv().await {
99 if let Err(e) = store.record(&entry).await {
100 tracing::warn!(
101 error = %e,
102 tenant = %entry.tenant,
103 request_id = %entry.request_id,
104 "ledger write failed"
105 );
106 writer_metrics.ledger_error("writer");
107 }
108 }
109 tracing::warn!("ledger background writer stopped");
110 });
111 Self { tx, metrics }
112 }
113
114 pub fn enqueue(&self, entry: UsageEntry) {
116 if self.tx.try_send(entry).is_err() {
117 self.metrics.ledger_dropped();
118 }
119 }
120}
121
122#[derive(Default)]
124pub struct InMemoryLedger {
125 pub entries: Mutex<Vec<UsageEntry>>,
126}
127
128impl InMemoryLedger {
129 #[cfg(test)]
131 pub fn entries(&self) -> Vec<UsageEntry> {
132 self.entries.lock().clone()
133 }
134}
135
136#[async_trait]
137impl LedgerStore for InMemoryLedger {
138 async fn record(&self, entry: &UsageEntry) -> Result<(), LedgerError> {
139 self.entries.lock().push(entry.clone());
140 Ok(())
141 }
142}
143
144pub struct FanoutLedger {
149 sinks: Vec<(&'static str, Arc<dyn LedgerStore>)>,
150 metrics: Arc<GatewayMetrics>,
151}
152
153impl FanoutLedger {
154 pub fn new(sinks: Vec<(&'static str, Arc<dyn LedgerStore>)>) -> Self {
155 Self {
156 sinks,
157 metrics: GatewayMetrics::noop(),
158 }
159 }
160
161 pub fn with_metrics(self, metrics: Arc<GatewayMetrics>) -> Self {
163 Self { metrics, ..self }
164 }
165}
166
167#[async_trait]
168impl LedgerStore for FanoutLedger {
169 async fn record(&self, entry: &UsageEntry) -> Result<(), LedgerError> {
170 let metrics = &self.metrics;
171 let futs = self.sinks.iter().map(|(label, sink)| async move {
172 if let Err(e) = sink.record(entry).await {
173 tracing::warn!(backend = label, error = %e, tenant = %entry.tenant, "ledger sink write failed");
174 metrics.ledger_error(label);
175 }
176 });
177 join_all(futs).await;
178 Ok(())
179 }
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185
186 fn entry() -> UsageEntry {
187 UsageEntry {
188 ts: Utc::now(),
189 tenant: "acme".into(),
190 workspace: None,
191 user: None,
192 thread: None,
193 message: None,
194 route: "fast".into(),
195 provider: "vertex".into(),
196 model: "gemini-3-flash".into(),
197 lane: "standard".into(),
198 input_tokens: 3,
199 output_tokens: 5,
200 cost_usd: 0.001,
201 request_id: "r1".into(),
202 status: "ok".into(),
203 op: "chat".into(),
204 user_task_type: None,
205 ai_task_type: "simple".into(),
206 }
207 }
208
209 #[tokio::test]
210 async fn in_memory_records_directly() {
211 let store = InMemoryLedger::default();
212 store.record(&entry()).await.unwrap();
213 assert_eq!(store.entries.lock().len(), 1);
214 }
215
216 #[tokio::test]
217 async fn handle_drains_into_store() {
218 let store = Arc::new(InMemoryLedger::default());
219 let handle = LedgerHandle::spawn(store.clone(), 16);
220 handle.enqueue(entry());
221 for _ in 0..50 {
223 if store.entries.lock().len() == 1 {
224 break;
225 }
226 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
227 }
228 assert_eq!(store.entries.lock().len(), 1);
229 }
230
231 struct FailingLedger;
232 #[async_trait]
233 impl LedgerStore for FailingLedger {
234 async fn record(&self, _e: &UsageEntry) -> Result<(), LedgerError> {
235 Err(LedgerError::Backend("boom".into()))
236 }
237 }
238
239 #[tokio::test]
240 async fn fanout_records_to_all_sinks() {
241 let a = Arc::new(InMemoryLedger::default());
242 let b = Arc::new(InMemoryLedger::default());
243 let fanout = FanoutLedger::new(vec![
244 ("a", a.clone() as Arc<dyn LedgerStore>),
245 ("b", b.clone() as Arc<dyn LedgerStore>),
246 ]);
247 fanout.record(&entry()).await.unwrap();
248 assert_eq!(a.entries.lock().len(), 1);
249 assert_eq!(b.entries.lock().len(), 1);
250 }
251
252 #[tokio::test]
253 async fn handle_keeps_accepting_after_write_failures() {
254 let handle = LedgerHandle::spawn(Arc::new(FailingLedger), 16);
255 handle.enqueue(entry());
256 handle.enqueue(entry());
257 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
258 handle.enqueue(entry());
259 }
260
261 #[tokio::test]
262 async fn fanout_survives_a_failing_sink_and_returns_ok() {
263 let healthy = Arc::new(InMemoryLedger::default());
264 let fanout = FanoutLedger::new(vec![
265 ("fail", Arc::new(FailingLedger) as Arc<dyn LedgerStore>),
266 ("mem", healthy.clone() as Arc<dyn LedgerStore>),
267 ]);
268 let r = fanout.record(&entry()).await;
269 assert!(r.is_ok());
270 assert_eq!(healthy.entries.lock().len(), 1);
271 }
272
273 #[cfg(feature = "server")]
274 async fn scraped_until(exporter: &crate::telemetry::MetricsExporter, line: &str) -> String {
275 for _ in 0..50 {
276 let text = crate::telemetry::scrape(exporter);
277 if text.lines().any(|l| l == line) {
278 return text;
279 }
280 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
281 }
282 panic!(
283 "missing `{line}` in:\n{}",
284 crate::telemetry::scrape(exporter)
285 );
286 }
287
288 #[cfg(feature = "server")]
289 #[tokio::test]
290 async fn writer_failures_are_counted_on_the_given_metrics() {
291 let (m, exporter) = crate::telemetry::test_metrics();
292 let handle = LedgerHandle::spawn_with_metrics(Arc::new(FailingLedger), 16, m);
293 handle.enqueue(entry());
294 scraped_until(
295 &exporter,
296 r#"synapse_ledger_errors_total{backend="writer"} 1"#,
297 )
298 .await;
299 }
300
301 #[cfg(feature = "server")]
302 #[tokio::test]
303 async fn full_channel_drops_are_counted_on_the_given_metrics() {
304 struct SlowLedger;
305 #[async_trait]
306 impl LedgerStore for SlowLedger {
307 async fn record(&self, _e: &UsageEntry) -> Result<(), LedgerError> {
308 tokio::time::sleep(std::time::Duration::from_secs(5)).await;
309 Ok(())
310 }
311 }
312 let (m, exporter) = crate::telemetry::test_metrics();
313 let handle = LedgerHandle::spawn_with_metrics(Arc::new(SlowLedger), 1, m);
314 (0..3).for_each(|_| handle.enqueue(entry()));
315 let dropped: u64 = crate::telemetry::scrape(&exporter)
316 .lines()
317 .find_map(|l| l.strip_prefix("synapse_ledger_dropped_total "))
318 .and_then(|v| v.parse().ok())
319 .unwrap();
320 assert!(dropped >= 1, "expected at least one drop, got {dropped}");
321 }
322
323 #[cfg(feature = "server")]
324 #[tokio::test]
325 async fn fanout_sink_failures_are_counted_per_backend() {
326 let (m, exporter) = crate::telemetry::test_metrics();
327 let fanout = FanoutLedger::new(vec![
328 ("fail", Arc::new(FailingLedger) as Arc<dyn LedgerStore>),
329 (
330 "mem",
331 Arc::new(InMemoryLedger::default()) as Arc<dyn LedgerStore>,
332 ),
333 ])
334 .with_metrics(m);
335 fanout.record(&entry()).await.unwrap();
336 scraped_until(
337 &exporter,
338 r#"synapse_ledger_errors_total{backend="fail"} 1"#,
339 )
340 .await;
341 }
342}