From 31c5ac52d741dab8809f112f79cc0e21b317154f Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 14 Apr 2026 00:53:22 +0000 Subject: [PATCH 1/5] fix(event-view): add cross-org fallback when event not found (#734) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user specifies the wrong org (e.g., `sentry event view wrong-org/cli/`), the event lookup now searches across all accessible organizations as a last resort, instead of only searching within the specified org. Fallback chain: project-scoped fetch → same-org eventids → cross-org fan-out. Warns the user when the event is found in a different org/project. Also adds `excludeOrgs` option to `findEventAcrossOrgs` to skip the org already searched, avoiding a redundant API call. --- src/commands/event/view.ts | 75 ++++++++++++++++++++-------- src/lib/api/events.ts | 18 ++++++- test/commands/event/view.test.ts | 86 +++++++++++++++++++++++++++++++- 3 files changed, 156 insertions(+), 23 deletions(-) diff --git a/src/commands/event/view.ts b/src/commands/event/view.ts index c2b0886ef..930b13264 100644 --- a/src/commands/event/view.ts +++ b/src/commands/event/view.ts @@ -446,17 +446,63 @@ 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. + 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 cross-org + } + + // Cross-org fallback: the event may exist in a different organization. + // Skips the org already searched to avoid a redundant API call. + try { + const crossOrg = await findEventAcrossOrgs(eventId, { + excludeOrgs: [org], + }); + if (crossOrg) { + logger.warn( + `Event not found in '${org}', but found in ${crossOrg.org}/${crossOrg.project}.` + ); + return crossOrg.event; + } + } catch { + // Cross-org search failed — 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 +523,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..66b7032b8 100644 --- a/test/commands/event/view.test.ts +++ b/test/commands/event/view.test.ts @@ -978,17 +978,101 @@ 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 to skip already-searched org", async () => { + spyOn(apiClient, "getEvent").mockRejectedValue( + new ApiError("Not found", 404) + ); + 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("swallows cross-org fallback 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("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) From 2dc113ed1d39afce9496efe16bad62c860294665 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 14 Apr 2026 01:03:24 +0000 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20address=20bot=20review=20=E2=80=94?= =?UTF-8?q?=20conditional=20excludeOrgs=20and=20AuthError=20propagation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Only exclude the org from cross-org search when same-org search completed successfully (returned null). Transient errors (500, timeout) no longer prevent the cross-org fallback from retrying that org. - Re-throw AuthError from cross-org fallback instead of swallowing it, matching the behavior in findEventAcrossOrgs and resolveAutoDetectTarget. --- src/commands/event/view.ts | 26 +++++++++++++++----- test/commands/event/view.test.ts | 42 ++++++++++++++++++++++++++++++-- 2 files changed, 60 insertions(+), 8 deletions(-) diff --git a/src/commands/event/view.ts b/src/commands/event/view.ts index 930b13264..49da15416 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"; @@ -462,8 +467,12 @@ async function tryEventFallbacks( ): 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}.` @@ -471,14 +480,15 @@ async function tryEventFallbacks( return resolved.event; } } catch { - // Fallback failed (network, 500, etc.) — continue to cross-org + // Transient failure — don't mark org as searched so cross-org retries it } // Cross-org fallback: the event may exist in a different organization. - // Skips the org already searched to avoid a redundant API call. + // 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: [org], + excludeOrgs: sameOrgSearched ? [org] : undefined, }); if (crossOrg) { logger.warn( @@ -486,8 +496,12 @@ async function tryEventFallbacks( ); return crossOrg.event; } - } catch { - // Cross-org search failed — continue to suggestions + } 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; diff --git a/test/commands/event/view.test.ts b/test/commands/event/view.test.ts index 66b7032b8..3873b8526 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, @@ -1014,10 +1015,11 @@ describe("fetchEventWithContext", () => { expect(result).toBe(crossOrgEvent); }); - test("cross-org fallback passes excludeOrgs to skip already-searched org", async () => { + 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 @@ -1032,7 +1034,29 @@ describe("fetchEventWithContext", () => { }); }); - test("swallows cross-org fallback errors and throws ResolutionError", async () => { + 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) ); @@ -1046,6 +1070,20 @@ describe("fetchEventWithContext", () => { ).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("Token expired", "expired") + ); + + await expect( + fetchEventWithContext(null, "my-org", "my-project", "abc123") + ).rejects.toThrow(AuthError); + }); + test("tries cross-org fallback even when org-wide search throws", async () => { spyOn(apiClient, "getEvent").mockRejectedValue( new ApiError("Not found", 404) From 4272e7b0c968e8bf84a240bbbfbd30a65b1ec888 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 14 Apr 2026 01:10:31 +0000 Subject: [PATCH 3/5] fix: propagate AuthError from same-org fallback catch block Same-org catch block now also re-throws AuthError, consistent with the cross-org catch block. Prevents masking auth problems behind misleading "event not found" errors. --- src/commands/event/view.ts | 6 +++++- test/commands/event/view.test.ts | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/commands/event/view.ts b/src/commands/event/view.ts index 49da15416..1fdf5f99a 100644 --- a/src/commands/event/view.ts +++ b/src/commands/event/view.ts @@ -479,7 +479,11 @@ async function tryEventFallbacks( ); return resolved.event; } - } catch { + } 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 } diff --git a/test/commands/event/view.test.ts b/test/commands/event/view.test.ts index 3873b8526..77ebb6976 100644 --- a/test/commands/event/view.test.ts +++ b/test/commands/event/view.test.ts @@ -1084,6 +1084,22 @@ describe("fetchEventWithContext", () => { ).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("Token expired", "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) From 07c0a349cca252c6cc5e19668a8650702dc0f6bb Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 14 Apr 2026 01:18:47 +0000 Subject: [PATCH 4/5] fix: correct AuthError constructor arg order in tests --- test/commands/event/view.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/commands/event/view.test.ts b/test/commands/event/view.test.ts index 77ebb6976..f3b444f2d 100644 --- a/test/commands/event/view.test.ts +++ b/test/commands/event/view.test.ts @@ -1076,7 +1076,7 @@ describe("fetchEventWithContext", () => { ); spyOn(apiClient, "resolveEventInOrg").mockResolvedValue(null); spyOn(apiClient, "findEventAcrossOrgs").mockRejectedValue( - new AuthError("Token expired", "expired") + new AuthError("expired", "Token expired") ); await expect( @@ -1089,7 +1089,7 @@ describe("fetchEventWithContext", () => { new ApiError("Not found", 404) ); spyOn(apiClient, "resolveEventInOrg").mockRejectedValue( - new AuthError("Token expired", "expired") + new AuthError("expired", "Token expired") ); const findSpy = spyOn(apiClient, "findEventAcrossOrgs"); From d9655bf2550e4d4ee2192a53a58571cb49150697 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 14 Apr 2026 01:25:56 +0000 Subject: [PATCH 5/5] fix: improve cross-org warning when event found in same org, different project --- src/commands/event/view.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/commands/event/view.ts b/src/commands/event/view.ts index 1fdf5f99a..2a5cbdafb 100644 --- a/src/commands/event/view.ts +++ b/src/commands/event/view.ts @@ -495,9 +495,14 @@ async function tryEventFallbacks( excludeOrgs: sameOrgSearched ? [org] : undefined, }); if (crossOrg) { - logger.warn( - `Event not found in '${org}', but found in ${crossOrg.org}/${crossOrg.project}.` - ); + // 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) {