From 8d9d8ec2a6f020df1270e44a5ab64e2df9141a6a Mon Sep 17 00:00:00 2001 From: William Mak Date: Fri, 19 Jun 2026 15:03:50 -0400 Subject: [PATCH 1/2] feat(search-events): Require validation before search and agent repair Block search_events until GET /events/validate/ passes. When validation fails with an embedded agent configured, run up to three repair attempts before returning formatted validation errors. Without an agent provider, reject invalid searches immediately. Keep datasetAttributes as discovery-only, add validation telemetry spans, and cover repair success, repaired /events/ params, and retry limits in tests. Co-Authored-By: Composer Co-authored-by: Cursor --- .../src/tools/catalog/search-events.test.ts | 352 ++++++++++++++++++ .../src/tools/catalog/search-events.ts | 242 ++++++++++-- .../src/tools/support/search-events/config.ts | 4 +- .../src/tools/support/search-events/utils.ts | 164 ++++++-- 4 files changed, 712 insertions(+), 50 deletions(-) diff --git a/packages/mcp-core/src/tools/catalog/search-events.test.ts b/packages/mcp-core/src/tools/catalog/search-events.test.ts index a93040807..5247b1c1d 100644 --- a/packages/mcp-core/src/tools/catalog/search-events.test.ts +++ b/packages/mcp-core/src/tools/catalog/search-events.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { http, HttpResponse } from "msw"; import { mswServer } from "@sentry/mcp-server-mocks"; import searchEvents from "./search-events"; +import { MAX_EVENTS_VALIDATION_ATTEMPTS } from "../support/search-events/utils"; import { generateText } from "ai"; import { UserInputError } from "../../errors"; @@ -99,10 +100,30 @@ describe("search_events", () => { } as any; }; + const validEventsValidationResponse = { + valid: true, + projects: [], + dataset: [], + environment: [], + field: [], + query: { valid: true, error: null, fields: [] }, + orderby: [], + }; + + const mockValidEventsValidation = () => { + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/:orgSlug/events/validate/", + () => HttpResponse.json(validEventsValidationResponse), + ), + ); + }; + beforeEach(() => { vi.clearAllMocks(); process.env.OPENAI_API_KEY = "test-key"; mockGenerateText.mockResolvedValue(mockAIResponse("errors")); + mockValidEventsValidation(); }); it("should handle spans dataset queries", async () => { @@ -2314,4 +2335,335 @@ describe("search_events", () => { expect(mockGenerateText).not.toHaveBeenCalled(); expect(result).toContain("Database Error"); }); + + it("repairs invalid search params and calls events with updated parameters after validation fails", async () => { + let validateCalls = 0; + let eventsRequestUrl: URL | undefined; + + mockGenerateText.mockResolvedValueOnce( + mockAIResponse( + "spans", + "span.duration:>100 span.op:db", + ["span.op", "span.duration"], + undefined, + "-span.duration", + { statsPeriod: "7d" }, + ), + ); + + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/test-org/events/validate/", + () => { + validateCalls += 1; + if (validateCalls === 1) { + return HttpResponse.json({ + valid: false, + projects: [], + dataset: [], + environment: [], + field: [ + { + name: "spon.duration", + valid: false, + error: "Unknown attribute", + }, + ], + query: { + valid: false, + error: "Invalid syntax", + fields: [ + { + name: "spon.duration", + valid: false, + error: "Unknown attribute", + }, + ], + }, + orderby: [ + { + name: "-spon.duration", + valid: false, + error: "Orderby must also be a selected field", + }, + ], + }); + } + + return HttpResponse.json(validEventsValidationResponse); + }, + ), + http.get( + "https://sentry.io/api/0/organizations/test-org/events/", + ({ request }) => { + eventsRequestUrl = new URL(request.url); + return HttpResponse.json({ + data: [ + { + id: "span1", + "span.op": "db", + "span.duration": 42, + }, + ], + }); + }, + ), + ); + + const result = await searchEvents.handler( + { + organizationSlug: "test-org", + regionUrl: null, + projectSlug: null, + dataset: "spans", + query: "spon.duration:>100 span.op:db", + fields: ["spon.duration"], + sort: "-spon.duration", + statsPeriod: "24h", + limit: 10, + includeExplanation: false, + }, + { + constraints: { + organizationSlug: null, + regionUrl: null, + projectSlug: null, + }, + accessToken: "test-token", + userId: "1", + }, + ); + + expect(validateCalls).toBe(2); + expect(mockGenerateText).toHaveBeenCalledTimes(1); + expect(eventsRequestUrl).toBeDefined(); + expect(eventsRequestUrl!.searchParams.get("dataset")).toBe("spans"); + expect(eventsRequestUrl!.searchParams.get("query")).toBe( + "span.duration:>100 span.op:db", + ); + expect(eventsRequestUrl!.searchParams.get("query")).not.toContain( + "spon.duration", + ); + expect(eventsRequestUrl!.searchParams.getAll("field")).toEqual([ + "span.op", + "span.duration", + ]); + expect(eventsRequestUrl!.searchParams.getAll("field")).not.toContain( + "spon.duration", + ); + expect(eventsRequestUrl!.searchParams.get("sort")).toBe("-span.duration"); + expect(eventsRequestUrl!.searchParams.get("statsPeriod")).toBe("7d"); + expect(result).toContain("span1"); + }); + + it("repairs invalid query syntax with the agent after validation fails", async () => { + let validateCalls = 0; + + mockGenerateText.mockResolvedValueOnce( + mockAIResponse("spans", "span.op:db", ["span.op", "span.duration"]), + ); + + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/test-org/events/validate/", + () => { + validateCalls += 1; + if (validateCalls === 1) { + return HttpResponse.json({ + valid: false, + projects: [], + dataset: [], + environment: [], + field: [], + query: { + valid: false, + error: "Invalid syntax", + fields: [], + }, + orderby: [], + }); + } + + return HttpResponse.json(validEventsValidationResponse); + }, + ), + http.get("https://sentry.io/api/0/organizations/test-org/events/", () => + HttpResponse.json({ + data: [ + { + id: "span1", + "span.op": "db", + "span.duration": 42, + }, + ], + }), + ), + ); + + const result = await searchEvents.handler( + { + organizationSlug: "test-org", + regionUrl: null, + projectSlug: null, + dataset: "spans", + query: "span.op:db AND", + fields: ["span.duration"], + sort: "-span.duration", + statsPeriod: "24h", + limit: 10, + includeExplanation: false, + }, + { + constraints: { + organizationSlug: null, + regionUrl: null, + projectSlug: null, + }, + accessToken: "test-token", + userId: "1", + }, + ); + + expect(validateCalls).toBe(2); + expect(mockGenerateText).toHaveBeenCalledTimes(1); + expect(result).toContain("span1"); + }); + + it("rejects search after MAX_EVENTS_VALIDATION_ATTEMPTS without calling events", async () => { + let validateCalls = 0; + + mockGenerateText.mockResolvedValue( + mockAIResponse("spans", "tags[missing]:true", ["span.duration"]), + ); + + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/test-org/events/validate/", + () => { + validateCalls += 1; + return HttpResponse.json({ + valid: false, + projects: [], + dataset: [], + environment: [], + field: [], + query: { + valid: false, + error: "Invalid syntax", + fields: [ + { + name: "tags[missing]", + valid: false, + attrType: null, + error: "Unknown attribute", + }, + ], + }, + orderby: [], + }); + }, + ), + http.get("https://sentry.io/api/0/organizations/test-org/events/", () => { + throw new Error("searchEvents should not be called"); + }), + ); + + await expect( + searchEvents.handler( + { + organizationSlug: "test-org", + regionUrl: null, + projectSlug: null, + dataset: "spans", + query: "tags[missing]:true", + fields: ["span.duration"], + sort: "-span.duration", + statsPeriod: "24h", + limit: 10, + includeExplanation: false, + }, + { + constraints: { + organizationSlug: null, + regionUrl: null, + projectSlug: null, + }, + accessToken: "test-token", + userId: "1", + }, + ), + ).rejects.toThrow(/Search validation failed after repair attempts/); + + expect(validateCalls).toBe(1 + MAX_EVENTS_VALIDATION_ATTEMPTS); + expect(mockGenerateText).toHaveBeenCalledTimes( + MAX_EVENTS_VALIDATION_ATTEMPTS, + ); + }); + + it("rejects search immediately when validation fails without an agent provider", async () => { + process.env.OPENAI_API_KEY = undefined; + + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/test-org/events/validate/", + () => + HttpResponse.json({ + valid: false, + projects: [], + dataset: [], + environment: [], + field: [ + { + name: "spon.duration", + valid: false, + error: "Unknown attribute", + }, + ], + query: { + valid: false, + error: "Invalid syntax", + fields: [ + { + name: "spon.duration", + valid: false, + error: "Unknown attribute", + }, + ], + }, + orderby: [], + }), + ), + http.get("https://sentry.io/api/0/organizations/test-org/events/", () => { + throw new Error("searchEvents should not be called"); + }), + ); + + await expect( + searchEvents.handler( + { + organizationSlug: "test-org", + regionUrl: null, + projectSlug: null, + dataset: "spans", + query: "spon.duration:>100", + fields: ["spon.duration"], + sort: "-spon.duration", + statsPeriod: "24h", + limit: 10, + includeExplanation: false, + }, + { + constraints: { + organizationSlug: null, + regionUrl: null, + projectSlug: null, + }, + accessToken: "test-token", + userId: "1", + }, + ), + ).rejects.toThrow(/Search validation failed:/); + + expect(mockGenerateText).not.toHaveBeenCalled(); + }); }); diff --git a/packages/mcp-core/src/tools/catalog/search-events.ts b/packages/mcp-core/src/tools/catalog/search-events.ts index 0979acf94..f88ead695 100644 --- a/packages/mcp-core/src/tools/catalog/search-events.ts +++ b/packages/mcp-core/src/tools/catalog/search-events.ts @@ -1,4 +1,4 @@ -import { getActiveSpan, setTag } from "@sentry/core"; +import { getActiveSpan, setTag, startSpan } from "@sentry/core"; import { z } from "zod"; import { UserInputError } from "../../errors"; import { hasAgentProvider } from "../../internal/agents/provider-factory"; @@ -16,7 +16,10 @@ import { isMetricsDataset, normalizeEventsDataset, } from "../../utils/events-datasets"; -import { searchEventsAgent } from "../support/search-events/agent"; +import { + searchEventsAgent, + type searchEventsAgentOutputSchema, +} from "../support/search-events/agent"; import { RECOMMENDED_FIELDS, TRACE_METRICS_SAMPLE_IDENTITY_FIELDS, @@ -35,13 +38,28 @@ import { isValidReplaySort, } from "../support/search-events/replays"; import { + formatEventsValidationResults, isAggregateQuery, looksLikeSentrySearchSyntax, + MAX_EVENTS_VALIDATION_ATTEMPTS, + recordEventsSearchValidationTelemetry, + validateEventsSearch, } from "../support/search-events/utils"; const SEARCH_EVENTS_DATASETS = [...PUBLIC_EVENTS_DATASETS, "replays"] as const; const DEFAULT_EVENTS_SORT = "-timestamp"; +type SearchEventsAgentResult = z.output; + +type EventsSearchState = { + dataset: PublicEventsDataset; + sentryQuery: string; + fields: string[]; + sortParam: string; + environment?: string | string[] | null; + timeParams: { statsPeriod?: string; start?: string; end?: string }; +}; + function defaultSortForDataset(dataset: PublicEventsDataset | "replays") { return dataset === "replays" ? DEFAULT_REPLAY_SORT : DEFAULT_EVENTS_SORT; } @@ -92,6 +110,45 @@ function parseAgentTimeRange( return undefined; } +function augmentFieldsWithSort(fields: string[], sort: string): string[] { + const sortField = sort.startsWith("-") ? sort.slice(1) : sort; + const sortIsAggregate = sortField.includes("(") && sortField.includes(")"); + if ( + sortField && + !fields.includes(sortField) && + (sortIsAggregate || !isAggregateQuery(fields)) + ) { + return [...fields, sortField]; + } + return fields; +} + +function buildRequestFields( + dataset: PublicEventsDataset | "replays", + fields: string[], +): string[] { + return dataset !== "replays" && + isMetricsDataset(dataset) && + !isAggregateQuery(fields) + ? Array.from(new Set([...fields, ...TRACE_METRICS_SAMPLE_IDENTITY_FIELDS])) + : fields; +} + +function applyValidationRepairFromAgent( + state: EventsSearchState, + parsed: SearchEventsAgentResult, +): EventsSearchState { + const sortParam = parsed.sort.trim(); + return { + dataset: parsed.dataset === "replays" ? state.dataset : parsed.dataset, + sentryQuery: parsed.query || state.sentryQuery, + fields: augmentFieldsWithSort(parsed.fields ?? state.fields, sortParam), + sortParam, + environment: parsed.environment ? parsed.environment : state.environment, + timeParams: parseAgentTimeRange(parsed.timeRange) ?? state.timeParams, + }; +} + function isTraceItemDataset(dataset: PublicEventsDataset | "replays"): boolean { return dataset === "spans" || dataset === "logs" || dataset === "metrics"; } @@ -227,8 +284,8 @@ function buildSearchRepairPrompt(params: { "Fix this Sentry event search request.", "The query may be natural language or already-valid Sentry search syntax.", "Preserve valid explicit parameters, but correct dataset, query syntax, fields, sort, and time range when they conflict or would fail.", - "If the user query already uses Sentry search syntax, treat its filters as authoritative unless validation proves a field is invalid.", - "For spans, logs, and metrics, use datasetAttributes exact attribute validation for explicit fields or query filters before dropping or renaming them.", + "If the user query already uses Sentry search syntax, treat its filters as authoritative unless the search validation step proves a field is invalid.", + "For spans, logs, and metrics, use datasetAttributes to discover likely fields with substringMatch, query, and attributeTypes before dropping or renaming explicit fields.", "A broad datasetAttributes result may be truncated, so absence from that preview does not prove an explicit field is invalid.", "For non-replay datasets, convert environment parameters into query filters. For replays, keep environment in the separate environment parameter.", "", @@ -248,6 +305,44 @@ function buildSearchRepairPrompt(params: { ].join("\n"); } +function buildValidationFailureRepairPrompt(params: { + originalQuery?: string; + dataset: PublicEventsDataset | "replays"; + query: string; + fields: string[]; + sort: string; + environment?: string | string[] | null; + timeParams: { statsPeriod?: string; start?: string; end?: string }; + validation: string; +}): string { + return [ + "Sentry events search validation failed. Repair the request so it passes validation.", + "Use the validation results to correct every invalid parameter: dataset, environment, query filters, selected fields, sort, and time range.", + "Use datasetAttributes to discover valid field names before renaming or replacing invalid fields.", + "Prefer renaming invalid fields to valid attributes instead of dropping them.", + "Ensure the sort field is included in the fields array.", + "For non-replay datasets, convert environment filters into query syntax when validation rejects the environment parameter.", + "For replays, keep environment in the separate environment parameter.", + "", + params.validation.trim(), + "", + `Original user query: ${params.originalQuery || "(empty)"}`, + "Current request:", + JSON.stringify( + { + dataset: params.dataset, + query: params.query, + fields: params.fields, + sort: params.sort, + environment: params.environment ?? null, + ...params.timeParams, + }, + null, + 2, + ), + ].join("\n"); +} + export default defineTool({ name: "search_events", skills: ["inspect", "triage", "seer"], // Available in inspect, triage, and seer skills @@ -567,29 +662,134 @@ export default defineTool({ // sortParam is also pre-normalization here. The API client normalizes // aggregate sort params to underscore form (e.g. "count_unique_user_id") // later, so an exact string match against fields is correct at this stage. - const sortField = sortParam.startsWith("-") - ? sortParam.slice(1) - : sortParam; - const sortIsAggregate = sortField.includes("(") && sortField.includes(")"); - if ( - sortField && - !fields.includes(sortField) && - (sortIsAggregate || !isAggregateQuery(fields)) - ) { - fields = [...fields, sortField]; + fields = augmentFieldsWithSort(fields, sortParam); + + const requestFields = buildRequestFields(dataset, fields); + + let validationExplanation: string | undefined; + let lastValidation = await validateEventsSearch(apiService, { + organizationSlug, + dataset, + fields: requestFields, + query: sentryQuery, + sort: sortParam, + projectId, + environment: environment ?? undefined, + ...timeParams, + }); + recordEventsSearchValidationTelemetry({ + attempt: 0, + validation: lastValidation, + }); + + if (!lastValidation.valid) { + if (!hasAgentProvider()) { + const formatted = formatEventsValidationResults(lastValidation); + throw new UserInputError( + formatted + ? `Search validation failed:\n${formatted}` + : "Search validation failed.", + ); + } + + for ( + let attempt = 0; + attempt < MAX_EVENTS_VALIDATION_ATTEMPTS && !lastValidation.valid; + attempt++ + ) { + await startSpan( + { + name: "search_events.validation_repair", + op: "gen_ai.embedded_agent", + attributes: { + "app.search_events.validation.repair_iteration": attempt + 1, + }, + }, + async () => { + const agentResult = await searchEventsAgent({ + query: buildValidationFailureRepairPrompt({ + originalQuery: params.query, + dataset, + query: sentryQuery, + fields, + sort: sortParam, + environment, + timeParams, + validation: formatEventsValidationResults(lastValidation), + }), + organizationSlug, + apiService, + projectId, + }); + + const parsed = agentResult.result; + if (!parsed.sort?.trim()) { + throw new UserInputError( + `Search validation repair failed: agent response missing required 'sort' parameter. Received: ${JSON.stringify(parsed, null, 2)}.`, + ); + } + + ({ + dataset, + sentryQuery, + fields, + sortParam, + environment, + timeParams, + } = applyValidationRepairFromAgent( + { + dataset: dataset as PublicEventsDataset, + sentryQuery, + fields, + sortParam, + environment, + timeParams, + }, + parsed, + )); + validationExplanation = parsed.explanation || validationExplanation; + + lastValidation = await validateEventsSearch(apiService, { + organizationSlug, + dataset, + fields: buildRequestFields(dataset, fields), + query: sentryQuery, + sort: sortParam, + projectId, + environment: environment ?? undefined, + ...timeParams, + }); + recordEventsSearchValidationTelemetry({ + attempt: attempt + 1, + repairIteration: attempt + 1, + validation: lastValidation, + }); + }, + ); + } + + if (!lastValidation.valid) { + const formatted = formatEventsValidationResults(lastValidation); + throw new UserInputError( + formatted + ? `Search validation failed after repair attempts:\n${formatted}` + : "Search validation failed after repair attempts.", + ); + } + + if (validationExplanation) { + explanation = explanation + ? `${explanation} ${validationExplanation}` + : validationExplanation; + } } - const requestFields = - isMetricsDataset(dataset) && !isAggregateQuery(fields) - ? Array.from( - new Set([...fields, ...TRACE_METRICS_SAMPLE_IDENTITY_FIELDS]), - ) - : fields; + const finalRequestFields = buildRequestFields(dataset, fields); const eventsResponse = await apiService.searchEvents({ organizationSlug, query: sentryQuery, - fields: requestFields, + fields: finalRequestFields, limit: params.limit, projectId, dataset, diff --git a/packages/mcp-core/src/tools/support/search-events/config.ts b/packages/mcp-core/src/tools/support/search-events/config.ts index 3575c3459..ca3432caa 100644 --- a/packages/mcp-core/src/tools/support/search-events/config.ts +++ b/packages/mcp-core/src/tools/support/search-events/config.ts @@ -28,7 +28,7 @@ Before constructing ANY query, you MUST verify field availability: 2. This includes ALL fields: custom attributes, database fields, HTTP fields, AI fields, user fields, etc. 3. Fields vary by project based on what data is being sent to Sentry 4. Using an unverified field WILL cause your query to fail with "field not found" errors -5. For spans, logs, and metrics, datasetAttributes can validate exact field names with attributes and can list likely fields using substringMatch/query/attributeTypes +5. For spans, logs, and metrics, datasetAttributes can list likely fields using substringMatch/query/attributeTypes 6. A broad datasetAttributes listing is a discovery preview and may be truncated; do not treat absence from the preview as proof that a user-supplied field is invalid 7. Replay fields vary by project too, so use replayFields before constructing replay queries @@ -38,7 +38,7 @@ TOOL USAGE GUIDELINES: 3. Use otelSemantics tool when you need specific OpenTelemetry semantic convention attributes 4. Use whoami tool when queries contain "me" references for user.id or user.email fields 5. IMPORTANT: For ambiguous terms like "user agents", "browser", "client" - use the appropriate field discovery tool instead of guessing field names -6. When the user already supplied Sentry search syntax for spans/logs/metrics, call datasetAttributes with exact attributes from the query or fields before dropping or renaming them +6. When the user already supplied Sentry search syntax for spans/logs/metrics, call datasetAttributes with substringMatch or query filters from the request before dropping or renaming fields 7. Use datasetAttributes substringMatch, query, and attributeTypes for targeted lookup when broad field discovery is truncated CRITICAL - TOOL RESPONSE HANDLING: diff --git a/packages/mcp-core/src/tools/support/search-events/utils.ts b/packages/mcp-core/src/tools/support/search-events/utils.ts index a334bc795..e09866ef8 100644 --- a/packages/mcp-core/src/tools/support/search-events/utils.ts +++ b/packages/mcp-core/src/tools/support/search-events/utils.ts @@ -1,4 +1,6 @@ import { z } from "zod"; +import { getActiveSpan } from "@sentry/core"; +import { UserInputError } from "../../../errors"; import type { SentryApiService, TraceItemAttributeType, @@ -443,6 +445,138 @@ ${entries `; } +const VALIDATION_OUTPUT_MAX_LENGTH = 1024; + +function truncateValidationOutput(output: string): string { + if (output.length <= VALIDATION_OUTPUT_MAX_LENGTH) { + return output; + } + return `${output.slice(0, VALIDATION_OUTPUT_MAX_LENGTH)}…`; +} + +export function recordEventsSearchValidationTelemetry({ + attempt, + repairIteration, + validation, +}: { + attempt: number; + repairIteration?: number; + validation: EventsValidationResult; +}): void { + const span = getActiveSpan(); + if (!span) { + return; + } + + span.setAttribute("app.search_events.validation.attempt", attempt); + span.setAttribute("app.search_events.validation.valid", validation.valid); + if (repairIteration !== undefined) { + span.setAttribute( + "app.search_events.validation.repair_iteration", + repairIteration, + ); + } + + const formatted = formatEventsValidationResults(validation); + if (formatted) { + span.setAttribute( + "app.search_events.validation.output", + truncateValidationOutput(formatted), + ); + } +} + +export const MAX_EVENTS_VALIDATION_ATTEMPTS = 3; + +export async function validateEventsSearch( + apiService: SentryApiService, + { + organizationSlug, + dataset, + fields, + query, + sort, + projectId, + environment, + statsPeriod, + start, + end, + }: { + organizationSlug: string; + dataset: EventsDataset; + fields: string[]; + query: string; + sort: string; + projectId?: string; + environment?: string | string[]; + statsPeriod?: string; + start?: string; + end?: string; + }, +): Promise { + return apiService.validateEvents({ + organizationSlug, + dataset, + fields, + query, + orderby: [sort], + project: projectId, + environment, + statsPeriod, + start, + end, + }); +} + +export async function assertEventsSearchIsValid( + apiService: SentryApiService, + { + organizationSlug, + dataset, + fields, + query, + sort, + projectId, + environment, + statsPeriod, + start, + end, + }: { + organizationSlug: string; + dataset: EventsDataset; + fields: string[]; + query: string; + sort: string; + projectId?: string; + environment?: string | string[]; + statsPeriod?: string; + start?: string; + end?: string; + }, +): Promise { + const validationResults = await validateEventsSearch(apiService, { + organizationSlug, + dataset, + fields, + query, + sort, + projectId, + environment, + statsPeriod, + start, + end, + }); + + if (!validationResults.valid) { + const formatted = formatEventsValidationResults(validationResults); + throw new UserInputError( + formatted + ? `Search validation failed:\n${formatted}` + : "Search validation failed.", + ); + } +} + /** * Create a tool for the agent to query available attributes by dataset * The tool is pre-bound with the API service and organization configured for the appropriate region @@ -457,7 +591,7 @@ export function createDatasetAttributesTool(options: { return agentTool({ description: - "Query, filter, and validate available attributes and fields for a specific Sentry dataset to understand what data is available", + "Query and filter available attributes and fields for a specific Sentry dataset to understand what data is available", parameters: z.object({ dataset: z .enum(PUBLIC_EVENTS_DATASETS) @@ -483,21 +617,8 @@ export function createDatasetAttributesTool(options: { .describe( "Optional attribute types to list. Use ['string','number','boolean'] when unsure.", ), - attributes: z - .array(z.string().trim().min(1)) - .min(1) - .optional() - .describe( - "Optional exact attribute keys to validate, such as ['tags[type]', 'span.duration']", - ), }), - execute: async ({ - dataset, - substringMatch, - query, - attributeTypes, - attributes, - }) => { + execute: async ({ dataset, substringMatch, query, attributeTypes }) => { const { BASE_COMMON_FIELDS, DATASET_FIELDS, @@ -513,16 +634,6 @@ export function createDatasetAttributesTool(options: { const normalizedDataset = normalizeEventsDataset(dataset); const traceItemType = getTraceItemType(normalizedDataset); const attributeTimeParams = { statsPeriod: "14d" }; - const validationResults = - traceItemType && attributes?.length - ? await apiService.validateTraceItemAttributes({ - organizationSlug, - itemType: traceItemType, - attributes, - project: projectId, - ...attributeTimeParams, - }) - : {}; const { attributes: customAttributes, fieldTypes } = await fetchCustomAttributes( apiService, @@ -560,7 +671,6 @@ export function createDatasetAttributesTool(options: { recordAgentToolResultCount(fieldCount); return `Dataset: ${dataset} -${formatValidationResults(validationResults)} Available Fields (${fieldCount} total): ${Object.entries(allFields) @@ -574,7 +684,7 @@ ${recommendedFields.basic.map((f) => `- ${f}`).join("\n")} Field Types (CRITICAL for aggregate functions): ${Object.entries(allFieldTypes) - .slice(0, 30) // Show more field types since this is critical for validation + .slice(0, 30) // Show more field types since this is critical for aggregate functions .map(([key, type]) => `- ${key}: ${type}`) .join("\n")} ${Object.keys(allFieldTypes).length > 30 ? `\n... and ${Object.keys(allFieldTypes).length - 30} more fields` : ""} From 074fea19748d03130fd00b66227f66fd02297577 Mon Sep 17 00:00:00 2001 From: William Mak Date: Fri, 19 Jun 2026 16:22:54 -0400 Subject: [PATCH 2/2] fix(search-events): Address validation repair review feedback Preserve prior fields when repair returns an empty array, merge repaired environment into the executed search query, and clear agent API keys with empty strings so the no-agent rejection path is exercised in tests. Refs GH-1113 Co-Authored-By: Composer Co-authored-by: Cursor --- .../src/tools/catalog/search-events.test.ts | 169 +++++++++++++++++- .../src/tools/catalog/search-events.ts | 30 +++- 2 files changed, 197 insertions(+), 2 deletions(-) diff --git a/packages/mcp-core/src/tools/catalog/search-events.test.ts b/packages/mcp-core/src/tools/catalog/search-events.test.ts index 5247b1c1d..c0b1dcf3d 100644 --- a/packages/mcp-core/src/tools/catalog/search-events.test.ts +++ b/packages/mcp-core/src/tools/catalog/search-events.test.ts @@ -2529,6 +2529,172 @@ describe("search_events", () => { expect(result).toContain("span1"); }); + it("keeps prior fields when validation repair returns an empty fields array", async () => { + let validateCalls = 0; + let eventsRequestUrl: URL | undefined; + + mockGenerateText.mockResolvedValueOnce( + mockAIResponse( + "spans", + "span.duration:>100", + [], + undefined, + "-span.duration", + ), + ); + + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/test-org/events/validate/", + () => { + validateCalls += 1; + if (validateCalls === 1) { + return HttpResponse.json({ + valid: false, + projects: [], + dataset: [], + environment: [], + field: [ + { + name: "spon.duration", + valid: false, + error: "Unknown attribute", + }, + ], + query: { + valid: false, + error: "Invalid syntax", + fields: [], + }, + orderby: [], + }); + } + + return HttpResponse.json(validEventsValidationResponse); + }, + ), + http.get( + "https://sentry.io/api/0/organizations/test-org/events/", + ({ request }) => { + eventsRequestUrl = new URL(request.url); + return HttpResponse.json({ + data: [{ id: "span1", "span.duration": 42 }], + }); + }, + ), + ); + + await searchEvents.handler( + { + organizationSlug: "test-org", + regionUrl: null, + projectSlug: null, + dataset: "spans", + query: "spon.duration:>100", + fields: ["span.duration"], + sort: "-span.duration", + statsPeriod: "24h", + limit: 10, + includeExplanation: false, + }, + { + constraints: { + organizationSlug: null, + regionUrl: null, + projectSlug: null, + }, + accessToken: "test-token", + userId: "1", + }, + ); + + expect(eventsRequestUrl).toBeDefined(); + expect(eventsRequestUrl!.searchParams.getAll("field")).toEqual([ + "span.duration", + ]); + }); + + it("merges repaired environment into the events search query for non-replay datasets", async () => { + let validateCalls = 0; + let eventsRequestUrl: URL | undefined; + + mockGenerateText.mockResolvedValueOnce( + mockAIResponse( + "spans", + "span.duration:>100", + ["span.duration"], + undefined, + "-span.duration", + undefined, + "production", + ), + ); + + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/test-org/events/validate/", + () => { + validateCalls += 1; + if (validateCalls === 1) { + return HttpResponse.json({ + valid: false, + projects: [], + dataset: [], + environment: [], + field: [], + query: { + valid: false, + error: "Invalid syntax", + fields: [], + }, + orderby: [], + }); + } + + return HttpResponse.json(validEventsValidationResponse); + }, + ), + http.get( + "https://sentry.io/api/0/organizations/test-org/events/", + ({ request }) => { + eventsRequestUrl = new URL(request.url); + return HttpResponse.json({ + data: [{ id: "span1", "span.duration": 42 }], + }); + }, + ), + ); + + await searchEvents.handler( + { + organizationSlug: "test-org", + regionUrl: null, + projectSlug: null, + dataset: "spans", + query: "spon.duration:>100", + fields: ["span.duration"], + sort: "-span.duration", + statsPeriod: "24h", + limit: 10, + includeExplanation: false, + }, + { + constraints: { + organizationSlug: null, + regionUrl: null, + projectSlug: null, + }, + accessToken: "test-token", + userId: "1", + }, + ); + + expect(eventsRequestUrl).toBeDefined(); + expect(eventsRequestUrl!.searchParams.get("query")).toContain( + "environment:production", + ); + }); + it("rejects search after MAX_EVENTS_VALIDATION_ATTEMPTS without calling events", async () => { let validateCalls = 0; @@ -2601,7 +2767,8 @@ describe("search_events", () => { }); it("rejects search immediately when validation fails without an agent provider", async () => { - process.env.OPENAI_API_KEY = undefined; + process.env.OPENAI_API_KEY = ""; + process.env.ANTHROPIC_API_KEY = ""; mswServer.use( http.get( diff --git a/packages/mcp-core/src/tools/catalog/search-events.ts b/packages/mcp-core/src/tools/catalog/search-events.ts index f88ead695..e6e2f8559 100644 --- a/packages/mcp-core/src/tools/catalog/search-events.ts +++ b/packages/mcp-core/src/tools/catalog/search-events.ts @@ -139,10 +139,12 @@ function applyValidationRepairFromAgent( parsed: SearchEventsAgentResult, ): EventsSearchState { const sortParam = parsed.sort.trim(); + const repairedFields = + parsed.fields && parsed.fields.length > 0 ? parsed.fields : state.fields; return { dataset: parsed.dataset === "replays" ? state.dataset : parsed.dataset, sentryQuery: parsed.query || state.sentryQuery, - fields: augmentFieldsWithSort(parsed.fields ?? state.fields, sortParam), + fields: augmentFieldsWithSort(repairedFields, sortParam), sortParam, environment: parsed.environment ? parsed.environment : state.environment, timeParams: parseAgentTimeRange(parsed.timeRange) ?? state.timeParams, @@ -192,6 +194,17 @@ function appendSearchFilter(query: string, filter?: string): string { return [trimmedQuery, filter].filter(Boolean).join(" "); } +function applyEnvironmentToEventsQuery( + dataset: PublicEventsDataset | "replays", + query: string, + environment?: string | string[] | null, +): string { + if (dataset === "replays") { + return query; + } + return appendSearchFilter(query, formatEnvironmentFilter(environment)); +} + function tokenizeSearchQuery(query: string): string[] { const tokens: string[] = []; let currentToken = ""; @@ -667,6 +680,11 @@ export default defineTool({ const requestFields = buildRequestFields(dataset, fields); let validationExplanation: string | undefined; + sentryQuery = applyEnvironmentToEventsQuery( + dataset, + sentryQuery, + environment, + ); let lastValidation = await validateEventsSearch(apiService, { organizationSlug, dataset, @@ -748,6 +766,11 @@ export default defineTool({ parsed, )); validationExplanation = parsed.explanation || validationExplanation; + sentryQuery = applyEnvironmentToEventsQuery( + dataset, + sentryQuery, + environment, + ); lastValidation = await validateEventsSearch(apiService, { organizationSlug, @@ -785,6 +808,11 @@ export default defineTool({ } const finalRequestFields = buildRequestFields(dataset, fields); + sentryQuery = applyEnvironmentToEventsQuery( + dataset, + sentryQuery, + environment, + ); const eventsResponse = await apiService.searchEvents({ organizationSlug,