1use async_trait::async_trait;
2use reqwest::{Client, header};
3use serde::{Deserialize, Serialize};
4use std::env;
5use tracing::info;
6
7#[async_trait]
8pub trait SentryApi: Send + Sync {
9 async fn get_issue(&self, org_slug: &str, issue_id: &str) -> anyhow::Result<Issue>;
10 async fn get_latest_event(&self, org_slug: &str, issue_id: &str) -> anyhow::Result<Event>;
11 async fn get_event(&self, org_slug: &str, issue_id: &str, event_id: &str) -> anyhow::Result<Event>;
12 async fn get_trace(&self, org_slug: &str, trace_id: &str) -> anyhow::Result<TraceResponse>;
13 async fn list_events_for_issue(&self, org_slug: &str, issue_id: &str, query: &EventsQuery) -> anyhow::Result<Vec<Event>>;
14}
15
16pub struct SentryApiClient {
17 client: Client,
18 base_url: String,
19}
20
21#[derive(Debug, Clone, Deserialize)]
22#[serde(rename_all = "camelCase")]
23#[allow(dead_code)]
24pub struct Issue {
25 pub id: String,
26 pub short_id: String,
27 pub title: String,
28 pub culprit: Option<String>,
29 pub status: String,
30 #[serde(default)]
31 pub substatus: Option<String>,
32 #[serde(default)]
33 pub level: Option<String>,
34 pub platform: Option<String>,
35 pub project: Project,
36 pub first_seen: String,
37 pub last_seen: String,
38 pub count: String,
39 #[serde(rename = "userCount")]
40 pub user_count: i64,
41 pub permalink: Option<String>,
42 #[serde(default)]
43 pub metadata: serde_json::Value,
44 #[serde(default)]
45 pub tags: Vec<IssueTag>,
46 #[serde(default, rename = "issueType")]
47 pub issue_type: Option<String>,
48 #[serde(default, rename = "issueCategory")]
49 pub issue_category: Option<String>,
50}
51
52#[derive(Debug, Clone, Deserialize)]
53#[allow(dead_code)]
54pub struct Project {
55 pub id: String,
56 pub name: String,
57 pub slug: String,
58}
59
60#[derive(Debug, Clone, Deserialize)]
61#[allow(dead_code)]
62pub struct IssueTag {
63 pub key: String,
64 pub name: String,
65 #[serde(rename = "totalValues")]
66 pub total_values: i64,
67}
68
69#[derive(Debug, Clone, Deserialize)]
70pub struct EventTag {
71 pub key: String,
72 pub value: String,
73}
74
75#[derive(Debug, Clone, Deserialize)]
76#[serde(rename_all = "camelCase")]
77#[allow(dead_code)]
78pub struct Event {
79 pub id: String,
80 #[serde(rename = "eventID")]
81 pub event_id: String,
82 #[serde(rename = "dateCreated", default)]
83 pub date_created: Option<String>,
84 #[serde(default)]
85 pub message: Option<String>,
86 #[serde(default)]
87 pub platform: Option<String>,
88 #[serde(default)]
89 pub entries: Vec<EventEntry>,
90 #[serde(default)]
91 pub contexts: serde_json::Value,
92 #[serde(default)]
93 pub context: serde_json::Value,
94 #[serde(default)]
95 pub tags: Vec<EventTag>,
96}
97
98#[derive(Debug, Clone, Deserialize)]
99pub struct EventEntry {
100 #[serde(rename = "type")]
101 pub entry_type: String,
102 #[serde(default)]
103 pub data: serde_json::Value,
104}
105
106#[derive(Debug, Clone, Deserialize)]
107pub struct TraceResponse {
108 pub transactions: Vec<TraceTransaction>,
109 #[serde(default)]
110 pub orphan_errors: Vec<serde_json::Value>,
111}
112
113#[derive(Debug, Clone, Deserialize)]
114#[serde(rename_all = "camelCase")]
115#[allow(dead_code)]
116pub struct TraceTransaction {
117 pub event_id: String,
118 pub project_id: i64,
119 pub project_slug: String,
120 pub transaction: String,
121 #[serde(rename = "start_timestamp")]
122 pub start_timestamp: f64,
123 #[serde(rename = "sdk.name")]
124 pub sdk_name: Option<String>,
125 pub timestamp: f64,
126 #[serde(default)]
127 pub children: Vec<TraceTransaction>,
128 #[serde(default)]
129 pub errors: Vec<serde_json::Value>,
130 pub span_id: Option<String>,
131 pub parent_span_id: Option<String>,
132 #[serde(rename = "span.op")]
133 pub span_op: Option<String>,
134 #[serde(rename = "span.description")]
135 pub span_description: Option<String>,
136 #[serde(rename = "span.status")]
137 pub span_status: Option<String>,
138 #[serde(rename = "span.duration")]
139 pub span_duration: Option<f64>,
140}
141
142#[derive(Debug, Serialize)]
143pub struct EventsQuery {
144 #[serde(skip_serializing_if = "Option::is_none")]
145 pub query: Option<String>,
146 #[serde(skip_serializing_if = "Option::is_none")]
147 pub limit: Option<i32>,
148 #[serde(skip_serializing_if = "Option::is_none")]
149 pub sort: Option<String>,
150}
151
152impl SentryApiClient {
153 pub fn new() -> Self {
154 let auth_token = env::var("SENTRY_AUTH_TOKEN").expect("SENTRY_AUTH_TOKEN must be set");
155 let host = env::var("SENTRY_HOST").unwrap_or_else(|_| "sentry.io".to_string());
156 let base_url = format!("https://{}/api/0", host);
157 let mut headers = header::HeaderMap::new();
158 headers.insert(
159 header::AUTHORIZATION,
160 header::HeaderValue::from_str(&format!("Bearer {}", auth_token)).unwrap(),
161 );
162 let mut builder = Client::builder().default_headers(headers);
163 if let Ok(proxy_url) = env::var("SOCKS_PROXY").or_else(|_| env::var("socks_proxy")) {
164 if let Ok(proxy) = reqwest::Proxy::all(&proxy_url) {
165 builder = builder.proxy(proxy);
166 }
167 } else if let Ok(proxy_url) = env::var("HTTPS_PROXY").or_else(|_| env::var("https_proxy"))
168 && let Ok(proxy) = reqwest::Proxy::https(&proxy_url) {
169 builder = builder.proxy(proxy);
170 }
171 let client = builder.build().expect("Failed to build HTTP client");
172 Self { client, base_url }
173 }
174 #[cfg(test)]
175 pub fn with_base_url(client: Client, base_url: String) -> Self {
176 Self { client, base_url }
177 }
178}
179
180#[async_trait]
181impl SentryApi for SentryApiClient {
182 async fn get_issue(&self, org_slug: &str, issue_id: &str) -> anyhow::Result<Issue> {
183 let url = format!(
184 "{}/organizations/{}/issues/{}/",
185 self.base_url, org_slug, issue_id
186 );
187 info!("GET {}", url);
188 let resp = self.client.get(&url).send().await?;
189 let status = resp.status();
190 if !status.is_success() {
191 let text = resp.text().await.unwrap_or_default();
192 anyhow::bail!("Failed to get issue: {} - {}", status, text);
193 }
194 let text = resp.text().await?;
195 serde_json::from_str(&text).map_err(|e| {
196 tracing::error!("Failed to parse issue JSON: {}. Response: {}", e, &text[..500.min(text.len())]);
197 anyhow::anyhow!("JSON parse error: {}", e)
198 })
199 }
200 async fn get_latest_event(&self, org_slug: &str, issue_id: &str) -> anyhow::Result<Event> {
201 let url = format!(
202 "{}/organizations/{}/issues/{}/events/latest/",
203 self.base_url, org_slug, issue_id
204 );
205 info!("GET {}", url);
206 let resp = self.client.get(&url).send().await?;
207 let status = resp.status();
208 if !status.is_success() {
209 let text = resp.text().await.unwrap_or_default();
210 anyhow::bail!("Failed to get latest event: {} - {}", status, text);
211 }
212 let text = resp.text().await?;
213 serde_json::from_str(&text).map_err(|e| {
214 tracing::error!("Failed to parse event JSON: {}. Response: {}", e, &text[..1000.min(text.len())]);
215 anyhow::anyhow!("JSON parse error: {}", e)
216 })
217 }
218 async fn get_event(
219 &self,
220 org_slug: &str,
221 issue_id: &str,
222 event_id: &str,
223 ) -> anyhow::Result<Event> {
224 let url = format!(
225 "{}/organizations/{}/issues/{}/events/{}/",
226 self.base_url, org_slug, issue_id, event_id
227 );
228 info!("GET {}", url);
229 let resp = self.client.get(&url).send().await?;
230 let status = resp.status();
231 if !status.is_success() {
232 let text = resp.text().await.unwrap_or_default();
233 anyhow::bail!("Failed to get event: {} - {}", status, text);
234 }
235 Ok(resp.json().await?)
236 }
237 async fn get_trace(
238 &self,
239 org_slug: &str,
240 trace_id: &str,
241 ) -> anyhow::Result<TraceResponse> {
242 let url = format!(
243 "{}/organizations/{}/events-trace/{}/?limit=100&useSpans=1",
244 self.base_url, org_slug, trace_id
245 );
246 info!("GET {}", url);
247 let resp = self.client.get(&url).send().await?;
248 let status = resp.status();
249 if !status.is_success() {
250 let text = resp.text().await.unwrap_or_default();
251 anyhow::bail!("Failed to get trace: {} - {}", status, text);
252 }
253 Ok(resp.json().await?)
254 }
255 async fn list_events_for_issue(
256 &self,
257 org_slug: &str,
258 issue_id: &str,
259 query: &EventsQuery,
260 ) -> anyhow::Result<Vec<Event>> {
261 let mut url = format!(
262 "{}/organizations/{}/issues/{}/events/",
263 self.base_url, org_slug, issue_id
264 );
265 let query_string = serde_qs::to_string(query).unwrap_or_default();
266 if !query_string.is_empty() {
267 url.push('?');
268 url.push_str(&query_string);
269 }
270 info!("GET {}", url);
271 let resp = self.client.get(&url).send().await?;
272 let status = resp.status();
273 if !status.is_success() {
274 let text = resp.text().await.unwrap_or_default();
275 anyhow::bail!("Failed to list events: {} - {}", status, text);
276 }
277 Ok(resp.json().await?)
278 }
279}
280
281impl Default for SentryApiClient {
282 fn default() -> Self {
283 Self::new()
284 }
285}
286
287#[cfg(test)]
288mod tests {
289 use super::*;
290 use wiremock::matchers::{method, path};
291 use wiremock::{Mock, MockServer, ResponseTemplate};
292 #[tokio::test]
293 async fn test_get_issue_success() {
294 let mock_server = MockServer::start().await;
295 let response = r#"{
296 "id": "123",
297 "shortId": "PROJ-1",
298 "title": "Test Error",
299 "culprit": "test.py",
300 "status": "unresolved",
301 "project": {"id": "1", "name": "Test", "slug": "test"},
302 "firstSeen": "2024-01-01T00:00:00Z",
303 "lastSeen": "2024-01-02T00:00:00Z",
304 "count": "42",
305 "userCount": 5
306 }"#;
307 Mock::given(method("GET"))
308 .and(path("/organizations/test-org/issues/123/"))
309 .respond_with(ResponseTemplate::new(200).set_body_string(response))
310 .mount(&mock_server)
311 .await;
312 let client = SentryApiClient::with_base_url(Client::new(), mock_server.uri());
313 let issue = client.get_issue("test-org", "123").await.unwrap();
314 assert_eq!(issue.id, "123");
315 assert_eq!(issue.short_id, "PROJ-1");
316 assert_eq!(issue.title, "Test Error");
317 assert_eq!(issue.count, "42");
318 }
319 #[tokio::test]
320 async fn test_get_issue_error() {
321 let mock_server = MockServer::start().await;
322 Mock::given(method("GET"))
323 .and(path("/organizations/test-org/issues/999/"))
324 .respond_with(ResponseTemplate::new(404).set_body_string("Not Found"))
325 .mount(&mock_server)
326 .await;
327 let client = SentryApiClient::with_base_url(Client::new(), mock_server.uri());
328 let result = client.get_issue("test-org", "999").await;
329 assert!(result.is_err());
330 assert!(result.unwrap_err().to_string().contains("404"));
331 }
332 #[tokio::test]
333 async fn test_get_latest_event_success() {
334 let mock_server = MockServer::start().await;
335 let response = r#"{
336 "id": "ev1",
337 "eventID": "abc123",
338 "dateCreated": "2024-01-01T00:00:00Z",
339 "message": "Test message"
340 }"#;
341 Mock::given(method("GET"))
342 .and(path("/organizations/test-org/issues/123/events/latest/"))
343 .respond_with(ResponseTemplate::new(200).set_body_string(response))
344 .mount(&mock_server)
345 .await;
346 let client = SentryApiClient::with_base_url(Client::new(), mock_server.uri());
347 let event = client.get_latest_event("test-org", "123").await.unwrap();
348 assert_eq!(event.event_id, "abc123");
349 assert_eq!(event.date_created, Some("2024-01-01T00:00:00Z".to_string()));
350 }
351 #[tokio::test]
352 async fn test_get_latest_event_without_date_created() {
353 let mock_server = MockServer::start().await;
354 let response = r#"{
355 "id": "ev1",
356 "eventID": "abc123",
357 "message": "Test message"
358 }"#;
359 Mock::given(method("GET"))
360 .and(path("/organizations/test-org/issues/123/events/latest/"))
361 .respond_with(ResponseTemplate::new(200).set_body_string(response))
362 .mount(&mock_server)
363 .await;
364 let client = SentryApiClient::with_base_url(Client::new(), mock_server.uri());
365 let event = client.get_latest_event("test-org", "123").await.unwrap();
366 assert_eq!(event.event_id, "abc123");
367 assert!(event.date_created.is_none());
368 }
369 #[tokio::test]
370 async fn test_get_event_success() {
371 let mock_server = MockServer::start().await;
372 let response = r#"{
373 "id": "ev1",
374 "eventID": "abc123"
375 }"#;
376 Mock::given(method("GET"))
377 .and(path("/organizations/test-org/issues/123/events/abc123/"))
378 .respond_with(ResponseTemplate::new(200).set_body_string(response))
379 .mount(&mock_server)
380 .await;
381 let client = SentryApiClient::with_base_url(Client::new(), mock_server.uri());
382 let event = client.get_event("test-org", "123", "abc123").await.unwrap();
383 assert_eq!(event.event_id, "abc123");
384 }
385 #[tokio::test]
386 async fn test_get_trace_success() {
387 let mock_server = MockServer::start().await;
388 let response = r#"{
389 "transactions": [{
390 "eventId": "tx1",
391 "projectId": 1,
392 "projectSlug": "test",
393 "transaction": "GET /api",
394 "start_timestamp": 1704067200.0,
395 "timestamp": 1704067201.0
396 }],
397 "orphan_errors": []
398 }"#;
399 Mock::given(method("GET"))
400 .and(path("/organizations/test-org/events-trace/trace123/"))
401 .respond_with(ResponseTemplate::new(200).set_body_string(response))
402 .mount(&mock_server)
403 .await;
404 let client = SentryApiClient::with_base_url(Client::new(), mock_server.uri());
405 let trace = client.get_trace("test-org", "trace123").await.unwrap();
406 assert_eq!(trace.transactions.len(), 1);
407 assert_eq!(trace.transactions[0].transaction, "GET /api");
408 }
409 #[tokio::test]
410 async fn test_list_events_for_issue_success() {
411 let mock_server = MockServer::start().await;
412 let response = r#"[
413 {"id": "ev1", "eventID": "abc123"},
414 {"id": "ev2", "eventID": "def456"}
415 ]"#;
416 Mock::given(method("GET"))
417 .and(path("/organizations/test-org/issues/123/events/"))
418 .respond_with(ResponseTemplate::new(200).set_body_string(response))
419 .mount(&mock_server)
420 .await;
421 let client = SentryApiClient::with_base_url(Client::new(), mock_server.uri());
422 let query = EventsQuery { query: None, limit: Some(10), sort: None };
423 let events = client.list_events_for_issue("test-org", "123", &query).await.unwrap();
424 assert_eq!(events.len(), 2);
425 assert_eq!(events[0].event_id, "abc123");
426 assert_eq!(events[1].event_id, "def456");
427 }
428}