Skip to content

Commit ef812a7

Browse files
dcramercodex
andauthored
feat(issues): Add verified code locations to issue details (#1163)
get_issue_details now includes a Code Location section when Sentry can resolve the most relevant in-app frame to a verified source URL. It reports the repository, mapped path, line, and source link so agents can hand precise code context to subsequent steps without guessing project mappings. Resolution uses the existing stacktrace-link endpoint and remains optional: missing mappings, SCM verification failures, permission errors, and timeouts leave the issue response unchanged. Shared frame selection and formatting stay in the internal layer, while endpoint resolution lives in tool support. Regression coverage includes exception and thread selection, trusted embedded links, mapped-but-unverified paths, timeouts, and the final issue output. Fixes #1157 --------- Co-authored-by: OpenAI Codex <noreply@openai.com>
1 parent 3956553 commit ef812a7

10 files changed

Lines changed: 698 additions & 73 deletions

File tree

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

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ import {
7777
ReplayListResponseSchema,
7878
ReplayIdsByResourceSchema,
7979
ReplayRecordingSegmentsSchema,
80+
StacktraceLinkSchema,
8081
AIConversationSummaryListSchema,
8182
AIConversationSpanListSchema,
8283
UserReportListSchema,
@@ -129,6 +130,7 @@ import type {
129130
ReplayDetails,
130131
ReplayList,
131132
ReplayRecordingSegments,
133+
StacktraceLink,
132134
AIConversationSummary,
133135
AIConversationSpanList,
134136
UserReportList,
@@ -3308,6 +3310,58 @@ export class SentryApiService {
33083310
);
33093311
}
33103312

3313+
/**
3314+
* Resolves a stack frame through the project's configured source code mapping.
3315+
* The upstream endpoint verifies the mapped path with the SCM integration.
3316+
*/
3317+
async getStacktraceLink(
3318+
{
3319+
organizationSlug,
3320+
projectSlug,
3321+
file,
3322+
platform,
3323+
lineNo,
3324+
absPath,
3325+
module,
3326+
package: framePackage,
3327+
commitId,
3328+
groupId,
3329+
sdkName,
3330+
signal,
3331+
}: {
3332+
organizationSlug: string;
3333+
projectSlug: string;
3334+
file: string;
3335+
platform?: string;
3336+
lineNo?: number;
3337+
absPath?: string;
3338+
module?: string;
3339+
package?: string;
3340+
commitId?: string;
3341+
groupId?: string;
3342+
sdkName?: string;
3343+
signal?: AbortSignal;
3344+
},
3345+
opts?: RequestOptions,
3346+
): Promise<StacktraceLink> {
3347+
const query = new URLSearchParams({ file });
3348+
if (platform) query.set("platform", platform);
3349+
if (lineNo !== undefined) query.set("lineNo", String(lineNo));
3350+
if (absPath) query.set("absPath", absPath);
3351+
if (module) query.set("module", module);
3352+
if (framePackage) query.set("package", framePackage);
3353+
if (commitId) query.set("commitId", commitId);
3354+
if (groupId) query.set("groupId", groupId);
3355+
if (sdkName) query.set("sdkName", sdkName);
3356+
3357+
const body = await this.requestJSON(
3358+
`/projects/${organizationSlug}/${projectSlug}/stacktrace-link/?${query.toString()}`,
3359+
{ signal },
3360+
opts,
3361+
);
3362+
return StacktraceLinkSchema.parse(body);
3363+
}
3364+
33113365
/**
33123366
* Lists events for a specific issue.
33133367
* Uses the issue-specific endpoint which already filters by issue ID.

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

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -805,10 +805,13 @@ export const FrameInterface = z
805805
colNo: z.number().nullable(),
806806
absPath: z.string().nullable(),
807807
module: z.string().nullable(),
808+
package: z.string().nullable(),
809+
platform: z.string().nullable(),
810+
sourceLink: z.string().nullable(),
808811
// lineno, source code
809812
context: z.array(z.tuple([z.number(), z.string()])),
810813
inApp: z.boolean().optional(),
811-
vars: z.record(z.string(), z.unknown()).optional(),
814+
vars: z.record(z.string(), z.unknown()).nullable().optional(),
812815
})
813816
.partial();
814817

@@ -821,12 +824,15 @@ export const ExceptionInterface = z
821824
type: z.string().nullable(),
822825
handled: z.boolean().nullable(),
823826
})
824-
.partial(),
827+
.partial()
828+
.nullable(),
825829
type: z.string().nullable(),
826830
value: z.string().nullable(),
827-
stacktrace: z.object({
828-
frames: z.array(FrameInterface),
829-
}),
831+
stacktrace: z
832+
.object({
833+
frames: z.array(FrameInterface),
834+
})
835+
.nullable(),
830836
})
831837
.partial();
832838

@@ -933,6 +939,7 @@ const EventTagsSchema = z.preprocess((value) => {
933939

934940
const BaseEventSchema = z.object({
935941
id: z.string(),
942+
groupID: z.string().nullable().optional(),
936943
title: z.string(),
937944
message: z.string().nullable(),
938945
platform: z.string().nullable().optional(),
@@ -988,6 +995,26 @@ const BaseEventSchema = z.object({
988995
// "context" (singular) is the legacy "extra" field for arbitrary user-defined data
989996
// This is different from "contexts" (plural) which are structured contexts
990997
context: z.record(z.string(), z.unknown()).optional(),
998+
sdk: z
999+
.object({
1000+
name: z.string().nullable().optional(),
1001+
})
1002+
.passthrough()
1003+
.nullable()
1004+
.optional(),
1005+
release: z
1006+
.object({
1007+
lastCommit: z
1008+
.object({
1009+
id: z.string(),
1010+
})
1011+
.passthrough()
1012+
.nullable()
1013+
.optional(),
1014+
})
1015+
.passthrough()
1016+
.nullable()
1017+
.optional(),
9911018
tags: EventTagsSchema.optional(),
9921019
user: z
9931020
.object({
@@ -1122,6 +1149,20 @@ export const EventSchema = z.union([
11221149
UnknownEventSchema,
11231150
]);
11241151

1152+
export const StacktraceLinkSchema = z
1153+
.object({
1154+
config: z
1155+
.object({
1156+
repoName: z.string(),
1157+
})
1158+
.passthrough()
1159+
.nullable()
1160+
.optional(),
1161+
sourcePath: z.string().nullable().optional(),
1162+
sourceUrl: z.string().nullable().optional(),
1163+
})
1164+
.passthrough();
1165+
11251166
export const EventsResponseSchema = z.object({
11261167
data: z.array(z.unknown()),
11271168
meta: z

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ import type {
103103
ReplayListResponseSchema,
104104
ReplayRecordingEventSchema,
105105
ReplayRecordingSegmentsSchema,
106+
StacktraceLinkSchema,
106107
TagListSchema,
107108
TagSchema,
108109
TeamListSchema,
@@ -155,6 +156,7 @@ export type Event =
155156
| TransactionEvent
156157
| GenericEvent
157158
| UnknownEvent;
159+
export type StacktraceLink = z.infer<typeof StacktraceLinkSchema>;
158160

159161
export type EventAttachment = z.infer<typeof EventAttachmentSchema>;
160162
export type Tag = z.infer<typeof TagSchema>;
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import type { z } from "zod";
2+
import type { FrameInterface } from "../api-client";
3+
4+
export type Frame = z.infer<typeof FrameInterface>;
5+
6+
export type CodeLocation = {
7+
repository?: string;
8+
path?: string;
9+
line?: number;
10+
url: string;
11+
};
12+
13+
/** Formats a verified source location for the issue-details response. */
14+
export function formatCodeLocation(codeLocation: CodeLocation): string {
15+
const output = ["## Code Location", ""];
16+
if (codeLocation.repository) {
17+
output.push(`**Repository**: ${codeLocation.repository}`);
18+
}
19+
if (codeLocation.path) {
20+
output.push(`**Path**: ${codeLocation.path}`);
21+
}
22+
if (codeLocation.line !== undefined) {
23+
output.push(`**Line**: ${codeLocation.line}`);
24+
}
25+
output.push(`**Source**: ${codeLocation.url}`, "");
26+
return `${output.join("\n")}\n`;
27+
}
28+
29+
/** Selects Sentry's most relevant application frame: the last in-app frame. */
30+
export function findMostRelevantInAppFrame(frames: Frame[]): Frame | undefined {
31+
return frames.findLast((frame) => frame.inApp === true);
32+
}

‎packages/mcp-core/src/internal/formatting.ts‎

Lines changed: 28 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -29,18 +29,23 @@ import type {
2929
TraceSpan,
3030
} from "../api-client/types";
3131
import { logIssue } from "../telem/logging";
32+
import {
33+
type CodeLocation,
34+
findMostRelevantInAppFrame,
35+
formatCodeLocation,
36+
} from "./code-location";
37+
import {
38+
type AIConversationReference,
39+
formatAIConversationActionInstructions,
40+
} from "./tool-helpers/ai-conversation-actions";
3241
import {
3342
getAutofixArtifactSummaries,
3443
getStatusDisplayName,
3544
isTerminalStatus,
3645
} from "./tool-helpers/seer";
3746
import { formatToolCallInstruction } from "./tool-helpers/tool-call-formatting";
38-
import {
39-
formatAIConversationActionInstructions,
40-
type AIConversationReference,
41-
} from "./tool-helpers/ai-conversation-actions";
42-
import { formatUserGeoSummary } from "./user-formatting";
4347
import { isPlainObject } from "./type-guards";
48+
import { formatUserGeoSummary } from "./user-formatting";
4449

4550
/**
4651
* Convert Seer fixability score to actionability label.
@@ -416,12 +421,12 @@ function formatExceptionInterfaceOutput(
416421

417422
// Only show enhanced frame for the first (outermost) exception to avoid overwhelming output
418423
if (index === 0) {
419-
const firstInAppFrame = findFirstInAppFrame(frames);
424+
const relevantFrame = findMostRelevantInAppFrame(frames);
420425
if (
421-
firstInAppFrame &&
422-
(firstInAppFrame.context?.length || firstInAppFrame.vars)
426+
relevantFrame &&
427+
(relevantFrame.context?.length || relevantFrame.vars)
423428
) {
424-
parts.push(renderEnhancedFrame(firstInAppFrame, event));
429+
parts.push(renderEnhancedFrame(relevantFrame, event));
425430
parts.push("");
426431
parts.push("**Full Stacktrace:**");
427432
parts.push("────────────────");
@@ -590,13 +595,10 @@ function formatThreadsInterfaceOutput(
590595

591596
const frames = crashedThread.stacktrace.frames;
592597

593-
// Find and format the first in-app frame with enhanced view
594-
const firstInAppFrame = findFirstInAppFrame(frames);
595-
if (
596-
firstInAppFrame &&
597-
(firstInAppFrame.context?.length || firstInAppFrame.vars)
598-
) {
599-
parts.push(renderEnhancedFrame(firstInAppFrame, event));
598+
// Find and format the most relevant in-app frame with enhanced view
599+
const relevantFrame = findMostRelevantInAppFrame(frames);
600+
if (relevantFrame && (relevantFrame.context?.length || relevantFrame.vars)) {
601+
parts.push(renderEnhancedFrame(relevantFrame, event));
600602
parts.push("");
601603
parts.push("**Full Stacktrace:**");
602604
parts.push("────────────────");
@@ -725,12 +727,9 @@ export function formatThreadStacktraceOutput({
725727
parts.push("");
726728
}
727729

728-
const firstInAppFrame = findFirstInAppFrame(frames);
729-
if (
730-
firstInAppFrame &&
731-
(firstInAppFrame.context?.length || firstInAppFrame.vars)
732-
) {
733-
parts.push(renderEnhancedFrame(firstInAppFrame, event));
730+
const relevantFrame = findMostRelevantInAppFrame(frames);
731+
if (relevantFrame && (relevantFrame.context?.length || relevantFrame.vars)) {
732+
parts.push(renderEnhancedFrame(relevantFrame, event));
734733
parts.push("");
735734
parts.push("**Full Stacktrace:**");
736735
parts.push("────────────────");
@@ -886,27 +885,6 @@ function renderVariablesTable(vars: Record<string, unknown>): string {
886885
return lines.join("\n");
887886
}
888887

889-
/**
890-
* Finds the first application frame (in_app) in a stack trace.
891-
* Searches from the bottom of the stack (oldest frame) to find the first
892-
* frame that belongs to the user's application code rather than libraries.
893-
*
894-
* @param frames - Array of stack frames, typically in reverse chronological order
895-
* @returns The first in-app frame found, or undefined if none exist
896-
*/
897-
function findFirstInAppFrame(
898-
frames: z.infer<typeof FrameInterface>[],
899-
): z.infer<typeof FrameInterface> | undefined {
900-
// Frames are usually in reverse order (most recent first)
901-
// We want the first in-app frame from the bottom
902-
for (let i = frames.length - 1; i >= 0; i--) {
903-
if (frames[i].inApp === true) {
904-
return frames[i];
905-
}
906-
}
907-
return undefined;
908-
}
909-
910888
/**
911889
* Constants for performance issue formatting
912890
*/
@@ -1979,6 +1957,7 @@ export function formatIssueOutput({
19791957
externalIssues,
19801958
relatedReplayIds,
19811959
aiConversations,
1960+
codeLocation,
19821961
experimentalMode,
19831962
availableToolNames,
19841963
directToolNames,
@@ -1992,6 +1971,7 @@ export function formatIssueOutput({
19921971
externalIssues?: ExternalIssueList;
19931972
relatedReplayIds?: string[];
19941973
aiConversations?: AIConversationReference[];
1974+
codeLocation?: CodeLocation;
19951975
experimentalMode?: boolean;
19961976
availableToolNames?: ReadonlySet<string>;
19971977
directToolNames?: ReadonlySet<string>;
@@ -2066,6 +2046,11 @@ export function formatIssueOutput({
20662046
output += `**Project**: ${issue.project.name}\n`;
20672047
output += `**URL**: ${apiService.getIssueUrl(organizationSlug, issue.shortId)}\n`;
20682048
output += "\n";
2049+
2050+
if (codeLocation) {
2051+
output += formatCodeLocation(codeLocation);
2052+
}
2053+
20692054
output += "## Event Details\n\n";
20702055

20712056
// Check if this is an unsupported event type

0 commit comments

Comments
 (0)