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