From bfd4bb53ec64b09a95a660759757186c2c3eb582 Mon Sep 17 00:00:00 2001 From: Noemi <45180344+unflxw@users.noreply.github.com> Date: Mon, 18 Nov 2024 12:46:16 +0100 Subject: [PATCH 1/5] Report error when process fails to start When starting the child process fails and error reporting is enabled, report the failure to start the process as an error to AppSignal. Fixes #22. --- ...rt-exit-failures-as-errors-to-appsignal.md | 12 ++ src/error.rs | 129 +++++++++++++++--- src/main.rs | 26 +++- 3 files changed, 141 insertions(+), 26 deletions(-) create mode 100644 .changesets/report-exit-failures-as-errors-to-appsignal.md diff --git a/.changesets/report-exit-failures-as-errors-to-appsignal.md b/.changesets/report-exit-failures-as-errors-to-appsignal.md new file mode 100644 index 0000000..a9088bc --- /dev/null +++ b/.changesets/report-exit-failures-as-errors-to-appsignal.md @@ -0,0 +1,12 @@ +--- +bump: patch +type: add +--- + +Report exit failures as errors to AppSignal. Use the `--error` command-line option to report an error to AppSignal when the command exits with a non-zero status code, or when the command fails to start: + +``` +appsignal-wrap --error backup -- ./backup.sh +``` + +The name given as the value to the `--error` command-line option will be used to group the errors in AppSignal. diff --git a/src/error.rs b/src/error.rs index 6416e0c..e1bad9b 100644 --- a/src/error.rs +++ b/src/error.rs @@ -19,21 +19,41 @@ pub struct ErrorConfig { } impl ErrorConfig { - pub fn request( - &self, - timestamp: &mut impl Timestamp, - exit: &ExitStatus, - lines: impl IntoIterator, - ) -> Result { + pub fn request(&self, body: impl Into) -> Result { let url = format!("{}/errors", self.endpoint); client() .post(url) .query(&[("api_key", &self.api_key)]) .header("Content-Type", "application/json") - .body(ErrorBody::from_config(self, timestamp, exit, lines)) + .body(body) .build() } + + pub fn request_from_spawn( + &self, + timestamp: &mut impl Timestamp, + error: &std::io::Error, + ) -> Result { + self.request(ErrorBody::from_spawn(self, timestamp, error)) + } + + pub fn request_from_exit( + &self, + timestamp: &mut impl Timestamp, + exit: &ExitStatus, + lines: impl IntoIterator, + ) -> Result { + self.request(ErrorBody::from_exit(self, timestamp, exit, lines)) + } + + fn tags(&self) -> BTreeMap { + [ + ("hostname".to_string(), self.hostname.clone()), + (format!("{}-digest", NAME), self.digest.clone()), + ] + .into() + } } #[derive(Serialize)] @@ -46,26 +66,42 @@ pub struct ErrorBody { } impl ErrorBody { - pub fn from_config( + pub fn new( config: &ErrorConfig, timestamp: &mut impl Timestamp, - exit: &ExitStatus, - lines: impl IntoIterator, + error: ErrorBodyError, + tags: impl IntoIterator, ) -> Self { ErrorBody { timestamp: timestamp.as_secs(), action: config.action.clone(), namespace: "process".to_string(), - error: ErrorBodyError::new(exit, lines), - tags: exit_tags(exit) - .into_iter() - .chain([ - ("hostname".to_string(), config.hostname.clone()), - (format!("{}-digest", NAME), config.digest.clone()), - ]) - .collect(), + error, + tags: tags.into_iter().chain(config.tags()).collect(), } } + + pub fn from_spawn( + config: &ErrorConfig, + timestamp: &mut impl Timestamp, + error: &std::io::Error, + ) -> Self { + Self::new(config, timestamp, ErrorBodyError::from_spawn(error), vec![]) + } + + pub fn from_exit( + config: &ErrorConfig, + timestamp: &mut impl Timestamp, + exit: &ExitStatus, + lines: impl IntoIterator, + ) -> Self { + Self::new( + config, + timestamp, + ErrorBodyError::from_exit(exit, lines), + exit_tags(exit), + ) + } } impl From for Body { @@ -81,7 +117,14 @@ pub struct ErrorBodyError { } impl ErrorBodyError { - pub fn new(exit: &ExitStatus, lines: impl IntoIterator) -> Self { + pub fn from_spawn(error: &std::io::Error) -> Self { + ErrorBodyError { + name: "StartError".to_string(), + message: format!("[Error starting process: {}]", error), + } + } + + pub fn from_exit(exit: &ExitStatus, lines: impl IntoIterator) -> Self { let (name, exit_context) = if let Some(code) = exit.code() { ("NonZeroExit".to_string(), format!("code {}", code)) } else if let Some(signal) = exit.signal() { @@ -136,7 +179,49 @@ mod tests { } #[test] - fn error_config_request() { + fn error_config_request_from_spawn() { + let config = error_config(); + let error = std::io::Error::new( + std::io::ErrorKind::NotFound, + "No such file or directory (os error 2)", + ); + + let request = config.request_from_spawn(&mut timestamp(), &error).unwrap(); + + assert_eq!(request.method().as_str(), "POST"); + assert_eq!( + request.url().as_str(), + "https://some-endpoint.com/errors?api_key=some_api_key" + ); + assert_eq!( + request.headers().get("Content-Type").unwrap(), + "application/json" + ); + assert_eq!( + String::from_utf8_lossy(request.body().unwrap().as_bytes().unwrap()), + format!( + concat!( + "{{", + r#""timestamp":{},"#, + r#""action":"some-action","#, + r#""namespace":"process","#, + r#""error":{{"#, + r#""name":"StartError","#, + r#""message":"[Error starting process: No such file or directory (os error 2)]""#, + r#"}},"#, + r#""tags":{{"#, + r#""{}-digest":"some-digest","#, + r#""hostname":"some-hostname""#, + r#"}}"#, + "}}" + ), + EXPECTED_SECS, NAME + ) + ); + } + + #[test] + fn error_config_request_from_exit() { let config = error_config(); // `ExitStatus::from_raw` expects a wait status, not an exit status. // The wait status for exit code `n` is represented by `n << 8`. @@ -144,7 +229,9 @@ mod tests { let exit = ExitStatus::from_raw(42 << 8); let lines = vec!["line 1".to_string(), "line 2".to_string()]; - let request = config.request(&mut timestamp(), &exit, lines).unwrap(); + let request = config + .request_from_exit(&mut timestamp(), &exit, lines) + .unwrap(); assert_eq!(request.method().as_str(), "POST"); assert_eq!( diff --git a/src/main.rs b/src/main.rs index cab60cb..cbd2a95 100644 --- a/src/main.rs +++ b/src/main.rs @@ -56,7 +56,10 @@ fn main() { match start(cli) { Ok(code) => exit(code), - Err(err) => error!("{}", err), + Err(err) => { + error!("{}", err); + exit(1); + } } } @@ -68,7 +71,20 @@ async fn start(cli: Cli) -> Result> { let tasks = TaskTracker::new(); - let (child, stdout, stderr) = spawn_child(&cli, &tasks)?; + let (child, stdout, stderr) = match spawn_child(&cli, &tasks) { + Ok(spawned_child) => spawned_child, + Err(err) => { + if let Some(config) = error { + tasks.spawn(send_request( + config.request_from_spawn(&mut SystemTimestamp, &err), + )); + tasks.close(); + tasks.wait().await; + } + + return Err(format!("could not spawn child process: {err}").into()); + } + }; let (log_stdout, error_stdout) = maybe_spawn_tee(stdout); let (log_stderr, error_stderr) = maybe_spawn_tee(stderr); @@ -106,7 +122,7 @@ async fn start(cli: Cli) -> Result> { )); } } else if let Some(error) = error { - tasks.spawn(send_error_request( + tasks.spawn(send_error_exit_request( error, exit_status, error_message.unwrap(), @@ -412,7 +428,7 @@ async fn forward_signals_and_wait(mut child: Child) -> io::Result { } } -async fn send_error_request( +async fn send_error_exit_request( error: ErrorConfig, exit_status: ExitStatus, receiver: oneshot::Receiver>, @@ -425,7 +441,7 @@ async fn send_error_request( } }; - send_request(error.request(&mut SystemTimestamp, &exit_status, lines)).await; + send_request(error.request_from_exit(&mut SystemTimestamp, &exit_status, lines)).await; } fn command(argv: &[String], should_stdout: bool, should_stderr: bool) -> Command { From 89a34cff89ab470878f3935fec37bbf2621b148c Mon Sep 17 00:00:00 2001 From: Noemi <45180344+unflxw@users.noreply.github.com> Date: Mon, 18 Nov 2024 12:56:31 +0100 Subject: [PATCH 2/5] Fix warnings to account for `--error` Using `--no-log` without either `--cron` or `--heartbeat` is now a reasonable usage if `--error` is in the mix. --- src/cli.rs | 45 ++++++++++++++++++++++++++++++++++----------- 1 file changed, 34 insertions(+), 11 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index c2d776a..7e8a8fc 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -176,10 +176,11 @@ impl Cli { } } - fn no_log_and_no_checkins_warning(&self) -> Option { + fn no_log_and_no_data_warning(&self) -> Option { let no_checkins: bool = self.cron.is_none() && self.heartbeat.is_none(); + let no_errors: bool = self.error.is_none(); - if no_checkins { + if no_checkins && no_errors { let using: Option<&str> = if self.no_log { Some("--no-log") } else if self.no_stdout && self.no_stderr { @@ -190,7 +191,7 @@ impl Cli { if let Some(using) = using { return Some(format!( - "using {using} without either --cron or --heartbeat; \ + "using {using} without either --cron, --heartbeat or --error; \ no data will be sent to AppSignal" )); } @@ -206,7 +207,7 @@ impl Cli { warnings.push(warning); } - if let Some(warning) = self.no_log_and_no_checkins_warning() { + if let Some(warning) = self.no_log_and_no_data_warning() { warnings.push(warning); } @@ -378,15 +379,33 @@ mod tests { } #[test] - fn cli_warnings_no_log_and_no_checkins() { - for (args, warning) in [( + fn cli_warnings_no_log_and_no_data() { + for (args, warning) in [ + ( vec!["--no-log"], - "using --no-log without either --cron or --heartbeat; no data will be sent to AppSignal" + Some("using --no-log without either --cron, --heartbeat or --error; no data will be sent to AppSignal") ), ( vec!["--no-stdout", "--no-stderr"], - "using --no-stdout and --no-stderr without either --cron or --heartbeat; no data will be sent to AppSignal" - )] { + Some("using --no-stdout and --no-stderr without either --cron, --heartbeat or --error; no data will be sent to AppSignal") + ), + ( + vec!["--no-log", "--no-stdout", "--no-stderr"], + Some("using --no-log without either --cron, --heartbeat or --error; no data will be sent to AppSignal") + ), + ( + vec!["--no-log", "--cron", "some-cron"], + None + ), + ( + vec!["--no-log", "--heartbeat", "some-hearttbeat"], + None + ), + ( + vec!["--no-log", "--error", "some-error"], + None + ) + ] { let cli = Cli::try_parse_from( with_required_args(args) @@ -394,8 +413,12 @@ mod tests { let warnings = cli.warnings(); - assert_eq!(warnings.len(), 1); - assert_eq!(warnings[0], warning); + if let Some(warning) = warning { + assert_eq!(warnings.len(), 1); + assert_eq!(warnings[0], warning); + } else { + assert!(warnings.is_empty()); + } } } From c7cc139d35eb4fd6938a1222c3db8e5099514022 Mon Sep 17 00:00:00 2001 From: Noemi <45180344+unflxw@users.noreply.github.com> Date: Mon, 18 Nov 2024 13:01:46 +0100 Subject: [PATCH 3/5] Add command as tag for error and logs Add the command that was ran as the child process as a tag in the errors reported to AppSignal, and as an attribute on the log lines that are sent to AppSignal. Fixes #21. --- ...-command-as-error-tag-and-log-attribute.md | 6 ++++ src/cli.rs | 14 ++++++-- src/error.rs | 5 +++ src/log.rs | 32 +++++++++++++------ 4 files changed, 44 insertions(+), 13 deletions(-) create mode 100644 .changesets/add-command-as-error-tag-and-log-attribute.md diff --git a/.changesets/add-command-as-error-tag-and-log-attribute.md b/.changesets/add-command-as-error-tag-and-log-attribute.md new file mode 100644 index 0000000..f1a055d --- /dev/null +++ b/.changesets/add-command-as-error-tag-and-log-attribute.md @@ -0,0 +1,6 @@ +--- +bump: patch +type: add +--- + +Add command as error tag and log attribute. When reporting log lines or errors, add the command that was used to spawn the child process (or to attempt to) as a tag or attribute. diff --git a/src/cli.rs b/src/cli.rs index 7e8a8fc..978addd 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -258,7 +258,8 @@ impl Cli { let origin = self.log_origin(); let group = self.log.clone().unwrap_or_else(|| "process".to_string()); let hostname = self.hostname.clone(); - let digest: String = self.digest.clone(); + let digest = self.digest.clone(); + let command = self.command_as_str(); LogConfig { api_key, @@ -267,6 +268,7 @@ impl Cli { hostname, group, digest, + command, } } @@ -277,6 +279,7 @@ impl Cli { let action = action.clone(); let hostname = self.hostname.clone(); let digest = self.digest.clone(); + let command = self.command_as_str(); ErrorConfig { api_key, @@ -284,6 +287,7 @@ impl Cli { action, hostname, digest, + command, } }) } @@ -311,6 +315,10 @@ impl Cli { self.log_origin().is_out() } + + fn command_as_str(&self) -> String { + self.command.join(" ") + } } #[cfg(test)] @@ -398,14 +406,14 @@ mod tests { None ), ( - vec!["--no-log", "--heartbeat", "some-hearttbeat"], + vec!["--no-log", "--heartbeat", "some-heartbeat"], None ), ( vec!["--no-log", "--error", "some-error"], None ) - ] { + ] { let cli = Cli::try_parse_from( with_required_args(args) diff --git a/src/error.rs b/src/error.rs index e1bad9b..c50b02e 100644 --- a/src/error.rs +++ b/src/error.rs @@ -16,6 +16,7 @@ pub struct ErrorConfig { pub action: String, pub hostname: String, pub digest: String, + pub command: String, } impl ErrorConfig { @@ -51,6 +52,7 @@ impl ErrorConfig { [ ("hostname".to_string(), self.hostname.clone()), (format!("{}-digest", NAME), self.digest.clone()), + ("command".to_string(), self.command.clone()), ] .into() } @@ -175,6 +177,7 @@ mod tests { hostname: "some-hostname".to_string(), digest: "some-digest".to_string(), action: "some-action".to_string(), + command: "some-command".to_string(), } } @@ -211,6 +214,7 @@ mod tests { r#"}},"#, r#""tags":{{"#, r#""{}-digest":"some-digest","#, + r#""command":"some-command","#, r#""hostname":"some-hostname""#, r#"}}"#, "}}" @@ -256,6 +260,7 @@ mod tests { r#"}},"#, r#""tags":{{"#, r#""{}-digest":"some-digest","#, + r#""command":"some-command","#, r#""exit_code":"42","#, r#""exit_kind":"code","#, r#""hostname":"some-hostname""#, diff --git a/src/log.rs b/src/log.rs index d76b5fd..6b69eab 100644 --- a/src/log.rs +++ b/src/log.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::BTreeMap; use serde::Serialize; @@ -14,6 +14,7 @@ pub struct LogConfig { pub group: String, pub origin: LogOrigin, pub digest: String, + pub command: String, } impl LogConfig { @@ -27,6 +28,14 @@ impl LogConfig { .body(ndjson::to_string(messages).expect("failed to serialize log messages")) .build() } + + fn tags(&self) -> BTreeMap { + [ + (format!("{}-digest", NAME), self.digest.clone()), + ("command".to_string(), self.command.clone()), + ] + .into() + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -67,8 +76,8 @@ pub struct LogMessage { severity: LogSeverity, message: String, hostname: String, - #[serde(skip_serializing_if = "HashMap::is_empty")] - attributes: HashMap, + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + attributes: BTreeMap, } impl LogMessage { @@ -78,17 +87,13 @@ impl LogMessage { severity: LogSeverity, message: String, ) -> Self { - let mut attributes = HashMap::new(); - - attributes.insert(format!("{}-digest", NAME), config.digest.clone()); - Self { group: config.group.clone(), timestamp: timestamp.as_rfc3339(), severity, message, hostname: config.hostname.clone(), - attributes, + attributes: config.tags(), } } } @@ -113,6 +118,7 @@ mod tests { group: "some-group".to_string(), origin: LogOrigin::All, digest: "some-digest".to_string(), + command: "some-command".to_string(), } } @@ -153,7 +159,10 @@ mod tests { r#""severity":"info","#, r#""message":"first-message","#, r#""hostname":"some-hostname","#, - r#""attributes":{{"{}-digest":"some-digest"}}"#, + r#""attributes":{{"#, + r#""{}-digest":"some-digest","#, + r#""command":"some-command""#, + r#"}}"#, "}}\n", "{{", r#""group":"some-group","#, @@ -161,7 +170,10 @@ mod tests { r#""severity":"error","#, r#""message":"second-message","#, r#""hostname":"some-hostname","#, - r#""attributes":{{"{}-digest":"some-digest"}}"#, + r#""attributes":{{"#, + r#""{}-digest":"some-digest","#, + r#""command":"some-command""#, + r#"}}"#, "}}\n" ), EXPECTED_RFC3339, NAME, EXPECTED_RFC3339, NAME From 800bee421b4ce937c8347fd752b920365ab339e5 Mon Sep 17 00:00:00 2001 From: Noemi <45180344+unflxw@users.noreply.github.com> Date: Mon, 18 Nov 2024 15:22:15 +0100 Subject: [PATCH 4/5] Disallow using both cron and heartbeat Do not allow sending both cron check-ins and heartbeat check-ins. It is unclear that an use case for this exists, and with the new name argument, both would default to being sent for the same check-in, which wouldn't be meaningful for at least one of them. --- src/cli.rs | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 978addd..8102541 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -67,7 +67,7 @@ pub struct Cli { /// /// If this option is set, a heartbeat check-in will be sent two times /// per minute. - #[arg(long, value_name = "IDENTIFIER", requires = "api_key")] + #[arg(long, value_name = "IDENTIFIER", requires = "api_key", conflicts_with = "cron")] heartbeat: Option, /// The identifier to use to send cron check-ins. @@ -76,7 +76,7 @@ pub struct Cli { /// process starts, and if the wrapped process finishes with a success /// exit code, a finish cron check-in will be sent when the process /// finishes. - #[arg(long, value_name = "IDENTIFIER", requires = "api_key")] + #[arg(long, value_name = "IDENTIFIER", requires = "api_key", conflicts_with = "heartbeat")] cron: Option, /// Do not send standard output. @@ -472,18 +472,6 @@ mod tests { #[test] fn cli_check_in_config() { for (args, cron, heartbeat) in [ - ( - vec![ - "--cron", - "some-cron", - "--digest", - "some-digest", - "--heartbeat", - "some-heartbeat", - ], - true, - true, - ), ( vec!["--cron", "some-cron", "--digest", "some-digest"], true, From 884073d5e4cce18b2ac46100c2b8a945fd548b93 Mon Sep 17 00:00:00 2001 From: Noemi <45180344+unflxw@users.noreply.github.com> Date: Mon, 18 Nov 2024 16:45:25 +0100 Subject: [PATCH 5/5] Add positional name argument Add a positional `name` argument that is used as the identifier for check-ins, as the log group for logs, and as the action name for errors. This makes using the CLI less repetitive: ```sh // before: appsignal-wrap --api-key=... \ --cron backup \ --error backup \ --log backup \ -- ./backup.sh // after: appsignal-wrap backup --api-key=... --cron -- ./backup.sh ``` In addition, errors are now opt-out, and cron and heartbeat check-ins cannot both be used in the same invocation (as one of the two would necessarily not be correct for the same name) --- .changesets/add-required-name-argument.md | 32 +++ README.md | 80 +++++- src/cli.rs | 320 ++++++++++++++++------ src/error.rs | 4 +- 4 files changed, 338 insertions(+), 98 deletions(-) create mode 100644 .changesets/add-required-name-argument.md diff --git a/.changesets/add-required-name-argument.md b/.changesets/add-required-name-argument.md new file mode 100644 index 0000000..b9905b4 --- /dev/null +++ b/.changesets/add-required-name-argument.md @@ -0,0 +1,32 @@ +--- +bump: minor +type: change +--- + +Add a required positional argument for the name. This name is used as the identifier for cron and heartbeat check-ins, the group for logs, and the action name for errors. + +This avoids repetition of command-line parameters that represent the name: + +```sh +# Before: +appsignal-wrap \ + --cron backup \ + --error backup \ + --log backup \ + -- ./backup.sh + +# After: +appsignal-wrap backup \ + --cron \ + -- ./backup.sh +``` + +It is still possible to override the name for a specific purpose by using the `--log GROUP` and `--error ACTION` arguments, or by passing an identifier to either `--cron` or `--heartbeat`: + +```sh +appsignal-wrap mysql \ + --heartbeat db + -- mysqld +``` + +Additionally, error sending is now enabled by default (use `--no-error` to disable it) and using both cron and heartbeat check-ins in the same invocation is no longer allowed. diff --git a/README.md b/README.md index 6a85990..3e55fdb 100644 --- a/README.md +++ b/README.md @@ -22,19 +22,69 @@ Not a fan of `curl | sh` one-liners? Download the binary for your operating syst ## Usage +See `appsignal-wrap --help` for detailed information on all configuration options. + ``` -appsignal-wrap [OPTIONS] -- COMMAND +appsignal-wrap NAME [OPTIONS] -- COMMAND ``` To use `appsignal-wrap`, you must provide an app-level API key. You can find the app-level API key in the [push and deploy settings](https://appsignal.com/redirect-to/app?to=api_keys) for your application. To provide the app-level API key, set it as the value for the `APPSIGNAL_APP_PUSH_API_KEY` environment variable, or pass it as the value for the `--api-key` command-line option. -You must also provide a command to execute, as the last argument, preceded by `--`. This is the command whose output and lifecycle will be monitored with AppSignal. +You must also provide a name as the first argument, which will be used as the identifier for cron and heartbeat check-ins, as the group for logs, and as the action to group errors in AppSignal. -## Examples +Finally, you must provide a command to execute as the last argument, preceded by `--`. This is the command whose output and lifecycle will be monitored with AppSignal. -See `appsignal-wrap --help` for detailed information on all configuration options. +### Send standard output and error as logs to AppSignal + +By default, `appsignal-wrap` will send the standard output and standard error of the command it executes as logs to AppSignal: + +```sh +appsignal-wrap sync_customers -- python ./sync_customers.py +``` + +The above command will execute `python ./sync_customers.py` with the AppSignal wrapper, sending its standard output and error as logs to AppSignal. + +You can disable sending logs entirely by using the `--no-log` command-line option, and you can use `--no-stdout` and `--no-stderr` to control whether standard output and error are used to send logs to AppSignal. + +### Report failure exit codes as errors to AppSignal + +By default, `appsignal-wrap` will report an error to AppSignal if the command it executes exits with a failure exit code, or if the command fails to be executed: + +```sh +appsignal-wrap sync_customers -- python ./sync_customers.py +``` + +The above command will attempt to execute `python ./sync_customers.py` with the AppSignal wrapper, and it will report an error to AppSignal if it fails to execute the command, or if the command ends with a failure exit code. + +You can disable sending errors entirely by using the `--no-error` command-line option. + +### Send heartbeat check-ins to AppSignal while your process is running + +Use the `--heartbeat` flag to send heartbeat check-ins continuously to AppSignal, for as long as the process is running. This allows you to track that certain processes are always up: + +```sh +appsignal-wrap worker --heartbeat -- bundle exec ./worker.rb +``` + +The above command will execute `bundle exec ./worker.rb`, and send heartbeat check-ins to AppSignal with the `worker` check-in identifier continuously, for as long as the process is running. + +It will also send logs and report errors, as described in previous sections. To only send heartbeat check-ins, use `--no-log` and `--no-error`. + +### Send cron check-ins to AppSignal when your process starts and finishes + +Use the `--cron` flag to send a start cron check-in to AppSignal when the process starts, and a finish cron check-in to AppSignal if it finishes successfully. This allows you to track that certain processes are executed on schedule: + +```sh +appsignal-wrap sync_customers --cron -- python ./sync_customers.py +``` + +The above command will execute `bundle exec ./worker.rb`, send a start cron check-in to AppSignal with the `sync_customers` check-in identifier if it starts successfully, and send a finish cron check-in to AppSignal if it finishes with a success exit code. + +It will also send logs and report errors, as described in previous sections. To only send cron check-ins, use `--no-log` and `--no-error`. + +## Examples ### Monitor your database's uptime with AppSignal @@ -43,7 +93,7 @@ You can use the `--heartbeat` command-line option to send heartbeat check-ins (n In this example, we'll start `mysqld`, the MySQL server process, using `appsignal-wrap`: ```sh -appsignal-wrap --heartbeat database -- mysqld +appsignal-wrap database --heartbeat -- mysqld ``` This invocation can then be added to the `mysql.service` service definition: @@ -53,12 +103,17 @@ This invocation can then be added to the `mysql.service` service definition: [Service] # Modify the existing ExecStart line to add `appsignal-wrap` -ExecStart=/usr/local/bin/appsignal-wrap --heartbeat database -- /usr/sbin/mysqld +ExecStart=/usr/local/bin/appsignal-wrap database --heartbeat -- /usr/sbin/mysqld # Add an environment variable containing the AppSignal app-level push API key Environment=APPSIGNAL_APP_PUSH_API_KEY=... ``` -In addition to the specified heartbeat check-ins, by default `appsignal-wrap` will also send your database process' standard output and standard error as logs to AppSignal. Use the `--no-log` configuration option to disable this behaviour. +In addition to sending heartbeat check-ins, by default `appsignal-wrap` will also: + +- Send your database process' standard output and standard error as logs to AppSignal, under the `database` group +- Report failure exit codes as errors to AppSignal, grouped under the `database` action + +You can use the `--no-log` and `--no-error` command-line option to disable this behaviour. ### Monitor your cron jobs with AppSignal @@ -67,7 +122,7 @@ You can use the `--cron` command-line option to send cron check-ins (named `back In this example, we'll run `/usr/local/bin/backup.sh`, our custom backup shell script, using `appsignal-wrap`: ```sh -appsignal-wrap --cron backup -- bash /usr/local/bin/backup.sh +appsignal-wrap backup --cron -- bash /usr/local/bin/backup.sh ``` This invocation can then be added to the `/etc/crontab` file: @@ -77,7 +132,12 @@ This invocation can then be added to the `/etc/crontab` file: APPSIGNAL_APP_PUSH_API_KEY=... -0 2 * * * appsignal-wrap --cron backup -- bash /usr/local/bin/backup.sh +0 2 * * * appsignal-wrap backup --cron -- bash /usr/local/bin/backup.sh ``` -In addition to the specified cron check-ins, by default `appsignal-wrap` will also send your database process' standard output and standard error as logs to AppSignal. Use the `--no-log` configuration option to disable this behaviour. +In addition to sending cron check-ins, by default `appsignal-wrap` will also: + +- Send your database process' standard output and standard error as logs to AppSignal, under the `backup` group +- Report failure exit codes as errors to AppSignal, grouped under the `backup` action + +You can use the `--no-log` and `--no-error` command-line option to disable this behaviour. diff --git a/src/cli.rs b/src/cli.rs index 8102541..e67ef9b 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -20,10 +20,17 @@ use clap::Parser; #[derive(Debug, Parser)] #[command(version)] pub struct Cli { - /// The AppSignal *app-level* push API key. + /// The AppSignal *app-level* push API key. Required. /// - /// Required unless a log source API key is provided (see `--log-source`) - /// and no check-ins are being sent (see `--cron` and `--heartbeat`) + /// This is the app-level push API key for the AppSignal application + /// that logs, errors and check-ins will be sent to. This is *not* the + /// organization-level API key. + /// + /// You can find these keys in the AppSignal dashboard: + /// https://appsignal.com/redirect-to/organization?to=admin/api_keys + /// + /// Required unless a log source API key is provided using the + /// `--log-source` option, and no check-ins or errors are being sent. #[arg( long, env = "APPSIGNAL_APP_PUSH_API_KEY", @@ -32,22 +39,107 @@ pub struct Cli { )] api_key: Option, - /// The log group to use to send logs. + /// The name to use to send check-ins, logs and errors to AppSignal. + /// Required. + /// + /// This value is used as the identifier for cron or heartbeat + /// check-ins, if either the `--cron` or `--heartbeat` option is set, as + /// the group for logs, and as the action for errors. + /// + /// This name should represent a *kind* of process, not be unique to + /// the specific invocation of the process. See the `--digest` option for + /// a unique identifier for this invocation. + /// + /// The `--cron`, `--heartbeat`, `--log` and `--error` options can be + /// used to override this value for each use case. + #[arg(index = 1, value_name = "NAME", required = true)] + name: String, + + /// The command to execute. Required. + /// + /// + #[arg(index = 2, allow_hyphen_values = true, last = true, required = true)] + pub command: Vec, + + /// Send heartbeat check-ins. + /// + /// If this option is set, a heartbeat check-in will be sent two times + /// per minute. + /// + /// Optionally, the identifier for the check-in can be provided. If + /// omitted, the name given as the first argument will be used. + #[arg( + long, + value_name = "IDENTIFIER", + requires = "api_key", + conflicts_with = "cron" + )] + heartbeat: Option>, + + /// Send cron check-ins. /// - /// If this option is not set, logs will be sent to the "process" - /// log group. + /// If this option is set, a start cron check-in will be sent when the + /// process starts, and if the wrapped process finishes with a success + /// exit code, a finish cron check-in will be sent when the process + /// finishes. + /// + /// Optionally, the identifier for the check-in can be provided. If + /// omitted, the name given as the first argument will be used. + #[arg( + long, + value_name = "IDENTIFIER", + requires = "api_key", + conflicts_with = "heartbeat" + )] + cron: Option>, + + /// Do not send logs. + /// + /// If this option is set, no logs will be sent to AppSignal. + /// + /// By default, both standard output and standard error will be sent as + /// logs. Use the `--no-stdout` and `--no-stderr` options to disable + /// sending standard output and standard error respectively. + #[arg(long)] + no_log: bool, + + /// Do not send errors. + /// + /// If this option is set, no errors will be sent to AppSignal. + /// + /// By default, an error will be sent to AppSignal if the process fails to + /// start, or if the process finishes with a non-zero exit code. + /// + /// The error message sent to AppSignal will include the last lines of + /// standard output and standard error, unless the `--no-stdout` or + /// `--no-stderr` options are set. + #[arg(long)] + no_error: bool, + + /// Override the log group to use to send logs. + /// + /// If this option is not set, the name given as the first argument will + /// be used as the log group. /// /// By default, both standard output and standard error will be sent as - /// logs. Use the --no-stdout and --no-stderr options to disable + /// logs. Use the `--no-stdout` and `--no-stderr` options to disable /// sending standard output and standard error respectively, or use the - /// --no-log option to disable sending logs entirely. + /// `--no-log` option to disable sending logs entirely. #[arg(long, value_name = "GROUP")] log: Option, - /// The action name to use to group errors by. + /// Override the action name to use to group errors. /// - /// If this option is not set, errors will not be sent to AppSignal when - /// a process exits with a non-zero exit code. + /// If this option is not set, the name given as the first argument will + /// be used as the action name. + /// + /// By default, an error will be sent to AppSignal if the process fails to + /// start, or if Use the `--no-error` option to disable sending errors to + /// AppSignal. + /// + /// The error message sent to AppSignal will include the last lines of + /// standard output and standard error. Use the `--no-stdout` or + /// `--no-stderr` options are set. #[arg(long, value_name = "ACTION", requires = "api_key")] error: Option, @@ -55,7 +147,7 @@ pub struct Cli { /// /// If this option is not set, logs will be sent to the default /// "application" log source for the application specified by the - /// app-level push API key. + /// app-level push API key -- see the `--api-key` option. #[arg( long, env = "APPSIGNAL_LOG_SOURCE_API_KEY", @@ -63,46 +155,20 @@ pub struct Cli { )] log_source: Option, - /// The identifier to use to send heartbeat check-ins. - /// - /// If this option is set, a heartbeat check-in will be sent two times - /// per minute. - #[arg(long, value_name = "IDENTIFIER", requires = "api_key", conflicts_with = "cron")] - heartbeat: Option, - - /// The identifier to use to send cron check-ins. - /// - /// If this option is set, a start cron check-in will be sent when the - /// process starts, and if the wrapped process finishes with a success - /// exit code, a finish cron check-in will be sent when the process - /// finishes. - #[arg(long, value_name = "IDENTIFIER", requires = "api_key", conflicts_with = "heartbeat")] - cron: Option, - - /// Do not send standard output. + /// Do not use standard output in logs or error messages. /// /// Do not send standard output as logs, and do not use the last - /// lines of standard output as part of the error message when - /// `--error` is set. + /// lines of standard output as part of the error message. #[arg(long)] no_stdout: bool, - /// Do not send standard error. + /// Do not use standard error in logs or error messages. /// /// Do not send standard error as logs, and do not use the last - /// lines of standard error as part of the error message when - /// `--error` is set. + /// lines of standard error as part of the error message. #[arg(long)] no_stderr: bool, - /// Do not send any logs. - #[arg(long)] - no_log: bool, - - /// The command to execute. - #[arg(allow_hyphen_values = true, last = true, required = true)] - pub command: Vec, - /// The AppSignal public endpoint to use. #[arg( long, @@ -113,7 +179,11 @@ pub struct Cli { )] endpoint: String, - /// The hostname to report when sending logs. + /// The hostname to report. Determined automatically. + /// + /// This value will be used as the hostname when sending logs, and added + /// as a tag to errors. We attempt to determine the hostname automatically, + /// but this configuration option can be used to override it. #[arg( long, env = "APPSIGNAL_HOSTNAME", @@ -122,7 +192,8 @@ pub struct Cli { hostname: String, /// The digest to uniquely identify this invocation of the process. - /// Used in cron check-ins as a digest, and in logs as an attribute. + /// Used in cron check-ins as a digest, in logs as an attribute, and in + /// errors as a tag. /// Unless overriden, this value is automatically set to a random value. #[arg( long, @@ -176,22 +247,37 @@ impl Cli { } } + fn error_and_no_error_warning(&self) -> Option { + if self.no_error && self.error.is_some() { + return Some( + "using --no-error alongside --error; \ + no errors will be sent to AppSignal" + .to_string(), + ); + }; + + None + } + fn no_log_and_no_data_warning(&self) -> Option { + if !self.no_error { + return None; + } + let no_checkins: bool = self.cron.is_none() && self.heartbeat.is_none(); - let no_errors: bool = self.error.is_none(); - if no_checkins && no_errors { + if no_checkins { let using: Option<&str> = if self.no_log { - Some("--no-log") + Some("--no-log and --no-error") } else if self.no_stdout && self.no_stderr { - Some("--no-stdout and --no-stderr") + Some("--no-stdout, --no-stderr and --no-error") } else { None }; if let Some(using) = using { return Some(format!( - "using {using} without either --cron, --heartbeat or --error; \ + "using {using} without either --cron or --heartbeat; \ no data will be sent to AppSignal" )); } @@ -207,6 +293,10 @@ impl Cli { warnings.push(warning); } + if let Some(warning) = self.error_and_no_error_warning() { + warnings.push(warning); + } + if let Some(warning) = self.no_log_and_no_data_warning() { warnings.push(warning); } @@ -226,7 +316,7 @@ impl Cli { check_in: CheckInConfig { api_key: api_key.clone(), endpoint: self.endpoint.clone(), - identifier: identifier.clone(), + identifier: identifier.as_ref().unwrap_or(&self.name).clone(), }, digest: self.digest.clone(), }), @@ -240,7 +330,7 @@ impl Cli { check_in: CheckInConfig { api_key: api_key.clone(), endpoint: self.endpoint.clone(), - identifier: identifier.clone(), + identifier: identifier.as_ref().unwrap_or(&self.name).clone(), }, }), _ => None, @@ -256,7 +346,7 @@ impl Cli { .clone(); let endpoint = self.endpoint.clone(); let origin = self.log_origin(); - let group = self.log.clone().unwrap_or_else(|| "process".to_string()); + let group = self.log.as_ref().unwrap_or(&self.name).clone(); let hostname = self.hostname.clone(); let digest = self.digest.clone(); let command = self.command_as_str(); @@ -273,22 +363,24 @@ impl Cli { } pub fn error(&self) -> Option { - self.error.as_ref().map(|action| { - let api_key = self.api_key.as_ref().unwrap().clone(); - let endpoint = self.endpoint.clone(); - let action = action.clone(); - let hostname = self.hostname.clone(); - let digest = self.digest.clone(); - let command = self.command_as_str(); - - ErrorConfig { - api_key, - endpoint, - action, - hostname, - digest, - command, - } + if self.no_error { + return None; + } + + let api_key = self.api_key.as_ref().unwrap().clone(); + let endpoint = self.endpoint.clone(); + let action = self.error.as_ref().unwrap_or(&self.name).clone(); + let hostname = self.hostname.clone(); + let digest = self.digest.clone(); + let command = self.command_as_str(); + + Some(ErrorConfig { + api_key, + endpoint, + action, + hostname, + digest, + command, }) } @@ -328,7 +420,7 @@ mod tests { // These arguments are required -- without them, the CLI parser will fail. fn with_required_args(args: Vec<&str>) -> Vec<&str> { - let first_args: Vec<&str> = vec![NAME, "--api-key", "some-api-key"]; + let first_args: Vec<&str> = vec![NAME, "some-name", "--api-key", "some-api-key"]; let last_args: Vec<&str> = vec!["--", "true"]; first_args .into_iter() @@ -386,33 +478,48 @@ mod tests { } } + #[test] + fn cli_warnings_error_and_no_error() { + let args = vec!["--error", "some-action", "--no-error"]; + let cli = + Cli::try_parse_from(with_required_args(args)).expect("failed to parse CLI arguments"); + + let warnings = cli.warnings(); + + assert!(warnings.len() == 1); + assert_eq!( + warnings[0], + "using --no-error alongside --error; no errors will be sent to AppSignal" + ); + } + #[test] fn cli_warnings_no_log_and_no_data() { for (args, warning) in [ ( - vec!["--no-log"], - Some("using --no-log without either --cron, --heartbeat or --error; no data will be sent to AppSignal") + vec!["--no-log", "--no-error"], + Some("using --no-log and --no-error without either --cron or --heartbeat; no data will be sent to AppSignal") ), ( - vec!["--no-stdout", "--no-stderr"], - Some("using --no-stdout and --no-stderr without either --cron, --heartbeat or --error; no data will be sent to AppSignal") + vec!["--no-stdout", "--no-stderr", "--no-error"], + Some("using --no-stdout, --no-stderr and --no-error without either --cron or --heartbeat; no data will be sent to AppSignal") ), ( - vec!["--no-log", "--no-stdout", "--no-stderr"], - Some("using --no-log without either --cron, --heartbeat or --error; no data will be sent to AppSignal") + vec!["--no-log", "--no-stdout", "--no-stderr", "--no-error"], + Some("using --no-log and --no-error without either --cron or --heartbeat; no data will be sent to AppSignal") ), ( - vec!["--no-log", "--cron", "some-cron"], + vec!["--no-log", "--no-error", "--cron"], None ), ( - vec!["--no-log", "--heartbeat", "some-heartbeat"], + vec!["--no-log", "--no-error", "--heartbeat"], None ), ( - vec!["--no-log", "--error", "some-error"], + vec!["--no-log"], None - ) + ), ] { let cli = Cli::try_parse_from( with_required_args(args) @@ -469,16 +576,55 @@ mod tests { } } + #[test] + fn cli_error_config() { + for (args, error) in [ + (vec!["--no-error"], None), + (vec!["--error", "some-action"], Some("some-action")), + (vec![], Some("some-name")), + ] { + let cli = Cli::try_parse_from(with_required_args( + args.into_iter() + .chain(["--hostname", "some-hostname", "--digest", "some-digest"].into_iter()) + .collect(), + )) + .expect("failed to parse CLI arguments"); + + let error_config = cli.error(); + + if let Some(action) = error { + let error_config = error_config.expect("expected error config"); + assert_eq!(error_config.action, action); + assert_eq!(error_config.api_key, "some-api-key"); + assert_eq!(error_config.endpoint, "https://appsignal-endpoint.net"); + assert_eq!(error_config.hostname, "some-hostname"); + assert_eq!(error_config.digest, "some-digest"); + } else { + assert!(error_config.is_none()); + } + } + } + #[test] fn cli_check_in_config() { for (args, cron, heartbeat) in [ ( vec!["--cron", "some-cron", "--digest", "some-digest"], - true, - false, + Some("some-cron"), + None, + ), + ( + vec!["--heartbeat", "some-heartbeat"], + None, + Some("some-heartbeat"), + ), + ( + vec!["--cron", "--digest", "some-digest"], + Some("some-name"), + None, ), - (vec!["--heartbeat", "some-heartbeat"], false, true), - (vec![], false, false), + (vec!["--heartbeat"], None, Some("some-name")), + (vec![], None, None), ] { let cli = Cli::try_parse_from(with_required_args(args)) .expect("failed to parse CLI arguments"); @@ -486,9 +632,9 @@ mod tests { let cron_config = cli.cron(); let heartbeat_config = cli.heartbeat(); - if cron { + if let Some(identifier) = cron { let cron_config = cron_config.expect("expected cron config"); - assert_eq!(cron_config.check_in.identifier, "some-cron"); + assert_eq!(cron_config.check_in.identifier, identifier); assert_eq!(cron_config.check_in.api_key, "some-api-key"); assert_eq!( cron_config.check_in.endpoint, @@ -499,9 +645,9 @@ mod tests { assert!(cron_config.is_none()); } - if heartbeat { + if let Some(identifier) = heartbeat { let heartbeat_config = heartbeat_config.expect("expected heartbeat config"); - assert_eq!(heartbeat_config.check_in.identifier, "some-heartbeat"); + assert_eq!(heartbeat_config.check_in.identifier, identifier); assert_eq!(heartbeat_config.check_in.api_key, "some-api-key"); assert_eq!( heartbeat_config.check_in.endpoint, diff --git a/src/error.rs b/src/error.rs index c50b02e..67ba145 100644 --- a/src/error.rs +++ b/src/error.rs @@ -67,6 +67,8 @@ pub struct ErrorBody { pub tags: BTreeMap, } +const NAMESPACE: &str = "process"; + impl ErrorBody { pub fn new( config: &ErrorConfig, @@ -77,7 +79,7 @@ impl ErrorBody { ErrorBody { timestamp: timestamp.as_secs(), action: config.action.clone(), - namespace: "process".to_string(), + namespace: NAMESPACE.to_string(), error, tags: tags.into_iter().chain(config.tags()).collect(), }