Skip to main content
Glama

Sentry MCP

Official
by getsentry
formatting.ts49.8 kB
/** * LLM response formatting utilities for Sentry data. * * Converts Sentry API responses into structured markdown format optimized * for LLM consumption. Handles stacktraces, event details, issue summaries, * and contextual information with consistent formatting patterns. */ import type { z } from "zod"; import type { Event, Issue, AutofixRunState, Trace, TraceSpan, } from "../api-client/types"; import type { ErrorEntrySchema, ErrorEventSchema, EventSchema, FrameInterface, RequestEntrySchema, MessageEntrySchema, ThreadsEntrySchema, SentryApiService, AutofixRunStepRootCauseAnalysisSchema, } from "../api-client"; import { getOutputForAutofixStep, isTerminalStatus, getStatusDisplayName, } from "./tool-helpers/seer"; // Language detection mappings const LANGUAGE_EXTENSIONS: Record<string, string> = { ".java": "java", ".py": "python", ".js": "javascript", ".jsx": "javascript", ".ts": "javascript", ".tsx": "javascript", ".rb": "ruby", ".php": "php", }; const LANGUAGE_MODULE_PATTERNS: Array<[RegExp, string]> = [ [/^(java\.|com\.|org\.)/, "java"], ]; /** * Detects the programming language of a stack frame based on the file extension. * Falls back to the platform parameter if no filename is available or extension is unrecognized. * * @param frame - The stack frame containing file and location information * @param platform - Optional platform hint to use as fallback * @returns The detected language or platform fallback or "unknown" */ function detectLanguage( frame: z.infer<typeof FrameInterface>, platform?: string | null, ): string { // Check filename extensions if (frame.filename) { const ext = frame.filename.toLowerCase().match(/\.[^.]+$/)?.[0]; if (ext && LANGUAGE_EXTENSIONS[ext]) { return LANGUAGE_EXTENSIONS[ext]; } } // Check module patterns if (frame.module) { for (const [pattern, language] of LANGUAGE_MODULE_PATTERNS) { if (pattern.test(frame.module)) { return language; } } } // Fallback to platform or unknown return platform || "unknown"; } /** * Formats a stack frame into a language-specific string representation. * Different languages have different conventions for displaying stack traces. * * @param frame - The stack frame to format * @param frameIndex - Optional frame index for languages that display frame numbers * @param platform - Optional platform hint for language detection fallback * @returns Formatted stack frame string */ export function formatFrameHeader( frame: z.infer<typeof FrameInterface>, frameIndex?: number, platform?: string | null, ) { const language = detectLanguage(frame, platform); switch (language) { case "java": { // at com.example.ClassName.methodName(FileName.java:123) const className = frame.module || "UnknownClass"; const method = frame.function || "<unknown>"; const source = frame.filename || "Unknown Source"; const location = frame.lineNo ? `:${frame.lineNo}` : ""; return `at ${className}.${method}(${source}${location})`; } case "python": { // File "/path/to/file.py", line 42, in function_name const file = frame.filename || frame.absPath || frame.module || "<unknown>"; const func = frame.function || "<module>"; const line = frame.lineNo ? `, line ${frame.lineNo}` : ""; return ` File "${file}"${line}, in ${func}`; } case "javascript": { // Original compact format: filename:line:col (function) // This preserves backward compatibility return `${[frame.filename, frame.lineNo, frame.colNo] .filter((i) => !!i) .join(":")}${frame.function ? ` (${frame.function})` : ""}`; } case "ruby": { // from /path/to/file.rb:42:in `method_name' const file = frame.filename || frame.module || "<unknown>"; const func = frame.function ? ` \`${frame.function}\`` : ""; const line = frame.lineNo ? `:${frame.lineNo}:in` : ""; return ` from ${file}${line}${func}`; } case "php": { // #0 /path/to/file.php(42): functionName() const file = frame.filename || "<unknown>"; const line = frame.lineNo ? `(${frame.lineNo})` : ""; const func = frame.function || "<unknown>"; const prefix = frameIndex !== undefined ? `#${frameIndex} ` : ""; return `${prefix}${file}${line}: ${func}()`; } default: { // Generic format for unknown languages const func = frame.function || "<unknown>"; const location = frame.filename || frame.module || "<unknown>"; const line = frame.lineNo ? `:${frame.lineNo}` : ""; const col = frame.colNo != null ? `:${frame.colNo}` : ""; return ` at ${func} (${location}${line}${col})`; } } } /** * Formats a Sentry event into a structured markdown output. * Includes error messages, stack traces, request info, and contextual data. * * @param event - The Sentry event to format * @param options - Additional formatting context * @returns Formatted markdown string */ export function formatEventOutput( event: Event, options?: { performanceTrace?: Trace; }, ) { let output = ""; // Look for the primary error information const messageEntry = event.entries.find((e) => e.type === "message"); const exceptionEntry = event.entries.find((e) => e.type === "exception"); const threadsEntry = event.entries.find((e) => e.type === "threads"); const requestEntry = event.entries.find((e) => e.type === "request"); const spansEntry = event.entries.find((e) => e.type === "spans"); // Error message (if present) if (messageEntry) { output += formatMessageInterfaceOutput( event, messageEntry.data as z.infer<typeof MessageEntrySchema>, ); } // Stack trace (from exception or threads) if (exceptionEntry) { output += formatExceptionInterfaceOutput( event, exceptionEntry.data as z.infer<typeof ErrorEntrySchema>, ); } else if (threadsEntry) { output += formatThreadsInterfaceOutput( event, threadsEntry.data as z.infer<typeof ThreadsEntrySchema>, ); } // Request info (if HTTP error) if (requestEntry) { output += formatRequestInterfaceOutput( event, requestEntry.data as z.infer<typeof RequestEntrySchema>, ); } // Performance issue details (N+1 queries, etc.) // Pass spans data for additional context even if we have evidence if (event.type === "transaction") { output += formatPerformanceIssueOutput(event, spansEntry?.data, options); } output += formatTags(event.tags); output += formatContexts(event.contexts); return output; } /** * Extracts the context line matching the frame's line number for inline display. * This is used in the full stacktrace view to show the actual line of code * that caused the error inline with the stack frame. * * @param frame - The stack frame containing context lines * @returns The line of code at the frame's line number, or empty string if not available */ function renderInlineContext(frame: z.infer<typeof FrameInterface>): string { if (!frame.context?.length || !frame.lineNo) { return ""; } const contextLine = frame.context.find(([lineNo]) => lineNo === frame.lineNo); return contextLine ? `\n${contextLine[1]}` : ""; } /** * Renders an enhanced view of a stack frame with context lines and variables. * Used for the "Most Relevant Frame" section to provide detailed information * about the most relevant application frame where the error occurred. * * @param frame - The stack frame to render with enhanced information * @param event - The Sentry event containing platform information for language detection * @returns Formatted string with frame header, context lines, and variables table */ function renderEnhancedFrame( frame: z.infer<typeof FrameInterface>, event: Event, ): string { const parts: string[] = []; parts.push("**Most Relevant Frame:**"); parts.push("─────────────────────"); parts.push(formatFrameHeader(frame, undefined, event.platform)); // Add context lines if available if (frame.context?.length) { const contextLines = renderContextLines(frame); if (contextLines) { parts.push(""); parts.push(contextLines); } } // Add variables table if available if (frame.vars && Object.keys(frame.vars).length > 0) { parts.push(""); parts.push(renderVariablesTable(frame.vars)); } return parts.join("\n"); } function formatExceptionInterfaceOutput( event: Event, data: z.infer<typeof ErrorEntrySchema>, ) { const parts: string[] = []; // Handle both single exception (value) and chained exceptions (values) const exceptions = data.values || (data.value ? [data.value] : []); if (exceptions.length === 0) { return ""; } // For chained exceptions, they are typically ordered from innermost to outermost // We'll render them in reverse order (outermost first) to match how they occurred const isChained = exceptions.length > 1; // Create a copy before reversing to avoid mutating the original array [...exceptions].reverse().forEach((exception, index) => { if (!exception) return; // Add language-specific chain indicator for multiple exceptions if (isChained && index > 0) { parts.push(""); parts.push( getExceptionChainMessage( event.platform || null, index, exceptions.length, ), ); parts.push(""); } // Use the actual exception type and value as the heading const exceptionTitle = `${exception.type}${exception.value ? `: ${exception.value}` : ""}`; parts.push(index === 0 ? "### Error" : `### ${exceptionTitle}`); parts.push(""); // Add the error details in a code block for the first exception // to maintain backward compatibility if (index === 0) { parts.push("```"); parts.push(exceptionTitle); parts.push("```"); parts.push(""); } if (!exception.stacktrace || !exception.stacktrace.frames) { parts.push("**Stacktrace:**"); parts.push("```"); parts.push("No stacktrace available"); parts.push("```"); return; } const frames = exception.stacktrace.frames; // Only show enhanced frame for the first (outermost) exception to avoid overwhelming output if (index === 0) { const firstInAppFrame = findFirstInAppFrame(frames); if ( firstInAppFrame && (firstInAppFrame.context?.length || firstInAppFrame.vars) ) { parts.push(renderEnhancedFrame(firstInAppFrame, event)); parts.push(""); parts.push("**Full Stacktrace:**"); parts.push("────────────────"); } else { parts.push("**Stacktrace:**"); } } else { parts.push("**Stacktrace:**"); } parts.push("```"); parts.push( frames .map((frame) => { const header = formatFrameHeader(frame, undefined, event.platform); const context = renderInlineContext(frame); return `${header}${context}`; }) .join("\n"), ); parts.push("```"); }); parts.push(""); parts.push(""); return parts.join("\n"); } /** * Get the appropriate exception chain message based on the platform */ function getExceptionChainMessage( platform: string | null, index: number, totalExceptions: number, ): string { // Default message for unknown platforms const defaultMessage = "**During handling of the above exception, another exception occurred:**"; if (!platform) { return defaultMessage; } switch (platform.toLowerCase()) { case "python": // Python has two distinct messages, but without additional metadata // we default to the implicit chaining message return "**During handling of the above exception, another exception occurred:**"; case "java": return "**Caused by:**"; case "csharp": case "dotnet": return "**---> Inner Exception:**"; case "ruby": return "**Caused by:**"; case "go": return "**Wrapped error:**"; case "rust": return `**Caused by (${index}):**`; default: return defaultMessage; } } function formatRequestInterfaceOutput( event: Event, data: z.infer<typeof RequestEntrySchema>, ) { if (!data.method || !data.url) { return ""; } return `### HTTP Request\n\n**Method:** ${data.method}\n**URL:** ${data.url}\n\n`; } function formatMessageInterfaceOutput( event: Event, data: z.infer<typeof MessageEntrySchema>, ) { if (!data.formatted && !data.message) { return ""; } const message = data.formatted || data.message || ""; return `### Error\n\n${"```"}\n${message}\n${"```"}\n\n`; } function formatThreadsInterfaceOutput( event: Event, data: z.infer<typeof ThreadsEntrySchema>, ) { if (!data.values || data.values.length === 0) { return ""; } // Find the crashed thread only const crashedThread = data.values.find((t) => t.crashed); if (!crashedThread?.stacktrace?.frames) { return ""; } const parts: string[] = []; // Include thread name if available if (crashedThread.name) { parts.push(`**Thread** (${crashedThread.name})`); parts.push(""); } const frames = crashedThread.stacktrace.frames; // Find and format the first in-app frame with enhanced view const firstInAppFrame = findFirstInAppFrame(frames); if ( firstInAppFrame && (firstInAppFrame.context?.length || firstInAppFrame.vars) ) { parts.push(renderEnhancedFrame(firstInAppFrame, event)); parts.push(""); parts.push("**Full Stacktrace:**"); parts.push("────────────────"); } else { parts.push("**Stacktrace:**"); } parts.push("```"); parts.push( frames .map((frame) => { const header = formatFrameHeader(frame, undefined, event.platform); const context = renderInlineContext(frame); return `${header}${context}`; }) .join("\n"), ); parts.push("```"); parts.push(""); return parts.join("\n"); } /** * Renders surrounding source code context for a stack frame. * Shows a window of code lines around the error line with visual indicators. * * @param frame - The stack frame containing context lines * @param contextSize - Number of lines to show before and after the error line (default: 3) * @returns Formatted context lines with line numbers and arrow indicator for the error line */ function renderContextLines( frame: z.infer<typeof FrameInterface>, contextSize = 3, ): string { if (!frame.context || frame.context.length === 0 || !frame.lineNo) { return ""; } const lines: string[] = []; const errorLine = frame.lineNo; const maxLineNoWidth = Math.max( ...frame.context.map(([lineNo]) => lineNo.toString().length), ); for (const [lineNo, code] of frame.context) { const isErrorLine = lineNo === errorLine; const lineNoStr = lineNo.toString().padStart(maxLineNoWidth, " "); if (Math.abs(lineNo - errorLine) <= contextSize) { if (isErrorLine) { lines.push(` → ${lineNoStr} │ ${code}`); } else { lines.push(` ${lineNoStr} │ ${code}`); } } } return lines.join("\n"); } /** * Formats a variable value for display in the variables table. * Handles different types appropriately and safely, converting complex objects * to readable representations and handling edge cases like circular references. * * @param value - The variable value to format (can be any type) * @param maxLength - Maximum length for stringified objects/arrays (default: 80) * @returns Human-readable string representation of the value */ function formatVariableValue(value: unknown, maxLength = 80): string { try { if (typeof value === "string") { return `"${value}"`; } if (value === null) { return "null"; } if (value === undefined) { return "undefined"; } if (typeof value === "object") { const stringified = JSON.stringify(value); if (stringified.length > maxLength) { // Leave room for ", ...]" or ", ...}" const truncateAt = maxLength - 6; let truncated = stringified.substring(0, truncateAt); // Find the last complete element by looking for the last comma const lastComma = truncated.lastIndexOf(","); if (lastComma > 0) { truncated = truncated.substring(0, lastComma); } // Add the appropriate ending if (Array.isArray(value)) { return `${truncated}, ...]`; } return `${truncated}, ...}`; } return stringified; } return String(value); } catch { // Handle circular references or other stringify errors return `<${typeof value}>`; } } /** * Renders a table of local variables in a tree-like format. * Uses box-drawing characters to create a visual hierarchy of variables * and their values at the point where the error occurred. * * @param vars - Object containing variable names as keys and their values * @returns Formatted variables table with tree-style prefix characters */ function renderVariablesTable(vars: Record<string, unknown>): string { const entries = Object.entries(vars); if (entries.length === 0) { return ""; } const lines: string[] = ["Local Variables:"]; const lastIndex = entries.length - 1; entries.forEach(([key, value], index) => { const prefix = index === lastIndex ? "└─" : "├─"; const valueStr = formatVariableValue(value); lines.push(`${prefix} ${key}: ${valueStr}`); }); return lines.join("\n"); } /** * Finds the first application frame (in_app) in a stack trace. * Searches from the bottom of the stack (oldest frame) to find the first * frame that belongs to the user's application code rather than libraries. * * @param frames - Array of stack frames, typically in reverse chronological order * @returns The first in-app frame found, or undefined if none exist */ function findFirstInAppFrame( frames: z.infer<typeof FrameInterface>[], ): z.infer<typeof FrameInterface> | undefined { // Frames are usually in reverse order (most recent first) // We want the first in-app frame from the bottom for (let i = frames.length - 1; i >= 0; i--) { if (frames[i].inApp === true) { return frames[i]; } } return undefined; } /** * Constants for performance issue formatting */ const MAX_SPANS_IN_TREE = 10; /** * Safely parse a number from a string, returning a default if invalid */ function safeParseInt(value: unknown, defaultValue: number): number { if (typeof value === "number") return value; if (typeof value === "string") { const parsed = Number.parseInt(value, 10); return Number.isNaN(parsed) ? defaultValue : parsed; } return defaultValue; } /** * Simplified span structure for rendering span trees in performance issues. * This is a subset of the full span data focused on visualization needs. */ interface PerformanceSpan { span_id: string; op: string; // Operation type (e.g., "db.query", "http.client") description: string; // Human-readable description of what the span did duration: number; // Duration in milliseconds is_n1_query: boolean; // Whether this span is part of the N+1 pattern children: PerformanceSpan[]; level: number; // Nesting level for tree rendering } interface RawSpan { span_id?: string; id?: string; op?: string; description?: string; timestamp?: number; start_timestamp?: number; duration?: number; } interface N1EvidenceData { parentSpan?: string; parentSpanIds?: string[]; repeatingSpansCompact?: string[]; repeatingSpans?: string[]; numberRepeatingSpans?: string; // API returns string even though it's a number numPatternRepetitions?: number; offenderSpanIds?: string[]; transactionName?: string; [key: string]: unknown; } interface SlowDbEvidenceData { parentSpan?: string; [key: string]: unknown; } function normalizeSpanId(value: unknown): string | undefined { if (typeof value === "string" && value) { return value; } return undefined; } function getSpanIdentifier(span: RawSpan): string | undefined { if (span.span_id !== undefined) { return normalizeSpanId(span.span_id); } if (span.id !== undefined) { return normalizeSpanId(span.id); } return undefined; } function getSpanDurationMs(span: RawSpan): number { if ( typeof span.timestamp === "number" && typeof span.start_timestamp === "number" ) { const deltaSeconds = span.timestamp - span.start_timestamp; if (Number.isFinite(deltaSeconds) && deltaSeconds >= 0) { return deltaSeconds * 1000; } } // Trace APIs expose `duration` in milliseconds. Preserve fractional values. if (typeof span.duration === "number" && Number.isFinite(span.duration)) { return span.duration >= 0 ? span.duration : 0; } return 0; } function normalizeIdArray(values: unknown): string[] { if (!Array.isArray(values)) { return []; } return values .map((value) => normalizeSpanId(value)) .filter((value): value is string => value !== undefined); } function isValidSpanArray(value: unknown): value is RawSpan[] { return Array.isArray(value); } /** * Get the repeating span descriptions from evidence data. * Prefers repeatingSpansCompact (more concise) over repeatingSpans (verbose). */ function getRepeatingSpanLines(evidenceData: N1EvidenceData): string[] { // Try compact version first (preferred for display) if ( Array.isArray(evidenceData.repeatingSpansCompact) && evidenceData.repeatingSpansCompact.length > 0 ) { return evidenceData.repeatingSpansCompact .map((s) => (typeof s === "string" ? s.trim() : "")) .filter((s): s is string => s.length > 0); } // Fall back to full version if ( Array.isArray(evidenceData.repeatingSpans) && evidenceData.repeatingSpans.length > 0 ) { return evidenceData.repeatingSpans .map((s) => (typeof s === "string" ? s.trim() : "")) .filter((s): s is string => s.length > 0); } return []; } function isTraceSpan(node: unknown): node is TraceSpan { if (node === null || typeof node !== "object") { return false; } const candidate = node as { event_type?: unknown; event_id?: unknown }; // Trace API returns spans with event_type: "span" return ( candidate.event_type === "span" && typeof candidate.event_id === "string" ); } function buildTraceSpanTree( trace: Trace, parentSpanIds: string[], offenderSpanIds: string[], maxSpans: number, ): string[] { const offenderSet = new Set(offenderSpanIds); const spanMap = new Map<string, TraceSpan>(); function indexSpan(span: TraceSpan): void { // Try to get span_id from additional_attributes, fall back to event_id const spanId = normalizeSpanId(span.additional_attributes?.span_id) || span.event_id; if (spanId && spanId.length > 0) { spanMap.set(spanId, span); } for (const child of span.children ?? []) { if (isTraceSpan(child)) { indexSpan(child); } } } for (const node of trace) { if (isTraceSpan(node)) { indexSpan(node); } } const roots: PerformanceSpan[] = []; const budget = { count: 0, limit: maxSpans }; // First, try to find parent spans for (const parentId of parentSpanIds) { const span = spanMap.get(parentId); if (!span) { continue; } const perfSpan = convertTraceSpanToPerformanceSpan( span, offenderSet, budget, 0, ); if (perfSpan) { roots.push(perfSpan); } if (budget.count >= budget.limit) { break; } } // If no parent spans found, try to find offender spans directly if (roots.length === 0 && offenderSpanIds.length > 0) { for (const offenderId of offenderSpanIds) { const span = spanMap.get(offenderId); if (!span) { continue; } const perfSpan = convertTraceSpanToPerformanceSpan( span, offenderSet, budget, 0, ); if (perfSpan) { roots.push(perfSpan); } if (budget.count >= budget.limit) { break; } } } if (roots.length === 0) { return []; } return renderPerformanceSpanTree(roots); } function convertTraceSpanToPerformanceSpan( span: TraceSpan, offenderSet: Set<string>, budget: { count: number; limit: number }, level: number, ): PerformanceSpan | null { if (budget.count >= budget.limit) { return null; } budget.count += 1; // Get span ID from additional_attributes or fall back to event_id const spanId = normalizeSpanId(span.additional_attributes?.span_id) || span.event_id; const performanceSpan: PerformanceSpan = { span_id: spanId, op: span.op || "unknown", description: formatTraceSpanDescription(span), duration: getTraceSpanDurationMs(span), is_n1_query: offenderSet.has(spanId), children: [], level, }; for (const child of span.children ?? []) { if (!isTraceSpan(child)) { continue; } if (budget.count >= budget.limit) { break; } const childSpan = convertTraceSpanToPerformanceSpan( child, offenderSet, budget, level + 1, ); if (childSpan) { performanceSpan.children.push(childSpan); } if (budget.count >= budget.limit) { break; } } return performanceSpan; } function formatTraceSpanDescription(span: TraceSpan): string { if (span.name && span.name.trim().length > 0) { return span.name.trim(); } if (span.description && span.description.trim().length > 0) { return span.description.trim(); } if (span.op && span.op.trim().length > 0) { return span.op.trim(); } return "unnamed"; } function getTraceSpanDurationMs(span: TraceSpan): number { if (typeof span.duration === "number" && span.duration >= 0) { return span.duration; } if ( typeof (span as { end_timestamp?: number }).end_timestamp === "number" && typeof span.start_timestamp === "number" ) { const deltaSeconds = (span as { end_timestamp: number }).end_timestamp - span.start_timestamp; if (Number.isFinite(deltaSeconds) && deltaSeconds >= 0) { return deltaSeconds * 1000; } } return 0; } function buildOffenderSummaries( spans: RawSpan[], offenderSpanIds: string[], ): string[] { if (offenderSpanIds.length === 0) { return []; } const spanMap = new Map<string, RawSpan>(); for (const span of spans) { const identifier = getSpanIdentifier(span); if (identifier) { spanMap.set(identifier, span); } } const summaries: string[] = []; for (const offenderId of offenderSpanIds) { const span = spanMap.get(offenderId); if (span) { const description = span.description || span.op || `Span ${offenderId}`; const duration = getSpanDurationMs(span); const durationLabel = duration > 0 ? ` (${Math.round(duration)}ms)` : ""; summaries.push(`${description}${durationLabel} [${offenderId}] [N+1]`); } else { summaries.push(`Span ${offenderId} [N+1]`); } } return summaries; } /** * Renders a hierarchical tree of performance spans using box-drawing characters. * Highlights N+1 queries with a special indicator. * * @param spans - Array of selected performance spans * @returns Array of formatted strings representing the tree */ function renderPerformanceSpanTree(spans: PerformanceSpan[]): string[] { const lines: string[] = []; function renderSpan(span: PerformanceSpan, prefix = "", isLast = true): void { const connector = prefix === "" ? "" : isLast ? "└─ " : "├─ "; const displayName = span.description?.trim() || span.op || "unnamed"; const shortId = span.span_id ? span.span_id.substring(0, 8) : "unknown"; const durationDisplay = span.duration > 0 ? `${Math.round(span.duration)}ms` : "unknown"; const metadataParts: string[] = [shortId]; if (span.op && span.op !== "default") { metadataParts.push(span.op); } metadataParts.push(durationDisplay); const line = `${prefix}${connector}${displayName} [${metadataParts.join( " · ", )}]${span.is_n1_query ? " [N+1]" : ""}`; lines.push(line); // Render children for (let i = 0; i < span.children.length; i++) { const child = span.children[i]; const isLastChild = i === span.children.length - 1; const childPrefix = prefix + (isLast ? " " : "│ "); renderSpan(child, childPrefix, isLastChild); } } for (let i = 0; i < spans.length; i++) { const span = spans[i]; const isLastRoot = i === spans.length - 1; renderSpan(span, "", isLastRoot); } return lines; } function selectN1QuerySpans( spans: RawSpan[], evidence: N1EvidenceData, maxSpans = MAX_SPANS_IN_TREE, ): PerformanceSpan[] { const selected: PerformanceSpan[] = []; let spanCount = 0; const offenderSpanIds = normalizeIdArray(evidence.offenderSpanIds); const parentSpanIds = normalizeIdArray(evidence.parentSpanIds); let parentSpan: PerformanceSpan | null = null; if (parentSpanIds.length > 0) { const parent = spans.find((span) => { const identifier = getSpanIdentifier(span); return identifier ? parentSpanIds.includes(identifier) : false; }); if (parent) { parentSpan = { span_id: getSpanIdentifier(parent) ?? "unknown", op: parent.op || "unknown", description: parent.description || evidence.parentSpan || "Parent Operation", duration: getSpanDurationMs(parent), is_n1_query: false, children: [], level: 0, }; selected.push(parentSpan); spanCount += 1; } } if (offenderSpanIds.length > 0) { const offenderSet = new Set(offenderSpanIds); const offenderSpans = spans .filter((span) => { const identifier = getSpanIdentifier(span); return identifier ? offenderSet.has(identifier) : false; }) .slice(0, Math.max(0, maxSpans - spanCount)); for (const span of offenderSpans) { const perfSpan: PerformanceSpan = { span_id: getSpanIdentifier(span) ?? "unknown", op: span.op || "db.query", description: span.description || "Database Query", duration: getSpanDurationMs(span), is_n1_query: true, children: [], level: parentSpan ? 1 : 0, }; if (parentSpan) { parentSpan.children.push(perfSpan); } else { selected.push(perfSpan); } spanCount += 1; if (spanCount >= maxSpans) { break; } } } return selected; } /** * Known Sentry performance issue types that we handle. * * NOTE: We intentionally only implement formatters for high-value performance issues * that provide complex insights. Not all issue types need custom formatting - many * can rely on the generic evidenceDisplay fields that Sentry provides. * * Currently fully implemented: * - N+1 query detection (DB and API) * * Partially implemented: * - Slow DB queries (shows parent span only) * * Not implemented (lower priority): * - Asset-related issues (render blocking, uncompressed, large payloads) * - File I/O issues * - Consecutive queries */ const KNOWN_PERFORMANCE_ISSUE_TYPES = { N_PLUS_ONE_DB_QUERIES: "performance_n_plus_one_db_queries", N_PLUS_ONE_API_CALLS: "performance_n_plus_one_api_calls", SLOW_DB_QUERY: "performance_slow_db_query", RENDER_BLOCKING_ASSET: "performance_render_blocking_asset", CONSECUTIVE_DB_QUERIES: "performance_consecutive_db_queries", FILE_IO_MAIN_THREAD: "performance_file_io_main_thread", M_N_PLUS_ONE_DB_QUERIES: "performance_m_n_plus_one_db_queries", UNCOMPRESSED_ASSET: "performance_uncompressed_asset", LARGE_HTTP_PAYLOAD: "performance_large_http_payload", } as const; /** * Map numeric occurrence types to issue types (from Sentry's codebase). * * Sentry uses numeric type IDs internally in the occurrence data structure, * but string issue types in the UI and other APIs. This mapping converts * between them. * * Source: sentry/static/app/types/group.tsx in Sentry's codebase * Range: 1xxx = transaction-based performance issues * 2xxx = profile-based performance issues */ const OCCURRENCE_TYPE_TO_ISSUE_TYPE: Record<number, string> = { 1001: KNOWN_PERFORMANCE_ISSUE_TYPES.SLOW_DB_QUERY, 1004: KNOWN_PERFORMANCE_ISSUE_TYPES.RENDER_BLOCKING_ASSET, 1006: KNOWN_PERFORMANCE_ISSUE_TYPES.N_PLUS_ONE_DB_QUERIES, 1906: KNOWN_PERFORMANCE_ISSUE_TYPES.N_PLUS_ONE_DB_QUERIES, // Alternative ID for N+1 DB 1007: KNOWN_PERFORMANCE_ISSUE_TYPES.CONSECUTIVE_DB_QUERIES, 1008: KNOWN_PERFORMANCE_ISSUE_TYPES.FILE_IO_MAIN_THREAD, 1009: "performance_consecutive_http", 1010: KNOWN_PERFORMANCE_ISSUE_TYPES.N_PLUS_ONE_API_CALLS, 1910: KNOWN_PERFORMANCE_ISSUE_TYPES.N_PLUS_ONE_API_CALLS, // Alternative ID for N+1 API 1012: KNOWN_PERFORMANCE_ISSUE_TYPES.UNCOMPRESSED_ASSET, 1013: "performance_db_main_thread", 1015: KNOWN_PERFORMANCE_ISSUE_TYPES.LARGE_HTTP_PAYLOAD, 1016: "performance_http_overhead", }; // Type alias currently unused but kept for potential future type safety // type PerformanceIssueType = typeof KNOWN_PERFORMANCE_ISSUE_TYPES[keyof typeof KNOWN_PERFORMANCE_ISSUE_TYPES]; /** * Formats N+1 query issue evidence data. * * N+1 queries are a common performance anti-pattern where code executes * 1 query to get a list of items, then N additional queries (one per item) * instead of using a single JOIN or batch query. * * Evidence fields we use: * - parentSpan: The operation that triggered the N+1 queries * - repeatingSpansCompact/repeatingSpans: The query pattern being repeated * - numberRepeatingSpans: How many times the query was executed * - offenderSpanIds: IDs of the actual span instances * - parentSpanIds: IDs of parent spans for tree visualization */ function formatN1QueryEvidence( evidenceData: N1EvidenceData, spansData: unknown, performanceTrace?: Trace, ): string { const parts: string[] = []; // Format parent span info if available if (evidenceData.parentSpan) { parts.push("**Parent Operation:**"); parts.push(`${evidenceData.parentSpan}`); parts.push(""); } // Format repeating spans (the N+1 queries) const repeatingLines = getRepeatingSpanLines(evidenceData); if (repeatingLines.length > 0) { parts.push("### Repeated Database Queries"); parts.push(""); const queryCount = evidenceData.numberRepeatingSpans ? safeParseInt(evidenceData.numberRepeatingSpans, 0) : evidenceData.numPatternRepetitions || evidenceData.offenderSpanIds?.length || 0; if (queryCount > 0) { parts.push(`**Query executed ${queryCount} times:**`); } // Show the query pattern - if single line, render as SQL block; if multiple, as list if (repeatingLines.length === 1) { parts.push("```sql"); parts.push(repeatingLines[0]); parts.push("```"); parts.push(""); } else { parts.push("**Repeated operations:**"); for (const line of repeatingLines) { parts.push(`- ${line}`); } parts.push(""); } } const parentSpanIds = normalizeIdArray(evidenceData.parentSpanIds); const offenderSpanIds = normalizeIdArray(evidenceData.offenderSpanIds); const traceLines = performanceTrace ? buildTraceSpanTree( performanceTrace, parentSpanIds, offenderSpanIds, MAX_SPANS_IN_TREE, ) : []; if (traceLines.length > 0) { parts.push(`### Span Tree (Limited to ${MAX_SPANS_IN_TREE} spans)`); parts.push(""); parts.push("```"); parts.push(...traceLines); parts.push("```"); parts.push(""); } else { const spanTree = isValidSpanArray(spansData) ? selectN1QuerySpans(spansData, evidenceData, MAX_SPANS_IN_TREE) : []; if (spanTree.length > 0) { parts.push(`### Span Tree (Limited to ${MAX_SPANS_IN_TREE} spans)`); parts.push(""); parts.push("```"); parts.push(...renderPerformanceSpanTree(spanTree)); parts.push("```"); parts.push(""); } else if (isValidSpanArray(spansData)) { // Only show offender summaries if we have spans data but couldn't build a tree const offenderSummaries = buildOffenderSummaries( spansData as RawSpan[], offenderSpanIds, ); if (offenderSummaries.length > 0) { parts.push("### Offending Spans"); parts.push(""); for (const summary of offenderSummaries) { parts.push(`- ${summary}`); } } } } return parts.join("\n"); } /** * Formats slow DB query issue evidence data. * * Currently only partially implemented - shows parent span information. * Full implementation would show query duration, explain plan, etc. * * This is lower priority as the generic evidenceDisplay fields usually * provide sufficient information for slow query issues. */ function formatSlowDbQueryEvidence( evidenceData: SlowDbEvidenceData, spansData: unknown, ): string { const parts: string[] = []; // Show parent span if available (generic field that applies to slow queries) if (evidenceData.parentSpan) { parts.push("**Parent Operation:**"); parts.push(`${evidenceData.parentSpan}`); parts.push(""); } // TODO: Implement slow query specific fields when we know the structure // Potential fields: query duration, database name, query plan console.warn( "[formatSlowDbQueryEvidence] Evidence data rendering not yet fully implemented", ); return parts.join("\n"); } /** * Formats performance issue details from transaction events based on the issue type. * * This is the main dispatcher for performance issue formatting. It: * 1. Detects the issue type from occurrence data (numeric or string) * 2. Calls the appropriate type-specific formatter if implemented * 3. Falls back to generic evidenceDisplay fields for unimplemented types * 4. Provides span analysis fallback for events without occurrence data * * The occurrence data structure comes from Sentry's performance issue detection * and contains evidence about what triggered the issue. * * @param event - The transaction event containing performance issue data * @param spansData - The spans data from the event entries * @returns Formatted markdown string with performance issue details */ function formatPerformanceIssueOutput( event: Event, spansData: unknown, options?: { performanceTrace?: Trace; }, ): string { const parts: string[] = []; // Check if we have occurrence data const occurrence = (event as any).occurrence; if (!occurrence) { return ""; } // Get issue type - occurrence.type is numeric, issueType may be a string let issueType: string | undefined; if (typeof occurrence.type === "number") { issueType = OCCURRENCE_TYPE_TO_ISSUE_TYPE[occurrence.type]; } else { issueType = occurrence.issueType || occurrence.type; } const evidenceData = occurrence.evidenceData; // Process evidence data based on known performance issue types if (evidenceData) { switch (issueType) { case KNOWN_PERFORMANCE_ISSUE_TYPES.N_PLUS_ONE_DB_QUERIES: case KNOWN_PERFORMANCE_ISSUE_TYPES.N_PLUS_ONE_API_CALLS: case KNOWN_PERFORMANCE_ISSUE_TYPES.M_N_PLUS_ONE_DB_QUERIES: { const result = formatN1QueryEvidence( evidenceData, spansData, options?.performanceTrace, ); if (result) parts.push(result); break; } case KNOWN_PERFORMANCE_ISSUE_TYPES.SLOW_DB_QUERY: { const result = formatSlowDbQueryEvidence(evidenceData, spansData); if (result) parts.push(result); break; } default: // We don't implement formatters for all performance issue types. // Many lower-priority issues (consecutive queries, asset issues, file I/O) // work fine with just the generic evidenceDisplay fields below. // Only high-value, complex issues like N+1 queries need custom formatting. if (issueType) { console.warn( `[formatPerformanceIssueOutput] No custom formatter for issue type: ${issueType}`, ); } // Fall through to show generic evidence display below } } // Show transaction name if available for any performance issue (generic field) if (evidenceData?.transactionName) { parts.push("**Transaction:**"); parts.push(`${evidenceData.transactionName}`); parts.push(""); } // Always show evidence display if available (this is generic and doesn't require type knowledge) if (occurrence.evidenceDisplay?.length > 0) { for (const display of occurrence.evidenceDisplay) { if (display.important) { parts.push(`**${display.name}:**`); parts.push(`${display.value}`); parts.push(""); } } } return parts.length > 0 ? `${parts.join("\n")}\n` : ""; } function formatTags(tags: z.infer<typeof EventSchema>["tags"]) { if (!tags || tags.length === 0) { return ""; } return `### Tags\n\n${tags .map((tag) => `**${tag.key}**: ${tag.value}`) .join("\n")}\n\n`; } function formatContexts(contexts: z.infer<typeof EventSchema>["contexts"]) { if (!contexts || Object.keys(contexts).length === 0) { return ""; } return `### Additional Context\n\nThese are additional context provided by the user when they're instrumenting their application.\n\n${Object.entries( contexts, ) .map( ([name, data]) => `**${name}**\n${Object.entries(data) .filter(([key, _]) => key !== "type") .map(([key, value]) => { return `${key}: ${JSON.stringify(value, undefined, 2)}`; }) .join("\n")}`, ) .join("\n\n")}\n\n`; } /** * Formats a brief Seer analysis summary for inclusion in issue details. * Shows current status and high-level insights, prompting to use analyze_issue_with_seer for full details. * * @param autofixState - The autofix state containing Seer analysis data * @param organizationSlug - The organization slug for the issue * @param issueId - The issue ID (shortId) * @returns Formatted markdown string with Seer summary, or empty string if no analysis exists */ function formatSeerSummary( autofixState: AutofixRunState | undefined, organizationSlug: string, issueId: string, ): string { if (!autofixState || !autofixState.autofix) { return ""; } const { autofix } = autofixState; const parts: string[] = []; parts.push("## Seer Analysis"); parts.push(""); // Show status first const statusDisplay = getStatusDisplayName(autofix.status); if (!isTerminalStatus(autofix.status)) { parts.push(`**Status:** ${statusDisplay}`); parts.push(""); } // Show summary of what we have so far if (autofix.steps.length > 0) { const completedSteps = autofix.steps.filter( (step) => step.status === "COMPLETED", ); // Find the solution step if available const solutionStep = completedSteps.find( (step) => step.type === "solution", ); if (solutionStep) { // For solution steps, use the description directly const solutionDescription = solutionStep.description; if ( solutionDescription && typeof solutionDescription === "string" && solutionDescription.trim() ) { parts.push("**Summary:**"); parts.push(solutionDescription.trim()); } else { // Fallback to extracting from output if no description const solutionOutput = getOutputForAutofixStep(solutionStep); const lines = solutionOutput.split("\n"); const firstParagraph = lines.find( (line) => line.trim().length > 50 && !line.startsWith("#") && !line.startsWith("*"), ); if (firstParagraph) { parts.push("**Summary:**"); parts.push(firstParagraph.trim()); } } } else if (completedSteps.length > 0) { // Show what steps have been completed so far const rootCauseStep = completedSteps.find( (step) => step.type === "root_cause_analysis", ); if (rootCauseStep) { const typedStep = rootCauseStep as z.infer< typeof AutofixRunStepRootCauseAnalysisSchema >; if ( typedStep.causes && typedStep.causes.length > 0 && typedStep.causes[0].description ) { parts.push("**Root Cause Identified:**"); parts.push(typedStep.causes[0].description.trim()); } } else { // Show generic progress parts.push( `**Progress:** ${completedSteps.length} of ${autofix.steps.length} steps completed`, ); } } } else { // No steps yet - check for terminal states first if (isTerminalStatus(autofix.status)) { if (autofix.status === "FAILED" || autofix.status === "ERROR") { parts.push("**Status:** Analysis failed."); } else if (autofix.status === "CANCELLED") { parts.push("**Status:** Analysis was cancelled."); } else if ( autofix.status === "NEED_MORE_INFORMATION" || autofix.status === "WAITING_FOR_USER_RESPONSE" ) { parts.push( "**Status:** Analysis paused - additional information needed.", ); } } else { parts.push("Analysis has started but no results yet."); } } // Add specific messages for terminal states when steps exist if (autofix.steps.length > 0 && isTerminalStatus(autofix.status)) { if (autofix.status === "FAILED" || autofix.status === "ERROR") { parts.push(""); parts.push("**Status:** Analysis failed."); } else if (autofix.status === "CANCELLED") { parts.push(""); parts.push("**Status:** Analysis was cancelled."); } else if ( autofix.status === "NEED_MORE_INFORMATION" || autofix.status === "WAITING_FOR_USER_RESPONSE" ) { parts.push(""); parts.push( "**Status:** Analysis paused - additional information needed.", ); } } // Always suggest using analyze_issue_with_seer for more details parts.push(""); parts.push( `**Note:** For detailed root cause analysis and solutions, call \`analyze_issue_with_seer(organizationSlug='${organizationSlug}', issueId='${issueId}')\``, ); return `${parts.join("\n")}\n\n`; } /** * Formats a Sentry issue with its latest event into comprehensive markdown output. * Includes issue metadata, event details, and usage instructions. * * @param params - Object containing organization slug, issue, event, and API service * @returns Formatted markdown string with complete issue information */ export function formatIssueOutput({ organizationSlug, issue, event, apiService, autofixState, performanceTrace, }: { organizationSlug: string; issue: Issue; event: Event; apiService: SentryApiService; autofixState?: AutofixRunState; performanceTrace?: Trace; }) { let output = `# Issue ${issue.shortId} in **${organizationSlug}**\n\n`; // Check if this is a performance issue based on issueCategory or issueType // Performance issues can have various categories like 'db_query' but issueType starts with 'performance_' const isPerformanceIssue = issue.issueType?.startsWith("performance_") || issue.issueCategory === "performance"; if (isPerformanceIssue && issue.metadata) { // For performance issues, use metadata for better context const issueTitle = issue.metadata.title || issue.title; output += `**Description**: ${issueTitle}\n`; if (issue.metadata.location) { output += `**Location**: ${issue.metadata.location}\n`; } if (issue.metadata.value) { output += `**Query Pattern**: \`${issue.metadata.value}\`\n`; } } else { // For regular errors and other issues output += `**Description**: ${issue.title}\n`; output += `**Culprit**: ${issue.culprit}\n`; } output += `**First Seen**: ${new Date(issue.firstSeen).toISOString()}\n`; output += `**Last Seen**: ${new Date(issue.lastSeen).toISOString()}\n`; output += `**Occurrences**: ${issue.count}\n`; output += `**Users Impacted**: ${issue.userCount}\n`; output += `**Status**: ${issue.status}\n`; output += `**Platform**: ${issue.platform}\n`; output += `**Project**: ${issue.project.name}\n`; output += `**URL**: ${apiService.getIssueUrl(organizationSlug, issue.shortId)}\n`; output += "\n"; output += "## Event Details\n\n"; output += `**Event ID**: ${event.id}\n`; // "default" type represents error events without exception data if (event.type === "error" || event.type === "default") { output += `**Occurred At**: ${new Date((event as z.infer<typeof ErrorEventSchema>).dateCreated).toISOString()}\n`; } if (event.message) { output += `**Message**:\n${event.message}\n`; } output += "\n"; output += formatEventOutput(event, { performanceTrace }); // Add Seer context if available if (autofixState) { output += formatSeerSummary(autofixState, organizationSlug, issue.shortId); } output += "# Using this information\n\n"; output += `- You can reference the IssueID in commit messages (e.g. \`Fixes ${issue.shortId}\`) to automatically close the issue when the commit is merged.\n`; output += "- The stacktrace includes both first-party application code as well as third-party code, its important to triage to first-party code.\n"; return output; }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/getsentry/sentry-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server