@@ -114,13 +114,31 @@ export class Traces {
114
114
first : number | null ,
115
115
filter : TraceFilter ,
116
116
sort : GraphQLSchema . TracesSortInput | null ,
117
+ cursorStr : string | null ,
117
118
) {
119
+ function createCursor ( trace : Trace ) {
120
+ return Buffer . from (
121
+ JSON . stringify ( {
122
+ timestamp : trace . timestamp ,
123
+ traceId : trace . traceId ,
124
+ duration : sort ?. sort === 'DURATION' ? trace . duration : undefined ,
125
+ } satisfies z . TypeOf < typeof PaginatedTraceCursorModel > ) ,
126
+ ) . toString ( 'base64' ) ;
127
+ }
128
+
129
+ function parseCursor ( cursor : string ) {
130
+ const data = PaginatedTraceCursorModel . parse (
131
+ JSON . parse ( Buffer . from ( cursor , 'base64' ) . toString ( 'utf8' ) ) ,
132
+ ) ;
133
+ if ( sort ?. sort === 'DURATION' && ! data . duration ) {
134
+ throw new HiveError ( 'Invalid cursor provided.' ) ;
135
+ }
136
+ return data ;
137
+ }
138
+
118
139
await this . _guardViewerCanAccessTraces ( organizationId ) ;
119
- const limit = ( first ?? 10 ) + 1 ;
120
- const sqlConditions = buildTraceFilterSQLConditions ( filter ) ;
121
- const filterSQLFragment = sqlConditions . length
122
- ? sql `AND ${ sql . join ( sqlConditions , ' AND ' ) } `
123
- : sql `` ;
140
+ const limit = first ?? 50 ;
141
+ const cursor = cursorStr ? parseCursor ( cursorStr ) : null ;
124
142
125
143
// By default we order by timestamp DESC
126
144
// In case a custom sort is provided, we order by duration asc/desc or timestamp asc
@@ -130,6 +148,46 @@ export class Traces {
130
148
, "trace_id" DESC
131
149
` ;
132
150
151
+ let paginationSQLFragmentPart = sql `` ;
152
+
153
+ if ( cursor ) {
154
+ if ( sort ?. sort === 'DURATION' ) {
155
+ const operator = sort . direction === 'ASC' ? sql `>` : sql `<` ;
156
+ const durationStr = String ( cursor . duration ) ;
157
+ paginationSQLFragmentPart = sql `
158
+ AND (
159
+ "duration" ${ operator } ${ durationStr }
160
+ OR (
161
+ "duration" = ${ durationStr }
162
+ AND "timestamp" < ${ cursor . timestamp }
163
+ )
164
+ OR (
165
+ "duration" = ${ durationStr }
166
+ AND "timestamp" = ${ cursor . timestamp }
167
+ AND "trace_id" < ${ cursor . traceId }
168
+ )
169
+ )
170
+ ` ;
171
+ } /* TIMESTAMP */ else {
172
+ const operator = sort ?. direction === 'ASC' ? sql `>` : sql `<` ;
173
+ paginationSQLFragmentPart = sql `
174
+ AND (
175
+ (
176
+ "timestamp" = ${ cursor . timestamp }
177
+ AND "trace_id" < ${ cursor . traceId }
178
+ )
179
+ OR "timestamp" ${ operator } ${ cursor . timestamp }
180
+ )
181
+ ` ;
182
+ }
183
+ }
184
+
185
+ const sqlConditions = buildTraceFilterSQLConditions ( filter , false ) ;
186
+
187
+ const filterSQLFragment = sqlConditions . length
188
+ ? sql `AND ${ sql . join ( sqlConditions , ' AND ' ) } `
189
+ : sql `` ;
190
+
133
191
const tracesQuery = await this . clickHouse . query < unknown > ( {
134
192
query : sql `
135
193
SELECT
@@ -138,41 +196,30 @@ export class Traces {
138
196
"otel_traces_normalized"
139
197
WHERE
140
198
target_id = ${ targetId }
199
+ ${ paginationSQLFragmentPart }
141
200
${ filterSQLFragment }
142
201
ORDER BY
143
202
${ orderByFragment }
144
- LIMIT ${ sql . raw ( String ( limit ) ) }
203
+ LIMIT ${ sql . raw ( String ( limit + 1 ) ) }
145
204
` ,
146
205
queryId : 'traces' ,
147
206
timeout : 10_000 ,
148
207
} ) ;
149
208
150
- const traces = TraceListModel . parse ( tracesQuery . data ) ;
151
- let hasNext = false ;
152
-
153
- if ( traces . length == limit ) {
154
- hasNext = true ;
155
- ( traces as any ) . pop ( ) ;
156
- }
209
+ let traces = TraceListModel . parse ( tracesQuery . data ) ;
210
+ const hasNext = traces . length > limit ;
211
+ traces = traces . slice ( 0 , limit ) ;
157
212
158
213
return {
159
- edges : traces . map ( trace => {
160
- return {
161
- node : trace ,
162
- cursor : Buffer . from ( `${ trace . timestamp } |${ trace . traceId } ` ) . toString ( 'base64' ) ,
163
- } ;
164
- } ) ,
214
+ edges : traces . map ( trace => ( {
215
+ node : trace ,
216
+ cursor : createCursor ( trace ) ,
217
+ } ) ) ,
165
218
pageInfo : {
166
219
hasNextPage : hasNext ,
167
220
hasPreviousPage : false ,
168
- endCursor : traces . length
169
- ? Buffer . from (
170
- `${ traces [ traces . length - 1 ] . timestamp } |${ traces [ traces . length - 1 ] . traceId } ` ,
171
- ) . toString ( 'base64' )
172
- : '' ,
173
- startCursor : traces . length
174
- ? Buffer . from ( `${ traces [ 0 ] . timestamp } |${ traces [ 0 ] . traceId } ` ) . toString ( 'base64' )
175
- : '' ,
221
+ endCursor : traces . length ? createCursor ( traces [ traces . length - 1 ] ) : '' ,
222
+ startCursor : traces . length ? createCursor ( traces [ 0 ] ) : '' ,
176
223
} ,
177
224
} ;
178
225
}
@@ -325,7 +372,7 @@ type TraceFilter = {
325
372
httpUrls : ReadonlyArray < string > | null ;
326
373
} ;
327
374
328
- function buildTraceFilterSQLConditions ( filter : TraceFilter , skipPeriod = false ) {
375
+ function buildTraceFilterSQLConditions ( filter : TraceFilter , skipPeriod : boolean ) {
329
376
const ANDs : SqlValue [ ] = [ ] ;
330
377
331
378
if ( filter ?. period && ! skipPeriod ) {
@@ -512,3 +559,12 @@ function getBucketUnitAndCount(startDate: Date, endDate: Date): BucketResult {
512
559
513
560
return { unit, count } ;
514
561
}
562
+
563
+ /**
564
+ * All sortable fields (duration, timestamp), must be part of the cursor
565
+ */
566
+ const PaginatedTraceCursorModel = z . object ( {
567
+ traceId : z . string ( ) ,
568
+ timestamp : z . string ( ) ,
569
+ duration : z . number ( ) . optional ( ) ,
570
+ } ) ;
0 commit comments