From 89577689b80368799bbe8afa22ddaa3a97636f61 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:51:02 +0000 Subject: [PATCH] fix(search-events): Run literal queries without Seer or the agent A bare trace ID, `*`, or a single quoted phrase has only one reading, so translating it with Seer or the embedded agent adds latency and cost with nothing to gain. Rewrite these directly into Sentry search syntax wherever Seer would otherwise run. Co-Authored-By: Dominik Buszowiecki <44422760+DominikB2014@users.noreply.github.com> --- docs/specs/search-events.md | 1 + .../src/tools/catalog/search-events.test.ts | 32 +++++++++++++++++++ .../src/tools/support/search-events/search.ts | 29 ++++++++++++----- .../tools/support/search-events/utils.test.ts | 31 ++++++++++++++++++ .../src/tools/support/search-events/utils.ts | 31 ++++++++++++++++++ 5 files changed, 116 insertions(+), 8 deletions(-) diff --git a/docs/specs/search-events.md b/docs/specs/search-events.md index d942cff05..8a1f2d77b 100644 --- a/docs/specs/search-events.md +++ b/docs/specs/search-events.md @@ -146,6 +146,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 `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. +- **Literal queries skip translation**: A query with only one literal reading runs directly, without Seer or the embedded agent: `*` matches everything, a bare 32-character hex ID becomes `trace:`, and a single quoted phrase runs as a full-text search. This applies only where Seer would otherwise run (no structured syntax, explicit fields, or explicit sort). - **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 df8615d6a..d02a38d2f 100644 --- a/packages/mcp-core/src/tools/catalog/search-events.test.ts +++ b/packages/mcp-core/src/tools/catalog/search-events.test.ts @@ -4645,5 +4645,37 @@ describe("search_events", () => { expect(mockGenerateText).toHaveBeenCalled(); }); + + it.each([ + [ + "a bare trace ID", + "A1B2C3D4E5F6A7B8C9D0E1F2A3B4C5D6", + "trace:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", + ], + ["a quoted phrase", '"connection refused"', '"connection refused"'], + ["a wildcard", "*", ""], + ])( + "should run %s without Seer or the agent", + async (_, query, expected) => { + mswServer.use( + mockOrganization(["mcp-search-events-seer-translate"]), + http.get( + "https://sentry.io/api/0/organizations/test-org/events/", + ({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.get("dataset")).toBe("spans"); + expect(url.searchParams.get("query")).toBe(expected); + expect(url.searchParams.get("sort")).toBe("-timestamp"); + return HttpResponse.json({ data: [] }); + }, + ), + ); + + await searchEvents.handler({ ...seerParams, query }, context); + + expect(mockSeerStart).not.toHaveBeenCalled(); + expect(mockGenerateText).not.toHaveBeenCalled(); + }, + ); }); }); diff --git a/packages/mcp-core/src/tools/support/search-events/search.ts b/packages/mcp-core/src/tools/support/search-events/search.ts index 07c4e729e..30a717a73 100644 --- a/packages/mcp-core/src/tools/support/search-events/search.ts +++ b/packages/mcp-core/src/tools/support/search-events/search.ts @@ -56,6 +56,7 @@ import { looksLikeSentrySearchSyntax, readRegexFilterValue, recordEventsSearchValidationTelemetry, + rewriteLiteralQuery, validateEventsSearch, } from "./utils"; @@ -662,17 +663,23 @@ export async function runSearchEvents( // structured queries and explicit fields or sort, which the embedded agent // preserves. Like the UI, an explicit environment is added to Seer's query // afterwards. - const seerTranslation = - params.query && + const seerDataset = isSeerSearchDataset(params.dataset) && !hasStructuredQuery && !hasExplicitFields && !hasExplicitSort + ? params.dataset + : null; + // Queries with one literal reading, like a bare trace ID, run directly + // instead of going through Seer or the embedded agent. + const literalQuery = seerDataset ? rewriteLiteralQuery(params.query) : null; + const seerTranslation = + params.query && seerDataset && literalQuery === null ? await translateWithSeer({ apiService, organizationSlug, projectId, - dataset: params.dataset, + dataset: seerDataset, query: params.query, }) : null; @@ -685,7 +692,8 @@ export async function runSearchEvents( inputDataset !== "replays" && params.environment && !canApplyEnvironmentFilter && - !seerTranslation + !seerTranslation && + literalQuery === null ) { throw new UserInputError( "The `environment` parameter is only supported for dataset='replays'. For other datasets, include environment filtering in the query string instead.", @@ -693,7 +701,10 @@ export async function runSearchEvents( } const willRunAgent = - hasAgentProvider() && !canRunWithoutAgent && !seerTranslation; + hasAgentProvider() && + !canRunWithoutAgent && + !seerTranslation && + literalQuery === null; const inputReferencesEnvironment = params.environment != null || collectRequestedEnvironments(null, params.query ?? "").length > 0; @@ -816,9 +827,11 @@ export async function runSearchEvents( } } else { dataset = inputDataset; - sentryQuery = shouldTrustStructuredTraceSearch - ? explicitStructuredTraceQuery - : (params.query ?? ""); + sentryQuery = + literalQuery ?? + (shouldTrustStructuredTraceSearch + ? explicitStructuredTraceQuery + : (params.query ?? "")); sortParam = explicitSort || defaultSortForDataset(dataset); timeParams = { statsPeriod: params.period ?? "14d" }; fields = diff --git a/packages/mcp-core/src/tools/support/search-events/utils.test.ts b/packages/mcp-core/src/tools/support/search-events/utils.test.ts index 80230886c..1f29e0640 100644 --- a/packages/mcp-core/src/tools/support/search-events/utils.test.ts +++ b/packages/mcp-core/src/tools/support/search-events/utils.test.ts @@ -13,6 +13,7 @@ import { formatKnownUserValue, isSemanticFilterDowngrade, looksLikeSentrySearchSyntax, + rewriteLiteralQuery, } from "./utils"; describe("validateSearch tool contract", () => { @@ -681,6 +682,36 @@ describe("search query helpers", () => { }); }); +describe("rewriteLiteralQuery", () => { + it("rewrites queries with one literal reading", () => { + expect(rewriteLiteralQuery("*")).toBe(""); + expect(rewriteLiteralQuery(" A1B2C3D4E5F6A7B8C9D0E1F2A3B4C5D6 ")).toBe( + "trace:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", + ); + expect(rewriteLiteralQuery('"connection refused"')).toBe( + '"connection refused"', + ); + expect(rewriteLiteralQuery(String.raw`"say \"hi\""`)).toBe( + String.raw`"say \"hi\""`, + ); + }); + + it("leaves everything else for translation", () => { + expect(rewriteLiteralQuery(undefined)).toBeNull(); + expect(rewriteLiteralQuery("")).toBeNull(); + expect(rewriteLiteralQuery('""')).toBeNull(); + expect(rewriteLiteralQuery('" "')).toBeNull(); + expect(rewriteLiteralQuery("TypeError")).toBeNull(); + expect(rewriteLiteralQuery("slow requests")).toBeNull(); + expect(rewriteLiteralQuery("a1b2c3d4")).toBeNull(); + expect( + rewriteLiteralQuery("a1b2c3d4-e5f6-a7b8-c9d0-e1f2a3b4c5d6"), + ).toBeNull(); + expect(rewriteLiteralQuery('"timeout" in checkout')).toBeNull(); + expect(rewriteLiteralQuery('"timeout" "refused"')).toBeNull(); + }); +}); + describe("datasetAttributes tool pagination", () => { beforeEach(() => { mswServer.use( 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 d759f30aa..84cd091b4 100644 --- a/packages/mcp-core/src/tools/support/search-events/utils.ts +++ b/packages/mcp-core/src/tools/support/search-events/utils.ts @@ -57,6 +57,9 @@ const REGEX_FILTER_KEY_BEFORE_PATTERN = new RegExp( `${REGEX_FILTER_KEY_SOURCE}$`, ); const REGEX_FILTER_KEY_SCAN_LIMIT = 256; +const TRACE_ID_PATTERN = /^[0-9a-f]{32}$/i; +// One double-quoted phrase with no unescaped quote inside. +const QUOTED_PHRASE_PATTERN = /^"(?:[^"\\]|\\.)+"$/; const SEARCH_FILTER_KEY_SOURCE = String.raw`(^|\s)!?(?[A-Za-z_][A-Za-z0-9_.[\],-]*):`; // Used by the semantic-downgrade comparison below, which still scans the raw // query text. `looksLikeSentrySearchSyntax` parses with the shared grammar. @@ -208,6 +211,34 @@ export function looksLikeSentrySearchSyntax( return false; } +/** + * Rewrite a query with only one literal reading into Sentry search syntax, so + * it can run without query translation. Returns null for anything else. + * + * - `*` matches everything. + * - A bare 32-character hex ID is a trace ID. + * - A single quoted phrase is a full-text search, which Sentry runs as is. + */ +export function rewriteLiteralQuery(query?: string): string | null { + const trimmedQuery = query?.trim(); + if (!trimmedQuery) { + return null; + } + if (trimmedQuery === "*") { + return ""; + } + if (TRACE_ID_PATTERN.test(trimmedQuery)) { + return `trace:${trimmedQuery.toLowerCase()}`; + } + if ( + QUOTED_PHRASE_PATTERN.test(trimmedQuery) && + trimmedQuery.slice(1, -1).trim() + ) { + return trimmedQuery; + } + return null; +} + export function readRegexFilterValue( query: string, index: number,