Skip to content

Commit 4da341b

Browse files
committed
perf(issue-list): use collapse parameter to skip unused Snuba queries
The Sentry web UI optimizes issue list loading by sending collapse parameters to /organizations/{org}/issues/, telling the server to skip computing data the client won't use. This avoids expensive Snuba/ClickHouse queries and saves 200-500ms per API request. The CLI's issue list command never consumes filtered, lifetime, or unhandled fields — these are always collapsed. The stats field (sparkline data for the TREND column) is conditionally collapsed when sparklines won't be rendered: JSON mode, narrow terminals (<100 cols), and non-TTY output. When stats are collapsed, groupStatsPeriod is also omitted since the server won't compute stats anyway. Changes: - Add IssueCollapseField type and buildIssueListCollapse() to API layer - Add collapse parameter to listIssuesPaginated/listIssuesAllPages - Add willShowTrend() helper to formatters (avoids process.stdout in commands) - Thread collapse through fetchIssuesForTarget, fetchWithBudget, fetchOrgAllIssues - Property tests for buildIssueListCollapse invariants - Integration tests verifying collapse reaches the API
1 parent b590472 commit 4da341b

File tree

7 files changed

+392
-49
lines changed

7 files changed

+392
-49
lines changed

AGENTS.md

Lines changed: 43 additions & 44 deletions
Large diffs are not rendered by default.

src/commands/issue/list.ts

Lines changed: 69 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@ import type { SentryContext } from "../../context.js";
99
import { buildOrgAwareAliases } from "../../lib/alias.js";
1010
import {
1111
API_MAX_PER_PAGE,
12+
buildIssueListCollapse,
1213
findProjectsByPattern,
1314
findProjectsBySlug,
1415
getProject,
16+
type IssueCollapseField,
1517
type IssuesPage,
1618
listIssuesAllPages,
1719
listIssuesPaginated,
@@ -44,6 +46,7 @@ import {
4446
import {
4547
type IssueTableRow,
4648
shouldAutoCompact,
49+
willShowTrend,
4750
writeIssueTable,
4851
} from "../../lib/formatters/index.js";
4952
import {
@@ -133,6 +136,48 @@ const USAGE_HINT = "sentry issue list <org>/<project>";
133136
*/
134137
const MAX_LIMIT = 1000;
135138

139+
/** Options returned by {@link buildListApiOptions}. */
140+
type ListApiOptions = {
141+
/** Fields to collapse (omit) from the API response for performance. */
142+
collapse: IssueCollapseField[];
143+
/** Stats period resolution — undefined when stats are collapsed. */
144+
groupStatsPeriod: "" | "14d" | "24h" | "auto" | undefined;
145+
};
146+
147+
/**
148+
* Determine whether stats data should be collapsed (skipped) in the API request.
149+
*
150+
* Stats power the TREND sparkline column, which is only shown when:
151+
* 1. Output is human (not `--json`) — JSON consumers don't render sparklines
152+
* 2. Terminal is wide enough — narrow terminals and non-TTY hide TREND
153+
*
154+
* Collapsing stats avoids expensive Snuba/ClickHouse aggregation queries,
155+
* saving 200-500ms per API request.
156+
*
157+
* @see {@link willShowTrend} for the terminal width threshold logic
158+
*/
159+
function shouldCollapseStats(json: boolean): boolean {
160+
if (json) {
161+
return true;
162+
}
163+
return !willShowTrend();
164+
}
165+
166+
/**
167+
* Build the collapse and groupStatsPeriod options for issue list API calls.
168+
*
169+
* When stats are collapsed, groupStatsPeriod is omitted (undefined) since
170+
* the server won't compute stats anyway. This avoids wasted server-side
171+
* processing and makes the request intent explicit.
172+
*/
173+
function buildListApiOptions(json: boolean): ListApiOptions {
174+
const collapseStats = shouldCollapseStats(json);
175+
return {
176+
collapse: buildIssueListCollapse({ shouldCollapseStats: collapseStats }),
177+
groupStatsPeriod: collapseStats ? undefined : "auto",
178+
};
179+
}
180+
136181
/**
137182
* Resolve the effective compact mode from the flag tri-state and issue count.
138183
*
@@ -502,13 +547,21 @@ async function fetchIssuesForTarget(
502547
/** Resume from this cursor (Phase 2 redistribution or next-page resume). */
503548
startCursor?: string;
504549
onPage?: (fetched: number, limit: number) => void;
550+
/** Pre-computed API performance options. @see {@link buildListApiOptions} */
551+
collapse?: IssueCollapseField[];
552+
/** Stats period resolution — undefined when stats are collapsed. */
553+
groupStatsPeriod?: "" | "14d" | "24h" | "auto";
505554
}
506555
): Promise<FetchResult> {
507556
const result = await withAuthGuard(async () => {
508557
const { issues, nextCursor } = await listIssuesAllPages(
509558
target.org,
510559
target.project,
511-
{ ...options, projectId: target.projectId, groupStatsPeriod: "auto" }
560+
{
561+
...options,
562+
projectId: target.projectId,
563+
groupStatsPeriod: options.groupStatsPeriod ?? "auto",
564+
}
512565
);
513566
return { target, issues, hasMore: !!nextCursor, nextCursor };
514567
});
@@ -578,6 +631,10 @@ type BudgetFetchOptions = {
578631
statsPeriod?: string;
579632
/** Per-target cursors from a previous page (compound cursor resume). */
580633
startCursors?: Map<string, string>;
634+
/** Pre-computed collapse fields for API performance. @see {@link buildListApiOptions} */
635+
collapse?: IssueCollapseField[];
636+
/** Stats period resolution — undefined when stats are collapsed. */
637+
groupStatsPeriod?: "" | "14d" | "24h" | "auto";
581638
};
582639

583640
/**
@@ -792,10 +849,12 @@ function nextPageHint(org: string, flags: ListFlags): string {
792849
*/
793850
async function fetchOrgAllIssues(
794851
org: string,
795-
flags: Pick<ListFlags, "query" | "limit" | "sort" | "period">,
852+
flags: Pick<ListFlags, "query" | "limit" | "sort" | "period" | "json">,
796853
cursor: string | undefined,
797854
onPage?: (fetched: number, limit: number) => void
798855
): Promise<IssuesPage> {
856+
const apiOpts = buildListApiOptions(flags.json);
857+
799858
// When resuming with --cursor, fetch a single page so the cursor chain stays intact.
800859
if (cursor) {
801860
const perPage = Math.min(flags.limit, API_MAX_PER_PAGE);
@@ -805,7 +864,8 @@ async function fetchOrgAllIssues(
805864
perPage,
806865
sort: flags.sort,
807866
statsPeriod: flags.period,
808-
groupStatsPeriod: "auto",
867+
groupStatsPeriod: apiOpts.groupStatsPeriod,
868+
collapse: apiOpts.collapse,
809869
});
810870
return { issues: response.data, nextCursor: response.nextCursor };
811871
}
@@ -816,7 +876,8 @@ async function fetchOrgAllIssues(
816876
limit: flags.limit,
817877
sort: flags.sort,
818878
statsPeriod: flags.period,
819-
groupStatsPeriod: "auto",
879+
groupStatsPeriod: apiOpts.groupStatsPeriod,
880+
collapse: apiOpts.collapse,
820881
onPage,
821882
});
822883
return { issues, nextCursor };
@@ -1110,6 +1171,8 @@ async function handleResolvedTargets(
11101171
? `Fetching issues from ${targetCount} projects`
11111172
: "Fetching issues";
11121173

1174+
const apiOpts = buildListApiOptions(flags.json);
1175+
11131176
const { results, hasMore } = await withProgress(
11141177
{ message: `${baseMessage} (up to ${flags.limit})...`, json: flags.json },
11151178
(setMessage) =>
@@ -1121,6 +1184,8 @@ async function handleResolvedTargets(
11211184
sort: flags.sort,
11221185
statsPeriod: flags.period,
11231186
startCursors,
1187+
collapse: apiOpts.collapse,
1188+
groupStatsPeriod: apiOpts.groupStatsPeriod,
11241189
},
11251190
(fetched) => {
11261191
setMessage(

src/lib/api-client.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,11 @@ export {
4444
rawApiRequest,
4545
} from "./api/infrastructure.js";
4646
export {
47+
buildIssueListCollapse,
4748
getIssue,
4849
getIssueByShortId,
4950
getIssueInOrg,
51+
type IssueCollapseField,
5052
type IssueSort,
5153
type IssuesPage,
5254
listIssuesAllPages,

src/lib/api/issues.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,47 @@ export type IssueSort = NonNullable<
3333
NonNullable<ListAnOrganizationSissuesData["query"]>["sort"]
3434
>;
3535

36+
/**
37+
* Collapse options for issue listing, derived from the @sentry/api SDK types.
38+
* Each value tells the server to skip computing that data field, avoiding
39+
* expensive Snuba/ClickHouse queries on the backend.
40+
*
41+
* - `'stats'` — time-series event counts (sparkline data)
42+
* - `'lifetime'` — lifetime aggregate counts (count, userCount, firstSeen)
43+
* - `'filtered'` — filtered aggregate counts
44+
* - `'unhandled'` — unhandled event flag computation
45+
* - `'base'` — base group fields (rarely useful to collapse)
46+
*/
47+
export type IssueCollapseField = NonNullable<
48+
NonNullable<ListAnOrganizationSissuesData["query"]>["collapse"]
49+
>[number];
50+
51+
/**
52+
* Build the `collapse` parameter for issue list API calls.
53+
*
54+
* Always collapses fields the CLI never consumes in issue list:
55+
* `filtered`, `lifetime`, `unhandled`. Conditionally collapses `stats`
56+
* when sparklines won't be rendered (narrow terminal, non-TTY, or JSON).
57+
*
58+
* Matches the Sentry web UI's optimization: the initial page load sends
59+
* `collapse=stats,unhandled` to skip expensive Snuba queries, fetching
60+
* stats in a follow-up request only when needed.
61+
*
62+
* @param options - Context for determining what to collapse
63+
* @param options.shouldCollapseStats - Whether stats data can be skipped
64+
* (true when sparklines won't be shown: narrow terminal, non-TTY, --json)
65+
* @returns Array of fields to collapse
66+
*/
67+
export function buildIssueListCollapse(options: {
68+
shouldCollapseStats: boolean;
69+
}): IssueCollapseField[] {
70+
const collapse: IssueCollapseField[] = ["filtered", "lifetime", "unhandled"];
71+
if (options.shouldCollapseStats) {
72+
collapse.push("stats");
73+
}
74+
return collapse;
75+
}
76+
3677
/**
3778
* List issues for a project with pagination control.
3879
*
@@ -59,6 +100,9 @@ export async function listIssuesPaginated(
59100
projectId?: number;
60101
/** Controls the time resolution of inline stats data. "auto" adapts to statsPeriod. */
61102
groupStatsPeriod?: "" | "14d" | "24h" | "auto";
103+
/** Fields to collapse (omit) from the response for performance.
104+
* @see {@link buildIssueListCollapse} */
105+
collapse?: IssueCollapseField[];
62106
} = {}
63107
): Promise<PaginatedResponse<SentryIssue[]>> {
64108
// When we have a numeric project ID, use the `project` query param (Array<number>)
@@ -86,6 +130,7 @@ export async function listIssuesPaginated(
86130
sort: options.sort,
87131
statsPeriod: options.statsPeriod,
88132
groupStatsPeriod: options.groupStatsPeriod,
133+
collapse: options.collapse,
89134
},
90135
});
91136

@@ -138,6 +183,9 @@ export async function listIssuesAllPages(
138183
startCursor?: string;
139184
/** Called after each page is fetched. Useful for progress indicators. */
140185
onPage?: (fetched: number, limit: number) => void;
186+
/** Fields to collapse (omit) from the response for performance.
187+
* @see {@link buildIssueListCollapse} */
188+
collapse?: IssueCollapseField[];
141189
}
142190
): Promise<IssuesPage> {
143191
if (options.limit < 1) {
@@ -161,6 +209,7 @@ export async function listIssuesAllPages(
161209
statsPeriod: options.statsPeriod,
162210
projectId: options.projectId,
163211
groupStatsPeriod: options.groupStatsPeriod,
212+
collapse: options.collapse,
164213
});
165214

166215
allResults.push(...response.data);

src/lib/formatters/human.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -360,7 +360,22 @@ function computeAliasShorthand(shortId: string, projectAlias?: string): string {
360360
// Issue Table Helpers
361361

362362
/** Minimum terminal width to show the TREND sparkline column. */
363-
const TREND_MIN_TERM_WIDTH = 100;
363+
export const TREND_MIN_TERM_WIDTH = 100;
364+
365+
/**
366+
* Whether the TREND sparkline column will be rendered in the issue table.
367+
*
368+
* Returns `true` when the terminal is wide enough (≥ {@link TREND_MIN_TERM_WIDTH}).
369+
* Non-TTY output defaults to 80 columns, which is below the threshold.
370+
*
371+
* Used by the issue list command to decide whether to request stats data
372+
* from the API — when TREND won't be shown, stats can be collapsed to
373+
* save 200-500ms per request.
374+
*/
375+
export function willShowTrend(): boolean {
376+
const termWidth = process.stdout.columns || 80;
377+
return termWidth >= TREND_MIN_TERM_WIDTH;
378+
}
364379

365380
/** Lines per issue row in non-compact mode (2-line content + separator). */
366381
const LINES_PER_DEFAULT_ROW = 3;

0 commit comments

Comments
 (0)