1use crate::profiler::CommonProfileData;
2use chrono::Utc;
3use std::sync::{Arc, Mutex};
4use std::thread;
5use std::time::Duration;
6
7#[derive(Debug, Clone, Copy, PartialEq)]
8pub enum ReportFormat {
9 Json,
10 Protobuf,
11}
12
13#[derive(Debug, Clone)]
14pub struct ReporterConfig {
15 pub endpoint: String,
16 pub interval_secs: u64,
17 pub enabled: bool,
18 pub api_key: Option<String>,
19 pub max_buffer_size: usize,
20 pub format: ReportFormat,
21}
22
23impl Default for ReporterConfig {
24 fn default() -> Self {
25 ReporterConfig {
26 endpoint: "https://quality-web-app.pages.dev/api/v0/profiles".to_string(),
27 interval_secs: 300, enabled: false,
29 api_key: None,
30 max_buffer_size: 100, format: ReportFormat::Protobuf, }
33 }
34}
35
36impl ReporterConfig {
37 pub fn from_env_and_args(
38 endpoint: Option<String>,
39 interval: Option<u64>,
40 api_key: Option<String>,
41 max_buffer_size: Option<usize>,
42 format: Option<String>,
43 ) -> Self {
44 let mut config = ReporterConfig::default();
45
46 if let Ok(env_endpoint) = std::env::var("PROFILER_ENDPOINT") {
48 config.endpoint = env_endpoint;
49 }
50 if let Ok(env_interval) = std::env::var("PROFILER_INTERVAL") {
51 if let Ok(interval) = env_interval.parse() {
52 config.interval_secs = interval;
53 }
54 }
55 if let Ok(env_api_key) = std::env::var("PROFILER_API_KEY") {
56 config.api_key = Some(env_api_key);
57 }
58 if let Ok(env_buffer_size) = std::env::var("PROFILER_MAX_BUFFER_SIZE") {
59 if let Ok(size) = env_buffer_size.parse() {
60 config.max_buffer_size = size;
61 }
62 }
63 if let Ok(env_format) = std::env::var("PROFILER_FORMAT") {
64 config.format = match env_format.to_lowercase().as_str() {
65 "json" => ReportFormat::Json,
66 "protobuf" | "proto" => ReportFormat::Protobuf,
67 _ => config.format,
68 };
69 }
70
71 let endpoint_provided = endpoint.is_some();
73 if let Some(ep) = endpoint {
74 config.endpoint = ep;
75 config.enabled = true;
76 }
77 if let Some(int) = interval {
78 config.interval_secs = int;
79 }
80 if let Some(key) = api_key {
81 config.api_key = Some(key);
82 }
83 if let Some(size) = max_buffer_size {
84 config.max_buffer_size = size;
85 }
86 if let Some(fmt) = format {
87 config.format = match fmt.to_lowercase().as_str() {
88 "json" => ReportFormat::Json,
89 "protobuf" | "proto" => ReportFormat::Protobuf,
90 _ => config.format,
91 };
92 }
93
94 if std::env::var("PROFILER_ENDPOINT").is_ok() || endpoint_provided {
96 config.enabled = true;
97 }
98
99 config
100 }
101}
102
103pub struct ProfileReporter {
104 config: ReporterConfig,
105 profile_buffer: Arc<Mutex<Vec<CommonProfileData>>>,
106 client: reqwest::blocking::Client,
107}
108
109impl ProfileReporter {
110 pub fn new(config: ReporterConfig) -> Self {
111 let client = reqwest::blocking::Client::builder()
112 .timeout(Duration::from_secs(30))
113 .build()
114 .expect("Failed to create HTTP client");
115
116 ProfileReporter {
117 config,
118 profile_buffer: Arc::new(Mutex::new(Vec::new())),
119 client,
120 }
121 }
122
123 pub fn add_profile(&self, profile: CommonProfileData) {
124 if !self.config.enabled {
125 return;
126 }
127
128 let mut buffer = self.profile_buffer.lock().unwrap();
129
130 if buffer.len() >= self.config.max_buffer_size {
132 buffer.remove(0);
133 eprintln!(
134 "⚠️ Buffer full ({} profiles), discarding oldest profile",
135 self.config.max_buffer_size
136 );
137 }
138
139 buffer.push(profile);
140 println!("📊 Profile added to buffer (total: {})", buffer.len());
141 }
142
143 pub fn start_periodic_reporting(&self) -> Option<thread::JoinHandle<()>> {
144 if !self.config.enabled {
145 println!("📡 Periodic reporting disabled");
146 return None;
147 }
148
149 println!(
150 "📡 Starting periodic reporting to: {}",
151 self.config.endpoint
152 );
153 println!("⏱️ Report interval: {} seconds", self.config.interval_secs);
154
155 let config = self.config.clone();
156 let profile_buffer = Arc::clone(&self.profile_buffer);
157 let client = self.client.clone();
158
159 let handle = thread::spawn(move || {
160 loop {
161 thread::sleep(Duration::from_secs(config.interval_secs));
162
163 let mut buffer = profile_buffer.lock().unwrap();
164 if buffer.is_empty() {
165 println!("📊 No profiles to report");
166 continue;
167 }
168
169 let profiles_to_send: Vec<CommonProfileData> = buffer.drain(..).collect();
170 drop(buffer); println!(
173 "📤 Sending {} profiles to backend...",
174 profiles_to_send.len()
175 );
176
177 match Self::send_profiles_with_retry(&client, &config, &profiles_to_send, 3) {
178 Ok(_) => {
179 println!(
180 "✅ Successfully reported {} profiles",
181 profiles_to_send.len()
182 );
183 }
184 Err(e) => {
185 eprintln!("⚠️ Failed to report profiles after retries: {}", e);
186 eprintln!(" Profiles will be retried in next batch");
187 let mut buffer = profile_buffer.lock().unwrap();
189 buffer.extend(profiles_to_send);
190 }
191 }
192 }
193 });
194
195 Some(handle)
196 }
197
198 fn send_profiles_with_retry(
199 client: &reqwest::blocking::Client,
200 config: &ReporterConfig,
201 profiles: &[CommonProfileData],
202 max_retries: u32,
203 ) -> Result<(), String> {
204 let mut last_error = String::new();
205
206 for attempt in 1..=max_retries {
207 match Self::send_profiles(client, config, profiles) {
208 Ok(_) => {
209 if attempt > 1 {
210 println!(" ✅ Succeeded on attempt {}/{}", attempt, max_retries);
211 }
212 return Ok(());
213 }
214 Err(e) => {
215 last_error = e.clone();
216 if attempt < max_retries {
217 eprintln!(" ⚠️ Attempt {}/{} failed: {}", attempt, max_retries, e);
218 eprintln!(" 🔄 Retrying in {} seconds...", attempt);
219 thread::sleep(Duration::from_secs(attempt as u64));
220 }
221 }
222 }
223 }
224
225 Err(format!(
226 "All {} attempts failed. Last error: {}",
227 max_retries, last_error
228 ))
229 }
230
231 fn send_profiles(
232 client: &reqwest::blocking::Client,
233 config: &ReporterConfig,
234 profiles: &[CommonProfileData],
235 ) -> Result<(), String> {
236 let mut request = client.post(&config.endpoint);
237
238 match config.format {
239 ReportFormat::Json => {
240 let payload = serde_json::json!({
242 "profiles": profiles,
243 "reported_at": Utc::now().to_rfc3339(),
244 "agent_version": env!("CARGO_PKG_VERSION"),
245 });
246
247 request = request
248 .json(&payload)
249 .header("Content-Type", "application/json");
250 }
251 ReportFormat::Protobuf => {
252 use crate::proto::ProfileBatch;
254 use prost::Message;
255
256 let proto_profiles: Vec<_> = profiles.iter().map(|p| p.to_protobuf()).collect();
257
258 let batch = ProfileBatch {
259 profiles: proto_profiles,
260 reported_at: Utc::now().to_rfc3339(),
261 agent_version: env!("CARGO_PKG_VERSION").to_string(),
262 };
263
264 let mut buf = Vec::new();
265 batch
266 .encode(&mut buf)
267 .map_err(|e| format!("Failed to encode protobuf: {}", e))?;
268
269 request = request
270 .body(buf)
271 .header("Content-Type", "application/x-protobuf");
272 }
273 }
274
275 request = request.header(
276 "User-Agent",
277 format!("quality-agent/{}", env!("CARGO_PKG_VERSION")),
278 );
279
280 if let Some(ref api_key) = config.api_key {
282 request = request.header("Authorization", format!("Bearer {}", api_key));
283 }
284
285 let response = request
286 .send()
287 .map_err(|e| format!("HTTP request failed: {}", e))?;
288
289 if response.status().is_success() {
290 Ok(())
291 } else {
292 Err(format!(
293 "Server returned error: {} - {}",
294 response.status(),
295 response
296 .text()
297 .unwrap_or_else(|_| "Unknown error".to_string())
298 ))
299 }
300 }
301
302 pub fn send_now(&self) -> Result<(), String> {
303 if !self.config.enabled {
304 return Err("Reporting is disabled".to_string());
305 }
306
307 let mut buffer = self.profile_buffer.lock().unwrap();
308 if buffer.is_empty() {
309 return Err("No profiles to send".to_string());
310 }
311
312 let profiles_to_send: Vec<CommonProfileData> = buffer.drain(..).collect();
313 drop(buffer);
314
315 println!(
316 "📤 Sending {} profiles immediately...",
317 profiles_to_send.len()
318 );
319
320 match Self::send_profiles_with_retry(&self.client, &self.config, &profiles_to_send, 3) {
321 Ok(_) => Ok(()),
322 Err(e) => {
323 let mut buffer = self.profile_buffer.lock().unwrap();
325 buffer.extend(profiles_to_send);
326 Err(e)
327 }
328 }
329 }
330}
331
332#[cfg(test)]
333mod tests {
334 use super::*;
335
336 #[test]
337 fn test_reporter_config_defaults() {
338 let config = ReporterConfig::default();
339 assert_eq!(
340 config.endpoint,
341 "https://quality-web-app.pages.dev/api/v0/profiles"
342 );
343 assert_eq!(config.interval_secs, 300);
344 assert_eq!(config.max_buffer_size, 100);
345 assert_eq!(config.format, ReportFormat::Protobuf);
346 assert!(!config.enabled);
347 }
348
349 #[test]
350 fn test_reporter_config_from_args() {
351 let config = ReporterConfig::from_env_and_args(
352 Some("https://example.com/api".to_string()),
353 Some(60),
354 Some("test-key".to_string()),
355 Some(50),
356 Some("json".to_string()),
357 );
358 assert_eq!(config.endpoint, "https://example.com/api");
359 assert_eq!(config.interval_secs, 60);
360 assert_eq!(config.max_buffer_size, 50);
361 assert_eq!(config.format, ReportFormat::Json);
362 assert_eq!(config.api_key, Some("test-key".to_string()));
363 assert!(config.enabled);
364 }
365
366 #[test]
367 fn test_reporter_disabled_by_default() {
368 let config = ReporterConfig::from_env_and_args(None, None, None, None, None);
369 let reporter = ProfileReporter::new(config);
370 assert!(reporter.start_periodic_reporting().is_none());
371 }
372
373 #[test]
374 fn test_buffer_overflow_discards_oldest() {
375 use crate::profiler::common_format::{RuntimeMetrics, StaticMetrics};
376 use std::collections::HashMap;
377
378 let config = ReporterConfig {
380 enabled: true,
381 max_buffer_size: 3,
382 ..Default::default()
383 };
384
385 let reporter = ProfileReporter::new(config);
386
387 for i in 0..5 {
389 let profile = CommonProfileData {
390 language: "Python".to_string(),
391 source_file: format!("test_{}.py", i),
392 timestamp: chrono::Utc::now().to_rfc3339(),
393 static_analysis: StaticMetrics {
394 file_size_bytes: 100,
395 line_count: 10,
396 function_count: 1,
397 class_count: 0,
398 import_count: 0,
399 complexity_score: 1,
400 },
401 runtime_analysis: Some(RuntimeMetrics {
402 total_samples: i as u64,
403 execution_duration_secs: 1,
404 functions_executed: 0,
405 function_stats: HashMap::new(),
406 hot_functions: Vec::new(),
407 }),
408 };
409 reporter.add_profile(profile);
410 }
411
412 let buffer = reporter.profile_buffer.lock().unwrap();
414 assert_eq!(buffer.len(), 3);
415 assert_eq!(buffer[0].source_file, "test_2.py");
416 assert_eq!(buffer[1].source_file, "test_3.py");
417 assert_eq!(buffer[2].source_file, "test_4.py");
418 }
419
420 #[test]
423 fn test_reporter_config_format_json() {
424 let config = ReporterConfig::from_env_and_args(
425 Some("http://example.com".to_string()),
426 None,
427 None,
428 None,
429 Some("json".to_string()),
430 );
431 assert_eq!(config.format, ReportFormat::Json);
432 }
433
434 #[test]
435 fn test_reporter_config_format_protobuf() {
436 let config = ReporterConfig::from_env_and_args(
437 Some("http://example.com".to_string()),
438 None,
439 None,
440 None,
441 Some("protobuf".to_string()),
442 );
443 assert_eq!(config.format, ReportFormat::Protobuf);
444 }
445
446 #[test]
447 fn test_reporter_config_format_proto_alias() {
448 let config = ReporterConfig::from_env_and_args(
449 Some("http://example.com".to_string()),
450 None,
451 None,
452 None,
453 Some("proto".to_string()),
454 );
455 assert_eq!(config.format, ReportFormat::Protobuf);
456 }
457
458 #[test]
459 fn test_reporter_config_format_case_insensitive() {
460 let config1 = ReporterConfig::from_env_and_args(
461 Some("http://example.com".to_string()),
462 None,
463 None,
464 None,
465 Some("JSON".to_string()),
466 );
467 assert_eq!(config1.format, ReportFormat::Json);
468
469 let config2 = ReporterConfig::from_env_and_args(
470 Some("http://example.com".to_string()),
471 None,
472 None,
473 None,
474 Some("PROTOBUF".to_string()),
475 );
476 assert_eq!(config2.format, ReportFormat::Protobuf);
477 }
478
479 #[test]
480 fn test_reporter_config_format_invalid_keeps_default() {
481 let config = ReporterConfig::from_env_and_args(
482 Some("http://example.com".to_string()),
483 None,
484 None,
485 None,
486 Some("invalid_format".to_string()),
487 );
488 assert_eq!(config.format, ReportFormat::Protobuf);
490 }
491
492 #[test]
495 fn test_add_profile_when_disabled_buffer_stays_empty() {
496 use crate::profiler::common_format::StaticMetrics;
497
498 let config = ReporterConfig {
500 enabled: false,
501 ..Default::default()
502 };
503 let reporter = ProfileReporter::new(config);
504
505 let profile = CommonProfileData {
506 language: "Rust".to_string(),
507 source_file: "test.rs".to_string(),
508 timestamp: chrono::Utc::now().to_rfc3339(),
509 static_analysis: StaticMetrics {
510 file_size_bytes: 100,
511 line_count: 10,
512 function_count: 1,
513 class_count: 0,
514 import_count: 0,
515 complexity_score: 1,
516 },
517 runtime_analysis: None,
518 };
519
520 reporter.add_profile(profile);
521
522 let buffer = reporter.profile_buffer.lock().unwrap();
523 assert!(
524 buffer.is_empty(),
525 "Buffer should remain empty when reporter is disabled"
526 );
527 }
528
529 #[test]
530 fn test_add_profile_when_enabled() {
531 use crate::profiler::common_format::StaticMetrics;
532
533 let config = ReporterConfig {
534 enabled: true,
535 ..Default::default()
536 };
537 let reporter = ProfileReporter::new(config);
538
539 let profile = CommonProfileData {
540 language: "Rust".to_string(),
541 source_file: "test.rs".to_string(),
542 timestamp: chrono::Utc::now().to_rfc3339(),
543 static_analysis: StaticMetrics {
544 file_size_bytes: 100,
545 line_count: 10,
546 function_count: 1,
547 class_count: 0,
548 import_count: 0,
549 complexity_score: 1,
550 },
551 runtime_analysis: None,
552 };
553
554 reporter.add_profile(profile);
555
556 let buffer = reporter.profile_buffer.lock().unwrap();
557 assert_eq!(
558 buffer.len(),
559 1,
560 "Buffer should have 1 profile when reporter is enabled"
561 );
562 assert_eq!(buffer[0].source_file, "test.rs");
563 }
564
565 #[test]
568 fn test_send_now_when_disabled() {
569 let config = ReporterConfig {
570 enabled: false,
571 ..Default::default()
572 };
573 let reporter = ProfileReporter::new(config);
574
575 let result = reporter.send_now();
576
577 assert!(result.is_err());
578 assert_eq!(result.unwrap_err(), "Reporting is disabled");
579 }
580
581 #[test]
582 fn test_send_now_when_buffer_empty() {
583 let config = ReporterConfig {
584 enabled: true,
585 endpoint: "http://test.invalid".to_string(),
586 ..Default::default()
587 };
588 let reporter = ProfileReporter::new(config);
589
590 let result = reporter.send_now();
591
592 assert!(result.is_err());
593 assert_eq!(result.unwrap_err(), "No profiles to send");
594 }
595
596 #[test]
599 fn test_report_format_equality() {
600 assert_eq!(ReportFormat::Json, ReportFormat::Json);
601 assert_eq!(ReportFormat::Protobuf, ReportFormat::Protobuf);
602 assert_ne!(ReportFormat::Json, ReportFormat::Protobuf);
603 }
604
605 #[test]
606 fn test_report_format_clone() {
607 let format = ReportFormat::Json;
608 let format_clone = format;
609 assert_eq!(format_clone, ReportFormat::Json);
610 }
611
612 #[test]
613 fn test_report_format_copy() {
614 let format1 = ReportFormat::Protobuf;
615 let format2 = format1; assert_eq!(format1, format2);
617 }
618
619 #[test]
622 fn test_reporter_config_clone() {
623 let config = ReporterConfig {
624 endpoint: "http://example.com".to_string(),
625 interval_secs: 120,
626 enabled: true,
627 api_key: Some("my-api-key".to_string()),
628 max_buffer_size: 50,
629 format: ReportFormat::Json,
630 };
631
632 let config_clone = config.clone();
633
634 assert_eq!(config_clone.endpoint, "http://example.com");
635 assert_eq!(config_clone.interval_secs, 120);
636 assert!(config_clone.enabled);
637 assert_eq!(config_clone.api_key, Some("my-api-key".to_string()));
638 assert_eq!(config_clone.max_buffer_size, 50);
639 assert_eq!(config_clone.format, ReportFormat::Json);
640 }
641
642 #[test]
643 fn test_reporter_config_all_parameters() {
644 let config = ReporterConfig::from_env_and_args(
645 Some("https://custom.endpoint/profiles".to_string()),
646 Some(60),
647 Some("custom-api-key".to_string()),
648 Some(200),
649 Some("json".to_string()),
650 );
651
652 assert_eq!(config.endpoint, "https://custom.endpoint/profiles");
653 assert_eq!(config.interval_secs, 60);
654 assert_eq!(config.api_key, Some("custom-api-key".to_string()));
655 assert_eq!(config.max_buffer_size, 200);
656 assert_eq!(config.format, ReportFormat::Json);
657 assert!(config.enabled);
658 }
659
660 #[test]
661 fn test_reporter_enabled_when_endpoint_provided() {
662 let config = ReporterConfig::from_env_and_args(
663 Some("http://example.com".to_string()),
664 None,
665 None,
666 None,
667 None,
668 );
669
670 assert!(
671 config.enabled,
672 "Reporter should be enabled when endpoint is provided"
673 );
674 }
675
676 #[test]
677 fn test_reporter_disabled_without_endpoint() {
678 let config = ReporterConfig::from_env_and_args(None, None, None, None, None);
679
680 assert!(
681 !config.enabled,
682 "Reporter should be disabled when no endpoint is provided"
683 );
684 }
685
686 #[test]
689 fn test_profile_reporter_new() {
690 let config = ReporterConfig::default();
691 let reporter = ProfileReporter::new(config);
692
693 let buffer = reporter.profile_buffer.lock().unwrap();
695 assert!(buffer.is_empty());
696 }
697
698 #[test]
699 fn test_start_periodic_reporting_returns_none_when_disabled() {
700 let config = ReporterConfig {
701 enabled: false,
702 ..Default::default()
703 };
704 let reporter = ProfileReporter::new(config);
705
706 let handle = reporter.start_periodic_reporting();
707 assert!(
708 handle.is_none(),
709 "Should return None when reporting is disabled"
710 );
711 }
712
713 #[test]
716 fn test_multiple_profiles_added_correctly() {
717 use crate::profiler::common_format::StaticMetrics;
718
719 let config = ReporterConfig {
720 enabled: true,
721 max_buffer_size: 10,
722 ..Default::default()
723 };
724 let reporter = ProfileReporter::new(config);
725
726 for i in 0..5 {
727 let profile = CommonProfileData {
728 language: format!("Lang{}", i),
729 source_file: format!("file{}.rs", i),
730 timestamp: chrono::Utc::now().to_rfc3339(),
731 static_analysis: StaticMetrics {
732 file_size_bytes: i * 100,
733 line_count: i * 10,
734 function_count: i,
735 class_count: 0,
736 import_count: 0,
737 complexity_score: i as u32,
738 },
739 runtime_analysis: None,
740 };
741 reporter.add_profile(profile);
742 }
743
744 let buffer = reporter.profile_buffer.lock().unwrap();
745 assert_eq!(buffer.len(), 5);
746
747 for i in 0..5 {
749 assert_eq!(buffer[i].language, format!("Lang{}", i));
750 assert_eq!(buffer[i].source_file, format!("file{}.rs", i));
751 }
752 }
753}