Skip to content
151 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor1//! 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}

This file's history is long; its oldest lines are credited to the oldest commit read.