1
- import { z } from "zod" ;
2
- import { logError } from "./logging" ;
3
-
4
- export const SentryOrgSchema = z . object ( {
5
- id : z . string ( ) ,
6
- slug : z . string ( ) ,
7
- name : z . string ( ) ,
8
- } ) ;
9
-
10
- export const SentryTeamSchema = z . object ( {
11
- id : z . string ( ) ,
12
- slug : z . string ( ) ,
13
- name : z . string ( ) ,
14
- } ) ;
15
-
16
- export const SentryProjectSchema = z . object ( {
17
- id : z . string ( ) ,
18
- slug : z . string ( ) ,
19
- name : z . string ( ) ,
20
- } ) ;
21
-
22
- export const SentryClientKeySchema = z . object ( {
23
- id : z . string ( ) ,
24
- dsn : z . object ( {
25
- public : z . string ( ) ,
26
- } ) ,
27
- } ) ;
28
-
29
- export const SentryIssueSchema = z . object ( {
30
- id : z . string ( ) ,
31
- shortId : z . string ( ) ,
32
- title : z . string ( ) ,
33
- lastSeen : z . string ( ) . datetime ( ) ,
34
- count : z . number ( ) ,
35
- permalink : z . string ( ) . url ( ) ,
36
- } ) ;
37
-
38
- export const SentryFrameInterface = z
39
- . object ( {
40
- filename : z . string ( ) . nullable ( ) ,
41
- function : z . string ( ) . nullable ( ) ,
42
- lineNo : z . number ( ) . nullable ( ) ,
43
- colNo : z . number ( ) . nullable ( ) ,
44
- absPath : z . string ( ) . nullable ( ) ,
45
- module : z . string ( ) . nullable ( ) ,
46
- // lineno, source code
47
- context : z . array ( z . tuple ( [ z . number ( ) , z . string ( ) ] ) ) ,
48
- } )
49
- . partial ( ) ;
50
-
51
- // XXX: Sentry's schema generally speaking is "assume all user input is missing"
52
- // so we need to handle effectively every field being optional or nullable.
53
- export const SentryExceptionInterface = z
54
- . object ( {
55
- mechanism : z
56
- . object ( {
57
- type : z . string ( ) . nullable ( ) ,
58
- handled : z . boolean ( ) . nullable ( ) ,
59
- } )
60
- . partial ( ) ,
61
- type : z . string ( ) . nullable ( ) ,
62
- value : z . string ( ) . nullable ( ) ,
63
- stacktrace : z . object ( {
64
- frames : z . array ( SentryFrameInterface ) ,
65
- } ) ,
66
- } )
67
- . partial ( ) ;
68
-
69
- export const SentryErrorEntrySchema = z . object ( {
70
- // XXX: Sentry can return either of these. Not sure why we never normalized it.
71
- values : z . array ( SentryExceptionInterface . optional ( ) ) ,
72
- value : SentryExceptionInterface . nullable ( ) . optional ( ) ,
73
- } ) ;
74
-
75
- export const SentryEventSchema = z . object ( {
76
- id : z . string ( ) ,
77
- title : z . string ( ) ,
78
- message : z . string ( ) . nullable ( ) ,
79
- dateCreated : z . string ( ) . datetime ( ) ,
80
- culprit : z . string ( ) . nullable ( ) ,
81
- platform : z . string ( ) . nullable ( ) ,
82
- entries : z . array (
83
- z . union ( [
84
- // TODO: there are other types
85
- z . object ( {
86
- type : z . literal ( "exception" ) ,
87
- data : SentryErrorEntrySchema ,
88
- } ) ,
89
- z . object ( {
90
- type : z . string ( ) ,
91
- data : z . unknown ( ) ,
92
- } ) ,
93
- ] ) ,
94
- ) ,
95
- } ) ;
96
-
97
- // https://us.sentry.io/api/0/organizations/sentry/events/?dataset=errors&field=issue&field=title&field=project&field=timestamp&field=trace&per_page=5&query=event.type%3Aerror&referrer=sentry-mcp&sort=-timestamp&statsPeriod=1w
98
- export const SentryDiscoverEventSchema = z . object ( {
99
- issue : z . string ( ) ,
100
- "issue.id" : z . union ( [ z . string ( ) , z . number ( ) ] ) ,
101
- project : z . string ( ) ,
102
- title : z . string ( ) ,
103
- "count()" : z . number ( ) ,
104
- "last_seen()" : z . string ( ) ,
105
- } ) ;
106
-
107
- /**
108
- * Extracts the Sentry issue ID and organization slug from a full URL
109
- *
110
- * @param url - A full Sentry issue URL
111
- * @returns Object containing the numeric issue ID and organization slug (if found)
112
- * @throws Error if the input is invalid
113
- */
114
- export function extractIssueId ( url : string ) : {
115
- issueId : string ;
116
- organizationSlug : string ;
117
- } {
118
- if ( ! url . startsWith ( "http://" ) && ! url . startsWith ( "https://" ) ) {
119
- throw new Error (
120
- "Invalid Sentry issue URL. Must start with http:// or https://" ,
121
- ) ;
122
- }
123
-
124
- const parsedUrl = new URL ( url ) ;
125
-
126
- const pathParts = parsedUrl . pathname . split ( "/" ) . filter ( Boolean ) ;
127
- if ( pathParts . length < 2 || ! pathParts . includes ( "issues" ) ) {
128
- throw new Error (
129
- "Invalid Sentry issue URL. Path must contain '/issues/{issue_id}'" ,
130
- ) ;
131
- }
132
-
133
- const issueId = pathParts [ pathParts . indexOf ( "issues" ) + 1 ] ;
134
- if ( ! issueId || ! / ^ \d + $ / . test ( issueId ) ) {
135
- throw new Error ( "Invalid Sentry issue ID. Must be a numeric value." ) ;
136
- }
137
-
138
- // Extract organization slug from either the path or subdomain
139
- let organizationSlug : string | undefined ;
140
- if ( pathParts . includes ( "organizations" ) ) {
141
- organizationSlug = pathParts [ pathParts . indexOf ( "organizations" ) + 1 ] ;
142
- } else if ( pathParts . length > 1 && pathParts [ 0 ] !== "issues" ) {
143
- // If URL is like sentry.io/sentry/issues/123
144
- organizationSlug = pathParts [ 0 ] ;
145
- } else {
146
- // Check for subdomain
147
- const hostParts = parsedUrl . hostname . split ( "." ) ;
148
- if ( hostParts . length > 2 && hostParts [ 0 ] !== "www" ) {
149
- organizationSlug = hostParts [ 0 ] ;
150
- }
151
- }
152
-
153
- if ( ! organizationSlug ) {
154
- throw new Error (
155
- "Invalid Sentry issue URL. Could not determine organization." ,
156
- ) ;
157
- }
158
-
159
- return { issueId, organizationSlug } ;
160
- }
1
+ import type { z } from "zod" ;
2
+ import { logError } from "../logging" ;
3
+ import {
4
+ SentryClientKeySchema ,
5
+ SentryEventSchema ,
6
+ type SentryEventsResponseSchema ,
7
+ SentryIssueSchema ,
8
+ SentryOrgSchema ,
9
+ SentryProjectSchema ,
10
+ SentrySearchErrorsEventSchema ,
11
+ SentrySearchSpansEventSchema ,
12
+ SentryTeamSchema ,
13
+ } from "./schema" ;
161
14
162
15
export class SentryApiService {
163
16
private accessToken : string | null ;
@@ -208,6 +61,12 @@ export class SentryApiService {
208
61
: `https://${ organizationSlug } .${ this . host } /issues/${ issueId } ` ;
209
62
}
210
63
64
+ getTraceUrl ( organizationSlug : string , traceId : string ) : string {
65
+ return this . host !== "sentry.io"
66
+ ? `https://${ this . host } /organizations/${ organizationSlug } /explore/traces/trace/${ traceId } `
67
+ : `https://${ organizationSlug } .${ this . host } /explore/traces/trace/${ traceId } ` ;
68
+ }
69
+
211
70
async listOrganizations ( ) : Promise < z . infer < typeof SentryOrgSchema > [ ] > {
212
71
const response = await this . request ( "/organizations/" ) ;
213
72
@@ -301,6 +160,21 @@ export class SentryApiService {
301
160
return [ project , null ] ;
302
161
}
303
162
163
+ async getIssue ( {
164
+ organizationSlug,
165
+ issueId,
166
+ } : {
167
+ organizationSlug : string ;
168
+ issueId : string ;
169
+ } ) : Promise < z . infer < typeof SentryIssueSchema > > {
170
+ const response = await this . request (
171
+ `/organizations/${ organizationSlug } /issues/${ issueId } /` ,
172
+ ) ;
173
+
174
+ const body = await response . json ( ) ;
175
+ return SentryIssueSchema . parse ( body ) ;
176
+ }
177
+
304
178
async getLatestEventForIssue ( {
305
179
organizationSlug,
306
180
issueId,
@@ -316,25 +190,44 @@ export class SentryApiService {
316
190
return SentryEventSchema . parse ( body ) ;
317
191
}
318
192
319
- async searchEvents ( {
320
- dataset,
193
+ // TODO: Sentry is not yet exposing a reasonable API to fetch trace data
194
+ // async getTrace({
195
+ // organizationSlug,
196
+ // traceId,
197
+ // }: {
198
+ // organizationSlug: string;
199
+ // traceId: string;
200
+ // }): Promise<z.infer<typeof SentryIssueSchema>> {
201
+ // const response = await this.request(
202
+ // `/organizations/${organizationSlug}/issues/${traceId}/`,
203
+ // );
204
+
205
+ // const body = await response.json();
206
+ // return SentryIssueSchema.parse(body);
207
+ // }
208
+
209
+ async searchErrors ( {
321
210
organizationSlug,
322
211
projectSlug,
323
212
filename,
213
+ transaction,
324
214
query,
325
215
sortBy = "last_seen" ,
326
216
} : {
327
- dataset : "errors" | "transactions" ;
328
217
organizationSlug : string ;
329
218
projectSlug ?: string ;
330
219
filename ?: string ;
220
+ transaction ?: string ;
331
221
query ?: string ;
332
222
sortBy ?: "last_seen" | "count" ;
333
- } ) : Promise < z . infer < typeof SentryDiscoverEventSchema > [ ] > {
223
+ } ) : Promise < z . infer < typeof SentrySearchErrorsEventSchema > [ ] > {
334
224
const sentryQuery : string [ ] = [ ] ;
335
225
if ( filename ) {
336
226
sentryQuery . push ( `stack.filename:"*${ filename . replace ( / " / g, '\\"' ) } "` ) ;
337
227
}
228
+ if ( transaction ) {
229
+ sentryQuery . push ( `transaction:"${ transaction . replace ( / " / g, '\\"' ) } "` ) ;
230
+ }
338
231
if ( query ) {
339
232
sentryQuery . push ( query ) ;
340
233
}
@@ -343,7 +236,7 @@ export class SentryApiService {
343
236
}
344
237
345
238
const queryParams = new URLSearchParams ( ) ;
346
- queryParams . set ( "dataset" , dataset ) ;
239
+ queryParams . set ( "dataset" , "errors" ) ;
347
240
queryParams . set ( "per_page" , "10" ) ;
348
241
queryParams . set ( "referrer" , "sentry-mcp" ) ;
349
242
queryParams . set (
@@ -363,7 +256,62 @@ export class SentryApiService {
363
256
364
257
const response = await this . request ( apiUrl ) ;
365
258
366
- const listBody = await response . json < { data : unknown [ ] } > ( ) ;
367
- return listBody . data . map ( ( i ) => SentryDiscoverEventSchema . parse ( i ) ) ;
259
+ const listBody =
260
+ await response . json < z . infer < typeof SentryEventsResponseSchema > > ( ) ;
261
+ return listBody . data . map ( ( i ) => SentrySearchErrorsEventSchema . parse ( i ) ) ;
262
+ }
263
+
264
+ async searchSpans ( {
265
+ organizationSlug,
266
+ projectSlug,
267
+ transaction,
268
+ query,
269
+ sortBy = "timestamp" ,
270
+ } : {
271
+ organizationSlug : string ;
272
+ projectSlug ?: string ;
273
+ transaction ?: string ;
274
+ query ?: string ;
275
+ sortBy ?: "timestamp" | "duration" ;
276
+ } ) : Promise < z . infer < typeof SentrySearchSpansEventSchema > [ ] > {
277
+ const sentryQuery : string [ ] = [ "is_transaction:true" ] ;
278
+ if ( transaction ) {
279
+ sentryQuery . push ( `transaction:"${ transaction . replace ( / " / g, '\\"' ) } "` ) ;
280
+ }
281
+ if ( query ) {
282
+ sentryQuery . push ( query ) ;
283
+ }
284
+ if ( projectSlug ) {
285
+ sentryQuery . push ( `project:${ projectSlug } ` ) ;
286
+ }
287
+
288
+ const queryParams = new URLSearchParams ( ) ;
289
+ queryParams . set ( "dataset" , "spans" ) ;
290
+ queryParams . set ( "per_page" , "10" ) ;
291
+ queryParams . set ( "referrer" , "sentry-mcp" ) ;
292
+ queryParams . set (
293
+ "sort" ,
294
+ `-${ sortBy === "timestamp" ? "timestamp" : "span.duration" } ` ,
295
+ ) ;
296
+ queryParams . set ( "allowAggregateConditions" , "0" ) ;
297
+ queryParams . set ( "useRpc" , "1" ) ;
298
+ queryParams . append ( "field" , "id" ) ;
299
+ queryParams . append ( "field" , "trace" ) ;
300
+ queryParams . append ( "field" , "span.op" ) ;
301
+ queryParams . append ( "field" , "span.description" ) ;
302
+ queryParams . append ( "field" , "span.duration" ) ;
303
+ queryParams . append ( "field" , "transaction" ) ;
304
+ queryParams . append ( "field" , "project" ) ;
305
+ queryParams . append ( "field" , "timestamp" ) ;
306
+ queryParams . set ( "query" , sentryQuery . join ( " " ) ) ;
307
+ // if (projectSlug) queryParams.set("project", projectSlug);
308
+
309
+ const apiUrl = `/organizations/${ organizationSlug } /events/?${ queryParams . toString ( ) } ` ;
310
+
311
+ const response = await this . request ( apiUrl ) ;
312
+
313
+ const listBody =
314
+ await response . json < z . infer < typeof SentryEventsResponseSchema > > ( ) ;
315
+ return listBody . data . map ( ( i ) => SentrySearchSpansEventSchema . parse ( i ) ) ;
368
316
}
369
317
}
0 commit comments