pr_01m47d24b0e6n91zwymwxg0vpx/services/integrations/src/sentry.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 | //! 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 | } |