Skip to content

Commit 988dc24

Browse files
committed
perf(api): collapse stats on issue detail endpoints to save 100-300ms
Switch getIssueInOrg and getIssueByShortId from @sentry/api SDK to raw apiRequestToRegion() to enable passing the collapse query parameter. The SDK types declare query?: never on retrieveAnIssue and resolveAShortId, blocking query params entirely. Add ISSUE_DETAIL_COLLAPSE constant that collapses stats, lifetime, filtered, and unhandled fields on all single-issue API calls. These fields are never displayed in issue view, explain, or plan commands. The count, userCount, firstSeen, and lastSeen fields remain unaffected as they are top-level fields outside the collapsed sub-objects. Also add collapse to resolveSelector() which calls listIssuesPaginated with perPage: 1 — previously fetched all fields for a single issue that only needed identity data. Estimated savings: 100-300ms per issue detail request by skipping expensive Snuba/ClickHouse queries on the backend.
1 parent 7da52e0 commit 988dc24

File tree

6 files changed

+221
-49
lines changed

6 files changed

+221
-49
lines changed

AGENTS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -880,4 +880,6 @@ mock.module("./some-module", () => ({
880880
881881
<!-- lore:019cc325-d322-7e6e-86cc-93010b71abee -->
882882
* **Testing Stricli command func() bodies via spyOn mocking**: Stricli/Bun test patterns: (1) Command func tests: \`const func = await cmd.loader()\`, then \`func.call(mockContext, flags, ...args)\`. \`loader()\` return type union causes LSP errors — false positives that pass \`tsc\`. File naming: \`\*.func.test.ts\`. (2) ESM prevents \`vi.spyOn\` on Node built-in exports. Workaround: test subclass that overrides the method calling the built-in. (3) Follow-mode uses \`setTimeout\`-based scheduling; test with \`interceptSigint()\` helper. \`Bun.sleep()\` has no AbortSignal so \`setTimeout\`/\`clearTimeout\` required.
883+
884+
* **@sentry/api SDK blocks query params on issue detail endpoints**: The \`retrieveAnIssue\` and \`resolveAShortId\` SDK functions have \`query?: never\` in their TypeScript types, preventing callers from passing \`collapse\` or other query parameters. The Sentry backend DOES accept \`collapse\` on these endpoints (same Django view base class as the list endpoint), but the OpenAPI spec omits it. The CLI works around this by using raw \`apiRequestToRegion()\` instead of the SDK for \`getIssueInOrg\` and \`getIssueByShortId\`. Upstream issue filed at https://github.com/getsentry/sentry-api-schema/. If the schema is fixed, these functions can switch back to the SDK.
883885
<!-- End lore-managed section -->

src/commands/issue/utils.ts

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
getIssue,
1212
getIssueByShortId,
1313
getIssueInOrg,
14+
ISSUE_DETAIL_COLLAPSE,
1415
type IssueSort,
1516
listIssuesPaginated,
1617
listOrganizations,
@@ -137,7 +138,9 @@ async function tryResolveFromAlias(
137138
}
138139

139140
const resolvedShortId = expandToFullShortId(suffix, projectEntry.projectSlug);
140-
const issue = await getIssueByShortId(projectEntry.orgSlug, resolvedShortId);
141+
const issue = await getIssueByShortId(projectEntry.orgSlug, resolvedShortId, {
142+
collapse: ISSUE_DETAIL_COLLAPSE,
143+
});
141144
return { org: projectEntry.orgSlug, issue };
142145
}
143146

@@ -182,7 +185,9 @@ async function resolveProjectSearchFallback(
182185
const matchedOrg = matchedProject?.orgSlug;
183186
if (matchedOrg && matchedProject) {
184187
const retryShortId = expandToFullShortId(suffix, matchedProject.slug);
185-
const issue = await getIssueByShortId(matchedOrg, retryShortId);
188+
const issue = await getIssueByShortId(matchedOrg, retryShortId, {
189+
collapse: ISSUE_DETAIL_COLLAPSE,
190+
});
186191
return { org: matchedOrg, issue };
187192
}
188193

@@ -240,7 +245,9 @@ async function resolveProjectSearch(
240245
dsnTarget.project.toLowerCase() === projectSlug.toLowerCase()
241246
) {
242247
const fullShortId = expandToFullShortId(suffix, dsnTarget.project);
243-
const issue = await getIssueByShortId(dsnTarget.org, fullShortId);
248+
const issue = await getIssueByShortId(dsnTarget.org, fullShortId, {
249+
collapse: ISSUE_DETAIL_COLLAPSE,
250+
});
244251
return { org: dsnTarget.org, issue };
245252
}
246253

@@ -255,7 +262,11 @@ async function resolveProjectSearch(
255262
const results = await Promise.all(
256263
orgs.map((org) =>
257264
limit(() =>
258-
withAuthGuard(() => tryGetIssueByShortId(org.slug, fullShortId))
265+
withAuthGuard(() =>
266+
tryGetIssueByShortId(org.slug, fullShortId, {
267+
collapse: ISSUE_DETAIL_COLLAPSE,
268+
})
269+
)
259270
)
260271
)
261272
);
@@ -327,7 +338,9 @@ async function resolveSuffixOnly(
327338
);
328339
}
329340
const fullShortId = expandToFullShortId(suffix, target.project);
330-
const issue = await getIssueByShortId(target.org, fullShortId);
341+
const issue = await getIssueByShortId(target.org, fullShortId, {
342+
collapse: ISSUE_DETAIL_COLLAPSE,
343+
});
331344
return { org: target.org, issue };
332345
}
333346

@@ -413,11 +426,13 @@ async function resolveSelector(
413426
const sort = SELECTOR_SORT_MAP[selector];
414427
const label = SELECTOR_LABELS[selector];
415428

416-
// Fetch just the top issue with the appropriate sort
429+
// Fetch just the top issue with the appropriate sort.
430+
// Collapse all non-essential fields since we only need the issue identity.
417431
const { data: issues } = await listIssuesPaginated(orgSlug, "", {
418432
sort,
419433
perPage: 1,
420434
query: "is:unresolved",
435+
collapse: ISSUE_DETAIL_COLLAPSE,
421436
});
422437

423438
const issue = issues[0];
@@ -483,8 +498,10 @@ async function resolveNumericIssue(
483498
const resolvedOrg = await resolveOrg({ cwd });
484499
try {
485500
const issue = resolvedOrg
486-
? await getIssueInOrg(resolvedOrg.org, id)
487-
: await getIssue(id);
501+
? await getIssueInOrg(resolvedOrg.org, id, {
502+
collapse: ISSUE_DETAIL_COLLAPSE,
503+
})
504+
: await getIssue(id, { collapse: ISSUE_DETAIL_COLLAPSE });
488505
// Extract org from the response permalink as a fallback so that callers
489506
// like resolveOrgAndIssueId (used by explain/plan) get the org slug even
490507
// when no org context was available before the fetch.
@@ -542,7 +559,9 @@ export async function resolveIssue(
542559
// Full context: org + project + suffix
543560
const org = await resolveEffectiveOrg(parsed.org);
544561
const fullShortId = expandToFullShortId(parsed.suffix, parsed.project);
545-
const issue = await getIssueByShortId(org, fullShortId);
562+
const issue = await getIssueByShortId(org, fullShortId, {
563+
collapse: ISSUE_DETAIL_COLLAPSE,
564+
});
546565
result = { org, issue };
547566
break;
548567
}
@@ -551,7 +570,9 @@ export async function resolveIssue(
551570
// Org + numeric ID — use org-scoped endpoint for proper region routing.
552571
const org = await resolveEffectiveOrg(parsed.org);
553572
try {
554-
const issue = await getIssueInOrg(org, parsed.numericId);
573+
const issue = await getIssueInOrg(org, parsed.numericId, {
574+
collapse: ISSUE_DETAIL_COLLAPSE,
575+
});
555576
result = { org, issue };
556577
} catch (err) {
557578
if (err instanceof ApiError && err.status === 404) {

src/lib/api-client.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export {
4848
getIssue,
4949
getIssueByShortId,
5050
getIssueInOrg,
51+
ISSUE_DETAIL_COLLAPSE,
5152
type IssueCollapseField,
5253
type IssueSort,
5354
type IssuesPage,

src/lib/api/issues.ts

Lines changed: 73 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -5,24 +5,21 @@
55
*/
66

77
import type { ListAnOrganizationSissuesData } from "@sentry/api";
8-
import {
9-
listAnOrganization_sIssues,
10-
resolveAShortId,
11-
retrieveAnIssue,
12-
} from "@sentry/api";
8+
import { listAnOrganization_sIssues } from "@sentry/api";
139

1410
import type { SentryIssue } from "../../types/index.js";
1511

1612
import { ApiError } from "../errors.js";
13+
import { resolveOrgRegion } from "../region.js";
1714

1815
import {
1916
API_MAX_PER_PAGE,
2017
apiRequest,
18+
apiRequestToRegion,
2119
getOrgSdkConfig,
2220
MAX_PAGINATION_PAGES,
2321
type PaginatedResponse,
2422
unwrapPaginatedResult,
25-
unwrapResult,
2623
} from "./infrastructure.js";
2724

2825
/**
@@ -74,6 +71,24 @@ export function buildIssueListCollapse(options: {
7471
return collapse;
7572
}
7673

74+
/**
75+
* Collapse fields for single-issue detail endpoints.
76+
*
77+
* The CLI never displays stats (sparkline time-series), lifetime (aggregate
78+
* sub-object), filtered (filtered counts), or unhandled (unhandled sub-object)
79+
* in detail views (`issue view`, `issue explain`, `issue plan`).
80+
* Collapsing these skips expensive Snuba queries, saving 100-300ms per request.
81+
*
82+
* Note: `count`, `userCount`, `firstSeen`, `lastSeen` are top-level fields
83+
* and remain unaffected by collapsing.
84+
*/
85+
export const ISSUE_DETAIL_COLLAPSE: IssueCollapseField[] = [
86+
"stats",
87+
"lifetime",
88+
"filtered",
89+
"unhandled",
90+
];
91+
7792
/**
7893
* List issues for a project with pagination control.
7994
*
@@ -237,58 +252,80 @@ export async function listIssuesAllPages(
237252
*
238253
* Uses the legacy unscoped endpoint — no org context or region routing.
239254
* Prefer {@link getIssueInOrg} when the org slug is known.
255+
*
256+
* @param issueId - Numeric issue ID
257+
* @param options - Optional collapse fields to skip expensive backend queries
240258
*/
241-
export function getIssue(issueId: string): Promise<SentryIssue> {
242-
// The @sentry/api SDK's retrieveAnIssue requires org slug in path,
243-
// but the legacy endpoint /issues/{id}/ works without org context.
244-
// Use raw request for backward compatibility.
245-
return apiRequest<SentryIssue>(`/issues/${issueId}/`);
259+
export function getIssue(
260+
issueId: string,
261+
options?: { collapse?: IssueCollapseField[] }
262+
): Promise<SentryIssue> {
263+
return apiRequest<SentryIssue>(`/issues/${issueId}/`, {
264+
params: options?.collapse ? { collapse: options.collapse } : undefined,
265+
});
246266
}
247267

248268
/**
249269
* Get a specific issue by numeric ID, scoped to an organization.
250270
*
251-
* Uses the org-scoped SDK endpoint with region-aware routing.
271+
* Uses the org-scoped endpoint with region-aware routing.
252272
* Preferred over {@link getIssue} when the org slug is available.
253273
*
274+
* Uses raw `apiRequestToRegion` instead of the SDK's `retrieveAnIssue`
275+
* because the SDK types declare `query?: never`, blocking `collapse`
276+
* and other query parameters. See: https://github.com/getsentry/sentry-api-schema/
277+
*
254278
* @param orgSlug - Organization slug (used for region routing)
255279
* @param issueId - Numeric issue ID
280+
* @param options - Optional collapse fields to skip expensive backend queries
256281
*/
257282
export async function getIssueInOrg(
258283
orgSlug: string,
259-
issueId: string
284+
issueId: string,
285+
options?: { collapse?: IssueCollapseField[] }
260286
): Promise<SentryIssue> {
261-
const config = await getOrgSdkConfig(orgSlug);
262-
const result = await retrieveAnIssue({
263-
...config,
264-
path: { organization_id_or_slug: orgSlug, issue_id: issueId },
265-
});
266-
return unwrapResult(result, "Failed to get issue") as unknown as SentryIssue;
287+
const regionUrl = await resolveOrgRegion(orgSlug);
288+
const { data } = await apiRequestToRegion<SentryIssue>(
289+
regionUrl,
290+
`/organizations/${orgSlug}/issues/${issueId}/`,
291+
{
292+
params: options?.collapse ? { collapse: options.collapse } : undefined,
293+
}
294+
);
295+
return data;
267296
}
268297

269298
/**
270299
* Get an issue by short ID (e.g., SPOTLIGHT-ELECTRON-4D).
271300
* Requires organization context to resolve the short ID.
272301
* Uses region-aware routing for multi-region support.
302+
*
303+
* Uses raw `apiRequestToRegion` instead of the SDK's `resolveAShortId`
304+
* because the SDK types declare `query?: never`, blocking `collapse`
305+
* and other query parameters. See: https://github.com/getsentry/sentry-api-schema/
306+
*
307+
* @param orgSlug - Organization slug
308+
* @param shortId - Short ID (e.g., "CLI-G5", "SPOTLIGHT-ELECTRON-4D")
309+
* @param options - Optional collapse fields to skip expensive backend queries
273310
*/
274311
export async function getIssueByShortId(
275312
orgSlug: string,
276-
shortId: string
313+
shortId: string,
314+
options?: { collapse?: IssueCollapseField[] }
277315
): Promise<SentryIssue> {
278316
const normalizedShortId = shortId.toUpperCase();
279-
const config = await getOrgSdkConfig(orgSlug);
317+
const regionUrl = await resolveOrgRegion(orgSlug);
280318

281-
const result = await resolveAShortId({
282-
...config,
283-
path: {
284-
organization_id_or_slug: orgSlug,
285-
issue_id: normalizedShortId,
286-
},
287-
});
288-
289-
let data: ReturnType<typeof unwrapResult>;
319+
let data: { group?: SentryIssue };
290320
try {
291-
data = unwrapResult(result, "Failed to resolve short ID");
321+
const result = await apiRequestToRegion<{ group?: SentryIssue }>(
322+
regionUrl,
323+
`/organizations/${orgSlug}/shortids/${normalizedShortId}/`,
324+
{
325+
params: options?.collapse ? { collapse: options.collapse } : undefined,
326+
}
327+
);
328+
data = result.data;
292329
} catch (error) {
293330
// Enrich 404 errors with actionable context. The generic
294331
// "Failed to resolve short ID: 404 Not Found" is the most common
@@ -308,16 +345,14 @@ export async function getIssueByShortId(
308345
throw error;
309346
}
310347

311-
// resolveAShortId returns a ShortIdLookupResponse with a group (issue)
312-
const resolved = data as unknown as { group?: SentryIssue };
313-
if (!resolved.group) {
348+
if (!data.group) {
314349
throw new ApiError(
315350
`Short ID ${normalizedShortId} resolved but no issue group returned`,
316351
404,
317352
"Issue not found"
318353
);
319354
}
320-
return resolved.group;
355+
return data.group;
321356
}
322357

323358
/**
@@ -333,10 +368,11 @@ export async function getIssueByShortId(
333368
*/
334369
export async function tryGetIssueByShortId(
335370
orgSlug: string,
336-
shortId: string
371+
shortId: string,
372+
options?: { collapse?: IssueCollapseField[] }
337373
): Promise<SentryIssue | null> {
338374
try {
339-
return await getIssueByShortId(orgSlug, shortId);
375+
return await getIssueByShortId(orgSlug, shortId, options);
340376
} catch (error) {
341377
if (error instanceof ApiError && error.status === 404) {
342378
return null;

0 commit comments

Comments
 (0)