Skip to content

Commit 1794244

Browse files
committed
feat(span/list): add cursor pagination support
Add --cursor/-c flag for page-through pagination, matching the pattern used by trace list, issue list, and log list. - Wire up shared pagination infrastructure: buildPaginationContextKey, resolveOrgCursor, setPaginationCursor, clearPaginationCursor - Context key scoped to org/project/traceId + sort/query to prevent cursor collisions across different queries - Include nextCursor in JSON output envelope - Show '-c last' hint in footer when more pages available - Support 'sentry span list <trace-id> -c last' to resume The listSpans API already supported cursor — this just wires it up in the command layer.
1 parent 94014e7 commit 1794244

File tree

3 files changed

+170
-8
lines changed

3 files changed

+170
-8
lines changed

plugins/sentry-cli/skills/sentry-cli/SKILL.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -647,6 +647,7 @@ List spans in a trace
647647
- `-n, --limit <value> - Number of spans (<=1000) - (default: "25")`
648648
- `-q, --query <value> - Filter spans (e.g., "op:db", "duration:>100ms", "project:backend")`
649649
- `-s, --sort <value> - Sort order: date, duration - (default: "date")`
650+
- `-c, --cursor <value> - Pagination cursor (use "last" to continue from previous page)`
650651
- `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data`
651652
- `--json - Output as JSON`
652653
- `--fields <value> - Comma-separated fields to include in JSON output (dot.notation supported)`
@@ -844,6 +845,7 @@ List spans in a trace
844845
- `-n, --limit <value> - Number of spans (<=1000) - (default: "25")`
845846
- `-q, --query <value> - Filter spans (e.g., "op:db", "duration:>100ms", "project:backend")`
846847
- `-s, --sort <value> - Sort order: date, duration - (default: "date")`
848+
- `-c, --cursor <value> - Pagination cursor (use "last" to continue from previous page)`
847849
- `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data`
848850
- `--json - Output as JSON`
849851
- `--fields <value> - Comma-separated fields to include in JSON output (dot.notation supported)`

src/commands/span/list.ts

Lines changed: 72 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ import {
1313
validateLimit,
1414
} from "../../lib/arg-parsing.js";
1515
import { buildCommand } from "../../lib/command.js";
16+
import {
17+
buildPaginationContextKey,
18+
clearPaginationCursor,
19+
resolveOrgCursor,
20+
setPaginationCursor,
21+
} from "../../lib/db/pagination.js";
1622
import { ContextError, ValidationError } from "../../lib/errors.js";
1723
import {
1824
type FlatSpan,
@@ -27,6 +33,7 @@ import {
2733
applyFreshFlag,
2834
FRESH_ALIASES,
2935
FRESH_FLAG,
36+
LIST_CURSOR_FLAG,
3037
} from "../../lib/list-command.js";
3138
import { logger } from "../../lib/logger.js";
3239
import {
@@ -39,6 +46,7 @@ type ListFlags = {
3946
readonly limit: number;
4047
readonly query?: string;
4148
readonly sort: SpanSortValue;
49+
readonly cursor?: string;
4250
readonly json: boolean;
4351
readonly fresh: boolean;
4452
readonly fields?: string[];
@@ -61,6 +69,9 @@ const DEFAULT_LIMIT = 25;
6169
/** Default sort order for span results */
6270
const DEFAULT_SORT: SpanSortValue = "date";
6371

72+
/** Pagination storage key for cursor resume */
73+
export const PAGINATION_KEY = "span-list";
74+
6475
/** Usage hint for ContextError messages */
6576
const USAGE_HINT = "sentry span list [<org>/<project>/]<trace-id>";
6677

@@ -111,7 +122,7 @@ export function parsePositionalArgs(args: string[]): {
111122
* Parse --limit flag, delegating range validation to shared utility.
112123
*/
113124
function parseLimit(value: string): number {
114-
return validateLimit(value, 1, MAX_LIMIT); // min=1 is validateLimit's default, explicit for clarity
125+
return validateLimit(value, 1, MAX_LIMIT);
115126
}
116127

117128
/**
@@ -128,6 +139,24 @@ export function parseSort(value: string): SpanSortValue {
128139
return value as SpanSortValue;
129140
}
130141

142+
/** Build the CLI hint for fetching the next page, preserving active flags. */
143+
function nextPageHint(
144+
org: string,
145+
project: string,
146+
traceId: string,
147+
flags: Pick<ListFlags, "sort" | "query">
148+
): string {
149+
const base = `sentry span list ${org}/${project}/${traceId} -c last`;
150+
const parts: string[] = [];
151+
if (flags.sort !== DEFAULT_SORT) {
152+
parts.push(`--sort ${flags.sort}`);
153+
}
154+
if (flags.query) {
155+
parts.push(`-q "${flags.query}"`);
156+
}
157+
return parts.length > 0 ? `${base} ${parts.join(" ")}` : base;
158+
}
159+
131160
// ---------------------------------------------------------------------------
132161
// Output config types and formatters
133162
// ---------------------------------------------------------------------------
@@ -138,6 +167,8 @@ type SpanListData = {
138167
flatSpans: FlatSpan[];
139168
/** Whether more results are available beyond the limit */
140169
hasMore: boolean;
170+
/** Opaque cursor for fetching the next page (null/undefined when no more) */
171+
nextCursor?: string | null;
141172
/** The trace ID being queried */
142173
traceId: string;
143174
};
@@ -161,15 +192,26 @@ function formatSpanListHuman(data: SpanListData): string {
161192
/**
162193
* Transform span list data for JSON output.
163194
*
164-
* Produces a `{ data: [...], hasMore }` envelope matching the standard
165-
* paginated list format. Applies `--fields` filtering per element.
195+
* Produces a `{ data: [...], hasMore, nextCursor? }` envelope matching the
196+
* standard paginated list format. Applies `--fields` filtering per element.
166197
*/
167198
function jsonTransformSpanList(data: SpanListData, fields?: string[]): unknown {
168199
const items =
169200
fields && fields.length > 0
170201
? data.flatSpans.map((item) => filterFields(item, fields))
171202
: data.flatSpans;
172-
return { data: items, hasMore: data.hasMore };
203+
const envelope: Record<string, unknown> = {
204+
data: items,
205+
hasMore: data.hasMore,
206+
};
207+
if (
208+
data.nextCursor !== null &&
209+
data.nextCursor !== undefined &&
210+
data.nextCursor !== ""
211+
) {
212+
envelope.nextCursor = data.nextCursor;
213+
}
214+
return envelope;
173215
}
174216

175217
export const listCommand = buildCommand({
@@ -182,6 +224,8 @@ export const listCommand = buildCommand({
182224
" sentry span list <org>/<project>/<trace-id> # explicit org and project\n" +
183225
" sentry span list <project> <trace-id> # find project across all orgs\n\n" +
184226
"The trace ID is the 32-character hexadecimal identifier.\n\n" +
227+
"Pagination:\n" +
228+
" sentry span list <trace-id> -c last # fetch next page\n\n" +
185229
"Examples:\n" +
186230
" sentry span list <trace-id> # List spans in trace\n" +
187231
" sentry span list <trace-id> --limit 50 # Show more spans\n" +
@@ -223,13 +267,15 @@ export const listCommand = buildCommand({
223267
brief: `Sort order: ${VALID_SORT_VALUES.join(", ")}`,
224268
default: DEFAULT_SORT,
225269
},
270+
cursor: LIST_CURSOR_FLAG,
226271
fresh: FRESH_FLAG,
227272
},
228273
aliases: {
229274
...FRESH_ALIASES,
230275
n: "limit",
231276
q: "query",
232277
s: "sort",
278+
c: "cursor",
233279
},
234280
},
235281
async *func(this: SentryContext, flags: ListFlags, ...args: string[]) {
@@ -288,6 +334,14 @@ export const listCommand = buildCommand({
288334
}
289335
const apiQuery = queryParts.join(" ");
290336

337+
// Build context key and resolve cursor for pagination
338+
const contextKey = buildPaginationContextKey(
339+
"span",
340+
`${target.org}/${target.project}/${traceId}`,
341+
{ sort: flags.sort, q: flags.query }
342+
);
343+
const cursor = resolveOrgCursor(flags.cursor, PAGINATION_KEY, contextKey);
344+
291345
// Fetch spans from EAP endpoint
292346
const { data: spanItems, nextCursor } = await listSpans(
293347
target.org,
@@ -296,22 +350,32 @@ export const listCommand = buildCommand({
296350
query: apiQuery,
297351
sort: flags.sort,
298352
limit: flags.limit,
353+
cursor,
299354
}
300355
);
301356

357+
// Store or clear pagination cursor
358+
if (nextCursor) {
359+
setPaginationCursor(PAGINATION_KEY, contextKey, nextCursor);
360+
} else {
361+
clearPaginationCursor(PAGINATION_KEY, contextKey);
362+
}
363+
302364
const flatSpans = spanItems.map(spanListItemToFlatSpan);
303-
const hasMore = nextCursor !== undefined;
365+
const hasMore = !!nextCursor;
304366

305367
// Build hint footer
306368
let hint: string | undefined;
307-
if (flatSpans.length > 0) {
369+
if (flatSpans.length === 0 && hasMore) {
370+
hint = `Try the next page: ${nextPageHint(target.org, target.project, traceId, flags)}`;
371+
} else if (flatSpans.length > 0) {
308372
const countText = `Showing ${flatSpans.length} span${flatSpans.length === 1 ? "" : "s"}.`;
309373
hint = hasMore
310-
? `${countText} Use --limit to see more.`
374+
? `${countText} Next page: ${nextPageHint(target.org, target.project, traceId, flags)}`
311375
: `${countText} Use 'sentry span view ${traceId} <span-id>' to view span details.`;
312376
}
313377

314-
yield new CommandOutput({ flatSpans, hasMore, traceId });
378+
yield new CommandOutput({ flatSpans, hasMore, nextCursor, traceId });
315379
return { hint };
316380
},
317381
});

test/commands/span/list.test.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,4 +261,100 @@ describe("listCommand.func", () => {
261261
// Should NOT have called resolveOrgAndProject
262262
expect(resolveOrgAndProjectSpy).not.toHaveBeenCalled();
263263
});
264+
265+
test("passes cursor to API when --cursor is set", async () => {
266+
listSpansSpy.mockResolvedValue({
267+
data: [
268+
{
269+
id: "a1b2c3d4e5f67890",
270+
timestamp: "2024-01-15T10:30:00+00:00",
271+
project: "test-project",
272+
trace: VALID_TRACE_ID,
273+
},
274+
],
275+
nextCursor: undefined,
276+
});
277+
278+
const { context } = createContext();
279+
280+
await func.call(
281+
context,
282+
{
283+
limit: 25,
284+
sort: "date",
285+
cursor: "1735689600:0:0",
286+
fresh: false,
287+
},
288+
VALID_TRACE_ID
289+
);
290+
291+
expect(listSpansSpy).toHaveBeenCalledWith(
292+
"test-org",
293+
"test-project",
294+
expect.objectContaining({
295+
cursor: "1735689600:0:0",
296+
})
297+
);
298+
});
299+
300+
test("includes nextCursor in JSON output when hasMore", async () => {
301+
listSpansSpy.mockResolvedValue({
302+
data: [
303+
{
304+
id: "a1b2c3d4e5f67890",
305+
timestamp: "2024-01-15T10:30:00+00:00",
306+
project: "test-project",
307+
trace: VALID_TRACE_ID,
308+
},
309+
],
310+
nextCursor: "1735689600:0:1",
311+
});
312+
313+
const { context, getStdout } = createContext();
314+
315+
await func.call(
316+
context,
317+
{
318+
limit: 1,
319+
sort: "date",
320+
json: true,
321+
fresh: false,
322+
},
323+
VALID_TRACE_ID
324+
);
325+
326+
const output = getStdout();
327+
const parsed = JSON.parse(output);
328+
expect(parsed.hasMore).toBe(true);
329+
expect(parsed.nextCursor).toBe("1735689600:0:1");
330+
});
331+
332+
test("hint shows -c last when more pages available", async () => {
333+
listSpansSpy.mockResolvedValue({
334+
data: [
335+
{
336+
id: "a1b2c3d4e5f67890",
337+
timestamp: "2024-01-15T10:30:00+00:00",
338+
project: "test-project",
339+
trace: VALID_TRACE_ID,
340+
},
341+
],
342+
nextCursor: "1735689600:0:1",
343+
});
344+
345+
const { context, getStdout } = createContext();
346+
347+
await func.call(
348+
context,
349+
{
350+
limit: 1,
351+
sort: "date",
352+
fresh: false,
353+
},
354+
VALID_TRACE_ID
355+
);
356+
357+
const output = getStdout();
358+
expect(output).toContain("-c last");
359+
});
264360
});

0 commit comments

Comments
 (0)