Skip to content

Commit 8b21ab5

Browse files
dcramercodex
andauthored
feat: add replay summaries to issue details (#881)
## Summary This updates issue-details replay handling to behave like the embedded trace summary path, while also fixing replay resolution when organization context is bound above the tool layer. - add related replay lookup for issues and render attached and related replay summaries through the shared event formatting path - strip duplicate raw replay metadata from tags and contexts when the replay summary is present - prefer the injected or explicit organizationSlug over the replay URL org in get_replay_details - add regression coverage for replay summaries, constrained replay tool schemas, and replay URL org resolution ## Impact Issue details now surface replay context in a first-class way, including an attached replay, related replays, and a follow-up get_replay_details hint. Bound-organization sessions also stop falling through to the replay URL org and 403ing on the first lookup. ## Validation - ~/.volta/bin/pnpm run tsc - ~/.volta/bin/pnpm run lint - OPENAI_API_KEY= ~/.volta/bin/pnpm --filter @sentry/mcp-core test - OPENAI_API_KEY= ~/.volta/bin/pnpm run test still fails locally in mcp-cloudflare because workerd requires newer glibc symbols in this WSL environment Co-authored-by: Codex CLI Agent <noreply@openai.com>
1 parent 98d3a44 commit 8b21ab5

9 files changed

Lines changed: 451 additions & 84 deletions

File tree

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
FlamegraphSchema,
3636
ProfileChunkResponseSchema,
3737
ReplayDetailsSchema,
38+
ReplayIdsByResourceSchema,
3839
ReplayRecordingSegmentsSchema,
3940
} from "./schema";
4041
import { ConfigurationError } from "../errors";
@@ -1895,6 +1896,36 @@ export class SentryApiService {
18951896
return z.object({ data: ReplayDetailsSchema }).parse(body).data;
18961897
}
18971898

1899+
async listReplayIdsForIssue(
1900+
{
1901+
organizationSlug,
1902+
issueId,
1903+
dataSource,
1904+
}: {
1905+
organizationSlug: string;
1906+
issueId: string | number;
1907+
dataSource: "discover" | "search_issues";
1908+
},
1909+
opts?: RequestOptions,
1910+
): Promise<string[]> {
1911+
const normalizedIssueId = String(issueId);
1912+
const queryParams = new URLSearchParams();
1913+
queryParams.set("returnIds", "true");
1914+
queryParams.set("query", `issue.id:[${normalizedIssueId}]`);
1915+
queryParams.set("data_source", dataSource);
1916+
queryParams.set("statsPeriod", "90d");
1917+
queryParams.append("project", "-1");
1918+
1919+
const body = await this.requestJSON(
1920+
`/organizations/${organizationSlug}/replay-count/?${queryParams.toString()}`,
1921+
undefined,
1922+
opts,
1923+
);
1924+
1925+
const replayIdsByResource = ReplayIdsByResourceSchema.parse(body);
1926+
return replayIdsByResource[normalizedIssueId] ?? [];
1927+
}
1928+
18981929
async getReplayRecordingSegments(
18991930
{
19001931
organizationSlug,

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,11 @@ export const ReplayDetailsSchema = z
198198

199199
export const ReplayRecordingSegmentsSchema = z.array(z.array(z.unknown()));
200200

201+
export const ReplayIdsByResourceSchema = z.record(
202+
z.string(),
203+
z.array(z.string()),
204+
);
205+
201206
export const ClientKeySchema = z
202207
.object({
203208
id: z.union([z.string(), z.number()]),

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

Lines changed: 209 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -179,76 +179,100 @@ export function formatEventOutput(
179179
event: Event,
180180
options?: {
181181
performanceTrace?: Trace;
182+
replaySummary?: {
183+
apiService: SentryApiService;
184+
organizationSlug: string;
185+
relatedReplayIds?: string[];
186+
};
182187
},
183188
) {
184189
let output = "";
190+
const eventWithReplayMetadataStripped = options?.replaySummary
191+
? stripReplayMetadata(event)
192+
: event;
193+
const eventToRender = eventWithReplayMetadataStripped;
194+
195+
if (options?.replaySummary) {
196+
output += formatIssueReplayOutput({
197+
apiService: options.replaySummary.apiService,
198+
organizationSlug: options.replaySummary.organizationSlug,
199+
event,
200+
relatedReplayIds: options.replaySummary.relatedReplayIds,
201+
});
202+
}
185203

186204
// Check if entries exist (may be undefined for unsupported event types)
187-
if (!event.entries || !Array.isArray(event.entries)) {
205+
if (!eventToRender.entries || !Array.isArray(eventToRender.entries)) {
188206
// For unsupported event types, just show tags and contexts
189-
output += formatTags(event.tags);
190-
output += formatContext(event.context);
191-
output += formatContexts(event.contexts);
207+
output += formatTags(eventToRender.tags);
208+
output += formatContext(eventToRender.context);
209+
output += formatContexts(eventToRender.contexts);
192210
return output;
193211
}
194212

195213
// Look for the primary error information
196-
const messageEntry = event.entries.find((e) => e.type === "message");
197-
const exceptionEntry = event.entries.find((e) => e.type === "exception");
198-
const threadsEntry = event.entries.find((e) => e.type === "threads");
199-
const requestEntry = event.entries.find((e) => e.type === "request");
200-
const spansEntry = event.entries.find((e) => e.type === "spans");
201-
const cspEntry = event.entries.find((e) => e.type === "csp");
214+
const messageEntry = eventToRender.entries.find((e) => e.type === "message");
215+
const exceptionEntry = eventToRender.entries.find(
216+
(e) => e.type === "exception",
217+
);
218+
const threadsEntry = eventToRender.entries.find((e) => e.type === "threads");
219+
const requestEntry = eventToRender.entries.find((e) => e.type === "request");
220+
const spansEntry = eventToRender.entries.find((e) => e.type === "spans");
221+
const cspEntry = eventToRender.entries.find((e) => e.type === "csp");
202222

203223
// Error message (if present)
204224
if (messageEntry) {
205225
output += formatMessageInterfaceOutput(
206-
event,
226+
eventToRender,
207227
messageEntry.data as z.infer<typeof MessageEntrySchema>,
208228
);
209229
}
210230

211231
// Stack trace (from exception or threads)
212232
if (exceptionEntry) {
213233
output += formatExceptionInterfaceOutput(
214-
event,
234+
eventToRender,
215235
exceptionEntry.data as z.infer<typeof ErrorEntrySchema>,
216236
);
217237
} else if (threadsEntry) {
218238
output += formatThreadsInterfaceOutput(
219-
event,
239+
eventToRender,
220240
threadsEntry.data as z.infer<typeof ThreadsEntrySchema>,
221241
);
222242
}
223243

224244
// Request info (if HTTP error)
225245
if (requestEntry) {
226246
output += formatRequestInterfaceOutput(
227-
event,
247+
eventToRender,
228248
requestEntry.data as z.infer<typeof RequestEntrySchema>,
229249
);
230250
}
231251

232252
// CSP violation details
233253
if (cspEntry) {
234-
output += formatCspInterfaceOutput(event, cspEntry.data);
254+
output += formatCspInterfaceOutput(eventToRender, cspEntry.data);
235255
}
236256

237257
// Performance issue details (N+1 queries, etc.)
238258
// Pass spans data for additional context even if we have evidence
239-
if (event.type === "transaction") {
240-
output += formatPerformanceIssueOutput(event, spansEntry?.data, options);
259+
if (eventToRender.type === "transaction") {
260+
output += formatPerformanceIssueOutput(
261+
eventToRender,
262+
spansEntry?.data,
263+
options,
264+
);
241265
}
242266

243267
// Generic events (performance regressions, metric-based issues)
244268
// These have occurrence data with evidenceDisplay that needs formatting
245-
if (event.type === "generic") {
246-
output += formatGenericEventOutput(event);
269+
if (eventToRender.type === "generic") {
270+
output += formatGenericEventOutput(eventToRender);
247271
}
248272

249-
output += formatTags(event.tags);
250-
output += formatContext(event.context);
251-
output += formatContexts(event.contexts);
273+
output += formatTags(eventToRender.tags);
274+
output += formatContext(eventToRender.context);
275+
output += formatContexts(eventToRender.contexts);
252276
return output;
253277
}
254278

@@ -1684,6 +1708,7 @@ export function formatIssueOutput({
16841708
autofixState,
16851709
performanceTrace,
16861710
externalIssues,
1711+
relatedReplayIds,
16871712
experimentalMode,
16881713
}: {
16891714
organizationSlug: string;
@@ -1693,6 +1718,7 @@ export function formatIssueOutput({
16931718
autofixState?: AutofixRunState;
16941719
performanceTrace?: Trace;
16951720
externalIssues?: ExternalIssueList;
1721+
relatedReplayIds?: string[];
16961722
experimentalMode?: boolean;
16971723
}) {
16981724
let output = `# Issue ${issue.shortId} in **${organizationSlug}**\n\n`;
@@ -1829,7 +1855,14 @@ export function formatIssueOutput({
18291855
output += `**Message**:\n${event.message}\n`;
18301856
}
18311857
output += "\n";
1832-
output += formatEventOutput(event, { performanceTrace });
1858+
output += formatEventOutput(event, {
1859+
performanceTrace,
1860+
replaySummary: {
1861+
apiService,
1862+
organizationSlug,
1863+
relatedReplayIds,
1864+
},
1865+
});
18331866

18341867
// Add Seer context if available
18351868
if (autofixState) {
@@ -1855,3 +1888,156 @@ export function formatIssueOutput({
18551888
}
18561889
return output;
18571890
}
1891+
1892+
const MAX_DISPLAY_REPLAYS = 5;
1893+
1894+
function formatIssueReplayOutput({
1895+
apiService,
1896+
organizationSlug,
1897+
event,
1898+
relatedReplayIds,
1899+
}: {
1900+
apiService: SentryApiService;
1901+
organizationSlug: string;
1902+
event: Event;
1903+
relatedReplayIds?: string[];
1904+
}): string {
1905+
const attachedReplayId = getReplayIdFromEvent(event);
1906+
const normalizedRelatedReplayIds = dedupeReplayIds(relatedReplayIds ?? []);
1907+
const additionalRelatedReplayIds = normalizedRelatedReplayIds.filter(
1908+
(replayId) => replayId !== attachedReplayId,
1909+
);
1910+
1911+
if (!attachedReplayId && normalizedRelatedReplayIds.length === 0) {
1912+
return "";
1913+
}
1914+
1915+
const lines: string[] = ["## Session Replay", ""];
1916+
1917+
if (attachedReplayId) {
1918+
lines.push(
1919+
`**Attached Replay**: ${apiService.getReplayUrl(organizationSlug, attachedReplayId)}`,
1920+
);
1921+
}
1922+
1923+
if (normalizedRelatedReplayIds.length > 0) {
1924+
lines.push(
1925+
`**Related Replay Count**: ${normalizedRelatedReplayIds.length}`,
1926+
);
1927+
}
1928+
1929+
if (additionalRelatedReplayIds.length > 0) {
1930+
const displayedReplayIds = additionalRelatedReplayIds.slice(
1931+
0,
1932+
MAX_DISPLAY_REPLAYS,
1933+
);
1934+
1935+
lines.push("");
1936+
lines.push(
1937+
attachedReplayId ? "### Other Related Replays" : "### Related Replays",
1938+
);
1939+
lines.push("");
1940+
1941+
for (const replayId of displayedReplayIds) {
1942+
lines.push(`- ${apiService.getReplayUrl(organizationSlug, replayId)}`);
1943+
}
1944+
1945+
const remainingReplayCount =
1946+
additionalRelatedReplayIds.length - displayedReplayIds.length;
1947+
if (remainingReplayCount > 0) {
1948+
lines.push(
1949+
`- ... and ${remainingReplayCount} more replay${remainingReplayCount === 1 ? "" : "s"}`,
1950+
);
1951+
}
1952+
}
1953+
1954+
const exampleReplayId =
1955+
attachedReplayId ?? normalizedRelatedReplayIds.at(0) ?? null;
1956+
if (exampleReplayId) {
1957+
lines.push("");
1958+
lines.push(
1959+
`Use \`get_replay_details(organizationSlug='${organizationSlug}', replayId='${exampleReplayId}')\` to inspect a replay in detail.`,
1960+
);
1961+
}
1962+
1963+
return `${lines.join("\n")}\n\n`;
1964+
}
1965+
1966+
function getReplayIdFromEvent(event: Event): string | null {
1967+
const replayContext = event.contexts?.replay as
1968+
| Record<string, unknown>
1969+
| undefined;
1970+
const replayContextId =
1971+
typeof replayContext?.replay_id === "string"
1972+
? replayContext.replay_id
1973+
: null;
1974+
const replayTagId =
1975+
event.tags?.find((tag) => tag.key === "replayId" || tag.key === "replay.id")
1976+
?.value ?? null;
1977+
1978+
return normalizeReplayId(replayContextId ?? replayTagId);
1979+
}
1980+
1981+
function dedupeReplayIds(replayIds: string[]): string[] {
1982+
const normalizedReplayIds: string[] = [];
1983+
const seenReplayIds = new Set<string>();
1984+
1985+
for (const replayId of replayIds) {
1986+
const normalizedReplayId = normalizeReplayId(replayId);
1987+
if (!normalizedReplayId || seenReplayIds.has(normalizedReplayId)) {
1988+
continue;
1989+
}
1990+
seenReplayIds.add(normalizedReplayId);
1991+
normalizedReplayIds.push(normalizedReplayId);
1992+
}
1993+
1994+
return normalizedReplayIds;
1995+
}
1996+
1997+
function normalizeReplayId(replayId: string | null | undefined): string | null {
1998+
if (typeof replayId !== "string") {
1999+
return null;
2000+
}
2001+
2002+
const trimmedReplayId = replayId.trim();
2003+
if (!trimmedReplayId) {
2004+
return null;
2005+
}
2006+
2007+
return trimmedReplayId.replace(/-/g, "");
2008+
}
2009+
2010+
function stripReplayMetadata(event: Event): Event {
2011+
const tags = event.tags?.filter(
2012+
(tag) => tag.key !== "replay.id" && tag.key !== "replayId",
2013+
);
2014+
const replayContext = event.contexts?.replay;
2015+
2016+
if (!replayContext) {
2017+
return { ...event, tags };
2018+
}
2019+
2020+
const nextReplayContext = Object.fromEntries(
2021+
Object.entries(replayContext).filter(([key]) => key !== "replay_id"),
2022+
);
2023+
const nonTypeReplayKeys = Object.keys(nextReplayContext).filter(
2024+
(key) => key !== "type",
2025+
);
2026+
const contexts =
2027+
nonTypeReplayKeys.length === 0
2028+
? Object.fromEntries(
2029+
Object.entries(event.contexts ?? {}).filter(
2030+
([key]) => key !== "replay",
2031+
),
2032+
)
2033+
: {
2034+
...event.contexts,
2035+
replay: nextReplayContext,
2036+
};
2037+
2038+
return {
2039+
...event,
2040+
tags,
2041+
contexts,
2042+
};
2043+
}

0 commit comments

Comments
 (0)