Skip to content

Commit 1615ec7

Browse files
carderneTrigger.dev RepoOps
authored andcommitted
perf(webapp): select log search predicates by term shape
Use indexed whole-word matching for ASCII alphabetic search terms. Keep substring matching for terms containing digits, punctuation, non-ASCII text, or spaces. Mono-RevId: 891d4b39cb78efd74ca986d55b2e287cc304747d
1 parent 3758963 commit 1615ec7

4 files changed

Lines changed: 97 additions & 15 deletions

File tree

‎apps/webapp/app/presenters/v3/LogsListPresenter.server.ts‎

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,9 @@ import {
2121
} from "~/v3/eventRepository/clickhouseEventRepository.server";
2222
import { ServiceValidationError } from "~/v3/services/baseService.server";
2323
import {
24-
escapeClickHouseLike,
2524
hasMinimumLogsSearchLength,
2625
logsSearchExpansionPeriod,
26+
logsSearchPredicate,
2727
LOGS_SEARCH_RETRY_OVERFETCH_FACTOR,
2828
MIN_LOGS_SEARCH_LENGTH,
2929
normalizeLogsSearchTerm,
@@ -255,21 +255,26 @@ export class LogsListPresenter extends BasePresenter {
255255
`Log searches must be at least ${MIN_LOGS_SEARCH_LENGTH} characters.`
256256
);
257257
}
258-
const searchTerm =
259-
normalizedSearchTerm === "" ? undefined : escapeClickHouseLike(normalizedSearchTerm);
258+
const searchPredicate =
259+
normalizedSearchTerm === "" ? undefined : logsSearchPredicate(normalizedSearchTerm);
260260

261261
// Run exactly one bounded query. Broadening a search window is an explicit user action;
262262
// silently rescanning the same recent rows makes absence queries needlessly expensive.
263263
const runQuery = () => {
264264
const queryBuilder = this.clickhouse.taskEventsSearch.logsListQueryBuilder({
265265
// Scoped to this query rather than the logs client, which other pages share. ClickHouse
266266
// skips lazy materialization when LIMIT exceeds the max, so pass this query's limit.
267-
settings: env.CLICKHOUSE_LOGS_LIST_LAZY_MATERIALIZATION
268-
? {
269-
query_plan_optimize_lazy_materialization: 1,
270-
query_plan_max_limit_for_lazy_materialization: queryLimit,
271-
}
272-
: undefined,
267+
settings: {
268+
...(env.CLICKHOUSE_LOGS_LIST_LAZY_MATERIALIZATION
269+
? {
270+
query_plan_optimize_lazy_materialization: 1,
271+
query_plan_max_limit_for_lazy_materialization: queryLimit,
272+
}
273+
: {}),
274+
...(searchPredicate && searchPredicate.kind !== "word"
275+
? { ignore_data_skipping_indices: "idx_search_text,idx_search_words" }
276+
: {}),
277+
},
273278
});
274279

275280
// The projector excludes events without a trace_id.
@@ -308,9 +313,13 @@ export class LogsListPresenter extends BasePresenter {
308313
queryBuilder.where("run_id = {runId: String}", { runId });
309314
}
310315

311-
if (searchTerm !== undefined) {
316+
if (searchPredicate?.kind === "word") {
317+
queryBuilder.where("hasAllTokens(concat(search_text, ''), {searchTokens: String})", {
318+
searchTokens: searchPredicate.term,
319+
});
320+
} else if (searchPredicate) {
312321
queryBuilder.where("search_text LIKE {searchPattern: String}", {
313-
searchPattern: `%${searchTerm}%`,
322+
searchPattern: searchPredicate.pattern,
314323
});
315324
}
316325

@@ -378,7 +387,7 @@ export class LogsListPresenter extends BasePresenter {
378387
if (queryError) {
379388
if (isClickhouseResourceLimitError(queryError)) {
380389
throw new ServiceValidationError(
381-
searchTerm === undefined
390+
searchPredicate === undefined
382391
? "These logs took too long to load. Try a shorter time range or add a filter."
383392
: "This search took too long. Try a shorter time range, a more specific search, or add a filter."
384393
);
@@ -440,7 +449,7 @@ export class LogsListPresenter extends BasePresenter {
440449
});
441450

442451
const searchExpansion =
443-
searchTerm !== undefined && time.isDefault && transformedLogs.length === 0
452+
searchPredicate !== undefined && time.isDefault && transformedLogs.length === 0
444453
? logsSearchExpansionPeriod(effectiveFrom, clampedTo, retentionLimitDays)
445454
: undefined;
446455

‎apps/webapp/app/utils/logSearch.test.ts‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
escapeClickHouseLike,
44
hasMinimumLogsSearchLength,
55
logsSearchExpansionPeriod,
6+
logsSearchPredicate,
67
normalizeLogsSearchTerm,
78
prepareLogsSearchPage,
89
} from "./logSearch";
@@ -24,6 +25,53 @@ describe("log search normalization", () => {
2425
expect(escapeClickHouseLike("/api/a_b/100%")).toBe("/api/a\\_b/100\\%");
2526
});
2627

28+
it("uses token matching only for ASCII letters", () => {
29+
expect(logsSearchPredicate("attempt")).toEqual({ kind: "word", term: "attempt" });
30+
});
31+
32+
it("keeps partial ids and numeric terms on LIKE", () => {
33+
expect(logsSearchPredicate("zahlung123")).toEqual({
34+
kind: "id",
35+
pattern: "%zahlung123%",
36+
});
37+
expect(logsSearchPredicate("123")).toEqual({ kind: "id", pattern: "%123%" });
38+
expect(logsSearchPredicate("run_abc123")).toEqual({
39+
kind: "id",
40+
pattern: "%run\\_abc123%",
41+
});
42+
expect(logsSearchPredicate("api/orders:42@example.com+retry")).toEqual({
43+
kind: "id",
44+
pattern: "%api/orders:42@example.com+retry%",
45+
});
46+
expect(logsSearchPredicate("/api/orders/42")).toEqual({
47+
kind: "id",
48+
pattern: "%/api/orders/42%",
49+
});
50+
});
51+
52+
it("keeps non-ASCII terms, phrases, and irregular delimiter shapes on LIKE", () => {
53+
expect(logsSearchPredicate("你好世界")).toEqual({
54+
kind: "phrase",
55+
pattern: "%你好世界%",
56+
});
57+
expect(logsSearchPredicate("café")).toEqual({
58+
kind: "phrase",
59+
pattern: "%café%",
60+
});
61+
expect(logsSearchPredicate("attempt failed")).toEqual({
62+
kind: "phrase",
63+
pattern: "%attempt failed%",
64+
});
65+
expect(logsSearchPredicate("run_abc123 other")).toEqual({
66+
kind: "phrase",
67+
pattern: "%run\\_abc123 other%",
68+
});
69+
expect(logsSearchPredicate("___")).toEqual({
70+
kind: "phrase",
71+
pattern: "%\\_\\_\\_%",
72+
});
73+
});
74+
2775
it("requires at least three unicode characters after trimming", () => {
2876
expect(hasMinimumLogsSearchLength("ab")).toBe(false);
2977
expect(hasMinimumLogsSearchLength(" ab ")).toBe(false);

‎apps/webapp/app/utils/logSearch.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,33 @@ export function escapeClickHouseLike(value: string): string {
5858
return value.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_");
5959
}
6060

61+
export type LogsSearchPredicate =
62+
| { kind: "word"; term: string }
63+
| { kind: "id"; pattern: string }
64+
| { kind: "phrase"; pattern: string };
65+
66+
const LOGS_SEARCH_WORD_PATTERN = /^[a-z]+$/;
67+
const LOGS_SEARCH_ID_PATTERN = /^[a-z0-9_\-.:/@+]+$/;
68+
const LOGS_SEARCH_ID_CONTENT_PATTERN = /[a-z0-9]/;
69+
const LOGS_SEARCH_ID_MARKER_PATTERN = /[0-9_\-.:/@+]/;
70+
71+
export function logsSearchPredicate(normalizedTerm: string): LogsSearchPredicate {
72+
if (LOGS_SEARCH_WORD_PATTERN.test(normalizedTerm)) {
73+
return { kind: "word", term: normalizedTerm };
74+
}
75+
76+
const pattern = `%${escapeClickHouseLike(normalizedTerm)}%`;
77+
if (
78+
LOGS_SEARCH_ID_PATTERN.test(normalizedTerm) &&
79+
LOGS_SEARCH_ID_CONTENT_PATTERN.test(normalizedTerm) &&
80+
LOGS_SEARCH_ID_MARKER_PATTERN.test(normalizedTerm)
81+
) {
82+
return { kind: "id", pattern };
83+
}
84+
85+
return { kind: "phrase", pattern };
86+
}
87+
6188
export function normalizeLogsSearchTerm(value: string): string {
6289
return normalizeSearchText(value).trim();
6390
}

‎internal-packages/clickhouse/src/taskEvents.ts‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -536,8 +536,6 @@ export function getLogsSearchListQueryBuilder(ch: ClickhouseReader) {
536536
],
537537
settings: {
538538
use_query_condition_cache: 1,
539-
// The ngram text index covers every organization's rows in a part, so reading it costs more
540-
// than scanning one environment's time range, often by seconds when it is not cached.
541539
ignore_data_skipping_indices: "idx_search_text",
542540
// Hold the response until the query finishes so a limit error arrives as an HTTP error the
543541
// client turns into a QueryError, not mid-stream. Pages are a few hundred small rows.

0 commit comments

Comments
 (0)