diff --git a/src/commands/event/view.ts b/src/commands/event/view.ts index c2b0886ef..2a5cbdafb 100644 --- a/src/commands/event/view.ts +++ b/src/commands/event/view.ts @@ -23,7 +23,12 @@ import { } from "../../lib/arg-parsing.js"; import { openInBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; -import { ApiError, ContextError, ResolutionError } from "../../lib/errors.js"; +import { + ApiError, + AuthError, + ContextError, + ResolutionError, +} from "../../lib/errors.js"; import { formatEventDetails } from "../../lib/formatters/index.js"; import { filterFields } from "../../lib/formatters/json.js"; import { CommandOutput } from "../../lib/formatters/output.js"; @@ -446,17 +451,81 @@ async function fetchLatestEventData( return buildEventViewData(org, event, spans); } +/** + * Try to find an event via cross-project and cross-org fallbacks. + * + * 1. Same-org fallback: tries the eventids resolution endpoint within `org`. + * 2. Cross-org fallback: fans out to all accessible orgs (skipping `org`). + * + * Returns the event and logs a warning when found in a different location, + * or returns null if the event cannot be found anywhere. + */ +async function tryEventFallbacks( + org: string, + project: string, + eventId: string +): Promise { + // Same-org fallback: try cross-project lookup within the specified org. + // Handles wrong-project resolution from DSN auto-detect or config defaults. + // Track whether the search completed so we can skip the org in cross-org + // only when we got a definitive "not found" (not a transient failure). + let sameOrgSearched = false; + try { + const resolved = await resolveEventInOrg(org, eventId); + sameOrgSearched = true; + if (resolved) { + logger.warn( + `Event not found in ${org}/${project}, but found in ${resolved.org}/${resolved.project}.` + ); + return resolved.event; + } + } catch (sameOrgError) { + // Propagate auth errors — they indicate a global problem (expired token) + if (sameOrgError instanceof AuthError) { + throw sameOrgError; + } + // Transient failure — don't mark org as searched so cross-org retries it + } + + // Cross-org fallback: the event may exist in a different organization. + // Only exclude the org if the same-org search completed successfully + // (returned null). If it threw a transient error, let cross-org retry it. + try { + const crossOrg = await findEventAcrossOrgs(eventId, { + excludeOrgs: sameOrgSearched ? [org] : undefined, + }); + if (crossOrg) { + // Use project-scoped phrasing when found in same org (different project) + // to avoid the contradictory "not found in 'org', found in org/project". + const location = `${crossOrg.org}/${crossOrg.project}`; + const prefix = + crossOrg.org === org + ? `Event not found in ${org}/${project}` + : `Event not found in '${org}'`; + logger.warn(`${prefix}, but found in ${location}.`); + return crossOrg.event; + } + } catch (fallbackError) { + // Propagate auth errors — they indicate a global problem (expired token) + if (fallbackError instanceof AuthError) { + throw fallbackError; + } + // Swallow transient errors — continue to suggestions + } + + return null; +} + /** * Fetch an event, enriching 404 errors with actionable suggestions. * * The generic "Failed to get event: 404 Not Found" is the most common * event view failure (CLI-6F, 54 users). This wrapper adds context about - * data retention, ID format, and cross-project lookup. + * data retention, ID format, and cross-project/cross-org lookup. * - * When the project-scoped fetch returns 404, automatically tries the - * org-wide eventids resolution endpoint as a fallback. This handles the - * common case where DSN auto-detection or config defaults resolve to - * the wrong project within the correct org (CLI-KW, 9 users). + * When the project-scoped fetch returns 404, automatically tries: + * 1. Org-wide eventids resolution (wrong project within correct org) + * 2. Cross-org search across all accessible orgs (wrong org entirely) * * @param prefetchedEvent - Already-resolved event (from cross-org lookup), or null * @param org - Organization slug @@ -477,26 +546,15 @@ export async function fetchEventWithContext( return await getEvent(org, project, eventId); } catch (error) { if (error instanceof ApiError && error.status === 404) { - // Auto-fallback: try cross-project lookup within the same org. - // Handles wrong-project resolution from DSN auto-detect or config defaults. - // Wrapped in try-catch so that fallback failures (500s, network errors) - // don't mask the helpful ResolutionError with suggestions. - try { - const resolved = await resolveEventInOrg(org, eventId); - if (resolved) { - logger.warn( - `Event not found in ${org}/${project}, but found in ${resolved.org}/${resolved.project}.` - ); - return resolved.event; - } - } catch { - // Fallback failed (network, 500, etc.) — continue to suggestions + const fallback = await tryEventFallbacks(org, project, eventId); + if (fallback) { + return fallback; } const suggestions = [ "The event may have been deleted due to data retention policies", "Verify the event ID is a 32-character hex string (e.g., a1b2c3d4...)", - `The event was not found in any project in '${org}'`, + "The event was not found in any accessible organization", ]; // Nudge the user when the event ID looks like an issue short ID diff --git a/src/lib/api/events.ts b/src/lib/api/events.ts index 093b81f72..adec3f022 100644 --- a/src/lib/api/events.ts +++ b/src/lib/api/events.ts @@ -121,6 +121,12 @@ export async function resolveEventInOrg( } } +/** Options for {@link findEventAcrossOrgs}. */ +export type FindEventAcrossOrgsOptions = { + /** Org slugs to skip (already searched by the caller). */ + excludeOrgs?: string[]; +}; + /** * Search for an event across all accessible organizations by event ID. * @@ -128,11 +134,19 @@ export async function resolveEventInOrg( * Returns the first match found, or null if the event is not accessible. * * @param eventId - The event ID (UUID) to look up + * @param options - Optional settings (e.g., orgs to skip) */ export async function findEventAcrossOrgs( - eventId: string + eventId: string, + options?: FindEventAcrossOrgsOptions ): Promise { - const orgs = await listOrganizations(); + const excludeSet = options?.excludeOrgs + ? new Set(options.excludeOrgs) + : undefined; + const allOrgs = await listOrganizations(); + const orgs = excludeSet + ? allOrgs.filter((o) => !excludeSet.has(o.slug)) + : allOrgs; const limit = pLimit(ORG_FANOUT_CONCURRENCY); const results = await Promise.allSettled( diff --git a/test/commands/event/view.test.ts b/test/commands/event/view.test.ts index 59c21c559..f3b444f2d 100644 --- a/test/commands/event/view.test.ts +++ b/test/commands/event/view.test.ts @@ -32,6 +32,7 @@ import { DEFAULT_SENTRY_URL } from "../../../src/lib/constants.js"; import { setOrgRegion } from "../../../src/lib/db/regions.js"; import { ApiError, + AuthError, ContextError, ResolutionError, ValidationError, @@ -978,17 +979,154 @@ describe("fetchEventWithContext", () => { expect(result).toBe(resolvedEvent); }); - test("throws ResolutionError when both project-scoped and org-wide fail", async () => { + test("throws ResolutionError when project-scoped, org-wide, and cross-org all fail", async () => { spyOn(apiClient, "getEvent").mockRejectedValue( new ApiError("Not found", 404) ); spyOn(apiClient, "resolveEventInOrg").mockResolvedValue(null); + spyOn(apiClient, "findEventAcrossOrgs").mockResolvedValue(null); await expect( fetchEventWithContext(null, "my-org", "my-project", "abc123") ).rejects.toThrow(ResolutionError); }); + test("falls back to cross-org search when org-wide returns null", async () => { + spyOn(apiClient, "getEvent").mockRejectedValue( + new ApiError("Not found", 404) + ); + spyOn(apiClient, "resolveEventInOrg").mockResolvedValue(null); + const crossOrgEvent = { + ...mockEvent, + eventID: "found-in-other-org", + } as unknown as SentryEvent; + spyOn(apiClient, "findEventAcrossOrgs").mockResolvedValue({ + org: "other-org", + project: "other-project", + event: crossOrgEvent, + }); + + const result = await fetchEventWithContext( + null, + "my-org", + "my-project", + "abc123" + ); + expect(result).toBe(crossOrgEvent); + }); + + test("cross-org fallback passes excludeOrgs when same-org search succeeded", async () => { + spyOn(apiClient, "getEvent").mockRejectedValue( + new ApiError("Not found", 404) + ); + // Same-org search completed successfully (returned null = definitive "not found") + spyOn(apiClient, "resolveEventInOrg").mockResolvedValue(null); + const findSpy = spyOn(apiClient, "findEventAcrossOrgs").mockResolvedValue( + null + ); + + await expect( + fetchEventWithContext(null, "my-org", "my-project", "abc123") + ).rejects.toThrow(ResolutionError); + + expect(findSpy).toHaveBeenCalledWith("abc123", { + excludeOrgs: ["my-org"], + }); + }); + + test("cross-org does not exclude org when same-org search threw", async () => { + spyOn(apiClient, "getEvent").mockRejectedValue( + new ApiError("Not found", 404) + ); + // Same-org search threw a transient error — org was NOT definitively searched + spyOn(apiClient, "resolveEventInOrg").mockRejectedValue( + new Error("500 Internal Server Error") + ); + const findSpy = spyOn(apiClient, "findEventAcrossOrgs").mockResolvedValue( + null + ); + + await expect( + fetchEventWithContext(null, "my-org", "my-project", "abc123") + ).rejects.toThrow(ResolutionError); + + // excludeOrgs should be undefined so cross-org retries the same org + expect(findSpy).toHaveBeenCalledWith("abc123", { + excludeOrgs: undefined, + }); + }); + + test("swallows non-auth cross-org errors and throws ResolutionError", async () => { + spyOn(apiClient, "getEvent").mockRejectedValue( + new ApiError("Not found", 404) + ); + spyOn(apiClient, "resolveEventInOrg").mockResolvedValue(null); + spyOn(apiClient, "findEventAcrossOrgs").mockRejectedValue( + new Error("Network timeout") + ); + + await expect( + fetchEventWithContext(null, "my-org", "my-project", "abc123") + ).rejects.toThrow(ResolutionError); + }); + + test("propagates AuthError from cross-org fallback", async () => { + spyOn(apiClient, "getEvent").mockRejectedValue( + new ApiError("Not found", 404) + ); + spyOn(apiClient, "resolveEventInOrg").mockResolvedValue(null); + spyOn(apiClient, "findEventAcrossOrgs").mockRejectedValue( + new AuthError("expired", "Token expired") + ); + + await expect( + fetchEventWithContext(null, "my-org", "my-project", "abc123") + ).rejects.toThrow(AuthError); + }); + + test("propagates AuthError from same-org fallback", async () => { + spyOn(apiClient, "getEvent").mockRejectedValue( + new ApiError("Not found", 404) + ); + spyOn(apiClient, "resolveEventInOrg").mockRejectedValue( + new AuthError("expired", "Token expired") + ); + const findSpy = spyOn(apiClient, "findEventAcrossOrgs"); + + await expect( + fetchEventWithContext(null, "my-org", "my-project", "abc123") + ).rejects.toThrow(AuthError); + // Cross-org should never be attempted when auth is broken + expect(findSpy).not.toHaveBeenCalled(); + }); + + test("tries cross-org fallback even when org-wide search throws", async () => { + spyOn(apiClient, "getEvent").mockRejectedValue( + new ApiError("Not found", 404) + ); + spyOn(apiClient, "resolveEventInOrg").mockRejectedValue( + new Error("500 Internal Server Error") + ); + const crossOrgEvent = { + ...mockEvent, + eventID: "found-cross-org", + } as unknown as SentryEvent; + const findSpy = spyOn(apiClient, "findEventAcrossOrgs").mockResolvedValue({ + org: "other-org", + project: "other-project", + event: crossOrgEvent, + }); + + const result = await fetchEventWithContext( + null, + "my-org", + "my-project", + "abc123" + ); + expect(result).toBe(crossOrgEvent); + expect(findSpy).toHaveBeenCalled(); + }); + test("propagates non-404 errors without fallback", async () => { spyOn(apiClient, "getEvent").mockRejectedValue( new ApiError("Server error", 500)