diff --git a/docs/operations/embedded-agents.md b/docs/operations/embedded-agents.md index 8ec828206..603825b95 100644 --- a/docs/operations/embedded-agents.md +++ b/docs/operations/embedded-agents.md @@ -12,6 +12,8 @@ Sentry MCP uses embedded AI agents for the following tools: These tools require an LLM provider (OpenAI, Azure OpenAI, Anthropic, or OpenRouter) to be configured. +`search_errors`, `search_logs`, `search_traces`, `search_metrics`, and `search_issues` first try Seer's search agent when the organization has the `mcp-search-events-seer-translate` rollout flag and has not hidden AI features. `search_issues` also needs `gen-ai-issues-search`, which Sentry requires for the `Issues` strategy. When Seer is unavailable or cannot translate the query, they fall back to the embedded agent. + ## Provider Selection ### Explicit Configuration (Recommended) diff --git a/packages/mcp-core/src/skillDefinitions.json b/packages/mcp-core/src/skillDefinitions.json index 5871078d2..bf5f5e664 100644 --- a/packages/mcp-core/src/skillDefinitions.json +++ b/packages/mcp-core/src/skillDefinitions.json @@ -209,7 +209,7 @@ }, { "name": "search_issues", - "description": "Search for grouped issues/problems in Sentry - returns a LIST of issues, NOT counts or aggregations.\n\nProvide `query` as natural language or Sentry issue search syntax. When an embedded agent is configured, it fixes query and sort before running while preserving explicit Sentry search syntax.\n\nReturns grouped issues with metadata like title, status, and user count.\n\nCommon Query Syntax:\n- is:unresolved / is:resolved / is:ignored / is:for_review / is:new / is:regressed / is:escalating\n- level:error / level:warning\n- firstSeen:-24h / lastSeen:-7d\n- assigned:me / assigned_or_suggested:me\n- release:latest\n- issue.category:feedback\n- issue.priority:high\n- environment:production\n- userCount:>100\n\nNo AND/OR or parentheses: spaces mean AND. For OR on one field use field:[a,b]; for several statuses use status:[resolved,ignored] or substatus:[regressed,escalating].\n\nDO NOT USE FOR COUNTS/AGGREGATIONS → use search_errors\nDO NOT USE FOR individual events with timestamps → use search_errors, search_logs, or search_traces\nDO NOT USE FOR details about a specific issue → use get_sentry_resource\n\n\nsearch_issues(organizationSlug='my-org', query='critical bugs from last week')\nsearch_issues(organizationSlug='my-org', query='is:unresolved is:unassigned', sort='freq')\nsearch_issues(organizationSlug='my-org', query='level:error firstSeen:-24h', projectSlugOrId='my-project')\n\n\n\n- If the user passes a parameter in the form of name/otherName, it's likely in the format of /.\n- Parse org/project notation directly without calling find_organizations or find_projects.\n- The projectSlugOrId parameter accepts both project slugs (e.g., 'my-project') and numeric IDs (e.g., '123456').\n", + "description": "Search for grouped issues/problems in Sentry - returns a LIST of issues, NOT counts or aggregations.\n\nProvide `query` as natural language or Sentry issue search syntax. Seer translates natural language when the organization has AI issue search; otherwise a configured embedded agent fixes query and sort while preserving explicit Sentry search syntax.\n\nReturns grouped issues with metadata like title, status, and user count.\n\nCommon Query Syntax:\n- is:unresolved / is:resolved / is:ignored / is:for_review / is:new / is:regressed / is:escalating\n- level:error / level:warning\n- firstSeen:-24h / lastSeen:-7d\n- assigned:me / assigned_or_suggested:me\n- release:latest\n- issue.category:feedback\n- issue.priority:high\n- environment:production\n- userCount:>100\n\nNo AND/OR or parentheses: spaces mean AND. For OR on one field use field:[a,b]; for several statuses use status:[resolved,ignored] or substatus:[regressed,escalating].\n\nDO NOT USE FOR COUNTS/AGGREGATIONS → use search_errors\nDO NOT USE FOR individual events with timestamps → use search_errors, search_logs, or search_traces\nDO NOT USE FOR details about a specific issue → use get_sentry_resource\n\n\nsearch_issues(organizationSlug='my-org', query='critical bugs from last week')\nsearch_issues(organizationSlug='my-org', query='is:unresolved is:unassigned', sort='freq')\nsearch_issues(organizationSlug='my-org', query='level:error firstSeen:-24h', projectSlugOrId='my-project')\n\n\n\n- If the user passes a parameter in the form of name/otherName, it's likely in the format of /.\n- Parse org/project notation directly without calling find_organizations or find_projects.\n- The projectSlugOrId parameter accepts both project slugs (e.g., 'my-project') and numeric IDs (e.g., '123456').\n", "requiredScopes": ["event:read"] }, { @@ -309,7 +309,7 @@ }, { "name": "search_issues", - "description": "Search for grouped issues/problems in Sentry - returns a LIST of issues, NOT counts or aggregations.\n\nProvide `query` as natural language or Sentry issue search syntax. When an embedded agent is configured, it fixes query and sort before running while preserving explicit Sentry search syntax.\n\nReturns grouped issues with metadata like title, status, and user count.\n\nCommon Query Syntax:\n- is:unresolved / is:resolved / is:ignored / is:for_review / is:new / is:regressed / is:escalating\n- level:error / level:warning\n- firstSeen:-24h / lastSeen:-7d\n- assigned:me / assigned_or_suggested:me\n- release:latest\n- issue.category:feedback\n- issue.priority:high\n- environment:production\n- userCount:>100\n\nNo AND/OR or parentheses: spaces mean AND. For OR on one field use field:[a,b]; for several statuses use status:[resolved,ignored] or substatus:[regressed,escalating].\n\nDO NOT USE FOR COUNTS/AGGREGATIONS → use search_errors\nDO NOT USE FOR individual events with timestamps → use search_errors, search_logs, or search_traces\nDO NOT USE FOR details about a specific issue → use get_sentry_resource\n\n\nsearch_issues(organizationSlug='my-org', query='critical bugs from last week')\nsearch_issues(organizationSlug='my-org', query='is:unresolved is:unassigned', sort='freq')\nsearch_issues(organizationSlug='my-org', query='level:error firstSeen:-24h', projectSlugOrId='my-project')\n\n\n\n- If the user passes a parameter in the form of name/otherName, it's likely in the format of /.\n- Parse org/project notation directly without calling find_organizations or find_projects.\n- The projectSlugOrId parameter accepts both project slugs (e.g., 'my-project') and numeric IDs (e.g., '123456').\n", + "description": "Search for grouped issues/problems in Sentry - returns a LIST of issues, NOT counts or aggregations.\n\nProvide `query` as natural language or Sentry issue search syntax. Seer translates natural language when the organization has AI issue search; otherwise a configured embedded agent fixes query and sort while preserving explicit Sentry search syntax.\n\nReturns grouped issues with metadata like title, status, and user count.\n\nCommon Query Syntax:\n- is:unresolved / is:resolved / is:ignored / is:for_review / is:new / is:regressed / is:escalating\n- level:error / level:warning\n- firstSeen:-24h / lastSeen:-7d\n- assigned:me / assigned_or_suggested:me\n- release:latest\n- issue.category:feedback\n- issue.priority:high\n- environment:production\n- userCount:>100\n\nNo AND/OR or parentheses: spaces mean AND. For OR on one field use field:[a,b]; for several statuses use status:[resolved,ignored] or substatus:[regressed,escalating].\n\nDO NOT USE FOR COUNTS/AGGREGATIONS → use search_errors\nDO NOT USE FOR individual events with timestamps → use search_errors, search_logs, or search_traces\nDO NOT USE FOR details about a specific issue → use get_sentry_resource\n\n\nsearch_issues(organizationSlug='my-org', query='critical bugs from last week')\nsearch_issues(organizationSlug='my-org', query='is:unresolved is:unassigned', sort='freq')\nsearch_issues(organizationSlug='my-org', query='level:error firstSeen:-24h', projectSlugOrId='my-project')\n\n\n\n- If the user passes a parameter in the form of name/otherName, it's likely in the format of /.\n- Parse org/project notation directly without calling find_organizations or find_projects.\n- The projectSlugOrId parameter accepts both project slugs (e.g., 'my-project') and numeric IDs (e.g., '123456').\n", "requiredScopes": ["event:read"] }, { @@ -485,7 +485,7 @@ }, { "name": "search_issues", - "description": "Search for grouped issues/problems in Sentry - returns a LIST of issues, NOT counts or aggregations.\n\nProvide `query` as natural language or Sentry issue search syntax. When an embedded agent is configured, it fixes query and sort before running while preserving explicit Sentry search syntax.\n\nReturns grouped issues with metadata like title, status, and user count.\n\nCommon Query Syntax:\n- is:unresolved / is:resolved / is:ignored / is:for_review / is:new / is:regressed / is:escalating\n- level:error / level:warning\n- firstSeen:-24h / lastSeen:-7d\n- assigned:me / assigned_or_suggested:me\n- release:latest\n- issue.category:feedback\n- issue.priority:high\n- environment:production\n- userCount:>100\n\nNo AND/OR or parentheses: spaces mean AND. For OR on one field use field:[a,b]; for several statuses use status:[resolved,ignored] or substatus:[regressed,escalating].\n\nDO NOT USE FOR COUNTS/AGGREGATIONS → use search_errors\nDO NOT USE FOR individual events with timestamps → use search_errors, search_logs, or search_traces\nDO NOT USE FOR details about a specific issue → use get_sentry_resource\n\n\nsearch_issues(organizationSlug='my-org', query='critical bugs from last week')\nsearch_issues(organizationSlug='my-org', query='is:unresolved is:unassigned', sort='freq')\nsearch_issues(organizationSlug='my-org', query='level:error firstSeen:-24h', projectSlugOrId='my-project')\n\n\n\n- If the user passes a parameter in the form of name/otherName, it's likely in the format of /.\n- Parse org/project notation directly without calling find_organizations or find_projects.\n- The projectSlugOrId parameter accepts both project slugs (e.g., 'my-project') and numeric IDs (e.g., '123456').\n", + "description": "Search for grouped issues/problems in Sentry - returns a LIST of issues, NOT counts or aggregations.\n\nProvide `query` as natural language or Sentry issue search syntax. Seer translates natural language when the organization has AI issue search; otherwise a configured embedded agent fixes query and sort while preserving explicit Sentry search syntax.\n\nReturns grouped issues with metadata like title, status, and user count.\n\nCommon Query Syntax:\n- is:unresolved / is:resolved / is:ignored / is:for_review / is:new / is:regressed / is:escalating\n- level:error / level:warning\n- firstSeen:-24h / lastSeen:-7d\n- assigned:me / assigned_or_suggested:me\n- release:latest\n- issue.category:feedback\n- issue.priority:high\n- environment:production\n- userCount:>100\n\nNo AND/OR or parentheses: spaces mean AND. For OR on one field use field:[a,b]; for several statuses use status:[resolved,ignored] or substatus:[regressed,escalating].\n\nDO NOT USE FOR COUNTS/AGGREGATIONS → use search_errors\nDO NOT USE FOR individual events with timestamps → use search_errors, search_logs, or search_traces\nDO NOT USE FOR details about a specific issue → use get_sentry_resource\n\n\nsearch_issues(organizationSlug='my-org', query='critical bugs from last week')\nsearch_issues(organizationSlug='my-org', query='is:unresolved is:unassigned', sort='freq')\nsearch_issues(organizationSlug='my-org', query='level:error firstSeen:-24h', projectSlugOrId='my-project')\n\n\n\n- If the user passes a parameter in the form of name/otherName, it's likely in the format of /.\n- Parse org/project notation directly without calling find_organizations or find_projects.\n- The projectSlugOrId parameter accepts both project slugs (e.g., 'my-project') and numeric IDs (e.g., '123456').\n", "requiredScopes": ["event:read"] }, { diff --git a/packages/mcp-core/src/toolDefinitions.json b/packages/mcp-core/src/toolDefinitions.json index 4d81b7f8c..399398b30 100644 --- a/packages/mcp-core/src/toolDefinitions.json +++ b/packages/mcp-core/src/toolDefinitions.json @@ -11808,7 +11808,7 @@ { "name": "search_issues", "title": "Search Issues", - "description": "Search for grouped issues/problems in Sentry - returns a LIST of issues, NOT counts or aggregations.\n\nProvide `query` as natural language or Sentry issue search syntax. When an embedded agent is configured, it fixes query and sort before running while preserving explicit Sentry search syntax.\n\nReturns grouped issues with metadata like title, status, and user count.\n\nCommon Query Syntax:\n- is:unresolved / is:resolved / is:ignored / is:for_review / is:new / is:regressed / is:escalating\n- level:error / level:warning\n- firstSeen:-24h / lastSeen:-7d\n- assigned:me / assigned_or_suggested:me\n- release:latest\n- issue.category:feedback\n- issue.priority:high\n- environment:production\n- userCount:>100\n\nNo AND/OR or parentheses: spaces mean AND. For OR on one field use field:[a,b]; for several statuses use status:[resolved,ignored] or substatus:[regressed,escalating].\n\nDO NOT USE FOR COUNTS/AGGREGATIONS → use search_errors\nDO NOT USE FOR individual events with timestamps → use search_errors, search_logs, or search_traces\nDO NOT USE FOR details about a specific issue → use get_sentry_resource\n\n\nsearch_issues(organizationSlug='my-org', query='critical bugs from last week')\nsearch_issues(organizationSlug='my-org', query='is:unresolved is:unassigned', sort='freq')\nsearch_issues(organizationSlug='my-org', query='level:error firstSeen:-24h', projectSlugOrId='my-project')\n\n\n\n- If the user passes a parameter in the form of name/otherName, it's likely in the format of /.\n- Parse org/project notation directly without calling find_organizations or find_projects.\n- The projectSlugOrId parameter accepts both project slugs (e.g., 'my-project') and numeric IDs (e.g., '123456').\n", + "description": "Search for grouped issues/problems in Sentry - returns a LIST of issues, NOT counts or aggregations.\n\nProvide `query` as natural language or Sentry issue search syntax. Seer translates natural language when the organization has AI issue search; otherwise a configured embedded agent fixes query and sort while preserving explicit Sentry search syntax.\n\nReturns grouped issues with metadata like title, status, and user count.\n\nCommon Query Syntax:\n- is:unresolved / is:resolved / is:ignored / is:for_review / is:new / is:regressed / is:escalating\n- level:error / level:warning\n- firstSeen:-24h / lastSeen:-7d\n- assigned:me / assigned_or_suggested:me\n- release:latest\n- issue.category:feedback\n- issue.priority:high\n- environment:production\n- userCount:>100\n\nNo AND/OR or parentheses: spaces mean AND. For OR on one field use field:[a,b]; for several statuses use status:[resolved,ignored] or substatus:[regressed,escalating].\n\nDO NOT USE FOR COUNTS/AGGREGATIONS → use search_errors\nDO NOT USE FOR individual events with timestamps → use search_errors, search_logs, or search_traces\nDO NOT USE FOR details about a specific issue → use get_sentry_resource\n\n\nsearch_issues(organizationSlug='my-org', query='critical bugs from last week')\nsearch_issues(organizationSlug='my-org', query='is:unresolved is:unassigned', sort='freq')\nsearch_issues(organizationSlug='my-org', query='level:error firstSeen:-24h', projectSlugOrId='my-project')\n\n\n\n- If the user passes a parameter in the form of name/otherName, it's likely in the format of /.\n- Parse org/project notation directly without calling find_organizations or find_projects.\n- The projectSlugOrId parameter accepts both project slugs (e.g., 'my-project') and numeric IDs (e.g., '123456').\n", "inputSchema": { "type": "object", "properties": { diff --git a/packages/mcp-core/src/tools/catalog/search-issues.test.ts b/packages/mcp-core/src/tools/catalog/search-issues.test.ts index 835ec60d3..cbfd641f2 100644 --- a/packages/mcp-core/src/tools/catalog/search-issues.test.ts +++ b/packages/mcp-core/src/tools/catalog/search-issues.test.ts @@ -33,6 +33,31 @@ vi.mock("ai", async (importOriginal) => { }; }); +const SEER_FEATURES = [ + "mcp-search-events-seer-translate", + "gen-ai-issues-search", +]; + +// Sentry only serializes features when explicitly requested. +const mockOrganization = ( + features: string[], + { hideAiFeatures = false } = {}, +) => + http.get( + "https://sentry.io/api/0/organizations/:org/", + ({ request, params }) => + HttpResponse.json({ + id: "1", + slug: params.org, + name: "Test Org", + ...(new URL(request.url).searchParams.get("include_feature_flags") === + "1" + ? { features } + : {}), + hideAiFeatures, + }), + ); + const EMPTY_NOTE = "No issues found matching your search criteria. Try adjusting your search criteria or time range."; @@ -92,6 +117,8 @@ describe("search_issues", () => { process.env.OPENAI_API_KEY = "test-key"; process.env.OPENROUTER_API_KEY = ""; mockGenerateText.mockResolvedValue(mockAIResponse()); + // Seer translation stays off unless a test enables it. + mswServer.use(mockOrganization([])); }); it.each([null, "https://tenant.my.sentry.io"])( @@ -1025,4 +1052,176 @@ describe("search_issues", () => { ), ).rejects.toThrow(); }); + describe("with Seer", () => { + const seerParams = { + organizationSlug: "test-org", + query: "my unresolved issues from the last day", + sort: "date" as const, + projectSlugOrId: "123", + regionUrl: null, + limit: 10, + period: "30d" as const, + includeExplanation: true, + }; + const seerQuery = { + query: "is:unresolved assigned_or_suggested:me", + sort: "freq", + group_by: [], + visualization: [], + mode: "", + stats_period: "24h", + start: null, + end: null, + }; + const mockSeerStart = vi.fn(async ({ request }: { request: Request }) => { + expect(await request.json()).toEqual({ + project_ids: [123], + natural_language_query: seerParams.query, + strategy: "Issues", + }); + return HttpResponse.json({ run_id: 1, sentry_run_id: "run-uuid" }); + }); + const mockSeerState = (response: Record) => + http.get( + "https://sentry.io/api/0/organizations/test-org/search-agent/state/run-uuid/", + () => + HttpResponse.json({ + session: { status: "completed", final_response: response }, + sentry_run_id: "run-uuid", + }), + ); + const issueRequests: URL[] = []; + + beforeEach(() => { + mockSeerStart.mockClear(); + issueRequests.length = 0; + mswServer.use( + http.post( + "https://sentry.io/api/0/organizations/test-org/search-agent/start/", + mockSeerStart, + ), + http.get( + "https://sentry.io/api/0/organizations/*/issues/", + ({ request }) => { + issueRequests.push(new URL(request.url)); + return HttpResponse.json([]); + }, + ), + ); + }); + + it("translates natural language with Seer's Issues strategy", async () => { + mswServer.use( + mockOrganization(SEER_FEATURES), + mockSeerState({ responses: [seerQuery], unsupported_reason: null }), + ); + + const result = await runSearch(seerParams, mockContext); + + expect(mockSeerStart).toHaveBeenCalledTimes(1); + expect(mockGenerateText).not.toHaveBeenCalled(); + expect( + issueRequests.map((url) => Object.fromEntries(url.searchParams)), + ).toEqual([ + expect.objectContaining({ + query: "is:unresolved assigned_or_suggested:me", + sort: "freq", + statsPeriod: "24h", + project: "123", + }), + ]); + expect(result.explanation).toBe("Translated by Seer's search agent."); + expect(result.executedSearch).toMatchObject({ + sort: "freq", + period: "24h", + }); + }); + + it("keeps the requested sort and period when Seer returns neither", async () => { + mswServer.use( + mockOrganization(SEER_FEATURES), + mockSeerState({ + responses: [{ ...seerQuery, sort: "trends", stats_period: "" }], + }), + ); + + await runSearch(seerParams, mockContext); + + expect(issueRequests[0]?.searchParams.get("sort")).toBe("date"); + expect(issueRequests[0]?.searchParams.get("statsPeriod")).toBe("30d"); + }); + + it("falls back to the agent when Seer returns an absolute time range", async () => { + mockGenerateText.mockResolvedValue( + mockAIResponse("is:unresolved", "date"), + ); + mswServer.use( + mockOrganization(SEER_FEATURES), + mockSeerState({ + responses: [ + { + ...seerQuery, + stats_period: "", + start: "2026-01-01T00:00:00", + end: "2026-01-02T00:00:00", + }, + ], + }), + ); + + await runSearch(seerParams, mockContext); + + expect(mockSeerStart).toHaveBeenCalled(); + expect(mockGenerateText).toHaveBeenCalled(); + expect(issueRequests[0]?.searchParams.get("query")).toBe("is:unresolved"); + }); + + it.each([ + ["the rollout flag is off", ["gen-ai-issues-search"], {}], + ["issues search is off", ["mcp-search-events-seer-translate"], {}], + ["AI features are hidden", SEER_FEATURES, { hideAiFeatures: true }], + ])("falls back to the agent when %s", async (_name, features, options) => { + mockGenerateText.mockResolvedValue( + mockAIResponse("is:unresolved", "date"), + ); + mswServer.use(mockOrganization(features, options)); + + await runSearch(seerParams, mockContext); + + expect(mockSeerStart).not.toHaveBeenCalled(); + expect(mockGenerateText).toHaveBeenCalled(); + expect(issueRequests[0]?.searchParams.get("query")).toBe("is:unresolved"); + }); + + it("falls back to the agent when Seer returns 403", async () => { + mockGenerateText.mockResolvedValue( + mockAIResponse("is:unresolved", "date"), + ); + mswServer.use( + mockOrganization(SEER_FEATURES), + http.get( + "https://sentry.io/api/0/organizations/test-org/search-agent/state/run-uuid/", + () => HttpResponse.json({ detail: "Forbidden" }, { status: 403 }), + ), + ); + + await runSearch(seerParams, mockContext); + + expect(mockSeerStart).toHaveBeenCalled(); + expect(mockGenerateText).toHaveBeenCalled(); + expect(issueRequests[0]?.searchParams.get("query")).toBe("is:unresolved"); + }); + + it.each(["is:unresolved", "firstSeen:-24h", "userCount:>100"])( + "skips Seer for Sentry search syntax %s", + async (query) => { + mockGenerateText.mockResolvedValue(mockAIResponse(query, "date")); + mswServer.use(mockOrganization(SEER_FEATURES)); + + await runSearch({ ...seerParams, query }, mockContext); + + expect(mockSeerStart).not.toHaveBeenCalled(); + }, + ); + }); }); diff --git a/packages/mcp-core/src/tools/catalog/search-issues.ts b/packages/mcp-core/src/tools/catalog/search-issues.ts index 7068fcced..8364cc994 100644 --- a/packages/mcp-core/src/tools/catalog/search-issues.ts +++ b/packages/mcp-core/src/tools/catalog/search-issues.ts @@ -5,7 +5,10 @@ import { SearchQueryError, } from "@sentry/toolkit-core/search-query"; import { z } from "zod"; -import { SEARCH_ISSUES_PERIOD_VALUES } from "../../constants"; +import { + SEARCH_ISSUES_PERIOD_VALUES, + type SearchIssuesPeriodValue, +} from "../../constants"; import { UserInputError } from "../../errors"; import { hasAgentProvider } from "../../internal/agents/provider-factory"; import { withProviderFallback } from "../../internal/agents/provider-fallback"; @@ -15,16 +18,29 @@ import { ParamOrganizationSlug, ParamRegionUrl } from "../../schema"; import { setTargetTagsAndAttributes } from "../../telem/scope"; import type { ServerContext } from "../../types"; import { isNumericId, validateSlugOrId } from "../../utils/slug-validation"; -import { formatUntranslatedQueryNote } from "../support/search-events/utils"; +import { translateIssuesWithSeer } from "../support/search-events/seer"; +import { + formatUntranslatedQueryNote, + looksLikeSentrySearchSyntax, +} from "../support/search-events/utils"; import { searchIssuesAgent } from "../support/search-issues/agent"; import { buildIssueResults, + ISSUE_SORTS, type IssueSort, searchIssuesOutputSchema, } from "../support/search-issues/results"; const ProjectSlugOrIdSchema = z.string().trim().superRefine(validateSlugOrId); +function isIssueSort(value: string): value is IssueSort { + return (ISSUE_SORTS as readonly string[]).includes(value); +} + +function isSearchIssuesPeriod(value: string): value is SearchIssuesPeriodValue { + return (SEARCH_ISSUES_PERIOD_VALUES as readonly string[]).includes(value); +} + /** * Rewrite a query into syntax the issues endpoint accepts. * @@ -71,7 +87,7 @@ export default defineTool({ description: [ "Search for grouped issues/problems in Sentry - returns a LIST of issues, NOT counts or aggregations.", "", - "Provide `query` as natural language or Sentry issue search syntax. When an embedded agent is configured, it fixes query and sort before running while preserving explicit Sentry search syntax.", + "Provide `query` as natural language or Sentry issue search syntax. Seer translates natural language when the organization has AI issue search; otherwise a configured embedded agent fixes query and sort while preserving explicit Sentry search syntax.", "", "Returns grouped issues with metadata like title, status, and user count.", "", @@ -155,6 +171,7 @@ export default defineTool({ let query: string; let sort: IssueSort; + let period = params.period; let explanation: string | undefined; // Set only when the query ran as-is; shown when it then matches nothing. let untranslatedQueryNote: string | null = null; @@ -175,7 +192,28 @@ export default defineTool({ } } - if (hasAgentProvider()) { + // Seer only sees the natural language query, so skip it for Sentry syntax. + const seerTranslation = + !params.query || looksLikeSentrySearchSyntax(params.query, "issues") + ? null + : await translateIssuesWithSeer({ + apiService, + organizationSlug: params.organizationSlug, + projectId, + query: params.query, + }); + + if (seerTranslation) { + query = seerTranslation.query; + sort = isIssueSort(seerTranslation.sort) + ? seerTranslation.sort + : params.sort; + // Seer sets a period only when the query asks for one. + if (isSearchIssuesPeriod(seerTranslation.statsPeriod)) { + period = seerTranslation.statsPeriod; + } + explanation = seerTranslation.explanation; + } else if (hasAgentProvider()) { // Agent mode: repair requests before calling Sentry. If the optional AI // provider is unavailable, preserve the original query and continue. const translatedQuery = await withProviderFallback< @@ -224,7 +262,7 @@ export default defineTool({ query, sortBy: sort, limit: params.limit, - statsPeriod: params.period, + statsPeriod: period, }); getActiveSpan()?.setAttribute( @@ -238,7 +276,7 @@ export default defineTool({ inputQuery: params.query, query, sort, - period: params.period, + period, projectSlugOrId: params.projectSlugOrId ?? undefined, explanation: params.includeExplanation ? explanation : undefined, notes: [ diff --git a/packages/mcp-core/src/tools/support/search-events/seer.ts b/packages/mcp-core/src/tools/support/search-events/seer.ts index 9de875ed2..965196cd0 100644 --- a/packages/mcp-core/src/tools/support/search-events/seer.ts +++ b/packages/mcp-core/src/tools/support/search-events/seer.ts @@ -23,9 +23,13 @@ export const SEER_SEARCH_AGENT_TIMEOUT = 60 * 1000; // 1 minute // Sentry's sentinel for all projects the user can access. const ALL_ACCESSIBLE_PROJECTS = -1; -// Gradual rollout flag for Seer translation in search_events. The search agent +// Gradual rollout flag for Seer translation in the search tools. The search agent // endpoints themselves are ungated; `hideAiFeatures` is checked separately. const SEER_TRANSLATE_FEATURE = "mcp-search-events-seer-translate"; +// Sentry requires this flag for the search agent's `Issues` strategy. +const SEER_ISSUES_SEARCH_FEATURE = "gen-ai-issues-search"; + +const SEER_EXPLANATION = "Translated by Seer's search agent."; const SEER_STRATEGIES = { errors: "Errors", @@ -217,7 +221,7 @@ function toSearchTranslation( .filter(([, query]) => query) .map(([type, query]) => `${type} \`${query}\``); - let explanation = "Translated by Seer's search agent."; + let explanation = SEER_EXPLANATION; let warning: string | undefined; if (crossEventFilters.length > 0) { if (timeSeries) { @@ -244,32 +248,48 @@ function toSearchTranslation( }; } +type SearchAgentStrategy = + | (typeof SEER_STRATEGIES)[SeerSearchDataset] + | "Issues"; + +interface SearchAgentRun { + result: z.output; + projectIds: number[]; + runId: string; + organizationFeatures: string[]; +} + /** - * Translates a natural language query with Seer's search agent. + * Starts a search agent run and polls it until it finishes. * * Returns null when Seer is unavailable for the organization, cannot translate - * the query, or does not finish in time, so the caller can fall back to the - * embedded agent or direct query syntax. + * the query, or does not finish in time. */ -export async function translateWithSeer({ +async function runSearchAgent({ apiService, organizationSlug, projectId, - dataset, + strategy, query, + requiredFeature, }: { apiService: SentryApiService; organizationSlug: string; projectId?: string; - dataset: SeerSearchDataset; + strategy: SearchAgentStrategy; query: string; -}): Promise { + // A strategy-specific flag that Sentry also checks before starting the run. + requiredFeature?: string; +}): Promise { try { const organizationFeatures = await getSeerSearchAgentFeatures( apiService, organizationSlug, ); - if (!organizationFeatures) { + if ( + !organizationFeatures || + (requiredFeature && !organizationFeatures.includes(requiredFeature)) + ) { return null; } @@ -277,7 +297,7 @@ export async function translateWithSeer({ organizationSlug, projectIds: [projectId ? Number(projectId) : ALL_ACCESSIBLE_PROJECTS], naturalLanguageQuery: query, - strategy: SEER_STRATEGIES[dataset], + strategy, }); const deadline = Date.now() + SEER_SEARCH_AGENT_TIMEOUT; @@ -292,18 +312,12 @@ export async function translateWithSeer({ if (!result) { return null; } - // Seer can suggest broadening a project-scoped search, e.g. to other - // services in the same trace. With all projects requested there is - // nothing to add. - const suggestedProjectIds = projectId - ? (session.final_response?.project_ids ?? []).filter( - (id) => id !== Number(projectId), - ) - : []; - return toSearchTranslation(result, dataset, suggestedProjectIds, { + return { + result, + projectIds: session.final_response?.project_ids ?? [], runId: run.sentry_run_id, organizationFeatures, - }); + }; } if (session?.status === "error") { return null; @@ -327,3 +341,90 @@ export async function translateWithSeer({ return null; } } + +/** + * Translates a natural language query with Seer's search agent. + * + * Returns null when Seer is unavailable for the organization, cannot translate + * the query, or does not finish in time, so the caller can fall back to the + * embedded agent or direct query syntax. + */ +export async function translateWithSeer({ + apiService, + organizationSlug, + projectId, + dataset, + query, +}: { + apiService: SentryApiService; + organizationSlug: string; + projectId?: string; + dataset: SeerSearchDataset; + query: string; +}): Promise { + const run = await runSearchAgent({ + apiService, + organizationSlug, + projectId, + strategy: SEER_STRATEGIES[dataset], + query, + }); + if (!run) { + return null; + } + // Seer can suggest broadening a project-scoped search, e.g. to other + // services in the same trace. With all projects requested there is nothing + // to add. + const suggestedProjectIds = projectId + ? run.projectIds.filter((id) => id !== Number(projectId)) + : []; + return toSearchTranslation(run.result, dataset, suggestedProjectIds, run); +} + +export interface SeerIssueSearchTranslation { + query: string; + // Empty when Seer returned no sort. + sort: string; + // Empty unless the user asked for a time period. + statsPeriod: string; + explanation: string; +} + +/** + * Translates a natural language issue search with Seer's `Issues` strategy. + * + * Sentry gates that strategy on `gen-ai-issues-search` on top of the shared + * Seer translation checks. Returns null when any of them fail, so the caller + * can fall back to the embedded agent or direct query syntax. + */ +export async function translateIssuesWithSeer({ + apiService, + organizationSlug, + projectId, + query, +}: { + apiService: SentryApiService; + organizationSlug: string; + projectId?: string; + query: string; +}): Promise { + const run = await runSearchAgent({ + apiService, + organizationSlug, + projectId, + strategy: "Issues", + query, + requiredFeature: SEER_ISSUES_SEARCH_FEATURE, + }); + // search_issues only takes a relative period, so an absolute range would be + // dropped silently. Fall back instead of searching the wrong window. + if (!run || run.result.start || run.result.end) { + return null; + } + return { + query: run.result.query, + sort: run.result.sort.trim(), + statsPeriod: run.result.stats_period, + explanation: SEER_EXPLANATION, + }; +} diff --git a/packages/mcp-core/src/tools/support/search-issues/results.ts b/packages/mcp-core/src/tools/support/search-issues/results.ts index 35e9876c6..8ae5eeab3 100644 --- a/packages/mcp-core/src/tools/support/search-issues/results.ts +++ b/packages/mcp-core/src/tools/support/search-issues/results.ts @@ -17,7 +17,13 @@ const PRESENTATION_HINT = const EMPTY_NOTE = "No issues found matching your search criteria. Try adjusting your search criteria or time range."; -const ISSUE_SORTS = ["date", "freq", "new", "user", "recommended"] as const; +export const ISSUE_SORTS = [ + "date", + "freq", + "new", + "user", + "recommended", +] as const; export type IssueSort = (typeof ISSUE_SORTS)[number];