1use anyhow::Result;
18use tracing::{error, info};
19
20#[derive(Debug, PartialEq)]
22pub enum ProbeResult {
23 Success,
24 Error(u16),
25 Timeout,
26 NetworkError(String),
27}
28
29#[async_trait::async_trait]
30pub trait Probe {
31 fn new(config: cli::Config) -> Result<Self>
32 where
33 Self: std::marker::Sized;
34 async fn probe(&self) -> ProbeResult;
35}
36
37#[derive(Debug, Clone, Copy, PartialEq)]
39pub enum PingState {
40 Run,
41 Complete,
42 Fail,
43}
44
45impl PingState {
46 pub fn as_str(&self) -> &'static str {
47 match self {
48 PingState::Run => "run",
49 PingState::Complete => "complete",
50 PingState::Fail => "fail",
51 }
52 }
53}
54
55#[async_trait::async_trait]
56pub trait Export {
57 fn new(config: cli::Config) -> Result<Self>
58 where
59 Self: std::marker::Sized;
60 async fn ping(&self, state: PingState, status_code: u16, message: Option<&str>);
61}
62
63pub struct Monitor {
67 exporter: Box<dyn Export>,
68 probe: Box<dyn Probe>,
69}
70
71impl Monitor {
72 pub fn new(config: cli::Config) -> Result<Self> {
73 Ok(Monitor {
74 exporter: Box::new(exporters::Cronitor::new(config.clone())?),
75 probe: match config.endpoint_type {
76 probes::Type::OpenAIChatCompletion | probes::Type::OpenAIEmbedding => {
77 Box::new(probes::OpenAI::new(config.clone())?)
78 }
79 probes::Type::Newman => Box::new(probes::Newman::new(config.clone())?),
80 },
81 })
82 }
83
84 pub async fn run(&self) -> i32 {
85 info!("Sending start ping to Cronitor");
87 self.exporter.ping(PingState::Run, 0, None).await;
88
89 match self.probe.probe().await {
91 ProbeResult::Success => {
92 info!("Sending success ping to Cronitor");
93 self.exporter.ping(PingState::Complete, 0, None).await;
94 info!("SUCCESS: Endpoint responded successfully");
95 0
96 }
97 ProbeResult::Error(status_code) => {
98 info!("Sending failure ping to Cronitor");
99 self.exporter.ping(PingState::Fail, status_code, None).await;
100 error!("FAILURE: Endpoint failed with HTTP {status_code}");
101 1
102 }
103 ProbeResult::Timeout => {
104 info!("Sending timeout ping to Cronitor");
105 self.exporter
106 .ping(PingState::Fail, 124, Some("Request timeout"))
107 .await;
108 error!("TIMEOUT: Request timed out");
109 124
110 }
111 ProbeResult::NetworkError(error) => {
112 info!("Sending failure ping to Cronitor");
113 self.exporter
114 .ping(PingState::Fail, 1, Some(&format!("Network error: {error}")))
115 .await;
116 error!("FAILURE: Network error: {error}");
117 1
118 }
119 }
120 }
121}
122
123pub mod cli {
124 use clap::Parser;
125
126 use super::probes::Type as ProbeType;
127
128 #[derive(Parser, Debug, Clone, PartialEq)]
130 #[command(
131 author,
132 version,
133 about,
134 long_about = "Probe an LLM endpoint and report status to Cronitor."
135 )]
136 pub struct Config {
137 #[arg(long, env = "CRONITOR_BASE_URL")]
139 pub cronitor_base_url: String,
140
141 #[arg(long, env = "CRONITOR_API_KEY")]
143 pub cronitor_api_key: Option<String>,
144
145 #[arg(long, env = "MONITOR_NAME")]
147 pub monitor_name: String,
148
149 #[arg(long, env = "SERVER_URL", default_value = "http://localhost:8000/v1")]
151 pub server_url: String,
152
153 #[arg(long, env = "ENDPOINT_TYPE", default_value = ProbeType::OpenAIChatCompletion)]
155 pub endpoint_type: ProbeType,
156
157 #[arg(long, env = "MODEL_NAME", default_value = "gpt-4")]
159 pub model_name: String,
160
161 #[arg(long, env = "APP_ENV", default_value = "production")]
163 pub env: String,
164
165 #[arg(long, env = "TIMEOUT_SECONDS", default_value_t = 10)]
167 pub timeout_seconds: u64,
168
169 #[arg(long, env = "MIN_SUCCESS_FREQ")]
176 pub min_success_freq: Option<u8>,
177
178 #[arg(long, env = "SCHEDULE")]
181 pub schedule: Option<String>,
182
183 #[arg(long, env = "REALERT_INTERVAL")]
185 pub realert_interval: Option<u16>,
186
187 #[arg(long, env = "CONSECUTIVE_FAILURES_FOR_ALERT")]
189 pub consecutive_failures: Option<u8>,
190
191 #[arg(long, env = "CONSECUTIVE_MISSING_FOR_ALERT")]
194 pub consecutive_missing: Option<u8>,
195
196 #[arg(long, env = "MONITOR_GROUP")]
198 pub monitor_group: Option<String>,
199
200 #[arg(long, env = "COLLECTION_PATH", default_value = "collection.json")]
203 pub collection_path: String,
204
205 #[arg(long, env = "ENVIRONMENT_PATH", default_value = None)]
207 pub environment_path: Option<String>,
208
209 #[arg(long, env = "REQUEST_DELAY_MILLISECONDS", default_value = None)]
211 pub request_delay_milliseconds: Option<u64>,
212 }
213
214 impl Default for Config {
215 fn default() -> Self {
216 Config {
217 cronitor_base_url: "https://cronitor.link".to_string(),
218 cronitor_api_key: None,
219 monitor_name: "test-monitor".to_string(),
220 server_url: "https://api.openai.com".to_string(),
221 endpoint_type: ProbeType::OpenAIChatCompletion,
222 model_name: "gpt-4".to_string(),
223 env: "test".to_string(),
224 timeout_seconds: 10,
225 schedule: None,
226 realert_interval: Some(9999),
227 consecutive_failures: Some(1),
228 min_success_freq: Some(60),
229 monitor_group: None,
230 consecutive_missing: Some(1),
231 collection_path: "collection.json".to_string(),
232 environment_path: None,
233 request_delay_milliseconds: None,
234 }
235 }
236 }
237}
238
239pub mod exporters {
240 use anyhow::{Context, Result};
241 use chrono::Utc;
242 use hostname::get;
243 use reqwest::Client;
244 use serde_json::json;
245 use std::time::Duration;
246 use tracing::{error, info};
247
248 use crate::Export;
249
250 use super::{PingState, cli::Config};
251
252 pub struct Cronitor {
254 config: Config,
255 client: Client,
256 host: String,
257 series_id: String,
258 }
259
260 #[async_trait::async_trait]
262 impl Export for Cronitor {
263 fn new(config: Config) -> Result<Self> {
264 let client = Client::builder()
265 .timeout(Duration::from_secs(config.timeout_seconds))
266 .build()
267 .context("building reqwest client")?;
268
269 let host = get().unwrap_or_default().to_string_lossy().into_owned();
270 let series_id = format!("{}-{}", Utc::now().timestamp(), std::process::id());
271
272 info!("Starting job with series ID: {series_id}");
273
274 Ok(Cronitor {
275 config,
276 client,
277 host,
278 series_id,
279 })
280 }
281
282 async fn ping(&self, state: PingState, status_code: u16, message: Option<&str>) {
283 let url = self.build_ping_url(state, status_code, message);
284
285 match self.client.get(&url).send().await {
286 Ok(resp) if resp.status().is_success() => {
287 info!("Cronitor ping OK");
289 }
290 Ok(resp) => {
291 let status = resp.status();
293 let body = resp.text().await.unwrap_or_default(); error!("Cronitor ping non-2xx {status}: {body}");
295 }
296 Err(e) => {
297 error!("Failed to send ping to Cronitor: {e}");
299 }
300 }
301
302 if state == PingState::Run {
303 let Some(api_key) = self.config.cronitor_api_key.as_deref() else {
306 info!("No api key, skipping monitor enrichment");
307 return; };
309
310 match self
311 .client
312 .put("https://cronitor.io/api/monitors")
313 .basic_auth(api_key, Some("")) .json(&self.get_monitor_update_payload())
315 .send()
316 .await
317 {
318 Ok(resp) if resp.status().is_success() => {
319 info!("Monitor enriched successful");
320 }
321 Ok(resp) => {
322 if !resp.status().is_success() {
323 error!(
324 "Monitor enrichment failed {}: {}",
325 resp.status(),
326 resp.text().await.unwrap_or_default()
327 );
328 }
329 }
330 Err(err) => {
331 error!("Failed to enrich Cronitor monitor: {err}");
332 }
333 }
334 }
335 }
336 }
337
338 impl Cronitor {
340 pub fn build_ping_url(
341 &self,
342 state: PingState,
343 status_code: u16,
344 message: Option<&str>,
345 ) -> String {
346 let mut url = format!(
347 "{}/{}?state={}&series={}&status_code={}&env={}&host={}",
348 self.config.cronitor_base_url,
349 self.config.monitor_name,
350 state.as_str(),
351 self.series_id,
352 status_code,
353 self.config.env,
354 self.host
355 );
356 if let Some(msg) = message {
357 url.push_str("&message=");
358 url.push_str(&urlencoding::encode(msg));
359 }
360 url
361 }
362
363 pub fn get_monitor_update_payload(&self) -> serde_json::Value {
364 let mut monitor = serde_json::Map::new();
365 monitor.insert("type".into(), json!("job"));
366 monitor.insert("key".into(), json!(self.config.monitor_name));
367
368 if let Some(consecutive_failures) = self.config.consecutive_failures {
369 monitor.insert("failure_tolerance".into(), json!(consecutive_failures));
370 }
371
372 if let Some(schedule) = self.config.schedule.clone() {
373 monitor.insert("schedule".into(), json!(schedule));
374 }
375
376 if let Some(realert_interval) = self.config.realert_interval {
377 monitor.insert("realert_interval".into(), json!(realert_interval));
378 }
379
380 if let (Some(consecutive_missing), Some(_)) = (
381 self.config.consecutive_missing,
382 self.config.schedule.clone(),
383 ) {
384 monitor.insert("schedule_tolerance".into(), json!(consecutive_missing));
385 }
386
387 if let Some(group) = self.config.monitor_group.clone() {
388 monitor.insert("group".into(), json!(group));
389 }
390
391 let mut assertions: Vec<String> = vec![format!(
393 "metric.duration < {}s",
394 self.config.timeout_seconds * 2
395 )];
396
397 if let Some(min_success_freq) = self.config.min_success_freq {
398 assertions.push(format!("job.completes < {min_success_freq} minute"));
399 }
400 monitor.insert("assertions".into(), json!(assertions));
401
402 json!({ "monitors": [serde_json::Value::Object(monitor)] })
403 }
404 }
405
406 #[cfg(test)]
407 mod tests {
408 use super::*;
409
410 #[test]
411 fn test_cronitor_client_creation() {
412 let config = Config::default();
413 let client = Cronitor::new(config);
414 assert!(client.is_ok());
415 }
416
417 #[test]
418 fn test_cronitor_ping_url_construction_without_message() {
419 let config = Config::default();
420 let client = Cronitor::new(config).unwrap();
421
422 let url = client.build_ping_url(PingState::Run, 0, None);
423
424 assert!(url.contains("https://cronitor.link/test-monitor"));
425 assert!(url.contains("state=run"));
426 assert!(url.contains("status_code=0"));
427 assert!(url.contains("env=test"));
428 assert!(url.contains("series="));
429 assert!(url.contains("host="));
430 assert!(!url.contains("message="));
431 }
432
433 #[test]
434 fn test_cronitor_ping_url_construction_with_message() {
435 let config = Config::default();
436 let client = Cronitor::new(config).unwrap();
437
438 let url = client.build_ping_url(PingState::Fail, 500, Some("Test error"));
439
440 assert!(url.contains("https://cronitor.link/test-monitor"));
441 assert!(url.contains("state=fail"));
442 assert!(url.contains("status_code=500"));
443 assert!(url.contains("env=test"));
444 assert!(url.contains("message=Test%20error")); }
446
447 #[test]
448 fn test_cronitor_ping_url_special_characters() {
449 let config = Config::default();
450 let client = Cronitor::new(config).unwrap();
451
452 let url = client.build_ping_url(PingState::Fail, 500, Some("Error: 500 & timeout!"));
453
454 assert!(url.contains("message=Error%3A%20500%20%26%20timeout%21"));
455 }
456 }
457}
458
459pub mod probes {
460 use anyhow::{Context, Result};
461 use reqwest::Client;
462 use serde_json::json;
463 use std::{
464 process::{Command, Stdio},
465 time::Duration,
466 };
467 use tracing::{error, info};
468
469 use super::{ProbeResult, cli::Config};
470
471 #[derive(Debug, Clone, Copy, PartialEq, clap::ValueEnum)]
473 pub enum Type {
474 #[value(name = "openai-chat-completion")]
475 OpenAIChatCompletion,
476 #[value(name = "openai-embedding")]
477 OpenAIEmbedding,
478 #[value(name = "newman")]
479 Newman,
480 }
481
482 impl From<Type> for clap::builder::OsStr {
483 fn from(value: Type) -> Self {
484 match value {
485 Type::OpenAIChatCompletion => "openai-chat-completion".into(),
486 Type::OpenAIEmbedding => "openai-embedding".into(),
487 Type::Newman => "newman".into(),
488 }
489 }
490 }
491
492 pub struct OpenAI {
494 client: Client,
495 config: Config,
496 }
497
498 #[async_trait::async_trait]
500 impl super::Probe for OpenAI {
501 fn new(config: Config) -> Result<Self> {
502 let client = Client::builder()
503 .timeout(Duration::from_secs(config.timeout_seconds))
504 .build()
505 .context("building reqwest client")?;
506
507 Ok(OpenAI { client, config })
508 }
509
510 async fn probe(&self) -> ProbeResult {
511 let endpoint = self.build_endpoint_url();
512 let payload = self.build_payload();
513
514 info!("Querying {endpoint}");
515
516 match self.client.post(&endpoint).json(&payload).send().await {
517 Ok(resp) => {
518 let status = resp.status();
519 let body = resp.text().await.unwrap_or_default();
520 info!("Response body: {body}");
521
522 if status.is_success() {
523 ProbeResult::Success
524 } else {
525 ProbeResult::Error(status.as_u16())
526 }
527 }
528 Err(e) if e.is_timeout() => ProbeResult::Timeout,
529 Err(e) => ProbeResult::NetworkError(e.to_string()),
530 }
531 }
532 }
533
534 impl OpenAI {
536 pub fn build_endpoint_url(&self) -> String {
537 match self.config.endpoint_type {
538 Type::OpenAIChatCompletion => {
539 format!("{}/v1/chat/completions", self.config.server_url)
540 }
541 Type::OpenAIEmbedding => format!("{}/v1/embeddings", self.config.server_url),
542 _ => panic!("Unsupported endpoint type"),
543 }
544 }
545
546 pub fn build_payload(&self) -> serde_json::Value {
547 match self.config.endpoint_type {
548 Type::OpenAIChatCompletion => json!({
549 "model": self.config.model_name,
550 "messages": [{ "role": "user", "content": "test" }],
551 "max_tokens": 1,
552 "priority": -100
553 }),
554 Type::OpenAIEmbedding => json!({
555 "model": self.config.model_name,
556 "input": "test",
557 "priority": -100
558 }),
559 _ => panic!("Unsupported endpoint type"),
560 }
561 }
562 }
563
564 pub struct Newman {
566 config: Config,
567 }
568
569 #[async_trait::async_trait]
571 impl super::Probe for Newman {
572 fn new(config: Config) -> Result<Self> {
573 Ok(Newman { config })
574 }
575
576 async fn probe(&self) -> ProbeResult {
577 let mut newman = Command::new("newman");
578
579 newman.arg("run").arg(&self.config.collection_path);
580
581 newman
583 .arg("--timeout-request")
584 .arg((self.config.timeout_seconds * 1000).to_string());
585
586 if let Some(env_path) = &self.config.environment_path {
588 newman.arg("-e").arg(env_path);
589 }
590 if let Some(delay) = self.config.request_delay_milliseconds {
591 newman.arg("--delay-request").arg(delay.to_string());
592 }
593
594 if let Ok(child) = newman
595 .stdout(Stdio::piped())
596 .stderr(Stdio::piped())
597 .spawn()
598 .context("spawning newman process")
599 {
600 match child.wait_with_output() {
601 Ok(output) => {
602 let status = output.status;
603 let body = String::from_utf8_lossy(&output.stdout);
604 info!("--- Newman stdout ---\n {body}");
605
606 if status.success() {
607 ProbeResult::Success
608 } else {
609 ProbeResult::Error(1)
610 }
611 }
612 Err(e) => {
613 error!("Failed to wait for newman process: {e}");
614 ProbeResult::Error(1)
615 }
616 }
617 } else {
618 error!("Failed to start newman process");
619 ProbeResult::Error(1)
620 }
621 }
622 }
623
624 #[cfg(test)]
625 mod tests {
626 use super::{super::Probe, *};
627 use httpmock::prelude::*;
628 use serde_json::json;
629
630 #[test]
631 fn test_openai_creation() {
632 let config = Config::default();
633 let probe = OpenAI::new(config);
634 assert!(probe.is_ok());
635 }
636
637 #[test]
638 fn test_openai_chat_endpoint_url() {
639 let config = Config {
640 endpoint_type: Type::OpenAIChatCompletion,
641 server_url: "https://api.openai.com".to_string(),
642 ..Default::default()
643 };
644 let probe = OpenAI::new(config).unwrap();
645
646 let url = probe.build_endpoint_url();
647 assert_eq!(url, "https://api.openai.com/v1/chat/completions");
648 }
649
650 #[test]
651 fn test_openai_embedding_endpoint_url() {
652 let config = Config {
653 endpoint_type: Type::OpenAIEmbedding,
654 server_url: "https://api.example.com".to_string(),
655 ..Default::default()
656 };
657 let probe = OpenAI::new(config).unwrap();
658
659 let url = probe.build_endpoint_url();
660 assert_eq!(url, "https://api.example.com/v1/embeddings");
661 }
662
663 #[test]
664 fn test_openai_chat_payload() {
665 let config = Config {
666 endpoint_type: Type::OpenAIChatCompletion,
667 model_name: "a-piece-of-cheese".to_string(),
668 ..Default::default()
669 };
670 let probe = OpenAI::new(config).unwrap();
671
672 let payload = probe.build_payload();
673 let expected = json!({
674 "model": "a-piece-of-cheese",
675 "messages": [{ "role": "user", "content": "test" }],
676 "max_tokens": 1,
677 "priority": -100
678 });
679
680 assert_eq!(payload, expected);
681 }
682
683 #[test]
684 fn test_openai_embedding_payload() {
685 let config = Config {
686 endpoint_type: Type::OpenAIEmbedding,
687 model_name: "text-embedding-ada-002".to_string(),
688 ..Default::default()
689 };
690 let probe = OpenAI::new(config).unwrap();
691
692 let payload = probe.build_payload();
693 let expected = json!({
694 "model": "text-embedding-ada-002",
695 "input": "test",
696 "priority": -100
697 });
698
699 assert_eq!(payload, expected);
700 }
701
702 #[tokio::test]
703 async fn test_openai_successful_response() {
704 let server = MockServer::start();
705
706 let mock = server.mock(|when, then| {
708 when.method(POST)
709 .path("/v1/chat/completions")
710 .json_body(json!({
711 "model": "gpt-4",
712 "messages": [{ "role": "user", "content": "test" }],
713 "max_tokens": 1,
714 "priority": -100
715 }));
716 then.status(200).json_body(json!({
717 "choices": [{"message": {"role": "assistant", "content": "Hello"}}]
718 }));
719 });
720
721 let config = Config {
722 server_url: server.base_url(),
723 endpoint_type: Type::OpenAIChatCompletion,
724 model_name: "gpt-4".to_string(),
725 ..Default::default()
726 };
727
728 let probe = OpenAI::new(config).unwrap();
729 let result = probe.probe().await;
730
731 assert_eq!(result, ProbeResult::Success);
732
733 mock.assert();
734 }
735
736 #[tokio::test]
737 async fn test_openai_http_error_response() {
738 let server = MockServer::start();
739
740 let mock = server.mock(|when, then| {
742 when.method(POST).path("/v1/embeddings");
743 then.status(420).json_body(json!({
744 "error": {"message": "Internal server error"}
745 }));
746 });
747
748 let config = Config {
749 server_url: server.base_url(),
750 endpoint_type: Type::OpenAIEmbedding,
751 model_name: "text-embedding-ada-002".to_string(),
752 ..Default::default()
753 };
754
755 let probe = OpenAI::new(config).unwrap();
756 let result = probe.probe().await;
757
758 match result {
759 ProbeResult::Error(status_code) => {
760 assert_eq!(status_code, 420);
761 }
762 _ => panic!("Expected HTTP error probe result"),
763 }
764
765 mock.assert();
766 }
767
768 #[tokio::test]
769 async fn test_openai_timeout() {
770 let config = Config {
771 server_url: "http://10.255.255.1:12345".to_string(), timeout_seconds: 1, ..Default::default()
774 };
775
776 let probe = OpenAI::new(config).unwrap();
777 let result = probe.probe().await;
778
779 assert!(matches!(result, ProbeResult::Timeout));
780 }
781
782 #[tokio::test]
783 async fn test_openai_network_error() {
784 let config = Config {
785 server_url: "http://localhost:99999".to_string(), ..Default::default()
787 };
788
789 let probe = OpenAI::new(config).unwrap();
790 let result = probe.probe().await;
791
792 match result {
793 ProbeResult::NetworkError(error) => {
794 assert!(!error.is_empty());
795 }
796 _ => panic!("Expected network error probe result"),
797 }
798 }
799
800 use std::fs;
802 use tempfile::TempDir;
803
804 #[test]
805 fn test_newman_probe_creation() {
806 let config = Config {
807 endpoint_type: Type::Newman,
808 collection_path: "test-collection.json".to_string(),
809 environment_path: Some("test-environment.json".to_string()),
810 ..Default::default()
811 };
812 let probe = Newman::new(config);
813 assert!(probe.is_ok());
814 }
815
816 #[tokio::test]
817 async fn test_newman_probe_with_mock_endpoints() {
818 let server = MockServer::start();
819 let temp_dir = TempDir::new().unwrap();
820
821 let health_mock = server.mock(|when, then| {
823 when.method(GET).path("/health");
824 then.status(200).json_body(json!({"status": "ok"}));
825 });
826
827 let user_mock = server.mock(|when, then| {
828 when.method(GET)
829 .path("/api/v1/users/123")
830 .header("Authorization", "Bearer test-token-12345");
831 then.status(200)
832 .json_body(json!({"id": 123, "name": "Test User"}));
833 });
834
835 let create_mock = server.mock(|when, then| {
836 when.method(POST)
837 .path("/api/v1/resources")
838 .header("Authorization", "Bearer test-token-12345")
839 .header("Content-Type", "application/json");
840 then.status(201).json_body(json!({
841 "id": 789,
842 "name": "Test Resource",
843 "description": "Created at 1234567890",
844 "active": true
845 }));
846 });
847
848 let delete_mock = server.mock(|when, then| {
849 when.method(DELETE)
850 .path("/api/v1/resources/456")
851 .header("Authorization", "Bearer test-token-12345");
852 then.status(204);
853 });
854
855 let collection_path = temp_dir.path().join("collection.json");
857 let collection_content = fs::read_to_string("test-collection.json")
858 .unwrap_or_else(|_| include_str!("../test-collection.json").to_string());
859 fs::write(&collection_path, collection_content).unwrap();
860
861 let environment_path = temp_dir.path().join("environment.json");
863 let environment_content = json!({
864 "id": "test-env",
865 "name": "Test Environment",
866 "values": [
867 {
868 "key": "base_url",
869 "value": server.base_url(),
870 "enabled": true,
871 "type": "default"
872 },
873 {
874 "key": "api_token",
875 "value": "test-token-12345",
876 "enabled": true,
877 "type": "secret"
878 }
879 ],
880 "_postman_variable_scope": "environment"
881 });
882 fs::write(&environment_path, environment_content.to_string()).unwrap();
883
884 let config = Config {
885 endpoint_type: Type::Newman,
886 collection_path: collection_path.to_str().unwrap().to_string(),
887 environment_path: Some(environment_path.to_str().unwrap().to_string()),
888 ..Default::default()
889 };
890
891 let probe = Newman::new(config).unwrap();
892 let result = probe.probe().await;
893
894 assert_eq!(result, ProbeResult::Success);
896
897 health_mock.assert();
899 user_mock.assert();
900 create_mock.assert();
901 delete_mock.assert();
902 }
903
904 #[tokio::test]
905 async fn test_newman_probe_with_failed_test() {
906 let server = MockServer::start();
907 let temp_dir = TempDir::new().unwrap();
908
909 let health_mock = server.mock(|when, then| {
911 when.method(GET).path("/health");
912 then.status(500).json_body(json!({"error": "Server error"}));
913 });
914
915 let collection_path = temp_dir.path().join("collection.json");
917 let collection_content = json!({
918 "info": {
919 "name": "Test Collection",
920 "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
921 },
922 "item": [{
923 "name": "Health Check",
924 "event": [{
925 "listen": "test",
926 "script": {
927 "exec": [
928 "pm.test(\"Status code is 200\", function () {",
929 " pm.response.to.have.status(200);",
930 "});"
931 ],
932 "type": "text/javascript"
933 }
934 }],
935 "request": {
936 "method": "GET",
937 "url": "{{base_url}}/health"
938 }
939 }]
940 });
941 fs::write(&collection_path, collection_content.to_string()).unwrap();
942
943 let environment_path = temp_dir.path().join("environment.json");
945 let environment_content = json!({
946 "id": "test-env",
947 "name": "Test Environment",
948 "values": [{
949 "key": "base_url",
950 "value": server.base_url(),
951 "enabled": true,
952 "type": "default"
953 }],
954 "_postman_variable_scope": "environment"
955 });
956 fs::write(&environment_path, environment_content.to_string()).unwrap();
957
958 let config = Config {
959 endpoint_type: Type::Newman,
960 collection_path: collection_path.to_str().unwrap().to_string(),
961 environment_path: Some(environment_path.to_str().unwrap().to_string()),
962 ..Default::default()
963 };
964
965 let probe = Newman::new(config).unwrap();
966 let result = probe.probe().await;
967
968 assert_eq!(result, ProbeResult::Error(1));
970
971 health_mock.assert();
972 }
973
974 #[tokio::test]
975 async fn test_newman_probe_without_environment() {
976 let server = MockServer::start();
977 let temp_dir = TempDir::new().unwrap();
978
979 let health_mock = server.mock(|when, then| {
981 when.method(GET).path("/health");
982 then.status(200).json_body(json!({"status": "ok"}));
983 });
984
985 let collection_path = temp_dir.path().join("collection.json");
987 let collection_content = json!({
988 "info": {
989 "name": "Test Collection",
990 "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
991 },
992 "item": [{
993 "name": "Health Check",
994 "event": [{
995 "listen": "test",
996 "script": {
997 "exec": [
998 "pm.test(\"Status code is 200\", function () {",
999 " pm.response.to.have.status(200);",
1000 "});"
1001 ],
1002 "type": "text/javascript"
1003 }
1004 }],
1005 "request": {
1006 "method": "GET",
1007 "url": format!("{}/health", server.base_url())
1008 }
1009 }]
1010 });
1011 fs::write(&collection_path, collection_content.to_string()).unwrap();
1012
1013 let config = Config {
1014 endpoint_type: Type::Newman,
1015 collection_path: collection_path.to_str().unwrap().to_string(),
1016 environment_path: None, ..Default::default()
1018 };
1019
1020 let probe = Newman::new(config).unwrap();
1021 let result = probe.probe().await;
1022
1023 assert_eq!(result, ProbeResult::Success);
1024 health_mock.assert();
1025 }
1026 }
1027}
1028
1029#[cfg(test)]
1030mod tests {
1031 use super::{Monitor, cli::Config};
1032 use httpmock::prelude::*;
1033 use serde_json::json;
1034
1035 #[tokio::test]
1036 async fn test_monitor_creation() {
1037 let config = Config::default();
1038 let monitor = Monitor::new(config);
1039 assert!(monitor.is_ok());
1040 }
1041
1042 #[tokio::test]
1043 async fn test_monitor_run_success() {
1044 let server = MockServer::start();
1045
1046 let llm_mock = server.mock(|when, then| {
1048 when.method(POST).path("/v1/chat/completions");
1049 then.status(200).json_body(
1050 json!({"choices": [{"message": {"role": "assistant", "content": "OK"}}]}),
1051 );
1052 });
1053
1054 let cronitor_run_mock = server.mock(|when, then| {
1056 when.method(GET)
1057 .path("/test-monitor")
1058 .query_param("state", "run");
1059 then.status(200);
1060 });
1061
1062 let cronitor_complete_mock = server.mock(|when, then| {
1063 when.method(GET)
1064 .path("/test-monitor")
1065 .query_param("state", "complete");
1066 then.status(200);
1067 });
1068
1069 let config = Config {
1070 cronitor_base_url: server.base_url(),
1071 server_url: server.base_url(),
1072 ..Default::default()
1073 };
1074
1075 let monitor = Monitor::new(config).unwrap();
1076 let exit_code = monitor.run().await;
1077
1078 assert_eq!(exit_code, 0);
1079 llm_mock.assert();
1080 cronitor_run_mock.assert();
1081 cronitor_complete_mock.assert();
1082 }
1083
1084 #[tokio::test]
1085 async fn test_monitor_run_http_error() {
1086 let server = MockServer::start();
1087
1088 let llm_mock = server.mock(|when, then| {
1090 when.method(POST).path("/v1/chat/completions");
1091 then.status(500)
1092 .json_body(json!({"error": {"message": "Server error"}}));
1093 });
1094
1095 let cronitor_run_mock = server.mock(|when, then| {
1097 when.method(GET)
1098 .path("/test-monitor")
1099 .query_param("state", "run");
1100 then.status(200);
1101 });
1102
1103 let cronitor_fail_mock = server.mock(|when, then| {
1104 when.method(GET)
1105 .path("/test-monitor")
1106 .query_param("state", "fail")
1107 .query_param("status_code", "500");
1108 then.status(200);
1109 });
1110
1111 let config = Config {
1112 cronitor_base_url: server.base_url(),
1113 server_url: server.base_url(),
1114 ..Default::default()
1115 };
1116
1117 let monitor = Monitor::new(config).unwrap();
1118 let exit_code = monitor.run().await;
1119
1120 assert_eq!(exit_code, 1);
1121 llm_mock.assert();
1122 cronitor_run_mock.assert();
1123 cronitor_fail_mock.assert();
1124 }
1125
1126 #[tokio::test]
1127 async fn test_monitor_run_timeout() {
1128 let server = MockServer::start();
1129
1130 let cronitor_run_mock = server.mock(|when, then| {
1132 when.method(GET)
1133 .path("/test-monitor")
1134 .query_param("state", "run");
1135 then.status(200);
1136 });
1137
1138 let cronitor_fail_mock = server.mock(|when, then| {
1139 when.method(GET)
1140 .path("/test-monitor")
1141 .query_param("state", "fail")
1142 .query_param("status_code", "124")
1143 .query_param("message", "Request timeout");
1144 then.status(200);
1145 });
1146
1147 let config = Config {
1148 cronitor_base_url: server.base_url(),
1149 server_url: "http://10.255.255.1:12345".to_string(), timeout_seconds: 1,
1151 ..Default::default()
1152 };
1153
1154 let monitor = Monitor::new(config).unwrap();
1155 let exit_code = monitor.run().await;
1156
1157 assert_eq!(exit_code, 124); cronitor_run_mock.assert();
1159 cronitor_fail_mock.assert();
1160 }
1161
1162 #[tokio::test]
1163 async fn test_monitor_run_network_error() {
1164 let server = MockServer::start();
1165
1166 let cronitor_run_mock = server.mock(|when, then| {
1168 when.method(GET)
1169 .path("/test-monitor")
1170 .query_param("state", "run");
1171 then.status(200);
1172 });
1173
1174 let cronitor_fail_mock = server.mock(|when, then| {
1175 when.method(GET)
1176 .path("/test-monitor")
1177 .query_param("state", "fail")
1178 .query_param("status_code", "1")
1179 .query_param_exists("message"); then.status(200);
1181 });
1182
1183 let config = Config {
1184 cronitor_base_url: server.base_url(),
1185 server_url: "http://localhost:99999".to_string(), ..Default::default()
1187 };
1188
1189 let monitor = Monitor::new(config).unwrap();
1190 let exit_code = monitor.run().await;
1191
1192 assert_eq!(exit_code, 1); cronitor_run_mock.assert();
1194 cronitor_fail_mock.assert();
1195 }
1196
1197 #[tokio::test]
1198 async fn test_monitor_cronitor_message_validation() {
1199 let server = MockServer::start();
1200
1201 let cronitor_run_mock = server.mock(|when, then| {
1203 when.method(GET)
1204 .path("/test-monitor")
1205 .query_param("state", "run")
1206 .query_param("status_code", "0")
1207 .query_param("env", "test");
1208 then.status(200);
1209 });
1210
1211 let cronitor_timeout_mock = server.mock(|when, then| {
1212 when.method(GET)
1213 .path("/test-monitor")
1214 .query_param("state", "fail")
1215 .query_param("status_code", "124")
1216 .query_param("message", "Request timeout")
1217 .query_param("env", "test");
1218 then.status(200);
1219 });
1220
1221 let config = Config {
1222 cronitor_base_url: server.base_url(),
1223 server_url: "http://10.255.255.1:12345".to_string(), timeout_seconds: 1,
1225 ..Default::default()
1226 };
1227
1228 let monitor = Monitor::new(config).unwrap();
1229 let exit_code = monitor.run().await;
1230
1231 assert_eq!(exit_code, 124);
1232 cronitor_run_mock.assert();
1233 cronitor_timeout_mock.assert();
1234 }
1235}