Skip to main content

mcp_usage_export/
lib.rs

1//! Buffered usage accounting for MCP calls.
2//!
3//! Recording is synchronous, memory-only, and never performs network I/O. A
4//! separate [`BillingPipeline::flush`] drains aggregates into an exporter. If an
5//! export fails, the exact same event identifiers and quantities are retained
6//! for retry, which makes ambiguous provider failures safe.
7
8#![forbid(unsafe_code)]
9#![warn(missing_docs, clippy::pedantic)]
10#![allow(clippy::module_name_repetitions)]
11
12use std::collections::{HashMap, HashSet, VecDeque};
13use std::fmt;
14use std::future::Future;
15use std::pin::Pin;
16use std::sync::atomic::{AtomicBool, Ordering};
17use std::sync::{Arc, Mutex, MutexGuard};
18use std::time::{SystemTime, UNIX_EPOCH};
19
20use serde::Serialize;
21use thiserror::Error;
22
23mod meter_event;
24
25pub use meter_event::{
26    MeterEventDeadLetter, MeterEventDeadLetterReason, MeterEventExporter, MeterEventOutcome,
27    MeterEventProvider, MeterEventProviderError, MeterEventProviderFuture,
28};
29
30/// Boxed future returned by object-safe billing exporters.
31pub type ExportFuture<'a> = Pin<Box<dyn Future<Output = Result<(), ExportError>> + Send + 'a>>;
32
33/// One delivered unit event emitted by the request path.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct UsageEvent {
36    /// Internal tenant identifier used for deduplication and reconciliation.
37    pub tenant_id: String,
38    /// Billing-provider customer identifier.
39    pub customer_id: String,
40    /// Provider meter event name.
41    pub meter: String,
42    /// Integer quantity to add to the meter.
43    pub units: u64,
44    /// Stable key for once-only work such as a completed durable task.
45    pub idempotency_key: Option<String>,
46    /// Event time in Unix seconds.
47    pub timestamp: u64,
48}
49
50impl UsageEvent {
51    /// Construct a usage event stamped with the current wall-clock time.
52    #[must_use]
53    pub fn now(
54        tenant_id: impl Into<String>,
55        customer_id: impl Into<String>,
56        meter: impl Into<String>,
57        units: u64,
58        idempotency_key: Option<String>,
59    ) -> Self {
60        let timestamp = SystemTime::now()
61            .duration_since(UNIX_EPOCH)
62            .unwrap_or_default()
63            .as_secs();
64        Self {
65            tenant_id: tenant_id.into(),
66            customer_id: customer_id.into(),
67            meter: meter.into(),
68            units,
69            idempotency_key,
70            timestamp,
71        }
72    }
73}
74
75/// One pre-aggregated provider event ready for export.
76#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
77pub struct AggregatedUsage {
78    /// Globally unique provider-side idempotency identifier.
79    pub identifier: String,
80    /// Provider customer identifier.
81    pub customer_id: String,
82    /// Provider meter event name.
83    pub meter: String,
84    /// Summed integer quantity.
85    pub units: u64,
86    /// Timestamp of the oldest event represented by the aggregate.
87    pub timestamp: u64,
88}
89
90/// Result of attempting to record an event.
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub enum RecordOutcome {
93    /// The event was added to its aggregate.
94    Recorded,
95    /// The event's idempotency key was already observed.
96    Duplicate,
97    /// Zero units need no provider event.
98    ZeroUnits,
99}
100
101/// A synchronous usage sink suitable for the HTTP hot path.
102pub trait UsageRecorder: Send + Sync {
103    /// Add one event without performing I/O.
104    ///
105    /// # Errors
106    ///
107    /// Returns [`RecordError`] only when the local synchronization primitive is
108    /// unusable; provider outages cannot surface on this path.
109    fn record(&self, event: UsageEvent) -> Result<RecordOutcome, RecordError>;
110}
111
112/// Exports a prepared batch away from the request path.
113pub trait BatchExporter: Send + Sync {
114    /// Export every aggregate in `batch`.
115    ///
116    /// Retrying the same batch must be safe because identifiers are stable.
117    fn export<'a>(&'a self, batch: &'a [AggregatedUsage]) -> ExportFuture<'a>;
118}
119
120/// Convenient trait-object alias for composing exporters at runtime.
121pub type SharedExporter = Arc<dyn BatchExporter>;
122
123/// Adapter for an application-owned async export function.
124pub struct FnExporter<F> {
125    export: F,
126}
127
128impl<F> FnExporter<F> {
129    /// Wrap an async export function without defining a new type.
130    #[must_use]
131    pub const fn new(export: F) -> Self {
132        Self { export }
133    }
134}
135
136impl<F> fmt::Debug for FnExporter<F> {
137    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
138        formatter.debug_struct("FnExporter").finish_non_exhaustive()
139    }
140}
141
142impl<F> BatchExporter for FnExporter<F>
143where
144    F: for<'a> Fn(&'a [AggregatedUsage]) -> ExportFuture<'a> + Send + Sync,
145{
146    fn export<'a>(&'a self, batch: &'a [AggregatedUsage]) -> ExportFuture<'a> {
147        (self.export)(batch)
148    }
149}
150
151/// Fan-out exporter for logs, billing, analytics, and audit sinks.
152///
153/// Exporters run in registration order. If one fails, a later retry starts from
154/// the first exporter. Provider implementations must therefore honor the stable
155/// aggregate identifiers, as required by [`BatchExporter`].
156#[derive(Default)]
157pub struct CompositeExporter {
158    exporters: Vec<SharedExporter>,
159}
160
161impl CompositeExporter {
162    /// Construct an empty fan-out exporter.
163    #[must_use]
164    pub const fn new() -> Self {
165        Self {
166            exporters: Vec::new(),
167        }
168    }
169
170    /// Append an exporter to the fan-out sequence.
171    #[must_use]
172    pub fn with_exporter(mut self, exporter: SharedExporter) -> Self {
173        self.exporters.push(exporter);
174        self
175    }
176
177    /// Number of configured exporters.
178    #[must_use]
179    pub fn len(&self) -> usize {
180        self.exporters.len()
181    }
182
183    /// Whether no exporters are configured.
184    #[must_use]
185    pub fn is_empty(&self) -> bool {
186        self.exporters.is_empty()
187    }
188}
189
190impl fmt::Debug for CompositeExporter {
191    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
192        formatter
193            .debug_struct("CompositeExporter")
194            .field("exporters", &self.exporters.len())
195            .finish()
196    }
197}
198
199impl BatchExporter for CompositeExporter {
200    fn export<'a>(&'a self, batch: &'a [AggregatedUsage]) -> ExportFuture<'a> {
201        Box::pin(async move {
202            for exporter in &self.exporters {
203                exporter.export(batch).await?;
204            }
205            Ok(())
206        })
207    }
208}
209
210/// Recording failures are internal synchronization failures only.
211#[derive(Debug, Error)]
212pub enum RecordError {
213    /// A synchronization primitive was poisoned by a panicking thread.
214    #[error("usage buffer lock was poisoned")]
215    Poisoned,
216    /// Adding an event would exceed the representable integer quantity.
217    #[error("usage aggregate exceeds u64::MAX units")]
218    UnitsOverflow,
219}
220
221/// Provider export failure.
222#[derive(Debug, Error, Clone, PartialEq, Eq)]
223pub enum ExportError {
224    /// The provider or transport rejected an event.
225    #[error("billing export failed: {0}")]
226    Provider(String),
227    /// Another flush already owns the current retry batch.
228    #[error("a billing flush is already in progress")]
229    FlushInProgress,
230}
231
232#[derive(Debug, Clone, PartialEq, Eq, Hash)]
233struct AggregateKey {
234    customer_id: String,
235    meter: String,
236}
237
238#[derive(Debug, Clone)]
239struct PendingAggregate {
240    units: u64,
241    oldest_timestamp: u64,
242}
243
244#[derive(Debug, Default)]
245struct BufferState {
246    pending: HashMap<AggregateKey, PendingAggregate>,
247    retry: Vec<AggregatedUsage>,
248    seen: HashSet<(String, String)>,
249    seen_order: VecDeque<(String, String)>,
250    /// Set when a retry batch comes back unexported, so the next flush serves
251    /// fresh usage instead. Without it a batch the provider will never accept
252    /// is re-offered on every flush forever and no other customer's usage is
253    /// ever exported again.
254    serve_pending_next: bool,
255}
256
257/// Thread-safe in-memory aggregator with bounded idempotency memory.
258pub struct UsageBuffer {
259    state: Mutex<BufferState>,
260    max_idempotency_keys: usize,
261}
262
263impl fmt::Debug for UsageBuffer {
264    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
265        let state = self
266            .state
267            .lock()
268            .unwrap_or_else(std::sync::PoisonError::into_inner);
269        formatter
270            .debug_struct("UsageBuffer")
271            .field(
272                "pending_buckets",
273                &(state.pending.len() + state.retry.len()),
274            )
275            .field("retained_idempotency_keys", &state.seen.len())
276            .field("max_idempotency_keys", &self.max_idempotency_keys)
277            .finish_non_exhaustive()
278    }
279}
280
281impl Default for UsageBuffer {
282    fn default() -> Self {
283        Self::new(100_000)
284    }
285}
286
287impl UsageBuffer {
288    /// Construct a buffer retaining at most `max_idempotency_keys` once-only
289    /// keys. Zero disables in-process deduplication.
290    #[must_use]
291    pub fn new(max_idempotency_keys: usize) -> Self {
292        Self {
293            state: Mutex::new(BufferState::default()),
294            max_idempotency_keys,
295        }
296    }
297
298    fn lock(&self) -> Result<MutexGuard<'_, BufferState>, RecordError> {
299        self.state.lock().map_err(|_| RecordError::Poisoned)
300    }
301
302    /// Record one event into a `(customer, meter)` aggregate.
303    ///
304    /// # Errors
305    ///
306    /// Returns [`RecordError::Poisoned`] if another thread panicked while
307    /// mutating the buffer, or [`RecordError::UnitsOverflow`] rather than
308    /// silently saturating an invoice quantity.
309    pub fn record(&self, event: UsageEvent) -> Result<RecordOutcome, RecordError> {
310        if event.units == 0 {
311            return Ok(RecordOutcome::ZeroUnits);
312        }
313
314        let mut state = self.lock()?;
315        let scoped_idempotency = event
316            .idempotency_key
317            .map(|idempotency_key| (event.tenant_id, idempotency_key));
318        if scoped_idempotency
319            .as_ref()
320            .is_some_and(|scoped_key| state.seen.contains(scoped_key))
321        {
322            return Ok(RecordOutcome::Duplicate);
323        }
324        let key = AggregateKey {
325            customer_id: event.customer_id,
326            meter: event.meter,
327        };
328        let next_units = state
329            .pending
330            .get(&key)
331            .map_or(Some(event.units), |aggregate| {
332                aggregate.units.checked_add(event.units)
333            })
334            .ok_or(RecordError::UnitsOverflow)?;
335
336        if let Some(scoped_key) = scoped_idempotency
337            && self.max_idempotency_keys > 0
338        {
339            state.seen.insert(scoped_key.clone());
340            state.seen_order.push_back(scoped_key);
341            while state.seen_order.len() > self.max_idempotency_keys {
342                if let Some(expired) = state.seen_order.pop_front() {
343                    state.seen.remove(&expired);
344                }
345            }
346        }
347
348        let aggregate = state.pending.entry(key).or_insert(PendingAggregate {
349            units: 0,
350            oldest_timestamp: event.timestamp,
351        });
352        aggregate.units = next_units;
353        aggregate.oldest_timestamp = aggregate.oldest_timestamp.min(event.timestamp);
354        Ok(RecordOutcome::Recorded)
355    }
356
357    fn prepare_flush(&self) -> Result<Vec<AggregatedUsage>, RecordError> {
358        let mut state = self.lock()?;
359
360        // A retry goes first, because its identifiers are already known to the
361        // provider and delaying it delays an invoice. But it does not go first
362        // *every* time: a batch the provider will never accept would otherwise
363        // be re-offered on every flush forever, and every other customer's
364        // usage would sit behind it until the process died and took the whole
365        // buffer with it. After a failure the next flush serves fresh usage,
366        // so a stuck batch delays itself rather than everyone.
367        let take_retry =
368            !state.retry.is_empty() && (!state.serve_pending_next || state.pending.is_empty());
369        if take_retry {
370            state.serve_pending_next = false;
371            return Ok(std::mem::take(&mut state.retry));
372        }
373        state.serve_pending_next = false;
374        let mut batch: Vec<_> = std::mem::take(&mut state.pending)
375            .into_iter()
376            .map(|(key, aggregate)| AggregatedUsage {
377                identifier: format!("mcp_usage_{}", uuid::Uuid::new_v4().simple()),
378                customer_id: key.customer_id,
379                meter: key.meter,
380                units: aggregate.units,
381                timestamp: aggregate.oldest_timestamp,
382            })
383            .collect();
384        batch.sort_by(|a, b| {
385            a.customer_id
386                .cmp(&b.customer_id)
387                .then_with(|| a.meter.cmp(&b.meter))
388        });
389        Ok(batch)
390    }
391
392    fn restore_after_interruption(&self, batch: Vec<AggregatedUsage>) {
393        let mut state = self
394            .state
395            .lock()
396            .unwrap_or_else(std::sync::PoisonError::into_inner);
397        if state.retry.is_empty() {
398            state.retry = batch;
399        } else {
400            state.retry.extend(batch);
401        }
402        state.serve_pending_next = true;
403    }
404
405    /// Number of aggregates waiting to be retried.
406    ///
407    /// Separated from [`Self::pending_buckets`] because the two mean very
408    /// different things operationally: pending rising means traffic, retry
409    /// rising means the provider is refusing, and a retry count that never
410    /// falls is a batch that will never be accepted.
411    #[must_use]
412    pub fn retry_buckets(&self) -> usize {
413        self.state
414            .lock()
415            .map(|state| state.retry.len())
416            .unwrap_or_default()
417    }
418
419    /// Number of live aggregate buckets plus retry events.
420    #[must_use]
421    pub fn pending_buckets(&self) -> usize {
422        self.state
423            .lock()
424            .map(|state| state.pending.len() + state.retry.len())
425            .unwrap_or_default()
426    }
427}
428
429/// Coordinates a [`UsageBuffer`] with an off-path exporter.
430pub struct BillingPipeline<E> {
431    buffer: UsageBuffer,
432    exporter: E,
433    flush_in_progress: AtomicBool,
434}
435
436impl<E> fmt::Debug for BillingPipeline<E> {
437    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
438        formatter
439            .debug_struct("BillingPipeline")
440            .field("pending_buckets", &self.pending_buckets())
441            .field(
442                "flush_in_progress",
443                &self.flush_in_progress.load(Ordering::Relaxed),
444            )
445            .finish_non_exhaustive()
446    }
447}
448
449impl<E> BillingPipeline<E> {
450    /// Construct a pipeline with the default bounded deduplication store.
451    #[must_use]
452    pub fn new(exporter: E) -> Self {
453        Self {
454            buffer: UsageBuffer::default(),
455            exporter,
456            flush_in_progress: AtomicBool::new(false),
457        }
458    }
459
460    /// Construct a pipeline with an explicit idempotency-key bound.
461    #[must_use]
462    pub fn with_idempotency_capacity(exporter: E, capacity: usize) -> Self {
463        Self {
464            buffer: UsageBuffer::new(capacity),
465            exporter,
466            flush_in_progress: AtomicBool::new(false),
467        }
468    }
469
470    /// Number of buffered aggregate buckets.
471    #[must_use]
472    pub fn pending_buckets(&self) -> usize {
473        self.buffer.pending_buckets()
474    }
475
476    /// Number of aggregates waiting to be retried.
477    ///
478    /// Worth alerting on: a count that never falls is usage the provider keeps
479    /// refusing, and the buffer is in memory, so it is lost at the next deploy.
480    #[must_use]
481    pub fn retry_buckets(&self) -> usize {
482        self.buffer.retry_buckets()
483    }
484
485    /// Access the exporter for diagnostics and tests.
486    #[must_use]
487    pub const fn exporter(&self) -> &E {
488        &self.exporter
489    }
490}
491
492impl<E: BatchExporter> BillingPipeline<E> {
493    /// Drain and export one batch. Failed batches retain their exact identifiers
494    /// for an idempotent retry.
495    ///
496    /// # Errors
497    ///
498    /// Returns [`ExportError::FlushInProgress`] for overlapping flushes, or the
499    /// provider error after restoring the batch for retry.
500    pub async fn flush(&self) -> Result<usize, ExportError> {
501        if self
502            .flush_in_progress
503            .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
504            .is_err()
505        {
506            return Err(ExportError::FlushInProgress);
507        }
508        let _guard = FlushGuard(&self.flush_in_progress);
509        let batch = self
510            .buffer
511            .prepare_flush()
512            .map_err(|error| ExportError::Provider(error.to_string()))?;
513        if batch.is_empty() {
514            return Ok(0);
515        }
516        let retry = RetryGuard::new(&self.buffer, batch);
517        self.exporter.export(retry.batch()).await?;
518        Ok(retry.commit())
519    }
520}
521
522struct FlushGuard<'a>(&'a AtomicBool);
523
524impl Drop for FlushGuard<'_> {
525    fn drop(&mut self) {
526        self.0.store(false, Ordering::Release);
527    }
528}
529
530/// Owns a prepared batch until the exporter commits successfully.
531///
532/// Dropping a flush future through cancellation or unwinding drops this guard,
533/// which restores the exact batch before another flush can acquire ownership.
534struct RetryGuard<'a> {
535    buffer: &'a UsageBuffer,
536    batch: Option<Vec<AggregatedUsage>>,
537}
538
539impl<'a> RetryGuard<'a> {
540    fn new(buffer: &'a UsageBuffer, batch: Vec<AggregatedUsage>) -> Self {
541        Self {
542            buffer,
543            batch: Some(batch),
544        }
545    }
546
547    fn batch(&self) -> &[AggregatedUsage] {
548        self.batch.as_deref().unwrap_or_default()
549    }
550
551    fn commit(mut self) -> usize {
552        self.batch.take().map_or(0, |batch| batch.len())
553    }
554}
555
556impl Drop for RetryGuard<'_> {
557    fn drop(&mut self) {
558        if let Some(batch) = self.batch.take() {
559            self.buffer.restore_after_interruption(batch);
560        }
561    }
562}
563
564impl<E: BatchExporter> UsageRecorder for BillingPipeline<E> {
565    fn record(&self, event: UsageEvent) -> Result<RecordOutcome, RecordError> {
566        self.buffer.record(event)
567    }
568}
569
570/// Stateless exporter that emits structured log events.
571#[derive(Debug, Default, Clone, Copy)]
572pub struct LogExporter;
573
574impl LogExporter {
575    /// Construct a logging exporter.
576    #[must_use]
577    pub const fn new() -> Self {
578        Self
579    }
580}
581
582impl BatchExporter for LogExporter {
583    fn export<'a>(&'a self, batch: &'a [AggregatedUsage]) -> ExportFuture<'a> {
584        Box::pin(async move {
585            for usage in batch {
586                tracing::info!(
587                    identifier = %usage.identifier,
588                    meter = %usage.meter,
589                    units = usage.units,
590                    timestamp = usage.timestamp,
591                    "MCP billing usage"
592                );
593            }
594            Ok(())
595        })
596    }
597}
598
599/// Shared, no-op recorder for deployments that want attribution metrics only.
600#[derive(Debug, Default)]
601pub struct NoopRecorder;
602
603impl UsageRecorder for NoopRecorder {
604    fn record(&self, event: UsageEvent) -> Result<RecordOutcome, RecordError> {
605        Ok(if event.units == 0 {
606            RecordOutcome::ZeroUnits
607        } else {
608            RecordOutcome::Recorded
609        })
610    }
611}
612
613/// Convenient trait-object alias used by the edge layer.
614pub type SharedRecorder = Arc<dyn UsageRecorder>;
615
616impl fmt::Display for RecordOutcome {
617    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
618        f.write_str(match self {
619            Self::Recorded => "recorded",
620            Self::Duplicate => "duplicate",
621            Self::ZeroUnits => "zero_units",
622        })
623    }
624}
625
626#[cfg(test)]
627mod tests {
628    use super::*;
629    use std::sync::atomic::AtomicUsize;
630    use std::thread;
631
632    #[derive(Debug, Default)]
633    struct CaptureExporter {
634        exported: Mutex<Vec<AggregatedUsage>>,
635    }
636
637    impl CaptureExporter {
638        fn exported(&self) -> Vec<AggregatedUsage> {
639            self.exported
640                .lock()
641                .unwrap_or_else(std::sync::PoisonError::into_inner)
642                .clone()
643        }
644    }
645
646    impl BatchExporter for CaptureExporter {
647        fn export<'a>(&'a self, batch: &'a [AggregatedUsage]) -> ExportFuture<'a> {
648            Box::pin(async move {
649                self.exported
650                    .lock()
651                    .unwrap_or_else(std::sync::PoisonError::into_inner)
652                    .extend_from_slice(batch);
653                Ok(())
654            })
655        }
656    }
657
658    fn event(tenant: &str, units: u64) -> UsageEvent {
659        UsageEvent {
660            tenant_id: tenant.to_owned(),
661            customer_id: format!("cus_{tenant}"),
662            meter: "mcp_units".to_owned(),
663            units,
664            idempotency_key: None,
665            timestamp: 1_800_000_000,
666        }
667    }
668
669    #[tokio::test]
670    async fn aggregates_by_customer_and_meter() {
671        let pipeline = BillingPipeline::new(CaptureExporter::default());
672        pipeline.record(event("acme", 2)).unwrap();
673        pipeline.record(event("acme", 3)).unwrap();
674        pipeline.record(event("other", 7)).unwrap();
675        assert_eq!(pipeline.pending_buckets(), 2);
676        assert_eq!(pipeline.flush().await.unwrap(), 2);
677
678        let exported = pipeline.exporter().exported();
679        assert_eq!(exported.len(), 2);
680        assert_eq!(exported.iter().map(|e| e.units).sum::<u64>(), 12);
681    }
682
683    #[test]
684    fn duplicate_task_completions_are_suppressed_per_tenant() {
685        let pipeline = BillingPipeline::new(CaptureExporter::default());
686        let mut first = event("acme", 10);
687        first.idempotency_key = Some("task-1".to_owned());
688        assert_eq!(
689            pipeline.record(first.clone()).unwrap(),
690            RecordOutcome::Recorded
691        );
692        assert_eq!(pipeline.record(first).unwrap(), RecordOutcome::Duplicate);
693
694        let mut other_tenant = event("other", 10);
695        other_tenant.idempotency_key = Some("task-1".to_owned());
696        assert_eq!(
697            pipeline.record(other_tenant).unwrap(),
698            RecordOutcome::Recorded
699        );
700    }
701
702    #[test]
703    fn aggregate_overflow_is_reported_without_consuming_the_idempotency_key() {
704        let pipeline = BillingPipeline::new(CaptureExporter::default());
705        pipeline.record(event("acme", u64::MAX)).unwrap();
706        let mut overflowing = event("acme", 1);
707        overflowing.idempotency_key = Some("task-overflow".to_owned());
708        assert!(matches!(
709            pipeline.record(overflowing.clone()),
710            Err(RecordError::UnitsOverflow)
711        ));
712        assert!(matches!(
713            pipeline.record(overflowing),
714            Err(RecordError::UnitsOverflow)
715        ));
716    }
717
718    #[derive(Debug, Default)]
719    struct FailOnceExporter {
720        failed: AtomicBool,
721        attempts: Mutex<Vec<Vec<AggregatedUsage>>>,
722    }
723
724    impl BatchExporter for FailOnceExporter {
725        fn export<'a>(&'a self, batch: &'a [AggregatedUsage]) -> ExportFuture<'a> {
726            Box::pin(async move {
727                self.attempts.lock().unwrap().push(batch.to_vec());
728                if self.failed.swap(true, Ordering::SeqCst) {
729                    Ok(())
730                } else {
731                    Err(ExportError::Provider("temporary outage".to_owned()))
732                }
733            })
734        }
735    }
736
737    #[tokio::test]
738    async fn failed_flush_retries_identical_provider_events() {
739        let pipeline = BillingPipeline::new(FailOnceExporter::default());
740        pipeline.record(event("acme", 5)).unwrap();
741        assert!(pipeline.flush().await.is_err());
742        assert_eq!(pipeline.pending_buckets(), 1);
743        assert_eq!(pipeline.flush().await.unwrap(), 1);
744
745        let attempts = pipeline.exporter().attempts.lock().unwrap();
746        assert_eq!(attempts.len(), 2);
747        assert_eq!(attempts[0], attempts[1]);
748    }
749
750    #[derive(Debug, Default)]
751    struct CancelOnceExporter {
752        attempts: Mutex<Vec<Vec<AggregatedUsage>>>,
753        successful: AtomicUsize,
754    }
755
756    impl BatchExporter for CancelOnceExporter {
757        fn export<'a>(&'a self, batch: &'a [AggregatedUsage]) -> ExportFuture<'a> {
758            Box::pin(async move {
759                let attempt = {
760                    let mut attempts = self.attempts.lock().unwrap();
761                    attempts.push(batch.to_vec());
762                    attempts.len()
763                };
764                if attempt == 1 {
765                    std::future::pending::<()>().await;
766                }
767                self.successful.fetch_add(1, Ordering::SeqCst);
768                Ok(())
769            })
770        }
771    }
772
773    #[tokio::test]
774    async fn cancelled_flush_restores_the_identical_batch() {
775        let pipeline = Arc::new(BillingPipeline::new(CancelOnceExporter::default()));
776        pipeline.record(event("acme", 9)).unwrap();
777        let flush = tokio::spawn({
778            let pipeline = Arc::clone(&pipeline);
779            async move { pipeline.flush().await }
780        });
781        while pipeline.exporter().attempts.lock().unwrap().is_empty() {
782            tokio::task::yield_now().await;
783        }
784        flush.abort();
785        assert!(flush.await.unwrap_err().is_cancelled());
786
787        assert_eq!(pipeline.pending_buckets(), 1);
788        assert_eq!(pipeline.flush().await.unwrap(), 1);
789        let attempts = pipeline.exporter().attempts.lock().unwrap();
790        assert_eq!(attempts.len(), 2);
791        assert_eq!(attempts[0], attempts[1]);
792        assert_eq!(pipeline.exporter().successful.load(Ordering::SeqCst), 1);
793    }
794
795    #[derive(Debug, Default)]
796    struct PanicOnceExporter {
797        attempts: Mutex<Vec<Vec<AggregatedUsage>>>,
798        panicked: AtomicBool,
799        successful: AtomicUsize,
800    }
801
802    impl BatchExporter for PanicOnceExporter {
803        fn export<'a>(&'a self, batch: &'a [AggregatedUsage]) -> ExportFuture<'a> {
804            Box::pin(async move {
805                self.attempts.lock().unwrap().push(batch.to_vec());
806                assert!(
807                    self.panicked.swap(true, Ordering::SeqCst),
808                    "intentional exporter panic"
809                );
810                self.successful.fetch_add(1, Ordering::SeqCst);
811                Ok(())
812            })
813        }
814    }
815
816    #[tokio::test]
817    async fn panicking_flush_restores_the_identical_batch() {
818        let pipeline = Arc::new(BillingPipeline::new(PanicOnceExporter::default()));
819        pipeline.record(event("acme", 11)).unwrap();
820        let flush = tokio::spawn({
821            let pipeline = Arc::clone(&pipeline);
822            async move { pipeline.flush().await }
823        });
824        assert!(flush.await.unwrap_err().is_panic());
825
826        assert_eq!(pipeline.pending_buckets(), 1);
827        assert_eq!(pipeline.flush().await.unwrap(), 1);
828        let attempts = pipeline.exporter().attempts.lock().unwrap();
829        assert_eq!(attempts.len(), 2);
830        assert_eq!(attempts[0], attempts[1]);
831        assert_eq!(pipeline.exporter().successful.load(Ordering::SeqCst), 1);
832    }
833
834    #[test]
835    fn debug_output_contains_counts_but_not_buffered_identifiers() {
836        #[derive(Debug)]
837        struct SensitiveExporter(&'static str);
838
839        impl BatchExporter for SensitiveExporter {
840            fn export<'a>(&'a self, _batch: &'a [AggregatedUsage]) -> ExportFuture<'a> {
841                Box::pin(async { Ok(()) })
842            }
843        }
844
845        let pipeline = BillingPipeline::new(SensitiveExporter("https://secret.example"));
846        let mut sensitive = event("tenant-private", 3);
847        sensitive.customer_id = "customer-private".to_owned();
848        sensitive.meter = "meter-private".to_owned();
849        sensitive.idempotency_key = Some("task-private".to_owned());
850        pipeline.record(sensitive).unwrap();
851
852        let debug = format!("{pipeline:?}");
853        for secret in [
854            "tenant-private",
855            "customer-private",
856            "meter-private",
857            "task-private",
858            "https://secret.example",
859        ] {
860            assert!(!debug.contains(secret));
861        }
862        assert!(debug.contains("pending_buckets"));
863        assert_eq!(pipeline.exporter().0, "https://secret.example");
864    }
865
866    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
867    async fn concurrent_recording_reconciles_exactly() {
868        let pipeline = Arc::new(BillingPipeline::new(CaptureExporter::default()));
869        let threads = 8;
870        let per_thread = 2_000;
871        let mut handles = Vec::new();
872        for _ in 0..threads {
873            let pipeline = pipeline.clone();
874            handles.push(thread::spawn(move || {
875                for _ in 0..per_thread {
876                    pipeline.record(event("acme", 1)).unwrap();
877                }
878            }));
879        }
880        for handle in handles {
881            handle.join().unwrap();
882        }
883        pipeline.flush().await.unwrap();
884        let total: u64 = pipeline.exporter().exported().iter().map(|e| e.units).sum();
885        assert_eq!(total, threads * per_thread);
886    }
887}