Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 94 additions & 13 deletions packages/cli/src/commands/issue/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<IssueDetails> {
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<IssueDetails>;
};

/**
* 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<PrefetchedIssueDetails | undefined> {
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.
*/
Expand All @@ -130,30 +216,25 @@ async function buildSingleIssueViewData(
cwd: string,
spans: number,
): Promise<SingleIssueViewData> {
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,
};
}

Expand Down
123 changes: 123 additions & 0 deletions packages/cli/test/commands/issue/view.func.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import("../../../src/lib/region.js")>();
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,
Expand Down Expand Up @@ -435,3 +449,112 @@ describe("fetchMultipleIssueViews", () => {
).rejects.toBe(error);
});
});

describe("issue view org/numeric prefetch", () => {
let resolveIssueSpy: ReturnType<typeof vi.spyOn>;
let getLatestEventSpy: ReturnType<typeof vi.spyOn>;
let listReplayIdsForIssueSpy: ReturnType<typeof vi.spyOn>;
let getDetailedTraceSpy: ReturnType<typeof vi.spyOn>;

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,
);
});
});
Loading