pr_01m47d24b0e6n91zwymwxg0vpx/services/integrations/src/sentry.rs
| 1 | //! Sentry: issues it reports become g1t issues, the latest event's stack |
| 2 | //! trace goes with them, and the Sentry issue is resolved when the fix |
| 3 | //! lands. |
| 4 | //! |
| 5 | //! Connected as a Sentry internal integration: its webhook points at the |
| 6 | //! connection's address and is signed with the integration's client |
| 7 | //! secret; its token reads events and resolves issues. |
| 8 | |
| 9 | use g1t_contracts::integrations::{ConnectionConfig, ContextItem, Provider}; |
| 10 | use g1t_contracts::time::rfc3339; |
| 11 | use g1t_kit::now_ms; |
| 12 | use serde_json::{Value, json}; |
| 13 | use worker::{Method, Result}; |
| 14 | |
| 15 | use crate::alerts::{Action, Signal}; |
| 16 | use crate::http::{self, Answer}; |
| 17 | |
| 18 | fn site(config: &ConnectionConfig) -> String { |
| 19 | config |
| 20 | .site |
| 21 | .as_deref() |
| 22 | .unwrap_or("https://sentry.io") |
| 23 | .trim_end_matches('/') |
| 24 | .to_owned() |
| 25 | } |
| 26 | |
| 27 | async fn call(config: &ConnectionConfig, token: &str, method: Method, path: &str, body: Option<Value>) -> Result<Answer> { |
| 28 | let auth = format!("Bearer {token}"); |
| 29 | http::send( |
| 30 | method, |
| 31 | &format!("{}/api/0{path}", site(config)), |
| 32 | &[("authorization", &auth)], |
| 33 | body.map(|body| body.to_string()), |
| 34 | ) |
| 35 | .await |
| 36 | } |
| 37 | |
| 38 | fn text(value: &Value) -> Option<String> { |
| 39 | match value { |
| 40 | Value::String(text) if !text.is_empty() => Some(text.clone()), |
| 41 | Value::Number(number) => Some(number.to_string()), |
| 42 | _ => None, |
| 43 | } |
| 44 | } |
| 45 | |
| 46 | /// The address people open the issue at. |
| 47 | fn web_url(config: &ConnectionConfig, issue: &Value, id: &str) -> String { |
| 48 | text(&issue["web_url"]) |
| 49 | .or_else(|| text(&issue["permalink"])) |
| 50 | .unwrap_or_else(|| match &config.organization { |
| 51 | Some(org) => format!("https://{org}.sentry.io/issues/{id}/"), |
| 52 | None => format!("{}/issues/{id}/", site(config)), |
| 53 | }) |
| 54 | } |
| 55 | |
| 56 | /// What a webhook from Sentry asks for, or why it asks for nothing. |
| 57 | pub fn signal(resource: &str, payload: &Value, config: &ConnectionConfig) -> std::result::Result<Signal, String> { |
| 58 | let action = payload["action"].as_str().unwrap_or_default(); |
| 59 | let event = format!("{resource}.{action}"); |
| 60 | let (issue, act) = match (resource, action) { |
| 61 | ("issue", "created") => (&payload["data"]["issue"], Action::Open), |
| 62 | // A regression: Sentry saw it again after it was resolved. |
| 63 | ("issue", "unresolved") => (&payload["data"]["issue"], Action::Reopen), |
| 64 | ("event_alert", "triggered") => (&payload["data"]["event"], Action::Open), |
| 65 | ("installation", _) => return Err("Sentry installed the integration.".to_owned()), |
| 66 | _ => return Err(format!("g1t does nothing with {event}.")), |
| 67 | }; |
| 68 | let id = text(&issue["id"]) |
| 69 | .filter(|_| resource == "issue") |
| 70 | .or_else(|| text(&issue["issue_id"])) |
| 71 | .ok_or_else(|| "The payload names no issue.".to_owned())?; |
| 72 | let title = text(&issue["title"]).unwrap_or_else(|| "A problem Sentry reported".to_owned()); |
| 73 | let key = text(&issue["shortId"]).unwrap_or_else(|| format!("Sentry {id}")); |
| 74 | let mut facts = vec", web_url(config, issue, &id))]; |
| 75 | if let Some(level) = text(&issue["level"]) { |
| 76 | facts.push(level); |
| 77 | } |
| 78 | let count = text(&issue["count"]).and_then(|count| count.parse::<u32>().ok()); |
| 79 | if let Some(count) = count { |
| 80 | facts.push(format!("{count} {}", if count == 1 { "event" } else { "events" })); |
| 81 | } |
| 82 | if let Some(users) = issue["userCount"].as_u64().filter(|users| *users > 0) { |
| 83 | facts.push(format!("{users} {}", if users == 1 { "user" } else { "users" })); |
| 84 | } |
| 85 | let mut body = vec![facts.join(" · ")]; |
| 86 | if let Some(project) = text(&issue["project"]["slug"]).or_else(|| text(&issue["project"])) { |
| 87 | body.push(format!("Project `{project}`.")); |
| 88 | } |
| 89 | let what = [text(&issue["metadata"]["type"]), text(&issue["metadata"]["value"])] |
| 90 | .into_iter() |
| 91 | .flatten() |
| 92 | .collect::<Vec<_>>() |
| 93 | .join(": "); |
| 94 | if !what.is_empty() { |
| 95 | body.push(format!("> {}", http::shorten(&what, 600).replace('\n', "\n> "))); |
| 96 | } |
| 97 | if let Some(culprit) = text(&issue["culprit"]) { |
| 98 | body.push(format!("In `{culprit}`.")); |
| 99 | } |
| 100 | // An alert carries its event, and with it the stack trace. |
| 101 | if let Some(trace) = stack_trace(issue) { |
| 102 | body.push(trace); |
| 103 | } |
| 104 | Ok(Signal { |
| 105 | event, |
| 106 | action: act, |
| 107 | external_id: id, |
| 108 | key, |
| 109 | title, |
| 110 | url: web_url(config, issue, &text(&issue["id"]).unwrap_or_default()), |
| 111 | body: body.join("\n\n"), |
| 112 | count, |
| 113 | }) |
| 114 | } |
| 115 | |
| 116 | /// The frames of an event's exceptions, newest last, as a code block. |
| 117 | /// Takes both the API's form (`entries`) and the raw event's |
| 118 | /// (`exception.values`). |
| 119 | pub fn stack_trace(event: &Value) -> Option<String> { |
| 120 | let values: Vec<&Value> = event["entries"] |
| 121 | .as_array() |
| 122 | .and_then(|entries| entries.iter().find(|entry| entry["type"] == "exception")) |
| 123 | .and_then(|entry| entry["data"]["values"].as_array()) |
| 124 | .or_else(|| event["exception"]["values"].as_array()) |
| 125 | .map(|values| values.iter().collect()) |
| 126 | .unwrap_or_default(); |
| 127 | let mut lines = Vec::new(); |
| 128 | for value in values { |
| 129 | let frames = value["stacktrace"]["frames"].as_array().cloned().unwrap_or_default(); |
| 130 | let in_app: Vec<&Value> = frames |
| 131 | .iter() |
| 132 | .filter(|frame| frame["inApp"].as_bool().or(frame["in_app"].as_bool()) == Some(true)) |
| 133 | .collect(); |
| 134 | let shown: Vec<&Value> = if in_app.is_empty() { frames.iter().collect() } else { in_app }; |
| 135 | let skip = shown.len().saturating_sub(12); |
| 136 | if let Some(kind) = text(&value["type"]) { |
| 137 | lines.push(format!("{kind}: {}", text(&value["value"]).unwrap_or_default())); |
| 138 | } |
| 139 | for (at, frame) in shown.iter().enumerate().skip(skip) { |
| 140 | let file = text(&frame["filename"]).or_else(|| text(&frame["absPath"])).unwrap_or_else(|| "?".to_owned()); |
| 141 | let line = frame["lineNo"].as_u64().or(frame["lineno"].as_u64()); |
| 142 | let function = text(&frame["function"]).unwrap_or_else(|| "?".to_owned()); |
| 143 | lines.push(format!( |
| 144 | " {file}{} in {function}", |
| 145 | line.map(|line| format!(":{line}")).unwrap_or_default() |
| 146 | )); |
| 147 | // The line itself, for the frame the error was raised in. |
| 148 | if at + 1 == shown.len() |
| 149 | && let Some(code) = frame["context"] |
| 150 | .as_array() |
| 151 | .and_then(|context| context.iter().find(|pair| pair[0].as_u64() == line)) |
| 152 | .and_then(|pair| pair[1].as_str()) |
| 153 | { |
| 154 | lines.push(format!(" > {}", code.trim())); |
| 155 | } |
| 156 | } |
| 157 | } |
| 158 | (!lines.is_empty()).then(|| format!("Stack trace, most recent call last:\n\n```\n{}\n```", lines.join("\n"))) |
| 159 | } |
| 160 | |
| 161 | /// The stack trace of the issue's latest event, read with the token. |
| 162 | pub async fn latest_trace(config: &ConnectionConfig, token: &str, id: &str) -> Result<Option<String>> { |
| 163 | let Some(org) = &config.organization else { |
| 164 | return Ok(None); |
| 165 | }; |
| 166 | let answer = call(config, token, Method::Get, &format!("/organizations/{org}/issues/{id}/events/latest/"), None).await?; |
| 167 | Ok(answer.ok().then(|| stack_trace(&answer.json())).flatten()) |
| 168 | } |
| 169 | |
| 170 | /// A Sentry issue as it is now. |
| 171 | pub async fn fetch(config: &ConnectionConfig, token: &str, id: &str) -> Result<std::result::Result<Option<ContextItem>, String>> { |
| 172 | let Some(org) = &config.organization else { |
| 173 | return Ok(Err("The Sentry connection names no organization.".to_owned())); |
| 174 | }; |
| 175 | let answer = call(config, token, Method::Get, &format!("/organizations/{org}/issues/{id}/"), None).await?; |
| 176 | if answer.status == 404 { |
| 177 | return Ok(Ok(None)); |
| 178 | } |
| 179 | if !answer.ok() { |
| 180 | return Ok(Err(answer.problem("Sentry"))); |
| 181 | } |
| 182 | let issue = answer.json(); |
| 183 | let mut body = Vec::new(); |
| 184 | if let Some(culprit) = text(&issue["culprit"]) { |
| 185 | body.push(format!("In `{culprit}`.")); |
| 186 | } |
| 187 | if let Some(count) = text(&issue["count"]) { |
| 188 | body.push(format!("{count} events, last seen {}.", text(&issue["lastSeen"]).unwrap_or_default())); |
| 189 | } |
| 190 | if let Some(trace) = latest_trace(config, token, id).await? { |
| 191 | body.push(trace); |
| 192 | } |
| 193 | Ok(Ok(Some(ContextItem { |
| 194 | provider: Provider::Sentry, |
| 195 | key: text(&issue["shortId"]).unwrap_or_else(|| id.to_owned()), |
| 196 | title: text(&issue["title"]).unwrap_or_default(), |
| 197 | url: web_url(config, &issue, id), |
| 198 | status: text(&issue["status"]), |
| 199 | body: http::shorten(&body.join("\n\n"), 6000), |
| 200 | fetched_at: rfc3339(now_ms()), |
| 201 | }))) |
| 202 | } |
| 203 | |
| 204 | /// Marks the Sentry issue resolved and says why. `Err` with what went |
| 205 | /// wrong, for the connection to show. |
| 206 | pub async fn resolve(config: &ConnectionConfig, token: &str, id: &str, note: &str) -> Result<std::result::Result<(), String>> { |
| 207 | let Some(org) = &config.organization else { |
| 208 | return Ok(Err("The Sentry connection names no organization.".to_owned())); |
| 209 | }; |
| 210 | let answer = call( |
| 211 | config, |
| 212 | token, |
| 213 | Method::Put, |
| 214 | &format!("/organizations/{org}/issues/{id}/"), |
| 215 | Some(json!({ "status": "resolved" })), |
| 216 | ) |
| 217 | .await?; |
| 218 | if !answer.ok() { |
| 219 | return Ok(Err(answer.problem("Sentry"))); |
| 220 | } |
| 221 | comment(config, token, id, note).await |
| 222 | } |
| 223 | |
| 224 | pub async fn comment(config: &ConnectionConfig, token: &str, id: &str, note: &str) -> Result<std::result::Result<(), String>> { |
| 225 | let answer = call(config, token, Method::Post, &format!("/issues/{id}/comments/"), Some(json!({ "text": note }))).await?; |
| 226 | Ok(if answer.ok() { Ok(()) } else { Err(answer.problem("Sentry")) }) |
| 227 | } |
| 228 | |
| 229 | pub async fn test(config: &ConnectionConfig, token: &str) -> Result<std::result::Result<String, String>> { |
| 230 | let Some(org) = &config.organization else { |
| 231 | return Ok(Err("Name the Sentry organization, by its slug.".to_owned())); |
| 232 | }; |
| 233 | let answer = call(config, token, Method::Get, &format!("/organizations/{org}/"), None).await?; |
| 234 | Ok(if answer.ok() { |
| 235 | Ok(format!("Connected to {}.", text(&answer.json()["name"]).unwrap_or_else(|| org.clone()))) |
| 236 | } else { |
| 237 | Err(answer.problem("Sentry")) |
| 238 | }) |
| 239 | } |
| 240 | |
| 241 | #[cfg(test)] |
| 242 | mod tests { |
| 243 | use super::*; |
| 244 | |
| 245 | fn config() -> ConnectionConfig { |
| 246 | ConnectionConfig { |
| 247 | organization: Some("acme".to_owned()), |
| 248 | ..ConnectionConfig::default() |
| 249 | } |
| 250 | } |
| 251 | |
| 252 | #[test] |
| 253 | fn a_new_issue_opens_one() { |
| 254 | let payload = json!({ |
| 255 | "action": "created", |
| 256 | "data": { "issue": { |
| 257 | "id": "4509", "shortId": "WEB-3F", "title": "TypeError: x is undefined", |
| 258 | "culprit": "getUser(src/api/users.ts)", "level": "error", "count": "12", "userCount": 3, |
| 259 | "project": { "slug": "web" }, |
| 260 | "metadata": { "type": "TypeError", "value": "x is undefined" } |
| 261 | }} |
| 262 | }); |
| 263 | let signal = signal("issue", &payload, &config()).unwrap(); |
| 264 | assert_eq!(signal.external_id, "4509"); |
| 265 | assert_eq!(signal.key, "WEB-3F"); |
| 266 | assert_eq!(signal.action, Action::Open); |
| 267 | assert_eq!(signal.count, Some(12)); |
| 268 | assert_eq!(signal.url, "https://acme.sentry.io/issues/4509/"); |
| 269 | assert!(signal.body.contains("12 events · 3 users")); |
| 270 | assert!(signal.body.contains("> TypeError: x is undefined")); |
| 271 | } |
| 272 | |
| 273 | #[test] |
| 274 | fn a_regression_reopens_and_other_actions_are_ignored() { |
| 275 | let issue = json!({ "id": "1", "title": "t" }); |
| 276 | let reopened = signal("issue", &json!({ "action": "unresolved", "data": { "issue": issue } }), &config()).unwrap(); |
| 277 | assert_eq!(reopened.action, Action::Reopen); |
| 278 | assert!(signal("issue", &json!({ "action": "assigned", "data": { "issue": issue } }), &config()).is_err()); |
| 279 | assert!(signal("installation", &json!({ "action": "created" }), &config()).is_err()); |
| 280 | } |
| 281 | |
| 282 | #[test] |
| 283 | fn an_alert_brings_its_stack_trace() { |
| 284 | let payload = json!({ |
| 285 | "action": "triggered", |
| 286 | "data": { "event": { |
| 287 | "issue_id": "77", "title": "boom", "web_url": "https://acme.sentry.io/issues/77/events/abc/", |
| 288 | "exception": { "values": [{ "type": "Error", "value": "boom", "stacktrace": { "frames": [ |
| 289 | { "filename": "node_modules/x.js", "lineno": 1, "function": "lib", "in_app": false }, |
| 290 | { "filename": "src/a.ts", "lineno": 10, "function": "outer", "in_app": true }, |
| 291 | { "filename": "src/b.ts", "lineno": 20, "function": "inner", "in_app": true, |
| 292 | "context": [[19, " const y = 1;"], [20, " throw new Error('boom');"]] } |
| 293 | ]}}]} |
| 294 | }} |
| 295 | }); |
| 296 | let signal = signal("event_alert", &payload, &config()).unwrap(); |
| 297 | assert_eq!(signal.external_id, "77"); |
| 298 | assert!(signal.body.contains("src/a.ts:10 in outer\n src/b.ts:20 in inner\n > throw new Error('boom');")); |
| 299 | assert!(!signal.body.contains("node_modules")); |
| 300 | } |
| 301 | } |