Skip to content

Commit 5f41707

Browse files
authored
perf(api): collapse stats on issue detail endpoints to save 100-300ms (#551)
Applies the same optimization the Sentry web UI shipped: skip fetching `stats` (24h/30d time-series from Snuba) on issue detail requests. Saves 100-300ms per request. ## Problem The `@sentry/api` SDK's `retrieveAnIssue` and `resolveAShortId` both declare `query?: never`, making it impossible to pass the `collapse` query parameter through the SDK. ## Solution - Switch `getIssueInOrg` and `getIssueByShortId` from the SDK to raw `apiRequestToRegion()` to enable passing `collapse` - Add `ISSUE_DETAIL_COLLAPSE` constant: `["stats", "lifetime", "filtered", "unhandled"]` - Pass it to all 9 issue-fetch call sites in `utils.ts` (view, explain, plan, selector resolution) - Add `collapse` to `resolveSelector()` which previously fetched all fields for a single-issue `@latest`/`@most_frequent` lookup ## What's unaffected `count`, `userCount`, `firstSeen`, `lastSeen` are top-level fields outside the collapsed sub-objects — human output is unchanged. `issue list` already had its own conditional collapse logic and is not touched. ## Follow-up File upstream issue at https://github.com/getsentry/sentry-api-schema/ to add `collapse` to the OpenAPI spec for `retrieveAnIssue` and `resolveAShortId`, so the SDK can be used again.
1 parent b037bf4 commit 5f41707

File tree

6 files changed

+240
-73
lines changed

6 files changed

+240
-73
lines changed

AGENTS.md

Lines changed: 21 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -860,7 +860,7 @@ mock.module("./some-module", () => ({
860860
* **cli.sentry.dev is served from gh-pages branch via GitHub Pages**: \`cli.sentry.dev\` is served from gh-pages branch via GitHub Pages. Craft's gh-pages target runs \`git rm -r -f .\` before extracting docs — persist extra files via \`postReleaseCommand\` in \`.craft.yml\`. Install script supports \`--channel nightly\`, downloading from the \`nightly\` release tag directly. version.json is only used by upgrade/version-check flow.
861861
862862
<!-- lore:019cbe93-19b8-7776-9705-20bbde226599 -->
863-
* **Nightly delta upgrade buildNightlyPatchGraph fetches ALL patch tags — O(N) HTTP calls**: Delta upgrade in \`src/lib/delta-upgrade.ts\` supports stable (GitHub Releases) and nightly (GHCR) channels. \`filterAndSortChainTags\` filters \`patch-\*\` tags by version range using \`Bun.semver.order()\`. GHCR uses \`fetchWithRetry\` (10s timeout + 1 retry; blobs 30s) with optional \`signal?: AbortSignal\` combined via \`AbortSignal.any()\`. \`isExternalAbort(error, signal)\` skips retries for external aborts — critical for background prefetch. Patches cached to \`~/.sentry/patch-cache/\` (file-based, 7-day TTL). \`loadCachedChain\` stitches patches for multi-hop offline upgrades.
863+
* **Nightly delta upgrade buildNightlyPatchGraph fetches ALL patch tags — O(N) HTTP calls**: Delta upgrade in \`src/lib/delta-upgrade.ts\` supports stable (GitHub Releases) and nightly (GHCR) channels. \`filterAndSortChainTags\` filters \`patch-\*\` tags by version range using \`Bun.semver.order()\`. GHCR uses \`fetchWithRetry\` (10s timeout + 1 retry; blobs 30s) with optional \`signal?: AbortSignal\` combined via \`AbortSignal.any()\`. \`isExternalAbort(error, signal)\` skips retries for external aborts — critical for background prefetch. Patches cached to \`~/.sentry/patch-cache/\` (file-based, 7-day TTY). \`loadCachedChain\` stitches patches for multi-hop offline upgrades.
864864
865865
<!-- lore:2c3eb7ab-1341-4392-89fd-d81095cfe9c4 -->
866866
* **npm bundle requires Node.js >= 22 due to node:sqlite polyfill**: The npm package (dist/bin.cjs) requires Node.js >= 22 because the bun:sqlite polyfill uses \`node:sqlite\`. A runtime version guard in the esbuild banner catches this early. When writing esbuild banner strings in TS template literals, double-escape: \`\\\\\\\n\` in TS → \`\\\n\` in output → newline at runtime. Single \`\\\n\` produces a literal newline inside a JS string, causing SyntaxError.
@@ -886,37 +886,34 @@ mock.module("./some-module", () => ({
886886
<!-- lore:019cbf3f-6dc2-727d-8dca-228555e9603f -->
887887
* **withAuthGuard returns discriminated Result type, not fallback+onError**: \`withAuthGuard\<T>(fn)\` in \`src/lib/errors.ts\` returns a discriminated Result: \`{ ok: true, value: T } | { ok: false, error: unknown }\`. AuthErrors always re-throw (triggers bin.ts auto-login). All other errors are captured. Callers inspect \`result.ok\` to degrade gracefully. Used across 12+ files.
888888
889-
### Gotcha
889+
<!-- lore:019d082a-c40e-77b5-9374-ee993205ac79 -->
890+
* **Sentry SDK switched from @sentry/bun to @sentry/node-core/light**: The CLI switched from \`@sentry/bun\` to \`@sentry/node-core/light\` (PR #474) to eliminate the OpenTelemetry dependency tree (~170ms startup savings). All imports use \`@sentry/node-core/light\` not \`@sentry/bun\`. \`LightNodeClient\` replaces \`BunClient\` in telemetry.ts. \`Span\` type exports from \`@sentry/core\`. The \`@sentry/core\` barrel is patched to remove ~32 unused exports (AI tracing, MCP server, Supabase, feature flags). Light mode is labeled experimental but safe because SDK versions are pinned exactly (not caret). \`makeNodeTransport\` works fine in Bun. The \`cli.runtime\` tag still distinguishes Bun vs Node at runtime.
890891
891-
<!-- lore:019c9994-d161-783e-8b3e-79457cd62f42 -->
892-
* **Biome lint: Response.redirect() required, nested ternaries forbidden**: Biome lint rules that frequently trip up this codebase: (1) \`useResponseRedirect\`: use \`Response.redirect(url, status)\` not \`new Response\`. (2) \`noNestedTernary\`: use \`if/else\`. (3) \`noComputedPropertyAccess\`: use \`obj.property\` not \`obj\["property"]\`. (4) Max cognitive complexity 15 per function — extract helpers to stay under.
892+
### Gotcha
893893
894-
<!-- lore:019c8c31-f52f-7230-9252-cceb907f3e87 -->
895-
* **Bugbot flags defensive null-checks as dead code — keep them with JSDoc justification**: Cursor Bugbot and Sentry Seer repeatedly flag two false positives: (1) defensive null-checks as "dead code" — keep them with JSDoc explaining why the guard exists for future safety, especially when removing would require \`!\` assertions banned by \`noNonNullAssertion\`. (2) stderr spinner output during \`--json\` mode — always a false positive since progress goes to stderr, JSON to stdout. Reply explaining the rationale and resolve.
894+
<!-- lore:019d1d4b-19fa-7d78-a01a-4c375f425834 -->
895+
* **Chalk needs chalk.level=3 for ANSI output in tests**: In Bun tests, stdout is piped (not a TTY), so chalk's color level defaults to 0 and produces no ANSI escape codes — even with \`SENTRY\_PLAIN\_OUTPUT=0\`. Setting \`FORCE\_COLOR=1\` env var works but the project convention is to set \`chalk.level = 3\` at the top of test files that need to assert on ANSI output. The project's \`isPlainOutput()\` and chalk's color detection are independent systems: \`isPlainOutput()\` checks \`SENTRY\_PLAIN\_OUTPUT\`/\`NO\_COLOR\`/TTY, while chalk checks \`FORCE\_COLOR\`/TTY separately.
896896
897-
<!-- lore:019cc3e6-0cdd-7a53-9eb7-a284a3b4eb78 -->
898-
* **Bun mock.module for node:tty requires default export and class stubs**: Bun testing gotchas: (1) \`mock.module()\` for CJS built-ins requires a \`default\` re-export plus all named exports. Missing any causes \`SyntaxError: Export named 'X' not found\`. Always check the real module's full export list. (2) \`Bun.mmap()\` always opens with PROT\_WRITE — macOS SIGKILL on signed Mach-O, Linux ETXTBSY. Fix: use \`new Uint8Array(await Bun.file(path).arrayBuffer())\` in bspatch.ts. (3) Wrap \`Bun.which()\` with optional \`pathEnv\` param for deterministic testing without mocks.
897+
<!-- lore:019d1bca-b5c1-7e19-b8cc-74b6f32f3487 -->
898+
* **issue explain/plan commands never set sentry.org telemetry tag**: The \`issue explain\` and \`issue plan\` commands were missing \`setContext(\[org], \[])\` calls after \`resolveOrgAndIssueId()\`, so \`sentry.org\` and \`sentry.project\` tags were never set on SeerError events. Fixed in commit 816b44b5 on branch \`fix/seer-org-telemetry-tag\` — both commands now destructure \`setContext\` from \`this\` and call it immediately after resolving the org, before any Seer API call. The \`issue view\` and \`issue list\` commands already had this call. Pattern: always call \`setContext()\` right after org resolution, before any API call that might throw, so error events carry the org tag.
899899
900-
<!-- lore:019d0846-17bd-7ff3-a6d7-09b59b69a8fe -->
901-
* **Use toMatchObject not toEqual when testing resolution results with optional fields**: When \`resolveProjectBySlug()\` or \`resolveOrgProjectTarget()\` adds optional fields (like \`projectData\`) to the return type, tests using \`expect(result).toEqual({ org, project })\` fail because \`toEqual\` requires exact match. Use \`toMatchObject({ org, project })\` instead — it checks the specified subset without failing on extra properties. This affects tests across \`event/view\`, \`log/view\`, \`trace/view\`, and \`trace/list\` test files.
900+
<!-- lore:019d1d4b-1a07-7d8a-b8ec-69fcf1440922 -->
901+
* **LSP auto-organize imports can silently remove new imports**: When editing TypeScript files in this project, the LSP's auto-organize-imports feature can silently remove import lines that were just added if the imported symbols aren't yet referenced in the file. This happens when you add an import in one edit and the usage in a subsequent edit — the LSP runs between edits and strips the "unused" import. Fix: always add the import and its first usage in the same edit operation, or verify imports survived after each edit by grepping the file.
902902
903-
### Pattern
903+
<!-- lore:019d082a-c419-7c99-9259-eacd10d2bcbd -->
904+
* **project list bare-slug org fallback requires custom handleProjectSearch**: The \`project list\` command has a custom \`handleProjectSearch\` override (not the shared one from \`org-list.ts\`). When a user types \`sentry project list acme-corp\` where \`acme-corp\` is an org slug (not a project), the custom handler must check if the bare slug matches an organization and fall back to listing that org's projects. The shared \`handleProjectSearch\` in \`org-list.ts\` already does this, but custom overrides in individual commands can miss it, causing a confusing \`ResolutionError\` (PR #475 fix).
904905
905-
<!-- lore:dbd63348-2049-42b3-bb99-d6a3d64369c7 -->
906-
* **Branch naming and commit message conventions for Sentry CLI**: Branch naming: \`feat/\<short-description>\` or \`fix/\<issue-number>-\<short-description>\` (e.g., \`feat/ghcr-nightly-distribution\`, \`fix/268-limit-auto-pagination\`). Commit message format: \`type(scope): description (#issue)\` (e.g., \`fix(issue-list): auto-paginate --limit beyond 100 (#268)\`, \`feat(nightly): distribute via GHCR instead of GitHub Releases\`). Types seen: fix, refactor, meta, release, feat. PRs are created as drafts via \`gh pr create --draft\`. Implementation plans are attached to commits via \`git notes add\` rather than in PR body or commit message.
906+
<!-- lore:019d1d4b-19e6-7aea-a3d8-3f76bcdd2209 -->
907+
* **Zero runtime dependencies enforced by test**: The project has a test in \`test/package.test.ts\` that asserts \`pkg.dependencies\` is empty — all packages must be devDependencies. The CLI is distributed as a compiled Bun binary, so runtime deps are bundled at build time. Adding a package to \`dependencies\` instead of \`devDependencies\` will fail CI. When adding a new package like \`@sentry/sqlish\`, use \`bun add --dev\` not \`bun add\`.
907908
908-
<!-- lore:019cc3e6-0cf5-720d-beb7-97c9c9901295 -->
909-
* **Codecov patch coverage only counts test:unit and test:isolated, not E2E**: CI coverage merges \`test:unit\` (\`test/lib test/commands test/types --coverage\`) and \`test:isolated\` (\`test/isolated --coverage\`) into \`coverage/merged.lcov\`. E2E tests (\`test/e2e\`) are NOT included in coverage reports. So func tests that spy on exports (e.g., \`spyOn(apiClient, 'getLogs')\`) give zero coverage to the mocked function's body. To cover \`api-client.ts\` function bodies in unit tests, mock \`globalThis.fetch\` + \`setOrgRegion()\` + \`setAuthToken()\` and call the real function.
910-
911-
<!-- lore:019c90f5-913b-7995-8bac-84289cf5d6d9 -->
912-
* **Pagination contextKey must include all query-varying parameters with escaping**: Pagination \`contextKey\` must encode every query-varying parameter (sort, query, period) with \`escapeContextKeyValue()\` (replaces \`|\` with \`%7C\`). Always provide a fallback before escaping since \`flags.period\` may be \`undefined\` in tests despite having a default: \`flags.period ? escapeContextKeyValue(flags.period) : "90d"\`.
909+
### Pattern
913910
914-
<!-- lore:019c8a8a-64ee-703c-8c1e-ed32ae8a90a7 -->
915-
* **PR review workflow: reply, resolve, amend, force-push**: PR review workflow: (1) Read unresolved threads via GraphQL, (2) make code changes, (3) run lint+typecheck+tests, (4) create a SEPARATE commit per review round (not amend) for incremental review, (5) push normally, (6) reply to comments via REST API, (7) resolve threads via GraphQL \`resolveReviewThread\`. Only amend+force-push when user explicitly asks or pre-commit hook modified files.
911+
<!-- lore:019d1f98-3bed-7f36-ae7e-34a529709097 -->
912+
* **@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/issues/63. If the schema is fixed, these functions can switch back to the SDK.
916913
917-
<!-- lore:019cdd9b-330a-784f-9487-0abf7b80be3c -->
918-
* **Stricli optional boolean flags produce tri-state (true/false/undefined)**: Stricli boolean flags with \`optional: true\` (no \`default\`) produce \`boolean | undefined\` in the flags type. \`--flag\`\`true\`, \`--no-flag\` \`false\`, omitted → \`undefined\`. This enables auto-detect patterns: explicit user choice overrides, \`undefined\` triggers heuristic. Used by \`--compact\` on issue list. The flag type must be \`readonly field?: boolean\` (not \`readonly field: boolean\`). This differs from \`default: false\` which always produces a defined boolean.
914+
<!-- lore:019d1bca-b5c9-7e1a-bfe4-d5c0545176ee -->
915+
* **Dashboard widget layout uses 6-column grid with x,y,w,h**: Sentry dashboards use a 6-column grid with \`{x, y, w, h, minH}\` layout. Default big\_number is \`w=2, h=1\`; to center it use \`w=4, x=1\`. Tables default to \`w=6, h=2\`. The \`dashboard widget edit\` command only handles query/display/title — use \`sentry api\` with PUT to \`/api/0/organizations/{org}/dashboards/{id}/\` for layout, default period, environment, and project changes. The PUT payload must include the full \`widgets\` array. Widget queries need both \`fields\` (all columns + aggregates) and \`columns\` (non-aggregate columns only) arrays. Use \`user.display\` instead of \`user.email\` for user columns — it auto-selects the best available identifier.
919916
920-
<!-- lore:019cc325-d322-7e6e-86cc-93010b71abee -->
921-
* **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.
917+
<!-- lore:019d082a-c415-7685-b6f0-e303d95eb4a0 -->
918+
* **Issue resolution fan-out uses unbounded Promise.all across orgs**: \`resolveProjectSearch()\` in \`src/commands/issue/utils.ts\` fans out \`tryGetIssueByShortId\` across ALL orgs via \`Promise.all\`. Pattern: expand short ID, list all orgs, fire lookups in parallel, collect successes. Exactly one success → use it; multiple → ambiguity error; all fail → fall back to \`resolveProjectSearchFallback()\`. All org fan-out concurrency is controlled by \`ORG\_FANOUT\_CONCURRENCY\` (exported from \`src/lib/api/infrastructure.ts\`, re-exported via \`api-client.ts\`). This single constant is used in \`events.ts\`, \`projects.ts\`, \`issue/utils.ts\`, and any future fan-out sites. Never define a local concurrency limit for org fan-out — always import the shared constant.
922919
<!-- 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,

0 commit comments

Comments
 (0)