Skip to content

Commit a87f1e0

Browse files
committed
fix(issue): conditionally collapse lifetime to preserve count/userCount/firstSeen/lastSeen (#969)
`collapse=lifetime` on the issues list endpoint strips top-level `count`, `userCount`, `firstSeen`, and `lastSeen` fields from the response. The CLI was always sending this collapse parameter, causing `--json --fields count,firstSeen,...` to return empty values and the human table to show `EVENTS: ?`, `SEEN: —`, `USERS: 0`. Now `lifetime` is only collapsed in JSON mode when explicit `--fields` are provided and none of them are lifetime-dependent. Human output and JSON without `--fields` always get the full data. Closes #969
1 parent d6d69e3 commit a87f1e0

4 files changed

Lines changed: 261 additions & 47 deletions

File tree

src/commands/issue/list.ts

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -175,17 +175,46 @@ function shouldCollapseStats(json: boolean): boolean {
175175
return !willShowTrend();
176176
}
177177

178+
/**
179+
* Fields that depend on the `lifetime` API data. When `collapse=lifetime`
180+
* is sent, the server omits these from the list response. See #969.
181+
*/
182+
const LIFETIME_FIELDS = new Set([
183+
"count",
184+
"userCount",
185+
"firstSeen",
186+
"lastSeen",
187+
]);
188+
178189
/**
179190
* Build the collapse and groupStatsPeriod options for issue list API calls.
180191
*
181192
* When stats are collapsed, groupStatsPeriod is omitted (undefined) since
182193
* the server won't compute stats anyway. This avoids wasted server-side
183194
* processing and makes the request intent explicit.
195+
*
196+
* Lifetime is only collapsed in JSON mode when explicit `--fields` are
197+
* provided and none of them are lifetime-dependent (`count`, `userCount`,
198+
* `firstSeen`, `lastSeen`). Human output always needs these for the
199+
* EVENTS, USERS, SEEN, and AGE columns.
184200
*/
185-
function buildListApiOptions(json: boolean): ListApiOptions {
201+
function buildListApiOptions(json: boolean, fields?: string[]): ListApiOptions {
186202
const collapseStats = shouldCollapseStats(json);
203+
// Collapse lifetime only when in JSON mode with explicit --fields that
204+
// don't include any lifetime-dependent field. Human output always needs
205+
// these (EVENTS, USERS, SEEN, AGE columns), and JSON without --fields
206+
// returns all fields.
207+
const collapseLifetime =
208+
json &&
209+
fields !== null &&
210+
fields !== undefined &&
211+
fields.length > 0 &&
212+
!fields.some((f) => LIFETIME_FIELDS.has(f));
187213
return {
188-
collapse: buildIssueListCollapse({ shouldCollapseStats: collapseStats }),
214+
collapse: buildIssueListCollapse({
215+
shouldCollapseStats: collapseStats,
216+
shouldCollapseLifetime: collapseLifetime,
217+
}),
189218
groupStatsPeriod: collapseStats ? undefined : "auto",
190219
};
191220
}
@@ -870,14 +899,14 @@ function prevPageHint(org: string, flags: ListFlags): string {
870899
*/
871900
async function fetchOrgAllIssues(
872901
org: string,
873-
flags: Pick<ListFlags, "query" | "limit" | "sort" | "json">,
902+
flags: Pick<ListFlags, "query" | "limit" | "sort" | "json" | "fields">,
874903
timeRange: TimeRange,
875904
options: {
876905
cursor?: string;
877906
onPage?: (fetched: number, limit: number) => void;
878907
}
879908
): Promise<IssuesPage> {
880-
const apiOpts = buildListApiOptions(flags.json);
909+
const apiOpts = buildListApiOptions(flags.json, flags.fields);
881910
const timeParams = timeRangeToApiParams(timeRange);
882911
const { cursor, onPage } = options;
883912

@@ -1257,7 +1286,7 @@ async function handleResolvedTargets(
12571286
? `Fetching issues from ${targetCount} projects`
12581287
: "Fetching issues";
12591288

1260-
const apiOpts = buildListApiOptions(flags.json);
1289+
const apiOpts = buildListApiOptions(flags.json, flags.fields);
12611290

12621291
const { results, hasMore } = await withProgress(
12631292
{ message: `${baseMessage} (up to ${flags.limit})...`, json: flags.json },

src/lib/api/issues.ts

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ export type IssueSort = NonNullable<
4747
* expensive Snuba/ClickHouse queries on the backend.
4848
*
4949
* - `'stats'` — time-series event counts (sparkline data)
50-
* - `'lifetime'` — lifetime aggregate counts (count, userCount, firstSeen)
50+
* - `'lifetime'` — lifetime aggregate sub-object AND top-level count/userCount/firstSeen/lastSeen on list endpoints
5151
* - `'filtered'` — filtered aggregate counts
5252
* - `'unhandled'` — unhandled event flag computation
5353
* - `'base'` — base group fields (rarely useful to collapse)
@@ -59,9 +59,17 @@ export type IssueCollapseField = NonNullable<
5959
/**
6060
* Build the `collapse` parameter for issue list API calls.
6161
*
62-
* Always collapses fields the CLI never consumes in issue list:
63-
* `filtered`, `lifetime`, `unhandled`. Conditionally collapses `stats`
64-
* when sparklines won't be rendered (narrow terminal, non-TTY, or JSON).
62+
* Always collapses `filtered` and `unhandled` — the CLI never consumes
63+
* these in issue list views. Conditionally collapses `stats` when
64+
* sparklines won't be rendered (narrow terminal, non-TTY, or JSON),
65+
* and `lifetime` when the caller confirms the lifetime-dependent
66+
* top-level fields (`count`, `userCount`, `firstSeen`, `lastSeen`)
67+
* aren't needed.
68+
*
69+
* **Important:** Despite being documented as removing only the `lifetime`
70+
* sub-object, `collapse=lifetime` also strips the top-level `count`,
71+
* `userCount`, `firstSeen`, and `lastSeen` fields from list responses.
72+
* Only collapse it when those fields are confirmed unnecessary. See #969.
6573
*
6674
* Matches the Sentry web UI's optimization: the initial page load sends
6775
* `collapse=stats,unhandled` to skip expensive Snuba queries, fetching
@@ -70,12 +78,20 @@ export type IssueCollapseField = NonNullable<
7078
* @param options - Context for determining what to collapse
7179
* @param options.shouldCollapseStats - Whether stats data can be skipped
7280
* (true when sparklines won't be shown: narrow terminal, non-TTY, --json)
81+
* @param options.shouldCollapseLifetime - Whether lifetime data can be skipped.
82+
* Defaults to `false` because most output paths need `count`/`userCount`/
83+
* `firstSeen`/`lastSeen`. Only set to `true` when `--json --fields` omits
84+
* all lifetime-dependent fields.
7385
* @returns Array of fields to collapse
7486
*/
7587
export function buildIssueListCollapse(options: {
7688
shouldCollapseStats: boolean;
89+
shouldCollapseLifetime?: boolean;
7790
}): IssueCollapseField[] {
78-
const collapse: IssueCollapseField[] = ["filtered", "lifetime", "unhandled"];
91+
const collapse: IssueCollapseField[] = ["filtered", "unhandled"];
92+
if (options.shouldCollapseLifetime) {
93+
collapse.push("lifetime");
94+
}
7995
if (options.shouldCollapseStats) {
8096
collapse.push("stats");
8197
}
@@ -90,8 +106,10 @@ export function buildIssueListCollapse(options: {
90106
* in detail views (`issue view`, `issue explain`, `issue plan`).
91107
* Collapsing these skips expensive Snuba queries, saving 100-300ms per request.
92108
*
93-
* Note: `count`, `userCount`, `firstSeen`, `lastSeen` are top-level fields
94-
* and remain unaffected by collapsing.
109+
* Note: On the **list** endpoint, `collapse=lifetime` strips top-level
110+
* `count`, `userCount`, `firstSeen`, `lastSeen` (see #969). The detail
111+
* endpoint preserves these fields regardless of collapse — safe to include
112+
* `lifetime` here.
95113
*/
96114
export const ISSUE_DETAIL_COLLAPSE: IssueCollapseField[] = [
97115
"stats",

test/commands/issue/list.test.ts

Lines changed: 117 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1110,7 +1110,7 @@ describe("issue list: collapse parameter optimization", () => {
11101110
advancePaginationStateSpy.mockRestore();
11111111
});
11121112

1113-
test("always collapses filtered, lifetime, unhandled in org-all mode", async () => {
1113+
test("always collapses filtered and unhandled in org-all mode", async () => {
11141114
listIssuesPaginatedSpy.mockResolvedValue({
11151115
data: [sampleIssue],
11161116
nextCursor: undefined,
@@ -1134,10 +1134,125 @@ describe("issue list: collapse parameter optimization", () => {
11341134
const options = callArgs?.[2] as Record<string, unknown> | undefined;
11351135
const collapse = options?.collapse as string[];
11361136
expect(collapse).toContain("filtered");
1137-
expect(collapse).toContain("lifetime");
11381137
expect(collapse).toContain("unhandled");
11391138
});
11401139

1140+
test("does not collapse lifetime in human mode (needed for EVENTS/USERS/SEEN/AGE)", async () => {
1141+
listIssuesPaginatedSpy.mockResolvedValue({
1142+
data: [sampleIssue],
1143+
nextCursor: undefined,
1144+
});
1145+
1146+
const orgAllFunc = (await listCommand.loader()) as unknown as (
1147+
this: unknown,
1148+
flags: Record<string, unknown>,
1149+
target?: string
1150+
) => Promise<void>;
1151+
1152+
const { context } = createOrgAllContext();
1153+
await orgAllFunc.call(
1154+
context,
1155+
{ limit: 10, sort: "date", period: parsePeriod("90d"), json: false },
1156+
"my-org/"
1157+
);
1158+
1159+
expect(listIssuesPaginatedSpy).toHaveBeenCalled();
1160+
const callArgs = listIssuesPaginatedSpy.mock.calls[0];
1161+
const options = callArgs?.[2] as Record<string, unknown> | undefined;
1162+
const collapse = options?.collapse as string[];
1163+
expect(collapse).not.toContain("lifetime");
1164+
});
1165+
1166+
test("does not collapse lifetime in JSON mode without --fields", async () => {
1167+
listIssuesPaginatedSpy.mockResolvedValue({
1168+
data: [sampleIssue],
1169+
nextCursor: undefined,
1170+
});
1171+
1172+
const orgAllFunc = (await listCommand.loader()) as unknown as (
1173+
this: unknown,
1174+
flags: Record<string, unknown>,
1175+
target?: string
1176+
) => Promise<void>;
1177+
1178+
const { context } = createOrgAllContext();
1179+
await orgAllFunc.call(
1180+
context,
1181+
{ limit: 10, sort: "date", period: parsePeriod("90d"), json: true },
1182+
"my-org/"
1183+
);
1184+
1185+
expect(listIssuesPaginatedSpy).toHaveBeenCalled();
1186+
const callArgs = listIssuesPaginatedSpy.mock.calls[0];
1187+
const options = callArgs?.[2] as Record<string, unknown> | undefined;
1188+
const collapse = options?.collapse as string[];
1189+
expect(collapse).not.toContain("lifetime");
1190+
});
1191+
1192+
test("does not collapse lifetime in JSON mode when --fields includes lifetime-dependent field", async () => {
1193+
listIssuesPaginatedSpy.mockResolvedValue({
1194+
data: [sampleIssue],
1195+
nextCursor: undefined,
1196+
});
1197+
1198+
const orgAllFunc = (await listCommand.loader()) as unknown as (
1199+
this: unknown,
1200+
flags: Record<string, unknown>,
1201+
target?: string
1202+
) => Promise<void>;
1203+
1204+
const { context } = createOrgAllContext();
1205+
await orgAllFunc.call(
1206+
context,
1207+
{
1208+
limit: 10,
1209+
sort: "date",
1210+
period: parsePeriod("90d"),
1211+
json: true,
1212+
fields: ["shortId", "title", "count"],
1213+
},
1214+
"my-org/"
1215+
);
1216+
1217+
expect(listIssuesPaginatedSpy).toHaveBeenCalled();
1218+
const callArgs = listIssuesPaginatedSpy.mock.calls[0];
1219+
const options = callArgs?.[2] as Record<string, unknown> | undefined;
1220+
const collapse = options?.collapse as string[];
1221+
expect(collapse).not.toContain("lifetime");
1222+
});
1223+
1224+
test("collapses lifetime in JSON mode when --fields omits all lifetime-dependent fields", async () => {
1225+
listIssuesPaginatedSpy.mockResolvedValue({
1226+
data: [sampleIssue],
1227+
nextCursor: undefined,
1228+
});
1229+
1230+
const orgAllFunc = (await listCommand.loader()) as unknown as (
1231+
this: unknown,
1232+
flags: Record<string, unknown>,
1233+
target?: string
1234+
) => Promise<void>;
1235+
1236+
const { context } = createOrgAllContext();
1237+
await orgAllFunc.call(
1238+
context,
1239+
{
1240+
limit: 10,
1241+
sort: "date",
1242+
period: parsePeriod("90d"),
1243+
json: true,
1244+
fields: ["shortId", "title"],
1245+
},
1246+
"my-org/"
1247+
);
1248+
1249+
expect(listIssuesPaginatedSpy).toHaveBeenCalled();
1250+
const callArgs = listIssuesPaginatedSpy.mock.calls[0];
1251+
const options = callArgs?.[2] as Record<string, unknown> | undefined;
1252+
const collapse = options?.collapse as string[];
1253+
expect(collapse).toContain("lifetime");
1254+
});
1255+
11411256
test("collapses stats in JSON mode", async () => {
11421257
listIssuesPaginatedSpy.mockResolvedValue({
11431258
data: [sampleIssue],

0 commit comments

Comments
 (0)