@@ -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 } ;
@@ -67,7 +69,7 @@ export type LogsListAppliedFilters = LogsList["filters"];
6769
6870// Bump when the cursor shape changes so stale cursors are ignored (reset to the first page)
6971// rather than misparsed.
70- const LOG_CURSOR_VERSION = 3 ;
72+ const LOG_CURSOR_VERSION = 4 ;
7173
7274// Cursor is a base64 encoded JSON of the pagination keys
7375type LogCursor = {
@@ -77,6 +79,7 @@ type LogCursor = {
7779 triggeredTimestamp : string ; // DateTime64(9) string
7880 traceId : string ;
7981 spanId : string ;
82+ projectionFingerprint ?: string ;
8083} ;
8184
8285const LogCursorSchema = z . object ( {
@@ -86,6 +89,7 @@ const LogCursorSchema = z.object({
8689 triggeredTimestamp : z . string ( ) ,
8790 traceId : z . string ( ) ,
8891 spanId : z . string ( ) ,
92+ projectionFingerprint : z . string ( ) . optional ( ) ,
8993} ) ;
9094
9195function encodeCursor ( cursor : LogCursor ) : string {
@@ -223,6 +227,10 @@ export class LogsListPresenter extends BasePresenter {
223227 }
224228
225229 const effectivePageSize = Math . min ( pageSize , env . LOGS_LIST_MAX_PAGE_SIZE ) ;
230+ const usesV2Search = env . LOGS_SEARCH_TABLE_VERSION === "v2" ;
231+ const queryLimit = usesV2Search
232+ ? ( effectivePageSize + 1 ) * LOGS_SEARCH_RETRY_OVERFETCH_FACTOR
233+ : effectivePageSize + 1 ;
226234
227235 // Only honor a cursor scoped to this org+env; one copied from another scope would shift the
228236 // pagination anchor instead of resetting to the first page.
@@ -239,10 +247,9 @@ export class LogsListPresenter extends BasePresenter {
239247 const clampedTo = effectiveTo !== undefined ? ( effectiveTo > now ? now : effectiveTo ) : now ;
240248
241249 const rawSearchTerm = search ?. trim ( ) ?? "" ;
242- const normalizedSearchTerm =
243- env . LOGS_SEARCH_TABLE_VERSION === "v2"
244- ? normalizeLogsSearchTerm ( rawSearchTerm )
245- : rawSearchTerm . toLocaleLowerCase ( ) ;
250+ const normalizedSearchTerm = usesV2Search
251+ ? normalizeLogsSearchTerm ( rawSearchTerm )
252+ : rawSearchTerm . toLocaleLowerCase ( ) ;
246253 if ( rawSearchTerm !== "" && ! hasMinimumLogsSearchLength ( normalizedSearchTerm ) ) {
247254 throw new ServiceValidationError (
248255 `Log searches must be at least ${ MIN_LOGS_SEARCH_LENGTH } characters.`
@@ -287,7 +294,7 @@ export class LogsListPresenter extends BasePresenter {
287294 }
288295
289296 if ( searchTerm !== undefined ) {
290- if ( env . LOGS_SEARCH_TABLE_VERSION === "v2" ) {
297+ if ( usesV2Search ) {
291298 // One predicate lets the text index answer substring searches without an OR across
292299 // independently indexed columns.
293300 queryBuilder . where ( "search_text LIKE {searchPattern: String}" , {
@@ -328,26 +335,37 @@ export class LogsListPresenter extends BasePresenter {
328335 queryBuilder . whereOr ( conditions ) ;
329336 }
330337
331- // Keyset pagination over the full sort key. ORDER BY is DESC, so the next page is the rows
332- // that sort after the cursor (strictly less-than). (triggered_timestamp, trace_id) is not
333- // unique because spans of a trace share both, so span_id is the final tiebreaker; without
334- // it rows at a tie boundary could be skipped or duplicated across pages.
338+ // Keyset pagination over the sort key. ORDER BY is DESC, so the next page is the rows
339+ // that sort after the cursor (strictly less-than). V2 adds the projection identity as the
340+ // final tiebreaker so retry copies and distinct rows at a span boundary paginate safely.
335341 if ( decodedCursor ) {
342+ const cursorParams = {
343+ cursorTriggeredTimestamp : decodedCursor . triggeredTimestamp ,
344+ cursorTraceId : decodedCursor . traceId ,
345+ cursorSpanId : decodedCursor . spanId ,
346+ ...( usesV2Search && decodedCursor . projectionFingerprint
347+ ? { cursorProjectionFingerprint : decodedCursor . projectionFingerprint }
348+ : { } ) ,
349+ } ;
336350 queryBuilder . where (
337- `(triggered_timestamp < {cursorTriggeredTimestamp: String}
338- OR (triggered_timestamp = {cursorTriggeredTimestamp: String} AND trace_id < {cursorTraceId: String})
339- OR (triggered_timestamp = {cursorTriggeredTimestamp: String} AND trace_id = {cursorTraceId: String} AND span_id < {cursorSpanId: String}))` ,
340- {
341- cursorTriggeredTimestamp : decodedCursor . triggeredTimestamp ,
342- cursorTraceId : decodedCursor . traceId ,
343- cursorSpanId : decodedCursor . spanId ,
344- }
351+ usesV2Search && decodedCursor . projectionFingerprint
352+ ? `(triggered_timestamp < {cursorTriggeredTimestamp: String}
353+ OR (triggered_timestamp = {cursorTriggeredTimestamp: String} AND trace_id < {cursorTraceId: String})
354+ OR (triggered_timestamp = {cursorTriggeredTimestamp: String} AND trace_id = {cursorTraceId: String} AND span_id < {cursorSpanId: String})
355+ OR (triggered_timestamp = {cursorTriggeredTimestamp: String} AND trace_id = {cursorTraceId: String} AND span_id = {cursorSpanId: String} AND projection_fingerprint < {cursorProjectionFingerprint: UInt128}))`
356+ : `(triggered_timestamp < {cursorTriggeredTimestamp: String}
357+ OR (triggered_timestamp = {cursorTriggeredTimestamp: String} AND trace_id < {cursorTraceId: String})
358+ OR (triggered_timestamp = {cursorTriggeredTimestamp: String} AND trace_id = {cursorTraceId: String} AND span_id < {cursorSpanId: String}))` ,
359+ cursorParams
345360 ) ;
346361 }
347362
348- queryBuilder . orderBy ( "triggered_timestamp DESC, trace_id DESC, span_id DESC" ) ;
349- // Limit + 1 to check if there are more results
350- queryBuilder . limit ( effectivePageSize + 1 ) ;
363+ queryBuilder . orderBy (
364+ usesV2Search
365+ ? "triggered_timestamp DESC, trace_id DESC, span_id DESC, projection_fingerprint DESC"
366+ : "triggered_timestamp DESC, trace_id DESC, span_id DESC"
367+ ) ;
368+ queryBuilder . limit ( queryLimit ) ;
351369
352370 return queryBuilder . execute ( ) ;
353371 } ;
@@ -361,8 +379,14 @@ export class LogsListPresenter extends BasePresenter {
361379 // marker. Keep the default throw behavior so the product never presents truncated results as
362380 // complete.
363381 const results = queryResult ?? [ ] ;
364- const hasMore = results . length > effectivePageSize ;
365- const logs = results . slice ( 0 , effectivePageSize ) ;
382+ const page = usesV2Search
383+ ? prepareLogsSearchPage ( results , effectivePageSize , queryLimit )
384+ : {
385+ rows : results . slice ( 0 , effectivePageSize ) ,
386+ hasMore : results . length > effectivePageSize ,
387+ } ;
388+ const hasMore = page . hasMore ;
389+ const logs = page . rows ;
366390
367391 // Build next cursor from the last item
368392 let nextCursor : string | undefined ;
@@ -375,6 +399,7 @@ export class LogsListPresenter extends BasePresenter {
375399 triggeredTimestamp : lastLog . triggered_timestamp ,
376400 traceId : lastLog . trace_id ,
377401 spanId : lastLog . span_id ,
402+ projectionFingerprint : lastLog . projection_fingerprint_string ,
378403 } ) ;
379404 }
380405
0 commit comments