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
100 changes: 79 additions & 21 deletions src/commands/event/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<SentryEvent | null> {
// 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
Expand All @@ -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
Expand Down
18 changes: 16 additions & 2 deletions src/lib/api/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,18 +121,32 @@ 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.
*
* Fans out to every org in parallel using the eventids resolution endpoint.
* 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<ResolvedEvent | null> {
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(
Expand Down
140 changes: 139 additions & 1 deletion test/commands/event/view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
Loading