Skip to content

Commit 66c87c7

Browse files
committed
Report non-success exits as errors
When the child process exits due to a signal, or exits with a non-zero exit code, report this as an error to AppSignal. Closes #2. This commit implements this functionality as opt-in, with an `--error` flag. This will probably be changed to opt-out once #5 is implemented, which will provide a name to group by. Set the hostname, digest, and exit information as tags on the error sample.
1 parent e881c62 commit 66c87c7

File tree

4 files changed

+248
-0
lines changed

4 files changed

+248
-0
lines changed

src/cli.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use std::ffi::OsString;
22

33
use crate::check_in::{CheckInConfig, CronConfig, HeartbeatConfig};
4+
use crate::error::ErrorConfig;
45
use crate::log::{LogConfig, LogOrigin};
56

67
use ::log::warn;
@@ -43,6 +44,13 @@ pub struct Cli {
4344
#[arg(long, value_name = "GROUP")]
4445
log: Option<String>,
4546

47+
/// The action name to use to group errors by.
48+
///
49+
/// If this option is not set, errors will not be sent to AppSignal when
50+
/// a process exits with a non-zero exit code.
51+
#[arg(long, value_name = "ACTION", requires = "api_key")]
52+
error: Option<String>,
53+
4654
/// The log source API key to use to send logs.
4755
///
4856
/// If this option is not set, logs will be sent to the default
@@ -252,6 +260,24 @@ impl Cli {
252260
digest,
253261
}
254262
}
263+
264+
pub fn error(&self) -> Option<ErrorConfig> {
265+
self.error.as_ref().map(|action| {
266+
let api_key = self.api_key.as_ref().unwrap().clone();
267+
let endpoint = self.endpoint.clone();
268+
let action = action.clone();
269+
let hostname = self.hostname.clone();
270+
let digest = self.digest.clone();
271+
272+
ErrorConfig {
273+
api_key,
274+
endpoint,
275+
action,
276+
hostname,
277+
digest,
278+
}
279+
})
280+
}
255281
}
256282

257283
#[cfg(test)]

src/error.rs

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
use std::collections::BTreeMap;
2+
use std::os::unix::process::ExitStatusExt;
3+
use std::process::ExitStatus;
4+
5+
use reqwest::Body;
6+
use serde::Serialize;
7+
8+
use crate::client::client;
9+
use crate::package::NAME;
10+
use crate::signals::signal_name;
11+
use crate::timestamp::Timestamp;
12+
13+
pub struct ErrorConfig {
14+
pub api_key: String,
15+
pub endpoint: String,
16+
pub action: String,
17+
pub hostname: String,
18+
pub digest: String,
19+
}
20+
21+
impl ErrorConfig {
22+
pub fn request(
23+
&self,
24+
timestamp: &mut impl Timestamp,
25+
exit: &ExitStatus,
26+
) -> Result<reqwest::Request, reqwest::Error> {
27+
let url = format!("{}/errors", self.endpoint);
28+
29+
client()
30+
.post(url)
31+
.query(&[("api_key", &self.api_key)])
32+
.header("Content-Type", "application/json")
33+
.body(ErrorBody::from_config(&self, timestamp, exit))
34+
.build()
35+
}
36+
}
37+
38+
#[derive(Serialize)]
39+
pub struct ErrorBody {
40+
pub timestamp: u64,
41+
pub action: String,
42+
pub namespace: String,
43+
pub error: ErrorBodyError,
44+
pub tags: BTreeMap<String, String>,
45+
}
46+
47+
impl ErrorBody {
48+
pub fn from_config(
49+
config: &ErrorConfig,
50+
timestamp: &mut impl Timestamp,
51+
exit: &ExitStatus,
52+
) -> Self {
53+
ErrorBody {
54+
timestamp: timestamp.as_secs(),
55+
action: config.action.clone(),
56+
namespace: "process".to_string(),
57+
error: ErrorBodyError::from_exit(exit),
58+
tags: exit_tags(exit)
59+
.into_iter()
60+
.chain([
61+
("hostname".to_string(), config.hostname.clone()),
62+
(format!("{}-digest", NAME), config.digest.clone()),
63+
])
64+
.collect(),
65+
}
66+
}
67+
}
68+
69+
impl From<ErrorBody> for Body {
70+
fn from(body: ErrorBody) -> Self {
71+
Body::from(serde_json::to_string(&body).unwrap())
72+
}
73+
}
74+
75+
#[derive(Serialize)]
76+
pub struct ErrorBodyError {
77+
pub name: String,
78+
pub message: String,
79+
}
80+
81+
impl ErrorBodyError {
82+
pub fn from_exit(exit: &ExitStatus) -> Self {
83+
if let Some(code) = exit.code() {
84+
ErrorBodyError {
85+
name: "NonZeroExit".to_string(),
86+
message: format!("Process exited with code {}", code),
87+
}
88+
} else if let Some(signal) = exit.signal() {
89+
ErrorBodyError {
90+
name: "SignalExit".to_string(),
91+
message: format!("Process exited with signal {}", signal_name(signal)),
92+
}
93+
} else {
94+
ErrorBodyError {
95+
name: "UnknownExit".to_string(),
96+
message: "Process exited with unknown status".to_string(),
97+
}
98+
}
99+
}
100+
}
101+
102+
fn exit_tags(exit: &ExitStatus) -> BTreeMap<String, String> {
103+
if let Some(code) = exit.code() {
104+
[
105+
("exit_code".to_string(), format!("{}", code)),
106+
("exit_kind".to_string(), "code".to_string()),
107+
]
108+
.into()
109+
} else if let Some(signal) = exit.signal() {
110+
[
111+
("exit_signal".to_string(), signal_name(signal)),
112+
("exit_kind".to_string(), "signal".to_string()),
113+
]
114+
.into()
115+
} else {
116+
[("exit_kind".to_string(), "unknown".to_string())].into()
117+
}
118+
}
119+
120+
#[cfg(test)]
121+
mod tests {
122+
use super::*;
123+
use crate::timestamp::tests::{timestamp, EXPECTED_SECS};
124+
125+
fn error_config() -> ErrorConfig {
126+
ErrorConfig {
127+
api_key: "some_api_key".to_string(),
128+
endpoint: "https://some-endpoint.com".to_string(),
129+
hostname: "some-hostname".to_string(),
130+
digest: "some-digest".to_string(),
131+
action: "some-action".to_string(),
132+
}
133+
}
134+
135+
#[test]
136+
fn error_config_request() {
137+
let config = error_config();
138+
// `ExitStatus::from_raw` expects a wait status, not an exit status.
139+
// The wait status for exit code `n` is represented by `n << 8`.
140+
let exit = ExitStatus::from_raw(42 << 8);
141+
142+
let request = config.request(&mut timestamp(), &exit).unwrap();
143+
144+
assert_eq!(request.method().as_str(), "POST");
145+
assert_eq!(
146+
request.url().as_str(),
147+
"https://some-endpoint.com/errors?api_key=some_api_key"
148+
);
149+
assert_eq!(
150+
request.headers().get("Content-Type").unwrap(),
151+
"application/json"
152+
);
153+
assert_eq!(
154+
String::from_utf8_lossy(request.body().unwrap().as_bytes().unwrap()),
155+
format!(
156+
concat!(
157+
"{{",
158+
r#""timestamp":{},"#,
159+
r#""action":"some-action","#,
160+
r#""namespace":"process","#,
161+
r#""error":{{"#,
162+
r#""name":"NonZeroExit","#,
163+
r#""message":"Process exited with code 42""#,
164+
r#"}},"#,
165+
r#""tags":{{"#,
166+
r#""{}-digest":"some-digest","#,
167+
r#""exit_code":"42","#,
168+
r#""exit_kind":"code","#,
169+
r#""hostname":"some-hostname""#,
170+
r#"}}"#,
171+
"}}"
172+
),
173+
EXPECTED_SECS, NAME
174+
)
175+
);
176+
}
177+
}

src/main.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
mod check_in;
22
mod cli;
3+
mod error;
34
mod log;
45

56
mod client;
@@ -103,6 +104,12 @@ async fn start(cli: Cli) -> Result<i32, Box<dyn std::error::Error>> {
103104
cron.request(&mut SystemTimestamp, CronKind::Finish),
104105
));
105106
}
107+
} else {
108+
if let Some(error) = cli.error() {
109+
tasks.spawn(send_request(
110+
error.request(&mut SystemTimestamp, &exit_status),
111+
));
112+
}
106113
}
107114

108115
if let Some(heartbeat) = heartbeat {

src/signals.rs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,3 +68,41 @@ pub fn signal_stream() -> io::Result<impl Stream<Item = Signal>> {
6868

6969
Ok(signals.map(|(signal, _)| signal))
7070
}
71+
72+
// A mapping of signal numbers to signal names. Uses `libc` constants to
73+
// correctly map non-portable signals to their names across platforms.
74+
// For an unknown signal, the signal number is returned as a string.
75+
pub fn signal_name(signal: i32) -> String {
76+
match signal {
77+
libc::SIGABRT => "SIGABRT".to_owned(),
78+
libc::SIGALRM => "SIGALRM".to_owned(),
79+
libc::SIGBUS => "SIGBUS".to_owned(),
80+
libc::SIGCHLD => "SIGCHLD".to_owned(),
81+
libc::SIGCONT => "SIGCONT".to_owned(),
82+
libc::SIGFPE => "SIGFPE".to_owned(),
83+
libc::SIGHUP => "SIGHUP".to_owned(),
84+
libc::SIGILL => "SIGILL".to_owned(),
85+
libc::SIGINT => "SIGINT".to_owned(),
86+
libc::SIGIO => "SIGIO".to_owned(),
87+
libc::SIGKILL => "SIGKILL".to_owned(),
88+
libc::SIGPIPE => "SIGPIPE".to_owned(),
89+
libc::SIGPROF => "SIGPROF".to_owned(),
90+
libc::SIGQUIT => "SIGQUIT".to_owned(),
91+
libc::SIGSEGV => "SIGSEGV".to_owned(),
92+
libc::SIGSTOP => "SIGSTOP".to_owned(),
93+
libc::SIGSYS => "SIGSYS".to_owned(),
94+
libc::SIGTERM => "SIGTERM".to_owned(),
95+
libc::SIGTRAP => "SIGTRAP".to_owned(),
96+
libc::SIGTSTP => "SIGTSTP".to_owned(),
97+
libc::SIGTTIN => "SIGTTIN".to_owned(),
98+
libc::SIGTTOU => "SIGTTOU".to_owned(),
99+
libc::SIGURG => "SIGURG".to_owned(),
100+
libc::SIGUSR1 => "SIGUSR1".to_owned(),
101+
libc::SIGUSR2 => "SIGUSR2".to_owned(),
102+
libc::SIGVTALRM => "SIGVTALRM".to_owned(),
103+
libc::SIGWINCH => "SIGWINCH".to_owned(),
104+
libc::SIGXCPU => "SIGXCPU".to_owned(),
105+
libc::SIGXFSZ => "SIGXFSZ".to_owned(),
106+
signal => format!("{}", signal),
107+
}
108+
}

0 commit comments

Comments
 (0)