pr_01m47d24b0e6n91zwymwxg0vpx/services/integrations/src/alerts.rs

151 lines5,953 bytesCodeBlame
1//! Alerts: what an outside system reports, in one shape, whichever system
2//! it came from. Sentry has its own reader; Datadog and plain webhooks
3//! send JSON whose fields g1t picks out by their usual names.
4
5use serde_json::Value;
6
7use g1t_secrets as crypto;
8
9/// What an alert asks g1t to do.
10#[derive(Clone, Copy, Debug, PartialEq, Eq)]
11pub enum Action {
12 /// Open an issue for it, or count it against the one already open.
13 Open,
14 /// It came back after being fixed: reopen the issue.
15 Reopen,
16 /// It stopped. Say so on the issue, and nothing more.
17 Recovered,
18}
19
20/// One alert, from any system.
21#[derive(Clone, Debug)]
22pub struct Signal {
23 /// What the sender called it: `issue.created`, `Triggered`.
24 pub event: String,
25 pub action: Action,
26 /// The sender's stable id for the problem, so it maps to one issue.
27 pub external_id: String,
28 pub key: String,
29 pub title: String,
30 pub url: String,
31 /// Markdown describing it.
32 pub body: String,
33 /// How many times it has happened, if the sender says.
34 pub count: Option<u32>,
35}
36
37fn first(payload: &Value, names: &[&str]) -> Option<String> {
38 names.iter().find_map(|name| match &payload[*name] {
39 Value::String(text) if !text.trim().is_empty() => Some(text.trim().to_owned()),
40 Value::Number(number) => Some(number.to_string()),
41 _ => None,
42 })
43}
44
45/// Whether a Datadog or webhook request carries the connection's secret:
46/// as `Authorization: Bearer <secret>`, or as an HMAC-SHA256 of the body in
47/// `X-G1t-Signature`.
48pub fn authentic(headers: &std::collections::HashMap<String, String>, body: &str, secret: &str) -> bool {
49 if let Some(signature) = headers.get("x-g1t-signature") {
50 return crypto::signed(secret, body, signature);
51 }
52 headers
53 .get("authorization")
54 .and_then(|value| value.strip_prefix("Bearer ").or_else(|| value.strip_prefix("bearer ")))
55 .is_some_and(|given| crypto::same(given.trim(), secret))
56}
57
58/// Reads a Datadog webhook or a plain one.
59///
60/// Fields, by their first name present: an id (`id`, `alert_id`,
61/// `aggregate`, `incident_key`), a title (`title`, `event_title`,
62/// `summary`), a description (`body`, `message`, `event_msg`, `text`), an
63/// address (`url`, `link`) and a state (`status`, `transition`,
64/// `alert_transition`), where `recovered`, `resolved` or `ok` means it
65/// stopped.
66pub fn signal(system: &str, payload: &Value) -> std::result::Result<Signal, String> {
67 if !payload.is_object() {
68 return Err("The body is not a JSON object.".to_owned());
69 }
70 let title = first(payload, &["title", "event_title", "summary", "name"])
71 .ok_or_else(|| "The payload has no title.".to_owned())?;
72 let external_id = first(payload, &["id", "alert_id", "aggregate", "incident_key", "dedup_key"])
73 .unwrap_or_else(|| title.clone());
74 let state = first(payload, &["status", "transition", "alert_transition", "state"]).unwrap_or_default();
75 let action = match state.to_ascii_lowercase().as_str() {
76 "recovered" | "resolved" | "ok" | "closed" => Action::Recovered,
77 _ => Action::Open,
78 };
79 let url = first(payload, &["url", "link", "html_url"]).unwrap_or_default();
80 let mut body = vec![match url.is_empty() {
81 true => format!("**{system}**"),
82 false => format!("**{system}** · [open it there]({url})"),
83 }];
84 if !state.is_empty() {
85 body.push(format!("State: {state}."));
86 }
87 if let Some(priority) = first(payload, &["priority", "severity", "level"]) {
88 body.push(format!("Priority: {priority}."));
89 }
90 if let Some(text) = first(payload, &["body", "message", "event_msg", "text", "description"]) {
91 body.push(crate::http::shorten(&text, 6000));
92 }
93 if let Some(tags) = first(payload, &["tags"]) {
94 body.push(format!("Tags: `{tags}`"));
95 }
96 Ok(Signal {
97 event: if state.is_empty() { "alert".to_owned() } else { state },
98 action,
99 key: external_id.chars().take(40).collect(),
100 external_id,
101 title: title.chars().take(200).collect(),
102 url,
103 body: body.join("\n\n"),
104 count: first(payload, &["count"]).and_then(|count| count.parse().ok()),
105 })
106}
107
108#[cfg(test)]
109mod tests {
110 use super::*;
111 use serde_json::json;
112
113 #[test]
114 fn a_datadog_alert_is_read_by_its_usual_names() {
115 let alert = signal(
116 "Datadog",
117 &json!({ "alert_id": 123, "event_title": "[Triggered] p99 latency high", "event_msg": "p99 > 2s",
118 "link": "https://app.datadoghq.com/monitors/123", "alert_transition": "Triggered", "priority": "P2" }),
119 )
120 .unwrap();
121 assert_eq!(alert.external_id, "123");
122 assert_eq!(alert.action, Action::Open);
123 assert!(alert.body.contains("p99 > 2s"));
124 assert!(alert.body.contains("Priority: P2."));
125 }
126
127 #[test]
128 fn recovered_means_it_stopped() {
129 let alert = signal("Datadog", &json!({ "id": "1", "title": "x", "alert_transition": "Recovered" })).unwrap();
130 assert_eq!(alert.action, Action::Recovered);
131 }
132
133 #[test]
134 fn a_title_is_required() {
135 assert!(signal("Webhook", &json!({ "id": "1" })).is_err());
136 assert!(signal("Webhook", &json!([1, 2])).is_err());
137 }
138
139 #[test]
140 fn the_secret_is_checked_either_way() {
141 let body = "{\"title\":\"x\"}";
142 let mut headers = std::collections::HashMap::new();
143 headers.insert("authorization".to_owned(), "Bearer shh".to_owned());
144 assert!(authentic(&headers, body, "shh"));
145 assert!(!authentic(&headers, body, "other"));
146 let mut signed = std::collections::HashMap::new();
147 signed.insert("x-g1t-signature".to_owned(), format!("sha256={}", crypto::hmac_sha256_hex("shh", body)));
148 assert!(authentic(&signed, body, "shh"));
149 assert!(!authentic(&std::collections::HashMap::new(), body, "shh"));
150 }
151}