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