Skip to content

Commit 05c454c

Browse files
committed
Refactor API and add transaction list tool
- Add 'list_transactions' endpoint - Move API into its own submodule (getting a little large) - Improve some tool responses and expand coverage We cant yet reliable export a trace until Sentry lands some new APIs, so considering this a PoC.
1 parent 613b364 commit 05c454c

File tree

13 files changed

+733
-245
lines changed

13 files changed

+733
-245
lines changed

src/evals/workflow.eval.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,21 @@ describeEval("workflow", {
5252
`- **URL**: https://${CONFIG.organizationSlug}.sentry.io/issues/REMOTE-MCP-41`,
5353
].join("\n"),
5454
},
55+
{
56+
input: "Analyze issue REMOTE-MCP-41 from Sentry.",
57+
expected: [
58+
"## REMOTE-MCP-41",
59+
"- **Error**: Tool list_organizations is already registered",
60+
"- **Issue ID**: REMOTE-MCP-41",
61+
"- **Stacktrace**:",
62+
"```",
63+
"index.js at line 7809:27",
64+
'"index.js" at line 8029:24',
65+
'"index.js" at line 19631:28',
66+
"```",
67+
`- **URL**: https://${CONFIG.organizationSlug}.sentry.io/issues/REMOTE-MCP-41`,
68+
].join("\n"),
69+
},
5570
];
5671
},
5772
task: async (input) => {

src/lib/sentry-api/client.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { describe, it, expect } from "vitest";
2+
import { SentryApiService } from "./client";
3+
4+
describe("getIssueUrl", () => {
5+
it("should work with sentry.io", () => {
6+
const apiService = new SentryApiService(null, "sentry.io");
7+
const result = apiService.getIssueUrl("sentry-mcp", "123456");
8+
expect(result).toMatchInlineSnapshot(
9+
`"https://sentry-mcp.sentry.io/issues/123456"`,
10+
);
11+
});
12+
it("should work with self-hosted", () => {
13+
const apiService = new SentryApiService(null, "sentry.example.com");
14+
const result = apiService.getIssueUrl("sentry-mcp", "123456");
15+
expect(result).toMatchInlineSnapshot(
16+
`"https://sentry.example.com/organizations/sentry-mcp/issues/123456"`,
17+
);
18+
});
19+
});
20+
21+
describe("getTraceUrl", () => {
22+
it("should work with sentry.io", () => {
23+
const apiService = new SentryApiService(null, "sentry.io");
24+
const result = apiService.getTraceUrl(
25+
"sentry-mcp",
26+
"6a477f5b0f31ef7b6b9b5e1dea66c91d",
27+
);
28+
expect(result).toMatchInlineSnapshot(
29+
`"https://sentry-mcp.sentry.io/explore/traces/trace/6a477f5b0f31ef7b6b9b5e1dea66c91d"`,
30+
);
31+
});
32+
it("should work with self-hosted", () => {
33+
const apiService = new SentryApiService(null, "sentry.example.com");
34+
const result = apiService.getTraceUrl(
35+
"sentry-mcp",
36+
"6a477f5b0f31ef7b6b9b5e1dea66c91d",
37+
);
38+
expect(result).toMatchInlineSnapshot(
39+
`"https://sentry.example.com/organizations/sentry-mcp/explore/traces/trace/6a477f5b0f31ef7b6b9b5e1dea66c91d"`,
40+
);
41+
});
42+
});
Lines changed: 115 additions & 167 deletions
Original file line numberDiff line numberDiff line change
@@ -1,163 +1,16 @@
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";
16114

16215
export class SentryApiService {
16316
private accessToken: string | null;
@@ -208,6 +61,12 @@ export class SentryApiService {
20861
: `https://${organizationSlug}.${this.host}/issues/${issueId}`;
20962
}
21063

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+
21170
async listOrganizations(): Promise<z.infer<typeof SentryOrgSchema>[]> {
21271
const response = await this.request("/organizations/");
21372

@@ -301,6 +160,21 @@ export class SentryApiService {
301160
return [project, null];
302161
}
303162

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+
304178
async getLatestEventForIssue({
305179
organizationSlug,
306180
issueId,
@@ -316,25 +190,44 @@ export class SentryApiService {
316190
return SentryEventSchema.parse(body);
317191
}
318192

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({
321210
organizationSlug,
322211
projectSlug,
323212
filename,
213+
transaction,
324214
query,
325215
sortBy = "last_seen",
326216
}: {
327-
dataset: "errors" | "transactions";
328217
organizationSlug: string;
329218
projectSlug?: string;
330219
filename?: string;
220+
transaction?: string;
331221
query?: string;
332222
sortBy?: "last_seen" | "count";
333-
}): Promise<z.infer<typeof SentryDiscoverEventSchema>[]> {
223+
}): Promise<z.infer<typeof SentrySearchErrorsEventSchema>[]> {
334224
const sentryQuery: string[] = [];
335225
if (filename) {
336226
sentryQuery.push(`stack.filename:"*${filename.replace(/"/g, '\\"')}"`);
337227
}
228+
if (transaction) {
229+
sentryQuery.push(`transaction:"${transaction.replace(/"/g, '\\"')}"`);
230+
}
338231
if (query) {
339232
sentryQuery.push(query);
340233
}
@@ -343,7 +236,7 @@ export class SentryApiService {
343236
}
344237

345238
const queryParams = new URLSearchParams();
346-
queryParams.set("dataset", dataset);
239+
queryParams.set("dataset", "errors");
347240
queryParams.set("per_page", "10");
348241
queryParams.set("referrer", "sentry-mcp");
349242
queryParams.set(
@@ -363,7 +256,62 @@ export class SentryApiService {
363256

364257
const response = await this.request(apiUrl);
365258

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));
368316
}
369317
}

0 commit comments

Comments
 (0)