Skip to main content

sentry_mcp/
api_client.rs

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}