Skip to main content

arete_server/websocket/
usage.rs

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
14/// `UpdateSent` usage is merged per connection, view and identity within
15/// fixed wall-clock windows of this length. The length divides an hour, so a
16/// merged update never spans an hour boundary.
17const UPDATE_COALESCE_WINDOW_MS: u64 = 10_000;
18
19/// Merged updates kept in memory before they are queued early.
20const MAX_COALESCED_UPDATES: usize = 10_000;
21
22/// Billing and policy identity copied from one verified session token. The
23/// legacy fields remain present during the compatibility window; V2 fields
24/// are emitted only as a complete signed tuple.
25#[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    /// Finish or durably preserve every event accepted before this call while
117    /// leaving the emitter available for later events.
118    async fn flush(&self) {}
119
120    /// Finish or durably preserve every event accepted before this call.
121    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
144/// Posts usage events to an HTTP endpoint in bounded batches, retrying and
145/// optionally spooling to disk when the endpoint is unavailable.
146///
147/// `UpdateSent` events are not posted one per message. Updates for the same
148/// connection, deployment, identity and view are summed over a 10-second
149/// window and posted as one event carrying the total `messages` and `bytes`,
150/// stamped with the time of the last update it covers. Consumers should sum
151/// those fields rather than count events. Every other event type is posted
152/// as emitted.
153pub 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/// Identifies the updates that are summed into one `UpdateSent` event.
441/// `window` comes first so every closed window is a prefix of the map.
442#[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    /// The smallest key in `window`.
453    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
471/// `UpdateSent` usage waiting for its window to close.
472///
473/// A busy connection sends many updates a second. One event per update made
474/// usage volume, and everything downstream of it, grow with message rate.
475struct 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    /// Add an update to its window's totals. When the sum would overflow,
493    /// queue the window's earlier totals and start again from this update.
494    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    /// Queue every window that closed before `now_ms`.
530    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    /// Queue every window, open or not, for a flush barrier or shutdown.
539    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
681/// Exhaust every bounded in-memory batch for a flush barrier or shutdown.
682///
683/// A recovered retry can leave several batches queued behind it. Shutdown must
684/// continue after that retry succeeds instead of sending only one more chunk
685/// and dropping the rest when no spool directory is configured. On the first
686/// failed shutdown retry, preserve the failed and not-yet-attempted batches on
687/// disk when possible. A flush barrier that cannot persist its events keeps
688/// the failed batch in memory because the worker remains alive to retry it.
689struct 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        // The runtime explicitly awaits this shutdown path, but process-level
705        // termination still has a bounded grace period. Persist first so no
706        // accepted event depends on completing a sequence of HTTP requests.
707        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
803/// Remove at most one configured HTTP batch from the pending queue.
804///
805/// A failed request can leave the emitter retrying while new events continue
806/// to arrive. Draining the entire pending queue after recovery would turn that
807/// backlog into one unbounded request and can permanently wedge delivery on a
808/// `413 Payload Too Large`. Keep the wire-size invariant at every flush, not
809/// only on the fast path that first reaches `batch_size`.
810fn 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            // Replacing the file after the successful request can repeat the
910            // first chunk after a crash, but every envelope keeps its event ID
911            // and ingestion is idempotent. Rewriting before the request could
912            // lose billable usage instead.
913            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        // Ten updates sent back to back can straddle at most one window edge.
1353        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}