diff --git a/packages/cli/src/commands/issue/view.ts b/packages/cli/src/commands/issue/view.ts index a4865448c..9f655c3dd 100644 --- a/packages/cli/src/commands/issue/view.ts +++ b/packages/cli/src/commands/issue/view.ts @@ -24,6 +24,7 @@ import { FRESH_FLAG, } from "../../lib/list-command.js"; import { logger } from "../../lib/logger.js"; +import { resolveEffectiveOrg } from "../../lib/region.js"; import { collectReplayIds, getReplayIdFromEvent, @@ -122,6 +123,91 @@ async function buildIssueSpanData( return { trace: null }; } +/** Issue-scoped data fetched alongside (or after) the issue itself. */ +type IssueDetails = Pick< + SingleIssueViewData, + "replayIds" | "spanTreeLines" | "trace" +> & { + /** Latest event, or `undefined` when it could not be fetched. */ + event: SentryEvent | undefined; +}; + +/** + * Fetch the latest event, related replays, and optional span tree for an issue. + * + * The span tree only depends on the latest event, so its trace request starts + * as soon as the event arrives instead of also waiting on the replay lookup. + * Never rejects: every request is best-effort. + * + * @param orgSlug - Organization slug, or `undefined` when no org is known + * @param issueId - Numeric issue ID + * @param spans - Span tree depth (`0` skips the trace request) + */ +async function fetchIssueDetails( + orgSlug: string | undefined, + issueId: string, + spans: number, +): Promise { + if (!orgSlug) { + return { + event: undefined, + replayIds: [], + ...(await buildIssueSpanData(undefined, undefined, spans)), + }; + } + + const [{ event, spanData }, relatedReplayIds] = await Promise.all([ + tryGetLatestEvent(orgSlug, issueId).then(async (latest) => ({ + event: latest, + spanData: await buildIssueSpanData(orgSlug, latest, spans), + })), + tryListReplayIdsForIssue(orgSlug, issueId), + ]); + const replayIds = collectReplayIds([ + event ? getReplayIdFromEvent(event) : undefined, + ...relatedReplayIds, + ]); + return { event, replayIds, ...spanData }; +} + +/** Issue details started before the issue itself resolved. */ +type PrefetchedIssueDetails = { + /** Org slug the requests were sent to */ + org: string; + /** Numeric issue ID the requests were sent for */ + issueId: string; + /** In-flight details; never rejects */ + details: Promise; +}; + +/** + * Start the latest-event, replay, and trace requests before the issue resolves + * when the argument already names the org and numeric ID (`org/123456789`). + * + * Otherwise `issue view org/123` runs issue → latest event → trace strictly in + * series. On high-latency connections that waterfall takes seconds and trips + * Sentry's Consecutive HTTP detector (CLI-3F5). Other argument forms need the + * issue response to learn the numeric ID. Pasted URLs are skipped too: their + * server is only applied once `resolveIssue` runs. + * + * @param issueArg - Parsed issue argument + * @param spans - Span tree depth (`0` skips the trace request) + */ +async function prefetchIssueDetails( + { parsed, baseUrl }: IssueArgument, + spans: number, +): Promise { + if (parsed.type !== "explicit-org-numeric" || baseUrl) { + return; + } + const org = await resolveEffectiveOrg(parsed.org); + return { + org, + issueId: parsed.numericId, + details: fetchIssueDetails(org, parsed.numericId, spans), + }; +} + /** * Resolve one issue and attach latest event, replays, and optional span tree. */ @@ -130,30 +216,25 @@ async function buildSingleIssueViewData( cwd: string, spans: number, ): Promise { + const prefetched = await prefetchIssueDetails(issueArg, spans); const { org: orgSlug, issue } = await resolveIssue({ issueArg, cwd, command: "view", }); - const [event, relatedReplayIds] = orgSlug - ? await Promise.all([ - tryGetLatestEvent(orgSlug, issue.id), - tryListReplayIdsForIssue(orgSlug, issue.id), - ]) - : [undefined, []]; - const replayIds = collectReplayIds([ - event ? getReplayIdFromEvent(event) : undefined, - ...relatedReplayIds, - ]); - const spanData = await buildIssueSpanData(orgSlug, event, spans); + // A merged issue resolves to a different ID than the one requested, so the + // prefetched details would belong to the old issue. + const { event, ...details } = + prefetched && prefetched.org === orgSlug && prefetched.issueId === issue.id + ? await prefetched.details + : await fetchIssueDetails(orgSlug, issue.id, spans); return { org: orgSlug ?? null, issue, event: event ?? null, - replayIds, - ...spanData, + ...details, }; } diff --git a/packages/cli/test/commands/issue/view.func.test.ts b/packages/cli/test/commands/issue/view.func.test.ts index 90c39c9fe..23811aae2 100644 --- a/packages/cli/test/commands/issue/view.func.test.ts +++ b/packages/cli/test/commands/issue/view.func.test.ts @@ -52,6 +52,20 @@ vi.mock("../../../src/lib/browser.js", async (importOriginal) => { // oxlint-disable-next-line sentry-cli/no-namespace-import -- needed for spyOn mocking import * as browser from "../../../src/lib/browser.js"; + +vi.mock("../../../src/lib/region.js", async (importOriginal) => { + const actual = + await importOriginal(); + return Object.fromEntries( + Object.entries(actual).map(([k, v]) => [ + k, + typeof v === "function" ? vi.fn(v) : v, + ]), + ); +}); + +// oxlint-disable-next-line sentry-cli/no-namespace-import -- needed for spyOn mocking +import * as region from "../../../src/lib/region.js"; import { ContextError } from "../../../src/lib/errors.js"; import { type IssueArgument, @@ -435,3 +449,112 @@ describe("fetchMultipleIssueViews", () => { ).rejects.toBe(error); }); }); + +describe("issue view org/numeric prefetch", () => { + let resolveIssueSpy: ReturnType; + let getLatestEventSpy: ReturnType; + let listReplayIdsForIssueSpy: ReturnType; + let getDetailedTraceSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + resolveIssueSpy = vi.spyOn(issueUtils, "resolveIssue"); + getLatestEventSpy = vi + .spyOn(apiClient, "getLatestEvent") + .mockResolvedValue( + sampleEvent({ contexts: { trace: { trace_id: "a".repeat(32) } } }), + ); + listReplayIdsForIssueSpy = vi + .spyOn(apiClient, "listReplayIdsForIssue") + .mockResolvedValue([SECOND_REPLAY_ID]); + getDetailedTraceSpy = vi + .spyOn(apiClient, "getDetailedTrace") + .mockResolvedValue([]); + vi.spyOn(region, "resolveEffectiveOrg").mockImplementation( + async (org: string) => org, + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("fetches latest event, replays, and trace while the issue is still loading", async () => { + let releaseIssue: (() => void) | undefined; + resolveIssueSpy.mockImplementation( + () => + new Promise((resolve) => { + releaseIssue = () => + resolve({ org: "test-org", issue: sampleIssue() }); + }), + ); + + const pending = fetchMultipleIssueViews({ + issueArgs: [parseIssueArgument("test-org/12345")], + cwd: "/tmp", + spans: 3, + }); + + await vi.waitFor(() => { + expect(resolveIssueSpy).toHaveBeenCalled(); + expect(getDetailedTraceSpy).toHaveBeenCalled(); + }); + expect(getLatestEventSpy).toHaveBeenCalledWith("test-org", "12345"); + expect(listReplayIdsForIssueSpy).toHaveBeenCalledWith("test-org", "12345"); + + releaseIssue?.(); + const [view] = await pending; + + expect(getLatestEventSpy).toHaveBeenCalledTimes(1); + expect(listReplayIdsForIssueSpy).toHaveBeenCalledTimes(1); + expect(getDetailedTraceSpy).toHaveBeenCalledTimes(1); + expect(view?.event?.eventID).toBe(sampleEvent().eventID); + expect(view?.replayIds).toEqual([REPLAY_ID, SECOND_REPLAY_ID]); + expect(view?.trace?.traceId).toBe("a".repeat(32)); + }); + + test("refetches details when the issue resolves to a different ID", async () => { + resolveIssueSpy.mockResolvedValue({ + org: "test-org", + issue: sampleIssue({ id: "67890" }), + }); + + const [view] = await fetchMultipleIssueViews({ + issueArgs: [parseIssueArgument("test-org/12345")], + cwd: "/tmp", + spans: 0, + }); + + expect(getLatestEventSpy).toHaveBeenCalledWith("test-org", "12345"); + expect(getLatestEventSpy).toHaveBeenLastCalledWith("test-org", "67890"); + expect(listReplayIdsForIssueSpy).toHaveBeenLastCalledWith( + "test-org", + "67890", + ); + expect(view?.issue.id).toBe("67890"); + }); + + test.each([ + ["short IDs", "CLI-123"], + [ + "pasted URLs", + "https://sentry.example.com/organizations/test-org/issues/12345/", + ], + ])("waits for the issue before fetching details for %s", async (_, arg) => { + resolveIssueSpy.mockResolvedValue({ + org: "test-org", + issue: sampleIssue(), + }); + + await fetchMultipleIssueViews({ + issueArgs: [parseIssueArgument(arg)], + cwd: "/tmp", + spans: 0, + }); + + expect(getLatestEventSpy).toHaveBeenCalledTimes(1); + expect(resolveIssueSpy.mock.invocationCallOrder[0]).toBeLessThan( + getLatestEventSpy.mock.invocationCallOrder[0] ?? 0, + ); + }); +});