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/.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/.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/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 c2d776a..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 + /// 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 /// 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, the name given as the first argument will + /// be used as the action name. /// - /// If this option is not set, errors will not be sent to AppSignal when - /// a process exits with a non-zero exit code. + /// 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")] - 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")] - 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,14 +247,30 @@ impl Cli { } } - fn no_log_and_no_checkins_warning(&self) -> Option { + 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(); 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 }; @@ -206,7 +293,11 @@ impl Cli { warnings.push(warning); } - if let Some(warning) = self.no_log_and_no_checkins_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); } @@ -225,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(), }), @@ -239,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, @@ -255,9 +346,10 @@ 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: String = self.digest.clone(); + let digest = self.digest.clone(); + let command = self.command_as_str(); LogConfig { api_key, @@ -266,24 +358,29 @@ impl Cli { hostname, group, digest, + command, } } 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(); - - ErrorConfig { - api_key, - endpoint, - action, - hostname, - digest, - } + 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, }) } @@ -310,6 +407,10 @@ impl Cli { self.log_origin().is_out() } + + fn command_as_str(&self) -> String { + self.command.join(" ") + } } #[cfg(test)] @@ -319,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() @@ -378,15 +479,48 @@ mod tests { } #[test] - fn cli_warnings_no_log_and_no_checkins() { - for (args, warning) in [( - vec!["--no-log"], - "using --no-log without either --cron or --heartbeat; no data will be sent to AppSignal" + 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", "--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"], - "using --no-stdout and --no-stderr without either --cron or --heartbeat; 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", "--no-error"], + Some("using --no-log and --no-error without either --cron or --heartbeat; no data will be sent to AppSignal") + ), + ( + vec!["--no-log", "--no-error", "--cron"], + None + ), + ( + vec!["--no-log", "--no-error", "--heartbeat"], + None + ), + ( + vec!["--no-log"], + None + ), + ] { let cli = Cli::try_parse_from( with_required_args(args) @@ -394,8 +528,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()); + } } } @@ -438,28 +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", - "--heartbeat", - "some-heartbeat", - ], - true, - true, + vec!["--cron", "some-cron", "--digest", "some-digest"], + Some("some-cron"), + None, ), ( - vec!["--cron", "some-cron", "--digest", "some-digest"], - true, - false, + 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"); @@ -467,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, @@ -480,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 6416e0c..67ba145 100644 --- a/src/error.rs +++ b/src/error.rs @@ -16,24 +16,46 @@ pub struct ErrorConfig { pub action: String, pub hostname: String, pub digest: String, + pub command: String, } 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()), + ("command".to_string(), self.command.clone()), + ] + .into() + } } #[derive(Serialize)] @@ -45,27 +67,45 @@ pub struct ErrorBody { pub tags: BTreeMap, } +const NAMESPACE: &str = "process"; + 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(), + namespace: NAMESPACE.to_string(), + 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 +121,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() { @@ -132,11 +179,55 @@ mod tests { hostname: "some-hostname".to_string(), digest: "some-digest".to_string(), action: "some-action".to_string(), + command: "some-command".to_string(), } } #[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#""command":"some-command","#, + 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 +235,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!( @@ -169,6 +262,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 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 {