1#![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
30pub type ExportFuture<'a> = Pin<Box<dyn Future<Output = Result<(), ExportError>> + Send + 'a>>;
32
33#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct UsageEvent {
36 pub tenant_id: String,
38 pub customer_id: String,
40 pub meter: String,
42 pub units: u64,
44 pub idempotency_key: Option<String>,
46 pub timestamp: u64,
48}
49
50impl UsageEvent {
51 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
77pub struct AggregatedUsage {
78 pub identifier: String,
80 pub customer_id: String,
82 pub meter: String,
84 pub units: u64,
86 pub timestamp: u64,
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub enum RecordOutcome {
93 Recorded,
95 Duplicate,
97 ZeroUnits,
99}
100
101pub trait UsageRecorder: Send + Sync {
103 fn record(&self, event: UsageEvent) -> Result<RecordOutcome, RecordError>;
110}
111
112#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
114pub enum RetryPolicy {
115 #[default]
117 Interleave,
118 CompleteBatch,
120}
121
122pub trait BatchExporter: Send + Sync {
124 fn retry_policy(&self) -> RetryPolicy {
126 RetryPolicy::Interleave
127 }
128
129 fn export<'a>(&'a self, batch: &'a [AggregatedUsage]) -> ExportFuture<'a>;
133}
134
135pub type SharedExporter = Arc<dyn BatchExporter>;
137
138pub struct FnExporter<F> {
140 export: F,
141}
142
143impl<F> FnExporter<F> {
144 #[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#[derive(Default)]
172pub struct CompositeExporter {
173 exporters: Vec<SharedExporter>,
174}
175
176impl CompositeExporter {
177 #[must_use]
179 pub const fn new() -> Self {
180 Self {
181 exporters: Vec::new(),
182 }
183 }
184
185 #[must_use]
187 pub fn with_exporter(mut self, exporter: SharedExporter) -> Self {
188 self.exporters.push(exporter);
189 self
190 }
191
192 #[must_use]
194 pub fn len(&self) -> usize {
195 self.exporters.len()
196 }
197
198 #[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#[derive(Debug, Error)]
239pub enum RecordError {
240 #[error("usage buffer lock was poisoned")]
242 Poisoned,
243 #[error("usage aggregate exceeds u64::MAX units")]
245 UnitsOverflow,
246}
247
248#[derive(Debug, Error, Clone, PartialEq, Eq)]
250pub enum ExportError {
251 #[error("billing export failed: {0}")]
253 Provider(String),
254 #[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 serve_pending_next: bool,
282}
283
284pub 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 #[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 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 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 #[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 #[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
453pub 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 #[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 #[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 #[must_use]
496 pub fn pending_buckets(&self) -> usize {
497 self.buffer.pending_buckets()
498 }
499
500 #[must_use]
505 pub fn retry_buckets(&self) -> usize {
506 self.buffer.retry_buckets()
507 }
508
509 #[must_use]
511 pub const fn exporter(&self) -> &E {
512 &self.exporter
513 }
514}
515
516impl<E: BatchExporter> BillingPipeline<E> {
517 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
554struct 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#[derive(Debug, Default, Clone, Copy)]
596pub struct LogExporter;
597
598impl LogExporter {
599 #[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#[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
637pub 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}