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
60 changes: 50 additions & 10 deletions src/commands/event/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,15 @@

import { buildCommand } from "@stricli/core";
import type { SentryContext } from "../../context.js";
import { getEvent } from "../../lib/api-client.js";
import { getEvent, getTrace } from "../../lib/api-client.js";
import { openInBrowser } from "../../lib/browser.js";
import { ContextError } from "../../lib/errors.js";
import { formatEventDetails, writeJson } from "../../lib/formatters/index.js";
import {
formatEventDetails,
formatSpanTree,
muted,
writeJson,
} from "../../lib/formatters/index.js";
import { resolveOrgAndProject } from "../../lib/resolve-target.js";
import { buildEventSearchUrl } from "../../lib/sentry-urls.js";
import type { SentryEvent, Writer } from "../../types/index.js";
Expand All @@ -19,26 +24,34 @@ type ViewFlags = {
readonly project?: string;
readonly json: boolean;
readonly web: boolean;
readonly spans?: number;
};

type HumanOutputOptions = {
event: SentryEvent;
detectedFrom?: string;
spanTreeLines?: string[];
};

/**
* Write human-readable event output to stdout.
*
* @param stdout - Output stream
* @param event - The event to display
* @param detectedFrom - Optional source description for auto-detection
* @param options - Output options including event, detectedFrom, and spanTreeLines
*/
function writeHumanOutput(
stdout: Writer,
event: SentryEvent,
detectedFrom?: string
): void {
function writeHumanOutput(stdout: Writer, options: HumanOutputOptions): void {
const { event, detectedFrom, spanTreeLines } = options;

const lines = formatEventDetails(event, `Event ${event.eventID}`);

// Skip leading empty line for standalone display
const output = lines.slice(1);
stdout.write(`${output.join("\n")}\n`);

if (spanTreeLines && spanTreeLines.length > 0) {
stdout.write(`${spanTreeLines.join("\n")}\n`);
}

if (detectedFrom) {
stdout.write(`\nDetected from ${detectedFrom}\n`);
}
Expand Down Expand Up @@ -88,6 +101,13 @@ export const viewCommand = buildCommand({
brief: "Open in browser",
default: false,
},
spans: {
kind: "parsed",
parse: (input: string) => Number(input === "" ? 1 : input),
brief: "Show span tree from the event's trace",
optional: true,
inferEmpty: true,
},
},
aliases: { w: "web" },
},
Expand Down Expand Up @@ -123,11 +143,31 @@ export const viewCommand = buildCommand({

const event = await getEvent(target.org, target.project, eventId);

let spanTreeLines: string[] | undefined;
if (flags.spans !== undefined && event.contexts?.trace?.trace_id) {
try {
const traceEvents = await getTrace(
target.org,
event.contexts.trace.trace_id
);
spanTreeLines = formatSpanTree(traceEvents);
} catch {
// Non-fatal: trace data may not be available for all events
spanTreeLines = [muted("\nUnable to fetch span tree for this event.")];
}
} else if (flags.spans !== undefined && !event.contexts?.trace?.trace_id) {
spanTreeLines = [muted("\nNo trace data available for this event.")];
}

if (flags.json) {
writeJson(stdout, event);
return;
}

writeHumanOutput(stdout, event, target.detectedFrom);
writeHumanOutput(stdout, {
event,
detectedFrom: target.detectedFrom,
spanTreeLines,
});
},
});
79 changes: 72 additions & 7 deletions src/commands/issue/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@

import { buildCommand } from "@stricli/core";
import type { SentryContext } from "../../context.js";
import { getLatestEvent } from "../../lib/api-client.js";
import { getDetailedTrace, getLatestEvent } from "../../lib/api-client.js";
import { openInBrowser } from "../../lib/browser.js";
import {
formatEventDetails,
formatIssueDetails,
formatSimpleSpanTree,
muted,
writeFooter,
writeJson,
} from "../../lib/formatters/index.js";
Expand All @@ -26,6 +28,7 @@ import {
interface ViewFlags extends IssueIdFlags {
readonly json: boolean;
readonly web: boolean;
readonly spans?: number;
}

/**
Expand All @@ -42,18 +45,22 @@ async function tryGetLatestEvent(
try {
return await getLatestEvent(orgSlug, issueId);
} catch {
// Non-blocking: event fetch failures shouldn't prevent issue display
return;
}
}

type HumanOutputOptions = {
issue: SentryIssue;
event?: SentryEvent;
};

/**
* Write human-readable issue output
*/
function writeHumanOutput(
stdout: Writer,
issue: SentryIssue,
event?: SentryEvent
): void {
function writeHumanOutput(stdout: Writer, options: HumanOutputOptions): void {
const { issue, event } = options;

const issueLines = formatIssueDetails(issue);
stdout.write(`${issueLines.join("\n")}\n`);

Expand All @@ -68,6 +75,44 @@ function writeHumanOutput(
}
}

/**
* Display the span tree for an event.
* Shows ONLY the tree structure, no issue/event details.
*
* @param stdout - Output writer
* @param orgSlug - Organization slug
* @param event - The event to get trace from
* @param maxDepth - Maximum nesting depth to display
* @returns true if successfully displayed, false if missing data
*/
async function displaySpanTree(
stdout: Writer,
orgSlug: string,
event: SentryEvent,
maxDepth: number
): Promise<boolean> {
const traceId = event.contexts?.trace?.trace_id;
const dateCreated = (event as { dateCreated?: string }).dateCreated;
const timestamp = dateCreated
? new Date(dateCreated).getTime() / 1000
: undefined;

if (!(traceId && timestamp)) {
stdout.write(muted("No trace data available for this event.\n"));
return false;
}

try {
const spans = await getDetailedTrace(orgSlug, traceId, timestamp);
const lines = formatSimpleSpanTree(traceId, spans, maxDepth);
stdout.write(`${lines.join("\n")}\n`);
return true;
} catch {
stdout.write(muted("Unable to fetch span tree for this event.\n"));
return false;
}
}

export const viewCommand = buildCommand({
docs: {
brief: "View details of a specific issue",
Expand Down Expand Up @@ -97,6 +142,13 @@ export const viewCommand = buildCommand({
brief: "Open in browser",
default: false,
},
spans: {
kind: "parsed",
parse: (input: string) => Number(input === "" ? 1 : input),
brief: "Show span tree with N levels of nesting depth",
optional: true,
inferEmpty: true,
},
},
aliases: { w: "web" },
},
Expand Down Expand Up @@ -132,7 +184,20 @@ export const viewCommand = buildCommand({
return;
}

writeHumanOutput(stdout, issue, event);
writeHumanOutput(stdout, { issue, event });

if (flags.spans !== undefined && orgSlug && event) {
const depth = flags.spans > 0 ? flags.spans : Number.MAX_SAFE_INTEGER;
stdout.write("\n");
await displaySpanTree(stdout, orgSlug, event, depth);
} else if (flags.spans !== undefined && !orgSlug) {
stdout.write(
muted("\nOrganization context required to fetch span tree.\n")
);
} else if (flags.spans !== undefined && !event) {
stdout.write(muted("\nCould not fetch event to display span tree.\n"));
}

writeFooter(
stdout,
`Tip: Use 'sentry issue explain ${issue.shortId}' for AI root cause analysis`
Expand Down
53 changes: 49 additions & 4 deletions src/lib/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import {
SentryOrganizationSchema,
type SentryProject,
SentryProjectSchema,
type TraceResponse,
type TraceSpan,
} from "../types/index.js";
import type { AutofixResponse, AutofixState } from "../types/seer.js";
import { refreshToken } from "./config.js";
Expand Down Expand Up @@ -422,10 +424,7 @@ export function getLatestEvent(
issueId: string
): Promise<SentryEvent> {
return apiRequest<SentryEvent>(
`/organizations/${orgSlug}/issues/${issueId}/events/latest/`,
{
schema: SentryEventSchema,
}
`/organizations/${orgSlug}/issues/${issueId}/events/latest/`
);
Comment on lines 424 to 428
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The getLatestEvent function lacks schema validation for the API response, unlike similar functions, risking runtime errors from malformed data.
Severity: MEDIUM

Suggested Fix

Restore the schema validation by adding schema: SentryEventSchema to the apiRequest call within the getLatestEvent function. This will align it with the implementation of the getEvent function and ensure response integrity.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: src/lib/api-client.ts#L424-L428

Potential issue: The `getLatestEvent` function in `api-client.ts` has had its schema
validation removed. This is inconsistent with other high-level API methods in the file,
which use Zod schemas to ensure runtime safety. Without validation, if the Sentry API
returns a malformed response, such as one missing `dateCreated` or
`contexts.trace.trace_id`, the function will not fail fast. Instead, it will return
incomplete data. Downstream code in `src/commands/issue/view.ts`, which relies on these
properties for the `--spans` feature, may then fail silently or produce incorrect
output.

}

Expand All @@ -446,6 +445,52 @@ export function getEvent(
);
}

/**
* Get trace data including all transactions and spans.
* Returns the full trace tree for visualization.
*
* @param orgSlug - Organization slug
* @param traceId - The trace ID (from event.contexts.trace.trace_id)
* @returns Trace response with transactions array and orphan_errors
*/
export function getTrace(
orgSlug: string,
traceId: string
): Promise<TraceResponse> {
return apiRequest<TraceResponse>(
`/organizations/${orgSlug}/events-trace/${traceId}/`
);
}

/**
* Get detailed trace with nested children structure.
* Uses the same endpoint as Sentry's dashboard for hierarchical span trees.
*
* @param orgSlug - Organization slug
* @param traceId - The trace ID (from event.contexts.trace.trace_id)
* @param timestamp - Unix timestamp (seconds) from the event's dateCreated
* @returns Array of root spans with nested children
*/
export function getDetailedTrace(
orgSlug: string,
traceId: string,
timestamp: number
): Promise<TraceSpan[]> {
return apiRequest<TraceSpan[]>(
`/organizations/${orgSlug}/trace/${traceId}/`,
{
params: {
timestamp,
// Maximum spans to fetch - 10k is sufficient for most traces while
// preventing excessive response sizes for very large traces
limit: 10_000,
// -1 means "all projects" - required since trace can span multiple projects
project: -1,
},
}
);
}

/**
* Update an issue's status
*/
Expand Down
Loading
Loading