diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 387af9dc..a7315334 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -627,6 +627,33 @@ sentry log view my-org/backend 968c763c740cfda8b6728f27fb9e9b01 sentry log list --json | jq '.[] | select(.level == "error")' ``` +### Span + +View spans in distributed traces + +#### `sentry span list ` + +List spans in a trace + +**Flags:** +- `-n, --limit - Number of spans (1-1000) - (default: "25")` +- `-q, --query - Filter spans (e.g., "op:db", "duration:>100ms", "project:backend")` +- `-s, --sort - Sort by: time (default), duration - (default: "time")` +- `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` +- `--json - Output as JSON` +- `--fields - Comma-separated fields to include in JSON output (dot.notation supported)` + +#### `sentry span view ` + +View details of specific spans + +**Flags:** +- `-t, --trace - Trace ID containing the span(s) (required)` +- `--spans - Span tree depth limit (number, "all" for unlimited, "no" to disable) - (default: "3")` +- `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` +- `--json - Output as JSON` +- `--fields - Comma-separated fields to include in JSON output (dot.notation supported)` + ### Trace View distributed traces @@ -798,6 +825,22 @@ List logs from a project - `--json - Output as JSON` - `--fields - Comma-separated fields to include in JSON output (dot.notation supported)` +### Spans + +List spans in a trace + +#### `sentry spans ` + +List spans in a trace + +**Flags:** +- `-n, --limit - Number of spans (1-1000) - (default: "25")` +- `-q, --query - Filter spans (e.g., "op:db", "duration:>100ms", "project:backend")` +- `-s, --sort - Sort by: time (default), duration - (default: "time")` +- `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` +- `--json - Output as JSON` +- `--fields - Comma-separated fields to include in JSON output (dot.notation supported)` + ### Traces List recent traces in a project diff --git a/src/app.ts b/src/app.ts index 231367d6..431c14c9 100644 --- a/src/app.ts +++ b/src/app.ts @@ -24,6 +24,8 @@ import { projectRoute } from "./commands/project/index.js"; import { listCommand as projectListCommand } from "./commands/project/list.js"; import { repoRoute } from "./commands/repo/index.js"; import { listCommand as repoListCommand } from "./commands/repo/list.js"; +import { spanRoute } from "./commands/span/index.js"; +import { listCommand as spanListCommand } from "./commands/span/list.js"; import { teamRoute } from "./commands/team/index.js"; import { listCommand as teamListCommand } from "./commands/team/list.js"; import { traceRoute } from "./commands/trace/index.js"; @@ -50,6 +52,7 @@ const PLURAL_TO_SINGULAR: Record = { repos: "repo", teams: "team", logs: "log", + spans: "span", traces: "trace", trials: "trial", }; @@ -67,6 +70,7 @@ export const routes = buildRouteMap({ issue: issueRoute, event: eventRoute, log: logRoute, + span: spanRoute, trace: traceRoute, trial: trialRoute, init: initCommand, @@ -77,6 +81,7 @@ export const routes = buildRouteMap({ repos: repoListCommand, teams: teamListCommand, logs: logListCommand, + spans: spanListCommand, traces: traceListCommand, trials: trialListCommand, whoami: whoamiCommand, diff --git a/src/commands/span/index.ts b/src/commands/span/index.ts new file mode 100644 index 00000000..a0a30b2e --- /dev/null +++ b/src/commands/span/index.ts @@ -0,0 +1,24 @@ +/** + * sentry span + * + * View and explore individual spans within distributed traces. + */ + +import { buildRouteMap } from "@stricli/core"; +import { listCommand } from "./list.js"; +import { viewCommand } from "./view.js"; + +export const spanRoute = buildRouteMap({ + routes: { + list: listCommand, + view: viewCommand, + }, + docs: { + brief: "View spans in distributed traces", + fullDescription: + "View and explore individual spans within distributed traces.\n\n" + + "Commands:\n" + + " list List spans in a trace\n" + + " view View details of specific spans", + }, +}); diff --git a/src/commands/span/list.ts b/src/commands/span/list.ts new file mode 100644 index 00000000..b4f2864a --- /dev/null +++ b/src/commands/span/list.ts @@ -0,0 +1,281 @@ +/** + * sentry span list + * + * List spans in a distributed trace with optional filtering and sorting. + */ + +import type { SentryContext } from "../../context.js"; +import { listSpans } from "../../lib/api-client.js"; +import { + parseOrgProjectArg, + parseSlashSeparatedArg, + validateLimit, +} from "../../lib/arg-parsing.js"; +import { buildCommand } from "../../lib/command.js"; +import { ContextError, ValidationError } from "../../lib/errors.js"; +import { + spanListItemToFlatSpan, + translateSpanQuery, + writeFooter, + writeJsonList, + writeSpanTable, +} from "../../lib/formatters/index.js"; +import { + applyFreshFlag, + FRESH_ALIASES, + FRESH_FLAG, +} from "../../lib/list-command.js"; +import { logger } from "../../lib/logger.js"; +import { + resolveOrgAndProject, + resolveProjectBySlug, +} from "../../lib/resolve-target.js"; +import { validateTraceId } from "../../lib/trace-id.js"; + +type ListFlags = { + readonly limit: number; + readonly query?: string; + readonly sort: "time" | "duration"; + readonly json: boolean; + readonly fresh: boolean; + readonly fields?: string[]; +}; + +type SortValue = "time" | "duration"; + +/** Accepted values for the --sort flag */ +const VALID_SORT_VALUES: SortValue[] = ["time", "duration"]; + +/** Maximum allowed value for --limit flag */ +const MAX_LIMIT = 1000; + +/** Minimum allowed value for --limit flag */ +const MIN_LIMIT = 1; + +/** Default number of spans to show */ +const DEFAULT_LIMIT = 25; + +/** Usage hint for ContextError messages */ +const USAGE_HINT = "sentry span list [/] "; + +/** + * Parse positional arguments for span list. + * Handles: `` or ` ` + * + * @param args - Positional arguments from CLI + * @returns Parsed trace ID and optional target arg + * @throws {ContextError} If no arguments provided + * @throws {ValidationError} If the trace ID format is invalid + */ +export function parsePositionalArgs(args: string[]): { + traceId: string; + targetArg: string | undefined; +} { + if (args.length === 0) { + throw new ContextError("Trace ID", USAGE_HINT); + } + + const first = args[0]; + if (first === undefined) { + throw new ContextError("Trace ID", USAGE_HINT); + } + + if (args.length === 1) { + const { id, targetArg } = parseSlashSeparatedArg( + first, + "Trace ID", + USAGE_HINT + ); + return { traceId: validateTraceId(id), targetArg }; + } + + const second = args[1]; + if (second === undefined) { + return { traceId: validateTraceId(first), targetArg: undefined }; + } + + // Two or more args — first is target, second is trace ID + return { traceId: validateTraceId(second), targetArg: first }; +} + +/** + * Parse --limit flag, delegating range validation to shared utility. + */ +function parseLimit(value: string): number { + return validateLimit(value, MIN_LIMIT, MAX_LIMIT); +} + +/** + * Parse and validate sort flag value. + * + * @throws Error if value is not "time" or "duration" + */ +export function parseSort(value: string): SortValue { + if (!VALID_SORT_VALUES.includes(value as SortValue)) { + throw new Error( + `Invalid sort value. Must be one of: ${VALID_SORT_VALUES.join(", ")}` + ); + } + return value as SortValue; +} + +export const listCommand = buildCommand({ + docs: { + brief: "List spans in a trace", + fullDescription: + "List spans in a distributed trace with optional filtering and sorting.\n\n" + + "Target specification:\n" + + " sentry span list # auto-detect from DSN or config\n" + + " sentry span list / # explicit org and project\n" + + " sentry span list # find project across all orgs\n\n" + + "The trace ID is the 32-character hexadecimal identifier.\n\n" + + "Examples:\n" + + " sentry span list # List spans in trace\n" + + " sentry span list --limit 50 # Show more spans\n" + + ' sentry span list -q "op:db" # Filter by operation\n' + + " sentry span list --sort duration # Sort by slowest first\n" + + ' sentry span list -q "duration:>100ms" # Spans slower than 100ms', + }, + output: "json", + parameters: { + positional: { + kind: "array", + parameter: { + placeholder: "args", + brief: + "[/] - Target (optional) and trace ID (required)", + parse: String, + }, + }, + flags: { + limit: { + kind: "parsed", + parse: parseLimit, + brief: `Number of spans (${MIN_LIMIT}-${MAX_LIMIT})`, + default: String(DEFAULT_LIMIT), + }, + query: { + kind: "parsed", + parse: String, + brief: + 'Filter spans (e.g., "op:db", "duration:>100ms", "project:backend")', + optional: true, + }, + sort: { + kind: "parsed", + parse: parseSort, + brief: "Sort by: time (default), duration", + default: "time" as const, + }, + fresh: FRESH_FLAG, + }, + aliases: { + ...FRESH_ALIASES, + n: "limit", + q: "query", + s: "sort", + }, + }, + async func( + this: SentryContext, + flags: ListFlags, + ...args: string[] + ): Promise { + applyFreshFlag(flags); + const { stdout, cwd, setContext } = this; + const log = logger.withTag("span.list"); + + // Parse positional args + const { traceId, targetArg } = parsePositionalArgs(args); + const parsed = parseOrgProjectArg(targetArg); + if (parsed.type !== "auto-detect" && parsed.normalized) { + log.warn("Normalized slug (Sentry slugs use dashes, not underscores)"); + } + + // Resolve target + let target: { org: string; project: string } | null = null; + + switch (parsed.type) { + case "explicit": + target = { org: parsed.org, project: parsed.project }; + break; + + case "project-search": + target = await resolveProjectBySlug( + parsed.projectSlug, + USAGE_HINT, + `sentry span list /${parsed.projectSlug} ${traceId}` + ); + break; + + case "org-all": + throw new ContextError("Specific project", USAGE_HINT); + + case "auto-detect": + target = await resolveOrgAndProject({ cwd, usageHint: USAGE_HINT }); + break; + + default: { + const _exhaustiveCheck: never = parsed; + throw new ValidationError( + `Invalid target specification: ${_exhaustiveCheck}` + ); + } + } + + if (!target) { + throw new ContextError("Organization and project", USAGE_HINT); + } + + setContext([target.org], [target.project]); + + // Build server-side query + const queryParts = [`trace:${traceId}`]; + if (flags.query) { + queryParts.push(translateSpanQuery(flags.query)); + } + const apiQuery = queryParts.join(" "); + + // Fetch spans from EAP endpoint + const { data: spanItems, nextCursor } = await listSpans( + target.org, + target.project, + { + query: apiQuery, + sort: flags.sort, + limit: flags.limit, + } + ); + + const flatSpans = spanItems.map(spanListItemToFlatSpan); + const hasMore = nextCursor !== undefined; + + if (flags.json) { + writeJsonList(stdout, flatSpans, { + hasMore, + fields: flags.fields, + }); + return; + } + + if (flatSpans.length === 0) { + stdout.write("No spans matched the query.\n"); + return; + } + + stdout.write(`Spans in trace ${traceId}:\n\n`); + writeSpanTable(stdout, flatSpans); + + // Footer + const countText = `Showing ${flatSpans.length} span${flatSpans.length === 1 ? "" : "s"}.`; + + if (hasMore) { + writeFooter(stdout, `${countText} Use --limit to see more.`); + } else { + writeFooter( + stdout, + `${countText} Use 'sentry span view --trace ${traceId}' to view span details.` + ); + } + }, +}); diff --git a/src/commands/span/view.ts b/src/commands/span/view.ts new file mode 100644 index 00000000..37f55ab7 --- /dev/null +++ b/src/commands/span/view.ts @@ -0,0 +1,355 @@ +/** + * sentry span view + * + * View detailed information about one or more spans within a trace. + */ + +import type { SentryContext } from "../../context.js"; +import { getDetailedTrace } from "../../lib/api-client.js"; +import { + parseOrgProjectArg, + parseSlashSeparatedArg, + spansFlag, +} from "../../lib/arg-parsing.js"; +import { buildCommand } from "../../lib/command.js"; +import { ContextError, ValidationError } from "../../lib/errors.js"; +import { + type FoundSpan, + findSpanById, + formatSimpleSpanTree, + formatSpanDetails, + writeJson, +} from "../../lib/formatters/index.js"; +import { + applyFreshFlag, + FRESH_ALIASES, + FRESH_FLAG, +} from "../../lib/list-command.js"; +import { logger } from "../../lib/logger.js"; +import { + resolveOrgAndProject, + resolveProjectBySlug, +} from "../../lib/resolve-target.js"; +import { validateTraceId } from "../../lib/trace-id.js"; + +const log = logger.withTag("span.view"); + +type ViewFlags = { + readonly trace: string; + readonly json: boolean; + readonly spans: number; + readonly fresh: boolean; + readonly fields?: string[]; +}; + +/** Regex for a 16-character hex span ID */ +const SPAN_ID_RE = /^[0-9a-f]{16}$/i; + +/** Usage hint for ContextError messages */ +const USAGE_HINT = + "sentry span view [/] [...] --trace "; + +/** + * Validate that a string is a 16-character hexadecimal span ID. + * + * @param value - The string to validate + * @returns The trimmed, lowercased span ID + * @throws {ValidationError} If the format is invalid + */ +export function validateSpanId(value: string): string { + const trimmed = value.trim().toLowerCase(); + if (!SPAN_ID_RE.test(trimmed)) { + throw new ValidationError( + `Invalid span ID "${trimmed}". Expected a 16-character hexadecimal string.\n\n` + + "Example: a1b2c3d4e5f67890" + ); + } + return trimmed; +} + +/** + * Check if a string looks like a 16-char hex span ID. + * Used to distinguish span IDs from target args without throwing. + */ +function looksLikeSpanId(value: string): boolean { + return SPAN_ID_RE.test(value.trim()); +} + +/** + * Parse positional arguments for span view. + * Handles: + * - `` — single span ID (auto-detect org/project) + * - ` ...` — multiple span IDs + * - ` [...]` — explicit target + span IDs + * + * The first arg is treated as a target if it contains "/" or doesn't look + * like a 16-char hex span ID. + * + * @param args - Positional arguments from CLI + * @returns Parsed span IDs and optional target arg + * @throws {ContextError} If no arguments provided + * @throws {ValidationError} If any span ID has an invalid format + */ +export function parsePositionalArgs(args: string[]): { + spanIds: string[]; + targetArg: string | undefined; +} { + if (args.length === 0) { + throw new ContextError("Span ID", USAGE_HINT); + } + + const first = args[0]; + if (first === undefined) { + throw new ContextError("Span ID", USAGE_HINT); + } + + if (args.length === 1) { + // Single arg — could be slash-separated or a plain span ID + const { id, targetArg } = parseSlashSeparatedArg( + first, + "Span ID", + USAGE_HINT + ); + const spanIds = [validateSpanId(id)]; + return { spanIds, targetArg }; + } + + // Multiple args — determine if first is a target or span ID + if (first.includes("/") || !looksLikeSpanId(first)) { + // First arg is a target + const rawIds = args.slice(1); + const spanIds = rawIds.map((v) => validateSpanId(v)); + if (spanIds.length === 0) { + throw new ContextError("Span ID", USAGE_HINT); + } + return { spanIds, targetArg: first }; + } + + // All args are span IDs + const spanIds = args.map((v) => validateSpanId(v)); + return { spanIds, targetArg: undefined }; +} + +/** + * Format a list of span IDs as a markdown bullet list. + */ +function formatIdList(ids: string[]): string { + return ids.map((id) => ` - \`${id}\``).join("\n"); +} + +/** + * Warn about span IDs that weren't found in the trace. + */ +function warnMissingIds(spanIds: string[], foundIds: Set): void { + const missing = spanIds.filter((id) => !foundIds.has(id)); + if (missing.length > 0) { + log.warn( + `${missing.length} of ${spanIds.length} span(s) not found in trace:\n${formatIdList(missing)}` + ); + } +} + +/** Resolved target type for span commands. */ +type ResolvedSpanTarget = { org: string; project: string }; + +/** + * Resolve org/project from the parsed target argument. + */ +async function resolveTarget( + parsed: ReturnType, + spanIds: string[], + traceId: string, + cwd: string +): Promise { + switch (parsed.type) { + case "explicit": + return { org: parsed.org, project: parsed.project }; + + case "project-search": + return await resolveProjectBySlug( + parsed.projectSlug, + USAGE_HINT, + `sentry span view /${parsed.projectSlug} ${spanIds[0]} --trace ${traceId}` + ); + + case "org-all": + throw new ContextError("Specific project", USAGE_HINT); + + case "auto-detect": + return await resolveOrgAndProject({ cwd, usageHint: USAGE_HINT }); + + default: { + const _exhaustiveCheck: never = parsed; + throw new ValidationError( + `Invalid target specification: ${_exhaustiveCheck}` + ); + } + } +} + +/** Resolved span result from tree search. */ +type SpanResult = FoundSpan & { spanId: string }; + +/** + * Serialize span results for JSON output. + */ +function buildJsonResults(results: SpanResult[], traceId: string): unknown { + const mapped = results.map((r) => ({ + span_id: r.span.span_id, + parent_span_id: r.span.parent_span_id, + trace_id: traceId, + op: r.span.op || r.span["transaction.op"], + description: r.span.description || r.span.transaction, + start_timestamp: r.span.start_timestamp, + end_timestamp: r.span.end_timestamp || r.span.timestamp, + duration: r.span.duration, + project_slug: r.span.project_slug, + transaction: r.span.transaction, + depth: r.depth, + ancestors: r.ancestors.map((a) => ({ + span_id: a.span_id, + op: a.op || a["transaction.op"], + description: a.description || a.transaction, + })), + children: (r.span.children ?? []).map((c) => ({ + span_id: c.span_id, + op: c.op || c["transaction.op"], + description: c.description || c.transaction, + })), + })); + return mapped; +} + +export const viewCommand = buildCommand({ + docs: { + brief: "View details of specific spans", + fullDescription: + "View detailed information about one or more spans within a trace.\n\n" + + "Target specification:\n" + + " sentry span view --trace # auto-detect\n" + + " sentry span view / --trace # explicit\n" + + " sentry span view --trace # project search\n\n" + + "The --trace flag is required to identify which trace contains the span(s).\n" + + "Multiple span IDs can be passed as separate arguments.\n\n" + + "Examples:\n" + + " sentry span view a1b2c3d4e5f67890 --trace \n" + + " sentry span view a1b2c3d4e5f67890 b2c3d4e5f6789012 --trace ", + }, + output: "json", + parameters: { + positional: { + kind: "array", + parameter: { + placeholder: "args", + brief: + "[/] [...] - Target (optional) and one or more span IDs", + parse: String, + }, + }, + flags: { + trace: { + kind: "parsed", + parse: validateTraceId, + brief: "Trace ID containing the span(s) (required)", + }, + ...spansFlag, + fresh: FRESH_FLAG, + }, + aliases: { ...FRESH_ALIASES, t: "trace" }, + }, + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: view command with multi-span support + async func( + this: SentryContext, + flags: ViewFlags, + ...args: string[] + ): Promise { + applyFreshFlag(flags); + const { stdout, cwd, setContext } = this; + const cmdLog = logger.withTag("span.view"); + + const traceId = flags.trace; + + // Parse positional args + const { spanIds, targetArg } = parsePositionalArgs(args); + const parsed = parseOrgProjectArg(targetArg); + if (parsed.type !== "auto-detect" && parsed.normalized) { + cmdLog.warn("Normalized slug (Sentry slugs use dashes, not underscores)"); + } + + const target = await resolveTarget(parsed, spanIds, traceId, cwd); + + if (!target) { + throw new ContextError("Organization and project", USAGE_HINT); + } + + setContext([target.org], [target.project]); + + // Fetch trace data (single fetch for all span lookups) + const timestamp = Math.floor(Date.now() / 1000); + const spans = await getDetailedTrace(target.org, traceId, timestamp); + + if (spans.length === 0) { + throw new ValidationError( + `No trace found with ID "${traceId}".\n\n` + + "Make sure the trace ID is correct and the trace was sent recently." + ); + } + + // Find each requested span + const results: SpanResult[] = []; + const foundIds = new Set(); + + for (const spanId of spanIds) { + const found = findSpanById(spans, spanId); + if (found) { + results.push({ + spanId, + span: found.span, + ancestors: found.ancestors, + depth: found.depth, + }); + foundIds.add(spanId); + } + } + + if (results.length === 0) { + const idList = formatIdList(spanIds); + throw new ValidationError( + spanIds.length === 1 + ? `No span found with ID "${spanIds[0]}" in trace ${traceId}.` + : `No spans found with any of the following IDs in trace ${traceId}:\n${idList}` + ); + } + + warnMissingIds(spanIds, foundIds); + + if (flags.json) { + writeJson(stdout, buildJsonResults(results, traceId), flags.fields); + return; + } + + // Human output + let first = true; + for (const result of results) { + if (!first) { + stdout.write("\n---\n\n"); + } + stdout.write(formatSpanDetails(result.span, result.ancestors, traceId)); + + // Show child tree if --spans > 0 and the span has children + const children = result.span.children ?? []; + if (flags.spans > 0 && children.length > 0) { + const treeLines = formatSimpleSpanTree( + traceId, + [result.span], + flags.spans + ); + if (treeLines.length > 0) { + stdout.write(`${treeLines.join("\n")}\n`); + } + } + + first = false; + } + }, +}); diff --git a/src/commands/trace/view.ts b/src/commands/trace/view.ts index 982116e8..6f31e2a9 100644 --- a/src/commands/trace/view.ts +++ b/src/commands/trace/view.ts @@ -45,41 +45,12 @@ type ViewFlags = { /** Usage hint for ContextError messages */ const USAGE_HINT = "sentry trace view / "; -/** - * Validate a trace ID and detect UUID auto-correction. - * - * Returns the validated trace ID and an optional warning when dashes were - * stripped from a UUID-format input (e.g., `ed29abc8-71c4-475b-...`). - */ -function validateAndWarn(raw: string): { - traceId: string; - uuidWarning?: string; -} { - const traceId = validateTraceId(raw); - const trimmedRaw = raw.trim().toLowerCase(); - const uuidWarning = - trimmedRaw.includes("-") && trimmedRaw !== traceId - ? `Auto-corrected trace ID: stripped dashes → ${traceId}` - : undefined; - return { traceId, uuidWarning }; -} - -/** - * Merge multiple optional warning strings into a single warning, or undefined. - */ -function mergeWarnings( - ...warnings: (string | undefined)[] -): string | undefined { - const filtered = warnings.filter(Boolean); - return filtered.length > 0 ? filtered.join("\n") : undefined; -} - /** * Parse positional arguments for trace view. * Handles: `` or ` ` * - * Validates the trace ID format (32-character hex) and auto-corrects - * UUID-format inputs by stripping dashes. + * Validates the trace ID format (32-character hex) and silently strips + * dashes from UUID-format inputs. * * @param args - Positional arguments from CLI * @returns Parsed trace ID and optional target arg @@ -89,7 +60,7 @@ function mergeWarnings( export function parsePositionalArgs(args: string[]): { traceId: string; targetArg: string | undefined; - /** Warning message if arguments appear to be in the wrong order or UUID was auto-corrected */ + /** Warning message if arguments appear to be in the wrong order */ warning?: string; /** Suggestion when first arg looks like an issue short ID */ suggestion?: string; @@ -109,32 +80,21 @@ export function parsePositionalArgs(args: string[]): { "Trace ID", USAGE_HINT ); - const validated = validateAndWarn(id); - return { - traceId: validated.traceId, - targetArg, - warning: validated.uuidWarning, - }; + return { traceId: validateTraceId(id), targetArg }; } const second = args[1]; if (second === undefined) { - const validated = validateAndWarn(first); - return { - traceId: validated.traceId, - targetArg: undefined, - warning: validated.uuidWarning, - }; + return { traceId: validateTraceId(first), targetArg: undefined }; } // Detect swapped args: user put ID first and target second const swapWarning = detectSwappedViewArgs(first, second); if (swapWarning) { - const validated = validateAndWarn(first); return { - traceId: validated.traceId, + traceId: validateTraceId(first), targetArg: second, - warning: mergeWarnings(swapWarning, validated.uuidWarning), + warning: swapWarning, }; } @@ -144,11 +104,9 @@ export function parsePositionalArgs(args: string[]): { : undefined; // Two or more args - first is target, second is trace ID - const validated = validateAndWarn(second); return { - traceId: validated.traceId, + traceId: validateTraceId(second), targetArg: first, - warning: validated.uuidWarning, suggestion, }; } diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 51da7f67..e71e39fb 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -89,6 +89,7 @@ export { } from "./api/teams.js"; export { getDetailedTrace, + listSpans, listTransactions, normalizeTraceSpan, } from "./api/traces.js"; diff --git a/src/lib/api/traces.ts b/src/lib/api/traces.ts index 0b8cc895..be429e62 100644 --- a/src/lib/api/traces.ts +++ b/src/lib/api/traces.ts @@ -1,10 +1,13 @@ /** - * Trace and Transaction API functions + * Trace, Transaction, and Span API functions * - * Functions for retrieving detailed traces and listing transactions. + * Functions for retrieving detailed traces, listing transactions, and listing spans. */ import { + type SpanListItem, + type SpansResponse, + SpansResponseSchema, type TraceSpan, type TransactionListItem, type TransactionsResponse, @@ -144,3 +147,73 @@ export async function listTransactions( const { nextCursor } = parseLinkHeader(headers.get("link") ?? null); return { data: response.data, nextCursor }; } + +// Span listing + +/** Fields to request from the spans API */ +const SPAN_FIELDS = [ + "id", + "parent_span", + "span.op", + "description", + "span.duration", + "timestamp", + "project", + "transaction", + "trace", +]; + +type ListSpansOptions = { + /** Search query using Sentry query syntax */ + query?: string; + /** Maximum number of spans to return */ + limit?: number; + /** Sort order: "date"/"time" (newest first) or "duration" (slowest first) */ + sort?: "date" | "time" | "duration"; + /** Time period for spans (e.g., "7d", "24h") */ + statsPeriod?: string; + /** Pagination cursor to resume from a previous page */ + cursor?: string; +}; + +/** + * List spans using the EAP spans search endpoint. + * Uses the Explore/Events API with dataset=spans. + * + * @param orgSlug - Organization slug + * @param projectSlug - Project slug or numeric ID + * @param options - Query options (query, limit, sort, statsPeriod, cursor) + * @returns Paginated response with span items and optional next cursor + */ +export async function listSpans( + orgSlug: string, + projectSlug: string, + options: ListSpansOptions = {} +): Promise> { + const isNumericProject = isAllDigits(projectSlug); + const projectFilter = isNumericProject ? "" : `project:${projectSlug}`; + const fullQuery = [projectFilter, options.query].filter(Boolean).join(" "); + + const regionUrl = await resolveOrgRegion(orgSlug); + + const { data: response, headers } = await apiRequestToRegion( + regionUrl, + `/organizations/${orgSlug}/events/`, + { + params: { + dataset: "spans", + field: SPAN_FIELDS, + project: isNumericProject ? projectSlug : undefined, + query: fullQuery || undefined, + per_page: options.limit || 10, + statsPeriod: options.statsPeriod ?? "7d", + sort: options.sort === "duration" ? "-span.duration" : "-timestamp", + cursor: options.cursor, + }, + schema: SpansResponseSchema, + } + ); + + const { nextCursor } = parseLinkHeader(headers.get("link") ?? null); + return { data: response.data, nextCursor }; +} diff --git a/src/lib/formatters/human.ts b/src/lib/formatters/human.ts index 30442411..c2050d69 100644 --- a/src/lib/formatters/human.ts +++ b/src/lib/formatters/human.ts @@ -38,6 +38,7 @@ import { } from "./markdown.js"; import { sparkline } from "./sparkline.js"; import { type Column, writeTable } from "./table.js"; +import { computeSpanDurationMs } from "./trace.js"; // Color tag maps @@ -1076,24 +1077,6 @@ function buildRequestMarkdown(requestEntry: RequestEntry): string { // Span Tree Formatting -/** - * Compute the duration of a span in milliseconds. - * Prefers the API-provided `duration` field, falls back to timestamp arithmetic. - * - * @returns Duration in milliseconds, or undefined if not computable - */ -function computeSpanDurationMs(span: TraceSpan): number | undefined { - if (span.duration !== undefined && Number.isFinite(span.duration)) { - return span.duration; - } - const endTs = span.end_timestamp || span.timestamp; - if (endTs !== undefined && Number.isFinite(endTs)) { - const ms = (endTs - span.start_timestamp) * 1000; - return ms >= 0 ? ms : undefined; - } - return; -} - type FormatSpanOptions = { lines: string[]; prefix: string; @@ -1122,7 +1105,7 @@ function formatSpanSimple(span: TraceSpan, opts: FormatSpanOptions): void { line += ` ${muted(`(${prettyMs(durationMs)})`)}`; } - line += ` ${muted(span.span_id ?? "")}`; + line += ` ${muted(span.span_id)}`; lines.push(line); diff --git a/src/lib/formatters/trace.ts b/src/lib/formatters/trace.ts index 12b355d1..bee2653d 100644 --- a/src/lib/formatters/trace.ts +++ b/src/lib/formatters/trace.ts @@ -2,9 +2,15 @@ * Trace-specific formatters * * Provides formatting utilities for displaying Sentry traces in the CLI. + * Includes flat span utilities for `span list` and `span view` commands. */ -import type { TraceSpan, TransactionListItem } from "../../types/index.js"; +import type { + SpanListItem, + TraceSpan, + TransactionListItem, +} from "../../types/index.js"; +import { muted } from "./colors.js"; import { formatRelativeTime } from "./human.js"; import { escapeMarkdownCell, @@ -16,6 +22,7 @@ import { renderMarkdown, stripColorTags, } from "./markdown.js"; +import { type Column, writeTable } from "./table.js"; import { renderTextTable } from "./text-table.js"; /** @@ -279,3 +286,289 @@ export function formatTraceSummary(summary: TraceSummary): string { const md = `## Trace \`${summary.traceId}\`\n\n${mdKvTable(kvRows)}\n`; return renderMarkdown(md); } + +// --------------------------------------------------------------------------- +// Flat span utilities (for span list / span view) +// --------------------------------------------------------------------------- + +/** + * Compute the duration of a span in milliseconds. + * Prefers the API-provided `duration` field, falls back to timestamp arithmetic. + * + * @returns Duration in milliseconds, or undefined if not computable + */ +export function computeSpanDurationMs(span: TraceSpan): number | undefined { + if (span.duration !== undefined && Number.isFinite(span.duration)) { + return span.duration; + } + const endTs = span.end_timestamp || span.timestamp; + if (endTs !== undefined && Number.isFinite(endTs)) { + const ms = (endTs - span.start_timestamp) * 1000; + return ms >= 0 ? ms : undefined; + } + return; +} + +/** Flat span for list output — no nested children */ +export type FlatSpan = { + span_id: string; + parent_span_id?: string | null; + op?: string; + description?: string | null; + duration_ms?: number; + start_timestamp: number; + project_slug?: string; + transaction?: string; + depth?: number; + child_count?: number; +}; + +/** + * Flatten a hierarchical TraceSpan[] tree into a depth-first flat array. + * + * @param spans - Root-level spans from the /trace/ API + * @returns Flat array with depth and child_count computed + */ +export function flattenSpanTree(spans: TraceSpan[]): FlatSpan[] { + const result: FlatSpan[] = []; + + function walk(span: TraceSpan, depth: number): void { + const children = span.children ?? []; + result.push({ + span_id: span.span_id, + parent_span_id: span.parent_span_id, + op: span.op || span["transaction.op"], + description: span.description || span.transaction, + duration_ms: computeSpanDurationMs(span), + start_timestamp: span.start_timestamp, + project_slug: span.project_slug, + transaction: span.transaction, + depth, + child_count: children.length, + }); + for (const child of children) { + walk(child, depth + 1); + } + } + + for (const span of spans) { + walk(span, 0); + } + return result; +} + +/** Result of finding a span by ID in the tree */ +export type FoundSpan = { + span: TraceSpan; + depth: number; + ancestors: TraceSpan[]; +}; + +/** + * Find a span by ID in the tree, returning the span, its depth, and ancestor chain. + * + * @param spans - Root-level spans from the /trace/ API + * @param spanId - The span ID to search for + * @returns Found span with depth and ancestors (root→parent), or null + */ +export function findSpanById( + spans: TraceSpan[], + spanId: string +): FoundSpan | null { + function search( + span: TraceSpan, + depth: number, + ancestors: TraceSpan[] + ): FoundSpan | null { + if (span.span_id === spanId) { + return { span, depth, ancestors }; + } + for (const child of span.children ?? []) { + const found = search(child, depth + 1, [...ancestors, span]); + if (found) { + return found; + } + } + return null; + } + + for (const root of spans) { + const found = search(root, 0, []); + if (found) { + return found; + } + } + return null; +} + +/** Map of CLI shorthand keys to Sentry API span attribute names */ +const SPAN_KEY_ALIASES: Record = { + op: "span.op", + duration: "span.duration", +}; + +/** + * Translate CLI shorthand query keys to Sentry API span attribute names. + * Bare words pass through unchanged (server treats them as free-text search). + * + * @param query - Raw query string from --query flag + * @returns Translated query for the spans API + */ +export function translateSpanQuery(query: string): string { + const tokens = query.match(/(?:[^\s"]+|"[^"]*")+/g) ?? []; + return tokens + .map((token) => { + const colonIdx = token.indexOf(":"); + if (colonIdx === -1) { + return token; + } + const key = token.slice(0, colonIdx).toLowerCase(); + const rest = token.slice(colonIdx); + return (SPAN_KEY_ALIASES[key] ?? key) + rest; + }) + .join(" "); +} + +/** + * Map a SpanListItem from the EAP spans endpoint to a FlatSpan for display. + * + * @param item - Span item from the spans search API + * @returns FlatSpan suitable for table display + */ +export function spanListItemToFlatSpan(item: SpanListItem): FlatSpan { + return { + span_id: item.id, + parent_span_id: item.parent_span ?? undefined, + op: item["span.op"] ?? undefined, + description: item.description ?? undefined, + duration_ms: item["span.duration"] ?? undefined, + start_timestamp: new Date(item.timestamp).getTime() / 1000, + project_slug: item.project, + transaction: item.transaction ?? undefined, + }; +} + +/** Column definitions for the flat span table */ +const SPAN_TABLE_COLUMNS: Column[] = [ + { + header: "Span ID", + value: (s) => `\`${s.span_id}\``, + minWidth: 18, + shrinkable: false, + }, + { + header: "Op", + value: (s) => escapeMarkdownCell(s.op || "—"), + minWidth: 6, + }, + { + header: "Description", + value: (s) => escapeMarkdownCell(s.description || "(no description)"), + truncate: true, + }, + { + header: "Duration", + value: (s) => + s.duration_ms !== undefined ? formatTraceDuration(s.duration_ms) : "—", + align: "right", + minWidth: 8, + shrinkable: false, + }, +]; + +/** + * Write a flat span list as a formatted table. + * + * @param stdout - Output writer + * @param spans - Flat span array to display + */ +export function writeSpanTable( + stdout: { write(s: string): void }, + spans: FlatSpan[] +): void { + writeTable(stdout, spans, SPAN_TABLE_COLUMNS, { truncate: true }); +} + +/** + * Build key-value rows for a span's metadata. + */ +function buildSpanKvRows(span: TraceSpan, traceId: string): [string, string][] { + const kvRows: [string, string][] = []; + + kvRows.push(["Span ID", `\`${span.span_id}\``]); + kvRows.push(["Trace ID", `\`${traceId}\``]); + + if (span.parent_span_id) { + kvRows.push(["Parent", `\`${span.parent_span_id}\``]); + } + + const op = span.op || span["transaction.op"]; + if (op) { + kvRows.push(["Op", `\`${op}\``]); + } + + const desc = span.description || span.transaction; + if (desc) { + kvRows.push(["Description", escapeMarkdownCell(desc)]); + } + + const durationMs = computeSpanDurationMs(span); + if (durationMs !== undefined) { + kvRows.push(["Duration", formatTraceDuration(durationMs)]); + } + + if (span.project_slug) { + kvRows.push(["Project", span.project_slug]); + } + + if (isValidTimestamp(span.start_timestamp)) { + const date = new Date(span.start_timestamp * 1000); + kvRows.push(["Started", date.toLocaleString("sv-SE")]); + } + + kvRows.push(["Children", String((span.children ?? []).length)]); + + return kvRows; +} + +/** + * Format an ancestor chain as indented tree lines. + */ +function formatAncestorChain(ancestors: TraceSpan[]): string { + const lines: string[] = ["", muted("─── Ancestors ───"), ""]; + for (let i = 0; i < ancestors.length; i++) { + const a = ancestors[i]; + if (!a) { + continue; + } + const indent = " ".repeat(i); + const aOp = a.op || a["transaction.op"] || "unknown"; + const aDesc = a.description || a.transaction || "(no description)"; + lines.push(`${indent}${muted(aOp)} — ${aDesc} ${muted(`(${a.span_id})`)}`); + } + return `${lines.join("\n")}\n`; +} + +/** + * Format a single span's details for human-readable output. + * + * @param span - The TraceSpan to format + * @param ancestors - Ancestor chain from root to parent + * @param traceId - The trace ID for context + * @returns Rendered terminal string + */ +export function formatSpanDetails( + span: TraceSpan, + ancestors: TraceSpan[], + traceId: string +): string { + const kvRows = buildSpanKvRows(span, traceId); + const md = `## Span \`${span.span_id}\`\n\n${mdKvTable(kvRows)}\n`; + let output = renderMarkdown(md); + + if (ancestors.length > 0) { + output += formatAncestorChain(ancestors); + } + + return output; +} diff --git a/src/types/index.ts b/src/types/index.ts index 6db7ecf8..de47b5de 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -73,6 +73,8 @@ export type { SentryRepository, SentryTeam, SentryUser, + SpanListItem, + SpansResponse, StackFrame, Stacktrace, TraceContext, @@ -98,6 +100,8 @@ export { SentryRepositorySchema, SentryTeamSchema, SentryUserSchema, + SpanListItemSchema, + SpansResponseSchema, TraceLogSchema, TraceLogsResponseSchema, TransactionListItemSchema, diff --git a/src/types/sentry.ts b/src/types/sentry.ts index 2e1f36e8..b9bb0432 100644 --- a/src/types/sentry.ts +++ b/src/types/sentry.ts @@ -667,6 +667,36 @@ export const TransactionsResponseSchema = z.object({ export type TransactionsResponse = z.infer; +/** A single span item from the EAP spans search endpoint */ +export const SpanListItemSchema = z + .object({ + id: z.string(), + parent_span: z.string().nullable().optional(), + "span.op": z.string().nullable().optional(), + description: z.string().nullable().optional(), + "span.duration": z.number().nullable().optional(), + timestamp: z.string(), + project: z.string(), + transaction: z.string().nullable().optional(), + trace: z.string(), + }) + .passthrough(); + +export type SpanListItem = z.infer; + +/** Response from the spans events endpoint */ +export const SpansResponseSchema = z.object({ + data: z.array(SpanListItemSchema), + meta: z + .object({ + fields: z.record(z.string()).optional(), + }) + .passthrough() + .optional(), +}); + +export type SpansResponse = z.infer; + // Repository /** Repository provider (e.g., GitHub, GitLab) */ diff --git a/test/commands/trace/view.property.test.ts b/test/commands/trace/view.property.test.ts index b433d33d..b6fa2622 100644 --- a/test/commands/trace/view.property.test.ts +++ b/test/commands/trace/view.property.test.ts @@ -153,7 +153,6 @@ describe("parsePositionalArgs properties", () => { const uuid = toUuidFormat(hex); const result = parsePositionalArgs([uuid]); expect(result.traceId).toBe(hex); - expect(result.warning).toContain("Auto-corrected"); }), { numRuns: DEFAULT_NUM_RUNS } ); @@ -166,7 +165,6 @@ describe("parsePositionalArgs properties", () => { const result = parsePositionalArgs([target, uuid]); expect(result.traceId).toBe(hex); expect(result.targetArg).toBe(target); - expect(result.warning).toContain("Auto-corrected"); }), { numRuns: DEFAULT_NUM_RUNS } ); diff --git a/test/commands/trace/view.test.ts b/test/commands/trace/view.test.ts index 1f7ca22c..0daad540 100644 --- a/test/commands/trace/view.test.ts +++ b/test/commands/trace/view.test.ts @@ -141,30 +141,16 @@ describe("parsePositionalArgs", () => { expect(result.targetArg).toBeUndefined(); }); - test("returns warning when UUID dashes are stripped", () => { - const result = parsePositionalArgs([VALID_UUID]); - expect(result.warning).toBeDefined(); - expect(result.warning).toContain("Auto-corrected"); - expect(result.warning).toContain(VALID_UUID_STRIPPED); - }); - - test("no warning for plain 32-char hex", () => { - const result = parsePositionalArgs([VALID_TRACE_ID]); - expect(result.warning).toBeUndefined(); - }); - test("strips dashes from UUID trace ID (two-arg case)", () => { const result = parsePositionalArgs(["my-org/frontend", VALID_UUID]); expect(result.traceId).toBe(VALID_UUID_STRIPPED); expect(result.targetArg).toBe("my-org/frontend"); - expect(result.warning).toContain("Auto-corrected"); }); test("strips dashes from UUID in slash-separated form", () => { const result = parsePositionalArgs([`sentry/cli/${VALID_UUID}`]); expect(result.traceId).toBe(VALID_UUID_STRIPPED); expect(result.targetArg).toBe("sentry/cli"); - expect(result.warning).toContain("Auto-corrected"); }); test("handles real user input from CLI-7Z", () => { diff --git a/test/lib/formatters/trace.test.ts b/test/lib/formatters/trace.test.ts index b4116ea6..4fc15a83 100644 --- a/test/lib/formatters/trace.test.ts +++ b/test/lib/formatters/trace.test.ts @@ -1,6 +1,9 @@ /** * Unit Tests for Trace Formatters * + * Tests for formatTraceDuration, formatTraceTable, formatTracesHeader, formatTraceRow, + * computeTraceSummary, formatTraceSummary, and translateSpanQuery. + * * Note: Core invariants (duration formatting, trace ID containment, row newline * termination, determinism, span counting) are tested via property-based tests * in trace.property.test.ts. These tests focus on specific format output values, @@ -15,6 +18,7 @@ import { formatTraceSummary, formatTracesHeader, formatTraceTable, + translateSpanQuery, } from "../../../src/lib/formatters/trace.js"; import type { TraceSpan, @@ -111,11 +115,8 @@ describe("formatTraceDuration", () => { }); test("handles seconds rollover (never produces '60s')", () => { - // 119500ms = 1m 59.5s, rounds to 2m 0s (not 1m 60s) expect(formatTraceDuration(119_500)).toBe("2m 0s"); - // 179500ms = 2m 59.5s, rounds to 3m 0s (not 2m 60s) expect(formatTraceDuration(179_500)).toBe("3m 0s"); - // 59500ms is < 60000 so uses seconds format expect(formatTraceDuration(59_500)).toBe("59.50s"); }); @@ -179,7 +180,6 @@ describe("formatTracesHeader (plain mode)", () => { test("emits markdown table header and separator", () => { const result = formatTracesHeader(); expect(result).toContain("| Trace ID | Transaction | Duration | When |"); - // Duration column is right-aligned (`:` suffix in TRACE_TABLE_COLS) expect(result).toContain("| --- | --- | ---: | --- |"); }); @@ -233,7 +233,6 @@ describe("computeTraceSummary", () => { makeSpan({ start_timestamp: 1000.0, timestamp: 1002.5 }), ]; const summary = computeTraceSummary("trace-id", spans); - // (1002.5 - 1000.0) * 1000 = 2500ms expect(summary.duration).toBe(2500); }); @@ -243,7 +242,6 @@ describe("computeTraceSummary", () => { makeSpan({ start_timestamp: 999.5, timestamp: 1003.0 }), ]; const summary = computeTraceSummary("trace-id", spans); - // (1003.0 - 999.5) * 1000 = 3500ms expect(summary.duration).toBe(3500); }); @@ -306,12 +304,10 @@ describe("computeTraceSummary", () => { makeSpan({ start_timestamp: 1000.0, timestamp: 1002.0 }), ]; const summary = computeTraceSummary("trace-id", spans); - // Only the valid span should contribute: (1002.0 - 1000.0) * 1000 = 2000ms expect(summary.duration).toBe(2000); }); test("falls back to timestamp when end_timestamp is 0", () => { - // end_timestamp: 0 should be treated as missing, falling back to timestamp const spans: TraceSpan[] = [ makeSpan({ start_timestamp: 1000.0, @@ -320,8 +316,6 @@ describe("computeTraceSummary", () => { }), ]; const summary = computeTraceSummary("trace-id", spans); - // Should use timestamp (1002.5), not end_timestamp (0) - // Duration: (1002.5 - 1000.0) * 1000 = 2500ms expect(summary.duration).toBe(2500); }); }); @@ -436,3 +430,54 @@ describe("formatTraceTable", () => { expect(result).toContain("unknown"); }); }); + +// --------------------------------------------------------------------------- +// translateSpanQuery +// --------------------------------------------------------------------------- + +describe("translateSpanQuery", () => { + test("translates op: to span.op:", () => { + expect(translateSpanQuery("op:db")).toBe("span.op:db"); + }); + + test("translates duration: to span.duration:", () => { + expect(translateSpanQuery("duration:>100ms")).toBe("span.duration:>100ms"); + }); + + test("bare words pass through unchanged", () => { + expect(translateSpanQuery("GET users")).toBe("GET users"); + }); + + test("mixed shorthand and bare words", () => { + expect(translateSpanQuery("op:http GET duration:>50ms")).toBe( + "span.op:http GET span.duration:>50ms" + ); + }); + + test("native keys pass through unchanged", () => { + expect(translateSpanQuery("description:fetch project:backend")).toBe( + "description:fetch project:backend" + ); + }); + + test("transaction: passes through unchanged", () => { + expect(translateSpanQuery("transaction:checkout")).toBe( + "transaction:checkout" + ); + }); + + test("key translation is case-insensitive", () => { + expect(translateSpanQuery("Op:db")).toBe("span.op:db"); + expect(translateSpanQuery("DURATION:>1s")).toBe("span.duration:>1s"); + }); + + test("empty query returns empty string", () => { + expect(translateSpanQuery("")).toBe(""); + }); + + test("quoted values are preserved", () => { + expect(translateSpanQuery('description:"GET /api"')).toBe( + 'description:"GET /api"' + ); + }); +});