Skip to content

Commit 80ba22e

Browse files
fix(search): Only treat regex filters as search syntax for logs
Sentry only runs key://pattern// as a regex on logs; other datasets match the literal text, so a regex sent to search_spans or search_errors now lets the agent's rewrite through instead of forcing an empty search. Refs LOGS-1015 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 66a8d0c commit 80ba22e

4 files changed

Lines changed: 129 additions & 32 deletions

File tree

‎packages/mcp-core/src/tools/catalog/search-events.test.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3162,6 +3162,24 @@ describe("search_events", () => {
31623162
"severity:error message://can't connect to \\w+// has:trace",
31633163
]);
31643164
});
3165+
3166+
it("uses the agent's rewrite of regex syntax outside logs", async () => {
3167+
mockGenerateText.mockResolvedValueOnce(
3168+
mockAIResponse("spans", 'span.description:"GET /api/*"'),
3169+
);
3170+
const queries = captureEventsQueries();
3171+
3172+
await searchEvents.handler(
3173+
{
3174+
...regexSearchParams,
3175+
dataset: "spans",
3176+
query: "span.description://^GET \\/api\\/\\d+//",
3177+
},
3178+
regexSearchContext,
3179+
);
3180+
3181+
expect(queries).toEqual(['span.description:"GET /api/*"']);
3182+
});
31653183
});
31663184

31673185
it("keeps caller fields when the agent returns an empty fields array", async () => {

‎packages/mcp-core/src/tools/support/search-events/search.ts‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -331,14 +331,15 @@ function choosePreservingRepairedQuery(params: {
331331
originalQuery: string;
332332
repairedQuery?: string | null;
333333
filter?: string;
334+
dataset: PublicEventsDataset | "replays";
334335
}): string {
335336
const originalQuery = params.originalQuery.trim();
336337
const repairedQuery = params.repairedQuery?.trim();
337338
if (!repairedQuery) {
338339
return appendSearchFilter(originalQuery, params.filter);
339340
}
340341

341-
if (isSemanticFilterDowngrade(originalQuery, repairedQuery)) {
342+
if (isSemanticFilterDowngrade(originalQuery, repairedQuery, params.dataset)) {
342343
return appendSearchFilter(originalQuery, params.filter);
343344
}
344345

@@ -620,7 +621,10 @@ export async function runSearchEvents(
620621
setTargetTagsAndAttributes(params);
621622

622623
const inputDataset = params.dataset ?? "errors";
623-
const hasStructuredQuery = looksLikeSentrySearchSyntax(params.query);
624+
const hasStructuredQuery = looksLikeSentrySearchSyntax(
625+
params.query,
626+
inputDataset,
627+
);
624628
const canApplyEnvironmentFilter =
625629
inputDataset !== "replays" &&
626630
isTraceItemDataset(inputDataset) &&
@@ -790,9 +794,14 @@ export async function runSearchEvents(
790794
originalQuery: params.query ?? "",
791795
repairedQuery: parsed.query,
792796
filter: environmentFilter,
797+
dataset,
793798
})
794-
: looksLikeSentrySearchSyntax(params.query) &&
795-
isSemanticFilterDowngrade(params.query ?? "", parsed.query || "")
799+
: looksLikeSentrySearchSyntax(params.query, dataset) &&
800+
isSemanticFilterDowngrade(
801+
params.query ?? "",
802+
parsed.query || "",
803+
dataset,
804+
)
796805
? (params.query ?? "")
797806
: parsed.query || "";
798807
sortParam =

‎packages/mcp-core/src/tools/support/search-events/utils.test.ts‎

Lines changed: 50 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -297,49 +297,90 @@ describe("search query helpers", () => {
297297
expect(looksLikeSentrySearchSyntax("ERROR: service is down")).toBe(false);
298298
});
299299

300-
it("should detect regex filters", () => {
301-
expect(looksLikeSentrySearchSyntax("message://^Timeout//")).toBe(true);
302-
expect(
303-
looksLikeSentrySearchSyntax("message://^Timeout after \\d+ms//"),
304-
).toBe(true);
305-
expect(looksLikeSentrySearchSyntax("!message://(?i)healthcheck//")).toBe(
300+
it("should detect regex filters in logs queries", () => {
301+
expect(looksLikeSentrySearchSyntax("message://^Timeout//", "logs")).toBe(
306302
true,
307303
);
308-
expect(looksLikeSentrySearchSyntax("open http://example.com/a//b")).toBe(
304+
expect(
305+
looksLikeSentrySearchSyntax("message://^Timeout after \\d+ms//", "logs"),
306+
).toBe(true);
307+
expect(
308+
looksLikeSentrySearchSyntax("!message://(?i)healthcheck//", "logs"),
309+
).toBe(true);
310+
expect(
311+
looksLikeSentrySearchSyntax("open http://example.com/a//b", "logs"),
312+
).toBe(false);
313+
});
314+
315+
it("should not treat regex filters as search syntax outside logs", () => {
316+
expect(looksLikeSentrySearchSyntax("message://^Timeout//")).toBe(false);
317+
expect(
318+
looksLikeSentrySearchSyntax("span.description://^GET \\/api//", "spans"),
319+
).toBe(false);
320+
expect(looksLikeSentrySearchSyntax("message://^Timeout//", "errors")).toBe(
309321
false,
310322
);
311323
});
312324

313325
it("detects regex filters rewritten into plain or wildcard filters", () => {
314326
expect(
315-
isSemanticFilterDowngrade("message://^Timeout//", 'message:"*Timeout*"'),
327+
isSemanticFilterDowngrade(
328+
"message://^Timeout//",
329+
'message:"*Timeout*"',
330+
"logs",
331+
),
316332
).toBe(true);
317333
expect(
318334
isSemanticFilterDowngrade(
319335
"message://can't connect to \\w+//",
320336
"message:*connect*",
337+
"logs",
321338
),
322339
).toBe(true);
323340
expect(
324341
isSemanticFilterDowngrade(
325342
"custom://^order-\\d+$// level:error",
326343
'level:error message:"*order-*"',
344+
"logs",
327345
),
328346
).toBe(true);
329347
expect(
330-
isSemanticFilterDowngrade("custom://^order-\\d+$//", "custom:order-*"),
348+
isSemanticFilterDowngrade(
349+
"custom://^order-\\d+$//",
350+
"custom:order-*",
351+
"logs",
352+
),
331353
).toBe(true);
332354

333355
expect(
334356
isSemanticFilterDowngrade(
335357
"message://^Timeout//",
336358
"message://^Timeout// severity:error",
359+
"logs",
337360
),
338361
).toBe(false);
339362
expect(
340363
isSemanticFilterDowngrade(
341364
"custom://^order-\\d+$//",
342365
"tags[custom]://^order-\\d+$//",
366+
"logs",
367+
),
368+
).toBe(false);
369+
});
370+
371+
it("allows rewriting regex filters outside logs", () => {
372+
expect(
373+
isSemanticFilterDowngrade(
374+
"span.description://^GET \\/api\\/\\d+//",
375+
'span.description:"GET /api/*"',
376+
"spans",
377+
),
378+
).toBe(false);
379+
expect(
380+
isSemanticFilterDowngrade(
381+
"message://^Timeout//",
382+
'message:"*Timeout*"',
383+
"errors",
343384
),
344385
).toBe(false);
345386
});

‎packages/mcp-core/src/tools/support/search-events/utils.ts‎

Lines changed: 48 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ const SEARCH_FILTER_KEY_BEFORE_PATTERN = new RegExp(
3434
`${SEARCH_FILTER_KEY_SOURCE}$`,
3535
);
3636
const SENTRY_SEARCH_TOKEN_PATTERN = new RegExp(
37+
String.raw`${SEARCH_FILTER_KEY_SOURCE}(?=\S)(?!\/\/)`,
38+
"g",
39+
);
40+
const SENTRY_SEARCH_TOKEN_WITH_REGEX_PATTERN = new RegExp(
3741
String.raw`${SEARCH_FILTER_KEY_SOURCE}(?=\S)(?:(?!\/\/)|(?=${REGEX_FILTER_VALUE_SOURCE}))`,
3842
"g",
3943
);
@@ -92,13 +96,26 @@ export function isAggregateQuery(fields: string[]): boolean {
9296
return fields.some((field) => field.includes("(") && field.includes(")"));
9397
}
9498

95-
export function looksLikeSentrySearchSyntax(query?: string): boolean {
99+
type SearchSyntaxDataset = EventsDataset | "replays";
100+
101+
// Sentry only honors key://pattern// as a regex on logs; other datasets match
102+
// the literal text //pattern//.
103+
function searchTokenPattern(dataset?: SearchSyntaxDataset): RegExp {
104+
return dataset === "logs"
105+
? SENTRY_SEARCH_TOKEN_WITH_REGEX_PATTERN
106+
: SENTRY_SEARCH_TOKEN_PATTERN;
107+
}
108+
109+
export function looksLikeSentrySearchSyntax(
110+
query?: string,
111+
dataset?: SearchSyntaxDataset,
112+
): boolean {
96113
const trimmedQuery = query?.trim();
97114
if (!trimmedQuery) {
98115
return false;
99116
}
100117

101-
for (const match of trimmedQuery.matchAll(SENTRY_SEARCH_TOKEN_PATTERN)) {
118+
for (const match of trimmedQuery.matchAll(searchTokenPattern(dataset))) {
102119
const key = match[2];
103120
if (!key) {
104121
continue;
@@ -245,11 +262,14 @@ function readRawFilterValue(
245262
* boundaries. Values are normalized for comparison (strip wrapping quotes and
246263
* leading/trailing wildcards).
247264
*/
248-
function searchFilterOccurrences(query: string): SearchFilterOccurrence[] {
265+
function searchFilterOccurrences(
266+
query: string,
267+
dataset?: SearchSyntaxDataset,
268+
): SearchFilterOccurrence[] {
249269
const occurrences: SearchFilterOccurrence[] = [];
250270
const masked = maskQuotedRegions(query);
251271

252-
for (const match of masked.matchAll(SENTRY_SEARCH_TOKEN_PATTERN)) {
272+
for (const match of masked.matchAll(searchTokenPattern(dataset))) {
253273
const key = match[2]?.toLowerCase();
254274
if (!key || match.index === undefined) {
255275
continue;
@@ -284,14 +304,20 @@ function searchFilterOccurrences(query: string): SearchFilterOccurrence[] {
284304
return occurrences;
285305
}
286306

287-
function structuredFilterOccurrences(query: string): SearchFilterOccurrence[] {
288-
return searchFilterOccurrences(query).filter(
307+
function structuredFilterOccurrences(
308+
query: string,
309+
dataset?: SearchSyntaxDataset,
310+
): SearchFilterOccurrence[] {
311+
return searchFilterOccurrences(query, dataset).filter(
289312
(occurrence) => !FULL_TEXT_SEARCH_KEYS.has(occurrence.key),
290313
);
291314
}
292315

293-
function fullTextFilterValues(query: string): string[] {
294-
return searchFilterOccurrences(query)
316+
function fullTextFilterValues(
317+
query: string,
318+
dataset?: SearchSyntaxDataset,
319+
): string[] {
320+
return searchFilterOccurrences(query, dataset)
295321
.filter((occurrence) => FULL_TEXT_SEARCH_KEYS.has(occurrence.key))
296322
.map((occurrence) => occurrence.value);
297323
}
@@ -359,15 +385,17 @@ function unmatchedStructuredFilters(
359385
function isRegexFilterDowngrade(
360386
originalQuery: string,
361387
repairedQuery: string,
388+
dataset?: SearchSyntaxDataset,
362389
): boolean {
363-
const originalRegexFilters = searchFilterOccurrences(originalQuery).filter(
364-
(occurrence) => occurrence.regex,
365-
);
390+
const originalRegexFilters = searchFilterOccurrences(
391+
originalQuery,
392+
dataset,
393+
).filter((occurrence) => occurrence.regex);
366394
if (originalRegexFilters.length === 0) {
367395
return false;
368396
}
369397

370-
const repairedFilters = searchFilterOccurrences(repairedQuery);
398+
const repairedFilters = searchFilterOccurrences(repairedQuery, dataset);
371399
const repairedPlainFilters = repairedFilters.filter(
372400
(occurrence) => !occurrence.regex,
373401
);
@@ -384,8 +412,8 @@ function isRegexFilterDowngrade(
384412

385413
/**
386414
* True when a structured field:value filter was replaced with message/log.body
387-
* full-text matching (false-success path), or a `key://pattern//` regex filter
388-
* was replaced with a plain or wildcard filter. Allows real attribute renames.
415+
* full-text matching (false-success path), or a logs `key://pattern//` regex
416+
* filter was replaced with a plain or wildcard filter. Allows real attribute renames.
389417
*
390418
* Uses multiset key+value matching so dropping one of several identical keys
391419
* (e.g. `custom:foo custom:bar` → `custom:bar message:"*foo*"`) is still caught.
@@ -395,21 +423,22 @@ function isRegexFilterDowngrade(
395423
export function isSemanticFilterDowngrade(
396424
originalQuery: string,
397425
repairedQuery: string,
426+
dataset?: SearchSyntaxDataset,
398427
): boolean {
399-
if (!looksLikeSentrySearchSyntax(originalQuery)) {
428+
if (!looksLikeSentrySearchSyntax(originalQuery, dataset)) {
400429
return false;
401430
}
402431

403-
if (isRegexFilterDowngrade(originalQuery, repairedQuery)) {
432+
if (isRegexFilterDowngrade(originalQuery, repairedQuery, dataset)) {
404433
return true;
405434
}
406435

407-
const originalFilters = structuredFilterOccurrences(originalQuery);
436+
const originalFilters = structuredFilterOccurrences(originalQuery, dataset);
408437
if (originalFilters.length === 0) {
409438
return false;
410439
}
411440

412-
const repairedFilters = structuredFilterOccurrences(repairedQuery);
441+
const repairedFilters = structuredFilterOccurrences(repairedQuery, dataset);
413442
const droppedFilters = unmatchedStructuredFilters(
414443
originalFilters,
415444
repairedFilters,
@@ -418,7 +447,7 @@ export function isSemanticFilterDowngrade(
418447
return false;
419448
}
420449

421-
const repairedFullTextValues = fullTextFilterValues(repairedQuery);
450+
const repairedFullTextValues = fullTextFilterValues(repairedQuery, dataset);
422451
if (repairedFullTextValues.length === 0) {
423452
// Renames keep values on non-full-text attributes and do not need this guard.
424453
return false;

0 commit comments

Comments
 (0)