Skip to content

Commit 0ac022e

Browse files
betegonclaudeBYK
authored
perf(issue): skip getProject round-trip in project-search resolution (#473)
## Summary When resolving a project-search issue argument (e.g., `sentry issue view acme-web-4F2K`), `resolveProjectSearch()` was doing two sequential API steps: `findProjectsBySlug()` (calls `getProject` per org) then `getIssueByShortId()`. Since the shortid endpoint already validates both project and issue existence, the `getProject` call is redundant. Now tries `tryGetIssueByShortId()` directly across all orgs in parallel, saving one HTTP round-trip (~500-800ms). Falls back to `findProjectsBySlug()` only when all orgs 404, so error messages stay specific. Addresses [CLI-EV](https://sentry.sentry.io/issues/7340600304/) (Consecutive HTTP performance issue). ## Example: `sentry issue view acme-web-4F2K` **Before** (6 sequential HTTP calls, ~3.5s): ``` resolveProjectSearch("acme-web", "4F2K") | +- tryResolveFromAlias() <-- cache miss +- resolveFromDsn() <-- no DSN match | +- findProjectsBySlug("acme-web") | +- listOrganizations() | | +- GET /users/me/regions/ ~273ms --+ | | +- GET /organizations/ ~619ms | sequential | | | | +- Promise.all(orgs.map(getProject)) | | +- GET /projects/acme-corp/acme-web/ ~783ms | | +- getIssueByShortId("acme-corp", ...) | | +- GET /shortids/ACME-WEB-4F2K/ ~581ms | | | +- tryGetLatestEvent() | | +- GET /issues/{id}/events/latest/ ~544ms | | | +- getSpanTreeLines() | +- GET /trace/{trace_id}/ ~539ms --+ total: ~3.3s HTTP ``` **After** (5 sequential HTTP calls, ~2.5s): ``` resolveProjectSearch("acme-web", "4F2K") | +- tryResolveFromAlias() <-- cache miss +- resolveFromDsn() <-- no DSN match | +- listOrganizations() | +- GET /users/me/regions/ ~273ms --+ | +- GET /organizations/ ~619ms | | | +- Promise.all(orgs.map(tryGetIssueByShortId)) <-- skips getProject | +- GET /shortids/ACME-WEB-4F2K/ ~581ms | one fewer | | round-trip +- tryGetLatestEvent() | | +- GET /issues/{id}/events/latest/ ~544ms | | | +- getSpanTreeLines() | +- GET /trace/{trace_id}/ ~539ms --+ total: ~2.5s HTTP saved: ~783ms ``` With a warm org cache (the common case after PR #446), the regions + organizations calls are instant, bringing the total down further. ## Changes ### Performance - `src/lib/api/issues.ts` -- add `tryGetIssueByShortId()` (returns null on 404 instead of throwing) - `src/lib/api-client.ts` -- barrel export - `src/commands/issue/utils.ts` -- rewrite `resolveProjectSearch()`: fan out shortid lookups directly across all orgs in parallel, skipping the intermediate `getProject()` call ### Error handling - Surface real API errors (403, 5xx) and network errors when every org fails, instead of masking them with "not found" - Only throw when ALL orgs returned real errors -- a 403 from an unrelated org does not preempt the fallback when other orgs returned clean 404s - Retry `getIssueByShortId` when the fallback finds the project (handles transient failures from the fast path) - Extract `resolveProjectSearchFallback()` to stay under cognitive complexity lint limit ## Test plan - `bun run typecheck` passes - `bun run lint` passes (complexity now under 15) - `bun test --filter "issue"` -- all 137 tests pass - Manual: `sentry issue view <project>-<suffix>` resolves without the extra `getProject` call --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Burak Yigit Kaya <byk@sentry.io>
1 parent 9ecc62f commit 0ac022e

File tree

4 files changed

+326
-24
lines changed

4 files changed

+326
-24
lines changed

src/commands/issue/utils.ts

Lines changed: 104 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,20 @@ import {
1212
getIssueInOrg,
1313
type IssueSort,
1414
listIssuesPaginated,
15+
listOrganizations,
1516
triggerRootCauseAnalysis,
17+
tryGetIssueByShortId,
1618
} from "../../lib/api-client.js";
1719
import { type IssueSelector, parseIssueArg } from "../../lib/arg-parsing.js";
1820
import { getProjectByAlias } from "../../lib/db/project-aliases.js";
1921
import { detectAllDsns } from "../../lib/dsn/index.js";
20-
import { ApiError, ContextError, ResolutionError } from "../../lib/errors.js";
22+
import {
23+
ApiError,
24+
type AuthGuardFailure,
25+
ContextError,
26+
ResolutionError,
27+
withAuthGuard,
28+
} from "../../lib/errors.js";
2129
import { getProgressMessage } from "../../lib/formatters/seer.js";
2230
import { expandToFullShortId, isShortSuffix } from "../../lib/issue-id.js";
2331
import { logger } from "../../lib/logger.js";
@@ -124,12 +132,66 @@ async function tryResolveFromAlias(
124132
return { org: projectEntry.orgSlug, issue };
125133
}
126134

135+
/**
136+
* Fallback for when the fast shortid fan-out found no matches.
137+
* Uses findProjectsBySlug to give a precise error ("project not found" vs
138+
* "issue not found") and retries the issue lookup on transient failures.
139+
*/
140+
async function resolveProjectSearchFallback(
141+
projectSlug: string,
142+
suffix: string,
143+
commandHint: string
144+
): Promise<StrictResolvedIssue> {
145+
const { projects } = await findProjectsBySlug(projectSlug.toLowerCase());
146+
147+
if (projects.length === 0) {
148+
throw new ResolutionError(
149+
`Project '${projectSlug}'`,
150+
"not found",
151+
commandHint,
152+
["No project with this slug found in any accessible organization"]
153+
);
154+
}
155+
156+
if (projects.length > 1) {
157+
const orgList = projects.map((p) => p.orgSlug).join(", ");
158+
throw new ResolutionError(
159+
`Project '${projectSlug}'`,
160+
"is ambiguous",
161+
commandHint,
162+
[
163+
`Found in: ${orgList}`,
164+
`Specify the org: sentry issue ... <org>/${projectSlug}-${suffix}`,
165+
]
166+
);
167+
}
168+
169+
// Project exists — retry the issue lookup. The fast path may have failed
170+
// due to a transient error (5xx, timeout); retrying here either succeeds
171+
// or propagates the real error to the user.
172+
const matchedProject = projects[0];
173+
const matchedOrg = matchedProject?.orgSlug;
174+
if (matchedOrg && matchedProject) {
175+
const retryShortId = expandToFullShortId(suffix, matchedProject.slug);
176+
const issue = await getIssueByShortId(matchedOrg, retryShortId);
177+
return { org: matchedOrg, issue };
178+
}
179+
180+
throw new ResolutionError(
181+
`Project '${projectSlug}'`,
182+
"not found",
183+
commandHint
184+
);
185+
}
186+
127187
/**
128188
* Resolve project-search type: search for project across orgs, then fetch issue.
129189
*
130190
* Resolution order:
131191
* 1. Try alias cache (fast, local)
132-
* 2. Search for project across orgs via API
192+
* 2. Check DSN detection cache
193+
* 3. Try shortid endpoint directly across all orgs (fast path)
194+
* 4. Fall back to findProjectsBySlug for precise error messages
133195
*
134196
* @param projectSlug - Project slug to search for
135197
* @param suffix - Issue suffix (uppercase)
@@ -173,20 +235,33 @@ async function resolveProjectSearch(
173235
return { org: dsnTarget.org, issue };
174236
}
175237

176-
// 3. Search for project across all accessible orgs
177-
const { projects } = await findProjectsBySlug(projectSlug.toLowerCase());
238+
// 3. Fast path: try resolving the short ID directly across all orgs.
239+
// The shortid endpoint validates both project existence and issue existence
240+
// in a single call, eliminating the separate getProject() round-trip.
241+
const fullShortId = expandToFullShortId(suffix, projectSlug);
242+
const orgs = await listOrganizations();
178243

179-
if (projects.length === 0) {
180-
throw new ResolutionError(
181-
`Project '${projectSlug}'`,
182-
"not found",
183-
commandHint,
184-
["No project with this slug found in any accessible organization"]
185-
);
244+
const results = await Promise.all(
245+
orgs.map((org) =>
246+
withAuthGuard(() => tryGetIssueByShortId(org.slug, fullShortId))
247+
)
248+
);
249+
250+
const successes: StrictResolvedIssue[] = [];
251+
for (let i = 0; i < results.length; i++) {
252+
const result = results[i];
253+
const org = orgs[i];
254+
if (result && org && result.ok && result.value) {
255+
successes.push({ org: org.slug, issue: result.value });
256+
}
186257
}
187258

188-
if (projects.length > 1) {
189-
const orgList = projects.map((p) => p.orgSlug).join(", ");
259+
if (successes.length === 1 && successes[0]) {
260+
return successes[0];
261+
}
262+
263+
if (successes.length > 1) {
264+
const orgList = successes.map((s) => s.org).join(", ");
190265
throw new ResolutionError(
191266
`Project '${projectSlug}'`,
192267
"is ambiguous",
@@ -198,18 +273,24 @@ async function resolveProjectSearch(
198273
);
199274
}
200275

201-
const project = projects[0];
202-
if (!project) {
203-
throw new ResolutionError(
204-
`Project '${projectSlug}'`,
205-
"not found",
206-
commandHint
207-
);
276+
// If every org failed with a real error (403, 5xx, network timeout),
277+
// surface it instead of falling through to a misleading "not found".
278+
// Only throw when ALL results are errors — if some orgs returned clean
279+
// 404s ({ok: true, value: null}), fall through to the fallback for a
280+
// precise error message.
281+
const realErrors = results.filter(
282+
(r): r is AuthGuardFailure => r !== undefined && !r.ok
283+
);
284+
if (realErrors.length === results.length && realErrors.length > 0) {
285+
const firstError = realErrors[0]?.error;
286+
if (firstError instanceof Error) {
287+
throw firstError;
288+
}
208289
}
209290

210-
const fullShortId = expandToFullShortId(suffix, project.slug);
211-
const issue = await getIssueByShortId(project.orgSlug, fullShortId);
212-
return { org: project.orgSlug, issue };
291+
// 4. Fall back to findProjectsBySlug for precise error messages
292+
// and retry the issue lookup (handles transient failures).
293+
return resolveProjectSearchFallback(projectSlug, suffix, commandHint);
213294
}
214295

215296
/**

src/lib/api-client.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export {
4949
type IssuesPage,
5050
listIssuesAllPages,
5151
listIssuesPaginated,
52+
tryGetIssueByShortId,
5253
updateIssueStatus,
5354
} from "./api/issues.js";
5455
export {

src/lib/api/issues.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,31 @@ export async function getIssueByShortId(
251251
return resolved.group;
252252
}
253253

254+
/**
255+
* Try to get an issue by short ID, returning null on 404.
256+
*
257+
* Same as {@link getIssueByShortId} but returns null instead of throwing
258+
* when the short ID is not found. Useful for parallel fan-out across orgs
259+
* where most will 404.
260+
*
261+
* @param orgSlug - Organization slug
262+
* @param shortId - Full short ID (e.g., "CONSUMER-MOBILE-1QNEK")
263+
* @returns The resolved issue, or null if not found in this org
264+
*/
265+
export async function tryGetIssueByShortId(
266+
orgSlug: string,
267+
shortId: string
268+
): Promise<SentryIssue | null> {
269+
try {
270+
return await getIssueByShortId(orgSlug, shortId);
271+
} catch (error) {
272+
if (error instanceof ApiError && error.status === 404) {
273+
return null;
274+
}
275+
throw error;
276+
}
277+
}
278+
254279
/**
255280
* Update an issue's status.
256281
*/

0 commit comments

Comments
 (0)