flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/services/integrations/src/sentry.rs

301 lines12,957 bytesCodeBlame
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
9use g1t_contracts::integrations::{ConnectionConfig, ContextItem, Provider};
10use g1t_contracts::time::rfc3339;
11use g1t_kit::now_ms;
12use serde_json::{Value, json};
13use worker::{Method, Result};
14
15use crate::alerts::{Action, Signal};
16use crate::http::{self, Answer};
17
18fn 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
27async 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
38fn 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.
47fn 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.
57pub 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![format!("**Sentry** · [{key}]({})", 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`).
119pub 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.
162pub 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.
171pub 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.
206pub 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
224pub 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
229pub 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)]
242mod 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}