Skip to main content

synapse/ledger/
mod.rs

1//! Pluggable cost ledger. The hot path enqueues onto a bounded channel drained
2//! by a background writer; on a full channel we drop + count, never block.
3
4use 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    /// Lane discriminator for the ledger: "chat" or "embedding".
43    pub op: String,
44    /// Caller-supplied classification of the work this request serves
45    /// (`x-synapse-user-task-type`). Free-form; the gateway never interprets it.
46    pub user_task_type: Option<String>,
47    /// Classification of the work the *gateway* performed: the caller's
48    /// `x-synapse-ai-task-type` when supplied, else inferred from the route
49    /// alias, else `"simple"`. Always resolved, so never optional.
50    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/// Discards all usage events. Used when no ledger sink could be connected.
65#[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/// Fire-and-forget handle. Cloneable; the hot path calls `enqueue`.
76#[derive(Clone)]
77pub struct LedgerHandle {
78    tx: mpsc::Sender<UsageEntry>,
79    metrics: Arc<GatewayMetrics>,
80}
81
82impl LedgerHandle {
83    /// Spawn the background writer draining into `store`, recording no metrics.
84    pub fn spawn(store: Arc<dyn LedgerStore>, capacity: usize) -> Self {
85        Self::spawn_with_metrics(store, capacity, GatewayMetrics::noop())
86    }
87
88    /// Spawn the background writer draining into `store`. `capacity` bounds the
89    /// channel; a full channel drops the entry and bumps `ledger_dropped_total`.
90    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    /// Non-blocking enqueue. Never awaits the write; drops + counts on full.
115    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/// In-memory store for tests.
123#[derive(Default)]
124pub struct InMemoryLedger {
125    pub entries: Mutex<Vec<UsageEntry>>,
126}
127
128impl InMemoryLedger {
129    /// Snapshot the recorded entries (test-only read accessor).
130    #[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
144/// Records each entry to every configured sink, concurrently and independently.
145/// A sink failing never blocks the others; per-sink failures are logged and
146/// counted on `synapse_ledger_errors_total{backend=<label>}`. Always returns
147/// `Ok` — the ledger is fire-and-forget; the fan-out owns error reporting.
148pub 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    /// Count per-sink failures on `metrics`.
162    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        // give the writer task a tick to drain
222        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}