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
7 changes: 7 additions & 0 deletions apps/cli-docs/src/fragments/commands/event.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ The DSN is resolved in priority order:
2. `SENTRY_DSN` environment variable
3. Auto-detection from the current project (`.env`, source code, env files)

## Selecting an event from an issue

Copy a full event ID from `sentry issue events PROJ-ABC` and pass it after the
issue short ID to open that event. The issue resolves the project; omitting the
event ID selects the latest event. Invalid event IDs return an error instead of
selecting the latest event.

## Attachments

`sentry event view --json` lists an event's attachment metadata. Download an
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,15 @@ View details of one or more events
# View an event
sentry event view abc123def456abc123def456abc12345

# View a specific event using an issue short ID
sentry event view PROJ-ABC 7388f6a62b7d436ab77bb5365f97a1ac

# View a specific event with an explicit organization
sentry event view my-org/PROJ-ABC 7388f6a62b7d436ab77bb5365f97a1ac

# View the latest event for an issue
sentry event view PROJ-ABC

# Open in the browser
sentry event view abc123def456abc123def456abc12345 -w

Expand Down
39 changes: 27 additions & 12 deletions packages/cli/src/commands/event/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,7 @@ type ParsedPositionalArgs = {
* Handles:
* - `<event-id>` — event ID only (auto-detect org/project)
* - `<target> <event-id>` — explicit target + event ID
* - `[<org>/]<issue-short-id> <event-id>` — resolve the project from the issue
* - `<sentry-event-url>` — extract eventId and org from a Sentry event URL
* (e.g., `https://sentry.example.com/organizations/my-org/issues/123/events/abc/`)
* - `<sentry-issue-url>` — extract issueId and org; caller fetches latest event
Expand Down Expand Up @@ -458,16 +459,15 @@ export function parsePositionalArgs(args: string[]): ParsedPositionalArgs {
};
}

// Detect issue short ID passed as first arg (e.g., "CAM-82X 95fd7f5a").
// Auto-redirect to the issue's latest event instead of treating the short
// ID as a project slug (which would fail — slugs are lowercase, short IDs
// are uppercase). The second arg is ignored since we fetch the latest event.
if (looksLikeIssueShortId(first)) {
// An issue identifies the project; an explicit event ID must not be replaced
// by latest. Reuse the single-arg parser to preserve an optional org prefix.
const issueShortId =
slashCount === 1 ? first.slice(first.indexOf("/") + 1) : first;
if (looksLikeIssueShortId(issueShortId)) {
return {
eventId: LATEST_EVENT_SENTINEL,
targetArg: undefined,
issueShortId: first,
warning: `'${first}' is an issue short ID, not a project slug. Ignoring second argument '${second}'.`,
...parseSingleArg(first),
// Issue shortcuts run before the normal event-ID validation path.
eventId: validateHexId(second, "event ID"),
};
}

Expand Down Expand Up @@ -985,8 +985,8 @@ async function resolveIssueIdShortcut(
/**
* Handle issue-based shortcuts: issue URLs and issue short IDs.
*
* Both paths resolve an issue and fetch its latest event. Extracted from
* func() to reduce cyclomatic complexity.
* Resolve the issue's project for an explicit event ID, or fetch the latest
* event when no event was selected. Extracted from func() to reduce complexity.
*
* @returns Result with org, data, and hint — or null if not an issue shortcut
*/
Expand Down Expand Up @@ -1022,7 +1022,7 @@ async function resolveIssueShortcut(
);
}

// When the user specified a specific event ID (SHORT-ID/EVENT-ID),
// When the user specified an event ID (SHORT-ID/ID or SHORT-ID ID),
// resolve the issue to get the project, then fetch the specific event.
if (eventId !== LATEST_EVENT_SENTINEL) {
const issue = await getIssueByShortId(resolved.org, issueShortId);
Expand Down Expand Up @@ -1169,6 +1169,8 @@ export const viewCommand = buildCommand({
brief: "View details of one or more events",
fullDescription:
"View detailed information about Sentry events by their IDs.\n\n" +
"Pass an issue short ID followed by an event ID to resolve the project from the issue.\n" +
"An issue short ID without an event ID shows its latest event.\n\n" +
"Multiple event IDs can be passed as separate arguments or newline-separated\n" +
"within a single argument (handy when piping from other commands).\n\n" +
"Attachment metadata (id, name, size, mimetype, download URL) is included when available.\n" +
Expand All @@ -1178,6 +1180,19 @@ export const viewCommand = buildCommand({
description: "View an event",
command: "sentry event view abc123def456abc123def456abc12345",
},
{
description: "View a specific event using an issue short ID",
command: "sentry event view PROJ-ABC 7388f6a62b7d436ab77bb5365f97a1ac",
},
{
description: "View a specific event with an explicit organization",
command:
"sentry event view my-org/PROJ-ABC 7388f6a62b7d436ab77bb5365f97a1ac",
},
{
description: "View the latest event for an issue",
command: "sentry event view PROJ-ABC",
},
{
description: "Open in the browser",
command: "sentry event view abc123def456abc123def456abc12345 -w",
Expand Down
77 changes: 17 additions & 60 deletions packages/cli/test/commands/event/view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,25 +209,24 @@ describe("parsePositionalArgs", () => {
expect(result.eventId).toBe("abc123def456");
});

test("auto-redirects issue short ID in first arg to issueShortId (CLI-MP)", () => {
// "JAVASCRIPT-NUXT-52" matches looksLikeIssueShortId → redirect to issue's
// latest event instead of treating it as a project slug (which would fail).
const result = parsePositionalArgs([
"JAVASCRIPT-NUXT-52",
"abc123def456",
]);
expect(result.issueShortId).toBe("JAVASCRIPT-NUXT-52");
expect(result.eventId).toBe("@latest");
expect(result.targetArg).toBeUndefined();
expect(result.warning).toContain("issue short ID");
});
test.each([
"7388f6a62b7d436ab77bb5365f97a1ac",
"7388F6A6-2B7D-436A-B77B-B5365F97A1AC",
])(
"preserves and normalizes the event ID after an issue short ID: %s",
(eventId) => {
const result = parsePositionalArgs(["JAVASCRIPT-NUXT-52", eventId]);
expect(result.issueShortId).toBe("JAVASCRIPT-NUXT-52");
expect(result.eventId).toBe("7388f6a62b7d436ab77bb5365f97a1ac");
expect(result.targetArg).toBeUndefined();
expect(result.warning).toBeUndefined();
},
);

test("auto-redirects simple issue short ID like CAM-82X", () => {
const result = parsePositionalArgs(["CAM-82X", "95fd7f5a"]);
expect(result.issueShortId).toBe("CAM-82X");
expect(result.eventId).toBe("@latest");
expect(result.targetArg).toBeUndefined();
expect(result.warning).toContain("issue short ID");
test("rejects an incomplete event ID instead of selecting latest", () => {
expect(() => parsePositionalArgs(["CAM-82X", "95fd7f5a"])).toThrow(
ValidationError,
);
});
});

Expand Down Expand Up @@ -1190,48 +1189,6 @@ describe("viewCommand.func", () => {
expect(output.attachments).toEqual([]);
});

test("auto-redirects issue short ID in two-arg form via issueShortId path", async () => {
// "CAM-82X" as first arg matches looksLikeIssueShortId → sets issueShortId,
// NOT targetArg. The resolveIssueShortcut path fetches the latest event.
const resolveOrgSpy = vi
.spyOn(resolveTarget, "resolveOrg")
.mockResolvedValue({
org: "cam-org",
});
const getIssueByShortIdSpy = vi
.spyOn(apiClient, "getIssueByShortId")
.mockResolvedValue({ id: "999", shortId: "CAM-82X" } as never);
const getLatestEventSpy = vi
.spyOn(apiClient, "getLatestEvent")
.mockResolvedValue(sampleEvent);
getSpanTreeLinesSpy.mockResolvedValue({
lines: [],
spans: null,
traceId: null,
success: false,
});

const { context } = createMockContext();
const func = await viewCommand.loader();
// First arg "CAM-82X" is an issue short ID, second arg "95fd7f5a" is ignored
await func.call(
context,
{ json: true, web: false, spans: 0 },
"CAM-82X",
"95fd7f5a",
);

// Should NOT go through resolveProjectBoundSlug (the old buggy path)
expect(resolveProjectBoundSlugSpy).not.toHaveBeenCalled();
// Should resolve via issue short ID path
expect(getIssueByShortIdSpy).toHaveBeenCalledWith("cam-org", "CAM-82X");
expect(getLatestEventSpy).toHaveBeenCalled();

resolveOrgSpy.mockRestore();
getIssueByShortIdSpy.mockRestore();
getLatestEventSpy.mockRestore();
});

test("numeric issue ID resolves org via resolveOrg then fetches latest event", async () => {
// Regression: the numeric-issue-ID path must auto-detect the org via
// resolveOrg (env/config/DSN) rather than resolveEffectiveOrg(""), which
Expand Down
121 changes: 117 additions & 4 deletions packages/cli/test/e2e/event.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* Event Command E2E Tests
*
* Tests for sentry event get command.
* Tests event lookup and the list-to-view flow through the real CLI.
*/

import {
Expand All @@ -15,21 +15,64 @@ import {
} from "vitest";
import { EXIT } from "../../src/lib/errors.js";
import { createE2EContext, type E2EContext } from "../fixture.js";
import eventFixture from "../fixtures/event.json";
import { cleanupTestDir, createTestConfigDir } from "../helpers.js";
import {
createSentryMockServer,
apiRoutes,
TEST_ISSUE_ID,
TEST_ISSUE_SHORT_ID,
TEST_ORG,
TEST_PROJECT,
TEST_TOKEN,
} from "../mocks/routes.js";
import type { MockServer } from "../mocks/server.js";
import { createMockServer, type MockServer } from "../mocks/server.js";

const EVENT_ID = "abcd1234abcd5678abcd1234abcd5678";
const event = { ...eventFixture, id: EVENT_ID, eventID: EVENT_ID };
const LATEST_EVENT_ID = "fedc1234fedc5678fedc1234fedc5678";
const latestEvent = {
...event,
id: LATEST_EVENT_ID,
eventID: LATEST_EVENT_ID,
dateCreated: "2024-01-16T09:45:00.000000Z",
};

let testConfigDir: string;
let mockServer: MockServer;
let ctx: E2EContext;

beforeAll(async () => {
mockServer = createSentryMockServer();
mockServer = createMockServer(
[
{
method: "GET",
path: `/api/0/organizations/${TEST_ORG}/issues/${TEST_ISSUE_ID}/events/`,
response: [latestEvent, event],
},
{
method: "GET",
path: `/api/0/projects/${TEST_ORG}/${TEST_PROJECT}/events/${EVENT_ID}/`,
response: event,
},
{
method: "GET",
path: `/api/0/projects/${TEST_ORG}/${TEST_PROJECT}/events/${EVENT_ID}/attachments/`,
response: [],
},
{
method: "GET",
path: `/api/0/organizations/${TEST_ORG}/issues/${TEST_ISSUE_ID}/events/latest/`,
response: latestEvent,
},
{
method: "GET",
path: `/api/0/projects/${TEST_ORG}/${TEST_PROJECT}/events/${LATEST_EVENT_ID}/attachments/`,
response: [],
},
...apiRoutes,
],
{ validTokens: [TEST_TOKEN] },
);
await mockServer.start();
});

Expand All @@ -47,6 +90,76 @@ afterEach(async () => {
});

describe("sentry event view", () => {
test.each([TEST_ISSUE_SHORT_ID, `${TEST_ORG}/${TEST_ISSUE_SHORT_ID}`])(
"opens the listed event instead of the latest for %s <ID>",
async (issueArg) => {
await ctx.setAuthToken(TEST_TOKEN);
const defaults = await ctx.run(["cli", "defaults", "org", TEST_ORG]);
expect(defaults.exitCode, defaults.stderr).toBe(0);

const latest = await ctx.run([
"event",
"view",
issueArg,
"--json",
"--spans",
"0",
]);
expect(latest.exitCode, latest.stderr).toBe(0);
expect(JSON.parse(latest.stdout)).toMatchObject({
eventID: LATEST_EVENT_ID,
});

const listed = await ctx.run([
"issue",
"events",
issueArg,
"--limit",
"2",
]);
expect(listed.exitCode, listed.stderr).toBe(0);
const copiedId = listed.stdout.match(/\b[0-9a-f]{32}\b/g)?.[1] ?? "";
expect(copiedId, listed.stdout).toBe(EVENT_ID);

const viewed = await ctx.run([
"event",
"view",
issueArg,
copiedId,
"--json",
"--spans",
"0",
]);
expect(viewed.exitCode, viewed.stderr).toBe(0);
expect(JSON.parse(viewed.stdout)).toMatchObject({ eventID: copiedId });
},
);

test.each([
{ id: "abcd1234abcd", error: /32-character hexadecimal/i },
{ id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", error: /not found/i },
])(
"fails instead of showing latest when the selected ID is $id",
async ({ id, error }) => {
await ctx.setAuthToken(TEST_TOKEN);
const defaults = await ctx.run(["cli", "defaults", "org", TEST_ORG]);
expect(defaults.exitCode, defaults.stderr).toBe(0);

const result = await ctx.run([
"event",
"view",
TEST_ISSUE_SHORT_ID,
id,
"--json",
"--spans",
"0",
]);
expect(result.exitCode, result.stderr).not.toBe(0);
expect(result.stderr).toMatch(error);
expect(result.stdout).not.toContain(LATEST_EVENT_ID);
},
);

test("requires authentication", async () => {
// Use positional arg format: <org>/<project> <event-id>
const result = await ctx.run([
Expand Down
Loading