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#[derive(Debug, Clone)]
16pub struct BatchConfig {
17 pub max_batch_size: usize,
19 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
41pub 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 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)); let transport = BatchingHttpTransport::new(base_url, org_id, app_id, config, None)
284 .expect("should create transport");
285
286 {
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 {
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)); 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 tokio::time::sleep(Duration::from_millis(15)).await;
335
336 assert!(transport.should_flush().await);
337 }
338}