Skip to main content

sentry_mcp/
api_client.rs

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}