From c96c4eab14990dfa12773971e4c6498c99c7f71a Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:21:09 +0000 Subject: [PATCH 1/2] fix(search-events): Stop gating Seer on removed translate flag Co-Authored-By: Dominik Buszowiecki --- docs/specs/search-events.md | 2 +- .../src/tools/catalog/search-events.test.ts | 58 ++++++++----------- .../src/tools/support/search-events/seer.ts | 13 +---- 3 files changed, 29 insertions(+), 44 deletions(-) diff --git a/docs/specs/search-events.md b/docs/specs/search-events.md index 4ca2a6c99..5424b9896 100644 --- a/docs/specs/search-events.md +++ b/docs/specs/search-events.md @@ -144,7 +144,7 @@ Requests for a metric over time ("per hour", "per day", "trend", "over time") re - **Logs timestamp handling**: Logs don't support query-based timestamp filters like `timestamp:-1h`. Instead, use `statsPeriod=24h` parameter - **Project ID mapping**: API requires numeric project IDs, not slugs. Tool automatically converts project slugs to IDs -- **Seer translation**: Seer translates natural-language queries when the organization has the required Seer features and AI features are enabled. If Seer is unavailable or cannot translate the query, the tool falls back to the configured embedded agent. +- **Seer translation**: Seer translates natural-language queries when the organization has not hidden AI features. If Seer is unavailable or cannot translate the query, the tool falls back to the configured embedded agent. - **Seer cross-event filters**: Time series results do not apply cross-event filters. When Seer returns those filters for a time series, the response always begins with a warning identifying the omitted filters and the broader results, even when `includeExplanation` is false. - **Seer project scope**: For a successful Seer translation without `projectSlug`, search and Explorer links use `project=-1` to match the all-accessible-project scope sent to Seer. Other unscoped searches retain their existing default scope. - **Parallel attribute fetching**: For spans/logs/metrics, fetches both string and number attribute types in parallel for better performance 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 04fcced9f..3c604b82f 100644 --- a/packages/mcp-core/src/tools/catalog/search-events.test.ts +++ b/packages/mcp-core/src/tools/catalog/search-events.test.ts @@ -3611,22 +3611,14 @@ describe("search_events", () => { metric_query: null, }; - const mockOrganization = (features: string[]) => - http.get( - "https://sentry.io/api/0/organizations/test-org/", - ({ request }) => - HttpResponse.json({ - id: "1", - slug: "test-org", - name: "Test Org", - // Sentry only serializes features when explicitly requested. - ...(new URL(request.url).searchParams.get( - "include_feature_flags", - ) === "1" - ? { features } - : {}), - hideAiFeatures: false, - }), + const mockOrganization = ({ hideAiFeatures = false } = {}) => + http.get("https://sentry.io/api/0/organizations/test-org/", () => + HttpResponse.json({ + id: "1", + slug: "test-org", + name: "Test Org", + hideAiFeatures, + }), ); const mockProject = http.get( "https://sentry.io/api/0/projects/test-org/test-project/", @@ -3659,7 +3651,7 @@ describe("search_events", () => { it("should translate natural language queries with Seer", async () => { mswServer.use( - mockOrganization(["gen-ai-search-agent-translate"]), + mockOrganization(), mockSeerState({ status: "completed", final_response: { responses: [seerQuery], unsupported_reason: null }, @@ -3698,7 +3690,7 @@ describe("search_events", () => { it("should return a time series when Seer sets an interval", async () => { mswServer.use( - mockOrganization(["gen-ai-search-agent-translate"]), + mockOrganization(), mockSeerState({ status: "completed", final_response: { @@ -3752,7 +3744,7 @@ describe("search_events", () => { it("should apply Seer's cross-event filters", async () => { mswServer.use( - mockOrganization(["gen-ai-search-agent-translate"]), + mockOrganization(), mockSeerState({ status: "completed", final_response: { @@ -3790,7 +3782,7 @@ describe("search_events", () => { "warns about unapplied time-series filters with includeExplanation=%s", async (includeExplanation) => { mswServer.use( - mockOrganization(["gen-ai-search-agent-translate"]), + mockOrganization(), mockSeerState({ status: "completed", final_response: { @@ -3883,7 +3875,7 @@ describe("search_events", () => { it("should keep a grouped Seer query with an interval as a table", async () => { mswServer.use( - mockOrganization(["gen-ai-search-agent-translate"]), + mockOrganization(), mockSeerState({ status: "completed", final_response: { @@ -3914,7 +3906,7 @@ describe("search_events", () => { it("should add an explicit environment to Seer's query", async () => { mswServer.use( - mockOrganization(["gen-ai-search-agent-translate"]), + mockOrganization(), mockSeerState({ status: "completed", final_response: { responses: [seerQuery], unsupported_reason: null }, @@ -3958,7 +3950,7 @@ describe("search_events", () => { "should keep the requested project and %s Seer's wider scope", async (_, handlerContext, expectNote) => { mswServer.use( - mockOrganization(["gen-ai-search-agent-translate"]), + mockOrganization(), mockSeerState({ status: "completed", final_response: { @@ -3988,7 +3980,7 @@ describe("search_events", () => { it("should keep the requested project when Seer does not broaden it", async () => { mswServer.use( - mockOrganization(["gen-ai-search-agent-translate"]), + mockOrganization(), mockSeerState({ status: "completed", final_response: { @@ -4021,7 +4013,7 @@ describe("search_events", () => { }, ); mswServer.use( - mockOrganization(["gen-ai-search-agent-translate"]), + mockOrganization(), http.post( "https://sentry.io/api/0/organizations/test-org/search-agent/start/", mockAllProjectsStart, @@ -4068,7 +4060,7 @@ describe("search_events", () => { it("should keep Seer's all-project scope for time series", async () => { mswServer.use( - mockOrganization(["gen-ai-search-agent-translate"]), + mockOrganization(), http.post( "https://sentry.io/api/0/organizations/test-org/search-agent/start/", async ({ request }) => { @@ -4109,7 +4101,7 @@ describe("search_events", () => { it("should prefer an explicit period over Seer's time range", async () => { mswServer.use( - mockOrganization(["gen-ai-search-agent-translate"]), + mockOrganization(), mockSeerState({ status: "completed", final_response: { responses: [seerQuery], unsupported_reason: null }, @@ -4131,7 +4123,7 @@ describe("search_events", () => { it("should not group by a non-aggregate Seer sort", async () => { mswServer.use( - mockOrganization(["gen-ai-search-agent-translate"]), + mockOrganization(), mockSeerState({ status: "completed", final_response: { @@ -4167,7 +4159,7 @@ describe("search_events", () => { mockAIResponse("spans", "span.op:http.client"), ); mswServer.use( - mockOrganization(["gen-ai-search-agent-translate"]), + mockOrganization(), http.get("https://sentry.io/api/0/organizations/test-org/events/", () => HttpResponse.json({ data: [] }), ), @@ -4179,12 +4171,12 @@ describe("search_events", () => { expect(mockGenerateText).toHaveBeenCalled(); }); - it("should fall back to the agent when Seer is not enabled", async () => { + it("should fall back to the agent when AI features are hidden", async () => { mockGenerateText.mockResolvedValueOnce( mockAIResponse("spans", "span.op:http.client"), ); mswServer.use( - mockOrganization([]), + mockOrganization({ hideAiFeatures: true }), http.get( "https://sentry.io/api/0/organizations/test-org/environments/", ({ request }) => { @@ -4228,7 +4220,7 @@ describe("search_events", () => { mockAIResponse("spans", "span.op:http.client"), ); mswServer.use( - mockOrganization(["gen-ai-search-agent-translate"]), + mockOrganization(), mockSeerState({ status: "error", unsupported_reason: "Unsupported" }), http.get("https://sentry.io/api/0/organizations/test-org/events/", () => HttpResponse.json({ data: [] }), @@ -4246,7 +4238,7 @@ describe("search_events", () => { mockAIResponse("spans", "span.op:http.client"), ); mswServer.use( - mockOrganization(["gen-ai-search-agent-translate"]), + mockOrganization(), http.post( "https://sentry.io/api/0/organizations/test-org/search-agent/start/", () => 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 5ba7408d0..04bb8c6ec 100644 --- a/packages/mcp-core/src/tools/support/search-events/seer.ts +++ b/packages/mcp-core/src/tools/support/search-events/seer.ts @@ -17,9 +17,6 @@ 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; -// The search agent endpoints require this feature. `hideAiFeatures` is checked separately. -const SEARCH_AGENT_FEATURE = "gen-ai-search-agent-translate"; - const SEER_STRATEGIES = { errors: "Errors", logs: "Logs", @@ -53,16 +50,12 @@ async function hasSeerSearchAgentAccess( apiService: SentryApiService, organizationSlug: string, ): Promise { - // Sentry omits `features` unless explicitly requested. + // The search agent endpoints are no longer behind a feature flag, so only + // the org's AI opt-out applies. Other access failures fall back to the agent. const organization = await apiService.getOrganization(organizationSlug, { - includeFeatureFlags: true, detailed: false, }); - if (organization.hideAiFeatures) { - return false; - } - const features = organization.features ?? []; - return features.includes(SEARCH_AGENT_FEATURE); + return !organization.hideAiFeatures; } function toSearchTranslation( From a133931a5838fbe80a5c9f1e3459475a787b4785 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:39:57 +0000 Subject: [PATCH 2/2] fix(search-events): Gate Seer translation on a rollout flag The old gen-ai-search-agent-translate flag was removed from Sentry, so search_events never used Seer. Check the new mcp-search-events-seer-translate flag instead so Seer translation can be rolled out gradually rather than at 100%. Co-Authored-By: Dominik Buszowiecki <44422760+DominikB2014@users.noreply.github.com> --- docs/specs/search-events.md | 2 +- .../src/tools/catalog/search-events.test.ts | 80 +++++++++++++------ .../src/tools/support/search-events/seer.ts | 14 +++- 3 files changed, 67 insertions(+), 29 deletions(-) diff --git a/docs/specs/search-events.md b/docs/specs/search-events.md index 5424b9896..ff31c8ca9 100644 --- a/docs/specs/search-events.md +++ b/docs/specs/search-events.md @@ -144,7 +144,7 @@ Requests for a metric over time ("per hour", "per day", "trend", "over time") re - **Logs timestamp handling**: Logs don't support query-based timestamp filters like `timestamp:-1h`. Instead, use `statsPeriod=24h` parameter - **Project ID mapping**: API requires numeric project IDs, not slugs. Tool automatically converts project slugs to IDs -- **Seer translation**: Seer translates natural-language queries when the organization has not hidden AI features. If Seer is unavailable or cannot translate the query, the tool falls back to the configured embedded agent. +- **Seer translation**: Seer translates natural-language queries when the organization has the `mcp-search-events-seer-translate` rollout flag and has not hidden AI features. If Seer is unavailable or cannot translate the query, the tool falls back to the configured embedded agent. - **Seer cross-event filters**: Time series results do not apply cross-event filters. When Seer returns those filters for a time series, the response always begins with a warning identifying the omitted filters and the broader results, even when `includeExplanation` is false. - **Seer project scope**: For a successful Seer translation without `projectSlug`, search and Explorer links use `project=-1` to match the all-accessible-project scope sent to Seer. Other unscoped searches retain their existing default scope. - **Parallel attribute fetching**: For spans/logs/metrics, fetches both string and number attribute types in parallel for better performance 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 3c604b82f..ab030f013 100644 --- a/packages/mcp-core/src/tools/catalog/search-events.test.ts +++ b/packages/mcp-core/src/tools/catalog/search-events.test.ts @@ -3611,14 +3611,25 @@ describe("search_events", () => { metric_query: null, }; - const mockOrganization = ({ hideAiFeatures = false } = {}) => - http.get("https://sentry.io/api/0/organizations/test-org/", () => - HttpResponse.json({ - id: "1", - slug: "test-org", - name: "Test Org", - hideAiFeatures, - }), + const mockOrganization = ( + features: string[], + { hideAiFeatures = false } = {}, + ) => + http.get( + "https://sentry.io/api/0/organizations/test-org/", + ({ request }) => + HttpResponse.json({ + id: "1", + slug: "test-org", + name: "Test Org", + // Sentry only serializes features when explicitly requested. + ...(new URL(request.url).searchParams.get( + "include_feature_flags", + ) === "1" + ? { features } + : {}), + hideAiFeatures, + }), ); const mockProject = http.get( "https://sentry.io/api/0/projects/test-org/test-project/", @@ -3651,7 +3662,7 @@ describe("search_events", () => { it("should translate natural language queries with Seer", async () => { mswServer.use( - mockOrganization(), + mockOrganization(["mcp-search-events-seer-translate"]), mockSeerState({ status: "completed", final_response: { responses: [seerQuery], unsupported_reason: null }, @@ -3690,7 +3701,7 @@ describe("search_events", () => { it("should return a time series when Seer sets an interval", async () => { mswServer.use( - mockOrganization(), + mockOrganization(["mcp-search-events-seer-translate"]), mockSeerState({ status: "completed", final_response: { @@ -3744,7 +3755,7 @@ describe("search_events", () => { it("should apply Seer's cross-event filters", async () => { mswServer.use( - mockOrganization(), + mockOrganization(["mcp-search-events-seer-translate"]), mockSeerState({ status: "completed", final_response: { @@ -3782,7 +3793,7 @@ describe("search_events", () => { "warns about unapplied time-series filters with includeExplanation=%s", async (includeExplanation) => { mswServer.use( - mockOrganization(), + mockOrganization(["mcp-search-events-seer-translate"]), mockSeerState({ status: "completed", final_response: { @@ -3875,7 +3886,7 @@ describe("search_events", () => { it("should keep a grouped Seer query with an interval as a table", async () => { mswServer.use( - mockOrganization(), + mockOrganization(["mcp-search-events-seer-translate"]), mockSeerState({ status: "completed", final_response: { @@ -3906,7 +3917,7 @@ describe("search_events", () => { it("should add an explicit environment to Seer's query", async () => { mswServer.use( - mockOrganization(), + mockOrganization(["mcp-search-events-seer-translate"]), mockSeerState({ status: "completed", final_response: { responses: [seerQuery], unsupported_reason: null }, @@ -3950,7 +3961,7 @@ describe("search_events", () => { "should keep the requested project and %s Seer's wider scope", async (_, handlerContext, expectNote) => { mswServer.use( - mockOrganization(), + mockOrganization(["mcp-search-events-seer-translate"]), mockSeerState({ status: "completed", final_response: { @@ -3980,7 +3991,7 @@ describe("search_events", () => { it("should keep the requested project when Seer does not broaden it", async () => { mswServer.use( - mockOrganization(), + mockOrganization(["mcp-search-events-seer-translate"]), mockSeerState({ status: "completed", final_response: { @@ -4013,7 +4024,7 @@ describe("search_events", () => { }, ); mswServer.use( - mockOrganization(), + mockOrganization(["mcp-search-events-seer-translate"]), http.post( "https://sentry.io/api/0/organizations/test-org/search-agent/start/", mockAllProjectsStart, @@ -4060,7 +4071,7 @@ describe("search_events", () => { it("should keep Seer's all-project scope for time series", async () => { mswServer.use( - mockOrganization(), + mockOrganization(["mcp-search-events-seer-translate"]), http.post( "https://sentry.io/api/0/organizations/test-org/search-agent/start/", async ({ request }) => { @@ -4101,7 +4112,7 @@ describe("search_events", () => { it("should prefer an explicit period over Seer's time range", async () => { mswServer.use( - mockOrganization(), + mockOrganization(["mcp-search-events-seer-translate"]), mockSeerState({ status: "completed", final_response: { responses: [seerQuery], unsupported_reason: null }, @@ -4123,7 +4134,7 @@ describe("search_events", () => { it("should not group by a non-aggregate Seer sort", async () => { mswServer.use( - mockOrganization(), + mockOrganization(["mcp-search-events-seer-translate"]), mockSeerState({ status: "completed", final_response: { @@ -4159,7 +4170,7 @@ describe("search_events", () => { mockAIResponse("spans", "span.op:http.client"), ); mswServer.use( - mockOrganization(), + mockOrganization(["mcp-search-events-seer-translate"]), http.get("https://sentry.io/api/0/organizations/test-org/events/", () => HttpResponse.json({ data: [] }), ), @@ -4171,12 +4182,12 @@ describe("search_events", () => { expect(mockGenerateText).toHaveBeenCalled(); }); - it("should fall back to the agent when AI features are hidden", async () => { + it("should fall back to the agent when Seer is not enabled", async () => { mockGenerateText.mockResolvedValueOnce( mockAIResponse("spans", "span.op:http.client"), ); mswServer.use( - mockOrganization({ hideAiFeatures: true }), + mockOrganization([]), http.get( "https://sentry.io/api/0/organizations/test-org/environments/", ({ request }) => { @@ -4215,12 +4226,31 @@ describe("search_events", () => { expect(mockGenerateText).toHaveBeenCalled(); }); + it("should fall back to the agent when AI features are hidden", async () => { + mockGenerateText.mockResolvedValueOnce( + mockAIResponse("spans", "span.op:http.client"), + ); + mswServer.use( + mockOrganization(["mcp-search-events-seer-translate"], { + hideAiFeatures: true, + }), + http.get("https://sentry.io/api/0/organizations/test-org/events/", () => + HttpResponse.json({ data: [] }), + ), + ); + + await searchEvents.handler(seerParams, context); + + expect(mockSeerStart).not.toHaveBeenCalled(); + expect(mockGenerateText).toHaveBeenCalled(); + }); + it("should fall back to the agent when Seer cannot translate", async () => { mockGenerateText.mockResolvedValueOnce( mockAIResponse("spans", "span.op:http.client"), ); mswServer.use( - mockOrganization(), + mockOrganization(["mcp-search-events-seer-translate"]), mockSeerState({ status: "error", unsupported_reason: "Unsupported" }), http.get("https://sentry.io/api/0/organizations/test-org/events/", () => HttpResponse.json({ data: [] }), @@ -4238,7 +4268,7 @@ describe("search_events", () => { mockAIResponse("spans", "span.op:http.client"), ); mswServer.use( - mockOrganization(), + mockOrganization(["mcp-search-events-seer-translate"]), http.post( "https://sentry.io/api/0/organizations/test-org/search-agent/start/", () => 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 04bb8c6ec..b146a6742 100644 --- a/packages/mcp-core/src/tools/support/search-events/seer.ts +++ b/packages/mcp-core/src/tools/support/search-events/seer.ts @@ -17,6 +17,10 @@ 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 +// endpoints themselves are ungated; `hideAiFeatures` is checked separately. +const SEER_TRANSLATE_FEATURE = "mcp-search-events-seer-translate"; + const SEER_STRATEGIES = { errors: "Errors", logs: "Logs", @@ -50,12 +54,16 @@ async function hasSeerSearchAgentAccess( apiService: SentryApiService, organizationSlug: string, ): Promise { - // The search agent endpoints are no longer behind a feature flag, so only - // the org's AI opt-out applies. Other access failures fall back to the agent. + // Sentry omits `features` unless explicitly requested. const organization = await apiService.getOrganization(organizationSlug, { + includeFeatureFlags: true, detailed: false, }); - return !organization.hideAiFeatures; + if (organization.hideAiFeatures) { + return false; + } + const features = organization.features ?? []; + return features.includes(SEER_TRANSLATE_FEATURE); } function toSearchTranslation(