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,