Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/specs/search-events.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<id>`, 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
Expand Down
32 changes: 32 additions & 0 deletions packages/mcp-core/src/tools/catalog/search-events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
},
);
});
});
29 changes: 21 additions & 8 deletions packages/mcp-core/src/tools/support/search-events/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import {
looksLikeSentrySearchSyntax,
readRegexFilterValue,
recordEventsSearchValidationTelemetry,
rewriteLiteralQuery,
validateEventsSearch,
} from "./utils";

Expand Down Expand Up @@ -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;
Expand All @@ -685,15 +692,19 @@ 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.",
);
}

const willRunAgent =
hasAgentProvider() && !canRunWithoutAgent && !seerTranslation;
hasAgentProvider() &&
!canRunWithoutAgent &&
!seerTranslation &&
literalQuery === null;
const inputReferencesEnvironment =
params.environment != null ||
collectRequestedEnvironments(null, params.query ?? "").length > 0;
Expand Down Expand Up @@ -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 =
Expand Down
31 changes: 31 additions & 0 deletions packages/mcp-core/src/tools/support/search-events/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
formatKnownUserValue,
isSemanticFilterDowngrade,
looksLikeSentrySearchSyntax,
rewriteLiteralQuery,
} from "./utils";

describe("validateSearch tool contract", () => {
Expand Down Expand Up @@ -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(
Expand Down
31 changes: 31 additions & 0 deletions packages/mcp-core/src/tools/support/search-events/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)!?(?<key>[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.
Expand Down Expand Up @@ -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,
Expand Down
Loading