Skip to content

Commit 5bfe6eb

Browse files
authored
feat: add performance event formatting for N+1 query detection (#345) (#467)
Add comprehensive support for formatting performance issues, particularly N+1 database query detection. Includes evidence data parsing, span tree visualization, and test fixtures for performance events. - Add performance event fixture with N+1 query occurrence data - Extend API schema to support performance issue metadata - Implement formatPerformanceIssueOutput with N+1 query detection - Add span tree visualization for performance issues - Include comprehensive tests for performance formatting Fixes #345
1 parent 0dd4d91 commit 5bfe6eb

11 files changed

Lines changed: 1952 additions & 15 deletions

File tree

‎packages/mcp-server-mocks/src/fixtures/issue.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
"severity": 0,
3333
"severity_reason": "ml",
3434
"initial_priority": 50,
35-
"title": null
35+
"title": "Error: Tool list_organizations is already registered"
3636
},
3737
"numComments": 0,
3838
"assignedTo": null,
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
{
2+
"id": "a1b2c3d4e5f6789012345678901234567",
3+
"groupID": "7890123456",
4+
"eventID": "a1b2c3d4e5f6789012345678901234567",
5+
"projectID": "4509062593708032",
6+
"size": 8432,
7+
"type": "transaction",
8+
"title": "GET /api/users",
9+
"message": "",
10+
"platform": "python",
11+
"datetime": "2025-08-06T18:00:00.000000Z",
12+
"dateCreated": "2025-08-06T18:00:00.000000Z",
13+
"contexts": {
14+
"trace": {
15+
"trace_id": "abcdef1234567890abcdef1234567890",
16+
"span_id": "1234567890abcdef",
17+
"op": "http.server",
18+
"status": "ok",
19+
"exclusive_time": 250.5,
20+
"data": {
21+
"http.request.method": "GET",
22+
"url.path": "/api/users"
23+
}
24+
}
25+
},
26+
"occurrence": {
27+
"id": "occ_123456789",
28+
"projectId": 4509062593708032,
29+
"eventId": "a1b2c3d4e5f6789012345678901234567",
30+
"fingerprint": [
31+
"n_plus_one_db_queries",
32+
"SELECT * FROM users WHERE id = %s"
33+
],
34+
"issueTitle": "N+1 Query: SELECT * FROM users WHERE id = %s",
35+
"subtitle": "Database query repeated 25 times",
36+
"resourceId": null,
37+
"type": 1006,
38+
"detectionTime": 1722963600,
39+
"level": "warning",
40+
"culprit": "SELECT * FROM users WHERE id = %s",
41+
"priority": 50,
42+
"assignee": null,
43+
"evidenceData": {
44+
"transactionName": "/api/users",
45+
"parentSpan": "GET /api/users",
46+
"parentSpanIds": ["parent123"],
47+
"repeatingSpansCompact": ["SELECT * FROM users WHERE id = %s"],
48+
"repeatingSpans": ["db - SELECT * FROM users WHERE id = %s"],
49+
"numberRepeatingSpans": "25",
50+
"numPatternRepetitions": 25,
51+
"offenderSpanIds": [
52+
"span001",
53+
"span002",
54+
"span003",
55+
"span004",
56+
"span005",
57+
"span006",
58+
"span007",
59+
"span008",
60+
"span009",
61+
"span010",
62+
"span011",
63+
"span012",
64+
"span013",
65+
"span014",
66+
"span015",
67+
"span016",
68+
"span017",
69+
"span018",
70+
"span019",
71+
"span020",
72+
"span021",
73+
"span022",
74+
"span023",
75+
"span024",
76+
"span025"
77+
],
78+
"op": "db"
79+
},
80+
"evidenceDisplay": [
81+
{
82+
"name": "Offending Spans",
83+
"value": "SELECT * FROM users WHERE id = %s",
84+
"important": true
85+
},
86+
{
87+
"name": "Repeated",
88+
"value": "25 times",
89+
"important": true
90+
}
91+
]
92+
},
93+
"entries": [
94+
{
95+
"type": "spans",
96+
"data": [
97+
{
98+
"span_id": "parent123",
99+
"trace_id": "abcdef1234567890abcdef1234567890",
100+
"parent_span_id": "1234567890abcdef",
101+
"op": "http.server",
102+
"description": "GET /api/users",
103+
"status": "ok",
104+
"start_timestamp": 1722963600.0,
105+
"timestamp": 1722963600.25,
106+
"data": {
107+
"http.request.method": "GET",
108+
"url.path": "/api/users"
109+
}
110+
},
111+
{
112+
"span_id": "span001",
113+
"trace_id": "abcdef1234567890abcdef1234567890",
114+
"parent_span_id": "parent123",
115+
"op": "db.query",
116+
"description": "SELECT * FROM users WHERE id = 1",
117+
"status": "ok",
118+
"start_timestamp": 1722963600.01,
119+
"timestamp": 1722963600.015,
120+
"data": {
121+
"db.system": "postgresql",
122+
"db.operation": "SELECT"
123+
}
124+
},
125+
{
126+
"span_id": "span002",
127+
"trace_id": "abcdef1234567890abcdef1234567890",
128+
"parent_span_id": "parent123",
129+
"op": "db.query",
130+
"description": "SELECT * FROM users WHERE id = 2",
131+
"status": "ok",
132+
"start_timestamp": 1722963600.02,
133+
"timestamp": 1722963600.025,
134+
"data": {
135+
"db.system": "postgresql",
136+
"db.operation": "SELECT"
137+
}
138+
},
139+
{
140+
"span_id": "span003",
141+
"trace_id": "abcdef1234567890abcdef1234567890",
142+
"parent_span_id": "parent123",
143+
"op": "db.query",
144+
"description": "SELECT * FROM users WHERE id = 3",
145+
"status": "ok",
146+
"start_timestamp": 1722963600.03,
147+
"timestamp": 1722963600.035,
148+
"data": {
149+
"db.system": "postgresql",
150+
"db.operation": "SELECT"
151+
}
152+
},
153+
{
154+
"span_id": "span004",
155+
"trace_id": "abcdef1234567890abcdef1234567890",
156+
"parent_span_id": "parent123",
157+
"op": "db.query",
158+
"description": "SELECT * FROM users WHERE id = 4",
159+
"status": "ok",
160+
"start_timestamp": 1722963600.04,
161+
"timestamp": 1722963600.045,
162+
"data": {
163+
"db.system": "postgresql",
164+
"db.operation": "SELECT"
165+
}
166+
},
167+
{
168+
"span_id": "span005",
169+
"trace_id": "abcdef1234567890abcdef1234567890",
170+
"parent_span_id": "parent123",
171+
"op": "db.query",
172+
"description": "SELECT * FROM users WHERE id = 5",
173+
"status": "ok",
174+
"start_timestamp": 1722963600.05,
175+
"timestamp": 1722963600.055,
176+
"data": {
177+
"db.system": "postgresql",
178+
"db.operation": "SELECT"
179+
}
180+
}
181+
]
182+
},
183+
{
184+
"type": "request",
185+
"data": {
186+
"method": "GET",
187+
"url": "https://api.example.com/api/users",
188+
"query": [],
189+
"headers": [["Accept", "application/json"], ["Host", "api.example.com"]]
190+
}
191+
}
192+
],
193+
"tags": [
194+
{ "key": "environment", "value": "production" },
195+
{ "key": "transaction", "value": "/api/users" }
196+
]
197+
}

‎packages/mcp-server-mocks/src/index.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ import autofixStateFixture from "./fixtures/autofix-state.json" with {
2929
};
3030
import issueFixture from "./fixtures/issue.json" with { type: "json" };
3131
import eventsFixture from "./fixtures/event.json" with { type: "json" };
32+
import performanceEventFixture from "./fixtures/performance-event.json" with {
33+
type: "json",
34+
};
3235
import eventAttachmentsFixture from "./fixtures/event-attachments.json" with {
3336
type: "json",
3437
};
@@ -843,6 +846,23 @@ export const restHandlers = buildHandlers([
843846
fetch: () => HttpResponse.json(eventsFixture),
844847
},
845848

849+
// Performance issue with N+1 query detection
850+
{
851+
method: "get",
852+
path: "/api/0/organizations/sentry-mcp-evals/issues/PERF-N1-001/events/latest/",
853+
fetch: () => HttpResponse.json(performanceEventFixture),
854+
},
855+
{
856+
method: "get",
857+
path: "/api/0/organizations/sentry-mcp-evals/issues/7890123456/events/latest/",
858+
fetch: () => HttpResponse.json(performanceEventFixture),
859+
},
860+
{
861+
method: "get",
862+
path: "/api/0/organizations/sentry-mcp-evals/issues/PERF-N1-001/events/a1b2c3d4e5f6789012345678901234567/",
863+
fetch: () => HttpResponse.json(performanceEventFixture),
864+
},
865+
846866
{
847867
method: "get",
848868
path: "/api/0/organizations/sentry-mcp-evals/releases/",
@@ -1351,6 +1371,7 @@ export {
13511371
autofixStateFixture,
13521372
traceMetaFixture,
13531373
traceMetaWithNullsFixture,
1374+
performanceEventFixture,
13541375
traceFixture,
13551376
traceMixedFixture,
13561377
traceEventFixture,

‎packages/mcp-server/src/api-client/client.ts‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1497,7 +1497,24 @@ export class SentryApiService {
14971497
undefined,
14981498
opts,
14991499
);
1500-
return EventSchema.parse(body);
1500+
const rawEvent = EventSchema.parse(body);
1501+
1502+
// Filter out unknown events - only return known error/transaction types
1503+
if (rawEvent.type === "error") {
1504+
return rawEvent as Event;
1505+
}
1506+
if (rawEvent.type === "transaction") {
1507+
return rawEvent as Event;
1508+
}
1509+
1510+
const eventType =
1511+
typeof rawEvent.type === "string" ? rawEvent.type : String(rawEvent.type);
1512+
throw new ApiValidationError(
1513+
`Unknown event type: ${eventType}`,
1514+
400,
1515+
`Only error and transaction events are supported, got: ${eventType}`,
1516+
body,
1517+
);
15011518
}
15021519

15031520
async getLatestEventForIssue(

‎packages/mcp-server/src/api-client/schema.ts‎

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,15 @@ export const IssueSchema = z.object({
193193
culprit: z.string(),
194194
type: z.union([z.literal("error"), z.literal("transaction"), z.unknown()]),
195195
assignedTo: AssignedToSchema.optional(),
196+
issueType: z.string().optional(),
197+
issueCategory: z.string().optional(),
198+
metadata: z
199+
.object({
200+
title: z.string().optional(),
201+
location: z.string().optional(),
202+
value: z.string().optional(),
203+
})
204+
.optional(),
196205
});
197206

198207
export const IssueListSchema = z.array(IssueSchema);
@@ -360,6 +369,8 @@ const BaseEventSchema = z.object({
360369
// The _meta field contains metadata about fields in the response
361370
// It's safer to type as unknown since its structure varies
362371
_meta: z.unknown().optional(),
372+
// dateReceived is when the server received the event (may not be present in all contexts)
373+
dateReceived: z.string().datetime().optional(),
363374
});
364375

365376
export const ErrorEventSchema = BaseEventSchema.omit({
@@ -376,10 +387,31 @@ export const TransactionEventSchema = BaseEventSchema.omit({
376387
type: z.literal("transaction"),
377388
occurrence: z
378389
.object({
390+
id: z.string().optional(),
391+
projectId: z.number().optional(),
392+
eventId: z.string().optional(),
393+
fingerprint: z.array(z.string()).optional(),
379394
issueTitle: z.string(),
395+
subtitle: z.string().optional(),
396+
resourceId: z.string().nullable().optional(),
397+
evidenceData: z.record(z.string(), z.any()).optional(),
398+
evidenceDisplay: z
399+
.array(
400+
z.object({
401+
name: z.string(),
402+
value: z.string(),
403+
important: z.boolean().optional(),
404+
}),
405+
)
406+
.optional(),
407+
type: z.number().optional(),
408+
detectionTime: z.number().optional(),
409+
level: z.string().optional(),
380410
culprit: z.string().nullable(),
411+
priority: z.number().optional(),
412+
assignee: z.string().nullable().optional(),
381413
})
382-
.nullable(),
414+
.nullish(), // Allow both null and undefined
383415
});
384416

385417
export const UnknownEventSchema = BaseEventSchema.omit({

‎packages/mcp-server/src/api-client/types.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ import type {
4545
AutofixRunStateSchema,
4646
ClientKeyListSchema,
4747
ClientKeySchema,
48+
ErrorEventSchema,
49+
TransactionEventSchema,
50+
UnknownEventSchema,
4851
EventSchema,
4952
EventAttachmentSchema,
5053
EventAttachmentListSchema,
@@ -74,7 +77,16 @@ export type Project = z.infer<typeof ProjectSchema>;
7477
export type ClientKey = z.infer<typeof ClientKeySchema>;
7578
export type Release = z.infer<typeof ReleaseSchema>;
7679
export type Issue = z.infer<typeof IssueSchema>;
77-
export type Event = z.infer<typeof EventSchema>;
80+
81+
// Individual event types
82+
export type ErrorEvent = z.infer<typeof ErrorEventSchema>;
83+
export type TransactionEvent = z.infer<typeof TransactionEventSchema>;
84+
export type UnknownEvent = z.infer<typeof UnknownEventSchema>;
85+
86+
// Event union - use RawEvent for parsing, Event for known types only
87+
export type RawEvent = z.infer<typeof EventSchema>;
88+
export type Event = ErrorEvent | TransactionEvent;
89+
7890
export type EventAttachment = z.infer<typeof EventAttachmentSchema>;
7991
export type Tag = z.infer<typeof TagSchema>;
8092
export type AutofixRun = z.infer<typeof AutofixRunSchema>;

0 commit comments

Comments
 (0)