Skip to content

Commit 2061edd

Browse files
committed
fix(webapp,clickhouse): harden bounded global log search
1 parent 9b725c3 commit 2061edd

32 files changed

Lines changed: 688 additions & 639 deletions

File tree

.server-changes/improve-global-log-search.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@ area: webapp
33
type: improvement
44
---
55

6-
Global log search now uses a bounded search index, clearer time-range expansion, and partial-result feedback for expensive searches.
6+
Global log search now supports a bounded search index and clearer time-range expansion while keeping existing search history available during rollout.

apps/webapp/app/components/navigation/SideMenu.tsx

Lines changed: 53 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -840,55 +840,59 @@ export function SideMenu({
840840
} satisfies SideMenuItemConfig,
841841
]
842842
: []),
843-
{
844-
id: "errors",
845-
name: "Errors",
846-
icon: BugIcon,
847-
activeIconColor: "text-errors",
848-
to: v3ErrorsPath(organization, project, environment),
849-
dataAction: "errors",
850-
},
851-
{
852-
id: "query",
853-
name: "Query",
854-
icon: CodeSquareIcon,
855-
activeIconColor: "text-query",
856-
to: queryPath(organization, project, environment),
857-
dataAction: "query",
858-
},
859-
{
860-
id: "queues",
861-
name: "Queues",
862-
icon: QueuesIcon,
863-
activeIconColor: "text-queues",
864-
to: v3QueuesPath(organization, project, environment),
865-
dataAction: "queues",
866-
},
867-
{
868-
id: "dashboards",
869-
name: "Dashboards",
870-
icon: ChartBarIcon,
871-
activeIconColor: "text-metrics",
872-
to: v3DashboardsLandingPath(organization, project, environment),
873-
dataAction: "dashboards-landing",
874-
action: (
875-
<CreateDashboardButton
876-
organization={organization}
877-
project={project}
878-
environment={environment}
879-
isCollapsed={isCollapsed}
880-
/>
881-
),
882-
after: (
883-
<DashboardList
884-
organization={organization}
885-
project={project}
886-
environment={environment}
887-
isCollapsed={isCollapsed}
888-
user={user}
889-
/>
890-
),
891-
},
843+
...(isAdmin || featureFlags.hasQueryAccess
844+
? [
845+
{
846+
id: "errors",
847+
name: "Errors",
848+
icon: BugIcon,
849+
activeIconColor: "text-errors",
850+
to: v3ErrorsPath(organization, project, environment),
851+
dataAction: "errors",
852+
},
853+
{
854+
id: "query",
855+
name: "Query",
856+
icon: CodeSquareIcon,
857+
activeIconColor: "text-query",
858+
to: queryPath(organization, project, environment),
859+
dataAction: "query",
860+
},
861+
{
862+
id: "queues",
863+
name: "Queues",
864+
icon: QueuesIcon,
865+
activeIconColor: "text-queues",
866+
to: v3QueuesPath(organization, project, environment),
867+
dataAction: "queues",
868+
},
869+
{
870+
id: "dashboards",
871+
name: "Dashboards",
872+
icon: ChartBarIcon,
873+
activeIconColor: "text-metrics",
874+
to: v3DashboardsLandingPath(organization, project, environment),
875+
dataAction: "dashboards-landing",
876+
action: (
877+
<CreateDashboardButton
878+
organization={organization}
879+
project={project}
880+
environment={environment}
881+
isCollapsed={isCollapsed}
882+
/>
883+
),
884+
after: (
885+
<DashboardList
886+
organization={organization}
887+
project={project}
888+
environment={environment}
889+
isCollapsed={isCollapsed}
890+
user={user}
891+
/>
892+
),
893+
},
894+
]
895+
: []),
892896
],
893897
});
894898
}

apps/webapp/app/env.server.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1925,6 +1925,10 @@ const EnvironmentSchema = z
19251925
.nonnegative()
19261926
.optional(),
19271927

1928+
// v2 is populated forward-only. Keep reads on v1 until v2 has enough history or has been
1929+
// backfilled, then opt in explicitly per deployment.
1930+
LOGS_SEARCH_TABLE_VERSION: z.enum(["v1", "v2"]).default("v1"),
1931+
19281932
// Logs list pagination tuning.
19291933
LOGS_LIST_DEFAULT_PAGE_SIZE: z.coerce.number().int().positive().default(50),
19301934
LOGS_LIST_MAX_PAGE_SIZE: z.coerce.number().int().positive().default(100),

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

Lines changed: 28 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,10 @@ import {
1515
convertDateToClickhouseDateTime,
1616
} from "~/v3/eventRepository/clickhouseEventRepository.server";
1717
import { ServiceValidationError } from "~/v3/services/baseService.server";
18+
import { escapeClickHouseLike, normalizeLogsSearchTerm } from "~/utils/logSearch";
1819

1920
export type { LogLevel };
2021

21-
function escapeClickHouseString(val: string): string {
22-
return val.replace(/\\/g, "\\\\").replace(/\//g, "\\/").replace(/%/g, "\\%").replace(/_/g, "\\_");
23-
}
24-
2522
export type LogsListOptions = {
2623
userId?: string;
2724
projectId: string;
@@ -236,15 +233,20 @@ export class LogsListPresenter extends BasePresenter {
236233
const now = new Date();
237234
const clampedTo = effectiveTo !== undefined ? (effectiveTo > now ? now : effectiveTo) : now;
238235

236+
const rawSearchTerm = search?.trim() ?? "";
237+
const normalizedSearchTerm =
238+
env.LOGS_SEARCH_TABLE_VERSION === "v2"
239+
? normalizeLogsSearchTerm(rawSearchTerm)
240+
: rawSearchTerm.toLocaleLowerCase();
239241
const searchTerm =
240-
search && search.trim() !== ""
241-
? escapeClickHouseString(search.trim()).toLowerCase()
242-
: undefined;
242+
normalizedSearchTerm === "" ? undefined : escapeClickHouseLike(normalizedSearchTerm);
243243

244244
// Run exactly one bounded query. Broadening a search window is an explicit user action;
245245
// silently rescanning the same recent rows makes absence queries needlessly expensive.
246246
const runQuery = () => {
247-
const queryBuilder = this.clickhouse.taskEventsSearch.logsListQueryBuilder();
247+
const queryBuilder = this.clickhouse.taskEventsSearch.logsListQueryBuilder(
248+
env.LOGS_SEARCH_TABLE_VERSION
249+
);
248250

249251
// The materialized view excludes events without a trace_id; this guards the legacy tail.
250252
queryBuilder.where("trace_id != ''");
@@ -274,13 +276,19 @@ export class LogsListPresenter extends BasePresenter {
274276
queryBuilder.where("run_id = {runId: String}", { runId });
275277
}
276278

277-
// search_text is normalized and indexed as one field. A single predicate is required for
278-
// ClickHouse to use the text index; the old message OR attributes predicate defeated both
279-
// skip indexes.
280279
if (searchTerm !== undefined) {
281-
queryBuilder.where("search_text LIKE {searchPattern: String}", {
282-
searchPattern: `%${searchTerm}%`,
283-
});
280+
if (env.LOGS_SEARCH_TABLE_VERSION === "v2") {
281+
// One predicate lets the text index answer substring searches without an OR across
282+
// independently indexed columns.
283+
queryBuilder.where("search_text LIKE {searchPattern: String}", {
284+
searchPattern: `%${searchTerm}%`,
285+
});
286+
} else {
287+
queryBuilder.where(
288+
"(lowerUTF8(message) LIKE {searchPattern: String} OR lowerUTF8(attributes_text) LIKE {searchPattern: String})",
289+
{ searchPattern: `%${searchTerm}%` }
290+
);
291+
}
284292
}
285293

286294
if (levels && levels.length > 0) {
@@ -331,20 +339,18 @@ export class LogsListPresenter extends BasePresenter {
331339
// Limit + 1 to check if there are more results
332340
queryBuilder.limit(effectivePageSize + 1);
333341

334-
return queryBuilder.executeWithStats();
342+
return queryBuilder.execute();
335343
};
336344

337345
const [queryError, queryResult] = await runQuery();
338346
if (queryError) {
339347
throw queryError;
340348
}
341349

342-
const records = queryResult?.rows ?? [];
343-
const partial =
344-
Number(queryResult?.stats.read_rows ?? 0) >= env.CLICKHOUSE_LOGS_LIST_MAX_ROWS_TO_READ ||
345-
Number(queryResult?.stats.elapsed_ns ?? 0) >=
346-
env.CLICKHOUSE_LOGS_LIST_MAX_EXECUTION_TIME * 1_000_000_000;
347-
const results = records;
350+
// ClickHouse's break overflow modes can return a short prefix without a reliable completion
351+
// marker. Keep the default throw behavior so the product never presents truncated results as
352+
// complete.
353+
const results = queryResult ?? [];
348354
const hasMore = results.length > effectivePageSize;
349355
const logs = results.slice(0, effectivePageSize);
350356

@@ -413,11 +419,10 @@ export class LogsListPresenter extends BasePresenter {
413419
},
414420
hasFilters,
415421
hasAnyLogs: transformedLogs.length > 0,
416-
partial,
417422
searchTerm: search,
418423
searchExpansion:
419424
searchTerm !== undefined && time.isDefault && transformedLogs.length === 0
420-
? { nextPeriod: "7d" as const }
425+
? { nextPeriod: `${Math.min(retentionLimitDays ?? 7, 7)}d` }
421426
: undefined,
422427
retention:
423428
retentionLimitDays !== undefined

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -427,26 +427,21 @@ function LogsList({
427427

428428
const expandSearch = useCallback(() => {
429429
const url = new URL(window.location.href);
430-
url.searchParams.set("period", "7d");
430+
url.searchParams.set("period", list.searchExpansion?.nextPeriod ?? "7d");
431431
url.searchParams.delete("cursor");
432432
url.searchParams.delete("log");
433433
navigate(`${url.pathname}?${url.searchParams.toString()}`);
434-
}, [navigate]);
434+
}, [list.searchExpansion?.nextPeriod, navigate]);
435435

436436
return (
437437
<div className="flex min-h-0 flex-1 flex-col">
438-
{list.partial && (
439-
<Callout variant="warning" className="m-2 mb-0">
440-
Showing partial results. Refine your search or narrow the time range.
441-
</Callout>
442-
)}
443438
{list.searchExpansion && (
444439
<Callout
445440
variant="info"
446441
className="m-2 mb-0"
447442
cta={
448443
<Button variant="tertiary/small" onClick={expandSearch}>
449-
Search last 7 days
444+
Search last {list.searchExpansion.nextPeriod.replace("d", " days")}
450445
</Button>
451446
}
452447
>

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,6 @@ import {
119119
v3RunRedirectPath,
120120
v3RunSpanPath,
121121
v3RunStreamingPath,
122-
v3LogsPath,
123122
v3RunsPath,
124123
} from "~/utils/pathBuilder";
125124
import type { SpanOverride } from "~/v3/eventRepository/eventRepository.types";
@@ -496,12 +495,6 @@ export default function Page() {
496495
/>
497496
{environment.type === "DEVELOPMENT" && <DevDisconnectedBanner isConnected={isConnected} />}
498497
<PageAccessories>
499-
<LinkButton
500-
to={`${v3LogsPath(organization, project, environment)}?runId=${encodeURIComponent(run.friendlyId)}`}
501-
variant="secondary/small"
502-
>
503-
Search this run's logs
504-
</LinkButton>
505498
<AdminDebugTooltip>
506499
<Property.Table>
507500
<Property.Item>

apps/webapp/app/routes/api.v1.logs.ts

Lines changed: 0 additions & 54 deletions
This file was deleted.

apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,5 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
8787
return json({
8888
logs: result.logs,
8989
pagination: result.pagination,
90-
partial: result.partial,
91-
searchExpansion: result.searchExpansion,
9290
});
9391
};
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { describe, expect, it } from "vitest";
2+
import { escapeClickHouseLike, normalizeLogsSearchTerm } from "./logSearch";
3+
4+
describe("log search normalization", () => {
5+
it("normalizes punctuation while preserving unicode, paths, and ids", () => {
6+
expect(
7+
normalizeLogsSearchTerm("TypeError: Zahlungsübersicht failed, retrying (/api/orders/42)")
8+
).toBe("typeerror: zahlungsübersicht failed retrying /api/orders/42");
9+
});
10+
11+
it("escapes LIKE wildcards without escaping path separators", () => {
12+
expect(escapeClickHouseLike("/api/a_b/100%")).toBe("/api/a\\_b/100\\%");
13+
});
14+
});

apps/webapp/app/utils/logSearch.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export function escapeClickHouseLike(value: string): string {
2+
return value.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_");
3+
}
4+
5+
// Must match the normalization in ClickHouse migration 038.
6+
export function normalizeLogsSearchTerm(value: string): string {
7+
return value
8+
.toLocaleLowerCase()
9+
.replace(/[^\p{L}\p{N}_./:@+-]+/gu, " ")
10+
.trim();
11+
}

0 commit comments

Comments
 (0)