1use async_trait::async_trait;
2use serde::{Deserialize, Serialize};
3use std::collections::btree_map::Entry;
4use std::collections::BTreeMap;
5use std::path::{Path, PathBuf};
6use std::time::Duration;
7use tokio::sync::{mpsc, oneshot};
8use tokio::time::{interval, Instant, MissedTickBehavior};
9use tracing::{debug, error, warn};
10use uuid::Uuid;
11
12const MAX_IN_MEMORY_RETRIES: u32 = 3;
13
14const UPDATE_COALESCE_WINDOW_MS: u64 = 10_000;
18
19const MAX_COALESCED_UPDATES: usize = 10_000;
21
22#[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
26pub struct UsageIdentity {
27 #[serde(skip_serializing_if = "Option::is_none")]
28 pub metering_key: Option<String>,
29 #[serde(skip_serializing_if = "Option::is_none")]
30 pub subject: Option<String>,
31 #[serde(skip_serializing_if = "Option::is_none")]
32 pub key_class: Option<String>,
33 #[serde(skip_serializing_if = "Option::is_none")]
34 pub actor_key: Option<String>,
35 #[serde(skip_serializing_if = "Option::is_none")]
36 pub account_key: Option<String>,
37 #[serde(skip_serializing_if = "Option::is_none")]
38 pub consumer_key: Option<String>,
39 #[serde(skip_serializing_if = "Option::is_none")]
40 pub plan_code: Option<String>,
41 #[serde(skip_serializing_if = "Option::is_none")]
42 pub policy_version: Option<u32>,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
46#[serde(tag = "type", rename_all = "snake_case")]
47pub enum WebSocketUsageEvent {
48 ConnectionEstablished {
49 client_id: String,
50 remote_addr: String,
51 deployment_id: Option<String>,
52 #[serde(flatten)]
53 identity: UsageIdentity,
54 },
55 ConnectionClosed {
56 client_id: String,
57 deployment_id: Option<String>,
58 #[serde(flatten)]
59 identity: UsageIdentity,
60 duration_secs: Option<f64>,
61 subscription_count: u32,
62 },
63 SubscriptionCreated {
64 client_id: String,
65 deployment_id: Option<String>,
66 #[serde(flatten)]
67 identity: UsageIdentity,
68 view_id: String,
69 },
70 SubscriptionRemoved {
71 client_id: String,
72 deployment_id: Option<String>,
73 #[serde(flatten)]
74 identity: UsageIdentity,
75 view_id: String,
76 },
77 SnapshotSent {
78 client_id: String,
79 deployment_id: Option<String>,
80 #[serde(flatten)]
81 identity: UsageIdentity,
82 view_id: String,
83 rows: u32,
84 messages: u32,
85 bytes: u64,
86 },
87 UpdateSent {
88 client_id: String,
89 deployment_id: Option<String>,
90 #[serde(flatten)]
91 identity: UsageIdentity,
92 view_id: String,
93 messages: u32,
94 bytes: u64,
95 },
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct WebSocketUsageEnvelope {
100 pub event_id: String,
101 pub occurred_at_ms: u64,
102 #[serde(default, skip_serializing_if = "Option::is_none")]
103 pub build_id: Option<String>,
104 pub event: WebSocketUsageEvent,
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct WebSocketUsageBatch {
109 pub events: Vec<WebSocketUsageEnvelope>,
110}
111
112#[async_trait]
113pub trait WebSocketUsageEmitter: Send + Sync {
114 async fn emit(&self, event: WebSocketUsageEvent);
115
116 async fn flush(&self) {}
119
120 async fn shutdown(&self) {
122 self.flush().await;
123 }
124}
125
126#[derive(Clone)]
127pub struct ChannelUsageEmitter {
128 sender: mpsc::UnboundedSender<WebSocketUsageEvent>,
129}
130
131impl ChannelUsageEmitter {
132 pub fn new(sender: mpsc::UnboundedSender<WebSocketUsageEvent>) -> Self {
133 Self { sender }
134 }
135}
136
137#[async_trait]
138impl WebSocketUsageEmitter for ChannelUsageEmitter {
139 async fn emit(&self, event: WebSocketUsageEvent) {
140 let _ = self.sender.send(event);
141 }
142}
143
144pub struct HttpUsageEmitter {
154 sender: mpsc::UnboundedSender<UsageEmitterCommand>,
155}
156
157enum UsageEmitterCommand {
158 Event(Box<WebSocketUsageEvent>),
159 Flush(oneshot::Sender<()>),
160 Shutdown(oneshot::Sender<()>),
161}
162
163#[derive(Debug, thiserror::Error)]
164#[error("WebSocket usage build ID must be a positive integer")]
165pub struct InvalidUsageBuildId;
166
167#[derive(Debug, Clone)]
168struct RetryState {
169 batch: WebSocketUsageBatch,
170 attempts: u32,
171 next_retry_at: Instant,
172}
173
174impl HttpUsageEmitter {
175 pub fn new(endpoint: String, auth_token: Option<String>) -> Self {
176 Self::with_config(endpoint, auth_token, 50, Duration::from_secs(2))
177 }
178
179 pub fn new_attributed(
180 endpoint: String,
181 auth_token: Option<String>,
182 build_id: impl Into<String>,
183 ) -> Result<Self, InvalidUsageBuildId> {
184 let build_id = validate_build_id(build_id.into())?;
185 Ok(Self::with_full_config(
186 endpoint,
187 auth_token,
188 50,
189 Duration::from_secs(2),
190 None,
191 Some(build_id),
192 ))
193 }
194
195 pub fn with_spool_dir(
196 endpoint: String,
197 auth_token: Option<String>,
198 spool_dir: impl Into<PathBuf>,
199 ) -> Self {
200 Self::with_full_config(
201 endpoint,
202 auth_token,
203 50,
204 Duration::from_secs(2),
205 Some(spool_dir.into()),
206 None,
207 )
208 }
209
210 pub fn with_attributed_spool_dir(
211 endpoint: String,
212 auth_token: Option<String>,
213 spool_dir: impl Into<PathBuf>,
214 build_id: impl Into<String>,
215 ) -> Result<Self, InvalidUsageBuildId> {
216 let build_id = validate_build_id(build_id.into())?;
217 Ok(Self::with_full_config(
218 endpoint,
219 auth_token,
220 50,
221 Duration::from_secs(2),
222 Some(spool_dir.into()),
223 Some(build_id),
224 ))
225 }
226
227 pub fn with_config(
228 endpoint: String,
229 auth_token: Option<String>,
230 batch_size: usize,
231 flush_interval: Duration,
232 ) -> Self {
233 Self::with_full_config(endpoint, auth_token, batch_size, flush_interval, None, None)
234 }
235
236 fn with_full_config(
237 endpoint: String,
238 auth_token: Option<String>,
239 batch_size: usize,
240 flush_interval: Duration,
241 spool_dir: Option<PathBuf>,
242 build_id: Option<String>,
243 ) -> Self {
244 let (sender, mut receiver) = mpsc::unbounded_channel::<UsageEmitterCommand>();
245 let client = reqwest::Client::new();
246
247 tokio::spawn(async move {
248 let mut ticker = interval(flush_interval);
249 ticker.set_missed_tick_behavior(MissedTickBehavior::Delay);
250 let mut pending: Vec<WebSocketUsageEnvelope> = Vec::new();
251 let mut updates = CoalescedUpdates::new(build_id.clone());
252 let mut retry_state: Option<RetryState> = None;
253
254 if let Some(dir) = spool_dir.as_ref() {
255 if let Err(error) = ensure_spool_dir(dir) {
256 warn!(error = %error, path = %dir.display(), "failed to initialize websocket usage spool directory");
257 }
258 }
259
260 loop {
261 tokio::select! {
262 maybe_command = receiver.recv() => {
263 match maybe_command {
264 Some(UsageEmitterCommand::Event(event)) => {
265 let occurred_at_ms = current_time_ms();
266 match *event {
267 WebSocketUsageEvent::UpdateSent {
268 client_id,
269 deployment_id,
270 identity,
271 view_id,
272 messages,
273 bytes,
274 } => {
275 updates.add(
276 UpdateKey {
277 window: occurred_at_ms / UPDATE_COALESCE_WINDOW_MS,
278 client_id,
279 deployment_id,
280 identity,
281 view_id,
282 },
283 UpdateTotals {
284 messages,
285 bytes,
286 last_occurred_at_ms: occurred_at_ms,
287 },
288 &mut pending,
289 );
290 if updates.len() >= MAX_COALESCED_UPDATES {
291 updates.drain_all(&mut pending);
292 }
293 }
294 event => pending.push(WebSocketUsageEnvelope {
295 event_id: Uuid::new_v4().to_string(),
296 occurred_at_ms,
297 build_id: build_id.clone(),
298 event,
299 }),
300 }
301
302 if retry_state.is_none() && pending.len() >= batch_size {
303 flush_pending_batch(
304 &client,
305 &endpoint,
306 auth_token.as_deref(),
307 &mut pending,
308 &mut retry_state,
309 spool_dir.as_deref(),
310 batch_size,
311 ).await;
312 }
313 }
314 Some(UsageEmitterCommand::Flush(done)) => {
315 updates.drain_all(&mut pending);
316 flush_on_shutdown(
317 &client,
318 &endpoint,
319 auth_token.as_deref(),
320 &mut pending,
321 &mut retry_state,
322 LifecycleFlushOptions {
323 spool_dir: spool_dir.as_deref(),
324 batch_size,
325 preserve_failed_for_retry: true,
326 },
327 ).await;
328 let _ = done.send(());
329 }
330 Some(UsageEmitterCommand::Shutdown(done)) => {
331 updates.drain_all(&mut pending);
332 flush_on_shutdown(
333 &client,
334 &endpoint,
335 auth_token.as_deref(),
336 &mut pending,
337 &mut retry_state,
338 LifecycleFlushOptions {
339 spool_dir: spool_dir.as_deref(),
340 batch_size,
341 preserve_failed_for_retry: false,
342 },
343 ).await;
344 let _ = done.send(());
345 break;
346 }
347 None => {
348 updates.drain_all(&mut pending);
349 flush_on_shutdown(
350 &client,
351 &endpoint,
352 auth_token.as_deref(),
353 &mut pending,
354 &mut retry_state,
355 LifecycleFlushOptions {
356 spool_dir: spool_dir.as_deref(),
357 batch_size,
358 preserve_failed_for_retry: false,
359 },
360 ).await;
361 break;
362 }
363 }
364 }
365 _ = ticker.tick() => {
366 updates.drain_closed(current_time_ms(), &mut pending);
367 if let Some(dir) = spool_dir.as_deref() {
368 if retry_state.is_none() {
369 if let Err(error) = flush_one_spooled_batch(
370 &client,
371 &endpoint,
372 auth_token.as_deref(),
373 dir,
374 batch_size,
375 ).await {
376 warn!(error = %error, path = %dir.display(), "failed to process spooled websocket usage batch");
377 }
378 }
379 }
380
381 if let Some(state) = retry_state.take() {
382 if Instant::now() >= state.next_retry_at {
383 match flush_existing_batch(
384 &client,
385 &endpoint,
386 auth_token.as_deref(),
387 state,
388 ).await {
389 Ok(()) => {
390 if !pending.is_empty() {
391 flush_pending_batch(
392 &client,
393 &endpoint,
394 auth_token.as_deref(),
395 &mut pending,
396 &mut retry_state,
397 spool_dir.as_deref(),
398 batch_size,
399 ).await;
400 }
401 }
402 Err(state) => {
403 if state.attempts >= MAX_IN_MEMORY_RETRIES {
404 if let Some(dir) = spool_dir.as_deref() {
405 if let Err(error) = spool_retry_state(dir, &state) {
406 warn!(error = %error, count = state.batch.events.len(), "failed to spool websocket usage batch after retries");
407 retry_state = Some(state);
408 }
409 } else {
410 retry_state = Some(state);
411 }
412 } else {
413 retry_state = Some(state)
414 }
415 }
416 }
417 } else {
418 retry_state = Some(state);
419 }
420 } else if !pending.is_empty() {
421 flush_pending_batch(
422 &client,
423 &endpoint,
424 auth_token.as_deref(),
425 &mut pending,
426 &mut retry_state,
427 spool_dir.as_deref(),
428 batch_size,
429 ).await;
430 }
431 }
432 }
433 }
434 });
435
436 Self { sender }
437 }
438}
439
440#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
443struct UpdateKey {
444 window: u64,
445 client_id: String,
446 deployment_id: Option<String>,
447 identity: UsageIdentity,
448 view_id: String,
449}
450
451impl UpdateKey {
452 fn window_start(window: u64) -> Self {
454 Self {
455 window,
456 client_id: String::new(),
457 deployment_id: None,
458 identity: UsageIdentity::default(),
459 view_id: String::new(),
460 }
461 }
462}
463
464#[derive(Debug, Clone, Copy, PartialEq, Eq)]
465struct UpdateTotals {
466 messages: u32,
467 bytes: u64,
468 last_occurred_at_ms: u64,
469}
470
471struct CoalescedUpdates {
476 build_id: Option<String>,
477 updates: BTreeMap<UpdateKey, UpdateTotals>,
478}
479
480impl CoalescedUpdates {
481 fn new(build_id: Option<String>) -> Self {
482 Self {
483 build_id,
484 updates: BTreeMap::new(),
485 }
486 }
487
488 fn len(&self) -> usize {
489 self.updates.len()
490 }
491
492 fn add(
495 &mut self,
496 key: UpdateKey,
497 update: UpdateTotals,
498 pending: &mut Vec<WebSocketUsageEnvelope>,
499 ) {
500 match self.updates.entry(key) {
501 Entry::Vacant(entry) => {
502 entry.insert(update);
503 }
504 Entry::Occupied(mut entry) => {
505 let totals = entry.get_mut();
506 match (
507 totals.messages.checked_add(update.messages),
508 totals.bytes.checked_add(update.bytes),
509 ) {
510 (Some(messages), Some(bytes)) => {
511 totals.messages = messages;
512 totals.bytes = bytes;
513 totals.last_occurred_at_ms =
514 totals.last_occurred_at_ms.max(update.last_occurred_at_ms);
515 }
516 _ => {
517 let earlier = std::mem::replace(totals, update);
518 pending.push(update_envelope(
519 &self.build_id,
520 entry.key().clone(),
521 earlier,
522 ));
523 }
524 }
525 }
526 }
527 }
528
529 fn drain_closed(&mut self, now_ms: u64, pending: &mut Vec<WebSocketUsageEnvelope>) {
531 let open = self
532 .updates
533 .split_off(&UpdateKey::window_start(now_ms / UPDATE_COALESCE_WINDOW_MS));
534 let closed = std::mem::replace(&mut self.updates, open);
535 self.queue(closed, pending);
536 }
537
538 fn drain_all(&mut self, pending: &mut Vec<WebSocketUsageEnvelope>) {
540 let all = std::mem::take(&mut self.updates);
541 self.queue(all, pending);
542 }
543
544 fn queue(
545 &self,
546 updates: BTreeMap<UpdateKey, UpdateTotals>,
547 pending: &mut Vec<WebSocketUsageEnvelope>,
548 ) {
549 pending.extend(
550 updates
551 .into_iter()
552 .map(|(key, totals)| update_envelope(&self.build_id, key, totals)),
553 );
554 }
555}
556
557fn update_envelope(
558 build_id: &Option<String>,
559 key: UpdateKey,
560 totals: UpdateTotals,
561) -> WebSocketUsageEnvelope {
562 WebSocketUsageEnvelope {
563 event_id: Uuid::new_v4().to_string(),
564 occurred_at_ms: totals.last_occurred_at_ms,
565 build_id: build_id.clone(),
566 event: WebSocketUsageEvent::UpdateSent {
567 client_id: key.client_id,
568 deployment_id: key.deployment_id,
569 identity: key.identity,
570 view_id: key.view_id,
571 messages: totals.messages,
572 bytes: totals.bytes,
573 },
574 }
575}
576
577fn validate_build_id(value: String) -> Result<String, InvalidUsageBuildId> {
578 if value.trim() != value || value.parse::<i32>().ok().is_none_or(|value| value <= 0) {
579 return Err(InvalidUsageBuildId);
580 }
581 Ok(value)
582}
583
584#[async_trait]
585impl WebSocketUsageEmitter for HttpUsageEmitter {
586 async fn emit(&self, event: WebSocketUsageEvent) {
587 if let Err(error) = self
588 .sender
589 .send(UsageEmitterCommand::Event(Box::new(event)))
590 {
591 warn!(error = %error, "failed to queue websocket usage event");
592 }
593 }
594
595 async fn flush(&self) {
596 let (done, completed) = oneshot::channel();
597 if self.sender.send(UsageEmitterCommand::Flush(done)).is_ok() {
598 let _ = completed.await;
599 }
600 }
601
602 async fn shutdown(&self) {
603 let (done, completed) = oneshot::channel();
604 if self
605 .sender
606 .send(UsageEmitterCommand::Shutdown(done))
607 .is_ok()
608 {
609 let _ = completed.await;
610 }
611 }
612}
613
614fn current_time_ms() -> u64 {
615 std::time::SystemTime::now()
616 .duration_since(std::time::UNIX_EPOCH)
617 .unwrap_or_default()
618 .as_millis() as u64
619}
620
621async fn flush_batch(
622 client: &reqwest::Client,
623 endpoint: &str,
624 auth_token: Option<&str>,
625 batch: &WebSocketUsageBatch,
626) -> bool {
627 if batch.events.is_empty() {
628 return true;
629 }
630
631 let mut request = client.post(endpoint).json(batch);
632 if let Some(token) = auth_token {
633 request = request.header("Authorization", format!("Bearer {}", token));
634 }
635
636 match request.send().await {
637 Ok(response) if response.status().is_success() => {
638 debug!(count = batch.events.len(), "flushed websocket usage batch");
639 true
640 }
641 Ok(response) => {
642 error!(status = %response.status(), count = batch.events.len(), "failed to ingest websocket usage batch");
643 false
644 }
645 Err(error) => {
646 error!(error = %error, count = batch.events.len(), "failed to post websocket usage batch");
647 false
648 }
649 }
650}
651
652async fn flush_pending_batch(
653 client: &reqwest::Client,
654 endpoint: &str,
655 auth_token: Option<&str>,
656 pending: &mut Vec<WebSocketUsageEnvelope>,
657 retry_state: &mut Option<RetryState>,
658 spool_dir: Option<&Path>,
659 batch_size: usize,
660) {
661 let batch = take_pending_batch(pending, batch_size);
662
663 if !flush_batch(client, endpoint, auth_token, &batch).await {
664 let state = RetryState {
665 batch,
666 attempts: 1,
667 next_retry_at: Instant::now() + retry_delay(1),
668 };
669
670 if let Some(dir) = spool_dir.filter(|_| MAX_IN_MEMORY_RETRIES <= 1) {
671 if let Err(error) = spool_retry_state(dir, &state) {
672 warn!(error = %error, count = state.batch.events.len(), "failed to spool websocket usage batch after first failure");
673 *retry_state = Some(state);
674 }
675 } else {
676 *retry_state = Some(state);
677 }
678 }
679}
680
681struct LifecycleFlushOptions<'a> {
690 spool_dir: Option<&'a Path>,
691 batch_size: usize,
692 preserve_failed_for_retry: bool,
693}
694
695async fn flush_on_shutdown(
696 client: &reqwest::Client,
697 endpoint: &str,
698 auth_token: Option<&str>,
699 pending: &mut Vec<WebSocketUsageEnvelope>,
700 retry_state: &mut Option<RetryState>,
701 options: LifecycleFlushOptions<'_>,
702) {
703 if let Some(dir) = options.spool_dir {
704 if let Some(state) = retry_state.take() {
708 if let Err(error) = spool_retry_state(dir, &state) {
709 warn!(error = %error, count = state.batch.events.len(), "failed to spool websocket usage retry during shutdown");
710 if !flush_batch(client, endpoint, auth_token, &state.batch).await {
711 if options.preserve_failed_for_retry {
712 warn!(
713 count = state.batch.events.len(),
714 attempts = state.attempts,
715 "websocket usage flush failed; retaining batch for retry"
716 );
717 *retry_state = Some(state);
718 return;
719 }
720 error!(
721 count = state.batch.events.len(),
722 "failed to deliver websocket usage retry after shutdown spool failure"
723 );
724 }
725 }
726 }
727 while !pending.is_empty() {
728 let batch = take_pending_batch(pending, options.batch_size);
729 if let Err(error) = spool_batch(dir, &batch) {
730 warn!(error = %error, count = batch.events.len(), "failed to spool pending websocket usage batch during shutdown");
731 if !flush_batch(client, endpoint, auth_token, &batch).await {
732 if options.preserve_failed_for_retry {
733 let state = RetryState {
734 batch,
735 attempts: 1,
736 next_retry_at: Instant::now() + retry_delay(1),
737 };
738 warn!(
739 count = state.batch.events.len(),
740 "websocket usage flush failed; retaining batch for retry"
741 );
742 *retry_state = Some(state);
743 return;
744 }
745 error!(
746 count = batch.events.len(),
747 "failed to deliver pending websocket usage after shutdown spool failure"
748 );
749 }
750 }
751 }
752 return;
753 }
754
755 loop {
756 if let Some(state) = retry_state.take() {
757 if let Err(retry_state_failed) =
758 flush_existing_batch(client, endpoint, auth_token, state).await
759 {
760 if options.preserve_failed_for_retry {
761 warn!(
762 count = retry_state_failed.batch.events.len(),
763 attempts = retry_state_failed.attempts,
764 "websocket usage flush failed; retaining batch for retry"
765 );
766 *retry_state = Some(retry_state_failed);
767 return;
768 }
769 warn!(
770 count = retry_state_failed.batch.events.len(),
771 attempts = retry_state_failed.attempts,
772 "dropping websocket usage batch during shutdown after failed retry"
773 );
774 break;
775 }
776 continue;
777 }
778
779 if pending.is_empty() {
780 return;
781 }
782
783 flush_pending_batch(
784 client,
785 endpoint,
786 auth_token,
787 pending,
788 retry_state,
789 None,
790 options.batch_size,
791 )
792 .await;
793 }
794
795 if !pending.is_empty() {
796 warn!(
797 count = pending.len(),
798 "dropping pending websocket usage events during shutdown without spool directory"
799 );
800 }
801}
802
803fn take_pending_batch(
811 pending: &mut Vec<WebSocketUsageEnvelope>,
812 batch_size: usize,
813) -> WebSocketUsageBatch {
814 let batch_size = batch_size.max(1);
815 let remainder = if pending.len() > batch_size {
816 pending.split_off(batch_size)
817 } else {
818 Vec::new()
819 };
820 let events = std::mem::replace(pending, remainder);
821 WebSocketUsageBatch { events }
822}
823
824async fn flush_existing_batch(
825 client: &reqwest::Client,
826 endpoint: &str,
827 auth_token: Option<&str>,
828 mut state: RetryState,
829) -> Result<(), RetryState> {
830 if flush_batch(client, endpoint, auth_token, &state.batch).await {
831 Ok(())
832 } else {
833 state.attempts += 1;
834 state.next_retry_at = Instant::now() + retry_delay(state.attempts);
835 Err(state)
836 }
837}
838
839fn retry_delay(attempt: u32) -> Duration {
840 let capped_attempt = attempt.min(6);
841 Duration::from_secs(1_u64 << capped_attempt)
842}
843
844fn ensure_spool_dir(path: &Path) -> std::io::Result<()> {
845 std::fs::create_dir_all(path)
846}
847
848fn spool_retry_state(path: &Path, state: &RetryState) -> std::io::Result<PathBuf> {
849 spool_batch(path, &state.batch)
850}
851
852fn spool_batch(path: &Path, batch: &WebSocketUsageBatch) -> std::io::Result<PathBuf> {
853 ensure_spool_dir(path)?;
854
855 let file_name = format!(
856 "ws-usage-{}-{}.json",
857 current_time_ms(),
858 Uuid::new_v4().simple()
859 );
860 let final_path = path.join(file_name);
861 let temp_path = final_path.with_extension("tmp");
862 let data = serde_json::to_vec(batch).map_err(std::io::Error::other)?;
863 std::fs::write(&temp_path, data)?;
864 std::fs::rename(&temp_path, &final_path)?;
865 Ok(final_path)
866}
867
868fn load_batch_from_file(path: &Path) -> std::io::Result<WebSocketUsageBatch> {
869 let data = std::fs::read(path)?;
870 serde_json::from_slice(&data).map_err(std::io::Error::other)
871}
872
873fn oldest_spooled_batch(path: &Path) -> std::io::Result<Option<PathBuf>> {
874 if !path.exists() {
875 return Ok(None);
876 }
877
878 let mut entries: Vec<PathBuf> = std::fs::read_dir(path)?
879 .filter_map(|entry| entry.ok().map(|entry| entry.path()))
880 .filter(|entry| entry.extension().and_then(|ext| ext.to_str()) == Some("json"))
881 .collect();
882 entries.sort();
883 Ok(entries.into_iter().next())
884}
885
886async fn flush_one_spooled_batch(
887 client: &reqwest::Client,
888 endpoint: &str,
889 auth_token: Option<&str>,
890 spool_dir: &Path,
891 batch_size: usize,
892) -> std::io::Result<()> {
893 let Some(path) = oldest_spooled_batch(spool_dir)? else {
894 return Ok(());
895 };
896
897 let mut batch = load_batch_from_file(&path)?;
898 let batch_size = batch_size.max(1);
899 let remainder = if batch.events.len() > batch_size {
900 Some(WebSocketUsageBatch {
901 events: batch.events.split_off(batch_size),
902 })
903 } else {
904 None
905 };
906
907 if flush_batch(client, endpoint, auth_token, &batch).await {
908 if let Some(remainder) = remainder {
909 replace_spooled_batch(&path, &remainder)?;
914 } else {
915 std::fs::remove_file(path)?;
916 }
917 }
918
919 Ok(())
920}
921
922fn replace_spooled_batch(path: &Path, batch: &WebSocketUsageBatch) -> std::io::Result<()> {
923 let temp_path = path.with_extension("tmp");
924 let data = serde_json::to_vec(batch).map_err(std::io::Error::other)?;
925 std::fs::write(&temp_path, data)?;
926 std::fs::rename(temp_path, path)
927}
928
929#[cfg(test)]
930mod tests {
931 use super::*;
932 use std::fs;
933 use tokio::io::{AsyncReadExt, AsyncWriteExt};
934 use tokio::net::TcpListener;
935
936 fn usage_envelope(index: u64) -> WebSocketUsageEnvelope {
937 WebSocketUsageEnvelope {
938 event_id: format!("evt-{index}"),
939 occurred_at_ms: index,
940 build_id: None,
941 event: WebSocketUsageEvent::ConnectionEstablished {
942 client_id: format!("client-{index}"),
943 remote_addr: "127.0.0.1:1234".to_string(),
944 deployment_id: Some("1".to_string()),
945 identity: UsageIdentity::default(),
946 },
947 }
948 }
949
950 fn usage_event(index: u64) -> WebSocketUsageEvent {
951 WebSocketUsageEvent::ConnectionEstablished {
952 client_id: format!("client-{index}"),
953 remote_addr: "127.0.0.1:1234".to_string(),
954 deployment_id: Some("1".to_string()),
955 identity: UsageIdentity::default(),
956 }
957 }
958
959 async fn successful_batch_server(
960 request_count: usize,
961 ) -> (String, tokio::task::JoinHandle<Vec<Vec<String>>>) {
962 batch_server(request_count, |batch| {
963 batch
964 .events
965 .into_iter()
966 .map(|event| event.event_id)
967 .collect()
968 })
969 .await
970 }
971
972 async fn batch_server<T: Send + 'static>(
973 request_count: usize,
974 received_batch: fn(WebSocketUsageBatch) -> T,
975 ) -> (String, tokio::task::JoinHandle<Vec<T>>) {
976 let listener = TcpListener::bind("127.0.0.1:0")
977 .await
978 .expect("test server should bind");
979 let address = listener.local_addr().expect("test server address");
980 let server = tokio::spawn(async move {
981 let mut received = Vec::with_capacity(request_count);
982 for _ in 0..request_count {
983 let (mut stream, _) = listener.accept().await.expect("request should connect");
984 let mut bytes = Vec::new();
985 let body_start = loop {
986 let mut chunk = [0_u8; 4096];
987 let count = stream.read(&mut chunk).await.expect("request should read");
988 assert!(count > 0, "request ended before its headers");
989 bytes.extend_from_slice(&chunk[..count]);
990 if let Some(index) = bytes.windows(4).position(|window| window == b"\r\n\r\n") {
991 break index + 4;
992 }
993 };
994 let headers = std::str::from_utf8(&bytes[..body_start])
995 .expect("request headers should be UTF-8");
996 let content_length = headers
997 .lines()
998 .find_map(|line| {
999 let (name, value) = line.split_once(':')?;
1000 name.eq_ignore_ascii_case("content-length")
1001 .then(|| value.trim().parse::<usize>().expect("content length"))
1002 })
1003 .expect("request should have content length");
1004 while bytes.len() - body_start < content_length {
1005 let mut chunk = [0_u8; 4096];
1006 let count = stream.read(&mut chunk).await.expect("body should read");
1007 assert!(count > 0, "request ended before its body");
1008 bytes.extend_from_slice(&chunk[..count]);
1009 }
1010 let batch: WebSocketUsageBatch =
1011 serde_json::from_slice(&bytes[body_start..body_start + content_length])
1012 .expect("request should contain a usage batch");
1013 received.push(received_batch(batch));
1014 stream
1015 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
1016 .await
1017 .expect("response should write");
1018 }
1019 received
1020 });
1021 (format!("http://{address}"), server)
1022 }
1023
1024 fn temp_spool_dir() -> PathBuf {
1025 let dir = std::env::temp_dir().join(format!("arete-usage-test-{}", Uuid::new_v4()));
1026 fs::create_dir_all(&dir).expect("temp dir should be created");
1027 dir
1028 }
1029
1030 #[tokio::test]
1031 async fn channel_usage_emitter_forwards_events() {
1032 let (tx, mut rx) = mpsc::unbounded_channel();
1033 let emitter = ChannelUsageEmitter::new(tx);
1034
1035 emitter
1036 .emit(WebSocketUsageEvent::SubscriptionCreated {
1037 client_id: "client-1".to_string(),
1038 deployment_id: Some("deployment-1".to_string()),
1039 identity: UsageIdentity {
1040 metering_key: Some("meter-1".to_string()),
1041 subject: Some("subject-1".to_string()),
1042 ..Default::default()
1043 },
1044 view_id: "OreRound/latest".to_string(),
1045 })
1046 .await;
1047
1048 let event = rx.recv().await.expect("event should be forwarded");
1049 match event {
1050 WebSocketUsageEvent::SubscriptionCreated { view_id, .. } => {
1051 assert_eq!(view_id, "OreRound/latest");
1052 }
1053 other => panic!("unexpected event: {other:?}"),
1054 }
1055 }
1056
1057 #[tokio::test]
1058 async fn flush_is_an_ordered_barrier_and_keeps_the_emitter_open() {
1059 let (endpoint, server) = successful_batch_server(2).await;
1060 let emitter = HttpUsageEmitter::with_config(endpoint, None, 50, Duration::from_secs(3_600));
1061
1062 emitter.emit(usage_event(1)).await;
1063 emitter.flush().await;
1064 emitter.emit(usage_event(2)).await;
1065 emitter.shutdown().await;
1066
1067 let received = server.await.expect("test server should finish");
1068 assert_eq!(received.len(), 2);
1069 assert_eq!(received[0].len(), 1);
1070 assert_eq!(received[1].len(), 1);
1071 }
1072
1073 fn update_key(window: u64, client_id: &str, view_id: &str) -> UpdateKey {
1074 UpdateKey {
1075 window,
1076 client_id: client_id.to_string(),
1077 deployment_id: Some("1".to_string()),
1078 identity: UsageIdentity {
1079 account_key: Some("account:42".to_string()),
1080 ..Default::default()
1081 },
1082 view_id: view_id.to_string(),
1083 }
1084 }
1085
1086 fn update(messages: u32, bytes: u64, last_occurred_at_ms: u64) -> UpdateTotals {
1087 UpdateTotals {
1088 messages,
1089 bytes,
1090 last_occurred_at_ms,
1091 }
1092 }
1093
1094 fn update_counts(pending: &[WebSocketUsageEnvelope]) -> Vec<(String, String, u32, u64, u64)> {
1095 pending
1096 .iter()
1097 .map(|envelope| match &envelope.event {
1098 WebSocketUsageEvent::UpdateSent {
1099 client_id,
1100 view_id,
1101 messages,
1102 bytes,
1103 ..
1104 } => (
1105 client_id.clone(),
1106 view_id.clone(),
1107 *messages,
1108 *bytes,
1109 envelope.occurred_at_ms,
1110 ),
1111 other => panic!("unexpected event: {other:?}"),
1112 })
1113 .collect()
1114 }
1115
1116 #[test]
1117 fn updates_are_summed_per_connection_view_and_window() {
1118 let mut updates = CoalescedUpdates::new(Some("7".to_string()));
1119 let mut pending = Vec::new();
1120 updates.add(
1121 update_key(5, "client-1", "Round/latest"),
1122 update(1, 10, 50_001),
1123 &mut pending,
1124 );
1125 updates.add(
1126 update_key(5, "client-1", "Round/latest"),
1127 update(1, 20, 50_003),
1128 &mut pending,
1129 );
1130 updates.add(
1131 update_key(5, "client-1", "Round/list"),
1132 update(1, 5, 50_002),
1133 &mut pending,
1134 );
1135 updates.add(
1136 update_key(5, "client-2", "Round/latest"),
1137 update(1, 7, 50_004),
1138 &mut pending,
1139 );
1140 updates.add(
1141 update_key(6, "client-1", "Round/latest"),
1142 update(1, 30, 60_000),
1143 &mut pending,
1144 );
1145 assert!(
1146 pending.is_empty(),
1147 "nothing is queued before its window closes"
1148 );
1149
1150 updates.drain_all(&mut pending);
1151 assert_eq!(
1152 update_counts(&pending),
1153 vec![
1154 (
1155 "client-1".to_string(),
1156 "Round/latest".to_string(),
1157 2,
1158 30,
1159 50_003
1160 ),
1161 (
1162 "client-1".to_string(),
1163 "Round/list".to_string(),
1164 1,
1165 5,
1166 50_002
1167 ),
1168 (
1169 "client-2".to_string(),
1170 "Round/latest".to_string(),
1171 1,
1172 7,
1173 50_004
1174 ),
1175 (
1176 "client-1".to_string(),
1177 "Round/latest".to_string(),
1178 1,
1179 30,
1180 60_000
1181 ),
1182 ]
1183 );
1184 assert!(pending
1185 .iter()
1186 .all(|envelope| envelope.build_id.as_deref() == Some("7")));
1187 assert_eq!(updates.len(), 0);
1188 }
1189
1190 #[test]
1191 fn updates_for_different_identities_or_deployments_are_never_summed() {
1192 let mut updates = CoalescedUpdates::new(None);
1193 let mut pending = Vec::new();
1194 let base = update_key(5, "client-1", "Round/latest");
1195 let other_account = UpdateKey {
1196 identity: UsageIdentity {
1197 account_key: Some("account:43".to_string()),
1198 ..Default::default()
1199 },
1200 ..base.clone()
1201 };
1202 let other_plan = UpdateKey {
1203 identity: UsageIdentity {
1204 plan_code: Some("pro".to_string()),
1205 ..base.identity.clone()
1206 },
1207 ..base.clone()
1208 };
1209 let other_deployment = UpdateKey {
1210 deployment_id: Some("2".to_string()),
1211 ..base.clone()
1212 };
1213 for (key, bytes) in [
1214 (base.clone(), 1),
1215 (other_account.clone(), 10),
1216 (other_plan.clone(), 100),
1217 (other_deployment.clone(), 1_000),
1218 (base.clone(), 2),
1219 ] {
1220 updates.add(key, update(1, bytes, 50_000), &mut pending);
1221 }
1222 updates.drain_all(&mut pending);
1223
1224 let mut totals = pending
1225 .iter()
1226 .map(|envelope| match &envelope.event {
1227 WebSocketUsageEvent::UpdateSent {
1228 deployment_id,
1229 identity,
1230 messages,
1231 bytes,
1232 ..
1233 } => (deployment_id.clone(), identity.clone(), *messages, *bytes),
1234 other => panic!("unexpected event: {other:?}"),
1235 })
1236 .collect::<Vec<_>>();
1237 totals.sort_by_key(|(_, _, _, bytes)| *bytes);
1238 assert_eq!(
1239 totals,
1240 vec![
1241 (base.deployment_id, base.identity, 2, 3),
1242 (other_account.deployment_id, other_account.identity, 1, 10),
1243 (other_plan.deployment_id, other_plan.identity, 1, 100),
1244 (
1245 other_deployment.deployment_id,
1246 other_deployment.identity,
1247 1,
1248 1_000
1249 ),
1250 ]
1251 );
1252 }
1253
1254 #[test]
1255 fn closed_windows_drain_and_the_open_window_keeps_summing() {
1256 let mut updates = CoalescedUpdates::new(None);
1257 let mut pending = Vec::new();
1258 updates.add(
1259 update_key(5, "client-1", "view"),
1260 update(1, 1, 59_999),
1261 &mut pending,
1262 );
1263 updates.add(
1264 update_key(6, "client-1", "view"),
1265 update(1, 2, 60_000),
1266 &mut pending,
1267 );
1268
1269 updates.drain_closed(65_000, &mut pending);
1270 assert_eq!(
1271 update_counts(&pending),
1272 vec![("client-1".to_string(), "view".to_string(), 1, 1, 59_999)]
1273 );
1274
1275 pending.clear();
1276 updates.add(
1277 update_key(6, "client-1", "view"),
1278 update(1, 3, 61_000),
1279 &mut pending,
1280 );
1281 updates.drain_closed(69_999, &mut pending);
1282 assert!(pending.is_empty(), "window 6 is still open");
1283 updates.drain_closed(70_000, &mut pending);
1284 assert_eq!(
1285 update_counts(&pending),
1286 vec![("client-1".to_string(), "view".to_string(), 2, 5, 61_000)]
1287 );
1288 }
1289
1290 #[test]
1291 fn overflowing_update_totals_queue_the_earlier_sum() {
1292 let mut updates = CoalescedUpdates::new(None);
1293 let mut pending = Vec::new();
1294 updates.add(
1295 update_key(5, "client-1", "view"),
1296 update(u32::MAX, 1, 50_000),
1297 &mut pending,
1298 );
1299 updates.add(
1300 update_key(5, "client-1", "view"),
1301 update(2, 3, 50_001),
1302 &mut pending,
1303 );
1304 assert_eq!(
1305 update_counts(&pending),
1306 vec![(
1307 "client-1".to_string(),
1308 "view".to_string(),
1309 u32::MAX,
1310 1,
1311 50_000
1312 )]
1313 );
1314
1315 updates.drain_all(&mut pending);
1316 assert_eq!(
1317 update_counts(&pending[1..]),
1318 vec![("client-1".to_string(), "view".to_string(), 2, 3, 50_001)]
1319 );
1320 }
1321
1322 #[tokio::test]
1323 async fn http_emitter_posts_summed_updates_and_other_events_as_emitted() {
1324 let (endpoint, server) = batch_server(1, |batch| batch.events).await;
1325 let emitter = HttpUsageEmitter::with_config(endpoint, None, 50, Duration::from_secs(3_600));
1326 let identity = UsageIdentity {
1327 account_key: Some("account:42".to_string()),
1328 ..Default::default()
1329 };
1330
1331 emitter.emit(usage_event(1)).await;
1332 for bytes in 1..=10 {
1333 emitter
1334 .emit(WebSocketUsageEvent::UpdateSent {
1335 client_id: "client-1".to_string(),
1336 deployment_id: Some("1".to_string()),
1337 identity: identity.clone(),
1338 view_id: "Round/latest".to_string(),
1339 messages: 1,
1340 bytes,
1341 })
1342 .await;
1343 }
1344 emitter.shutdown().await;
1345
1346 let received = server.await.expect("test server should finish");
1347 assert_eq!(received.len(), 1);
1348 let (updates, others): (Vec<_>, Vec<_>) = received[0]
1349 .iter()
1350 .partition(|envelope| matches!(envelope.event, WebSocketUsageEvent::UpdateSent { .. }));
1351 assert_eq!(others.len(), 1, "connection events are posted one each");
1352 assert!(
1354 (1..=2).contains(&updates.len()),
1355 "expected the updates summed into at most two events, got {}",
1356 updates.len()
1357 );
1358 let (messages, bytes) = updates.iter().fold((0, 0), |(messages, bytes), envelope| {
1359 match &envelope.event {
1360 WebSocketUsageEvent::UpdateSent {
1361 messages: m,
1362 bytes: b,
1363 identity: event_identity,
1364 ..
1365 } => {
1366 assert_eq!(event_identity, &identity);
1367 (messages + m, bytes + b)
1368 }
1369 _ => unreachable!(),
1370 }
1371 });
1372 assert_eq!((messages, bytes), (10, 55));
1373 }
1374
1375 #[test]
1376 fn retry_delay_grows_and_caps() {
1377 assert_eq!(retry_delay(1), Duration::from_secs(2));
1378 assert_eq!(retry_delay(2), Duration::from_secs(4));
1379 assert_eq!(retry_delay(6), Duration::from_secs(64));
1380 assert_eq!(retry_delay(9), Duration::from_secs(64));
1381 }
1382
1383 #[test]
1384 fn spooled_batches_round_trip() {
1385 let dir = temp_spool_dir();
1386 let batch = WebSocketUsageBatch {
1387 events: vec![WebSocketUsageEnvelope {
1388 event_id: "evt_1".to_string(),
1389 occurred_at_ms: 123,
1390 build_id: Some("7".to_string()),
1391 event: WebSocketUsageEvent::UpdateSent {
1392 client_id: "client-1".to_string(),
1393 deployment_id: Some("1".to_string()),
1394 identity: UsageIdentity {
1395 metering_key: Some("api_key:1".to_string()),
1396 subject: Some("user:1".to_string()),
1397 ..Default::default()
1398 },
1399 view_id: "OreRound/latest".to_string(),
1400 messages: 1,
1401 bytes: 42,
1402 },
1403 }],
1404 };
1405
1406 let path = spool_batch(&dir, &batch).expect("batch should spool");
1407 let loaded = load_batch_from_file(&path).expect("batch should load");
1408 assert_eq!(loaded.events.len(), 1);
1409 assert_eq!(loaded.events[0].build_id.as_deref(), Some("7"));
1410
1411 fs::remove_dir_all(dir).expect("temp dir should be removed");
1412 }
1413
1414 #[test]
1415 fn pending_backlog_is_drained_in_bounded_batches() {
1416 let mut pending = (0..1250).map(usage_envelope).collect::<Vec<_>>();
1417
1418 let first = take_pending_batch(&mut pending, 50);
1419 assert_eq!(first.events.len(), 50);
1420 assert_eq!(pending.len(), 1200);
1421 assert_eq!(first.events[0].event_id, "evt-0");
1422 assert_eq!(pending[0].event_id, "evt-50");
1423 }
1424
1425 #[tokio::test]
1426 async fn oversized_legacy_spool_is_delivered_in_successive_bounded_chunks() {
1427 let dir = temp_spool_dir();
1428 let path = dir.join("ws-usage-100-a.json");
1429 let batch = WebSocketUsageBatch {
1430 events: (0..5).map(usage_envelope).collect(),
1431 };
1432 fs::write(&path, serde_json::to_vec(&batch).unwrap()).expect("legacy spool should write");
1433 let (endpoint, server) = successful_batch_server(3).await;
1434 let client = reqwest::Client::new();
1435
1436 flush_one_spooled_batch(&client, &endpoint, None, &dir, 2)
1437 .await
1438 .expect("first chunk should flush");
1439 let loaded = load_batch_from_file(&path).expect("remainder should be readable");
1440 assert_eq!(loaded.events.len(), 3);
1441 assert_eq!(loaded.events[0].event_id, "evt-2");
1442
1443 flush_one_spooled_batch(&client, &endpoint, None, &dir, 2)
1444 .await
1445 .expect("second chunk should flush");
1446 let loaded = load_batch_from_file(&path).expect("final remainder should be readable");
1447 assert_eq!(loaded.events.len(), 1);
1448 assert_eq!(loaded.events[0].event_id, "evt-4");
1449
1450 flush_one_spooled_batch(&client, &endpoint, None, &dir, 2)
1451 .await
1452 .expect("final chunk should flush");
1453 assert!(!path.exists());
1454
1455 let received = server.await.expect("test server should finish");
1456 assert_eq!(
1457 received,
1458 vec![
1459 vec!["evt-0".to_string(), "evt-1".to_string()],
1460 vec!["evt-2".to_string(), "evt-3".to_string()],
1461 vec!["evt-4".to_string()],
1462 ]
1463 );
1464
1465 fs::remove_dir_all(dir).expect("temp dir should be removed");
1466 }
1467
1468 #[tokio::test]
1469 async fn shutdown_drains_every_chunk_after_a_retry_recovers() {
1470 let (endpoint, server) = successful_batch_server(4).await;
1471 let client = reqwest::Client::new();
1472 let mut pending = (1..=5).map(usage_envelope).collect::<Vec<_>>();
1473 let mut retry_state = Some(RetryState {
1474 batch: WebSocketUsageBatch {
1475 events: vec![usage_envelope(0)],
1476 },
1477 attempts: 1,
1478 next_retry_at: Instant::now(),
1479 });
1480
1481 flush_on_shutdown(
1482 &client,
1483 &endpoint,
1484 None,
1485 &mut pending,
1486 &mut retry_state,
1487 LifecycleFlushOptions {
1488 spool_dir: None,
1489 batch_size: 2,
1490 preserve_failed_for_retry: false,
1491 },
1492 )
1493 .await;
1494
1495 assert!(pending.is_empty());
1496 assert!(retry_state.is_none());
1497 let received = server.await.expect("test server should finish");
1498 assert_eq!(
1499 received,
1500 vec![
1501 vec!["evt-0".to_string()],
1502 vec!["evt-1".to_string(), "evt-2".to_string()],
1503 vec!["evt-3".to_string(), "evt-4".to_string()],
1504 vec!["evt-5".to_string()],
1505 ]
1506 );
1507 }
1508
1509 #[tokio::test]
1510 async fn flush_barrier_retains_failed_batch_for_later_retry() {
1511 let client = reqwest::Client::new();
1512 let mut pending = vec![usage_envelope(0)];
1513 let mut retry_state = None;
1514
1515 flush_on_shutdown(
1516 &client,
1517 "http://127.0.0.1:1/unreachable",
1518 None,
1519 &mut pending,
1520 &mut retry_state,
1521 LifecycleFlushOptions {
1522 spool_dir: None,
1523 batch_size: 2,
1524 preserve_failed_for_retry: true,
1525 },
1526 )
1527 .await;
1528
1529 assert!(pending.is_empty());
1530 assert_eq!(
1531 retry_state
1532 .as_ref()
1533 .expect("failed flush should retain its retry")
1534 .batch
1535 .events[0]
1536 .event_id,
1537 "evt-0"
1538 );
1539
1540 let (endpoint, server) = successful_batch_server(1).await;
1541 flush_on_shutdown(
1542 &client,
1543 &endpoint,
1544 None,
1545 &mut pending,
1546 &mut retry_state,
1547 LifecycleFlushOptions {
1548 spool_dir: None,
1549 batch_size: 2,
1550 preserve_failed_for_retry: true,
1551 },
1552 )
1553 .await;
1554
1555 assert!(retry_state.is_none());
1556 assert_eq!(
1557 server.await.expect("test server should finish"),
1558 vec![vec!["evt-0".to_string()]]
1559 );
1560 }
1561
1562 #[tokio::test]
1563 async fn flush_barrier_retains_failed_batch_when_spool_is_unwritable() {
1564 let dir = temp_spool_dir();
1565 let invalid_spool_dir = dir.join("not-a-directory");
1566 fs::write(&invalid_spool_dir, b"occupied").expect("spool blocker should write");
1567 let client = reqwest::Client::new();
1568 let mut pending = vec![usage_envelope(0)];
1569 let mut retry_state = None;
1570
1571 flush_on_shutdown(
1572 &client,
1573 "http://127.0.0.1:1/unreachable",
1574 None,
1575 &mut pending,
1576 &mut retry_state,
1577 LifecycleFlushOptions {
1578 spool_dir: Some(&invalid_spool_dir),
1579 batch_size: 2,
1580 preserve_failed_for_retry: true,
1581 },
1582 )
1583 .await;
1584
1585 assert!(pending.is_empty());
1586 assert_eq!(
1587 retry_state
1588 .as_ref()
1589 .expect("failed flush should retain its retry")
1590 .batch
1591 .events[0]
1592 .event_id,
1593 "evt-0"
1594 );
1595
1596 fs::remove_dir_all(dir).expect("temp dir should be removed");
1597 }
1598
1599 #[tokio::test]
1600 async fn shutdown_persists_every_chunk_before_network_drain_when_spooling() {
1601 let dir = temp_spool_dir();
1602 let client = reqwest::Client::new();
1603 let mut pending = (1..=5).map(usage_envelope).collect::<Vec<_>>();
1604 let mut retry_state = Some(RetryState {
1605 batch: WebSocketUsageBatch {
1606 events: vec![usage_envelope(0)],
1607 },
1608 attempts: 1,
1609 next_retry_at: Instant::now(),
1610 });
1611
1612 flush_on_shutdown(
1613 &client,
1614 "http://127.0.0.1:1/unreachable",
1615 None,
1616 &mut pending,
1617 &mut retry_state,
1618 LifecycleFlushOptions {
1619 spool_dir: Some(&dir),
1620 batch_size: 2,
1621 preserve_failed_for_retry: false,
1622 },
1623 )
1624 .await;
1625
1626 assert!(pending.is_empty());
1627 assert!(retry_state.is_none());
1628 let mut event_ids = Vec::new();
1629 let mut batch_sizes = Vec::new();
1630 for entry in fs::read_dir(&dir).expect("spool directory should be readable") {
1631 let batch = load_batch_from_file(&entry.unwrap().path()).expect("spool should load");
1632 batch_sizes.push(batch.events.len());
1633 event_ids.extend(batch.events.into_iter().map(|event| event.event_id));
1634 }
1635 batch_sizes.sort_unstable();
1636 event_ids.sort();
1637 assert_eq!(batch_sizes, vec![1, 1, 2, 2]);
1638 assert_eq!(
1639 event_ids,
1640 (0..=5)
1641 .map(|index| format!("evt-{index}"))
1642 .collect::<Vec<_>>()
1643 );
1644
1645 fs::remove_dir_all(dir).expect("temp dir should be removed");
1646 }
1647
1648 #[tokio::test]
1649 async fn shutdown_uses_http_when_spool_storage_is_unwritable() {
1650 let dir = temp_spool_dir();
1651 let invalid_spool_dir = dir.join("not-a-directory");
1652 fs::write(&invalid_spool_dir, b"occupied").expect("spool blocker should write");
1653 let (endpoint, server) = successful_batch_server(4).await;
1654 let client = reqwest::Client::new();
1655 let mut pending = (1..=5).map(usage_envelope).collect::<Vec<_>>();
1656 let mut retry_state = Some(RetryState {
1657 batch: WebSocketUsageBatch {
1658 events: vec![usage_envelope(0)],
1659 },
1660 attempts: 1,
1661 next_retry_at: Instant::now(),
1662 });
1663
1664 flush_on_shutdown(
1665 &client,
1666 &endpoint,
1667 None,
1668 &mut pending,
1669 &mut retry_state,
1670 LifecycleFlushOptions {
1671 spool_dir: Some(&invalid_spool_dir),
1672 batch_size: 2,
1673 preserve_failed_for_retry: false,
1674 },
1675 )
1676 .await;
1677
1678 let received = server.await.expect("test server should finish");
1679 assert_eq!(
1680 received,
1681 vec![
1682 vec!["evt-0".to_string()],
1683 vec!["evt-1".to_string(), "evt-2".to_string()],
1684 vec!["evt-3".to_string(), "evt-4".to_string()],
1685 vec!["evt-5".to_string()],
1686 ]
1687 );
1688
1689 fs::remove_dir_all(dir).expect("temp dir should be removed");
1690 }
1691
1692 #[test]
1693 fn usage_identity_is_flattened_and_old_spool_json_remains_readable() {
1694 let legacy: WebSocketUsageBatch = serde_json::from_value(serde_json::json!({
1695 "events": [{
1696 "event_id": "evt-old",
1697 "occurred_at_ms": 123,
1698 "event": {
1699 "type": "update_sent",
1700 "client_id": "client-1",
1701 "deployment_id": "1",
1702 "metering_key": "api_key:1",
1703 "subject": "user:1",
1704 "view_id": "Round/latest",
1705 "messages": 1,
1706 "bytes": 42
1707 }
1708 }]
1709 }))
1710 .expect("old-format spool remains readable");
1711 let value = serde_json::to_value(&legacy).unwrap();
1712 assert_eq!(value["events"][0]["event"]["metering_key"], "api_key:1");
1713 assert!(value["events"][0]["event"].get("actor_key").is_none());
1714
1715 let event = WebSocketUsageEvent::UpdateSent {
1716 client_id: "client-2".to_string(),
1717 deployment_id: Some("2".to_string()),
1718 identity: UsageIdentity {
1719 metering_key: Some("account:42".to_string()),
1720 subject: Some("user:7".to_string()),
1721 key_class: Some("secret".to_string()),
1722 actor_key: Some("user:7".to_string()),
1723 account_key: Some("account:42".to_string()),
1724 consumer_key: Some("consumer:key-9".to_string()),
1725 plan_code: Some("agent_trial".to_string()),
1726 policy_version: Some(3),
1727 },
1728 view_id: "Round/latest".to_string(),
1729 messages: 1,
1730 bytes: 42,
1731 };
1732 let value = serde_json::to_value(event).unwrap();
1733 assert_eq!(value["account_key"], "account:42");
1734 assert_eq!(value["plan_code"], "agent_trial");
1735 assert_eq!(value["policy_version"], 3);
1736 }
1737
1738 #[test]
1739 fn legacy_and_attributed_envelopes_are_compatible() {
1740 let legacy = serde_json::json!({
1741 "event_id": "legacy",
1742 "occurred_at_ms": 123,
1743 "event": {
1744 "type": "update_sent",
1745 "client_id": "client",
1746 "deployment_id": "1",
1747 "metering_key": null,
1748 "subject": null,
1749 "view_id": "view",
1750 "messages": 1,
1751 "bytes": 2
1752 }
1753 });
1754 let decoded: WebSocketUsageEnvelope = serde_json::from_value(legacy).unwrap();
1755 assert_eq!(decoded.build_id, None);
1756
1757 let mut attributed = serde_json::to_value(decoded).unwrap();
1758 assert!(attributed.get("build_id").is_none());
1759 attributed["build_id"] = serde_json::json!("42");
1760 let decoded: WebSocketUsageEnvelope = serde_json::from_value(attributed).unwrap();
1761 assert_eq!(decoded.build_id.as_deref(), Some("42"));
1762 }
1763
1764 #[test]
1765 fn attributed_constructor_validates_build_identity() {
1766 assert!(
1767 HttpUsageEmitter::new_attributed("http://localhost".to_string(), None, "0").is_err()
1768 );
1769 assert!(
1770 HttpUsageEmitter::new_attributed("http://localhost".to_string(), None, " 7").is_err()
1771 );
1772 }
1773
1774 #[test]
1775 fn oldest_spooled_batch_prefers_lexicographically_oldest_file() {
1776 let dir = temp_spool_dir();
1777 fs::write(dir.join("ws-usage-100-a.json"), b"{\"events\":[]}").expect("first batch");
1778 fs::write(dir.join("ws-usage-200-b.json"), b"{\"events\":[]}").expect("second batch");
1779
1780 let oldest = oldest_spooled_batch(&dir)
1781 .expect("listing should succeed")
1782 .expect("batch should exist");
1783 assert!(oldest.ends_with("ws-usage-100-a.json"));
1784
1785 fs::remove_dir_all(dir).expect("temp dir should be removed");
1786 }
1787}