diff --git a/src/commands/monitors/run.rs b/src/commands/monitors/run.rs index 069aa79816..b85f8dc647 100644 --- a/src/commands/monitors/run.rs +++ b/src/commands/monitors/run.rs @@ -1,17 +1,28 @@ #![expect(clippy::unwrap_used, reason = "contains legacy code which uses unwrap")] use chrono_tz::Tz; -use std::process; +use std::borrow::Cow; +use std::collections::BTreeMap; +use std::io::{self, Read as _, Write as _}; +use std::process::{self, Stdio}; +use std::sync::{Arc, Mutex}; +use std::thread; use std::time::{Duration, Instant}; use uuid::Uuid; use anyhow::Result; use clap::{Arg, ArgMatches, Command}; use console::style; +use serde_json::Value; -use sentry::protocol::{MonitorCheckIn, MonitorCheckInStatus, MonitorConfig, MonitorSchedule}; +use sentry::protocol::{ + Context, Event, Level, LogEntry, MonitorConfig, MonitorSchedule, TraceContext, +}; +use sentry::Envelope; use crate::api::envelopes_api::EnvelopesApi; +use crate::commands::send_event::send_raw_event; +use crate::utils::event::get_sdk_info; use crate::utils::system::QuietExit; use crate::utils::value_parsers::auth_token_parser; @@ -98,6 +109,12 @@ pub fn make_command(command: Command) -> Command { issue. Requires --schedule.", ), ) + .arg( + Arg::new("with_output") + .long("with-output") + .action(clap::ArgAction::SetTrue) + .help("Capture command output (stdout and stderr combined)."), + ) // Hide auth token from --help output .arg( Arg::new("auth_token") @@ -129,52 +146,232 @@ fn run_program(args: Vec<&String>, monitor_slug: &str) -> (bool, Option, Du (success, code, elapsed) } +fn run_program_with_output( + args: Vec<&String>, + monitor_slug: &str, +) -> (bool, Option, Duration, Vec) { + let started = Instant::now(); + let mut p = process::Command::new(args[0]); + p.args(&args[1..]); + p.env("SENTRY_MONITOR_SLUG", monitor_slug); + p.stdout(Stdio::piped()); + p.stderr(Stdio::piped()); + + let (success, code, output) = match p.spawn() { + Ok(mut child) => { + let combined = Arc::new(Mutex::new(Vec::new())); + + let mut stdout_pipe = child.stdout.take(); + let mut stderr_pipe = child.stderr.take(); + + let out_handle = stdout_pipe.take().map(|mut pipe| { + let combined = Arc::clone(&combined); + thread::spawn(move || { + let mut buf = [0u8; 8192]; + loop { + match pipe.read(&mut buf) { + Ok(0) => break, + Ok(n) => { + let _ = io::stdout().write_all(&buf[..n]); + let _ = io::stdout().flush(); + combined.lock().unwrap().extend_from_slice(&buf[..n]); + } + Err(_) => break, + } + } + }) + }); + + let err_handle = stderr_pipe.take().map(|mut pipe| { + let combined = Arc::clone(&combined); + thread::spawn(move || { + let mut buf = [0u8; 8192]; + loop { + match pipe.read(&mut buf) { + Ok(0) => break, + Ok(n) => { + let _ = io::stderr().write_all(&buf[..n]); + let _ = io::stderr().flush(); + combined.lock().unwrap().extend_from_slice(&buf[..n]); + } + Err(_) => break, + } + } + }) + }); + + let (success, code) = match child.wait() { + Ok(status) => (status.success(), status.code()), + Err(err) => { + eprintln!( + "{} could not invoke program '{}': {err}", + style("error").red(), + args[0] + ); + (false, None) + } + }; + + if let Some(h) = out_handle { + let _ = h.join(); + } + if let Some(h) = err_handle { + let _ = h.join(); + } + + let output = combined.lock().unwrap().clone(); + (success, code, output) + } + Err(err) => { + eprintln!( + "{} could not invoke program '{}': {err}", + style("error").red(), + args[0] + ); + (false, None, Vec::new()) + } + }; + + let elapsed = started.elapsed(); + (success, code, elapsed, output) +} + +#[allow(clippy::allow_attributes, clippy::too_many_arguments)] +fn send_checkin_with_trace( + envelopes_api: &EnvelopesApi, + check_in_id: Uuid, + monitor_slug: &str, + status: &str, + environment: &str, + trace_id: Uuid, + duration: Option, + monitor_config: Option<&MonitorConfig>, +) { + let mut checkin = serde_json::json!({ + "check_in_id": check_in_id.to_string().replace('-', ""), + "monitor_slug": monitor_slug, + "status": status, + "environment": environment, + "contexts": { + "trace": { + "trace_id": trace_id.to_string().replace('-', "") + } + } + }); + if let Some(d) = duration { + checkin["duration"] = serde_json::json!(d); + } + if let Some(config) = monitor_config { + if let Ok(config_json) = serde_json::to_value(config) { + checkin["monitor_config"] = config_json; + } + } + let payload = checkin.to_string(); + let envelope_id = Uuid::new_v4(); + let mut buf = Vec::new(); + use std::io::Write as _; + let _ = writeln!(buf, r#"{{"event_id":"{envelope_id}"}}"#); + let _ = writeln!(buf, r#"{{"type":"check_in","length":{}}}"#, payload.len()); + buf.extend_from_slice(payload.as_bytes()); + if let Ok(envelope) = Envelope::from_bytes_raw(buf) { + if let Err(e) = envelopes_api.send_envelope(envelope) { + log::error!("Failed to send check-in envelope: {e}"); + } + } +} + fn execute_checkin( args: Vec<&String>, monitor_slug: &str, environment: &str, monitor_config: Option, + with_output: bool, ) -> Result<(bool, Option)> { let check_in_id = Uuid::new_v4(); - - let open_checkin = MonitorCheckIn { - check_in_id, - monitor_slug: monitor_slug.to_owned(), - status: MonitorCheckInStatus::InProgress, - duration: None, - environment: Some(environment.to_owned()), - monitor_config, - }; + let trace_id = Uuid::new_v4(); let envelopes_api = EnvelopesApi::try_new()?; - if let Err(e) = envelopes_api.send_envelope(open_checkin) { - log::error!("Failed to send in-progress check-in envelope: {e}"); - log::info!("Continuing to run program..."); - } - - let (success, code, elapsed) = run_program(args, monitor_slug); + send_checkin_with_trace( + &envelopes_api, + check_in_id, + monitor_slug, + "in_progress", + environment, + trace_id, + None, + monitor_config.as_ref(), + ); - let status = if success { - MonitorCheckInStatus::Ok + let (success, code, elapsed, output) = if with_output { + let (s, c, e, o) = run_program_with_output(args, monitor_slug); + (s, c, e, Some(o)) } else { - MonitorCheckInStatus::Error + let (s, c, e) = run_program(args, monitor_slug); + (s, c, e, None) }; + let status = if success { "ok" } else { "error" }; let duration = Some(elapsed.as_secs_f64()); - let close_checkin = MonitorCheckIn { + send_checkin_with_trace( + &envelopes_api, check_in_id, - monitor_slug: monitor_slug.to_owned(), + monitor_slug, status, + environment, + trace_id, duration, - environment: Some(environment.to_owned()), - monitor_config: None, - }; + None, + ); + + if !success { + if let Some(output) = output { + let output_str = String::from_utf8_lossy(&output); - if let Err(e) = envelopes_api.send_envelope(close_checkin) { - log::error!("Failed to send final check-in envelope: {e}"); - log::info!("Continuing to exit with program's exit code..."); + let mut monitor_ctx = BTreeMap::new(); + monitor_ctx.insert("id".into(), Value::String(monitor_slug.to_owned())); + monitor_ctx.insert("check_in_id".into(), Value::String(check_in_id.to_string())); + + let mut contexts = BTreeMap::new(); + contexts.insert("monitor".into(), Context::Other(monitor_ctx)); + + let trace_bytes: [u8; 16] = *trace_id.as_bytes(); + let span_bytes: [u8; 8] = trace_bytes[..8].try_into().unwrap(); + let trace_ctx = TraceContext { + trace_id: trace_bytes.into(), + span_id: span_bytes.into(), + ..TraceContext::default() + }; + contexts.insert("trace".into(), Context::Trace(Box::new(trace_ctx))); + + let mut title = match code { + Some(n) => format!("Cron failed (exit code {n})"), + None => "Cron failed".to_string(), + }; + title.push_str("\n\n"); + + let mut message = output_str.into_owned(); + message.insert_str(0, &title); + + let event = Event { + logentry: Some(LogEntry { + message, + params: vec![], + }), + transaction: Some(monitor_slug.to_owned()), + level: Level::Error, + environment: Some(Cow::Owned(environment.to_owned())), + sdk: Some(get_sdk_info()), + contexts, + ..Event::default() + }; + + match send_raw_event(event) { + Ok(event_id) => log::info!("Monitor output event sent: {event_id}"), + Err(e) => log::error!("Failed to send monitor output event: {e}"), + } + } } Ok((success, code)) @@ -200,8 +397,10 @@ pub fn execute(matches: &ArgMatches) -> Result<()> { let monitor_slug = matches.get_one::("monitor_slug").unwrap(); let environment = matches.get_one::("environment").unwrap(); let monitor_config = parse_monitor_config_args(matches)?; + let with_output = matches.get_flag("with_output"); - let (success, code) = execute_checkin(args, monitor_slug, environment, monitor_config)?; + let (success, code) = + execute_checkin(args, monitor_slug, environment, monitor_config, with_output)?; if !success { return Err(QuietExit(code.unwrap_or(1)).into()); diff --git a/tests/integration/_cases/monitors/monitors-run-help.trycmd b/tests/integration/_cases/monitors/monitors-run-help.trycmd index ffd7884ee3..27b16219d2 100644 --- a/tests/integration/_cases/monitors/monitors-run-help.trycmd +++ b/tests/integration/_cases/monitors/monitors-run-help.trycmd @@ -37,6 +37,8 @@ Options: --schedule. --recovery-threshold The number of consecutive successful check-ins that resolve an issue. Requires --schedule. + --with-output + Capture command output (stdout and stderr combined). -h, --help Print help diff --git a/tests/integration/_cases/monitors/server_error/monitors-run-server-error-win.trycmd b/tests/integration/_cases/monitors/server_error/monitors-run-server-error-win.trycmd index 745aad7ac5..de57f7437b 100644 --- a/tests/integration/_cases/monitors/server_error/monitors-run-server-error-win.trycmd +++ b/tests/integration/_cases/monitors/server_error/monitors-run-server-error-win.trycmd @@ -1,8 +1,8 @@ ``` $ sentry-cli monitors run foo-monitor -- cmd.exe /C echo 123 ? success -[..]ERROR[..]Failed to send in-progress check-in envelope[..] +[..]ERROR[..]Failed to send check-in envelope[..] 123 -[..]ERROR[..]Failed to send final check-in envelope[..] +[..]ERROR[..]Failed to send check-in envelope[..] ``` diff --git a/tests/integration/_cases/monitors/server_error/monitors-run-server-error.trycmd b/tests/integration/_cases/monitors/server_error/monitors-run-server-error.trycmd index 33d5c1cd4a..aaaed77230 100644 --- a/tests/integration/_cases/monitors/server_error/monitors-run-server-error.trycmd +++ b/tests/integration/_cases/monitors/server_error/monitors-run-server-error.trycmd @@ -1,8 +1,8 @@ ``` $ sentry-cli monitors run foo-monitor -- echo 123 ? success -[..]ERROR[..]Failed to send in-progress check-in envelope[..] +[..]ERROR[..]Failed to send check-in envelope[..] 123 -[..]ERROR[..]Failed to send final check-in envelope[..] +[..]ERROR[..]Failed to send check-in envelope[..] ```