pr_01m47d15m3e54sn21z27rpy5n9/services/integrations/src/alerts.rs
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.
| Integrations: your own model provider, alerts that open issues, tickets agents read | 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 | ||
| 5 | use serde_json::Value; | |
| 6 | ||
| Webhooks: every event, to your own addresses, signed and retried | 7 | use g1t_secrets as crypto; |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 8 | |
| 9 | /// What an alert asks g1t to do. | |
| 10 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 11 | pub 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)] | |
| 22 | pub 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 | ||
| 37 | fn 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`. | |
| 48 | pub 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. | |
| 66 | pub 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"), | |
| 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)] | |
| 109 | mod 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 | } |