From a819d487c7071328ea93f649d73682d3a743d91f Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:55:14 +0000 Subject: [PATCH 1/2] fix(formatting): read camelCase metric alert evidence and link to breach data Metric issue output only showed the evaluated value and threshold. The event API camelCases the whole occurrence payload, but the formatter read snake_case keys (`data_sources`, `query_obj`, `snuba_query`, `alert_id`), so dataset, aggregate, query, interval and environment never rendered. Only single-word keys like `value` and `conditions` matched. - Read `dataSources[].queryObj.snubaQuery`, `detectorId` and `alertId`. - Show the Metric Monitor ID, a readable dataset label and the evaluated window. - Replace the stacktrace and trace hints in Response Notes with `get_metric_monitor_details` and a pre-filled `search_errors`, `search_logs`, `search_traces` or `search_metrics` call for the monitor's query, environment and a period covering the breach. - Rebuild the metric tests from the real camelCased event shape. Fixes #1087 Co-Authored-By: Dhrumil Parekh --- docs/contributing/tool-responses.md | 8 + .../mcp-core/src/internal/formatting.test.ts | 379 +++++++++++------- packages/mcp-core/src/internal/formatting.ts | 297 ++++++++++++-- .../tool-helpers/tool-call-formatting.ts | 14 +- 4 files changed, 515 insertions(+), 183 deletions(-) diff --git a/docs/contributing/tool-responses.md b/docs/contributing/tool-responses.md index 2805c164c..e99c0806b 100644 --- a/docs/contributing/tool-responses.md +++ b/docs/contributing/tool-responses.md @@ -125,6 +125,14 @@ omits the section. Commit lookup is optional: failures do not prevent issue details from loading, but unexpected server or response-validation failures are reported to Sentry. +For metric issues, `Metric Alert Details` comes from the occurrence's +`evidenceData`, which the event API returns camelCased +(`dataSources[].queryObj.snubaQuery`, `detectorId`). It shows the Metric +Monitor ID, dataset, aggregate, query, interval, environment, evaluated window, +value, and threshold. Response Notes replace the stacktrace and trace hints with +`get_metric_monitor_details` and a pre-filled `search_errors`, `search_logs`, +`search_traces`, or `search_metrics` call for the monitor's query. + The event API's legacy `context` field (extra data) can be `null`. Issue details must still load; omit the `Extra Data` section for null or empty values while preserving the event's error, stacktrace, and structured `contexts` data. diff --git a/packages/mcp-core/src/internal/formatting.test.ts b/packages/mcp-core/src/internal/formatting.test.ts index 04bba5d01..4c13ea8bf 100644 --- a/packages/mcp-core/src/internal/formatting.test.ts +++ b/packages/mcp-core/src/internal/formatting.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { SentryApiService } from "../api-client"; import type { AutofixRunState, Event, Issue } from "../api-client/types"; import { @@ -2208,71 +2208,146 @@ describe("formatEventOutput", () => { }); }); -describe("formatEventOutput metric alert details", () => { - it("includes alert rule query context for metric_issue events", () => { - const event = { - id: "metric-event-1", - type: "generic", - title: "High error count", - message: null, - platform: "other", - dateCreated: "2026-06-01T00:00:00.000Z", - entries: [], - tags: [{ key: "level", value: "error" }], - occurrence: { - id: "occ-1", - projectId: 1, - eventId: "metric-event-1", - fingerprint: ["abc"], - issueTitle: "High error count", - subtitle: "Critical: Number of events in the last minute above 100", - type: 8001, - culprit: "", - evidenceData: { - value: 150, - alert_id: 42, - conditions: [{ type: "gt", comparison: 100 }], - data_sources: [ - { - query_obj: { - snuba_query: { - dataset: "events", - query: "process_shard OR OutboxFlushError", - aggregate: "count()", - time_window: 60, - environment: "production", - }, - }, - }, - ], +/** + * Mirrors a real metric issue event from `GET /issues/{id}/events/{id}/`: the + * event serializer camelCases the occurrence payload, including nested + * workflow-engine evidence and the serialized data source. + */ +function createMetricIssueEvent({ + evidenceData, + dateCreated = "2026-10-28T11:39:52.000Z", +}: { + evidenceData: Record; + dateCreated?: string; +}): Event { + return { + id: "4d6e1b607c564714a7de102a835fae54", + type: "generic", + title: "Error logs above 4000 over past 1 hour", + message: null, + platform: "python", + dateCreated, + entries: [], + tags: [ + { key: "interface_type", value: "contexts" }, + { key: "level", value: "error" }, + ], + contexts: { + trace: { + trace_id: "bfa06a9c589f4d4f89236378d2194adc", + span_id: "bfa06a9c589f4d4f", + status: "unknown", + type: "trace", + }, + }, + occurrence: { + id: "occ-1", + projectId: 1, + eventId: "4d6e1b607c564714a7de102a835fae54", + fingerprint: ["abc"], + issueTitle: "Error logs above 4000 over past 1 hour", + subtitle: "Critical: Number of error logs in the last hour above 4000", + type: 8001, + culprit: "", + evidenceData, + evidenceDisplay: [], + }, + } as unknown as Event; +} + +function createLogsMonitorEvidence( + snubaQuery: Record = {}, +): Record { + return { + value: 4988, + detectorId: 1234567, + dataPacketSourceId: "998877", + conditions: [{ id: 1, type: "gt", comparison: 4000, conditionResult: 75 }], + config: { detectionType: "static", thresholdPeriod: 1 }, + dataSources: [ + { + id: "55", + organizationId: "1", + type: "snuba_query_subscription", + sourceId: "998877", + queryObj: { + id: "998877", + status: 0, + subscription: "sub-1", + snubaQuery: { + id: "77", + dataset: "events_analytics_platform", + query: "severity:error", + aggregate: "count(message)", + timeWindow: 3600, + environment: "prod", + eventTypes: ["trace_item_log"], + extrapolationMode: "unknown", + ...snubaQuery, + }, }, - evidenceDisplay: [], }, - } as Event; + ], + }; +} - const output = formatEventOutput(event); +describe("formatEventOutput metric alert details", () => { + it("includes monitor query context from camelCased evidence", () => { + const output = formatEventOutput( + createMetricIssueEvent({ evidenceData: createLogsMonitorEvidence() }), + ); expect(output).toMatchInlineSnapshot(` "### Metric Alert Details - **Dataset**: events - **Aggregate**: count() - **Query**: \`process_shard OR OutboxFlushError\` - **Interval**: 60 second(s) - **Environment**: production - - **Evaluated Value**: 150 - **Threshold**: above 100 - **Alert Rule ID**: 42 + **Monitor ID**: 1234567 + **Dataset**: Logs + **Aggregate**: count(message) + **Query**: \`severity:error\` + **Interval**: 3600 second(s) + **Environment**: prod + **Evaluated Window**: 2026-10-28T10:39:52.000Z to 2026-10-28T11:39:52.000Z + **Evaluated Value**: 4988 + **Threshold**: above 4000 ### Tags + **interface_type**: contexts **level**: error + ### Additional Context + + These are additional context provided by the user when they're instrumenting their application. + + **trace** + trace_id: "bfa06a9c589f4d4f89236378d2194adc" + span_id: "bfa06a9c589f4d4f" + status: "unknown" + " `); }); + it("labels errors and spans datasets and keeps unknown datasets verbatim", () => { + const datasetLabel = (snubaQuery: Record) => + formatEventOutput( + createMetricIssueEvent({ + evidenceData: createLogsMonitorEvidence(snubaQuery), + }), + ).match(/\*\*Dataset\*\*: (.+)/)?.[1]; + + expect(datasetLabel({ dataset: "events", eventTypes: ["error"] })).toBe( + "Errors", + ); + expect(datasetLabel({ eventTypes: ["trace_item_span"] })).toBe("Spans"); + expect(datasetLabel({ eventTypes: ["trace_item_metric"] })).toBe( + "Application metrics", + ); + expect( + datasetLabel({ dataset: "transactions", eventTypes: ["transaction"] }), + ).toBe("transactions"); + }); + it("keeps performance regression formatting for regression evidence", () => { const event = { id: "regression-event-1", @@ -2312,123 +2387,147 @@ describe("formatEventOutput metric alert details", () => { expect(output).toContain("POST /oauth/token duration increased"); }); - it("formats below-threshold conditions using workflow-engine type strings", () => { - const event = { - id: "metric-event-2", - type: "generic", - title: "Low throughput", - message: null, - platform: "other", - dateCreated: "2026-06-01T00:00:00.000Z", - entries: [], - occurrence: { - id: "occ-3", - projectId: 1, - eventId: "metric-event-2", - fingerprint: ["ghi"], - issueTitle: "Low throughput", - type: 8001, - culprit: "", - evidenceData: { - value: 5, - alert_id: 99, - conditions: [{ type: "lt", comparison: 10 }], - data_sources: [ - { - query_obj: { - snuba_query: { - dataset: "events", - query: "", - aggregate: "count()", - time_window: 300, - }, - }, - }, - ], - }, - }, - } as Event; + it("formats below-threshold conditions and omits an empty environment", () => { + const evidenceData = createLogsMonitorEvidence({ environment: null }); + evidenceData.conditions = [{ type: "lt", comparison: 10 }]; - const output = formatEventOutput(event); + const output = formatEventOutput(createMetricIssueEvent({ evidenceData })); expect(output).toContain("**Threshold**: below 10"); expect(output).not.toContain("**Environment**:"); }); - it("supports alert_id-only metric alert evidence without data_sources", () => { - const event = { - id: "metric-event-3", - type: "generic", - title: "Alert triggered", - message: null, - platform: "other", - dateCreated: "2026-06-01T00:00:00.000Z", - entries: [], - occurrence: { - id: "occ-4", - projectId: 1, - eventId: "metric-event-3", - fingerprint: ["jkl"], - issueTitle: "Alert triggered", - type: 8001, - culprit: "", + it("renders value, threshold and legacy alert rule ID without data sources", () => { + const output = formatEventOutput( + createMetricIssueEvent({ evidenceData: { value: 42, - alert_id: 7, + alertId: 7, conditions: [ { type: "gte", comparison: 40 }, { type: "lte", comparison: 50 }, ], }, - }, - } as Event; - - const output = formatEventOutput(event); + }), + ); expect(output).toContain("**Evaluated Value**: 42"); expect(output).toContain("**Alert Rule ID**: 7"); expect(output).toContain("**Threshold**: at or above 40, at or below 50"); expect(output).not.toContain("**Dataset**:"); + expect(output).not.toContain("**Evaluated Window**:"); }); it("formats dynamic alert values without object placeholders", () => { - const event = { - id: "metric-event-4", - type: "generic", - title: "Dynamic throughput alert", - message: null, - platform: "other", - dateCreated: "2026-06-01T00:00:00.000Z", - entries: [], - occurrence: { - id: "occ-5", - projectId: 1, - eventId: "metric-event-4", - fingerprint: ["mno"], - issueTitle: "Dynamic throughput alert", - type: 8001, - culprit: "", - evidenceData: { - value: { value: 12.5, source_id: "123", timestamp: 1780272000 }, - alert_id: 11, - conditions: [ - { - type: "anomaly_detection", - comparison: { - sensitivity: "medium", - seasonality: "auto", - threshold_type: 0, - }, - }, - ], + const evidenceData = createLogsMonitorEvidence(); + evidenceData.value = { + value: 12.5, + source_id: "123", + timestamp: 1780272000, + }; + evidenceData.conditions = [ + { + type: "anomaly_detection", + comparison: { + sensitivity: "medium", + seasonality: "auto", + thresholdType: 0, }, }, - } as Event; + ]; - const output = formatEventOutput(event); + const output = formatEventOutput(createMetricIssueEvent({ evidenceData })); expect(output).toContain("**Evaluated Value**: 12.5"); expect(output).not.toContain("[object Object]"); expect(output).not.toContain("**Threshold**:"); }); }); + +describe("formatIssueOutput metric issue response notes", () => { + const metricIssue = { + id: "7001", + shortId: "SEER-A18", + title: "Error logs above 4000 over past 1 hour", + count: "2", + userCount: 0, + status: "resolved", + issueType: "metric_issue", + issueCategory: "metric", + project: { name: "seer", slug: "seer" }, + } as Issue; + const apiService = { + getIssueUrl: () => "https://sentry.sentry.io/issues/SEER-A18", + } as unknown as SentryApiService; + + function responseNotes(evidenceData: Record): string { + const output = formatIssueOutput({ + organizationSlug: "sentry", + issue: metricIssue, + event: createMetricIssueEvent({ evidenceData }), + apiService, + }); + return output.slice(output.indexOf("## Response Notes")); + } + + beforeEach(() => { + vi.useFakeTimers({ now: new Date("2026-10-29T02:30:00.000Z") }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("points at the monitor and the data that breached it", () => { + expect(responseNotes(createLogsMonitorEvidence())).toMatchInlineSnapshot(` + "## Response Notes + + - Commit message issue reference: \`Fixes SEER-A18\` automatically closes the issue when the commit is merged. + - Metric Monitor configuration and connected Alerts: Use the Sentry tool \`get_metric_monitor_details(organizationSlug='sentry', monitorId='1234567')\` + - Logs matched by the monitor query: Use the Sentry tool \`search_logs(organizationSlug='sentry', projectSlug='seer', query='severity:error environment:prod', period='16h')\`, then focus on 2026-10-28T10:39:52.000Z to 2026-10-28T11:39:52.000Z + - Issue event search: Use the Sentry tool \`search_issue_events\` + " + `); + }); + + it("uses the dataset-specific search tool", () => { + expect( + responseNotes( + createLogsMonitorEvidence({ + dataset: "events", + query: "", + environment: "my env", + eventTypes: ["error", "default"], + }), + ), + ).toContain( + "Use the Sentry tool `search_errors(organizationSlug='sentry', projectSlug='seer', query='environment:\"my env\"', period='16h')`", + ); + expect( + responseNotes( + createLogsMonitorEvidence({ eventTypes: ["trace_item_span"] }), + ), + ).toContain( + "Spans matched by the monitor query: Use the Sentry tool `search_traces(", + ); + }); + + it("omits the search call when the dataset has no matching search tool", () => { + const notes = responseNotes( + createLogsMonitorEvidence({ + dataset: "metrics", + eventTypes: [], + }), + ); + + expect(notes).toContain("get_metric_monitor_details("); + expect(notes).not.toContain("matched by the monitor query"); + }); + + it("drops stacktrace and evaluation trace hints", () => { + const notes = responseNotes(createLogsMonitorEvidence()); + + expect(notes).not.toContain("stacktrace"); + expect(notes).not.toContain("trace:bfa06a9c589f4d4f89236378d2194adc"); + }); +}); diff --git a/packages/mcp-core/src/internal/formatting.ts b/packages/mcp-core/src/internal/formatting.ts index 6ce811051..722459800 100644 --- a/packages/mcp-core/src/internal/formatting.ts +++ b/packages/mcp-core/src/internal/formatting.ts @@ -1691,34 +1691,143 @@ function formatPrimitive(value: unknown): string | undefined { return isPrimitive(value) ? String(value) : undefined; } +const METRIC_ISSUE_OCCURRENCE_TYPE = 8001; + +type MetricAlertQuery = { + dataset?: string; + aggregate?: string; + query?: string; + timeWindowSeconds?: number; + environment?: string; + eventTypes: string[]; +}; + +type MetricAlertContext = { + evidenceData: Record; + query?: MetricAlertQuery; + monitorId?: string; + alertRuleId?: string; +}; + /** - * Extracts the Snuba query from metric alert evidence data. + * Reads metric alert evidence from a metric issue event. The event API + * camelCases occurrence payloads recursively, so evidence keys arrive as + * `dataSources[].queryObj.snubaQuery`, `detectorId`, and `alertId`. */ -function getMetricAlertSnubaQuery( +function getMetricAlertContext(event: Event): MetricAlertContext | undefined { + if (event.type !== "generic") { + return undefined; + } + const occurrence = (event as GenericEvent).occurrence; + const evidenceData = occurrence?.evidenceData; + if (occurrence?.type !== METRIC_ISSUE_OCCURRENCE_TYPE || !evidenceData) { + return undefined; + } + + return { + evidenceData, + query: getMetricAlertQuery(evidenceData), + monitorId: formatPrimitive(evidenceData.detectorId), + alertRuleId: formatPrimitive(evidenceData.alertId), + }; +} + +function getMetricAlertQuery( evidenceData: Record, -): Record | undefined { - const dataSources = evidenceData.data_sources; +): MetricAlertQuery | undefined { + const dataSources = evidenceData.dataSources; if (!Array.isArray(dataSources)) { return undefined; } for (const dataSource of dataSources) { - if (!isPlainObject(dataSource)) { + if (!isPlainObject(dataSource) || !isPlainObject(dataSource.queryObj)) { continue; } - const queryObj = dataSource.query_obj; - if (!isPlainObject(queryObj)) { + const snubaQuery = dataSource.queryObj.snubaQuery; + if (!isPlainObject(snubaQuery)) { continue; } - const snubaQuery = queryObj.snuba_query; - if (isPlainObject(snubaQuery)) { - return snubaQuery; - } + const timeWindow = snubaQuery.timeWindow; + return { + dataset: formatPrimitive(snubaQuery.dataset), + aggregate: formatPrimitive(snubaQuery.aggregate), + query: formatPrimitive(snubaQuery.query)?.trim() || undefined, + timeWindowSeconds: + typeof timeWindow === "number" ? timeWindow : undefined, + environment: formatPrimitive(snubaQuery.environment), + eventTypes: Array.isArray(snubaQuery.eventTypes) + ? snubaQuery.eventTypes.filter( + (eventType): eventType is string => typeof eventType === "string", + ) + : [], + }; } return undefined; } +type MetricAlertDatasetKind = "errors" | "logs" | "spans" | "metrics"; + +/** Mirrors the Sentry UI's backend dataset -> detector dataset mapping. */ +function getMetricAlertDatasetKind( + query: MetricAlertQuery, +): MetricAlertDatasetKind | undefined { + if (query.dataset === "events") { + return "errors"; + } + if (query.dataset === "events_analytics_platform") { + if (query.eventTypes.includes("trace_item_log")) { + return "logs"; + } + if (query.eventTypes.includes("trace_item_metric")) { + return "metrics"; + } + return "spans"; + } + return undefined; +} + +const METRIC_ALERT_SEARCH_TOOLS: Record< + MetricAlertDatasetKind, + { toolName: string; label: string } +> = { + errors: { toolName: "search_errors", label: "Errors" }, + logs: { toolName: "search_logs", label: "Logs" }, + spans: { toolName: "search_traces", label: "Spans" }, + metrics: { toolName: "search_metrics", label: "Application metrics" }, +}; + +/** Returns the window the monitor aggregated over for this occurrence. */ +function getMetricAlertEvaluatedWindow( + event: Event, + query: MetricAlertQuery | undefined, +): { start: Date; end: Date } | undefined { + const dateCreated = (event as GenericEvent).dateCreated; + if (!dateCreated || query?.timeWindowSeconds == null) { + return undefined; + } + const end = new Date(dateCreated); + if (Number.isNaN(end.getTime())) { + return undefined; + } + return { + start: new Date(end.getTime() - query.timeWindowSeconds * 1000), + end, + }; +} + +function formatSearchFilterValue(value: string): string { + return /[\s"(),:]/.test(value) ? JSON.stringify(value) : value; +} + +/** Smallest relative search period that reaches back to `start`. */ +function formatPeriodSince(start: Date, now: number): string { + const elapsedMs = Math.max(now - start.getTime(), 0); + const hours = Math.max(Math.ceil(elapsedMs / 3_600_000), 1); + return hours <= 48 ? `${hours}h` : `${Math.ceil(hours / 24)}d`; +} + function formatMetricAlertCondition( condition: Record, ): string | undefined { @@ -1763,32 +1872,46 @@ function formatMetricAlertValue(value: unknown): string | undefined { } function formatMetricAlertDetails( - evidenceData: Record, + event: Event, + context: MetricAlertContext, ): string { + const { evidenceData, query } = context; const parts: string[] = ["### Metric Alert Details", ""]; - const snubaQuery = getMetricAlertSnubaQuery(evidenceData); - if (snubaQuery) { - if (snubaQuery.dataset != null) { - parts.push(`**Dataset**: ${formatUnknown(snubaQuery.dataset)}`); + if (context.monitorId) { + parts.push(`**Monitor ID**: ${context.monitorId}`); + } + if (context.alertRuleId) { + parts.push(`**Alert Rule ID**: ${context.alertRuleId}`); + } + + if (query) { + const datasetKind = getMetricAlertDatasetKind(query); + const dataset = datasetKind + ? METRIC_ALERT_SEARCH_TOOLS[datasetKind].label + : query.dataset; + if (dataset) { + parts.push(`**Dataset**: ${dataset}`); } - if (snubaQuery.aggregate != null) { - parts.push(`**Aggregate**: ${formatUnknown(snubaQuery.aggregate)}`); + if (query.aggregate) { + parts.push(`**Aggregate**: ${query.aggregate}`); } - const query = - snubaQuery.query != null ? formatUnknown(snubaQuery.query) : ""; - if (query.length > 0) { - parts.push(`**Query**: \`${query}\``); + if (query.query) { + parts.push(`**Query**: \`${query.query}\``); } - if (snubaQuery.time_window != null) { - parts.push( - `**Interval**: ${formatUnknown(snubaQuery.time_window)} second(s)`, - ); + if (query.timeWindowSeconds != null) { + parts.push(`**Interval**: ${query.timeWindowSeconds} second(s)`); } - if (snubaQuery.environment != null) { - parts.push(`**Environment**: ${formatUnknown(snubaQuery.environment)}`); + if (query.environment) { + parts.push(`**Environment**: ${query.environment}`); } - parts.push(""); + } + + const evaluatedWindow = getMetricAlertEvaluatedWindow(event, query); + if (evaluatedWindow) { + parts.push( + `**Evaluated Window**: ${evaluatedWindow.start.toISOString()} to ${evaluatedWindow.end.toISOString()}`, + ); } const formattedValue = formatMetricAlertValue(evidenceData.value); @@ -1807,14 +1930,89 @@ function formatMetricAlertDetails( } } - if (evidenceData.alert_id != null) { - parts.push(`**Alert Rule ID**: ${formatUnknown(evidenceData.alert_id)}`); - } - parts.push(""); return `${parts.join("\n")}\n`; } +/** + * Follow-up notes for metric issues: the monitor configuration and a search + * call that reproduces the monitor query over the evaluated window. + */ +function buildMetricAlertResponseNotes({ + organizationSlug, + issue, + event, + context, + experimentalMode, + availableToolNames, + directToolNames, +}: { + organizationSlug: string; + issue: Issue; + event: Event; + context: MetricAlertContext; + experimentalMode: boolean; + availableToolNames?: ReadonlySet; + directToolNames?: ReadonlySet; +}): string[] { + const notes: string[] = []; + + if (context.monitorId) { + const monitorInstruction = formatToolCallInstruction({ + toolName: "get_metric_monitor_details", + arguments: { organizationSlug, monitorId: context.monitorId }, + showArguments: true, + experimentalMode, + availableToolNames, + directToolNames, + fallbackInstruction: "", + }); + if (monitorInstruction) { + notes.push( + `Metric Monitor configuration and connected Alerts: ${monitorInstruction}`, + ); + } + } + + const query = context.query; + const datasetKind = query ? getMetricAlertDatasetKind(query) : undefined; + const evaluatedWindow = getMetricAlertEvaluatedWindow(event, query); + if (!query || !datasetKind || !evaluatedWindow) { + return notes; + } + + const { toolName, label } = METRIC_ALERT_SEARCH_TOOLS[datasetKind]; + const searchQuery = [ + query.query, + query.environment + ? `environment:${formatSearchFilterValue(query.environment)}` + : undefined, + ] + .filter(Boolean) + .join(" "); + const searchInstruction = formatToolCallInstruction({ + toolName, + arguments: { + organizationSlug, + projectSlug: issue.project.slug, + ...(searchQuery ? { query: searchQuery } : {}), + period: formatPeriodSince(evaluatedWindow.start, Date.now()), + }, + showArguments: true, + experimentalMode, + availableToolNames, + directToolNames, + fallbackInstruction: "", + }); + if (searchInstruction) { + notes.push( + `${label} matched by the monitor query: ${searchInstruction}, then focus on ${evaluatedWindow.start.toISOString()} to ${evaluatedWindow.end.toISOString()}`, + ); + } + + return notes; +} + /** * Formats generic event output (performance regressions, metric-based issues). * Generic events don't have traditional error entries, but have occurrence data @@ -1835,11 +2033,13 @@ function formatGenericEventOutput(event: Event): string { return ""; } - const evidenceData = occurrence.evidenceData; - if (occurrence.type === 8001 && evidenceData) { - return formatMetricAlertDetails(evidenceData); + const metricAlertContext = getMetricAlertContext(event); + if (metricAlertContext) { + return formatMetricAlertDetails(event, metricAlertContext); } + const evidenceData = occurrence.evidenceData; + // Add a section header for performance regression details if (evidenceData) { parts.push("### Performance Regression Details"); @@ -2274,9 +2474,26 @@ export function buildIssueResponseNotes({ notes.push( `Commit message issue reference: \`Fixes ${commitIssueReference}\` automatically closes the issue when the commit is merged.`, ); - notes.push( - "The stacktrace includes first-party application code and third-party code. First-party frames are usually the best starting point for triage.", - ); + // Metric issues have no stacktrace, and their trace context belongs to the + // monitor evaluation rather than the data that breached the threshold. + const metricAlertContext = getMetricAlertContext(event); + if (metricAlertContext) { + notes.push( + ...buildMetricAlertResponseNotes({ + organizationSlug, + issue, + event, + context: metricAlertContext, + experimentalMode: experimentalMode ?? false, + availableToolNames, + directToolNames, + }), + ); + } else { + notes.push( + "The stacktrace includes first-party application code and third-party code. First-party frames are usually the best starting point for triage.", + ); + } if (aiConversations && aiConversations.length > 0) { notes.push( ...buildAIConversationResponseNotes({ @@ -2328,7 +2545,7 @@ export function buildIssueResponseNotes({ notes.push(`Thread stacktrace lookup: ${stacktraceInstruction}`); } } - if (traceId) { + if (traceId && !metricAlertContext) { const traceDetailsInstruction = formatToolCallInstruction({ toolName: "get_sentry_resource", arguments: { diff --git a/packages/mcp-core/src/internal/tool-helpers/tool-call-formatting.ts b/packages/mcp-core/src/internal/tool-helpers/tool-call-formatting.ts index 883f1ffbb..553ca8eeb 100644 --- a/packages/mcp-core/src/internal/tool-helpers/tool-call-formatting.ts +++ b/packages/mcp-core/src/internal/tool-helpers/tool-call-formatting.ts @@ -65,10 +65,13 @@ export function formatToolCall({ /** * Formats user-facing tool guidance as a direct call, catalog gateway call, or * fallback message based on the current session's available/direct tools. + * `showArguments` renders the full call when the arguments are concrete values + * the agent can run as-is rather than placeholders. */ export function formatToolCallInstruction({ toolName, - arguments: _args = {}, + arguments: args = {}, + showArguments = false, experimentalMode, purpose, availableToolNames, @@ -77,6 +80,7 @@ export function formatToolCallInstruction({ }: { toolName: string; arguments?: Record; + showArguments?: boolean; experimentalMode: boolean; purpose?: string; availableToolNames?: ReadonlySet; @@ -88,11 +92,15 @@ export function formatToolCallInstruction({ availableToolNames, ); + const toolReference = showArguments + ? formatToolCall({ toolName, arguments: args }) + : toolName; + if ( targetAvailable && isDirectTool(toolName, experimentalMode, directToolNames) ) { - return `Use the Sentry tool \`${toolName}\`${formatPurpose(purpose)}`; + return `Use the Sentry tool \`${toolReference}\`${formatPurpose(purpose)}`; } const catalogGatewayAvailable = @@ -102,7 +110,7 @@ export function formatToolCallInstruction({ isDirectTool("execute_sentry_tool", experimentalMode, directToolNames); if (targetAvailable && catalogGatewayAvailable) { - return `Use the Sentry tool \`${toolName}\`${formatPurpose(purpose)}`; + return `Use the Sentry tool \`${toolReference}\`${formatPurpose(purpose)}`; } return ( From 5dbee3731c2ab2810084c7a9ae6234748f765b32 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:04:16 +0000 Subject: [PATCH 2/2] fix(formatting): group OR monitor queries before the environment filter Sentry search binds implicit AND tighter than OR, so appending `environment:` to an OR query only constrained its last term. --- packages/mcp-core/src/internal/formatting.test.ts | 10 ++++++++++ packages/mcp-core/src/internal/formatting.ts | 8 +++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/mcp-core/src/internal/formatting.test.ts b/packages/mcp-core/src/internal/formatting.test.ts index 4c13ea8bf..fc916b5ac 100644 --- a/packages/mcp-core/src/internal/formatting.test.ts +++ b/packages/mcp-core/src/internal/formatting.test.ts @@ -2490,6 +2490,16 @@ describe("formatIssueOutput metric issue response notes", () => { `); }); + it("groups an OR monitor query before adding the environment filter", () => { + expect( + responseNotes( + createLogsMonitorEvidence({ + query: "severity:error OR severity:fatal", + }), + ), + ).toContain("query='(severity:error OR severity:fatal) environment:prod'"); + }); + it("uses the dataset-specific search tool", () => { expect( responseNotes( diff --git a/packages/mcp-core/src/internal/formatting.ts b/packages/mcp-core/src/internal/formatting.ts index 722459800..36bacba56 100644 --- a/packages/mcp-core/src/internal/formatting.ts +++ b/packages/mcp-core/src/internal/formatting.ts @@ -1982,8 +1982,14 @@ function buildMetricAlertResponseNotes({ } const { toolName, label } = METRIC_ALERT_SEARCH_TOOLS[datasetKind]; + // Sentry search binds implicit AND tighter than OR, so group an OR query + // before appending the environment filter. + const monitorQuery = + query.query && query.environment && /\bOR\b/.test(query.query) + ? `(${query.query})` + : query.query; const searchQuery = [ - query.query, + monitorQuery, query.environment ? `environment:${formatSearchFilterValue(query.environment)}` : undefined,