@@ -18,8 +18,10 @@ import { ServiceValidationError } from "~/v3/services/baseService.server";
1818import {
1919 escapeClickHouseLike ,
2020 hasMinimumLogsSearchLength ,
21+ LOGS_SEARCH_RETRY_OVERFETCH_FACTOR ,
2122 MIN_LOGS_SEARCH_LENGTH ,
2223 normalizeLogsSearchTerm ,
24+ prepareLogsSearchPage ,
2325} from "~/utils/logSearch" ;
2426
2527export type { LogLevel } ;
@@ -66,7 +68,7 @@ export type LogEntry = LogsList["logs"][0];
6668
6769// Bump when the cursor shape changes so stale cursors are ignored (reset to the first page)
6870// rather than misparsed.
69- const LOG_CURSOR_VERSION = 3 ;
71+ const LOG_CURSOR_VERSION = 4 ;
7072
7173// Cursor is a base64 encoded JSON of the pagination keys
7274type LogCursor = {
@@ -76,6 +78,7 @@ type LogCursor = {
7678 triggeredTimestamp : string ; // DateTime64(9) string
7779 traceId : string ;
7880 spanId : string ;
81+ projectionFingerprint ?: string ;
7982} ;
8083
8184const LogCursorSchema = z . object ( {
@@ -85,6 +88,7 @@ const LogCursorSchema = z.object({
8588 triggeredTimestamp : z . string ( ) ,
8689 traceId : z . string ( ) ,
8790 spanId : z . string ( ) ,
91+ projectionFingerprint : z . string ( ) . optional ( ) ,
8892} ) ;
8993
9094function encodeCursor ( cursor : LogCursor ) : string {
@@ -222,6 +226,10 @@ export class LogsListPresenter extends BasePresenter {
222226 }
223227
224228 const effectivePageSize = Math . min ( pageSize , env . LOGS_LIST_MAX_PAGE_SIZE ) ;
229+ const usesV2Search = env . LOGS_SEARCH_TABLE_VERSION === "v2" ;
230+ const queryLimit = usesV2Search
231+ ? ( effectivePageSize + 1 ) * LOGS_SEARCH_RETRY_OVERFETCH_FACTOR
232+ : effectivePageSize + 1 ;
225233
226234 // Only honor a cursor scoped to this org+env; one copied from another scope would shift the
227235 // pagination anchor instead of resetting to the first page.
@@ -238,10 +246,9 @@ export class LogsListPresenter extends BasePresenter {
238246 const clampedTo = effectiveTo !== undefined ? ( effectiveTo > now ? now : effectiveTo ) : now ;
239247
240248 const rawSearchTerm = search ?. trim ( ) ?? "" ;
241- const normalizedSearchTerm =
242- env . LOGS_SEARCH_TABLE_VERSION === "v2"
243- ? normalizeLogsSearchTerm ( rawSearchTerm )
244- : rawSearchTerm . toLocaleLowerCase ( ) ;
249+ const normalizedSearchTerm = usesV2Search
250+ ? normalizeLogsSearchTerm ( rawSearchTerm )
251+ : rawSearchTerm . toLocaleLowerCase ( ) ;
245252 if ( rawSearchTerm !== "" && ! hasMinimumLogsSearchLength ( normalizedSearchTerm ) ) {
246253 throw new ServiceValidationError (
247254 `Log searches must be at least ${ MIN_LOGS_SEARCH_LENGTH } characters.`
@@ -286,7 +293,7 @@ export class LogsListPresenter extends BasePresenter {
286293 }
287294
288295 if ( searchTerm !== undefined ) {
289- if ( env . LOGS_SEARCH_TABLE_VERSION === "v2" ) {
296+ if ( usesV2Search ) {
290297 // One predicate lets the text index answer substring searches without an OR across
291298 // independently indexed columns.
292299 queryBuilder . where ( "search_text LIKE {searchPattern: String}" , {
@@ -327,26 +334,37 @@ export class LogsListPresenter extends BasePresenter {
327334 queryBuilder . whereOr ( conditions ) ;
328335 }
329336
330- // Keyset pagination over the full sort key. ORDER BY is DESC, so the next page is the rows
331- // that sort after the cursor (strictly less-than). (triggered_timestamp, trace_id) is not
332- // unique because spans of a trace share both, so span_id is the final tiebreaker; without
333- // it rows at a tie boundary could be skipped or duplicated across pages.
337+ // Keyset pagination over the sort key. ORDER BY is DESC, so the next page is the rows
338+ // that sort after the cursor (strictly less-than). V2 adds the projection identity as the
339+ // final tiebreaker so retry copies and distinct rows at a span boundary paginate safely.
334340 if ( decodedCursor ) {
341+ const cursorParams = {
342+ cursorTriggeredTimestamp : decodedCursor . triggeredTimestamp ,
343+ cursorTraceId : decodedCursor . traceId ,
344+ cursorSpanId : decodedCursor . spanId ,
345+ ...( usesV2Search && decodedCursor . projectionFingerprint
346+ ? { cursorProjectionFingerprint : decodedCursor . projectionFingerprint }
347+ : { } ) ,
348+ } ;
335349 queryBuilder . where (
336- `(triggered_timestamp < {cursorTriggeredTimestamp: String}
337- OR (triggered_timestamp = {cursorTriggeredTimestamp: String} AND trace_id < {cursorTraceId: String})
338- OR (triggered_timestamp = {cursorTriggeredTimestamp: String} AND trace_id = {cursorTraceId: String} AND span_id < {cursorSpanId: String}))` ,
339- {
340- cursorTriggeredTimestamp : decodedCursor . triggeredTimestamp ,
341- cursorTraceId : decodedCursor . traceId ,
342- cursorSpanId : decodedCursor . spanId ,
343- }
350+ usesV2Search && decodedCursor . projectionFingerprint
351+ ? `(triggered_timestamp < {cursorTriggeredTimestamp: String}
352+ OR (triggered_timestamp = {cursorTriggeredTimestamp: String} AND trace_id < {cursorTraceId: String})
353+ OR (triggered_timestamp = {cursorTriggeredTimestamp: String} AND trace_id = {cursorTraceId: String} AND span_id < {cursorSpanId: String})
354+ OR (triggered_timestamp = {cursorTriggeredTimestamp: String} AND trace_id = {cursorTraceId: String} AND span_id = {cursorSpanId: String} AND projection_fingerprint < {cursorProjectionFingerprint: UInt128}))`
355+ : `(triggered_timestamp < {cursorTriggeredTimestamp: String}
356+ OR (triggered_timestamp = {cursorTriggeredTimestamp: String} AND trace_id < {cursorTraceId: String})
357+ OR (triggered_timestamp = {cursorTriggeredTimestamp: String} AND trace_id = {cursorTraceId: String} AND span_id < {cursorSpanId: String}))` ,
358+ cursorParams
344359 ) ;
345360 }
346361
347- queryBuilder . orderBy ( "triggered_timestamp DESC, trace_id DESC, span_id DESC" ) ;
348- // Limit + 1 to check if there are more results
349- queryBuilder . limit ( effectivePageSize + 1 ) ;
362+ queryBuilder . orderBy (
363+ usesV2Search
364+ ? "triggered_timestamp DESC, trace_id DESC, span_id DESC, projection_fingerprint DESC"
365+ : "triggered_timestamp DESC, trace_id DESC, span_id DESC"
366+ ) ;
367+ queryBuilder . limit ( queryLimit ) ;
350368
351369 return queryBuilder . execute ( ) ;
352370 } ;
@@ -360,8 +378,14 @@ export class LogsListPresenter extends BasePresenter {
360378 // marker. Keep the default throw behavior so the product never presents truncated results as
361379 // complete.
362380 const results = queryResult ?? [ ] ;
363- const hasMore = results . length > effectivePageSize ;
364- const logs = results . slice ( 0 , effectivePageSize ) ;
381+ const page = usesV2Search
382+ ? prepareLogsSearchPage ( results , effectivePageSize , queryLimit )
383+ : {
384+ rows : results . slice ( 0 , effectivePageSize ) ,
385+ hasMore : results . length > effectivePageSize ,
386+ } ;
387+ const hasMore = page . hasMore ;
388+ const logs = page . rows ;
365389
366390 // Build next cursor from the last item
367391 let nextCursor : string | undefined ;
@@ -374,6 +398,7 @@ export class LogsListPresenter extends BasePresenter {
374398 triggeredTimestamp : lastLog . triggered_timestamp ,
375399 traceId : lastLog . trace_id ,
376400 spanId : lastLog . span_id ,
401+ projectionFingerprint : lastLog . projection_fingerprint_string ,
377402 } ) ;
378403 }
379404
0 commit comments