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
112pub trait BatchExporter: Send + Sync {
114 fn export<'a>(&'a self, batch: &'a [AggregatedUsage]) -> ExportFuture<'a>;
118}
119
120pub type SharedExporter = Arc<dyn BatchExporter>;
122
123pub struct FnExporter<F> {
125 export: F,
126}
127
128impl<F> FnExporter<F> {
129 #[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#[derive(Default)]
157pub struct CompositeExporter {
158 exporters: Vec<SharedExporter>,
159}
160
161impl CompositeExporter {
162 #[must_use]
164 pub const fn new() -> Self {
165 Self {
166 exporters: Vec::new(),
167 }
168 }
169
170 #[must_use]
172 pub fn with_exporter(mut self, exporter: SharedExporter) -> Self {
173 self.exporters.push(exporter);
174 self
175 }
176
177 #[must_use]
179 pub fn len(&self) -> usize {
180 self.exporters.len()
181 }
182
183 #[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#[derive(Debug, Error)]
212pub enum RecordError {
213 #[error("usage buffer lock was poisoned")]
215 Poisoned,
216 #[error("usage aggregate exceeds u64::MAX units")]
218 UnitsOverflow,
219}
220
221#[derive(Debug, Error, Clone, PartialEq, Eq)]
223pub enum ExportError {
224 #[error("billing export failed: {0}")]
226 Provider(String),
227 #[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 serve_pending_next: bool,
255}
256
257pub 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 #[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 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 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 #[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 #[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
429pub 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 #[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 #[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 #[must_use]
472 pub fn pending_buckets(&self) -> usize {
473 self.buffer.pending_buckets()
474 }
475
476 #[must_use]
481 pub fn retry_buckets(&self) -> usize {
482 self.buffer.retry_buckets()
483 }
484
485 #[must_use]
487 pub const fn exporter(&self) -> &E {
488 &self.exporter
489 }
490}
491
492impl<E: BatchExporter> BillingPipeline<E> {
493 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
530struct 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#[derive(Debug, Default, Clone, Copy)]
572pub struct LogExporter;
573
574impl LogExporter {
575 #[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#[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
613pub 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}