Skip to main content

arete_server/websocket/
usage.rs

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/// Billing and policy identity copied from one verified session token. The
13/// legacy fields remain present during the compatibility window; V2 fields
14/// are emitted only as a complete signed tuple.
15#[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    /// Finish or durably preserve every event accepted before this call while
107    /// leaving the emitter available for later events.
108    async fn flush(&self) {}
109
110    /// Finish or durably preserve every event accepted before this call.
111    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
490/// Exhaust every bounded in-memory batch for a flush barrier or shutdown.
491///
492/// A recovered retry can leave several batches queued behind it. Shutdown must
493/// continue after that retry succeeds instead of sending only one more chunk
494/// and dropping the rest when no spool directory is configured. On the first
495/// failed shutdown retry, preserve the failed and not-yet-attempted batches on
496/// disk when possible. A flush barrier that cannot persist its events keeps
497/// the failed batch in memory because the worker remains alive to retry it.
498struct 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        // The runtime explicitly awaits this shutdown path, but process-level
514        // termination still has a bounded grace period. Persist first so no
515        // accepted event depends on completing a sequence of HTTP requests.
516        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
612/// Remove at most one configured HTTP batch from the pending queue.
613///
614/// A failed request can leave the emitter retrying while new events continue
615/// to arrive. Draining the entire pending queue after recovery would turn that
616/// backlog into one unbounded request and can permanently wedge delivery on a
617/// `413 Payload Too Large`. Keep the wire-size invariant at every flush, not
618/// only on the fast path that first reaches `batch_size`.
619fn 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            // Replacing the file after the successful request can repeat the
719            // first chunk after a crash, but every envelope keeps its event ID
720            // and ingestion is idempotent. Rewriting before the request could
721            // lose billable usage instead.
722            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}