Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 43 additions & 44 deletions AGENTS.md

Large diffs are not rendered by default.

73 changes: 69 additions & 4 deletions src/commands/issue/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@ import type { SentryContext } from "../../context.js";
import { buildOrgAwareAliases } from "../../lib/alias.js";
import {
API_MAX_PER_PAGE,
buildIssueListCollapse,
findProjectsByPattern,
findProjectsBySlug,
getProject,
type IssueCollapseField,
type IssuesPage,
listIssuesAllPages,
listIssuesPaginated,
Expand Down Expand Up @@ -44,6 +46,7 @@ import {
import {
type IssueTableRow,
shouldAutoCompact,
willShowTrend,
writeIssueTable,
} from "../../lib/formatters/index.js";
import {
Expand Down Expand Up @@ -133,6 +136,48 @@ const USAGE_HINT = "sentry issue list <org>/<project>";
*/
const MAX_LIMIT = 1000;

/** Options returned by {@link buildListApiOptions}. */
type ListApiOptions = {
/** Fields to collapse (omit) from the API response for performance. */
collapse: IssueCollapseField[];
/** Stats period resolution — undefined when stats are collapsed. */
groupStatsPeriod: "" | "14d" | "24h" | "auto" | undefined;
};

/**
* Determine whether stats data should be collapsed (skipped) in the API request.
*
* Stats power the TREND sparkline column, which is only shown when:
* 1. Output is human (not `--json`) — JSON consumers don't render sparklines
* 2. Terminal is wide enough — narrow terminals and non-TTY hide TREND
*
* Collapsing stats avoids expensive Snuba/ClickHouse aggregation queries,
* saving 200-500ms per API request.
*
* @see {@link willShowTrend} for the terminal width threshold logic
*/
function shouldCollapseStats(json: boolean): boolean {
if (json) {
return true;
}
return !willShowTrend();
}

/**
* Build the collapse and groupStatsPeriod options for issue list API calls.
*
* When stats are collapsed, groupStatsPeriod is omitted (undefined) since
* the server won't compute stats anyway. This avoids wasted server-side
* processing and makes the request intent explicit.
*/
function buildListApiOptions(json: boolean): ListApiOptions {
const collapseStats = shouldCollapseStats(json);
return {
collapse: buildIssueListCollapse({ shouldCollapseStats: collapseStats }),
groupStatsPeriod: collapseStats ? undefined : "auto",
};
}

/**
* Resolve the effective compact mode from the flag tri-state and issue count.
*
Expand Down Expand Up @@ -502,13 +547,21 @@ async function fetchIssuesForTarget(
/** Resume from this cursor (Phase 2 redistribution or next-page resume). */
startCursor?: string;
onPage?: (fetched: number, limit: number) => void;
/** Pre-computed API performance options. @see {@link buildListApiOptions} */
collapse?: IssueCollapseField[];
/** Stats period resolution — undefined when stats are collapsed. */
groupStatsPeriod?: "" | "14d" | "24h" | "auto";
}
): Promise<FetchResult> {
const result = await withAuthGuard(async () => {
const { issues, nextCursor } = await listIssuesAllPages(
target.org,
target.project,
{ ...options, projectId: target.projectId, groupStatsPeriod: "auto" }
{
...options,
projectId: target.projectId,
groupStatsPeriod: options.groupStatsPeriod,
}
);
return { target, issues, hasMore: !!nextCursor, nextCursor };
});
Expand Down Expand Up @@ -578,6 +631,10 @@ type BudgetFetchOptions = {
statsPeriod?: string;
/** Per-target cursors from a previous page (compound cursor resume). */
startCursors?: Map<string, string>;
/** Pre-computed collapse fields for API performance. @see {@link buildListApiOptions} */
collapse?: IssueCollapseField[];
/** Stats period resolution — undefined when stats are collapsed. */
groupStatsPeriod?: "" | "14d" | "24h" | "auto";
};

/**
Expand Down Expand Up @@ -792,10 +849,12 @@ function nextPageHint(org: string, flags: ListFlags): string {
*/
async function fetchOrgAllIssues(
org: string,
flags: Pick<ListFlags, "query" | "limit" | "sort" | "period">,
flags: Pick<ListFlags, "query" | "limit" | "sort" | "period" | "json">,
cursor: string | undefined,
onPage?: (fetched: number, limit: number) => void
): Promise<IssuesPage> {
const apiOpts = buildListApiOptions(flags.json);

// When resuming with --cursor, fetch a single page so the cursor chain stays intact.
if (cursor) {
const perPage = Math.min(flags.limit, API_MAX_PER_PAGE);
Expand All @@ -805,7 +864,8 @@ async function fetchOrgAllIssues(
perPage,
sort: flags.sort,
statsPeriod: flags.period,
groupStatsPeriod: "auto",
groupStatsPeriod: apiOpts.groupStatsPeriod,
collapse: apiOpts.collapse,
});
return { issues: response.data, nextCursor: response.nextCursor };
}
Expand All @@ -816,7 +876,8 @@ async function fetchOrgAllIssues(
limit: flags.limit,
sort: flags.sort,
statsPeriod: flags.period,
groupStatsPeriod: "auto",
groupStatsPeriod: apiOpts.groupStatsPeriod,
collapse: apiOpts.collapse,
onPage,
});
return { issues, nextCursor };
Expand Down Expand Up @@ -1110,6 +1171,8 @@ async function handleResolvedTargets(
? `Fetching issues from ${targetCount} projects`
: "Fetching issues";

const apiOpts = buildListApiOptions(flags.json);

const { results, hasMore } = await withProgress(
{ message: `${baseMessage} (up to ${flags.limit})...`, json: flags.json },
(setMessage) =>
Expand All @@ -1121,6 +1184,8 @@ async function handleResolvedTargets(
sort: flags.sort,
statsPeriod: flags.period,
startCursors,
collapse: apiOpts.collapse,
groupStatsPeriod: apiOpts.groupStatsPeriod,
},
(fetched) => {
setMessage(
Expand Down
2 changes: 2 additions & 0 deletions src/lib/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,11 @@ export {
rawApiRequest,
} from "./api/infrastructure.js";
export {
buildIssueListCollapse,
getIssue,
getIssueByShortId,
getIssueInOrg,
type IssueCollapseField,
type IssueSort,
type IssuesPage,
listIssuesAllPages,
Expand Down
49 changes: 49 additions & 0 deletions src/lib/api/issues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,47 @@ export type IssueSort = NonNullable<
NonNullable<ListAnOrganizationSissuesData["query"]>["sort"]
>;

/**
* Collapse options for issue listing, derived from the @sentry/api SDK types.
* Each value tells the server to skip computing that data field, avoiding
* expensive Snuba/ClickHouse queries on the backend.
*
* - `'stats'` — time-series event counts (sparkline data)
* - `'lifetime'` — lifetime aggregate counts (count, userCount, firstSeen)
* - `'filtered'` — filtered aggregate counts
* - `'unhandled'` — unhandled event flag computation
* - `'base'` — base group fields (rarely useful to collapse)
*/
export type IssueCollapseField = NonNullable<
NonNullable<ListAnOrganizationSissuesData["query"]>["collapse"]
>[number];

/**
* Build the `collapse` parameter for issue list API calls.
*
* Always collapses fields the CLI never consumes in issue list:
* `filtered`, `lifetime`, `unhandled`. Conditionally collapses `stats`
* when sparklines won't be rendered (narrow terminal, non-TTY, or JSON).
*
* Matches the Sentry web UI's optimization: the initial page load sends
* `collapse=stats,unhandled` to skip expensive Snuba queries, fetching
* stats in a follow-up request only when needed.
*
* @param options - Context for determining what to collapse
* @param options.shouldCollapseStats - Whether stats data can be skipped
* (true when sparklines won't be shown: narrow terminal, non-TTY, --json)
* @returns Array of fields to collapse
*/
export function buildIssueListCollapse(options: {
shouldCollapseStats: boolean;
}): IssueCollapseField[] {
const collapse: IssueCollapseField[] = ["filtered", "lifetime", "unhandled"];
if (options.shouldCollapseStats) {
collapse.push("stats");
}
return collapse;
}

/**
* List issues for a project with pagination control.
*
Expand All @@ -59,6 +100,9 @@ export async function listIssuesPaginated(
projectId?: number;
/** Controls the time resolution of inline stats data. "auto" adapts to statsPeriod. */
groupStatsPeriod?: "" | "14d" | "24h" | "auto";
/** Fields to collapse (omit) from the response for performance.
* @see {@link buildIssueListCollapse} */
collapse?: IssueCollapseField[];
} = {}
): Promise<PaginatedResponse<SentryIssue[]>> {
// When we have a numeric project ID, use the `project` query param (Array<number>)
Expand Down Expand Up @@ -86,6 +130,7 @@ export async function listIssuesPaginated(
sort: options.sort,
statsPeriod: options.statsPeriod,
groupStatsPeriod: options.groupStatsPeriod,
collapse: options.collapse,
},
});

Expand Down Expand Up @@ -138,6 +183,9 @@ export async function listIssuesAllPages(
startCursor?: string;
/** Called after each page is fetched. Useful for progress indicators. */
onPage?: (fetched: number, limit: number) => void;
/** Fields to collapse (omit) from the response for performance.
* @see {@link buildIssueListCollapse} */
collapse?: IssueCollapseField[];
}
): Promise<IssuesPage> {
if (options.limit < 1) {
Expand All @@ -161,6 +209,7 @@ export async function listIssuesAllPages(
statsPeriod: options.statsPeriod,
projectId: options.projectId,
groupStatsPeriod: options.groupStatsPeriod,
collapse: options.collapse,
});

allResults.push(...response.data);
Expand Down
20 changes: 17 additions & 3 deletions src/lib/formatters/human.ts
Original file line number Diff line number Diff line change
Expand Up @@ -360,7 +360,22 @@ function computeAliasShorthand(shortId: string, projectAlias?: string): string {
// Issue Table Helpers

/** Minimum terminal width to show the TREND sparkline column. */
const TREND_MIN_TERM_WIDTH = 100;
export const TREND_MIN_TERM_WIDTH = 100;

/**
* Whether the TREND sparkline column will be rendered in the issue table.
*
* Returns `true` when the terminal is wide enough (≥ {@link TREND_MIN_TERM_WIDTH}).
* Non-TTY output defaults to 80 columns, which is below the threshold.
*
* Used by the issue list command to decide whether to request stats data
* from the API — when TREND won't be shown, stats can be collapsed to
* save 200-500ms per request.
*/
export function willShowTrend(): boolean {
const termWidth = process.stdout.columns || 80;
return termWidth >= TREND_MIN_TERM_WIDTH;
}

/** Lines per issue row in non-compact mode (2-line content + separator). */
const LINES_PER_DEFAULT_ROW = 3;
Expand Down Expand Up @@ -592,8 +607,7 @@ export function writeIssueTable(
options?: { compact?: boolean }
): void {
const compact = options?.compact ?? false;
const termWidth = process.stdout.columns || 80;
const showTrend = termWidth >= TREND_MIN_TERM_WIDTH;
const showTrend = willShowTrend();

const columns: Column<IssueTableRow>[] = [
// SHORT ID — primary identifier (+ alias), never shrink
Expand Down
Loading
Loading