Skip to main content

eyes_subscriber/
batching_http_transport.rs

1use async_trait::async_trait;
2use reqwest::Client;
3use std::time::Duration;
4use tokio::sync::Mutex;
5use tokio::time::Instant;
6use url::Url;
7use uuid::Uuid;
8
9use crate::{
10    transport::{Transport, TransportError},
11    EventData,
12};
13
14/// Configuration for batching behavior
15#[derive(Debug, Clone)]
16pub struct BatchConfig {
17    /// Maximum number of events in a batch before flushing
18    pub max_batch_size: usize,
19    /// Maximum time to wait before flushing a non-empty batch
20    pub max_batch_age: Duration,
21}
22
23impl Default for BatchConfig {
24    fn default() -> Self {
25        Self {
26            max_batch_size: 100,
27            max_batch_age: Duration::from_secs(5),
28        }
29    }
30}
31
32impl BatchConfig {
33    pub fn new(max_batch_size: usize, max_batch_age: Duration) -> Self {
34        Self {
35            max_batch_size,
36            max_batch_age,
37        }
38    }
39}
40
41/// HTTP transport with batching support
42///
43/// Buffers events and sends them in batches to reduce HTTP overhead.
44/// Flushes when either:
45/// - The batch reaches `max_batch_size` events
46/// - The oldest event in the batch is older than `max_batch_age`
47/// - `close()` is called (flush remaining events)
48pub struct BatchingHttpTransport {
49    client: Client,
50    batch_url: Url,
51    config: BatchConfig,
52    auth_token: Option<String>,
53    buffer: Mutex<BatchBuffer>,
54}
55
56struct BatchBuffer {
57    events: Vec<EventData>,
58    oldest_event_time: Option<Instant>,
59}
60
61impl BatchBuffer {
62    fn new() -> Self {
63        Self {
64            events: Vec::new(),
65            oldest_event_time: None,
66        }
67    }
68
69    fn push(&mut self, event: EventData) {
70        if self.events.is_empty() {
71            self.oldest_event_time = Some(Instant::now());
72        }
73        self.events.push(event);
74    }
75
76    fn take(&mut self) -> Vec<EventData> {
77        self.oldest_event_time = None;
78        std::mem::take(&mut self.events)
79    }
80
81    fn is_empty(&self) -> bool {
82        self.events.is_empty()
83    }
84
85    fn len(&self) -> usize {
86        self.events.len()
87    }
88
89    fn age(&self) -> Option<Duration> {
90        self.oldest_event_time.map(|t| t.elapsed())
91    }
92}
93
94impl BatchingHttpTransport {
95    pub fn new(
96        base_url: Url,
97        org_id: Uuid,
98        app_id: Uuid,
99        config: BatchConfig,
100        auth_token: Option<String>,
101    ) -> Result<Self, TransportError> {
102        let batch_url = base_url
103            .join(&format!(
104                "/api/orgs/{}/apps/{}/events/batch",
105                org_id, app_id
106            ))
107            .map_err(|e| TransportError::Configuration(format!("Invalid URL: {}", e)))?;
108
109        Ok(Self {
110            client: Client::new(),
111            batch_url,
112            config,
113            auth_token,
114            buffer: Mutex::new(BatchBuffer::new()),
115        })
116    }
117
118    pub fn with_default_config(
119        base_url: Url,
120        org_id: Uuid,
121        app_id: Uuid,
122        auth_token: Option<String>,
123    ) -> Result<Self, TransportError> {
124        Self::new(base_url, org_id, app_id, BatchConfig::default(), auth_token)
125    }
126
127    async fn flush(&self) -> Result<(), TransportError> {
128        let events = {
129            let mut buffer = self.buffer.lock().await;
130            if buffer.is_empty() {
131                return Ok(());
132            }
133            buffer.take()
134        };
135
136        self.send_batch(events).await
137    }
138
139    async fn send_batch(&self, events: Vec<EventData>) -> Result<(), TransportError> {
140        if events.is_empty() {
141            return Ok(());
142        }
143
144        let mut request = self.client.post(self.batch_url.clone()).json(&events);
145        if let Some(token) = &self.auth_token {
146            request = request.bearer_auth(token);
147        }
148        let response = request
149            .send()
150            .await
151            .map_err(|e| TransportError::Send(format!("HTTP batch request failed: {}", e)))?;
152
153        if !response.status().is_success() {
154            return Err(TransportError::Send(format!(
155                "Server returned error status for batch: {}",
156                response.status()
157            )));
158        }
159
160        Ok(())
161    }
162
163    async fn should_flush(&self) -> bool {
164        let buffer = self.buffer.lock().await;
165        if buffer.len() >= self.config.max_batch_size {
166            return true;
167        }
168        if let Some(age) = buffer.age() {
169            if age >= self.config.max_batch_age {
170                return true;
171            }
172        }
173        false
174    }
175}
176
177#[async_trait]
178impl Transport for BatchingHttpTransport {
179    async fn connect(&mut self) -> Result<(), TransportError> {
180        Ok(())
181    }
182
183    async fn send(&mut self, event: EventData) -> Result<(), TransportError> {
184        {
185            let mut buffer = self.buffer.lock().await;
186            buffer.push(event);
187        }
188
189        if self.should_flush().await {
190            self.flush().await?;
191        }
192
193        Ok(())
194    }
195
196    async fn close(&mut self) -> Result<(), TransportError> {
197        // Flush any remaining events
198        self.flush().await
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205    use chrono::Utc;
206
207    #[test]
208    fn test_batch_config_default() {
209        let config = BatchConfig::default();
210        assert_eq!(config.max_batch_size, 100);
211        assert_eq!(config.max_batch_age, Duration::from_secs(5));
212    }
213
214    #[test]
215    fn test_batch_config_custom() {
216        let config = BatchConfig::new(50, Duration::from_millis(500));
217        assert_eq!(config.max_batch_size, 50);
218        assert_eq!(config.max_batch_age, Duration::from_millis(500));
219    }
220
221    #[test]
222    fn test_batch_buffer_operations() {
223        let mut buffer = BatchBuffer::new();
224        assert!(buffer.is_empty());
225        assert_eq!(buffer.len(), 0);
226        assert!(buffer.age().is_none());
227
228        let event = EventData {
229            event_type: "test".to_string(),
230            event_data: serde_json::json!({}),
231            event_timestamp: Utc::now(),
232            process_instance_id: None,
233        };
234
235        buffer.push(event);
236        assert!(!buffer.is_empty());
237        assert_eq!(buffer.len(), 1);
238        assert!(buffer.age().is_some());
239
240        let events = buffer.take();
241        assert_eq!(events.len(), 1);
242        assert!(buffer.is_empty());
243        assert!(buffer.age().is_none());
244    }
245
246    #[test]
247    fn test_batching_transport_creation() {
248        let base_url = Url::parse("http://localhost:4318").unwrap();
249        let org_id = Uuid::new_v4();
250        let app_id = Uuid::new_v4();
251
252        let transport =
253            BatchingHttpTransport::with_default_config(base_url.clone(), org_id, app_id, None);
254        assert!(transport.is_ok());
255
256        let custom_config = BatchConfig::new(50, Duration::from_millis(100));
257        let transport = BatchingHttpTransport::new(base_url, org_id, app_id, custom_config, None);
258        assert!(transport.is_ok());
259    }
260
261    #[tokio::test]
262    async fn test_batching_transport_url() {
263        let base_url = Url::parse("http://localhost:4318").unwrap();
264        let org_id = Uuid::parse_str("12345678-1234-1234-1234-123456789012").unwrap();
265        let app_id = Uuid::parse_str("87654321-4321-4321-4321-210987654321").unwrap();
266
267        let transport = BatchingHttpTransport::with_default_config(base_url, org_id, app_id, None)
268            .expect("should create transport");
269
270        assert_eq!(
271            transport.batch_url.as_str(),
272            "http://localhost:4318/api/orgs/12345678-1234-1234-1234-123456789012/apps/87654321-4321-4321-4321-210987654321/events/batch"
273        );
274    }
275
276    #[tokio::test]
277    async fn test_should_flush_by_size() {
278        let base_url = Url::parse("http://localhost:4318").unwrap();
279        let org_id = Uuid::new_v4();
280        let app_id = Uuid::new_v4();
281        let config = BatchConfig::new(2, Duration::from_secs(60)); // Small batch for testing
282
283        let transport = BatchingHttpTransport::new(base_url, org_id, app_id, config, None)
284            .expect("should create transport");
285
286        // Add first event
287        {
288            let mut buffer = transport.buffer.lock().await;
289            buffer.push(EventData {
290                event_type: "test".to_string(),
291                event_data: serde_json::json!({}),
292                event_timestamp: Utc::now(),
293                process_instance_id: None,
294            });
295        }
296        assert!(!transport.should_flush().await);
297
298        // Add second event - should now want to flush
299        {
300            let mut buffer = transport.buffer.lock().await;
301            buffer.push(EventData {
302                event_type: "test".to_string(),
303                event_data: serde_json::json!({}),
304                event_timestamp: Utc::now(),
305                process_instance_id: None,
306            });
307        }
308        assert!(transport.should_flush().await);
309    }
310
311    #[tokio::test]
312    async fn test_should_flush_by_age() {
313        let base_url = Url::parse("http://localhost:4318").unwrap();
314        let org_id = Uuid::new_v4();
315        let app_id = Uuid::new_v4();
316        let config = BatchConfig::new(1000, Duration::from_millis(10)); // Short age for testing
317
318        let transport = BatchingHttpTransport::new(base_url, org_id, app_id, config, None)
319            .expect("should create transport");
320
321        {
322            let mut buffer = transport.buffer.lock().await;
323            buffer.push(EventData {
324                event_type: "test".to_string(),
325                event_data: serde_json::json!({}),
326                event_timestamp: Utc::now(),
327                process_instance_id: None,
328            });
329        }
330
331        assert!(!transport.should_flush().await);
332
333        // Wait for age threshold
334        tokio::time::sleep(Duration::from_millis(15)).await;
335
336        assert!(transport.should_flush().await);
337    }
338}