From df30e6fba7c15e34248f845b717949829a52afbd Mon Sep 17 00:00:00 2001 From: betegon Date: Wed, 11 Mar 2026 14:38:40 +0100 Subject: [PATCH 1/7] refactor: extract span utilities into trace formatter Move computeSpanDurationMs from human.ts to trace.ts and add shared utilities for the upcoming span commands: flattenSpanTree, findSpanById, parseSpanQuery, applySpanFilter, writeSpanTable, and formatSpanDetails. Co-Authored-By: Claude Opus 4.6 --- src/lib/formatters/human.ts | 19 +- src/lib/formatters/trace.ts | 399 ++++++++++++++++++++++++++++++++++++ 2 files changed, 400 insertions(+), 18 deletions(-) diff --git a/src/lib/formatters/human.ts b/src/lib/formatters/human.ts index 4979c10bd..357d6fef5 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 @@ -1038,24 +1039,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; diff --git a/src/lib/formatters/trace.ts b/src/lib/formatters/trace.ts index 12b355d14..127fc4e58 100644 --- a/src/lib/formatters/trace.ts +++ b/src/lib/formatters/trace.ts @@ -2,9 +2,11 @@ * 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 { muted } from "./colors.js"; import { formatRelativeTime } from "./human.js"; import { escapeMarkdownCell, @@ -16,6 +18,7 @@ import { renderMarkdown, stripColorTags, } from "./markdown.js"; +import { type Column, writeTable } from "./table.js"; import { renderTextTable } from "./text-table.js"; /** @@ -279,3 +282,399 @@ 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; +} + +/** Parsed span filter from a query string */ +export type SpanFilter = { + op?: string; + project?: string; + description?: string; + minDuration?: number; + maxDuration?: number; +}; + +/** + * Parse a "-q" filter string into structured filters. + * + * Supports: `op:db`, `project:backend`, `description:fetch`, + * `duration:>100ms`, `duration:<500ms` + * + * Bare words without a `:` prefix are treated as description filters. + * + * @param query - Raw query string + * @returns Parsed filter + */ +export function parseSpanQuery(query: string): SpanFilter { + const filter: SpanFilter = {}; + const tokens = query.match(/(?:[^\s"]+|"[^"]*")+/g) ?? []; + + for (const token of tokens) { + applyQueryToken(filter, token); + } + return filter; +} + +/** + * Apply a single query token to a filter. + * Bare words (no colon) are treated as description filters. + */ +function applyQueryToken(filter: SpanFilter, token: string): void { + const colonIdx = token.indexOf(":"); + if (colonIdx === -1) { + filter.description = token; + return; + } + const key = token.slice(0, colonIdx).toLowerCase(); + let value = token.slice(colonIdx + 1); + // Strip quotes + if (value.startsWith('"') && value.endsWith('"')) { + value = value.slice(1, -1); + } + + switch (key) { + case "op": + filter.op = value.toLowerCase(); + break; + case "project": + filter.project = value.toLowerCase(); + break; + case "description": + filter.description = value; + break; + case "duration": { + const ms = parseDurationValue(value); + if (ms !== null) { + if (value.startsWith(">")) { + filter.minDuration = ms; + } else if (value.startsWith("<")) { + filter.maxDuration = ms; + } + } + break; + } + default: + break; + } +} + +/** Regex to strip comparison operators from duration values */ +const COMPARISON_OP_RE = /^[><]=?/; + +/** Regex to parse a numeric duration with optional unit */ +const DURATION_RE = /^(\d+(?:\.\d+)?)\s*(ms|s|m)?$/i; + +/** + * Parse a duration filter value like ">100ms", "<2s", ">500". + * Returns the numeric milliseconds, or null if unparseable. + */ +function parseDurationValue(value: string): number | null { + // Strip comparison operator + const numStr = value.replace(COMPARISON_OP_RE, ""); + const match = numStr.match(DURATION_RE); + if (!match || match[1] === undefined) { + return null; + } + const num = Number(match[1]); + const unit = (match[2] ?? "ms").toLowerCase(); + switch (unit) { + case "s": + return num * 1000; + case "m": + return num * 60_000; + default: + return num; + } +} + +/** + * Test whether a single span matches all active filter criteria. + */ +function matchesFilter(span: FlatSpan, filter: SpanFilter): boolean { + if (filter.op && !span.op?.toLowerCase().includes(filter.op)) { + return false; + } + if ( + filter.project && + !span.project_slug?.toLowerCase().includes(filter.project) + ) { + return false; + } + if (filter.description) { + const desc = (span.description || "").toLowerCase(); + if (!desc.includes(filter.description.toLowerCase())) { + return false; + } + } + if ( + filter.minDuration !== undefined && + (span.duration_ms === undefined || span.duration_ms < filter.minDuration) + ) { + return false; + } + if ( + filter.maxDuration !== undefined && + (span.duration_ms === undefined || span.duration_ms > filter.maxDuration) + ) { + return false; + } + return true; +} + +/** + * Apply a parsed filter to a flat span list. + * + * @param spans - Flat span array + * @param filter - Parsed span filter + * @returns Filtered array (does not mutate input) + */ +export function applySpanFilter( + spans: FlatSpan[], + filter: SpanFilter +): FlatSpan[] { + return spans.filter((span) => matchesFilter(span, filter)); +} + +/** 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, + }, + { + header: "Depth", + value: (s) => String(s.depth), + align: "right", + minWidth: 5, + 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; +} From 5f56b3b1f2d2349af092459f0cd8abf35192cbff Mon Sep 17 00:00:00 2001 From: betegon Date: Wed, 11 Mar 2026 14:38:50 +0100 Subject: [PATCH 2/7] feat: add sentry span list and span view commands Add span as a first-class command group for AI-agent trace debugging. - span list: flatten and filter spans in a trace with -q "op:db duration:>100ms", --sort time|duration, --limit - span view: drill into specific spans by ID with --trace, shows metadata, ancestor chain, and child tree - spans: plural alias routes to span list Closes #391 Co-Authored-By: Claude Opus 4.6 --- src/app.ts | 5 + src/commands/span/index.ts | 24 +++ src/commands/span/list.ts | 332 ++++++++++++++++++++++++++++++++++ src/commands/span/view.ts | 359 +++++++++++++++++++++++++++++++++++++ 4 files changed, 720 insertions(+) create mode 100644 src/commands/span/index.ts create mode 100644 src/commands/span/list.ts create mode 100644 src/commands/span/view.ts diff --git a/src/app.ts b/src/app.ts index d3202987c..0b6c3c6c6 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"; @@ -48,6 +50,7 @@ const PLURAL_TO_SINGULAR: Record = { repos: "repo", teams: "team", logs: "log", + spans: "span", traces: "trace", }; @@ -64,6 +67,7 @@ export const routes = buildRouteMap({ issue: issueRoute, event: eventRoute, log: logRoute, + span: spanRoute, trace: traceRoute, init: initCommand, api: apiCommand, @@ -73,6 +77,7 @@ export const routes = buildRouteMap({ repos: repoListCommand, teams: teamListCommand, logs: logListCommand, + spans: spanListCommand, traces: traceListCommand, whoami: whoamiCommand, }, diff --git a/src/commands/span/index.ts b/src/commands/span/index.ts new file mode 100644 index 000000000..a0a30b2e5 --- /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 000000000..f7cf239d0 --- /dev/null +++ b/src/commands/span/list.ts @@ -0,0 +1,332 @@ +/** + * sentry span list + * + * List spans in a distributed trace with optional filtering and sorting. + */ + +import type { SentryContext } from "../../context.js"; +import { getDetailedTrace } 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 { + applySpanFilter, + flattenSpanTree, + parseSpanQuery, + 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 [/] "; + +/** + * Validate a trace ID and detect UUID auto-correction. + */ +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 }; +} + +/** + * 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; + warning?: string; +} { + 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 + ); + const validated = validateAndWarn(id); + return { + traceId: validated.traceId, + targetArg, + warning: validated.uuidWarning, + }; + } + + const second = args[1]; + if (second === undefined) { + const validated = validateAndWarn(first); + return { + traceId: validated.traceId, + targetArg: undefined, + warning: validated.uuidWarning, + }; + } + + // Two or more args — first is target, second is trace ID + const validated = validateAndWarn(second); + return { + traceId: validated.traceId, + targetArg: first, + warning: validated.uuidWarning, + }; +} + +/** + * 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, warning } = parsePositionalArgs(args); + if (warning) { + log.warn(warning); + } + 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]); + + // Fetch trace data + 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." + ); + } + + // Flatten and filter + let flatSpans = flattenSpanTree(spans); + const totalSpans = flatSpans.length; + + if (flags.query) { + const filter = parseSpanQuery(flags.query); + flatSpans = applySpanFilter(flatSpans, filter); + } + const matchedSpans = flatSpans.length; + + // Sort + if (flags.sort === "duration") { + flatSpans.sort((a, b) => (b.duration_ms ?? -1) - (a.duration_ms ?? -1)); + } + // "time" is already in depth-first (start_timestamp) order from flattenSpanTree + + // Apply limit + const hasMore = flatSpans.length > flags.limit; + flatSpans = flatSpans.slice(0, flags.limit); + + if (flags.json) { + writeJsonList(stdout, flatSpans, { + hasMore, + fields: flags.fields, + extra: { totalSpans, matchedSpans }, + }); + 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 filterNote = + matchedSpans < totalSpans + ? ` (${matchedSpans} matched, ${totalSpans} total)` + : ` (${totalSpans} total)`; + const countText = `Showing ${flatSpans.length} span${flatSpans.length === 1 ? "" : "s"}${filterNote}.`; + + 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 000000000..9c19f629d --- /dev/null +++ b/src/commands/span/view.ts @@ -0,0 +1,359 @@ +/** + * 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 { + 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 = { + spanId: string; + span: NonNullable>["span"]; + ancestors: NonNullable>["ancestors"]; + depth: number; +}; + +/** + * 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.length === 1 ? mapped[0] : 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; + } + }, +}); From ae185f6082911747ff85021689c8147159d9b181 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 11 Mar 2026 13:39:35 +0000 Subject: [PATCH 3/7] chore: regenerate SKILL.md --- plugins/sentry-cli/skills/sentry-cli/SKILL.md | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 4c3b4b6d1..f1866b7bd 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -609,6 +609,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 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 and fetch fresh data` +- `--json - Output as JSON` +- `--fields - Comma-separated fields to include in JSON output (dot.notation supported)` + ### Trace View distributed traces @@ -759,6 +786,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 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 From 8942c7361c226ffbdb85ddedd2377ed906bcc5d3 Mon Sep 17 00:00:00 2001 From: betegon Date: Wed, 11 Mar 2026 15:40:47 +0100 Subject: [PATCH 4/7] refactor: remove UUID warning from span/trace commands The UUID dash-stripping is already handled silently by validateHexId. Remove the validateAndWarn wrappers, mergeWarnings helper, and all related warning assertions from tests. Co-Authored-By: Claude Opus 4.6 --- src/commands/span/list.ts | 43 ++--------------- src/commands/span/view.ts | 8 +--- src/commands/trace/view.ts | 58 ++++------------------- test/commands/trace/view.property.test.ts | 2 - test/commands/trace/view.test.ts | 14 ------ 5 files changed, 14 insertions(+), 111 deletions(-) diff --git a/src/commands/span/list.ts b/src/commands/span/list.ts index f7cf239d0..8ac732d53 100644 --- a/src/commands/span/list.ts +++ b/src/commands/span/list.ts @@ -59,22 +59,6 @@ const DEFAULT_LIMIT = 25; /** Usage hint for ContextError messages */ const USAGE_HINT = "sentry span list [/] "; -/** - * Validate a trace ID and detect UUID auto-correction. - */ -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 }; -} - /** * Parse positional arguments for span list. * Handles: `` or ` ` @@ -87,7 +71,6 @@ function validateAndWarn(raw: string): { export function parsePositionalArgs(args: string[]): { traceId: string; targetArg: string | undefined; - warning?: string; } { if (args.length === 0) { throw new ContextError("Trace ID", USAGE_HINT); @@ -104,31 +87,16 @@ 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 }; } // Two or more args — first is target, second is trace ID - const validated = validateAndWarn(second); - return { - traceId: validated.traceId, - targetArg: first, - warning: validated.uuidWarning, - }; + return { traceId: validateTraceId(second), targetArg: first }; } /** @@ -219,10 +187,7 @@ export const listCommand = buildCommand({ const log = logger.withTag("span.list"); // Parse positional args - const { traceId, targetArg, warning } = parsePositionalArgs(args); - if (warning) { - log.warn(warning); - } + 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)"); diff --git a/src/commands/span/view.ts b/src/commands/span/view.ts index 9c19f629d..aafc7d65b 100644 --- a/src/commands/span/view.ts +++ b/src/commands/span/view.ts @@ -14,6 +14,7 @@ import { import { buildCommand } from "../../lib/command.js"; import { ContextError, ValidationError } from "../../lib/errors.js"; import { + type FoundSpan, findSpanById, formatSimpleSpanTree, formatSpanDetails, @@ -187,12 +188,7 @@ async function resolveTarget( } /** Resolved span result from tree search. */ -type SpanResult = { - spanId: string; - span: NonNullable>["span"]; - ancestors: NonNullable>["ancestors"]; - depth: number; -}; +type SpanResult = FoundSpan & { spanId: string }; /** * Serialize span results for JSON output. diff --git a/src/commands/trace/view.ts b/src/commands/trace/view.ts index 982116e87..6f31e2a99 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/test/commands/trace/view.property.test.ts b/test/commands/trace/view.property.test.ts index b433d33d7..b6fa2622c 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 1f7ca22cb..0daad540a 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", () => { From 9b1cfd669276493f09698ce7d35491071932d74e Mon Sep 17 00:00:00 2001 From: betegon Date: Wed, 11 Mar 2026 16:55:12 +0100 Subject: [PATCH 5/7] fix: correct duration filter semantics and span view JSON shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Duration filter `>` vs `>=` was wrong — `duration:>100ms` included 100ms instead of excluding it. Added exclusive/inclusive tracking to SpanFilter and extracted duration comparison helpers. Also made `span view --json` always return an array for consistent output shape. Co-Authored-By: Claude Opus 4.6 --- src/commands/span/view.ts | 2 +- src/lib/formatters/trace.ts | 75 ++++++++++++++++++++++++++++++------- 2 files changed, 63 insertions(+), 14 deletions(-) diff --git a/src/commands/span/view.ts b/src/commands/span/view.ts index aafc7d65b..37f55ab7c 100644 --- a/src/commands/span/view.ts +++ b/src/commands/span/view.ts @@ -217,7 +217,7 @@ function buildJsonResults(results: SpanResult[], traceId: string): unknown { description: c.description || c.transaction, })), })); - return mapped.length === 1 ? mapped[0] : mapped; + return mapped; } export const viewCommand = buildCommand({ diff --git a/src/lib/formatters/trace.ts b/src/lib/formatters/trace.ts index 127fc4e58..1b60df239 100644 --- a/src/lib/formatters/trace.ts +++ b/src/lib/formatters/trace.ts @@ -404,11 +404,19 @@ export type SpanFilter = { description?: string; minDuration?: number; maxDuration?: number; + /** When true, minDuration comparison is strict `>` (default). When false, `>=`. */ + minExclusive?: boolean; + /** When true, maxDuration comparison is strict `<` (default). When false, `<=`. */ + maxExclusive?: boolean; }; /** * Parse a "-q" filter string into structured filters. * + * Unlike issue/log/trace list (which pass --query to Sentry's search API for + * server-side filtering), the trace detail API returns the full span tree with + * no query parameter — so span filtering must be done client-side. + * * Supports: `op:db`, `project:backend`, `description:fetch`, * `duration:>100ms`, `duration:<500ms` * @@ -459,8 +467,10 @@ function applyQueryToken(filter: SpanFilter, token: string): void { if (ms !== null) { if (value.startsWith(">")) { filter.minDuration = ms; + filter.minExclusive = !value.startsWith(">="); } else if (value.startsWith("<")) { filter.maxDuration = ms; + filter.maxExclusive = !value.startsWith("<="); } } break; @@ -499,6 +509,57 @@ function parseDurationValue(value: string): number | null { } } +/** Check whether a duration value passes a single bound (min or max). */ +function passesDurationBound( + durationMs: number, + bound: number, + exclusive: boolean, + isMin: boolean +): boolean { + if (isMin) { + return exclusive ? durationMs > bound : durationMs >= bound; + } + return exclusive ? durationMs < bound : durationMs <= bound; +} + +/** Check if a span's duration passes the min/max filter bounds. */ +function matchesDurationFilter( + durationMs: number | undefined, + filter: SpanFilter +): boolean { + if (filter.minDuration !== undefined) { + if (durationMs === undefined) { + return false; + } + if ( + !passesDurationBound( + durationMs, + filter.minDuration, + filter.minExclusive !== false, + true + ) + ) { + return false; + } + } + if (filter.maxDuration !== undefined) { + if (durationMs === undefined) { + return false; + } + if ( + !passesDurationBound( + durationMs, + filter.maxDuration, + filter.maxExclusive !== false, + false + ) + ) { + return false; + } + } + return true; +} + /** * Test whether a single span matches all active filter criteria. */ @@ -518,19 +579,7 @@ function matchesFilter(span: FlatSpan, filter: SpanFilter): boolean { return false; } } - if ( - filter.minDuration !== undefined && - (span.duration_ms === undefined || span.duration_ms < filter.minDuration) - ) { - return false; - } - if ( - filter.maxDuration !== undefined && - (span.duration_ms === undefined || span.duration_ms > filter.maxDuration) - ) { - return false; - } - return true; + return matchesDurationFilter(span.duration_ms, filter); } /** From 1111a408d33242374d7ed880ab9904c888d435cf Mon Sep 17 00:00:00 2001 From: betegon Date: Thu, 12 Mar 2026 09:04:12 +0100 Subject: [PATCH 6/7] feat: switch span list to EAP spans endpoint and show span IDs in trace view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use the server-side spans search endpoint (dataset=spans) for `span list` instead of fetching the full trace tree and filtering client-side. Add `translateSpanQuery` to rewrite CLI shorthand keys (op→span.op, duration→span.duration) for the API. Also fix trace view showing `undefined` for span IDs — the trace detail API returns `event_id` instead of `span_id`, so normalize in `getDetailedTrace`. Append span IDs (dimmed) to each tree line. Co-Authored-By: Claude Opus 4.6 --- src/commands/span/list.ts | 58 +++----- src/lib/api-client.ts | 90 +++++++++++- src/lib/formatters/human.ts | 2 + src/lib/formatters/trace.ts | 235 +++++------------------------- src/types/index.ts | 4 + src/types/sentry.ts | 30 ++++ test/lib/formatters/trace.test.ts | 66 +++++++-- 7 files changed, 239 insertions(+), 246 deletions(-) diff --git a/src/commands/span/list.ts b/src/commands/span/list.ts index 8ac732d53..b4f2864ae 100644 --- a/src/commands/span/list.ts +++ b/src/commands/span/list.ts @@ -5,7 +5,7 @@ */ import type { SentryContext } from "../../context.js"; -import { getDetailedTrace } from "../../lib/api-client.js"; +import { listSpans } from "../../lib/api-client.js"; import { parseOrgProjectArg, parseSlashSeparatedArg, @@ -14,9 +14,8 @@ import { import { buildCommand } from "../../lib/command.js"; import { ContextError, ValidationError } from "../../lib/errors.js"; import { - applySpanFilter, - flattenSpanTree, - parseSpanQuery, + spanListItemToFlatSpan, + translateSpanQuery, writeFooter, writeJsonList, writeSpanTable, @@ -230,42 +229,31 @@ export const listCommand = buildCommand({ setContext([target.org], [target.project]); - // Fetch trace data - 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." - ); - } - - // Flatten and filter - let flatSpans = flattenSpanTree(spans); - const totalSpans = flatSpans.length; - + // Build server-side query + const queryParts = [`trace:${traceId}`]; if (flags.query) { - const filter = parseSpanQuery(flags.query); - flatSpans = applySpanFilter(flatSpans, filter); + queryParts.push(translateSpanQuery(flags.query)); } - const matchedSpans = flatSpans.length; - - // Sort - if (flags.sort === "duration") { - flatSpans.sort((a, b) => (b.duration_ms ?? -1) - (a.duration_ms ?? -1)); - } - // "time" is already in depth-first (start_timestamp) order from flattenSpanTree + 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, + } + ); - // Apply limit - const hasMore = flatSpans.length > flags.limit; - flatSpans = flatSpans.slice(0, flags.limit); + const flatSpans = spanItems.map(spanListItemToFlatSpan); + const hasMore = nextCursor !== undefined; if (flags.json) { writeJsonList(stdout, flatSpans, { hasMore, fields: flags.fields, - extra: { totalSpans, matchedSpans }, }); return; } @@ -279,11 +267,7 @@ export const listCommand = buildCommand({ writeSpanTable(stdout, flatSpans); // Footer - const filterNote = - matchedSpans < totalSpans - ? ` (${matchedSpans} matched, ${totalSpans} total)` - : ` (${totalSpans} total)`; - const countText = `Showing ${flatSpans.length} span${flatSpans.length === 1 ? "" : "s"}${filterNote}.`; + const countText = `Showing ${flatSpans.length} span${flatSpans.length === 1 ? "" : "s"}.`; if (hasMore) { writeFooter(stdout, `${countText} Use --limit to see more.`); diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index aa438a22e..2fa5b6d72 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -50,6 +50,9 @@ import { type SentryTeam, type SentryUser, SentryUserSchema, + type SpanListItem, + type SpansResponse, + SpansResponseSchema, type TraceLog, TraceLogsResponseSchema, type TraceSpan, @@ -1503,7 +1506,24 @@ export async function getDetailedTrace( }, } ); - return data; + return data.map(normalizeTraceSpan); +} + +/** + * The trace detail API (`/trace/{id}/`) returns each span's unique identifier + * as `event_id` rather than `span_id`. The value is the same 16-hex-char span + * ID that `parent_span_id` references on child spans. We copy it to `span_id` + * so the rest of the codebase can use a single, predictable field name. + */ +function normalizeTraceSpan(span: TraceSpan): TraceSpan { + const normalized = { ...span }; + if (!normalized.span_id && normalized.event_id) { + normalized.span_id = normalized.event_id; + } + if (normalized.children) { + normalized.children = normalized.children.map(normalizeTraceSpan); + } + return normalized; } /** Fields to request from the transactions API */ @@ -1583,6 +1603,74 @@ export async function listTransactions( return { data: response.data, nextCursor }; } +/** 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: "time" (newest first) or "duration" (slowest first) */ + sort?: "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 }; +} + // Issue update functions /** diff --git a/src/lib/formatters/human.ts b/src/lib/formatters/human.ts index 357d6fef5..a4ccb29d5 100644 --- a/src/lib/formatters/human.ts +++ b/src/lib/formatters/human.ts @@ -1067,6 +1067,8 @@ function formatSpanSimple(span: TraceSpan, opts: FormatSpanOptions): void { line += ` ${muted(`(${prettyMs(durationMs)})`)}`; } + line += ` ${muted(span.span_id)}`; + lines.push(line); if (currentDepth < maxDepth) { diff --git a/src/lib/formatters/trace.ts b/src/lib/formatters/trace.ts index 1b60df239..bee2653da 100644 --- a/src/lib/formatters/trace.ts +++ b/src/lib/formatters/trace.ts @@ -5,7 +5,11 @@ * 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 { @@ -315,8 +319,8 @@ export type FlatSpan = { start_timestamp: number; project_slug?: string; transaction?: string; - depth: number; - child_count: number; + depth?: number; + child_count?: number; }; /** @@ -397,203 +401,51 @@ export function findSpanById( return null; } -/** Parsed span filter from a query string */ -export type SpanFilter = { - op?: string; - project?: string; - description?: string; - minDuration?: number; - maxDuration?: number; - /** When true, minDuration comparison is strict `>` (default). When false, `>=`. */ - minExclusive?: boolean; - /** When true, maxDuration comparison is strict `<` (default). When false, `<=`. */ - maxExclusive?: boolean; +/** Map of CLI shorthand keys to Sentry API span attribute names */ +const SPAN_KEY_ALIASES: Record = { + op: "span.op", + duration: "span.duration", }; /** - * Parse a "-q" filter string into structured filters. - * - * Unlike issue/log/trace list (which pass --query to Sentry's search API for - * server-side filtering), the trace detail API returns the full span tree with - * no query parameter — so span filtering must be done client-side. - * - * Supports: `op:db`, `project:backend`, `description:fetch`, - * `duration:>100ms`, `duration:<500ms` + * Translate CLI shorthand query keys to Sentry API span attribute names. + * Bare words pass through unchanged (server treats them as free-text search). * - * Bare words without a `:` prefix are treated as description filters. - * - * @param query - Raw query string - * @returns Parsed filter + * @param query - Raw query string from --query flag + * @returns Translated query for the spans API */ -export function parseSpanQuery(query: string): SpanFilter { - const filter: SpanFilter = {}; +export function translateSpanQuery(query: string): string { const tokens = query.match(/(?:[^\s"]+|"[^"]*")+/g) ?? []; - - for (const token of tokens) { - applyQueryToken(filter, token); - } - return filter; -} - -/** - * Apply a single query token to a filter. - * Bare words (no colon) are treated as description filters. - */ -function applyQueryToken(filter: SpanFilter, token: string): void { - const colonIdx = token.indexOf(":"); - if (colonIdx === -1) { - filter.description = token; - return; - } - const key = token.slice(0, colonIdx).toLowerCase(); - let value = token.slice(colonIdx + 1); - // Strip quotes - if (value.startsWith('"') && value.endsWith('"')) { - value = value.slice(1, -1); - } - - switch (key) { - case "op": - filter.op = value.toLowerCase(); - break; - case "project": - filter.project = value.toLowerCase(); - break; - case "description": - filter.description = value; - break; - case "duration": { - const ms = parseDurationValue(value); - if (ms !== null) { - if (value.startsWith(">")) { - filter.minDuration = ms; - filter.minExclusive = !value.startsWith(">="); - } else if (value.startsWith("<")) { - filter.maxDuration = ms; - filter.maxExclusive = !value.startsWith("<="); - } + return tokens + .map((token) => { + const colonIdx = token.indexOf(":"); + if (colonIdx === -1) { + return token; } - break; - } - default: - break; - } -} - -/** Regex to strip comparison operators from duration values */ -const COMPARISON_OP_RE = /^[><]=?/; - -/** Regex to parse a numeric duration with optional unit */ -const DURATION_RE = /^(\d+(?:\.\d+)?)\s*(ms|s|m)?$/i; - -/** - * Parse a duration filter value like ">100ms", "<2s", ">500". - * Returns the numeric milliseconds, or null if unparseable. - */ -function parseDurationValue(value: string): number | null { - // Strip comparison operator - const numStr = value.replace(COMPARISON_OP_RE, ""); - const match = numStr.match(DURATION_RE); - if (!match || match[1] === undefined) { - return null; - } - const num = Number(match[1]); - const unit = (match[2] ?? "ms").toLowerCase(); - switch (unit) { - case "s": - return num * 1000; - case "m": - return num * 60_000; - default: - return num; - } -} - -/** Check whether a duration value passes a single bound (min or max). */ -function passesDurationBound( - durationMs: number, - bound: number, - exclusive: boolean, - isMin: boolean -): boolean { - if (isMin) { - return exclusive ? durationMs > bound : durationMs >= bound; - } - return exclusive ? durationMs < bound : durationMs <= bound; -} - -/** Check if a span's duration passes the min/max filter bounds. */ -function matchesDurationFilter( - durationMs: number | undefined, - filter: SpanFilter -): boolean { - if (filter.minDuration !== undefined) { - if (durationMs === undefined) { - return false; - } - if ( - !passesDurationBound( - durationMs, - filter.minDuration, - filter.minExclusive !== false, - true - ) - ) { - return false; - } - } - if (filter.maxDuration !== undefined) { - if (durationMs === undefined) { - return false; - } - if ( - !passesDurationBound( - durationMs, - filter.maxDuration, - filter.maxExclusive !== false, - false - ) - ) { - return false; - } - } - return true; + const key = token.slice(0, colonIdx).toLowerCase(); + const rest = token.slice(colonIdx); + return (SPAN_KEY_ALIASES[key] ?? key) + rest; + }) + .join(" "); } /** - * Test whether a single span matches all active filter criteria. - */ -function matchesFilter(span: FlatSpan, filter: SpanFilter): boolean { - if (filter.op && !span.op?.toLowerCase().includes(filter.op)) { - return false; - } - if ( - filter.project && - !span.project_slug?.toLowerCase().includes(filter.project) - ) { - return false; - } - if (filter.description) { - const desc = (span.description || "").toLowerCase(); - if (!desc.includes(filter.description.toLowerCase())) { - return false; - } - } - return matchesDurationFilter(span.duration_ms, filter); -} - -/** - * Apply a parsed filter to a flat span list. + * Map a SpanListItem from the EAP spans endpoint to a FlatSpan for display. * - * @param spans - Flat span array - * @param filter - Parsed span filter - * @returns Filtered array (does not mutate input) + * @param item - Span item from the spans search API + * @returns FlatSpan suitable for table display */ -export function applySpanFilter( - spans: FlatSpan[], - filter: SpanFilter -): FlatSpan[] { - return spans.filter((span) => matchesFilter(span, filter)); +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 */ @@ -622,13 +474,6 @@ const SPAN_TABLE_COLUMNS: Column[] = [ minWidth: 8, shrinkable: false, }, - { - header: "Depth", - value: (s) => String(s.depth), - align: "right", - minWidth: 5, - shrinkable: false, - }, ]; /** diff --git a/src/types/index.ts b/src/types/index.ts index ee7ac7389..1996b5ee4 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -71,6 +71,8 @@ export type { SentryRepository, SentryTeam, SentryUser, + SpanListItem, + SpansResponse, StackFrame, Stacktrace, TraceContext, @@ -94,6 +96,8 @@ export { SentryRepositorySchema, SentryTeamSchema, SentryUserSchema, + SpanListItemSchema, + SpansResponseSchema, TraceLogSchema, TraceLogsResponseSchema, TransactionListItemSchema, diff --git a/src/types/sentry.ts b/src/types/sentry.ts index b4d9599e7..f092c2f60 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/lib/formatters/trace.test.ts b/test/lib/formatters/trace.test.ts index dcfcc6a1d..d0aaa2a19 100644 --- a/test/lib/formatters/trace.test.ts +++ b/test/lib/formatters/trace.test.ts @@ -3,7 +3,7 @@ * * Tests for formatTraceDuration, formatTraceTable, formatTracesHeader, formatTraceRow, - * computeTraceSummary, and formatTraceSummary. + * computeTraceSummary, formatTraceSummary, and translateSpanQuery. */ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; @@ -14,6 +14,7 @@ import { formatTraceSummary, formatTracesHeader, formatTraceTable, + translateSpanQuery, } from "../../../src/lib/formatters/trace.js"; import type { TraceSpan, @@ -110,11 +111,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"); }); @@ -169,7 +167,6 @@ describe("formatTraceRow (rendered mode)", () => { test("includes full transaction name in markdown row", () => { const longName = "A".repeat(50); const row = formatTraceRow(makeTransaction({ transaction: longName })); - // Full name preserved in markdown table cell expect(row).toContain(longName); }); @@ -189,9 +186,7 @@ describe("formatTracesHeader (plain mode)", () => { test("emits markdown table header and separator", () => { const result = formatTracesHeader(); - // Plain mode produces mdTableHeader output (no bold markup), with separator expect(result).toContain("| Trace ID | Transaction | Duration | When |"); - // Duration column is right-aligned (`:` suffix in TRACE_TABLE_COLS) expect(result).toContain("| --- | --- | ---: | --- |"); }); @@ -256,7 +251,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); }); @@ -266,7 +260,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); }); @@ -322,7 +315,6 @@ 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); }); @@ -333,7 +325,6 @@ describe("computeTraceSummary", () => { }); 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, @@ -342,8 +333,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); }); }); @@ -463,3 +452,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"' + ); + }); +}); From 91424c0a4e3ddfdf0ccc75955b1b8affae89d2f3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 13 Mar 2026 19:30:58 +0000 Subject: [PATCH 7/7] chore: regenerate SKILL.md --- plugins/sentry-cli/skills/sentry-cli/SKILL.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 6fc43fcd1..a7315334d 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -639,7 +639,7 @@ List spans in a trace - `-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 and fetch fresh data` +- `-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)` @@ -650,7 +650,7 @@ 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 and fetch fresh data` +- `-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)` @@ -837,7 +837,7 @@ List spans in a trace - `-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 and fetch fresh data` +- `-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)`