1use async_trait::async_trait;
2use serde::{Deserialize, Serialize};
3use std::path::{Path, PathBuf};
4use std::time::Duration;
5use tokio::sync::{mpsc, oneshot};
6use tokio::time::{interval, Instant, MissedTickBehavior};
7use tracing::{debug, error, warn};
8use uuid::Uuid;
9
10const MAX_IN_MEMORY_RETRIES: u32 = 3;
11
12#[derive(Debug, Clone, Default, Serialize, Deserialize)]
16pub struct UsageIdentity {
17 #[serde(skip_serializing_if = "Option::is_none")]
18 pub metering_key: Option<String>,
19 #[serde(skip_serializing_if = "Option::is_none")]
20 pub subject: Option<String>,
21 #[serde(skip_serializing_if = "Option::is_none")]
22 pub key_class: Option<String>,
23 #[serde(skip_serializing_if = "Option::is_none")]
24 pub actor_key: Option<String>,
25 #[serde(skip_serializing_if = "Option::is_none")]
26 pub account_key: Option<String>,
27 #[serde(skip_serializing_if = "Option::is_none")]
28 pub consumer_key: Option<String>,
29 #[serde(skip_serializing_if = "Option::is_none")]
30 pub plan_code: Option<String>,
31 #[serde(skip_serializing_if = "Option::is_none")]
32 pub policy_version: Option<u32>,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
36#[serde(tag = "type", rename_all = "snake_case")]
37pub enum WebSocketUsageEvent {
38 ConnectionEstablished {
39 client_id: String,
40 remote_addr: String,
41 deployment_id: Option<String>,
42 #[serde(flatten)]
43 identity: UsageIdentity,
44 },
45 ConnectionClosed {
46 client_id: String,
47 deployment_id: Option<String>,
48 #[serde(flatten)]
49 identity: UsageIdentity,
50 duration_secs: Option<f64>,
51 subscription_count: u32,
52 },
53 SubscriptionCreated {
54 client_id: String,
55 deployment_id: Option<String>,
56 #[serde(flatten)]
57 identity: UsageIdentity,
58 view_id: String,
59 },
60 SubscriptionRemoved {
61 client_id: String,
62 deployment_id: Option<String>,
63 #[serde(flatten)]
64 identity: UsageIdentity,
65 view_id: String,
66 },
67 SnapshotSent {
68 client_id: String,
69 deployment_id: Option<String>,
70 #[serde(flatten)]
71 identity: UsageIdentity,
72 view_id: String,
73 rows: u32,
74 messages: u32,
75 bytes: u64,
76 },
77 UpdateSent {
78 client_id: String,
79 deployment_id: Option<String>,
80 #[serde(flatten)]
81 identity: UsageIdentity,
82 view_id: String,
83 messages: u32,
84 bytes: u64,
85 },
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct WebSocketUsageEnvelope {
90 pub event_id: String,
91 pub occurred_at_ms: u64,
92 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub build_id: Option<String>,
94 pub event: WebSocketUsageEvent,
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct WebSocketUsageBatch {
99 pub events: Vec<WebSocketUsageEnvelope>,
100}
101
102#[async_trait]
103pub trait WebSocketUsageEmitter: Send + Sync {
104 async fn emit(&self, event: WebSocketUsageEvent);
105
106 async fn flush(&self) {}
109
110 async fn shutdown(&self) {
112 self.flush().await;
113 }
114}
115
116#[derive(Clone)]
117pub struct ChannelUsageEmitter {
118 sender: mpsc::UnboundedSender<WebSocketUsageEvent>,
119}
120
121impl ChannelUsageEmitter {
122 pub fn new(sender: mpsc::UnboundedSender<WebSocketUsageEvent>) -> Self {
123 Self { sender }
124 }
125}
126
127#[async_trait]
128impl WebSocketUsageEmitter for ChannelUsageEmitter {
129 async fn emit(&self, event: WebSocketUsageEvent) {
130 let _ = self.sender.send(event);
131 }
132}
133
134pub struct HttpUsageEmitter {
135 sender: mpsc::UnboundedSender<UsageEmitterCommand>,
136}
137
138enum UsageEmitterCommand {
139 Event(Box<WebSocketUsageEvent>),
140 Flush(oneshot::Sender<()>),
141 Shutdown(oneshot::Sender<()>),
142}
143
144#[derive(Debug, thiserror::Error)]
145#[error("WebSocket usage build ID must be a positive integer")]
146pub struct InvalidUsageBuildId;
147
148#[derive(Debug, Clone)]
149struct RetryState {
150 batch: WebSocketUsageBatch,
151 attempts: u32,
152 next_retry_at: Instant,
153}
154
155impl HttpUsageEmitter {
156 pub fn new(endpoint: String, auth_token: Option<String>) -> Self {
157 Self::with_config(endpoint, auth_token, 50, Duration::from_secs(2))
158 }
159
160 pub fn new_attributed(
161 endpoint: String,
162 auth_token: Option<String>,
163 build_id: impl Into<String>,
164 ) -> Result<Self, InvalidUsageBuildId> {
165 let build_id = validate_build_id(build_id.into())?;
166 Ok(Self::with_full_config(
167 endpoint,
168 auth_token,
169 50,
170 Duration::from_secs(2),
171 None,
172 Some(build_id),
173 ))
174 }
175
176 pub fn with_spool_dir(
177 endpoint: String,
178 auth_token: Option<String>,
179 spool_dir: impl Into<PathBuf>,
180 ) -> Self {
181 Self::with_full_config(
182 endpoint,
183 auth_token,
184 50,
185 Duration::from_secs(2),
186 Some(spool_dir.into()),
187 None,
188 )
189 }
190
191 pub fn with_attributed_spool_dir(
192 endpoint: String,
193 auth_token: Option<String>,
194 spool_dir: impl Into<PathBuf>,
195 build_id: impl Into<String>,
196 ) -> Result<Self, InvalidUsageBuildId> {
197 let build_id = validate_build_id(build_id.into())?;
198 Ok(Self::with_full_config(
199 endpoint,
200 auth_token,
201 50,
202 Duration::from_secs(2),
203 Some(spool_dir.into()),
204 Some(build_id),
205 ))
206 }
207
208 pub fn with_config(
209 endpoint: String,
210 auth_token: Option<String>,
211 batch_size: usize,
212 flush_interval: Duration,
213 ) -> Self {
214 Self::with_full_config(endpoint, auth_token, batch_size, flush_interval, None, None)
215 }
216
217 fn with_full_config(
218 endpoint: String,
219 auth_token: Option<String>,
220 batch_size: usize,
221 flush_interval: Duration,
222 spool_dir: Option<PathBuf>,
223 build_id: Option<String>,
224 ) -> Self {
225 let (sender, mut receiver) = mpsc::unbounded_channel::<UsageEmitterCommand>();
226 let client = reqwest::Client::new();
227
228 tokio::spawn(async move {
229 let mut ticker = interval(flush_interval);
230 ticker.set_missed_tick_behavior(MissedTickBehavior::Delay);
231 let mut pending: Vec<WebSocketUsageEnvelope> = Vec::new();
232 let mut retry_state: Option<RetryState> = None;
233
234 if let Some(dir) = spool_dir.as_ref() {
235 if let Err(error) = ensure_spool_dir(dir) {
236 warn!(error = %error, path = %dir.display(), "failed to initialize websocket usage spool directory");
237 }
238 }
239
240 loop {
241 tokio::select! {
242 maybe_command = receiver.recv() => {
243 match maybe_command {
244 Some(UsageEmitterCommand::Event(event)) => {
245 pending.push(WebSocketUsageEnvelope {
246 event_id: Uuid::new_v4().to_string(),
247 occurred_at_ms: current_time_ms(),
248 build_id: build_id.clone(),
249 event: *event,
250 });
251
252 if retry_state.is_none() && pending.len() >= batch_size {
253 flush_pending_batch(
254 &client,
255 &endpoint,
256 auth_token.as_deref(),
257 &mut pending,
258 &mut retry_state,
259 spool_dir.as_deref(),
260 batch_size,
261 ).await;
262 }
263 }
264 Some(UsageEmitterCommand::Flush(done)) => {
265 flush_on_shutdown(
266 &client,
267 &endpoint,
268 auth_token.as_deref(),
269 &mut pending,
270 &mut retry_state,
271 LifecycleFlushOptions {
272 spool_dir: spool_dir.as_deref(),
273 batch_size,
274 preserve_failed_for_retry: true,
275 },
276 ).await;
277 let _ = done.send(());
278 }
279 Some(UsageEmitterCommand::Shutdown(done)) => {
280 flush_on_shutdown(
281 &client,
282 &endpoint,
283 auth_token.as_deref(),
284 &mut pending,
285 &mut retry_state,
286 LifecycleFlushOptions {
287 spool_dir: spool_dir.as_deref(),
288 batch_size,
289 preserve_failed_for_retry: false,
290 },
291 ).await;
292 let _ = done.send(());
293 break;
294 }
295 None => {
296 flush_on_shutdown(
297 &client,
298 &endpoint,
299 auth_token.as_deref(),
300 &mut pending,
301 &mut retry_state,
302 LifecycleFlushOptions {
303 spool_dir: spool_dir.as_deref(),
304 batch_size,
305 preserve_failed_for_retry: false,
306 },
307 ).await;
308 break;
309 }
310 }
311 }
312 _ = ticker.tick() => {
313 if let Some(dir) = spool_dir.as_deref() {
314 if retry_state.is_none() {
315 if let Err(error) = flush_one_spooled_batch(
316 &client,
317 &endpoint,
318 auth_token.as_deref(),
319 dir,
320 batch_size,
321 ).await {
322 warn!(error = %error, path = %dir.display(), "failed to process spooled websocket usage batch");
323 }
324 }
325 }
326
327 if let Some(state) = retry_state.take() {
328 if Instant::now() >= state.next_retry_at {
329 match flush_existing_batch(
330 &client,
331 &endpoint,
332 auth_token.as_deref(),
333 state,
334 ).await {
335 Ok(()) => {
336 if !pending.is_empty() {
337 flush_pending_batch(
338 &client,
339 &endpoint,
340 auth_token.as_deref(),
341 &mut pending,
342 &mut retry_state,
343 spool_dir.as_deref(),
344 batch_size,
345 ).await;
346 }
347 }
348 Err(state) => {
349 if state.attempts >= MAX_IN_MEMORY_RETRIES {
350 if let Some(dir) = spool_dir.as_deref() {
351 if let Err(error) = spool_retry_state(dir, &state) {
352 warn!(error = %error, count = state.batch.events.len(), "failed to spool websocket usage batch after retries");
353 retry_state = Some(state);
354 }
355 } else {
356 retry_state = Some(state);
357 }
358 } else {
359 retry_state = Some(state)
360 }
361 }
362 }
363 } else {
364 retry_state = Some(state);
365 }
366 } else if !pending.is_empty() {
367 flush_pending_batch(
368 &client,
369 &endpoint,
370 auth_token.as_deref(),
371 &mut pending,
372 &mut retry_state,
373 spool_dir.as_deref(),
374 batch_size,
375 ).await;
376 }
377 }
378 }
379 }
380 });
381
382 Self { sender }
383 }
384}
385
386fn validate_build_id(value: String) -> Result<String, InvalidUsageBuildId> {
387 if value.trim() != value || value.parse::<i32>().ok().is_none_or(|value| value <= 0) {
388 return Err(InvalidUsageBuildId);
389 }
390 Ok(value)
391}
392
393#[async_trait]
394impl WebSocketUsageEmitter for HttpUsageEmitter {
395 async fn emit(&self, event: WebSocketUsageEvent) {
396 if let Err(error) = self
397 .sender
398 .send(UsageEmitterCommand::Event(Box::new(event)))
399 {
400 warn!(error = %error, "failed to queue websocket usage event");
401 }
402 }
403
404 async fn flush(&self) {
405 let (done, completed) = oneshot::channel();
406 if self.sender.send(UsageEmitterCommand::Flush(done)).is_ok() {
407 let _ = completed.await;
408 }
409 }
410
411 async fn shutdown(&self) {
412 let (done, completed) = oneshot::channel();
413 if self
414 .sender
415 .send(UsageEmitterCommand::Shutdown(done))
416 .is_ok()
417 {
418 let _ = completed.await;
419 }
420 }
421}
422
423fn current_time_ms() -> u64 {
424 std::time::SystemTime::now()
425 .duration_since(std::time::UNIX_EPOCH)
426 .unwrap_or_default()
427 .as_millis() as u64
428}
429
430async fn flush_batch(
431 client: &reqwest::Client,
432 endpoint: &str,
433 auth_token: Option<&str>,
434 batch: &WebSocketUsageBatch,
435) -> bool {
436 if batch.events.is_empty() {
437 return true;
438 }
439
440 let mut request = client.post(endpoint).json(batch);
441 if let Some(token) = auth_token {
442 request = request.header("Authorization", format!("Bearer {}", token));
443 }
444
445 match request.send().await {
446 Ok(response) if response.status().is_success() => {
447 debug!(count = batch.events.len(), "flushed websocket usage batch");
448 true
449 }
450 Ok(response) => {
451 error!(status = %response.status(), count = batch.events.len(), "failed to ingest websocket usage batch");
452 false
453 }
454 Err(error) => {
455 error!(error = %error, count = batch.events.len(), "failed to post websocket usage batch");
456 false
457 }
458 }
459}
460
461async fn flush_pending_batch(
462 client: &reqwest::Client,
463 endpoint: &str,
464 auth_token: Option<&str>,
465 pending: &mut Vec<WebSocketUsageEnvelope>,
466 retry_state: &mut Option<RetryState>,
467 spool_dir: Option<&Path>,
468 batch_size: usize,
469) {
470 let batch = take_pending_batch(pending, batch_size);
471
472 if !flush_batch(client, endpoint, auth_token, &batch).await {
473 let state = RetryState {
474 batch,
475 attempts: 1,
476 next_retry_at: Instant::now() + retry_delay(1),
477 };
478
479 if let Some(dir) = spool_dir.filter(|_| MAX_IN_MEMORY_RETRIES <= 1) {
480 if let Err(error) = spool_retry_state(dir, &state) {
481 warn!(error = %error, count = state.batch.events.len(), "failed to spool websocket usage batch after first failure");
482 *retry_state = Some(state);
483 }
484 } else {
485 *retry_state = Some(state);
486 }
487 }
488}
489
490struct LifecycleFlushOptions<'a> {
499 spool_dir: Option<&'a Path>,
500 batch_size: usize,
501 preserve_failed_for_retry: bool,
502}
503
504async fn flush_on_shutdown(
505 client: &reqwest::Client,
506 endpoint: &str,
507 auth_token: Option<&str>,
508 pending: &mut Vec<WebSocketUsageEnvelope>,
509 retry_state: &mut Option<RetryState>,
510 options: LifecycleFlushOptions<'_>,
511) {
512 if let Some(dir) = options.spool_dir {
513 if let Some(state) = retry_state.take() {
517 if let Err(error) = spool_retry_state(dir, &state) {
518 warn!(error = %error, count = state.batch.events.len(), "failed to spool websocket usage retry during shutdown");
519 if !flush_batch(client, endpoint, auth_token, &state.batch).await {
520 if options.preserve_failed_for_retry {
521 warn!(
522 count = state.batch.events.len(),
523 attempts = state.attempts,
524 "websocket usage flush failed; retaining batch for retry"
525 );
526 *retry_state = Some(state);
527 return;
528 }
529 error!(
530 count = state.batch.events.len(),
531 "failed to deliver websocket usage retry after shutdown spool failure"
532 );
533 }
534 }
535 }
536 while !pending.is_empty() {
537 let batch = take_pending_batch(pending, options.batch_size);
538 if let Err(error) = spool_batch(dir, &batch) {
539 warn!(error = %error, count = batch.events.len(), "failed to spool pending websocket usage batch during shutdown");
540 if !flush_batch(client, endpoint, auth_token, &batch).await {
541 if options.preserve_failed_for_retry {
542 let state = RetryState {
543 batch,
544 attempts: 1,
545 next_retry_at: Instant::now() + retry_delay(1),
546 };
547 warn!(
548 count = state.batch.events.len(),
549 "websocket usage flush failed; retaining batch for retry"
550 );
551 *retry_state = Some(state);
552 return;
553 }
554 error!(
555 count = batch.events.len(),
556 "failed to deliver pending websocket usage after shutdown spool failure"
557 );
558 }
559 }
560 }
561 return;
562 }
563
564 loop {
565 if let Some(state) = retry_state.take() {
566 if let Err(retry_state_failed) =
567 flush_existing_batch(client, endpoint, auth_token, state).await
568 {
569 if options.preserve_failed_for_retry {
570 warn!(
571 count = retry_state_failed.batch.events.len(),
572 attempts = retry_state_failed.attempts,
573 "websocket usage flush failed; retaining batch for retry"
574 );
575 *retry_state = Some(retry_state_failed);
576 return;
577 }
578 warn!(
579 count = retry_state_failed.batch.events.len(),
580 attempts = retry_state_failed.attempts,
581 "dropping websocket usage batch during shutdown after failed retry"
582 );
583 break;
584 }
585 continue;
586 }
587
588 if pending.is_empty() {
589 return;
590 }
591
592 flush_pending_batch(
593 client,
594 endpoint,
595 auth_token,
596 pending,
597 retry_state,
598 None,
599 options.batch_size,
600 )
601 .await;
602 }
603
604 if !pending.is_empty() {
605 warn!(
606 count = pending.len(),
607 "dropping pending websocket usage events during shutdown without spool directory"
608 );
609 }
610}
611
612fn take_pending_batch(
620 pending: &mut Vec<WebSocketUsageEnvelope>,
621 batch_size: usize,
622) -> WebSocketUsageBatch {
623 let batch_size = batch_size.max(1);
624 let remainder = if pending.len() > batch_size {
625 pending.split_off(batch_size)
626 } else {
627 Vec::new()
628 };
629 let events = std::mem::replace(pending, remainder);
630 WebSocketUsageBatch { events }
631}
632
633async fn flush_existing_batch(
634 client: &reqwest::Client,
635 endpoint: &str,
636 auth_token: Option<&str>,
637 mut state: RetryState,
638) -> Result<(), RetryState> {
639 if flush_batch(client, endpoint, auth_token, &state.batch).await {
640 Ok(())
641 } else {
642 state.attempts += 1;
643 state.next_retry_at = Instant::now() + retry_delay(state.attempts);
644 Err(state)
645 }
646}
647
648fn retry_delay(attempt: u32) -> Duration {
649 let capped_attempt = attempt.min(6);
650 Duration::from_secs(1_u64 << capped_attempt)
651}
652
653fn ensure_spool_dir(path: &Path) -> std::io::Result<()> {
654 std::fs::create_dir_all(path)
655}
656
657fn spool_retry_state(path: &Path, state: &RetryState) -> std::io::Result<PathBuf> {
658 spool_batch(path, &state.batch)
659}
660
661fn spool_batch(path: &Path, batch: &WebSocketUsageBatch) -> std::io::Result<PathBuf> {
662 ensure_spool_dir(path)?;
663
664 let file_name = format!(
665 "ws-usage-{}-{}.json",
666 current_time_ms(),
667 Uuid::new_v4().simple()
668 );
669 let final_path = path.join(file_name);
670 let temp_path = final_path.with_extension("tmp");
671 let data = serde_json::to_vec(batch).map_err(std::io::Error::other)?;
672 std::fs::write(&temp_path, data)?;
673 std::fs::rename(&temp_path, &final_path)?;
674 Ok(final_path)
675}
676
677fn load_batch_from_file(path: &Path) -> std::io::Result<WebSocketUsageBatch> {
678 let data = std::fs::read(path)?;
679 serde_json::from_slice(&data).map_err(std::io::Error::other)
680}
681
682fn oldest_spooled_batch(path: &Path) -> std::io::Result<Option<PathBuf>> {
683 if !path.exists() {
684 return Ok(None);
685 }
686
687 let mut entries: Vec<PathBuf> = std::fs::read_dir(path)?
688 .filter_map(|entry| entry.ok().map(|entry| entry.path()))
689 .filter(|entry| entry.extension().and_then(|ext| ext.to_str()) == Some("json"))
690 .collect();
691 entries.sort();
692 Ok(entries.into_iter().next())
693}
694
695async fn flush_one_spooled_batch(
696 client: &reqwest::Client,
697 endpoint: &str,
698 auth_token: Option<&str>,
699 spool_dir: &Path,
700 batch_size: usize,
701) -> std::io::Result<()> {
702 let Some(path) = oldest_spooled_batch(spool_dir)? else {
703 return Ok(());
704 };
705
706 let mut batch = load_batch_from_file(&path)?;
707 let batch_size = batch_size.max(1);
708 let remainder = if batch.events.len() > batch_size {
709 Some(WebSocketUsageBatch {
710 events: batch.events.split_off(batch_size),
711 })
712 } else {
713 None
714 };
715
716 if flush_batch(client, endpoint, auth_token, &batch).await {
717 if let Some(remainder) = remainder {
718 replace_spooled_batch(&path, &remainder)?;
723 } else {
724 std::fs::remove_file(path)?;
725 }
726 }
727
728 Ok(())
729}
730
731fn replace_spooled_batch(path: &Path, batch: &WebSocketUsageBatch) -> std::io::Result<()> {
732 let temp_path = path.with_extension("tmp");
733 let data = serde_json::to_vec(batch).map_err(std::io::Error::other)?;
734 std::fs::write(&temp_path, data)?;
735 std::fs::rename(temp_path, path)
736}
737
738#[cfg(test)]
739mod tests {
740 use super::*;
741 use std::fs;
742 use tokio::io::{AsyncReadExt, AsyncWriteExt};
743 use tokio::net::TcpListener;
744
745 fn usage_envelope(index: u64) -> WebSocketUsageEnvelope {
746 WebSocketUsageEnvelope {
747 event_id: format!("evt-{index}"),
748 occurred_at_ms: index,
749 build_id: None,
750 event: WebSocketUsageEvent::ConnectionEstablished {
751 client_id: format!("client-{index}"),
752 remote_addr: "127.0.0.1:1234".to_string(),
753 deployment_id: Some("1".to_string()),
754 identity: UsageIdentity::default(),
755 },
756 }
757 }
758
759 fn usage_event(index: u64) -> WebSocketUsageEvent {
760 WebSocketUsageEvent::ConnectionEstablished {
761 client_id: format!("client-{index}"),
762 remote_addr: "127.0.0.1:1234".to_string(),
763 deployment_id: Some("1".to_string()),
764 identity: UsageIdentity::default(),
765 }
766 }
767
768 async fn successful_batch_server(
769 request_count: usize,
770 ) -> (String, tokio::task::JoinHandle<Vec<Vec<String>>>) {
771 let listener = TcpListener::bind("127.0.0.1:0")
772 .await
773 .expect("test server should bind");
774 let address = listener.local_addr().expect("test server address");
775 let server = tokio::spawn(async move {
776 let mut received = Vec::with_capacity(request_count);
777 for _ in 0..request_count {
778 let (mut stream, _) = listener.accept().await.expect("request should connect");
779 let mut bytes = Vec::new();
780 let body_start = loop {
781 let mut chunk = [0_u8; 4096];
782 let count = stream.read(&mut chunk).await.expect("request should read");
783 assert!(count > 0, "request ended before its headers");
784 bytes.extend_from_slice(&chunk[..count]);
785 if let Some(index) = bytes.windows(4).position(|window| window == b"\r\n\r\n") {
786 break index + 4;
787 }
788 };
789 let headers = std::str::from_utf8(&bytes[..body_start])
790 .expect("request headers should be UTF-8");
791 let content_length = headers
792 .lines()
793 .find_map(|line| {
794 let (name, value) = line.split_once(':')?;
795 name.eq_ignore_ascii_case("content-length")
796 .then(|| value.trim().parse::<usize>().expect("content length"))
797 })
798 .expect("request should have content length");
799 while bytes.len() - body_start < content_length {
800 let mut chunk = [0_u8; 4096];
801 let count = stream.read(&mut chunk).await.expect("body should read");
802 assert!(count > 0, "request ended before its body");
803 bytes.extend_from_slice(&chunk[..count]);
804 }
805 let batch: WebSocketUsageBatch =
806 serde_json::from_slice(&bytes[body_start..body_start + content_length])
807 .expect("request should contain a usage batch");
808 received.push(
809 batch
810 .events
811 .into_iter()
812 .map(|event| event.event_id)
813 .collect(),
814 );
815 stream
816 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
817 .await
818 .expect("response should write");
819 }
820 received
821 });
822 (format!("http://{address}"), server)
823 }
824
825 fn temp_spool_dir() -> PathBuf {
826 let dir = std::env::temp_dir().join(format!("arete-usage-test-{}", Uuid::new_v4()));
827 fs::create_dir_all(&dir).expect("temp dir should be created");
828 dir
829 }
830
831 #[tokio::test]
832 async fn channel_usage_emitter_forwards_events() {
833 let (tx, mut rx) = mpsc::unbounded_channel();
834 let emitter = ChannelUsageEmitter::new(tx);
835
836 emitter
837 .emit(WebSocketUsageEvent::SubscriptionCreated {
838 client_id: "client-1".to_string(),
839 deployment_id: Some("deployment-1".to_string()),
840 identity: UsageIdentity {
841 metering_key: Some("meter-1".to_string()),
842 subject: Some("subject-1".to_string()),
843 ..Default::default()
844 },
845 view_id: "OreRound/latest".to_string(),
846 })
847 .await;
848
849 let event = rx.recv().await.expect("event should be forwarded");
850 match event {
851 WebSocketUsageEvent::SubscriptionCreated { view_id, .. } => {
852 assert_eq!(view_id, "OreRound/latest");
853 }
854 other => panic!("unexpected event: {other:?}"),
855 }
856 }
857
858 #[tokio::test]
859 async fn flush_is_an_ordered_barrier_and_keeps_the_emitter_open() {
860 let (endpoint, server) = successful_batch_server(2).await;
861 let emitter = HttpUsageEmitter::with_config(endpoint, None, 50, Duration::from_secs(3_600));
862
863 emitter.emit(usage_event(1)).await;
864 emitter.flush().await;
865 emitter.emit(usage_event(2)).await;
866 emitter.shutdown().await;
867
868 let received = server.await.expect("test server should finish");
869 assert_eq!(received.len(), 2);
870 assert_eq!(received[0].len(), 1);
871 assert_eq!(received[1].len(), 1);
872 }
873
874 #[test]
875 fn retry_delay_grows_and_caps() {
876 assert_eq!(retry_delay(1), Duration::from_secs(2));
877 assert_eq!(retry_delay(2), Duration::from_secs(4));
878 assert_eq!(retry_delay(6), Duration::from_secs(64));
879 assert_eq!(retry_delay(9), Duration::from_secs(64));
880 }
881
882 #[test]
883 fn spooled_batches_round_trip() {
884 let dir = temp_spool_dir();
885 let batch = WebSocketUsageBatch {
886 events: vec![WebSocketUsageEnvelope {
887 event_id: "evt_1".to_string(),
888 occurred_at_ms: 123,
889 build_id: Some("7".to_string()),
890 event: WebSocketUsageEvent::UpdateSent {
891 client_id: "client-1".to_string(),
892 deployment_id: Some("1".to_string()),
893 identity: UsageIdentity {
894 metering_key: Some("api_key:1".to_string()),
895 subject: Some("user:1".to_string()),
896 ..Default::default()
897 },
898 view_id: "OreRound/latest".to_string(),
899 messages: 1,
900 bytes: 42,
901 },
902 }],
903 };
904
905 let path = spool_batch(&dir, &batch).expect("batch should spool");
906 let loaded = load_batch_from_file(&path).expect("batch should load");
907 assert_eq!(loaded.events.len(), 1);
908 assert_eq!(loaded.events[0].build_id.as_deref(), Some("7"));
909
910 fs::remove_dir_all(dir).expect("temp dir should be removed");
911 }
912
913 #[test]
914 fn pending_backlog_is_drained_in_bounded_batches() {
915 let mut pending = (0..1250).map(usage_envelope).collect::<Vec<_>>();
916
917 let first = take_pending_batch(&mut pending, 50);
918 assert_eq!(first.events.len(), 50);
919 assert_eq!(pending.len(), 1200);
920 assert_eq!(first.events[0].event_id, "evt-0");
921 assert_eq!(pending[0].event_id, "evt-50");
922 }
923
924 #[tokio::test]
925 async fn oversized_legacy_spool_is_delivered_in_successive_bounded_chunks() {
926 let dir = temp_spool_dir();
927 let path = dir.join("ws-usage-100-a.json");
928 let batch = WebSocketUsageBatch {
929 events: (0..5).map(usage_envelope).collect(),
930 };
931 fs::write(&path, serde_json::to_vec(&batch).unwrap()).expect("legacy spool should write");
932 let (endpoint, server) = successful_batch_server(3).await;
933 let client = reqwest::Client::new();
934
935 flush_one_spooled_batch(&client, &endpoint, None, &dir, 2)
936 .await
937 .expect("first chunk should flush");
938 let loaded = load_batch_from_file(&path).expect("remainder should be readable");
939 assert_eq!(loaded.events.len(), 3);
940 assert_eq!(loaded.events[0].event_id, "evt-2");
941
942 flush_one_spooled_batch(&client, &endpoint, None, &dir, 2)
943 .await
944 .expect("second chunk should flush");
945 let loaded = load_batch_from_file(&path).expect("final remainder should be readable");
946 assert_eq!(loaded.events.len(), 1);
947 assert_eq!(loaded.events[0].event_id, "evt-4");
948
949 flush_one_spooled_batch(&client, &endpoint, None, &dir, 2)
950 .await
951 .expect("final chunk should flush");
952 assert!(!path.exists());
953
954 let received = server.await.expect("test server should finish");
955 assert_eq!(
956 received,
957 vec![
958 vec!["evt-0".to_string(), "evt-1".to_string()],
959 vec!["evt-2".to_string(), "evt-3".to_string()],
960 vec!["evt-4".to_string()],
961 ]
962 );
963
964 fs::remove_dir_all(dir).expect("temp dir should be removed");
965 }
966
967 #[tokio::test]
968 async fn shutdown_drains_every_chunk_after_a_retry_recovers() {
969 let (endpoint, server) = successful_batch_server(4).await;
970 let client = reqwest::Client::new();
971 let mut pending = (1..=5).map(usage_envelope).collect::<Vec<_>>();
972 let mut retry_state = Some(RetryState {
973 batch: WebSocketUsageBatch {
974 events: vec![usage_envelope(0)],
975 },
976 attempts: 1,
977 next_retry_at: Instant::now(),
978 });
979
980 flush_on_shutdown(
981 &client,
982 &endpoint,
983 None,
984 &mut pending,
985 &mut retry_state,
986 LifecycleFlushOptions {
987 spool_dir: None,
988 batch_size: 2,
989 preserve_failed_for_retry: false,
990 },
991 )
992 .await;
993
994 assert!(pending.is_empty());
995 assert!(retry_state.is_none());
996 let received = server.await.expect("test server should finish");
997 assert_eq!(
998 received,
999 vec![
1000 vec!["evt-0".to_string()],
1001 vec!["evt-1".to_string(), "evt-2".to_string()],
1002 vec!["evt-3".to_string(), "evt-4".to_string()],
1003 vec!["evt-5".to_string()],
1004 ]
1005 );
1006 }
1007
1008 #[tokio::test]
1009 async fn flush_barrier_retains_failed_batch_for_later_retry() {
1010 let client = reqwest::Client::new();
1011 let mut pending = vec![usage_envelope(0)];
1012 let mut retry_state = None;
1013
1014 flush_on_shutdown(
1015 &client,
1016 "http://127.0.0.1:1/unreachable",
1017 None,
1018 &mut pending,
1019 &mut retry_state,
1020 LifecycleFlushOptions {
1021 spool_dir: None,
1022 batch_size: 2,
1023 preserve_failed_for_retry: true,
1024 },
1025 )
1026 .await;
1027
1028 assert!(pending.is_empty());
1029 assert_eq!(
1030 retry_state
1031 .as_ref()
1032 .expect("failed flush should retain its retry")
1033 .batch
1034 .events[0]
1035 .event_id,
1036 "evt-0"
1037 );
1038
1039 let (endpoint, server) = successful_batch_server(1).await;
1040 flush_on_shutdown(
1041 &client,
1042 &endpoint,
1043 None,
1044 &mut pending,
1045 &mut retry_state,
1046 LifecycleFlushOptions {
1047 spool_dir: None,
1048 batch_size: 2,
1049 preserve_failed_for_retry: true,
1050 },
1051 )
1052 .await;
1053
1054 assert!(retry_state.is_none());
1055 assert_eq!(
1056 server.await.expect("test server should finish"),
1057 vec![vec!["evt-0".to_string()]]
1058 );
1059 }
1060
1061 #[tokio::test]
1062 async fn flush_barrier_retains_failed_batch_when_spool_is_unwritable() {
1063 let dir = temp_spool_dir();
1064 let invalid_spool_dir = dir.join("not-a-directory");
1065 fs::write(&invalid_spool_dir, b"occupied").expect("spool blocker should write");
1066 let client = reqwest::Client::new();
1067 let mut pending = vec![usage_envelope(0)];
1068 let mut retry_state = None;
1069
1070 flush_on_shutdown(
1071 &client,
1072 "http://127.0.0.1:1/unreachable",
1073 None,
1074 &mut pending,
1075 &mut retry_state,
1076 LifecycleFlushOptions {
1077 spool_dir: Some(&invalid_spool_dir),
1078 batch_size: 2,
1079 preserve_failed_for_retry: true,
1080 },
1081 )
1082 .await;
1083
1084 assert!(pending.is_empty());
1085 assert_eq!(
1086 retry_state
1087 .as_ref()
1088 .expect("failed flush should retain its retry")
1089 .batch
1090 .events[0]
1091 .event_id,
1092 "evt-0"
1093 );
1094
1095 fs::remove_dir_all(dir).expect("temp dir should be removed");
1096 }
1097
1098 #[tokio::test]
1099 async fn shutdown_persists_every_chunk_before_network_drain_when_spooling() {
1100 let dir = temp_spool_dir();
1101 let client = reqwest::Client::new();
1102 let mut pending = (1..=5).map(usage_envelope).collect::<Vec<_>>();
1103 let mut retry_state = Some(RetryState {
1104 batch: WebSocketUsageBatch {
1105 events: vec![usage_envelope(0)],
1106 },
1107 attempts: 1,
1108 next_retry_at: Instant::now(),
1109 });
1110
1111 flush_on_shutdown(
1112 &client,
1113 "http://127.0.0.1:1/unreachable",
1114 None,
1115 &mut pending,
1116 &mut retry_state,
1117 LifecycleFlushOptions {
1118 spool_dir: Some(&dir),
1119 batch_size: 2,
1120 preserve_failed_for_retry: false,
1121 },
1122 )
1123 .await;
1124
1125 assert!(pending.is_empty());
1126 assert!(retry_state.is_none());
1127 let mut event_ids = Vec::new();
1128 let mut batch_sizes = Vec::new();
1129 for entry in fs::read_dir(&dir).expect("spool directory should be readable") {
1130 let batch = load_batch_from_file(&entry.unwrap().path()).expect("spool should load");
1131 batch_sizes.push(batch.events.len());
1132 event_ids.extend(batch.events.into_iter().map(|event| event.event_id));
1133 }
1134 batch_sizes.sort_unstable();
1135 event_ids.sort();
1136 assert_eq!(batch_sizes, vec![1, 1, 2, 2]);
1137 assert_eq!(
1138 event_ids,
1139 (0..=5)
1140 .map(|index| format!("evt-{index}"))
1141 .collect::<Vec<_>>()
1142 );
1143
1144 fs::remove_dir_all(dir).expect("temp dir should be removed");
1145 }
1146
1147 #[tokio::test]
1148 async fn shutdown_uses_http_when_spool_storage_is_unwritable() {
1149 let dir = temp_spool_dir();
1150 let invalid_spool_dir = dir.join("not-a-directory");
1151 fs::write(&invalid_spool_dir, b"occupied").expect("spool blocker should write");
1152 let (endpoint, server) = successful_batch_server(4).await;
1153 let client = reqwest::Client::new();
1154 let mut pending = (1..=5).map(usage_envelope).collect::<Vec<_>>();
1155 let mut retry_state = Some(RetryState {
1156 batch: WebSocketUsageBatch {
1157 events: vec![usage_envelope(0)],
1158 },
1159 attempts: 1,
1160 next_retry_at: Instant::now(),
1161 });
1162
1163 flush_on_shutdown(
1164 &client,
1165 &endpoint,
1166 None,
1167 &mut pending,
1168 &mut retry_state,
1169 LifecycleFlushOptions {
1170 spool_dir: Some(&invalid_spool_dir),
1171 batch_size: 2,
1172 preserve_failed_for_retry: false,
1173 },
1174 )
1175 .await;
1176
1177 let received = server.await.expect("test server should finish");
1178 assert_eq!(
1179 received,
1180 vec![
1181 vec!["evt-0".to_string()],
1182 vec!["evt-1".to_string(), "evt-2".to_string()],
1183 vec!["evt-3".to_string(), "evt-4".to_string()],
1184 vec!["evt-5".to_string()],
1185 ]
1186 );
1187
1188 fs::remove_dir_all(dir).expect("temp dir should be removed");
1189 }
1190
1191 #[test]
1192 fn usage_identity_is_flattened_and_old_spool_json_remains_readable() {
1193 let legacy: WebSocketUsageBatch = serde_json::from_value(serde_json::json!({
1194 "events": [{
1195 "event_id": "evt-old",
1196 "occurred_at_ms": 123,
1197 "event": {
1198 "type": "update_sent",
1199 "client_id": "client-1",
1200 "deployment_id": "1",
1201 "metering_key": "api_key:1",
1202 "subject": "user:1",
1203 "view_id": "Round/latest",
1204 "messages": 1,
1205 "bytes": 42
1206 }
1207 }]
1208 }))
1209 .expect("old-format spool remains readable");
1210 let value = serde_json::to_value(&legacy).unwrap();
1211 assert_eq!(value["events"][0]["event"]["metering_key"], "api_key:1");
1212 assert!(value["events"][0]["event"].get("actor_key").is_none());
1213
1214 let event = WebSocketUsageEvent::UpdateSent {
1215 client_id: "client-2".to_string(),
1216 deployment_id: Some("2".to_string()),
1217 identity: UsageIdentity {
1218 metering_key: Some("account:42".to_string()),
1219 subject: Some("user:7".to_string()),
1220 key_class: Some("secret".to_string()),
1221 actor_key: Some("user:7".to_string()),
1222 account_key: Some("account:42".to_string()),
1223 consumer_key: Some("consumer:key-9".to_string()),
1224 plan_code: Some("agent_trial".to_string()),
1225 policy_version: Some(3),
1226 },
1227 view_id: "Round/latest".to_string(),
1228 messages: 1,
1229 bytes: 42,
1230 };
1231 let value = serde_json::to_value(event).unwrap();
1232 assert_eq!(value["account_key"], "account:42");
1233 assert_eq!(value["plan_code"], "agent_trial");
1234 assert_eq!(value["policy_version"], 3);
1235 }
1236
1237 #[test]
1238 fn legacy_and_attributed_envelopes_are_compatible() {
1239 let legacy = serde_json::json!({
1240 "event_id": "legacy",
1241 "occurred_at_ms": 123,
1242 "event": {
1243 "type": "update_sent",
1244 "client_id": "client",
1245 "deployment_id": "1",
1246 "metering_key": null,
1247 "subject": null,
1248 "view_id": "view",
1249 "messages": 1,
1250 "bytes": 2
1251 }
1252 });
1253 let decoded: WebSocketUsageEnvelope = serde_json::from_value(legacy).unwrap();
1254 assert_eq!(decoded.build_id, None);
1255
1256 let mut attributed = serde_json::to_value(decoded).unwrap();
1257 assert!(attributed.get("build_id").is_none());
1258 attributed["build_id"] = serde_json::json!("42");
1259 let decoded: WebSocketUsageEnvelope = serde_json::from_value(attributed).unwrap();
1260 assert_eq!(decoded.build_id.as_deref(), Some("42"));
1261 }
1262
1263 #[test]
1264 fn attributed_constructor_validates_build_identity() {
1265 assert!(
1266 HttpUsageEmitter::new_attributed("http://localhost".to_string(), None, "0").is_err()
1267 );
1268 assert!(
1269 HttpUsageEmitter::new_attributed("http://localhost".to_string(), None, " 7").is_err()
1270 );
1271 }
1272
1273 #[test]
1274 fn oldest_spooled_batch_prefers_lexicographically_oldest_file() {
1275 let dir = temp_spool_dir();
1276 fs::write(dir.join("ws-usage-100-a.json"), b"{\"events\":[]}").expect("first batch");
1277 fs::write(dir.join("ws-usage-200-b.json"), b"{\"events\":[]}").expect("second batch");
1278
1279 let oldest = oldest_spooled_batch(&dir)
1280 .expect("listing should succeed")
1281 .expect("batch should exist");
1282 assert!(oldest.ends_with("ws-usage-100-a.json"));
1283
1284 fs::remove_dir_all(dir).expect("temp dir should be removed");
1285 }
1286}