From df30e6fba7c15e34248f845b717949829a52afbd Mon Sep 17 00:00:00 2001 From: betegon Date: Wed, 11 Mar 2026 14:38:40 +0100 Subject: [PATCH 01/28] 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 02/28] 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 03/28] 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 04/28] 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 05/28] 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 06/28] 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 07/28] 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)` From 7bc947c35f94b6a498c013884570dd34854348e9 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Mon, 16 Mar 2026 10:01:16 +0000 Subject: [PATCH 08/28] refactor: migrate span commands to output config + fix markdown rendering Migrate span list/view from imperative output to auto-rendered OutputConfig pattern, and fix plain-mode ANSI leaks in span tree/ancestor formatting. **Output system migration (span/list.ts, span/view.ts):** - Replace `output: "json"` shorthand with `output: { json: true, human: fn, jsonTransform: fn }` - Extract human formatters (formatSpanListHuman, formatSpanViewHuman) that return strings instead of writing to stdout directly - Extract JSON transforms (jsonTransformSpanList, jsonTransformSpanView) for the { data: [...], hasMore } envelope and --fields filtering - Return `{ data, hint }` from func() so the wrapper handles rendering - Remove manual `if (flags.json)` branching and direct stdout writes **Markdown rendering fixes (trace.ts, human.ts):** - formatAncestorChain: replace raw `muted()` (chalk ANSI) with `colorTag("muted", ...)` + `renderMarkdown()` so output respects NO_COLOR/isPlainOutput/non-TTY - formatSimpleSpanTree/formatSpanSimple: replace `muted()` with `plainSafeMuted()` that checks `isPlainOutput()` before applying ANSI - Span list header now renders via `renderMarkdown()` for proper styling **Formatter exports (trace.ts):** - Export SPAN_TABLE_COLUMNS for use by span/list formatter - Add formatSpanTable() return-based wrapper around formatTable() --- src/commands/span/list.ts | 98 +++++++++++++++++++------------ src/commands/span/view.ts | 111 +++++++++++++++++++++++------------- src/lib/formatters/human.ts | 24 ++++++-- src/lib/formatters/trace.ts | 31 ++++++++-- 4 files changed, 178 insertions(+), 86 deletions(-) diff --git a/src/commands/span/list.ts b/src/commands/span/list.ts index b4f2864ae..909421c32 100644 --- a/src/commands/span/list.ts +++ b/src/commands/span/list.ts @@ -14,12 +14,13 @@ import { import { buildCommand } from "../../lib/command.js"; import { ContextError, ValidationError } from "../../lib/errors.js"; import { + type FlatSpan, + formatSpanTable, spanListItemToFlatSpan, translateSpanQuery, - writeFooter, - writeJsonList, - writeSpanTable, } from "../../lib/formatters/index.js"; +import { filterFields } from "../../lib/formatters/json.js"; +import { renderMarkdown } from "../../lib/formatters/markdown.js"; import { applyFreshFlag, FRESH_ALIASES, @@ -119,6 +120,50 @@ export function parseSort(value: string): SortValue { return value as SortValue; } +// --------------------------------------------------------------------------- +// Output config types and formatters +// --------------------------------------------------------------------------- + +/** Structured data returned by the command for both JSON and human output */ +type SpanListData = { + /** Flattened span items for display */ + flatSpans: FlatSpan[]; + /** Whether more results are available beyond the limit */ + hasMore: boolean; + /** The trace ID being queried */ + traceId: string; +}; + +/** + * Format span list data for human-readable terminal output. + * + * Uses `renderMarkdown()` for the header and `formatSpanTable()` for the table, + * ensuring proper rendering in both TTY and plain output modes. + */ +function formatSpanListHuman(data: SpanListData): string { + if (data.flatSpans.length === 0) { + return "No spans matched the query."; + } + const parts: string[] = []; + parts.push(renderMarkdown(`Spans in trace \`${data.traceId}\`:\n`)); + parts.push(formatSpanTable(data.flatSpans)); + return parts.join("\n"); +} + +/** + * Transform span list data for JSON output. + * + * Produces a `{ data: [...], hasMore }` envelope matching the standard + * paginated list format. Applies `--fields` filtering per element. + */ +function jsonTransformSpanList(data: SpanListData, fields?: string[]): unknown { + const items = + fields && fields.length > 0 + ? data.flatSpans.map((item) => filterFields(item, fields)) + : data.flatSpans; + return { data: items, hasMore: data.hasMore }; +} + export const listCommand = buildCommand({ docs: { brief: "List spans in a trace", @@ -136,7 +181,11 @@ export const listCommand = buildCommand({ " sentry span list --sort duration # Sort by slowest first\n" + ' sentry span list -q "duration:>100ms" # Spans slower than 100ms', }, - output: "json", + output: { + json: true, + human: formatSpanListHuman, + jsonTransform: jsonTransformSpanList, + }, parameters: { positional: { kind: "array", @@ -176,13 +225,9 @@ export const listCommand = buildCommand({ s: "sort", }, }, - async func( - this: SentryContext, - flags: ListFlags, - ...args: string[] - ): Promise { + async func(this: SentryContext, flags: ListFlags, ...args: string[]) { applyFreshFlag(flags); - const { stdout, cwd, setContext } = this; + const { cwd, setContext } = this; const log = logger.withTag("span.list"); // Parse positional args @@ -250,32 +295,15 @@ export const listCommand = buildCommand({ const flatSpans = spanItems.map(spanListItemToFlatSpan); const hasMore = nextCursor !== undefined; - if (flags.json) { - writeJsonList(stdout, flatSpans, { - hasMore, - fields: flags.fields, - }); - return; + // Build hint footer + let hint: string | undefined; + if (flatSpans.length > 0) { + const countText = `Showing ${flatSpans.length} span${flatSpans.length === 1 ? "" : "s"}.`; + hint = hasMore + ? `${countText} Use --limit to see more.` + : `${countText} Use 'sentry span view --trace ${traceId}' to view span details.`; } - 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.` - ); - } + return { data: { flatSpans, hasMore, traceId }, hint }; }, }); diff --git a/src/commands/span/view.ts b/src/commands/span/view.ts index 37f55ab7c..2b2f09096 100644 --- a/src/commands/span/view.ts +++ b/src/commands/span/view.ts @@ -18,8 +18,8 @@ import { findSpanById, formatSimpleSpanTree, formatSpanDetails, - writeJson, } from "../../lib/formatters/index.js"; +import { filterFields } from "../../lib/formatters/json.js"; import { applyFreshFlag, FRESH_ALIASES, @@ -187,14 +187,28 @@ async function resolveTarget( } } +// --------------------------------------------------------------------------- +// Output config types and formatters +// --------------------------------------------------------------------------- + /** Resolved span result from tree search. */ type SpanResult = FoundSpan & { spanId: string }; +/** Structured data returned by the command for both JSON and human output */ +type SpanViewData = { + /** Found span results with ancestors and depth */ + results: SpanResult[]; + /** The trace ID for context */ + traceId: string; + /** Maximum child tree depth to display (from --spans flag) */ + spansDepth: number; +}; + /** * Serialize span results for JSON output. */ -function buildJsonResults(results: SpanResult[], traceId: string): unknown { - const mapped = results.map((r) => ({ +function buildJsonResults(results: SpanResult[], traceId: string): unknown[] { + return results.map((r) => ({ span_id: r.span.span_id, parent_span_id: r.span.parent_span_id, trace_id: traceId, @@ -217,6 +231,51 @@ function buildJsonResults(results: SpanResult[], traceId: string): unknown { description: c.description || c.transaction, })), })); +} + +/** + * Format span view data for human-readable terminal output. + * + * Renders each span's details (KV table + ancestor chain) and optionally + * shows the child span tree. Multiple spans are separated by `---`. + */ +function formatSpanViewHuman(data: SpanViewData): string { + const parts: string[] = []; + for (let i = 0; i < data.results.length; i++) { + if (i > 0) { + parts.push("\n---\n"); + } + const result = data.results[i]; + if (!result) { + continue; + } + parts.push(formatSpanDetails(result.span, result.ancestors, data.traceId)); + + // Show child tree if --spans > 0 and the span has children + const children = result.span.children ?? []; + if (data.spansDepth > 0 && children.length > 0) { + const treeLines = formatSimpleSpanTree( + data.traceId, + [result.span], + data.spansDepth + ); + if (treeLines.length > 0) { + parts.push(`${treeLines.join("\n")}\n`); + } + } + } + return parts.join(""); +} + +/** + * Transform span view data for JSON output. + * Applies `--fields` filtering per element. + */ +function jsonTransformSpanView(data: SpanViewData, fields?: string[]): unknown { + const mapped = buildJsonResults(data.results, data.traceId); + if (fields && fields.length > 0) { + return mapped.map((item) => filterFields(item, fields)); + } return mapped; } @@ -235,7 +294,11 @@ export const viewCommand = buildCommand({ " sentry span view a1b2c3d4e5f67890 --trace \n" + " sentry span view a1b2c3d4e5f67890 b2c3d4e5f6789012 --trace ", }, - output: "json", + output: { + json: true, + human: formatSpanViewHuman, + jsonTransform: jsonTransformSpanView, + }, parameters: { positional: { kind: "array", @@ -257,14 +320,9 @@ export const viewCommand = buildCommand({ }, 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 { + async func(this: SentryContext, flags: ViewFlags, ...args: string[]) { applyFreshFlag(flags); - const { stdout, cwd, setContext } = this; + const { cwd, setContext } = this; const cmdLog = logger.withTag("span.view"); const traceId = flags.trace; @@ -323,33 +381,8 @@ export const viewCommand = buildCommand({ 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; - } + return { + data: { results, traceId, spansDepth: flags.spans }, + }; }, }); diff --git a/src/lib/formatters/human.ts b/src/lib/formatters/human.ts index c2050d69f..43745165f 100644 --- a/src/lib/formatters/human.ts +++ b/src/lib/formatters/human.ts @@ -30,6 +30,7 @@ import { colorTag, escapeMarkdownCell, escapeMarkdownInline, + isPlainOutput, mdKvTable, mdRow, mdTableHeader, @@ -1077,6 +1078,17 @@ function buildRequestMarkdown(requestEntry: RequestEntry): string { // Span Tree Formatting +/** + * Apply muted styling only in TTY/colored mode. + * + * Tree output uses box-drawing characters and indentation that can't go + * through full `renderMarkdown()`. This helper ensures no raw ANSI escapes + * leak when `NO_COLOR` is set, output is piped, or `isPlainOutput()` is true. + */ +function plainSafeMuted(text: string): string { + return isPlainOutput() ? text : muted(text); +} + type FormatSpanOptions = { lines: string[]; prefix: string; @@ -1098,14 +1110,14 @@ function formatSpanSimple(span: TraceSpan, opts: FormatSpanOptions): void { const branch = isLast ? "└─" : "├─"; const childPrefix = prefix + (isLast ? " " : "│ "); - let line = `${prefix}${branch} ${muted(op)} — ${desc}`; + let line = `${prefix}${branch} ${plainSafeMuted(op)} — ${desc}`; const durationMs = computeSpanDurationMs(span); if (durationMs !== undefined) { - line += ` ${muted(`(${prettyMs(durationMs)})`)}`; + line += ` ${plainSafeMuted(`(${prettyMs(durationMs)})`)}`; } - line += ` ${muted(span.span_id)}`; + line += ` ${plainSafeMuted(span.span_id)}`; lines.push(line); @@ -1161,9 +1173,9 @@ export function formatSimpleSpanTree( const lines: string[] = []; lines.push(""); - lines.push(muted("─── Span Tree ───")); + lines.push(plainSafeMuted("─── Span Tree ───")); lines.push(""); - lines.push(`${muted("Trace —")} ${traceId}`); + lines.push(`${plainSafeMuted("Trace —")} ${traceId}`); const totalRootSpans = spans.length; const truncated = totalRootSpans > MAX_ROOT_SPANS; @@ -1183,7 +1195,7 @@ export function formatSimpleSpanTree( if (truncated) { const remaining = totalRootSpans - MAX_ROOT_SPANS; lines.push( - `└─ ${muted(`... ${remaining} more root span${remaining === 1 ? "" : "s"} (${totalRootSpans} total). Use --json to see all.`)}` + `└─ ${plainSafeMuted(`... ${remaining} more root span${remaining === 1 ? "" : "s"} (${totalRootSpans} total). Use --json to see all.`)}` ); } diff --git a/src/lib/formatters/trace.ts b/src/lib/formatters/trace.ts index bee2653da..bcdcc21fc 100644 --- a/src/lib/formatters/trace.ts +++ b/src/lib/formatters/trace.ts @@ -10,10 +10,11 @@ import type { TraceSpan, TransactionListItem, } from "../../types/index.js"; -import { muted } from "./colors.js"; import { formatRelativeTime } from "./human.js"; import { + colorTag, escapeMarkdownCell, + escapeMarkdownInline, isPlainOutput, mdKvTable, mdRow, @@ -22,7 +23,7 @@ import { renderMarkdown, stripColorTags, } from "./markdown.js"; -import { type Column, writeTable } from "./table.js"; +import { type Column, formatTable, writeTable } from "./table.js"; import { renderTextTable } from "./text-table.js"; /** @@ -449,7 +450,7 @@ export function spanListItemToFlatSpan(item: SpanListItem): FlatSpan { } /** Column definitions for the flat span table */ -const SPAN_TABLE_COLUMNS: Column[] = [ +export const SPAN_TABLE_COLUMNS: Column[] = [ { header: "Span ID", value: (s) => `\`${s.span_id}\``, @@ -476,6 +477,19 @@ const SPAN_TABLE_COLUMNS: Column[] = [ }, ]; +/** + * Format a flat span list as a rendered table string. + * + * Prefer this in return-based command output pipelines. + * Uses {@link formatTable} (return-based) internally. + * + * @param spans - Flat span array to display + * @returns Rendered table string + */ +export function formatSpanTable(spans: FlatSpan[]): string { + return formatTable(spans, SPAN_TABLE_COLUMNS, { truncate: true }); +} + /** * Write a flat span list as a formatted table. * @@ -533,9 +547,12 @@ function buildSpanKvRows(span: TraceSpan, traceId: string): [string, string][] { /** * Format an ancestor chain as indented tree lines. + * + * Uses `colorTag()` + `renderMarkdown()` so output respects `NO_COLOR` + * and `isPlainOutput()` instead of leaking raw ANSI escapes. */ function formatAncestorChain(ancestors: TraceSpan[]): string { - const lines: string[] = ["", muted("─── Ancestors ───"), ""]; + const lines: string[] = ["", colorTag("muted", "─── Ancestors ───"), ""]; for (let i = 0; i < ancestors.length; i++) { const a = ancestors[i]; if (!a) { @@ -544,9 +561,11 @@ function formatAncestorChain(ancestors: TraceSpan[]): string { 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})`)}`); + lines.push( + `${indent}${colorTag("muted", aOp)} — ${escapeMarkdownInline(aDesc)} ${colorTag("muted", `(${a.span_id})`)}` + ); } - return `${lines.join("\n")}\n`; + return `${renderMarkdown(lines.join("\n"))}\n`; } /** From e6727e1181a0f4d729d6b773845f6430ebc8b909 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Mon, 16 Mar 2026 10:33:41 +0000 Subject: [PATCH 09/28] fix: adapt span commands to yield-based output system from main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After merging main, command.ts uses async generators + CommandOutput class: - Remove 'json: true' from OutputConfig (no longer needed on main) - Change async func → async *func (generator) - Replace return { data, hint } → yield new CommandOutput(data) + return { hint } - Import CommandOutput from formatters/output.js --- src/commands/span/list.ts | 7 ++++--- src/commands/span/view.ts | 8 +++----- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/commands/span/list.ts b/src/commands/span/list.ts index 909421c32..207caf354 100644 --- a/src/commands/span/list.ts +++ b/src/commands/span/list.ts @@ -21,6 +21,7 @@ import { } from "../../lib/formatters/index.js"; import { filterFields } from "../../lib/formatters/json.js"; import { renderMarkdown } from "../../lib/formatters/markdown.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; import { applyFreshFlag, FRESH_ALIASES, @@ -182,7 +183,6 @@ export const listCommand = buildCommand({ ' sentry span list -q "duration:>100ms" # Spans slower than 100ms', }, output: { - json: true, human: formatSpanListHuman, jsonTransform: jsonTransformSpanList, }, @@ -225,7 +225,7 @@ export const listCommand = buildCommand({ s: "sort", }, }, - async func(this: SentryContext, flags: ListFlags, ...args: string[]) { + async *func(this: SentryContext, flags: ListFlags, ...args: string[]) { applyFreshFlag(flags); const { cwd, setContext } = this; const log = logger.withTag("span.list"); @@ -304,6 +304,7 @@ export const listCommand = buildCommand({ : `${countText} Use 'sentry span view --trace ${traceId}' to view span details.`; } - return { data: { flatSpans, hasMore, traceId }, hint }; + yield new CommandOutput({ flatSpans, hasMore, traceId }); + return { hint }; }, }); diff --git a/src/commands/span/view.ts b/src/commands/span/view.ts index 2b2f09096..910330705 100644 --- a/src/commands/span/view.ts +++ b/src/commands/span/view.ts @@ -20,6 +20,7 @@ import { formatSpanDetails, } from "../../lib/formatters/index.js"; import { filterFields } from "../../lib/formatters/json.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; import { applyFreshFlag, FRESH_ALIASES, @@ -295,7 +296,6 @@ export const viewCommand = buildCommand({ " sentry span view a1b2c3d4e5f67890 b2c3d4e5f6789012 --trace ", }, output: { - json: true, human: formatSpanViewHuman, jsonTransform: jsonTransformSpanView, }, @@ -320,7 +320,7 @@ export const viewCommand = buildCommand({ }, aliases: { ...FRESH_ALIASES, t: "trace" }, }, - async func(this: SentryContext, flags: ViewFlags, ...args: string[]) { + async *func(this: SentryContext, flags: ViewFlags, ...args: string[]) { applyFreshFlag(flags); const { cwd, setContext } = this; const cmdLog = logger.withTag("span.view"); @@ -381,8 +381,6 @@ export const viewCommand = buildCommand({ warnMissingIds(spanIds, foundIds); - return { - data: { results, traceId, spansDepth: flags.spans }, - }; + yield new CommandOutput({ results, traceId, spansDepth: flags.spans }); }, }); From f7198ccea978a2086b56d7858580eb7e65659351 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Mon, 16 Mar 2026 10:37:07 +0000 Subject: [PATCH 10/28] fix: address review feedback from Cursor BugBot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove dead code: flattenSpanTree (unused, superseded by EAP API) - Remove dead code: writeSpanTable (superseded by formatSpanTable) - Fix case-insensitive span ID matching in findSpanById — lowercase the API-returned span_id before comparing with user-lowercased input - Fix JSON duration consistency: use computeSpanDurationMs() in buildJsonResults instead of raw r.span.duration, matching the human output path's timestamp-arithmetic fallback --- src/commands/span/view.ts | 3 ++- src/lib/formatters/trace.ts | 51 ++----------------------------------- 2 files changed, 4 insertions(+), 50 deletions(-) diff --git a/src/commands/span/view.ts b/src/commands/span/view.ts index 910330705..1efc413d3 100644 --- a/src/commands/span/view.ts +++ b/src/commands/span/view.ts @@ -21,6 +21,7 @@ import { } from "../../lib/formatters/index.js"; import { filterFields } from "../../lib/formatters/json.js"; import { CommandOutput } from "../../lib/formatters/output.js"; +import { computeSpanDurationMs } from "../../lib/formatters/trace.js"; import { applyFreshFlag, FRESH_ALIASES, @@ -217,7 +218,7 @@ function buildJsonResults(results: SpanResult[], traceId: string): unknown[] { 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, + duration: computeSpanDurationMs(r.span), project_slug: r.span.project_slug, transaction: r.span.transaction, depth: r.depth, diff --git a/src/lib/formatters/trace.ts b/src/lib/formatters/trace.ts index bcdcc21fc..071bcbd94 100644 --- a/src/lib/formatters/trace.ts +++ b/src/lib/formatters/trace.ts @@ -23,7 +23,7 @@ import { renderMarkdown, stripColorTags, } from "./markdown.js"; -import { type Column, formatTable, writeTable } from "./table.js"; +import { type Column, formatTable } from "./table.js"; import { renderTextTable } from "./text-table.js"; /** @@ -324,40 +324,6 @@ export type FlatSpan = { 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; @@ -381,7 +347,7 @@ export function findSpanById( depth: number, ancestors: TraceSpan[] ): FoundSpan | null { - if (span.span_id === spanId) { + if (span.span_id.toLowerCase() === spanId) { return { span, depth, ancestors }; } for (const child of span.children ?? []) { @@ -490,19 +456,6 @@ export function formatSpanTable(spans: FlatSpan[]): string { return formatTable(spans, SPAN_TABLE_COLUMNS, { truncate: true }); } -/** - * 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. */ From 995573a5034d167a894f0a900c9f455839b8a144 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Mon, 16 Mar 2026 10:48:17 +0000 Subject: [PATCH 11/28] fix(test): exclude punycode slugs from URL parser property tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The orgSlugArb and projectSlugArb generators could produce strings starting with 'xn--' (punycode-encoded IDN labels). When used as a subdomain (e.g. 'xn--0a.sentry.io'), the URL constructor silently decodes the punycode, collapsing the hostname to 'sentry.io' and dropping the org. This caused flaky failures in the buildTraceUrl → parseSentryUrl round-trip test. Fix: filter out xn-- prefixed slugs since real Sentry org/project slugs are never punycode-encoded. --- test/lib/sentry-url-parser.property.test.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/test/lib/sentry-url-parser.property.test.ts b/test/lib/sentry-url-parser.property.test.ts index 1885e28d0..3af7e815f 100644 --- a/test/lib/sentry-url-parser.property.test.ts +++ b/test/lib/sentry-url-parser.property.test.ts @@ -20,11 +20,21 @@ import { } from "../../src/lib/sentry-urls.js"; import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; -/** Generates valid org slugs (lowercase, alphanumeric with hyphens) */ -const orgSlugArb = stringMatching(/^[a-z][a-z0-9-]{1,20}[a-z0-9]$/); +/** + * Generates valid org slugs (lowercase, alphanumeric with hyphens). + * + * Excludes `xn--` prefixes (punycode-encoded IDN labels) because the URL + * constructor silently decodes them, collapsing `xn--XX.sentry.io` into + * `sentry.io` and dropping the org subdomain. + */ +const orgSlugArb = stringMatching(/^[a-z][a-z0-9-]{1,20}[a-z0-9]$/).filter( + (s) => !s.startsWith("xn--") +); /** Generates valid project slugs (lowercase, alphanumeric with hyphens) */ -const projectSlugArb = stringMatching(/^[a-z][a-z0-9-]{1,20}[a-z0-9]$/); +const projectSlugArb = stringMatching(/^[a-z][a-z0-9-]{1,20}[a-z0-9]$/).filter( + (s) => !s.startsWith("xn--") +); /** Generates valid 32-character hex trace IDs */ const traceIdArb = stringMatching(/^[0-9a-f]{32}$/); From 051830afd49f4104b6eaf90730f190cd142c1d63 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Mon, 16 Mar 2026 10:56:28 +0000 Subject: [PATCH 12/28] fix: address second round of BugBot feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Restore null fallback for span_id in formatSpanSimple: span.span_id ?? '' (defensive against runtime undefined from normalizeTraceSpan edge cases) - Unexport SPAN_TABLE_COLUMNS — only used internally by formatSpanTable, no external consumers --- src/lib/formatters/human.ts | 2 +- src/lib/formatters/trace.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/formatters/human.ts b/src/lib/formatters/human.ts index 43745165f..6c8122424 100644 --- a/src/lib/formatters/human.ts +++ b/src/lib/formatters/human.ts @@ -1117,7 +1117,7 @@ function formatSpanSimple(span: TraceSpan, opts: FormatSpanOptions): void { line += ` ${plainSafeMuted(`(${prettyMs(durationMs)})`)}`; } - line += ` ${plainSafeMuted(span.span_id)}`; + line += ` ${plainSafeMuted(span.span_id ?? "")}`; lines.push(line); diff --git a/src/lib/formatters/trace.ts b/src/lib/formatters/trace.ts index 071bcbd94..e511fa9df 100644 --- a/src/lib/formatters/trace.ts +++ b/src/lib/formatters/trace.ts @@ -416,7 +416,7 @@ export function spanListItemToFlatSpan(item: SpanListItem): FlatSpan { } /** Column definitions for the flat span table */ -export const SPAN_TABLE_COLUMNS: Column[] = [ +const SPAN_TABLE_COLUMNS: Column[] = [ { header: "Span ID", value: (s) => `\`${s.span_id}\``, From 753acc4f5a0841b47fdc144a93c91c77fd93bd2f Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Mon, 16 Mar 2026 11:04:20 +0000 Subject: [PATCH 13/28] fix: null-safe span_id comparison in findSpanById Use optional chaining (span.span_id?.toLowerCase()) to avoid crash when a span from the API has neither span_id nor event_id. --- src/lib/formatters/trace.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/formatters/trace.ts b/src/lib/formatters/trace.ts index e511fa9df..0550ec3b0 100644 --- a/src/lib/formatters/trace.ts +++ b/src/lib/formatters/trace.ts @@ -347,7 +347,7 @@ export function findSpanById( depth: number, ancestors: TraceSpan[] ): FoundSpan | null { - if (span.span_id.toLowerCase() === spanId) { + if (span.span_id?.toLowerCase() === spanId) { return { span, depth, ancestors }; } for (const child of span.children ?? []) { From e2c92155bb5ba3d1e5c2b93be4ed90bac912079e Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Mon, 16 Mar 2026 11:48:45 +0000 Subject: [PATCH 14/28] refactor(span/view): use positional trace ID + add tests + fix SKILL.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UX improvement: Replace --trace flag with positional argument to match the standardized [//] pattern used by span list, trace view, and event view. Before: sentry span view --trace After: sentry span view [//] This is consistent with span list which already uses: sentry span list [//] SKILL.md: Update placeholder text from generic '' to descriptive '' and ''. Tests: Add comprehensive tests for both span commands: - span/list: parsePositionalArgs, parseSort, and func body tests (API calls, query translation, org/project resolution) → 86.71% coverage - span/view: validateSpanId, parsePositionalArgs, and func body tests (span lookup, JSON output, multi-span, child tree) → 86.89% coverage - formatters/trace: findSpanById (case-insensitive, undefined span_id), computeSpanDurationMs, spanListItemToFlatSpan --- AGENTS.md | 70 ++- plugins/sentry-cli/skills/sentry-cli/SKILL.md | 7 +- src/commands/span/list.ts | 4 +- src/commands/span/view.ts | 112 ++--- test/commands/span/list.test.ts | 260 +++++++++++ test/commands/span/view.test.ts | 407 ++++++++++++++++++ test/lib/formatters/trace.test.ts | 140 ++++++ 7 files changed, 908 insertions(+), 92 deletions(-) create mode 100644 test/commands/span/list.test.ts create mode 100644 test/commands/span/view.test.ts diff --git a/AGENTS.md b/AGENTS.md index 74acde8d8..48ec3ae54 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -653,39 +653,69 @@ mock.module("./some-module", () => ({ ### Architecture - -* **Auth token env var override pattern: SENTRY\_AUTH\_TOKEN > SENTRY\_TOKEN > SQLite**: Auth in \`src/lib/db/auth.ts\` follows layered precedence: \`SENTRY\_AUTH\_TOKEN\` > \`SENTRY\_TOKEN\` > SQLite OAuth token. \`getEnvToken()\` trims env vars (empty/whitespace = unset). \`AuthSource\` tracks provenance. \`ENV\_SOURCE\_PREFIX = "env:"\` — use \`.length\` not hardcoded 4. Env tokens bypass refresh/expiry. \`isEnvTokenActive()\` guards auth commands. Logout must NOT clear stored auth when env token active. These functions stay in \`db/auth.ts\` despite not touching DB because they're tightly coupled with token retrieval. + +* **api-client.ts split into domain modules under src/lib/api/**: The original monolithic \`src/lib/api-client.ts\` (1,977 lines) was split into 12 focused domain modules under \`src/lib/api/\`: infrastructure.ts (shared helpers, types, raw requests), organizations.ts, projects.ts, teams.ts, repositories.ts, issues.ts, events.ts, traces.ts, logs.ts, seer.ts, trials.ts, users.ts. The original \`api-client.ts\` was converted to a ~100-line barrel re-export file preserving all existing import paths. The \`biome.jsonc\` override for \`noBarrelFile\` already includes \`api-client.ts\`. When adding new API functions, place them in the appropriate domain module under \`src/lib/api/\`, not in the barrel file. - -* **Consola chosen as CLI logger with Sentry createConsolaReporter integration**: Consola is the CLI logger with Sentry \`createConsolaReporter\` integration. Two reporters: FancyReporter (stderr) + Sentry structured logs. Level via \`SENTRY\_LOG\_LEVEL\`. \`buildCommand\` injects hidden \`--log-level\`/\`--verbose\` flags. \`withTag()\` creates independent instances; \`setLogLevel()\` propagates via registry. All user-facing output must use consola, not raw stderr. \`HandlerContext\` intentionally omits stderr. + +* **CLI telemetry DSN is public write-only — safe to embed in install script**: The CLI's Sentry DSN (\`SENTRY\_CLI\_DSN\` in \`src/lib/constants.ts\`) is a public write-only ingest key already baked into every binary. Safe to hardcode in install scripts. Opt-out: \`SENTRY\_CLI\_NO\_TELEMETRY=1\`. - -* **Input validation layer: src/lib/input-validation.ts guards CLI arg parsing**: Four validators in \`src/lib/input-validation.ts\` guard against agent-hallucinated inputs: \`rejectControlChars\` (ASCII < 0x20), \`rejectPreEncoded\` (%XX), \`validateResourceId\` (rejects ?, #, %, whitespace), \`validateEndpoint\` (rejects \`..\` traversal). Applied in \`parseSlashOrgProject\`, bare-slug path in \`parseOrgProjectArg\`, \`parseIssueArg\`, and \`normalizeEndpoint\` (api.ts). NOT applied in \`parseSlashSeparatedArg\` for no-slash plain IDs — those may contain structural separators (newlines for log view batch IDs) that callers split downstream. Validation targets user-facing parse boundaries only; env vars and DB cache values are trusted. + +* **cli.sentry.dev is served from gh-pages branch via GitHub Pages**: \`cli.sentry.dev\` is served from gh-pages branch via GitHub Pages. Craft's gh-pages target runs \`git rm -r -f .\` before extracting docs — persist extra files via \`postReleaseCommand\` in \`.craft.yml\`. Install script supports \`--channel nightly\`, downloading from the \`nightly\` release tag directly. version.json is only used by upgrade/version-check flow. - -* **Magic @ selectors resolve issues dynamically via sort-based list API queries**: Magic \`@\` selectors (\`@latest\`, \`@most\_frequent\`) in \`parseIssueArg\` are detected early (before \`validateResourceId\`) because \`@\` is not in the forbidden charset. \`SELECTOR\_MAP\` provides case-insensitive matching with common variations (\`@mostfrequent\`, \`@most-frequent\`). Resolution in \`resolveSelector\` (issue/utils.ts) maps selectors to \`IssueSort\` values (\`date\`, \`freq\`), calls \`listIssuesPaginated\` with \`perPage: 1\` and \`query: 'is:unresolved'\`. Supports org-prefixed form: \`sentry/@latest\`. Unrecognized \`@\`-prefixed strings fall through to suffix-only parsing (not an error). The \`ParsedIssueArg\` union includes \`{ type: 'selector'; selector: IssueSelector; org?: string }\`. + +* **Nightly delta upgrade buildNightlyPatchGraph fetches ALL patch tags — O(N) HTTP calls**: Delta upgrade in \`src/lib/delta-upgrade.ts\` supports stable (GitHub Releases) and nightly (GHCR) channels. \`filterAndSortChainTags\` filters \`patch-\*\` tags by version range using \`Bun.semver.order()\`. GHCR uses \`fetchWithRetry\` (10s timeout + 1 retry; blobs 30s) with optional \`signal?: AbortSignal\` combined via \`AbortSignal.any()\`. \`isExternalAbort(error, signal)\` skips retries for external aborts — critical for background prefetch. Patches cached to \`~/.sentry/patch-cache/\` (file-based, 7-day TTL). \`loadCachedChain\` stitches patches for multi-hop offline upgrades. + + +* **npm bundle requires Node.js >= 22 due to node:sqlite polyfill**: The npm package (dist/bin.cjs) requires Node.js >= 22 because the bun:sqlite polyfill uses \`node:sqlite\`. A runtime version guard in the esbuild banner catches this early. When writing esbuild banner strings in TS template literals, double-escape: \`\\\\\\\n\` in TS → \`\\\n\` in output → newline at runtime. Single \`\\\n\` produces a literal newline inside a JS string, causing SyntaxError. + + +* **Numeric issue ID resolution returns org:undefined despite API success**: Numeric issue ID resolution in \`resolveNumericIssue()\`: (1) try DSN/env/config for org, (2) if found use \`getIssueInOrg(org, id)\` with region routing, (3) else fall back to unscoped \`getIssue(id)\`, (4) extract org from \`issue.permalink\` via \`parseSentryUrl\` as final fallback. \`parseSentryUrl\` handles path-based (\`/organizations/{org}/...\`) and subdomain-style URLs. \`matchSubdomainOrg()\` filters region subdomains by requiring slug length > 2. Self-hosted uses path-based only. + + +* **Seer trial prompt uses middleware layering in bin.ts error handling chain**: The CLI's error recovery middlewares in \`bin.ts\` are layered: \`main() → executeWithAutoAuth() → executeWithSeerTrialPrompt() → runCommand()\`. Seer trial prompts (for \`no\_budget\`/\`not\_enabled\` errors) are caught by the inner wrapper; auth errors bubble up to the outer wrapper. After successful auth login retry, the retry also goes through \`executeWithSeerTrialPrompt\` (not \`runCommand\` directly) so the full middleware chain applies. Trial check API: \`GET /api/0/customers/{org}/\` → \`productTrials\[]\` (prefer \`seerUsers\`, fallback \`seerAutofix\`). Start trial: \`PUT /api/0/customers/{org}/product-trial/\`. The \`/customers/\` endpoint is getsentry SaaS-only; self-hosted 404s gracefully. \`ai\_disabled\` errors are excluded (admin's explicit choice). \`startSeerTrial\` accepts \`category\` from the trial object — don't hardcode it. ### Decision - -* **All view subcommands should use \ \ positional pattern**: All \`\* view\` subcommands should follow a consistent \`\ \\` positional argument pattern where target is the optional \`org/project\` specifier. During migration, use opportunistic argument swapping with a stderr warning when args are in wrong order. This is an instance of the broader CLI UX auto-correction pattern: safe when input is already invalid, correction is unambiguous, warning goes to stderr. Normalize at command level, keep parsers pure. Model after \`gh\` CLI conventions. + +* **Raw markdown output for non-interactive terminals, rendered for TTY**: Markdown-first output pipeline: custom renderer in \`src/lib/formatters/markdown.ts\` walks \`marked\` tokens to produce ANSI-styled output. Commands build CommonMark using helpers (\`mdKvTable()\`, \`mdRow()\`, \`colorTag()\`, \`escapeMarkdownCell()\`, \`safeCodeSpan()\`) and pass through \`renderMarkdown()\`. \`isPlainOutput()\` precedence: \`SENTRY\_PLAIN\_OUTPUT\` > \`NO\_COLOR\` > \`FORCE\_COLOR\` > \`!isTTY\`. \`--json\` always outputs JSON. Colors defined in \`COLORS\` object in \`colors.ts\`. Tests run non-TTY so assertions match raw CommonMark; use \`stripAnsi()\` helper for rendered-mode assertions. + + +* **whoami should be separate from auth status command**: The \`sentry auth whoami\` command should be a dedicated command separate from \`sentry auth status\`. They serve different purposes: \`status\` shows everything about auth state (token, expiry, defaults, org verification), while \`whoami\` just shows user identity (name, email, username, ID) by fetching live from \`/auth/\` endpoint. \`sentry whoami\` should be a top-level alias (like \`sentry issues\` → \`sentry issue list\`). \`whoami\` should support \`--json\` for machine consumption and be lightweight — no credential verification, no defaults listing. ### Gotcha - -* **Dot-notation field filtering is ambiguous for keys containing dots**: The \`filterFields\` function in \`src/lib/formatters/json.ts\` uses dot-notation to address nested fields (e.g., \`metadata.value\`). This means object keys that literally contain dots are ambiguous and cannot be addressed. Property-based tests for this function must generate field name arbitraries that exclude dots — use a restricted charset like \`\[a-zA-Z0-9\_]\` in fast-check arbitraries. Counterexample found by fast-check: \`{"a":{".":false}}\` with path \`"a."\` splits into \`\["a", ""]\` and fails to resolve. + +* **@sentry/api SDK passes Request object to custom fetch — headers lost on Node.js**: @sentry/api SDK calls \`\_fetch(request)\` with no init object. In \`authenticatedFetch\`, \`init\` is undefined so \`prepareHeaders\` creates empty headers — on Node.js this strips Content-Type (HTTP 415). Fix: fall back to \`input.headers\` when \`init\` is undefined. Use \`unwrapPaginatedResult\` (not \`unwrapResult\`) to access the Response's Link header for pagination. \`per\_page\` is not in SDK types; cast query to pass it at runtime. + + +* **Bun binary build requires SENTRY\_CLIENT\_ID env var**: The build script (\`script/bundle.ts\`) requires \`SENTRY\_CLIENT\_ID\` environment variable and exits with code 1 if missing. When building locally, use \`bun run --env-file=.env.local build\` or set the env var explicitly. The binary build (\`bun run build\`) also needs it. Without it you get: \`Error: SENTRY\_CLIENT\_ID environment variable is required.\` - -* **Stricli rejects unknown flags — pre-parsed global flags must be consumed from argv**: Stricli's arg parser is strict: any \`--flag\` not registered on a command throws \`No flag registered for --flag\`. Global flags (parsed before Stricli in bin.ts) MUST be spliced out of argv. \`--log-level\` was correctly consumed but \`--verbose\` was intentionally left in (for the \`api\` command's own \`--verbose\`). This breaks every other command. Also, \`argv.indexOf('--flag')\` doesn't match \`--flag=value\` form — must check both space-separated and equals-sign forms when pre-parsing. A Biome \`noRestrictedImports\` lint rule in \`biome.jsonc\` now blocks \`import { buildCommand } from "@stricli/core"\` at error level — only \`src/lib/command.ts\` is exempted. Other \`@stricli/core\` exports (\`buildRouteMap\`, \`run\`, etc.) are allowed. + +* **GitHub immutable releases prevent rolling nightly tag pattern**: getsentry/cli has immutable GitHub releases — assets can't be modified and tags can NEVER be reused. Nightly builds publish to GHCR with versioned tags like \`nightly-0.14.0-dev.1772661724\`, not GitHub Releases or npm. \`fetchManifest()\` throws \`UpgradeError("network\_error")\` for both network failures and non-200 — callers must check message for HTTP 404/403. Craft with no \`preReleaseCommand\` silently skips \`bump-version.sh\` if only target is \`github\`. + + +* **Install script: BSD sed and awk JSON parsing breaks OCI digest extraction**: The install script parses OCI manifests with awk (no jq). Key trap: BSD sed \`\n\` is literal, not newline. Fix: single awk pass tracking last-seen \`"digest"\`, printing when \`"org.opencontainers.image.title"\` matches target. The config digest (\`sha256:44136fa...\`) is a 2-byte \`{}\` blob — downloading it instead of the real binary causes \`gunzip: unexpected end of file\`. + + +* **Multiple mockFetch calls replace each other — use unified mocks for multi-endpoint tests**: Bun test mocking gotchas: (1) \`mockFetch()\` replaces \`globalThis.fetch\` — calling it twice replaces the first mock. Use a single unified fetch mock dispatching by URL pattern. (2) \`mock.module()\` pollutes the module registry for ALL subsequent test files. Tests using it must live in \`test/isolated/\` and run via \`test:isolated\`. This also causes \`delta-upgrade.test.ts\` to fail when run alongside \`test/isolated/delta-upgrade.test.ts\` — the isolated test's \`mock.module()\` replaces \`CLI\_VERSION\` for all subsequent files. (3) For \`Bun.spawn\`, use direct property assignment in \`beforeEach\`/\`afterEach\`. + + +* **useTestConfigDir without isolateProjectRoot causes DSN scanning of repo tree**: \`useTestConfigDir()\` creates temp dirs under \`.test-tmp/\` in the repo tree. Without \`{ isolateProjectRoot: true }\`, \`findProjectRoot\` walks up and finds the repo's \`.git\`, causing DSN detection to scan real source code and trigger network calls against test mocks (timeouts). Always pass \`isolateProjectRoot: true\` when tests exercise \`resolveOrg\`, \`detectDsn\`, or \`findProjectRoot\`. ### Pattern - -* **Property-based tests for input validators use stringMatching for forbidden char coverage**: In \`test/lib/input-validation.property.test.ts\`, forbidden-character arbitraries are built with \`stringMatching\` targeting specific regex patterns (e.g., \`/^\[^\x00-\x1f]\*\[\x00-\x1f]\[^\x00-\x1f]\*$/\` for control chars). This ensures fast-check generates strings that always contain the forbidden character while varying surrounding content. The \`biome-ignore lint/suspicious/noControlCharactersInRegex\` suppression is needed on the control char regex constant in \`input-validation.ts\`. + +* **Org-scoped SDK calls follow getOrgSdkConfig + unwrapResult pattern**: All org-scoped API calls in src/lib/api-client.ts: (1) call \`getOrgSdkConfig(orgSlug)\` for regional URL + SDK config, (2) spread into SDK function: \`{ ...config, path: { organization\_id\_or\_slug: orgSlug, ... } }\`, (3) pass to \`unwrapResult(result, errorContext)\`. Shared helpers \`resolveAllTargets\`/\`resolveOrgAndProject\` must NOT call \`fetchProjectId\` — commands that need it enrich targets themselves. + + +* **PR workflow: wait for Seer and Cursor BugBot before resolving**: After pushing a PR in the getsentry/cli repo, the CI pipeline includes Seer Code Review and Cursor Bugbot as advisory checks. Both typically take 2-3 minutes but may not trigger on draft PRs — only ready-for-review PRs reliably get bot reviews. The workflow is: push → wait for all CI (including npm build jobs which test the actual bundle) → check for inline review comments from Seer/BugBot → fix if needed → repeat. Use \`gh pr checks \ --watch\` to monitor. Review comments are fetched via \`gh api repos/OWNER/REPO/pulls/NUM/comments\` and \`gh api repos/OWNER/REPO/pulls/NUM/reviews\`. + + +* **Shared pagination infrastructure: buildPaginationContextKey and parseCursorFlag**: List commands with cursor pagination use \`buildPaginationContextKey(type, identifier, flags)\` for composite context keys and \`parseCursorFlag(value)\` accepting \`"last"\` magic value. Critical: \`resolveCursor()\` must be called inside the \`org-all\` override closure, not before \`dispatchOrgScopedList\` — otherwise cursor validation errors fire before the correct mode-specific error. - -* **Shared flag constants in list-command.ts for cross-command consistency**: \`src/lib/list-command.ts\` exports shared Stricli flag definitions (\`FIELDS\_FLAG\`, \`FRESH\_FLAG\`, \`FRESH\_ALIASES\`) reused across all commands. When adding a new global-ish flag to multiple commands, define it once here as a const satisfying Stricli's flag shape, then spread into each command's \`flags\` object. The \`--fields\` flag is \`{ kind: 'parsed', parse: String, brief: '...', optional: true }\`. \`parseFieldsList()\` in \`formatters/json.ts\` handles comma-separated parsing with trim/dedup. \`writeJson()\` accepts an optional \`fields\` array and calls \`filterFields()\` before serialization. + +* **Telemetry instrumentation pattern: withTracingSpan + captureException for handled errors**: For graceful-fallback operations, use \`withTracingSpan\` from \`src/lib/telemetry.ts\` for child spans and \`captureException\` from \`@sentry/bun\` (named import — Biome forbids namespace imports) with \`level: 'warning'\` for non-fatal errors. \`withTracingSpan\` uses \`onlyIfParent: true\` — no-op without active transaction. User-visible fallbacks use \`log.warn()\` not \`log.debug()\`. Several commands bypass telemetry by importing \`buildCommand\` from \`@stricli/core\` directly instead of \`../../lib/command.js\` (trace/list, trace/view, log/view, api.ts, help.ts). - -* **SKILL.md generator must filter hidden Stricli flags**: \`script/generate-skill.ts\` introspects Stricli's route tree to auto-generate \`plugins/sentry-cli/skills/sentry-cli/SKILL.md\`. The \`FlagDef\` type must include \`hidden?: boolean\` and \`extractFlags\` must propagate it to \`FlagInfo\`. The filter in \`generateCommandDoc\` must exclude \`f.hidden\` alongside \`help\`/\`helpAll\`. Without this, hidden flags injected by \`buildCommand\` (like \`--log-level\`, \`--verbose\`) appear on every command in the AI agent skill file. Global flags should instead be documented once in \`docs/src/content/docs/commands/index.md\` Global Options section, which the generator pulls into SKILL.md via \`loadCommandsOverview\`. + +* **Testing Stricli command func() bodies via spyOn mocking**: To unit-test a Stricli command's \`func()\` body: (1) \`const func = await cmd.loader()\`, (2) \`func.call(mockContext, flags, ...args)\` with mock \`stdout\`, \`stderr\`, \`cwd\`, \`setContext\`. (3) \`spyOn\` namespace imports to mock dependencies (e.g., \`spyOn(apiClient, 'getLogs')\`). The \`loader()\` return type union causes \`.call()\` LSP errors — these are false positives that pass \`tsc --noEmit\`. When API functions are renamed (e.g., \`getLog\` → \`getLogs\`), update both spy target name AND mock return shape (single → array). Slug normalization (\`normalizeSlug\`) replaces underscores with dashes but does NOT lowercase — test assertions must match original casing (e.g., \`'CAM-82X'\` not \`'cam-82x'\`). diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 492b8c973..4df6398e8 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -639,7 +639,7 @@ sentry log list --json | jq '.[] | select(.level == "error")' View spans in distributed traces -#### `sentry span list ` +#### `sentry span list ` List spans in a trace @@ -651,12 +651,11 @@ List spans in a trace - `--json - Output as JSON` - `--fields - Comma-separated fields to include in JSON output (dot.notation supported)` -#### `sentry span view ` +#### `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` @@ -837,7 +836,7 @@ List logs from a project List spans in a trace -#### `sentry spans ` +#### `sentry spans ` List spans in a trace diff --git a/src/commands/span/list.ts b/src/commands/span/list.ts index 207caf354..bfbb5e737 100644 --- a/src/commands/span/list.ts +++ b/src/commands/span/list.ts @@ -190,9 +190,9 @@ export const listCommand = buildCommand({ positional: { kind: "array", parameter: { - placeholder: "args", + placeholder: "org/project/trace-id", brief: - "[/] - Target (optional) and trace ID (required)", + "[//] - Target (optional) and trace ID (required)", parse: String, }, }, diff --git a/src/commands/span/view.ts b/src/commands/span/view.ts index 1efc413d3..68a95b8b3 100644 --- a/src/commands/span/view.ts +++ b/src/commands/span/view.ts @@ -37,7 +37,6 @@ 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; @@ -49,7 +48,7 @@ const SPAN_ID_RE = /^[0-9a-f]{16}$/i; /** Usage hint for ContextError messages */ const USAGE_HINT = - "sentry span view [/] [...] --trace "; + "sentry span view [//] [...]"; /** * Validate that a string is a 16-character hexadecimal span ID. @@ -69,67 +68,55 @@ export function validateSpanId(value: string): string { 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. + * Uses the same `[//]` pattern as other commands. + * The first positional is the trace ID (optionally slash-prefixed with + * org/project), and the remaining positionals are span IDs. + * + * Formats: + * - ` [...]` — auto-detect org/project + * - `// [...]` — explicit target + * - `/ [...]` — project search * * @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 + * @returns Parsed trace ID, span IDs, and optional target arg + * @throws {ContextError} If insufficient arguments + * @throws {ValidationError} If any ID has an invalid format */ export function parsePositionalArgs(args: string[]): { + traceId: string; spanIds: string[]; targetArg: string | undefined; } { if (args.length === 0) { - throw new ContextError("Span ID", USAGE_HINT); + throw new ContextError("Trace ID and span ID", USAGE_HINT); } const first = args[0]; if (first === undefined) { - throw new ContextError("Span ID", USAGE_HINT); + throw new ContextError("Trace ID and 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 }; + // First arg is trace ID (possibly with org/project prefix) + const { id, targetArg } = parseSlashSeparatedArg( + first, + "Trace ID", + USAGE_HINT + ); + const traceId = validateTraceId(id); + + // Remaining args are span IDs + const rawSpanIds = args.slice(1); + if (rawSpanIds.length === 0) { + throw new ContextError("Span ID", USAGE_HINT, [ + `Use 'sentry span list ${first}' to find span IDs within this trace`, + ]); } + const spanIds = rawSpanIds.map((v) => validateSpanId(v)); - // All args are span IDs - const spanIds = args.map((v) => validateSpanId(v)); - return { spanIds, targetArg: undefined }; + return { traceId, spanIds, targetArg }; } /** @@ -159,7 +146,6 @@ type ResolvedSpanTarget = { org: string; project: string }; */ async function resolveTarget( parsed: ReturnType, - spanIds: string[], traceId: string, cwd: string ): Promise { @@ -171,7 +157,7 @@ async function resolveTarget( return await resolveProjectBySlug( parsed.projectSlug, USAGE_HINT, - `sentry span view /${parsed.projectSlug} ${spanIds[0]} --trace ${traceId}` + `sentry span view /${parsed.projectSlug}/${traceId} ` ); case "org-all": @@ -287,14 +273,15 @@ export const viewCommand = buildCommand({ 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" + + " sentry span view # auto-detect\n" + + " sentry span view // # explicit\n" + + " sentry span view / # project search\n\n" + + "The first argument is the trace ID (optionally prefixed with org/project),\n" + + "followed by one or more span IDs.\n\n" + "Examples:\n" + - " sentry span view a1b2c3d4e5f67890 --trace \n" + - " sentry span view a1b2c3d4e5f67890 b2c3d4e5f6789012 --trace ", + " sentry span view a1b2c3d4e5f67890\n" + + " sentry span view a1b2c3d4e5f67890 b2c3d4e5f6789012\n" + + " sentry span view sentry/my-project/ a1b2c3d4e5f67890", }, output: { human: formatSpanViewHuman, @@ -304,38 +291,31 @@ export const viewCommand = buildCommand({ positional: { kind: "array", parameter: { - placeholder: "args", + placeholder: "trace-id/span-id", brief: - "[/] [...] - Target (optional) and one or more span IDs", + "[//] [...] - Trace ID 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" }, + aliases: { ...FRESH_ALIASES }, }, async *func(this: SentryContext, flags: ViewFlags, ...args: string[]) { applyFreshFlag(flags); const { cwd, setContext } = this; const cmdLog = logger.withTag("span.view"); - const traceId = flags.trace; - - // Parse positional args - const { spanIds, targetArg } = parsePositionalArgs(args); + // Parse positional args: first is trace ID (with optional target), rest are span IDs + const { traceId, 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); + const target = await resolveTarget(parsed, traceId, cwd); if (!target) { throw new ContextError("Organization and project", USAGE_HINT); diff --git a/test/commands/span/list.test.ts b/test/commands/span/list.test.ts new file mode 100644 index 000000000..bb2db57e6 --- /dev/null +++ b/test/commands/span/list.test.ts @@ -0,0 +1,260 @@ +/** + * Span List Command Tests + * + * Tests for positional argument parsing, sort flag parsing, + * and the command func body in src/commands/span/list.ts. + */ + +import { + afterEach, + beforeEach, + describe, + expect, + mock, + spyOn, + test, +} from "bun:test"; +import { + listCommand, + parsePositionalArgs, + parseSort, +} from "../../../src/commands/span/list.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as apiClient from "../../../src/lib/api-client.js"; +import { ContextError, ValidationError } from "../../../src/lib/errors.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as resolveTarget from "../../../src/lib/resolve-target.js"; + +const VALID_TRACE_ID = "aaaa1111bbbb2222cccc3333dddd4444"; + +describe("parsePositionalArgs", () => { + describe("single argument (trace ID only)", () => { + test("parses plain trace ID", () => { + const result = parsePositionalArgs([VALID_TRACE_ID]); + expect(result.traceId).toBe(VALID_TRACE_ID); + expect(result.targetArg).toBeUndefined(); + }); + + test("normalizes uppercase trace ID", () => { + const result = parsePositionalArgs(["AAAA1111BBBB2222CCCC3333DDDD4444"]); + expect(result.traceId).toBe(VALID_TRACE_ID); + }); + + test("strips dashes from UUID-format input", () => { + const result = parsePositionalArgs([ + "aaaa1111-bbbb-2222-cccc-3333dddd4444", + ]); + expect(result.traceId).toBe(VALID_TRACE_ID); + }); + }); + + describe("slash-separated argument (org/project/trace-id)", () => { + test("parses org/project/trace-id format", () => { + const result = parsePositionalArgs([ + `my-org/my-project/${VALID_TRACE_ID}`, + ]); + expect(result.traceId).toBe(VALID_TRACE_ID); + expect(result.targetArg).toBe("my-org/my-project"); + }); + + test("single slash (org/project without ID) throws ContextError", () => { + // "my-project/trace-id" has exactly one slash → parseSlashSeparatedArg + // treats it as "org/project" without an ID, which throws + expect(() => + parsePositionalArgs([`my-project/${VALID_TRACE_ID}`]) + ).toThrow(ContextError); + }); + }); + + describe("two arguments (target + trace-id)", () => { + test("parses target and trace ID", () => { + const result = parsePositionalArgs(["my-org/frontend", VALID_TRACE_ID]); + expect(result.targetArg).toBe("my-org/frontend"); + expect(result.traceId).toBe(VALID_TRACE_ID); + }); + + test("parses project-only target", () => { + const result = parsePositionalArgs(["frontend", VALID_TRACE_ID]); + expect(result.targetArg).toBe("frontend"); + expect(result.traceId).toBe(VALID_TRACE_ID); + }); + }); + + describe("error cases", () => { + test("throws ContextError for empty args", () => { + expect(() => parsePositionalArgs([])).toThrow(ContextError); + }); + + test("throws ValidationError for invalid trace ID", () => { + expect(() => parsePositionalArgs(["not-a-trace-id"])).toThrow( + ValidationError + ); + }); + + test("throws ValidationError for short hex", () => { + expect(() => parsePositionalArgs(["aabbccdd"])).toThrow(ValidationError); + }); + }); +}); + +describe("parseSort", () => { + test("accepts 'time'", () => { + expect(parseSort("time")).toBe("time"); + }); + + test("accepts 'duration'", () => { + expect(parseSort("duration")).toBe("duration"); + }); + + test("throws for invalid value", () => { + expect(() => parseSort("name")).toThrow("Invalid sort value"); + }); + + test("throws for empty string", () => { + expect(() => parseSort("")).toThrow("Invalid sort value"); + }); +}); + +// --------------------------------------------------------------------------- +// listCommand.func — tests the command body with mocked APIs +// --------------------------------------------------------------------------- + +type ListFunc = ( + this: unknown, + flags: Record, + ...args: string[] +) => Promise; + +describe("listCommand.func", () => { + let func: ListFunc; + let listSpansSpy: ReturnType; + let resolveOrgAndProjectSpy: ReturnType; + + function createContext() { + const stdoutChunks: string[] = []; + return { + context: { + stdout: { + write: mock((s: string) => { + stdoutChunks.push(s); + }), + }, + stderr: { + write: mock((_s: string) => { + /* no-op */ + }), + }, + cwd: "/tmp/test-project", + setContext: mock((_orgs: string[], _projects: string[]) => { + /* no-op */ + }), + }, + getStdout: () => stdoutChunks.join(""), + }; + } + + beforeEach(async () => { + func = (await listCommand.loader()) as unknown as ListFunc; + listSpansSpy = spyOn(apiClient, "listSpans"); + resolveOrgAndProjectSpy = spyOn(resolveTarget, "resolveOrgAndProject"); + resolveOrgAndProjectSpy.mockResolvedValue({ + org: "test-org", + project: "test-project", + }); + }); + + afterEach(() => { + listSpansSpy.mockRestore(); + resolveOrgAndProjectSpy.mockRestore(); + }); + + test("calls listSpans with trace ID in query", async () => { + listSpansSpy.mockResolvedValue({ + data: [ + { + id: "a1b2c3d4e5f67890", + "span.op": "http.client", + description: "GET /api", + "span.duration": 123, + timestamp: "2024-01-15T10:30:00+00:00", + project: "test-project", + trace: VALID_TRACE_ID, + }, + ], + nextCursor: undefined, + }); + + const { context, getStdout } = createContext(); + + await func.call( + context, + { + limit: 25, + sort: "time", + fresh: false, + }, + VALID_TRACE_ID + ); + + expect(listSpansSpy).toHaveBeenCalledWith( + "test-org", + "test-project", + expect.objectContaining({ + query: `trace:${VALID_TRACE_ID}`, + }) + ); + + // Output should contain the span data (rendered by wrapper) + const output = getStdout(); + expect(output).toContain("a1b2c3d4e5f67890"); + }); + + test("translates query shorthand when --query is set", async () => { + listSpansSpy.mockResolvedValue({ data: [], nextCursor: undefined }); + + const { context } = createContext(); + + await func.call( + context, + { + limit: 25, + query: "op:db", + sort: "time", + fresh: false, + }, + VALID_TRACE_ID + ); + + expect(listSpansSpy).toHaveBeenCalledWith( + "test-org", + "test-project", + expect.objectContaining({ + query: `trace:${VALID_TRACE_ID} span.op:db`, + }) + ); + }); + + test("uses explicit org/project when target is provided", async () => { + listSpansSpy.mockResolvedValue({ data: [], nextCursor: undefined }); + + const { context } = createContext(); + + await func.call( + context, + { + limit: 25, + sort: "time", + fresh: false, + }, + `my-org/my-project/${VALID_TRACE_ID}` + ); + + expect(listSpansSpy).toHaveBeenCalledWith( + "my-org", + "my-project", + expect.anything() + ); + // Should NOT have called resolveOrgAndProject + expect(resolveOrgAndProjectSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/test/commands/span/view.test.ts b/test/commands/span/view.test.ts new file mode 100644 index 000000000..c058f46ea --- /dev/null +++ b/test/commands/span/view.test.ts @@ -0,0 +1,407 @@ +/** + * Span View Command Tests + * + * Tests for positional argument parsing, span ID validation, + * and output formatting in src/commands/span/view.ts. + */ + +import { + afterEach, + beforeEach, + describe, + expect, + mock, + spyOn, + test, +} from "bun:test"; +import { + parsePositionalArgs, + validateSpanId, + viewCommand, +} from "../../../src/commands/span/view.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as apiClient from "../../../src/lib/api-client.js"; +import { ContextError, ValidationError } from "../../../src/lib/errors.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as resolveTarget from "../../../src/lib/resolve-target.js"; + +const VALID_TRACE_ID = "aaaa1111bbbb2222cccc3333dddd4444"; +const VALID_SPAN_ID = "a1b2c3d4e5f67890"; +const VALID_SPAN_ID_2 = "1234567890abcdef"; + +describe("validateSpanId", () => { + test("accepts valid 16-char lowercase hex", () => { + expect(validateSpanId("a1b2c3d4e5f67890")).toBe("a1b2c3d4e5f67890"); + }); + + test("normalizes uppercase to lowercase", () => { + expect(validateSpanId("A1B2C3D4E5F67890")).toBe("a1b2c3d4e5f67890"); + }); + + test("trims whitespace", () => { + expect(validateSpanId(" a1b2c3d4e5f67890 ")).toBe("a1b2c3d4e5f67890"); + }); + + test("throws for non-hex characters", () => { + expect(() => validateSpanId("g1b2c3d4e5f67890")).toThrow(ValidationError); + }); + + test("throws for too short", () => { + expect(() => validateSpanId("a1b2c3d4")).toThrow(ValidationError); + }); + + test("throws for too long", () => { + expect(() => validateSpanId("a1b2c3d4e5f678901234")).toThrow( + ValidationError + ); + }); + + test("throws for empty string", () => { + expect(() => validateSpanId("")).toThrow(ValidationError); + }); + + test("error message includes the invalid value", () => { + try { + validateSpanId("bad"); + expect.unreachable("Should have thrown"); + } catch (error) { + expect((error as ValidationError).message).toContain("bad"); + } + }); +}); + +describe("parsePositionalArgs", () => { + describe("trace-id + single span-id", () => { + test("parses trace ID and span ID as two positional args", () => { + const result = parsePositionalArgs([VALID_TRACE_ID, VALID_SPAN_ID]); + expect(result.traceId).toBe(VALID_TRACE_ID); + expect(result.spanIds).toEqual([VALID_SPAN_ID]); + expect(result.targetArg).toBeUndefined(); + }); + }); + + describe("trace-id + multiple span-ids", () => { + test("parses trace ID and multiple span IDs", () => { + const result = parsePositionalArgs([ + VALID_TRACE_ID, + VALID_SPAN_ID, + VALID_SPAN_ID_2, + ]); + expect(result.traceId).toBe(VALID_TRACE_ID); + expect(result.spanIds).toEqual([VALID_SPAN_ID, VALID_SPAN_ID_2]); + expect(result.targetArg).toBeUndefined(); + }); + }); + + describe("org/project/trace-id + span-id", () => { + test("parses slash-separated target with trace ID", () => { + const result = parsePositionalArgs([ + `my-org/my-project/${VALID_TRACE_ID}`, + VALID_SPAN_ID, + ]); + expect(result.traceId).toBe(VALID_TRACE_ID); + expect(result.spanIds).toEqual([VALID_SPAN_ID]); + expect(result.targetArg).toBe("my-org/my-project"); + }); + + test("parses slash-separated target with multiple span IDs", () => { + const result = parsePositionalArgs([ + `my-org/my-project/${VALID_TRACE_ID}`, + VALID_SPAN_ID, + VALID_SPAN_ID_2, + ]); + expect(result.traceId).toBe(VALID_TRACE_ID); + expect(result.spanIds).toEqual([VALID_SPAN_ID, VALID_SPAN_ID_2]); + expect(result.targetArg).toBe("my-org/my-project"); + }); + }); + + describe("error cases", () => { + test("throws ContextError for empty args", () => { + expect(() => parsePositionalArgs([])).toThrow(ContextError); + }); + + test("error message mentions trace ID and span ID", () => { + try { + parsePositionalArgs([]); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ContextError); + expect((error as ContextError).message).toContain("Trace ID"); + } + }); + + test("throws ContextError when only trace ID provided (no span IDs)", () => { + expect(() => parsePositionalArgs([VALID_TRACE_ID])).toThrow(ContextError); + }); + + test("missing span IDs error suggests span list", () => { + try { + parsePositionalArgs([VALID_TRACE_ID]); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ContextError); + expect((error as ContextError).message).toContain("span list"); + } + }); + + test("throws ValidationError for invalid trace ID", () => { + expect(() => parsePositionalArgs(["not-valid", VALID_SPAN_ID])).toThrow( + ValidationError + ); + }); + + test("throws ValidationError for invalid span ID", () => { + expect(() => + parsePositionalArgs([VALID_TRACE_ID, "not-a-span-id"]) + ).toThrow(ValidationError); + }); + + test("throws ValidationError for span ID that is too short", () => { + expect(() => parsePositionalArgs([VALID_TRACE_ID, "abcd1234"])).toThrow( + ValidationError + ); + }); + }); +}); + +// --------------------------------------------------------------------------- +// viewCommand.func — tests the command body with mocked APIs +// --------------------------------------------------------------------------- + +type ViewFunc = ( + this: unknown, + flags: Record, + ...args: string[] +) => Promise; + +/** Minimal trace span tree for testing */ +function makeTraceSpan(spanId: string, children: unknown[] = []): unknown { + return { + span_id: spanId, + parent_span_id: null, + op: "http.server", + description: "GET /api", + start_timestamp: 1_700_000_000, + timestamp: 1_700_000_001, + duration: 1000, + project_slug: "test-project", + transaction: "GET /api", + children, + }; +} + +describe("viewCommand.func", () => { + let func: ViewFunc; + let getDetailedTraceSpy: ReturnType; + let resolveOrgAndProjectSpy: ReturnType; + + function createContext() { + const stdoutChunks: string[] = []; + return { + context: { + stdout: { + write: mock((s: string) => { + stdoutChunks.push(s); + }), + }, + stderr: { + write: mock((_s: string) => { + /* no-op */ + }), + }, + cwd: "/tmp/test-project", + setContext: mock((_orgs: string[], _projects: string[]) => { + /* no-op */ + }), + }, + getStdout: () => stdoutChunks.join(""), + }; + } + + beforeEach(async () => { + func = (await viewCommand.loader()) as unknown as ViewFunc; + getDetailedTraceSpy = spyOn(apiClient, "getDetailedTrace"); + resolveOrgAndProjectSpy = spyOn(resolveTarget, "resolveOrgAndProject"); + resolveOrgAndProjectSpy.mockResolvedValue({ + org: "test-org", + project: "test-project", + }); + }); + + afterEach(() => { + getDetailedTraceSpy.mockRestore(); + resolveOrgAndProjectSpy.mockRestore(); + }); + + test("renders span details for a found span", async () => { + getDetailedTraceSpy.mockResolvedValue([makeTraceSpan(VALID_SPAN_ID)]); + + const { context, getStdout } = createContext(); + + await func.call( + context, + { + spans: 3, + fresh: false, + }, + VALID_TRACE_ID, + VALID_SPAN_ID + ); + + const output = getStdout(); + expect(output).toContain(VALID_SPAN_ID); + expect(output).toContain("http.server"); + }); + + test("throws ValidationError when trace has no spans", async () => { + getDetailedTraceSpy.mockResolvedValue([]); + + const { context } = createContext(); + + await expect( + func.call( + context, + { + spans: 3, + fresh: false, + }, + VALID_TRACE_ID, + VALID_SPAN_ID + ) + ).rejects.toThrow(ValidationError); + }); + + test("throws ValidationError when span ID not found in trace", async () => { + getDetailedTraceSpy.mockResolvedValue([makeTraceSpan("0000000000000000")]); + + const { context } = createContext(); + + await expect( + func.call( + context, + { + spans: 3, + fresh: false, + }, + VALID_TRACE_ID, + VALID_SPAN_ID + ) + ).rejects.toThrow(ValidationError); + }); + + test("uses explicit org/project from slash-separated arg", async () => { + getDetailedTraceSpy.mockResolvedValue([makeTraceSpan(VALID_SPAN_ID)]); + + const { context } = createContext(); + + await func.call( + context, + { + spans: 0, + fresh: false, + }, + `my-org/my-project/${VALID_TRACE_ID}`, + VALID_SPAN_ID + ); + + expect(getDetailedTraceSpy).toHaveBeenCalledWith( + "my-org", + VALID_TRACE_ID, + expect.any(Number) + ); + expect(resolveOrgAndProjectSpy).not.toHaveBeenCalled(); + }); + + test("renders multiple spans with partial matches", async () => { + const FOUND_SPAN = "aaaa111122223333"; + const MISSING_SPAN = "bbbb444455556666"; + getDetailedTraceSpy.mockResolvedValue([makeTraceSpan(FOUND_SPAN)]); + + const { context, getStdout } = createContext(); + + // One span found, one missing — should render the found one and warn about the missing one + await func.call( + context, + { spans: 0, fresh: false }, + VALID_TRACE_ID, + FOUND_SPAN, + MISSING_SPAN + ); + + const output = getStdout(); + expect(output).toContain(FOUND_SPAN); + }); + + test("renders span with child tree when --spans > 0", async () => { + const childSpan = makeTraceSpan("childspan1234567"); + getDetailedTraceSpy.mockResolvedValue([ + makeTraceSpan(VALID_SPAN_ID, [childSpan]), + ]); + + const { context, getStdout } = createContext(); + + await func.call( + context, + { spans: 3, fresh: false }, + VALID_TRACE_ID, + VALID_SPAN_ID + ); + + const output = getStdout(); + expect(output).toContain(VALID_SPAN_ID); + // Span tree should include child info + expect(output).toContain("Span Tree"); + }); + + test("outputs JSON when --json flag is set", async () => { + getDetailedTraceSpy.mockResolvedValue([makeTraceSpan(VALID_SPAN_ID)]); + + const { context, getStdout } = createContext(); + + await func.call( + context, + { spans: 0, fresh: false, json: true }, + VALID_TRACE_ID, + VALID_SPAN_ID + ); + + const output = getStdout(); + const parsed = JSON.parse(output); + expect(Array.isArray(parsed)).toBe(true); + expect(parsed[0].span_id).toBe(VALID_SPAN_ID); + expect(parsed[0].trace_id).toBe(VALID_TRACE_ID); + expect(parsed[0].duration).toBeDefined(); + expect(parsed[0].ancestors).toEqual([]); + }); + + test("throws ContextError for org-all target (org/ without project)", async () => { + const { context } = createContext(); + + // "my-org/" is parsed as org-all mode which is not supported for span view + await expect( + func.call( + context, + { spans: 0, fresh: false }, + `my-org/my-project/${VALID_TRACE_ID}` + // No span IDs — but we need at least one + ) + ).rejects.toThrow(ContextError); + }); + + test("throws ValidationError for multiple missing span IDs", async () => { + getDetailedTraceSpy.mockResolvedValue([makeTraceSpan("0000000000000000")]); + + const { context } = createContext(); + + await expect( + func.call( + context, + { spans: 0, fresh: false }, + VALID_TRACE_ID, + VALID_SPAN_ID, + VALID_SPAN_ID_2 + ) + ).rejects.toThrow(ValidationError); + }); +}); diff --git a/test/lib/formatters/trace.test.ts b/test/lib/formatters/trace.test.ts index 4fc15a831..33137f7be 100644 --- a/test/lib/formatters/trace.test.ts +++ b/test/lib/formatters/trace.test.ts @@ -12,15 +12,19 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { + computeSpanDurationMs, computeTraceSummary, + findSpanById, formatTraceDuration, formatTraceRow, formatTraceSummary, formatTracesHeader, formatTraceTable, + spanListItemToFlatSpan, translateSpanQuery, } from "../../../src/lib/formatters/trace.js"; import type { + SpanListItem, TraceSpan, TransactionListItem, } from "../../../src/types/index.js"; @@ -481,3 +485,139 @@ describe("translateSpanQuery", () => { ); }); }); + +// --------------------------------------------------------------------------- +// findSpanById +// --------------------------------------------------------------------------- + +describe("findSpanById", () => { + test("finds root-level span", () => { + const spans = [makeSpan({ span_id: "a1b2c3d4e5f67890" })]; + const result = findSpanById(spans, "a1b2c3d4e5f67890"); + expect(result).not.toBeNull(); + expect(result?.span.span_id).toBe("a1b2c3d4e5f67890"); + expect(result?.depth).toBe(0); + expect(result?.ancestors).toEqual([]); + }); + + test("finds nested span with ancestor chain", () => { + const child = makeSpan({ span_id: "childid123456789" }); + const root = makeSpan({ + span_id: "rootid1234567890", + children: [child], + }); + const result = findSpanById([root], "childid123456789"); + expect(result).not.toBeNull(); + expect(result?.span.span_id).toBe("childid123456789"); + expect(result?.depth).toBe(1); + expect(result?.ancestors).toHaveLength(1); + expect(result?.ancestors[0]?.span_id).toBe("rootid1234567890"); + }); + + test("case-insensitive matching (API returns uppercase)", () => { + const spans = [makeSpan({ span_id: "A1B2C3D4E5F67890" })]; + const result = findSpanById(spans, "a1b2c3d4e5f67890"); + expect(result).not.toBeNull(); + expect(result?.span.span_id).toBe("A1B2C3D4E5F67890"); + }); + + test("returns null for non-existent span ID", () => { + const spans = [makeSpan({ span_id: "a1b2c3d4e5f67890" })]; + const result = findSpanById(spans, "0000000000000000"); + expect(result).toBeNull(); + }); + + test("handles span with undefined span_id gracefully", () => { + const spans = [ + { start_timestamp: 1000, children: [] } as unknown as TraceSpan, + ]; + const result = findSpanById(spans, "a1b2c3d4e5f67890"); + expect(result).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// computeSpanDurationMs +// --------------------------------------------------------------------------- + +describe("computeSpanDurationMs", () => { + test("returns duration when present", () => { + const span = { duration: 123.45, start_timestamp: 1000 } as TraceSpan; + expect(computeSpanDurationMs(span)).toBe(123.45); + }); + + test("falls back to timestamp arithmetic", () => { + const span = { + start_timestamp: 1000, + timestamp: 1001.5, + } as TraceSpan; + expect(computeSpanDurationMs(span)).toBe(1500); + }); + + test("prefers end_timestamp over timestamp", () => { + const span = { + start_timestamp: 1000, + end_timestamp: 1002, + timestamp: 1001, + } as TraceSpan; + expect(computeSpanDurationMs(span)).toBe(2000); + }); + + test("returns undefined when no duration data", () => { + const span = { start_timestamp: 1000 } as TraceSpan; + expect(computeSpanDurationMs(span)).toBeUndefined(); + }); + + test("returns undefined for negative duration", () => { + const span = { + start_timestamp: 1002, + timestamp: 1000, + } as TraceSpan; + expect(computeSpanDurationMs(span)).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// spanListItemToFlatSpan +// --------------------------------------------------------------------------- + +describe("spanListItemToFlatSpan", () => { + test("maps all fields correctly", () => { + const item: SpanListItem = { + id: "a1b2c3d4e5f67890", + parent_span: "1234567890abcdef", + "span.op": "http.client", + description: "GET /api/users", + "span.duration": 245.5, + timestamp: "2024-01-15T10:30:00+00:00", + project: "backend", + transaction: "/api/users", + trace: "aaaa1111bbbb2222cccc3333dddd4444", + }; + + const flat = spanListItemToFlatSpan(item); + expect(flat.span_id).toBe("a1b2c3d4e5f67890"); + expect(flat.parent_span_id).toBe("1234567890abcdef"); + expect(flat.op).toBe("http.client"); + expect(flat.description).toBe("GET /api/users"); + expect(flat.duration_ms).toBe(245.5); + expect(flat.project_slug).toBe("backend"); + expect(flat.transaction).toBe("/api/users"); + }); + + test("handles missing optional fields", () => { + const item: SpanListItem = { + id: "a1b2c3d4e5f67890", + timestamp: "2024-01-15T10:30:00+00:00", + trace: "aaaa1111bbbb2222cccc3333dddd4444", + project: "backend", + }; + + const flat = spanListItemToFlatSpan(item); + expect(flat.span_id).toBe("a1b2c3d4e5f67890"); + expect(flat.parent_span_id).toBeUndefined(); + expect(flat.op).toBeUndefined(); + expect(flat.description).toBeUndefined(); + expect(flat.duration_ms).toBeUndefined(); + }); +}); From c3500fa0ca7ed40cca8da73001e236f2041e316c Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Mon, 16 Mar 2026 11:54:17 +0000 Subject: [PATCH 15/28] fix: update span view hint to use new positional syntax The hint in span list still referenced the removed --trace flag. Updated to: 'sentry span view ' --- src/commands/span/list.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/span/list.ts b/src/commands/span/list.ts index bfbb5e737..6d380f69c 100644 --- a/src/commands/span/list.ts +++ b/src/commands/span/list.ts @@ -301,7 +301,7 @@ export const listCommand = buildCommand({ const countText = `Showing ${flatSpans.length} span${flatSpans.length === 1 ? "" : "s"}.`; hint = hasMore ? `${countText} Use --limit to see more.` - : `${countText} Use 'sentry span view --trace ${traceId}' to view span details.`; + : `${countText} Use 'sentry span view ${traceId} ' to view span details.`; } yield new CommandOutput({ flatSpans, hasMore, traceId }); From f68d1e64bf96312a6c1f4e346bbc270eaae33155 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Mon, 16 Mar 2026 12:00:35 +0000 Subject: [PATCH 16/28] fix(docs): remove unsupported project-search format from span view The / format doesn't work because parseSlashSeparatedArg treats a single-slash input as org/project (missing ID), not project/trace-id. Unlike span list or trace view, span view can't fall back to two separate args since the second positional is always a span ID. --- src/commands/span/view.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/commands/span/view.ts b/src/commands/span/view.ts index 68a95b8b3..9501894f3 100644 --- a/src/commands/span/view.ts +++ b/src/commands/span/view.ts @@ -78,7 +78,6 @@ export function validateSpanId(value: string): string { * Formats: * - ` [...]` — auto-detect org/project * - `// [...]` — explicit target - * - `/ [...]` — project search * * @param args - Positional arguments from CLI * @returns Parsed trace ID, span IDs, and optional target arg @@ -274,8 +273,7 @@ export const viewCommand = buildCommand({ "View detailed information about one or more spans within a trace.\n\n" + "Target specification:\n" + " sentry span view # auto-detect\n" + - " sentry span view // # explicit\n" + - " sentry span view / # project search\n\n" + + " sentry span view // # explicit\n\n" + "The first argument is the trace ID (optionally prefixed with org/project),\n" + "followed by one or more span IDs.\n\n" + "Examples:\n" + From f3b6fe3885d8d30b881803e86059ab18e4cfa78d Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Mon, 16 Mar 2026 14:16:43 +0000 Subject: [PATCH 17/28] refactor: address human reviewer feedback on span commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move validateSpanId to shared hex-id.ts (reuses pattern from validateHexId) with automatic dash-stripping for span IDs - Remove MIN_LIMIT constant — inline the value 1 in parseLimit call - Add JSDoc to MAX_LIMIT explaining it's a CLI-side convention (1000) matching issue list, trace list, and log list caps - Extract DEFAULT_SORT constant for sort flag default - Remove '(default)' from sort brief — Stricli shows defaults automatically - Update USAGE_HINT and fullDescription to use slash-separated format: sentry span list // - Update resolveProjectBySlug suggestion to slash format --- src/commands/span/list.ts | 48 ++++++++++++++++++++------------- src/commands/span/view.ts | 22 +-------------- src/lib/hex-id.ts | 35 ++++++++++++++++++++++-- test/commands/span/view.test.ts | 2 +- 4 files changed, 64 insertions(+), 43 deletions(-) diff --git a/src/commands/span/list.ts b/src/commands/span/list.ts index 6d380f69c..cab529d32 100644 --- a/src/commands/span/list.ts +++ b/src/commands/span/list.ts @@ -48,21 +48,31 @@ type SortValue = "time" | "duration"; /** Accepted values for the --sort flag */ const VALID_SORT_VALUES: SortValue[] = ["time", "duration"]; -/** Maximum allowed value for --limit flag */ +/** + * CLI-side upper bound for --limit. + * + * The Sentry Events API (spans dataset) accepts `per_page` up to 100 per + * request, but the CLI allows requesting up to 1000 as a convenience — + * the API client paginates internally when needed. This matches the cap + * used by `issue list`, `trace list`, and `log list`. + */ const MAX_LIMIT = 1000; -/** Minimum allowed value for --limit flag */ -const MIN_LIMIT = 1; - /** Default number of spans to show */ const DEFAULT_LIMIT = 25; +/** Default sort order for span results */ +const DEFAULT_SORT: SortValue = "time"; + /** Usage hint for ContextError messages */ -const USAGE_HINT = "sentry span list [/] "; +const USAGE_HINT = "sentry span list [//]"; /** * Parse positional arguments for span list. - * Handles: `` or ` ` + * Handles: `` or `//` + * + * Uses the standard `parseSlashSeparatedArg` pattern: the last `/`-separated + * segment is the trace ID, and everything before it is the org/project target. * * @param args - Positional arguments from CLI * @returns Parsed trace ID and optional target arg @@ -104,7 +114,7 @@ export function parsePositionalArgs(args: string[]): { * Parse --limit flag, delegating range validation to shared utility. */ function parseLimit(value: string): number { - return validateLimit(value, MIN_LIMIT, MAX_LIMIT); + return validateLimit(value, 1, MAX_LIMIT); } /** @@ -171,16 +181,16 @@ export const listCommand = buildCommand({ 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" + + " 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', + " 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: { human: formatSpanListHuman, @@ -200,7 +210,7 @@ export const listCommand = buildCommand({ limit: { kind: "parsed", parse: parseLimit, - brief: `Number of spans (${MIN_LIMIT}-${MAX_LIMIT})`, + brief: `Number of spans (1-${MAX_LIMIT})`, default: String(DEFAULT_LIMIT), }, query: { @@ -213,8 +223,8 @@ export const listCommand = buildCommand({ sort: { kind: "parsed", parse: parseSort, - brief: "Sort by: time (default), duration", - default: "time" as const, + brief: `Sort order: ${VALID_SORT_VALUES.join(", ")}`, + default: DEFAULT_SORT, }, fresh: FRESH_FLAG, }, @@ -249,7 +259,7 @@ export const listCommand = buildCommand({ target = await resolveProjectBySlug( parsed.projectSlug, USAGE_HINT, - `sentry span list /${parsed.projectSlug} ${traceId}` + `sentry span list /${parsed.projectSlug}/${traceId}` ); break; diff --git a/src/commands/span/view.ts b/src/commands/span/view.ts index 9501894f3..72f63d8f3 100644 --- a/src/commands/span/view.ts +++ b/src/commands/span/view.ts @@ -22,6 +22,7 @@ import { import { filterFields } from "../../lib/formatters/json.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { computeSpanDurationMs } from "../../lib/formatters/trace.js"; +import { validateSpanId } from "../../lib/hex-id.js"; import { applyFreshFlag, FRESH_ALIASES, @@ -43,31 +44,10 @@ type ViewFlags = { 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 [//] [...]"; -/** - * 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; -} - /** * Parse positional arguments for span view. * diff --git a/src/lib/hex-id.ts b/src/lib/hex-id.ts index 0e21bc40d..4d6d5fede 100644 --- a/src/lib/hex-id.ts +++ b/src/lib/hex-id.ts @@ -1,8 +1,8 @@ /** * Shared Hex ID Validation * - * Provides regex and validation for 32-character hexadecimal identifiers - * used across the CLI (log IDs, trace IDs, etc.). + * Provides regex and validation for hexadecimal identifiers used across + * the CLI (trace IDs, log IDs, span IDs, etc.). */ import { ValidationError } from "./errors.js"; @@ -10,6 +10,9 @@ import { ValidationError } from "./errors.js"; /** Regex for a valid 32-character hexadecimal ID */ export const HEX_ID_RE = /^[0-9a-f]{32}$/i; +/** Regex for a valid 16-character hexadecimal span ID */ +export const SPAN_ID_RE = /^[0-9a-f]{16}$/i; + /** * Regex for UUID format with dashes: 8-4-4-4-12 hex groups. * Users often copy trace/log IDs from tools that display them in UUID format. @@ -61,3 +64,31 @@ export function validateHexId(value: string, label: string): string { return trimmed; } + +/** + * Validate that a string is a 16-character hexadecimal span ID. + * Trims whitespace and normalizes to lowercase before validation. + * + * Dashes are stripped automatically so users can paste IDs in dash-separated + * formats (e.g., from debugging tools that format span IDs with dashes). + * + * @param value - The string to validate + * @returns The trimmed, lowercased, validated span ID + * @throws {ValidationError} If the format is invalid + */ +export function validateSpanId(value: string): string { + const trimmed = value.trim().toLowerCase().replace(/-/g, ""); + + if (!SPAN_ID_RE.test(trimmed)) { + const display = + trimmed.length > MAX_DISPLAY_LENGTH + ? `${trimmed.slice(0, MAX_DISPLAY_LENGTH - 3)}...` + : trimmed; + throw new ValidationError( + `Invalid span ID "${display}". Expected a 16-character hexadecimal string.\n\n` + + "Example: a1b2c3d4e5f67890" + ); + } + + return trimmed; +} diff --git a/test/commands/span/view.test.ts b/test/commands/span/view.test.ts index c058f46ea..699c19561 100644 --- a/test/commands/span/view.test.ts +++ b/test/commands/span/view.test.ts @@ -16,12 +16,12 @@ import { } from "bun:test"; import { parsePositionalArgs, - validateSpanId, viewCommand, } from "../../../src/commands/span/view.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as apiClient from "../../../src/lib/api-client.js"; import { ContextError, ValidationError } from "../../../src/lib/errors.js"; +import { validateSpanId } from "../../../src/lib/hex-id.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as resolveTarget from "../../../src/lib/resolve-target.js"; From 1230ccb869bbcbb8871e4498dfd752c4e84877ce Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 16 Mar 2026 14:17:09 +0000 Subject: [PATCH 18/28] chore: regenerate SKILL.md --- plugins/sentry-cli/skills/sentry-cli/SKILL.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 4df6398e8..f1ddfc0ba 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -646,7 +646,7 @@ 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")` +- `-s, --sort - Sort order: time, 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)` @@ -843,7 +843,7 @@ 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")` +- `-s, --sort - Sort order: time, 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)` From af9e278b649d3a005dd595539207895730eace89 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Mon, 16 Mar 2026 14:34:04 +0000 Subject: [PATCH 19/28] refactor: unify sort values with trace list ('date'/'duration') MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit span list used 'time' while trace list used 'date' — both mapped to the same -timestamp API sort. Standardize on 'date'/'duration' to match trace list and issue list conventions. Also removes 'time' from the listSpans API type since it was redundant with 'date'. Refs: PR #393 review feedback --- src/commands/span/list.ts | 12 ++++++------ src/lib/api/traces.ts | 4 ++-- test/commands/span/list.test.ts | 14 +++++++++----- 3 files changed, 17 insertions(+), 13 deletions(-) diff --git a/src/commands/span/list.ts b/src/commands/span/list.ts index cab529d32..48a7a145f 100644 --- a/src/commands/span/list.ts +++ b/src/commands/span/list.ts @@ -37,16 +37,16 @@ import { validateTraceId } from "../../lib/trace-id.js"; type ListFlags = { readonly limit: number; readonly query?: string; - readonly sort: "time" | "duration"; + readonly sort: "date" | "duration"; readonly json: boolean; readonly fresh: boolean; readonly fields?: string[]; }; -type SortValue = "time" | "duration"; +type SortValue = "date" | "duration"; -/** Accepted values for the --sort flag */ -const VALID_SORT_VALUES: SortValue[] = ["time", "duration"]; +/** Accepted values for the --sort flag (matches trace list) */ +const VALID_SORT_VALUES: SortValue[] = ["date", "duration"]; /** * CLI-side upper bound for --limit. @@ -62,7 +62,7 @@ const MAX_LIMIT = 1000; const DEFAULT_LIMIT = 25; /** Default sort order for span results */ -const DEFAULT_SORT: SortValue = "time"; +const DEFAULT_SORT: SortValue = "date"; /** Usage hint for ContextError messages */ const USAGE_HINT = "sentry span list [//]"; @@ -120,7 +120,7 @@ function parseLimit(value: string): number { /** * Parse and validate sort flag value. * - * @throws Error if value is not "time" or "duration" + * @throws Error if value is not "date" or "duration" */ export function parseSort(value: string): SortValue { if (!VALID_SORT_VALUES.includes(value as SortValue)) { diff --git a/src/lib/api/traces.ts b/src/lib/api/traces.ts index be429e622..c2cacf8dd 100644 --- a/src/lib/api/traces.ts +++ b/src/lib/api/traces.ts @@ -168,8 +168,8 @@ type ListSpansOptions = { query?: string; /** Maximum number of spans to return */ limit?: number; - /** Sort order: "date"/"time" (newest first) or "duration" (slowest first) */ - sort?: "date" | "time" | "duration"; + /** Sort order: "date" (newest first) or "duration" (slowest first) */ + sort?: "date" | "duration"; /** Time period for spans (e.g., "7d", "24h") */ statsPeriod?: string; /** Pagination cursor to resume from a previous page */ diff --git a/test/commands/span/list.test.ts b/test/commands/span/list.test.ts index bb2db57e6..f5c7c547f 100644 --- a/test/commands/span/list.test.ts +++ b/test/commands/span/list.test.ts @@ -98,14 +98,18 @@ describe("parsePositionalArgs", () => { }); describe("parseSort", () => { - test("accepts 'time'", () => { - expect(parseSort("time")).toBe("time"); + test("accepts 'date'", () => { + expect(parseSort("date")).toBe("date"); }); test("accepts 'duration'", () => { expect(parseSort("duration")).toBe("duration"); }); + test("rejects 'time' (use 'date' instead)", () => { + expect(() => parseSort("time")).toThrow("Invalid sort value"); + }); + test("throws for invalid value", () => { expect(() => parseSort("name")).toThrow("Invalid sort value"); }); @@ -190,7 +194,7 @@ describe("listCommand.func", () => { context, { limit: 25, - sort: "time", + sort: "date", fresh: false, }, VALID_TRACE_ID @@ -219,7 +223,7 @@ describe("listCommand.func", () => { { limit: 25, query: "op:db", - sort: "time", + sort: "date", fresh: false, }, VALID_TRACE_ID @@ -243,7 +247,7 @@ describe("listCommand.func", () => { context, { limit: 25, - sort: "time", + sort: "date", fresh: false, }, `my-org/my-project/${VALID_TRACE_ID}` From 817eb16ee3c23124e988c67a86964f468a425528 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 16 Mar 2026 14:34:55 +0000 Subject: [PATCH 20/28] chore: regenerate SKILL.md --- plugins/sentry-cli/skills/sentry-cli/SKILL.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index f1ddfc0ba..8e52e70c3 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -646,7 +646,7 @@ 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 order: time, duration - (default: "time")` +- `-s, --sort - Sort order: date, duration - (default: "date")` - `-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)` @@ -843,7 +843,7 @@ 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 order: time, duration - (default: "time")` +- `-s, --sort - Sort order: date, duration - (default: "date")` - `-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)` From 22fc1b24efc497e3aa8c2d561ec6213c737d04b0 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Mon, 16 Mar 2026 14:44:57 +0000 Subject: [PATCH 21/28] refactor: address second round of human review feedback - Use SortValue type alias in ListFlags instead of inline literal union - Fix MAX_LIMIT JSDoc: removed incorrect claim about internal pagination (listSpans passes per_page directly, no client-side pagination) - Add default params to validateLimit(value, min=1, max=1000) - Change limit brief to (<=) format --- src/commands/span/list.ts | 25 +++++++++++-------------- src/lib/api/traces.ts | 7 +++++-- src/lib/arg-parsing.ts | 2 +- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/commands/span/list.ts b/src/commands/span/list.ts index 48a7a145f..92a1425dc 100644 --- a/src/commands/span/list.ts +++ b/src/commands/span/list.ts @@ -5,6 +5,7 @@ */ import type { SentryContext } from "../../context.js"; +import type { SpanSortValue } from "../../lib/api/traces.js"; import { listSpans } from "../../lib/api-client.js"; import { parseOrgProjectArg, @@ -37,24 +38,20 @@ import { validateTraceId } from "../../lib/trace-id.js"; type ListFlags = { readonly limit: number; readonly query?: string; - readonly sort: "date" | "duration"; + readonly sort: SpanSortValue; readonly json: boolean; readonly fresh: boolean; readonly fields?: string[]; }; -type SortValue = "date" | "duration"; - /** Accepted values for the --sort flag (matches trace list) */ -const VALID_SORT_VALUES: SortValue[] = ["date", "duration"]; +const VALID_SORT_VALUES: SpanSortValue[] = ["date", "duration"]; /** * CLI-side upper bound for --limit. * - * The Sentry Events API (spans dataset) accepts `per_page` up to 100 per - * request, but the CLI allows requesting up to 1000 as a convenience — - * the API client paginates internally when needed. This matches the cap - * used by `issue list`, `trace list`, and `log list`. + * Passed directly as `per_page` to the Sentry Events API (spans dataset). + * Matches the cap used by `issue list`, `trace list`, and `log list`. */ const MAX_LIMIT = 1000; @@ -62,7 +59,7 @@ const MAX_LIMIT = 1000; const DEFAULT_LIMIT = 25; /** Default sort order for span results */ -const DEFAULT_SORT: SortValue = "date"; +const DEFAULT_SORT: SpanSortValue = "date"; /** Usage hint for ContextError messages */ const USAGE_HINT = "sentry span list [//]"; @@ -114,7 +111,7 @@ export function parsePositionalArgs(args: string[]): { * Parse --limit flag, delegating range validation to shared utility. */ function parseLimit(value: string): number { - return validateLimit(value, 1, MAX_LIMIT); + return validateLimit(value, 1, MAX_LIMIT); // min=1 is validateLimit's default, explicit for clarity } /** @@ -122,13 +119,13 @@ function parseLimit(value: string): number { * * @throws Error if value is not "date" or "duration" */ -export function parseSort(value: string): SortValue { - if (!VALID_SORT_VALUES.includes(value as SortValue)) { +export function parseSort(value: string): SpanSortValue { + if (!VALID_SORT_VALUES.includes(value as SpanSortValue)) { throw new Error( `Invalid sort value. Must be one of: ${VALID_SORT_VALUES.join(", ")}` ); } - return value as SortValue; + return value as SpanSortValue; } // --------------------------------------------------------------------------- @@ -210,7 +207,7 @@ export const listCommand = buildCommand({ limit: { kind: "parsed", parse: parseLimit, - brief: `Number of spans (1-${MAX_LIMIT})`, + brief: `Number of spans (<=${MAX_LIMIT})`, default: String(DEFAULT_LIMIT), }, query: { diff --git a/src/lib/api/traces.ts b/src/lib/api/traces.ts index c2cacf8dd..5d7bc213e 100644 --- a/src/lib/api/traces.ts +++ b/src/lib/api/traces.ts @@ -163,13 +163,16 @@ const SPAN_FIELDS = [ "trace", ]; +/** Sort values for span listing: newest first or slowest first */ +export type SpanSortValue = "date" | "duration"; + type ListSpansOptions = { /** Search query using Sentry query syntax */ query?: string; /** Maximum number of spans to return */ limit?: number; - /** Sort order: "date" (newest first) or "duration" (slowest first) */ - sort?: "date" | "duration"; + /** Sort order */ + sort?: SpanSortValue; /** Time period for spans (e.g., "7d", "24h") */ statsPeriod?: string; /** Pagination cursor to resume from a previous page */ diff --git a/src/lib/arg-parsing.ts b/src/lib/arg-parsing.ts index 9bfb3d46a..b9ee3209f 100644 --- a/src/lib/arg-parsing.ts +++ b/src/lib/arg-parsing.ts @@ -176,7 +176,7 @@ export function detectSwappedTrialArgs( * validateLimit("0", 1, 1000) // throws * validateLimit("abc", 1, 1000) // throws */ -export function validateLimit(value: string, min: number, max: number): number { +export function validateLimit(value: string, min = 1, max = 1000): number { const num = Number.parseInt(value, 10); if (Number.isNaN(num) || num < min || num > max) { throw new Error(`--limit must be between ${min} and ${max}`); From 9a5cb9a97939d5f48f85e68091ba45bde62dcf6b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 16 Mar 2026 14:50:58 +0000 Subject: [PATCH 22/28] chore: regenerate SKILL.md --- plugins/sentry-cli/skills/sentry-cli/SKILL.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 8e52e70c3..1298a6d3d 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -644,7 +644,7 @@ View spans in distributed traces List spans in a trace **Flags:** -- `-n, --limit - Number of spans (1-1000) - (default: "25")` +- `-n, --limit - Number of spans (<=1000) - (default: "25")` - `-q, --query - Filter spans (e.g., "op:db", "duration:>100ms", "project:backend")` - `-s, --sort - Sort order: date, duration - (default: "date")` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` @@ -841,7 +841,7 @@ List spans in a trace List spans in a trace **Flags:** -- `-n, --limit - Number of spans (1-1000) - (default: "25")` +- `-n, --limit - Number of spans (<=1000) - (default: "25")` - `-q, --query - Filter spans (e.g., "op:db", "duration:>100ms", "project:backend")` - `-s, --sort - Sort order: date, duration - (default: "date")` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` From 94014e79b4e455cf27b4db35f630608a1b69cec5 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Mon, 16 Mar 2026 15:08:03 +0000 Subject: [PATCH 23/28] refactor: break circular import between human.ts and trace.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract formatRelativeTime and computeSpanDurationMs into a new time-utils.ts module. Both are pure utility functions that were causing a circular dependency: trace.ts → human.ts (formatRelativeTime) human.ts → trace.ts (computeSpanDurationMs) Now both import from time-utils.ts which only depends on types/ and markdown.ts — no cycle. Update all direct importers (span/view.ts, test files) to use the new module path. --- src/commands/span/view.ts | 2 +- src/lib/formatters/human.ts | 40 ++------------- src/lib/formatters/index.ts | 1 + src/lib/formatters/time-utils.ts | 67 +++++++++++++++++++++++++ src/lib/formatters/trace.ts | 20 +------- test/lib/formatters/human.utils.test.ts | 2 +- test/lib/formatters/trace.test.ts | 2 +- 7 files changed, 75 insertions(+), 59 deletions(-) create mode 100644 src/lib/formatters/time-utils.ts diff --git a/src/commands/span/view.ts b/src/commands/span/view.ts index 72f63d8f3..819e4f304 100644 --- a/src/commands/span/view.ts +++ b/src/commands/span/view.ts @@ -21,7 +21,7 @@ import { } from "../../lib/formatters/index.js"; import { filterFields } from "../../lib/formatters/json.js"; import { CommandOutput } from "../../lib/formatters/output.js"; -import { computeSpanDurationMs } from "../../lib/formatters/trace.js"; +import { computeSpanDurationMs } from "../../lib/formatters/time-utils.js"; import { validateSpanId } from "../../lib/hex-id.js"; import { applyFreshFlag, diff --git a/src/lib/formatters/human.ts b/src/lib/formatters/human.ts index 6c8122424..c106ab9d3 100644 --- a/src/lib/formatters/human.ts +++ b/src/lib/formatters/human.ts @@ -39,7 +39,7 @@ import { } from "./markdown.js"; import { sparkline } from "./sparkline.js"; import { type Column, writeTable } from "./table.js"; -import { computeSpanDurationMs } from "./trace.js"; +import { computeSpanDurationMs } from "./time-utils.js"; // Color tag maps @@ -200,42 +200,8 @@ export function formatStatusLabel(status: string | undefined): string { ); } -// Date Formatting - -/** - * Format a date as relative time (e.g., "2h ago", "3d ago") or short date for older dates. - * - * - < 1 hour: "Xm ago" - * - < 24 hours: "Xh ago" - * - < 3 days: "Xd ago" - * - >= 3 days: Short date (e.g., "Jan 18") - */ -export function formatRelativeTime(dateString: string | undefined): string { - if (!dateString) { - return colorTag("muted", "—"); - } - - const date = new Date(dateString); - const now = Date.now(); - const diffMs = now - date.getTime(); - const diffMins = Math.floor(diffMs / 60_000); - const diffHours = Math.floor(diffMs / 3_600_000); - const diffDays = Math.floor(diffMs / 86_400_000); - - let text: string; - if (diffMins < 60) { - text = `${diffMins}m ago`; - } else if (diffHours < 24) { - text = `${diffHours}h ago`; - } else if (diffDays < 3) { - text = `${diffDays}d ago`; - } else { - // Short date: "Jan 18" - text = date.toLocaleDateString("en-US", { month: "short", day: "numeric" }); - } - - return text; -} +// formatRelativeTime moved to time-utils.ts to break circular import +import { formatRelativeTime } from "./time-utils.js"; // Issue Formatting diff --git a/src/lib/formatters/index.ts b/src/lib/formatters/index.ts index 012584944..f487c9a31 100644 --- a/src/lib/formatters/index.ts +++ b/src/lib/formatters/index.ts @@ -14,4 +14,5 @@ export * from "./output.js"; export * from "./seer.js"; export * from "./sparkline.js"; export * from "./table.js"; +export * from "./time-utils.js"; export * from "./trace.js"; diff --git a/src/lib/formatters/time-utils.ts b/src/lib/formatters/time-utils.ts new file mode 100644 index 000000000..0d68b2595 --- /dev/null +++ b/src/lib/formatters/time-utils.ts @@ -0,0 +1,67 @@ +/** + * Time and duration utility functions for formatters. + * + * Extracted to break the circular import between `human.ts` and `trace.ts`: + * both modules need these utilities but neither should depend on the other. + */ + +import type { TraceSpan } from "../../types/index.js"; +import { colorTag } from "./markdown.js"; + +/** + * Format a date string as a relative time label. + * + * - Under 60 minutes: "5m ago" + * - Under 24 hours: "3h ago" + * - Under 3 days: "2d ago" + * - Otherwise: short date like "Jan 18" + * + * Returns a muted "—" when the input is undefined. + * + * @param dateString - ISO date string or undefined + * @returns Human-readable relative time string + */ +export function formatRelativeTime(dateString: string | undefined): string { + if (!dateString) { + return colorTag("muted", "—"); + } + + const date = new Date(dateString); + const now = Date.now(); + const diffMs = now - date.getTime(); + const diffMins = Math.floor(diffMs / 60_000); + const diffHours = Math.floor(diffMs / 3_600_000); + const diffDays = Math.floor(diffMs / 86_400_000); + + let text: string; + if (diffMins < 60) { + text = `${diffMins}m ago`; + } else if (diffHours < 24) { + text = `${diffHours}h ago`; + } else if (diffDays < 3) { + text = `${diffDays}d ago`; + } else { + // Short date: "Jan 18" + text = date.toLocaleDateString("en-US", { month: "short", day: "numeric" }); + } + + return text; +} + +/** + * 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; +} diff --git a/src/lib/formatters/trace.ts b/src/lib/formatters/trace.ts index 0550ec3b0..f9a01a207 100644 --- a/src/lib/formatters/trace.ts +++ b/src/lib/formatters/trace.ts @@ -10,7 +10,6 @@ import type { TraceSpan, TransactionListItem, } from "../../types/index.js"; -import { formatRelativeTime } from "./human.js"; import { colorTag, escapeMarkdownCell, @@ -25,6 +24,7 @@ import { } from "./markdown.js"; import { type Column, formatTable } from "./table.js"; import { renderTextTable } from "./text-table.js"; +import { computeSpanDurationMs, formatRelativeTime } from "./time-utils.js"; /** * Format a duration in milliseconds to a human-readable string. @@ -292,24 +292,6 @@ export function formatTraceSummary(summary: TraceSummary): string { // 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; diff --git a/test/lib/formatters/human.utils.test.ts b/test/lib/formatters/human.utils.test.ts index 08006dd46..c0a92fe55 100644 --- a/test/lib/formatters/human.utils.test.ts +++ b/test/lib/formatters/human.utils.test.ts @@ -17,11 +17,11 @@ import { import { formatDuration, formatExpiration, - formatRelativeTime, formatStatusIcon, formatStatusLabel, maskToken, } from "../../../src/lib/formatters/human.js"; +import { formatRelativeTime } from "../../../src/lib/formatters/time-utils.js"; import { DEFAULT_NUM_RUNS } from "../../model-based/helpers.js"; // Helper to strip ANSI codes and markdown color tags for content testing. diff --git a/test/lib/formatters/trace.test.ts b/test/lib/formatters/trace.test.ts index 33137f7be..e5db93cc1 100644 --- a/test/lib/formatters/trace.test.ts +++ b/test/lib/formatters/trace.test.ts @@ -11,8 +11,8 @@ */ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { computeSpanDurationMs } from "../../../src/lib/formatters/time-utils.js"; import { - computeSpanDurationMs, computeTraceSummary, findSpanById, formatTraceDuration, From 17942444eeaab9afe4367952da66c522c8f9a0c3 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Mon, 16 Mar 2026 15:21:24 +0000 Subject: [PATCH 24/28] feat(span/list): add cursor pagination support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add --cursor/-c flag for page-through pagination, matching the pattern used by trace list, issue list, and log list. - Wire up shared pagination infrastructure: buildPaginationContextKey, resolveOrgCursor, setPaginationCursor, clearPaginationCursor - Context key scoped to org/project/traceId + sort/query to prevent cursor collisions across different queries - Include nextCursor in JSON output envelope - Show '-c last' hint in footer when more pages available - Support 'sentry span list -c last' to resume The listSpans API already supported cursor — this just wires it up in the command layer. --- plugins/sentry-cli/skills/sentry-cli/SKILL.md | 2 + src/commands/span/list.ts | 80 ++++++++++++++-- test/commands/span/list.test.ts | 96 +++++++++++++++++++ 3 files changed, 170 insertions(+), 8 deletions(-) diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 1298a6d3d..f5ff46056 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -647,6 +647,7 @@ List spans in a trace - `-n, --limit - Number of spans (<=1000) - (default: "25")` - `-q, --query - Filter spans (e.g., "op:db", "duration:>100ms", "project:backend")` - `-s, --sort - Sort order: date, duration - (default: "date")` +- `-c, --cursor - Pagination cursor (use "last" to continue from previous page)` - `-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)` @@ -844,6 +845,7 @@ List spans in a trace - `-n, --limit - Number of spans (<=1000) - (default: "25")` - `-q, --query - Filter spans (e.g., "op:db", "duration:>100ms", "project:backend")` - `-s, --sort - Sort order: date, duration - (default: "date")` +- `-c, --cursor - Pagination cursor (use "last" to continue from previous page)` - `-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)` diff --git a/src/commands/span/list.ts b/src/commands/span/list.ts index 92a1425dc..b734b43b9 100644 --- a/src/commands/span/list.ts +++ b/src/commands/span/list.ts @@ -13,6 +13,12 @@ import { validateLimit, } from "../../lib/arg-parsing.js"; import { buildCommand } from "../../lib/command.js"; +import { + buildPaginationContextKey, + clearPaginationCursor, + resolveOrgCursor, + setPaginationCursor, +} from "../../lib/db/pagination.js"; import { ContextError, ValidationError } from "../../lib/errors.js"; import { type FlatSpan, @@ -27,6 +33,7 @@ import { applyFreshFlag, FRESH_ALIASES, FRESH_FLAG, + LIST_CURSOR_FLAG, } from "../../lib/list-command.js"; import { logger } from "../../lib/logger.js"; import { @@ -39,6 +46,7 @@ type ListFlags = { readonly limit: number; readonly query?: string; readonly sort: SpanSortValue; + readonly cursor?: string; readonly json: boolean; readonly fresh: boolean; readonly fields?: string[]; @@ -61,6 +69,9 @@ const DEFAULT_LIMIT = 25; /** Default sort order for span results */ const DEFAULT_SORT: SpanSortValue = "date"; +/** Pagination storage key for cursor resume */ +export const PAGINATION_KEY = "span-list"; + /** Usage hint for ContextError messages */ const USAGE_HINT = "sentry span list [//]"; @@ -111,7 +122,7 @@ export function parsePositionalArgs(args: string[]): { * Parse --limit flag, delegating range validation to shared utility. */ function parseLimit(value: string): number { - return validateLimit(value, 1, MAX_LIMIT); // min=1 is validateLimit's default, explicit for clarity + return validateLimit(value, 1, MAX_LIMIT); } /** @@ -128,6 +139,24 @@ export function parseSort(value: string): SpanSortValue { return value as SpanSortValue; } +/** Build the CLI hint for fetching the next page, preserving active flags. */ +function nextPageHint( + org: string, + project: string, + traceId: string, + flags: Pick +): string { + const base = `sentry span list ${org}/${project}/${traceId} -c last`; + const parts: string[] = []; + if (flags.sort !== DEFAULT_SORT) { + parts.push(`--sort ${flags.sort}`); + } + if (flags.query) { + parts.push(`-q "${flags.query}"`); + } + return parts.length > 0 ? `${base} ${parts.join(" ")}` : base; +} + // --------------------------------------------------------------------------- // Output config types and formatters // --------------------------------------------------------------------------- @@ -138,6 +167,8 @@ type SpanListData = { flatSpans: FlatSpan[]; /** Whether more results are available beyond the limit */ hasMore: boolean; + /** Opaque cursor for fetching the next page (null/undefined when no more) */ + nextCursor?: string | null; /** The trace ID being queried */ traceId: string; }; @@ -161,15 +192,26 @@ function formatSpanListHuman(data: SpanListData): string { /** * Transform span list data for JSON output. * - * Produces a `{ data: [...], hasMore }` envelope matching the standard - * paginated list format. Applies `--fields` filtering per element. + * Produces a `{ data: [...], hasMore, nextCursor? }` envelope matching the + * standard paginated list format. Applies `--fields` filtering per element. */ function jsonTransformSpanList(data: SpanListData, fields?: string[]): unknown { const items = fields && fields.length > 0 ? data.flatSpans.map((item) => filterFields(item, fields)) : data.flatSpans; - return { data: items, hasMore: data.hasMore }; + const envelope: Record = { + data: items, + hasMore: data.hasMore, + }; + if ( + data.nextCursor !== null && + data.nextCursor !== undefined && + data.nextCursor !== "" + ) { + envelope.nextCursor = data.nextCursor; + } + return envelope; } export const listCommand = buildCommand({ @@ -182,6 +224,8 @@ export const listCommand = buildCommand({ " 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" + + "Pagination:\n" + + " sentry span list -c last # fetch next page\n\n" + "Examples:\n" + " sentry span list # List spans in trace\n" + " sentry span list --limit 50 # Show more spans\n" + @@ -223,6 +267,7 @@ export const listCommand = buildCommand({ brief: `Sort order: ${VALID_SORT_VALUES.join(", ")}`, default: DEFAULT_SORT, }, + cursor: LIST_CURSOR_FLAG, fresh: FRESH_FLAG, }, aliases: { @@ -230,6 +275,7 @@ export const listCommand = buildCommand({ n: "limit", q: "query", s: "sort", + c: "cursor", }, }, async *func(this: SentryContext, flags: ListFlags, ...args: string[]) { @@ -288,6 +334,14 @@ export const listCommand = buildCommand({ } const apiQuery = queryParts.join(" "); + // Build context key and resolve cursor for pagination + const contextKey = buildPaginationContextKey( + "span", + `${target.org}/${target.project}/${traceId}`, + { sort: flags.sort, q: flags.query } + ); + const cursor = resolveOrgCursor(flags.cursor, PAGINATION_KEY, contextKey); + // Fetch spans from EAP endpoint const { data: spanItems, nextCursor } = await listSpans( target.org, @@ -296,22 +350,32 @@ export const listCommand = buildCommand({ query: apiQuery, sort: flags.sort, limit: flags.limit, + cursor, } ); + // Store or clear pagination cursor + if (nextCursor) { + setPaginationCursor(PAGINATION_KEY, contextKey, nextCursor); + } else { + clearPaginationCursor(PAGINATION_KEY, contextKey); + } + const flatSpans = spanItems.map(spanListItemToFlatSpan); - const hasMore = nextCursor !== undefined; + const hasMore = !!nextCursor; // Build hint footer let hint: string | undefined; - if (flatSpans.length > 0) { + if (flatSpans.length === 0 && hasMore) { + hint = `Try the next page: ${nextPageHint(target.org, target.project, traceId, flags)}`; + } else if (flatSpans.length > 0) { const countText = `Showing ${flatSpans.length} span${flatSpans.length === 1 ? "" : "s"}.`; hint = hasMore - ? `${countText} Use --limit to see more.` + ? `${countText} Next page: ${nextPageHint(target.org, target.project, traceId, flags)}` : `${countText} Use 'sentry span view ${traceId} ' to view span details.`; } - yield new CommandOutput({ flatSpans, hasMore, traceId }); + yield new CommandOutput({ flatSpans, hasMore, nextCursor, traceId }); return { hint }; }, }); diff --git a/test/commands/span/list.test.ts b/test/commands/span/list.test.ts index f5c7c547f..c2875fa34 100644 --- a/test/commands/span/list.test.ts +++ b/test/commands/span/list.test.ts @@ -261,4 +261,100 @@ describe("listCommand.func", () => { // Should NOT have called resolveOrgAndProject expect(resolveOrgAndProjectSpy).not.toHaveBeenCalled(); }); + + test("passes cursor to API when --cursor is set", async () => { + listSpansSpy.mockResolvedValue({ + data: [ + { + id: "a1b2c3d4e5f67890", + timestamp: "2024-01-15T10:30:00+00:00", + project: "test-project", + trace: VALID_TRACE_ID, + }, + ], + nextCursor: undefined, + }); + + const { context } = createContext(); + + await func.call( + context, + { + limit: 25, + sort: "date", + cursor: "1735689600:0:0", + fresh: false, + }, + VALID_TRACE_ID + ); + + expect(listSpansSpy).toHaveBeenCalledWith( + "test-org", + "test-project", + expect.objectContaining({ + cursor: "1735689600:0:0", + }) + ); + }); + + test("includes nextCursor in JSON output when hasMore", async () => { + listSpansSpy.mockResolvedValue({ + data: [ + { + id: "a1b2c3d4e5f67890", + timestamp: "2024-01-15T10:30:00+00:00", + project: "test-project", + trace: VALID_TRACE_ID, + }, + ], + nextCursor: "1735689600:0:1", + }); + + const { context, getStdout } = createContext(); + + await func.call( + context, + { + limit: 1, + sort: "date", + json: true, + fresh: false, + }, + VALID_TRACE_ID + ); + + const output = getStdout(); + const parsed = JSON.parse(output); + expect(parsed.hasMore).toBe(true); + expect(parsed.nextCursor).toBe("1735689600:0:1"); + }); + + test("hint shows -c last when more pages available", async () => { + listSpansSpy.mockResolvedValue({ + data: [ + { + id: "a1b2c3d4e5f67890", + timestamp: "2024-01-15T10:30:00+00:00", + project: "test-project", + trace: VALID_TRACE_ID, + }, + ], + nextCursor: "1735689600:0:1", + }); + + const { context, getStdout } = createContext(); + + await func.call( + context, + { + limit: 1, + sort: "date", + fresh: false, + }, + VALID_TRACE_ID + ); + + const output = getStdout(); + expect(output).toContain("-c last"); + }); }); From 87664f192dd122f39d582e4a3d822f04d75689e4 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Mon, 16 Mar 2026 15:29:04 +0000 Subject: [PATCH 25/28] fix: merge duplicate time-utils imports in human.ts Consolidate two separate imports from time-utils.js into a single import statement at the top of the file. Removes the mid-file import that was left from the circular import refactor. --- src/lib/formatters/human.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/lib/formatters/human.ts b/src/lib/formatters/human.ts index c106ab9d3..bcb3856a6 100644 --- a/src/lib/formatters/human.ts +++ b/src/lib/formatters/human.ts @@ -39,7 +39,7 @@ import { } from "./markdown.js"; import { sparkline } from "./sparkline.js"; import { type Column, writeTable } from "./table.js"; -import { computeSpanDurationMs } from "./time-utils.js"; +import { computeSpanDurationMs, formatRelativeTime } from "./time-utils.js"; // Color tag maps @@ -200,9 +200,6 @@ export function formatStatusLabel(status: string | undefined): string { ); } -// formatRelativeTime moved to time-utils.ts to break circular import -import { formatRelativeTime } from "./time-utils.js"; - // Issue Formatting /** Quantifier suffixes indexed by groups of 3 digits (K=10^3, M=10^6, …, E=10^18) */ From e47b25401e9fcf11ea98a896f36d9ec918246671 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Mon, 16 Mar 2026 15:36:54 +0000 Subject: [PATCH 26/28] fix: handle negated query keys + remove unused FlatSpan fields - translateSpanQuery: strip leading '!' before alias lookup, re-add after resolution. '!op:db' now correctly translates to '!span.op:db'. - FlatSpan: remove unused depth/child_count fields (remnants of removed flattenSpanTree function). --- src/lib/formatters/trace.ts | 12 ++++++++---- test/lib/formatters/trace.test.ts | 11 +++++++++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/lib/formatters/trace.ts b/src/lib/formatters/trace.ts index f9a01a207..bcb31e7f1 100644 --- a/src/lib/formatters/trace.ts +++ b/src/lib/formatters/trace.ts @@ -302,8 +302,6 @@ export type FlatSpan = { start_timestamp: number; project_slug?: string; transaction?: string; - depth?: number; - child_count?: number; }; /** Result of finding a span by ID in the tree */ @@ -371,9 +369,15 @@ export function translateSpanQuery(query: string): string { if (colonIdx === -1) { return token; } - const key = token.slice(0, colonIdx).toLowerCase(); + let key = token.slice(0, colonIdx).toLowerCase(); const rest = token.slice(colonIdx); - return (SPAN_KEY_ALIASES[key] ?? key) + rest; + // Strip negation prefix before alias lookup, re-add after + const negated = key.startsWith("!"); + if (negated) { + key = key.slice(1); + } + const resolved = SPAN_KEY_ALIASES[key] ?? key; + return (negated ? "!" : "") + resolved + rest; }) .join(" "); } diff --git a/test/lib/formatters/trace.test.ts b/test/lib/formatters/trace.test.ts index e5db93cc1..1150ebf2a 100644 --- a/test/lib/formatters/trace.test.ts +++ b/test/lib/formatters/trace.test.ts @@ -484,6 +484,17 @@ describe("translateSpanQuery", () => { 'description:"GET /api"' ); }); + + test("negated shorthand keys are translated correctly", () => { + expect(translateSpanQuery("!op:db")).toBe("!span.op:db"); + expect(translateSpanQuery("!duration:>100ms")).toBe( + "!span.duration:>100ms" + ); + }); + + test("negated non-alias keys pass through unchanged", () => { + expect(translateSpanQuery("!description:fetch")).toBe("!description:fetch"); + }); }); // --------------------------------------------------------------------------- From 72a9686f188fc461bf1105c278d83aa6c86fca20 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Mon, 16 Mar 2026 17:23:39 +0000 Subject: [PATCH 27/28] docs: update AGENTS.md with patterns from span commands work Major updates based on lessons learned during PR #393: 1. CLI Commands section: Replace deprecated stdout.write() pattern with the current async generator + CommandOutput + OutputConfig pattern. Add explicit rules about buildCommand import source. 2. New sections: Positional Arguments (parseSlashSeparatedArg pattern), Markdown Rendering (renderMarkdown/colorTag rules, plainSafeMuted), List Command Pagination (cursor infrastructure), ID Validation (hex-id.ts validators), Sort Convention ('date' not 'time'), SKILL.md (generate:skill, descriptive placeholders). 3. Architecture tree: Add missing directories (span/, trace/, log/, trial/, cli/, api/), files (command.ts, hex-id.ts, trace-id.ts, pagination.ts, time-utils.ts, markdown.ts, trace.ts, table.ts), and fix stale descriptions (api-client.ts is barrel, not ky-based). 4. List Command Infrastructure: Add standalone list command pattern (span list, trace list) as third tier alongside buildOrgListCommand and dispatchOrgScopedList. 5. Imports: Fix stale @stricli/core import in example. --- AGENTS.md | 134 ++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 124 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 48ec3ae54..0644a2871 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -109,13 +109,31 @@ cli/ │ │ ├── issue/ # list, view, explain, plan │ │ ├── org/ # list, view │ │ ├── project/ # list, view +│ │ ├── span/ # list, view +│ │ ├── trace/ # list, view, logs +│ │ ├── log/ # list, view +│ │ ├── trial/ # list, start +│ │ ├── cli/ # fix, upgrade, feedback, setup │ │ ├── api.ts # Direct API access command │ │ └── help.ts # Help command │ ├── lib/ # Shared utilities -│ │ ├── api-client.ts # Sentry API client (ky-based) +│ │ ├── command.ts # buildCommand wrapper (telemetry + output) +│ │ ├── api-client.ts # Barrel re-export for API modules +│ │ ├── api/ # Domain API modules +│ │ │ ├── infrastructure.ts # Shared helpers, types, raw requests +│ │ │ ├── organizations.ts +│ │ │ ├── projects.ts +│ │ │ ├── issues.ts +│ │ │ ├── events.ts +│ │ │ ├── traces.ts # Trace + span listing +│ │ │ ├── logs.ts +│ │ │ ├── seer.ts +│ │ │ └── trials.ts │ │ ├── region.ts # Multi-region resolution │ │ ├── telemetry.ts # Sentry SDK instrumentation │ │ ├── sentry-urls.ts # URL builders for Sentry +│ │ ├── hex-id.ts # Hex ID validation (32-char + 16-char span) +│ │ ├── trace-id.ts # Trace ID validation wrapper │ │ ├── db/ # SQLite database layer │ │ │ ├── instance.ts # Database singleton │ │ │ ├── schema.ts # Table definitions @@ -125,6 +143,7 @@ cli/ │ │ │ ├── user.ts # User info cache │ │ │ ├── regions.ts # Org→region URL cache │ │ │ ├── defaults.ts # Default org/project +│ │ │ ├── pagination.ts # Cursor pagination storage │ │ │ ├── dsn-cache.ts # DSN resolution cache │ │ │ ├── project-cache.ts # Project data cache │ │ │ ├── project-root-cache.ts # Project root cache @@ -154,7 +173,12 @@ cli/ │ │ │ ├── json.ts # JSON output │ │ │ ├── output.ts # Output utilities │ │ │ ├── seer.ts # Seer AI response formatting -│ │ │ └── colors.ts # Terminal colors +│ │ │ ├── colors.ts # Terminal colors +│ │ │ ├── markdown.ts # Markdown → ANSI renderer +│ │ │ ├── trace.ts # Trace/span formatters +│ │ │ ├── time-utils.ts # Shared time/duration utils +│ │ │ ├── table.ts # Table rendering +│ │ │ └── log.ts # Log entry formatting │ │ ├── oauth.ts # OAuth device flow │ │ ├── errors.ts # Error classes │ │ ├── resolve-target.ts # Org/project resolution @@ -197,34 +221,122 @@ cli/ ### CLI Commands (Stricli) -Commands use `@stricli/core`. +Commands use [Stricli](https://bloomberg.github.io/stricli/docs/getting-started/principles) wrapped by `src/lib/command.ts`. -**Stricli Documentation**: https://bloomberg.github.io/stricli/docs/getting-started/principles +**CRITICAL**: Import `buildCommand` from `../../lib/command.js`, **NEVER** from `@stricli/core` directly — the wrapper adds telemetry, `--json`/`--fields` injection, and output rendering. Pattern: ```typescript -import { buildCommand } from "@stricli/core"; +import { buildCommand } from "../../lib/command.js"; import type { SentryContext } from "../../context.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; export const myCommand = buildCommand({ docs: { brief: "Short description", fullDescription: "Detailed description", }, + output: { + human: formatMyData, // (data: T) => string + jsonTransform: jsonTransformMyData, // optional: (data: T, fields?) => unknown + jsonExclude: ["humanOnlyField"], // optional: strip keys from JSON + }, parameters: { flags: { - json: { kind: "boolean", brief: "Output as JSON", default: false }, limit: { kind: "parsed", parse: Number, brief: "Max items", default: 10 }, }, }, - async func(this: SentryContext, flags) { - const { process } = this; - // Implementation - use process.stdout.write() for output + async *func(this: SentryContext, flags) { + const data = await fetchData(); + yield new CommandOutput(data); + return { hint: "Tip: use --json for machine-readable output" }; }, }); ``` +**Key rules:** +- Functions are `async *func()` generators — yield `new CommandOutput(data)`, return `{ hint }`. +- `output.human` receives the same data object that gets serialized to JSON — no divergent-data paths. +- The wrapper auto-injects `--json` and `--fields` flags. Do NOT add your own `json` flag. +- Do NOT use `stdout.write()` or `if (flags.json)` branching — the wrapper handles it. + +### Positional Arguments + +Use `parseSlashSeparatedArg` from `src/lib/arg-parsing.ts` for the standard `[//]` pattern. Required identifiers (trace IDs, span IDs) should be **positional args**, not flags. + +```typescript +import { parseSlashSeparatedArg, parseOrgProjectArg } from "../../lib/arg-parsing.js"; + +// "my-org/my-project/abc123" → { id: "abc123", targetArg: "my-org/my-project" } +const { id, targetArg } = parseSlashSeparatedArg(first, "Trace ID", USAGE_HINT); +const parsed = parseOrgProjectArg(targetArg); +// parsed.type: "auto-detect" | "explicit" | "project-search" | "org-all" +``` + +Reference: `span/list.ts`, `trace/view.ts`, `event/view.ts` + +### Markdown Rendering + +All non-trivial human output must use the markdown rendering pipeline: + +- Build markdown strings with helpers: `mdKvTable()`, `colorTag()`, `escapeMarkdownCell()`, `renderMarkdown()` +- **NEVER** use raw `muted()` / chalk in output strings — use `colorTag("muted", text)` inside markdown +- Tree-structured output (box-drawing characters) that can't go through `renderMarkdown()` should use the `plainSafeMuted` pattern: `isPlainOutput() ? text : muted(text)` +- `isPlainOutput()` precedence: `SENTRY_PLAIN_OUTPUT` > `NO_COLOR` > `FORCE_COLOR` > `!isTTY` + +Reference: `formatters/trace.ts` (`formatAncestorChain`), `formatters/human.ts` (`plainSafeMuted`) + +### List Command Pagination + +All list commands with API pagination MUST use the shared cursor infrastructure: + +```typescript +import { LIST_CURSOR_FLAG } from "../../lib/list-command.js"; +import { + buildPaginationContextKey, resolveOrgCursor, + setPaginationCursor, clearPaginationCursor, +} from "../../lib/db/pagination.js"; + +export const PAGINATION_KEY = "my-entity-list"; + +// In buildCommand: +flags: { cursor: LIST_CURSOR_FLAG }, +aliases: { c: "cursor" }, + +// In func(): +const contextKey = buildPaginationContextKey("entity", `${org}/${project}`, { + sort: flags.sort, q: flags.query, +}); +const cursor = resolveOrgCursor(flags.cursor, PAGINATION_KEY, contextKey); +const { data, nextCursor } = await listEntities(org, project, { cursor, ... }); +if (nextCursor) setPaginationCursor(PAGINATION_KEY, contextKey, nextCursor); +else clearPaginationCursor(PAGINATION_KEY, contextKey); +``` + +Show `-c last` in the hint footer when more pages are available. Include `nextCursor` in the JSON envelope. + +Reference template: `trace/list.ts`, `span/list.ts` + +### ID Validation + +Use shared validators from `src/lib/hex-id.ts`: +- `validateHexId(value, label)` — 32-char hex IDs (trace IDs, log IDs). Auto-strips UUID dashes. +- `validateSpanId(value)` — 16-char hex span IDs. Auto-strips dashes. +- `validateTraceId(value)` — thin wrapper around `validateHexId` in `src/lib/trace-id.ts`. + +All normalize to lowercase. Throw `ValidationError` on invalid input. + +### Sort Convention + +Use `"date"` for timestamp-based sort (not `"time"`). Export sort types from the API layer (e.g., `SpanSortValue` from `api/traces.ts`), import in commands. This matches `issue list`, `trace list`, and `span list`. + +### SKILL.md + +- Run `bun run generate:skill` after changing any command parameters, flags, or docs. +- CI check `bun run check:skill` will fail if SKILL.md is stale. +- Positional `placeholder` values must be descriptive: `"org/project/trace-id"` not `"args"`. + ### Zod Schemas for Validation All config and API types use Zod schemas: @@ -320,7 +432,7 @@ await setAuthToken(token, expiresIn); ```typescript import { z } from "zod"; -import { buildCommand } from "@stricli/core"; +import { buildCommand } from "../../lib/command.js"; import type { SentryContext } from "../../context.js"; import { getAuthToken } from "../../lib/config.js"; ``` @@ -339,6 +451,8 @@ Key rules when writing overrides: - `resolveCursor()` must be called **inside** the `org-all` override closure, not before `dispatchOrgScopedList`, so that `--cursor` validation errors fire correctly for non-org-all modes. - `handleProjectSearch` errors must use `"Project"` as the `ContextError` resource, not `config.entityName`. +3. **Standalone list commands** (e.g., `span list`, `trace list`) that don't use org-scoped dispatch wire pagination directly in `func()`. See the "List Command Pagination" section above for the pattern. + ## Commenting & Documentation (JSDoc-first) ### Default Rule From 4da7ff4ae17b3da4fe7347b805a9505e18d425c6 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Mon, 16 Mar 2026 17:56:02 +0000 Subject: [PATCH 28/28] revert: remove AGENTS.md changes (moved to PR #433) --- AGENTS.md | 134 ++++-------------------------------------------------- 1 file changed, 10 insertions(+), 124 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0644a2871..48ec3ae54 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -109,31 +109,13 @@ cli/ │ │ ├── issue/ # list, view, explain, plan │ │ ├── org/ # list, view │ │ ├── project/ # list, view -│ │ ├── span/ # list, view -│ │ ├── trace/ # list, view, logs -│ │ ├── log/ # list, view -│ │ ├── trial/ # list, start -│ │ ├── cli/ # fix, upgrade, feedback, setup │ │ ├── api.ts # Direct API access command │ │ └── help.ts # Help command │ ├── lib/ # Shared utilities -│ │ ├── command.ts # buildCommand wrapper (telemetry + output) -│ │ ├── api-client.ts # Barrel re-export for API modules -│ │ ├── api/ # Domain API modules -│ │ │ ├── infrastructure.ts # Shared helpers, types, raw requests -│ │ │ ├── organizations.ts -│ │ │ ├── projects.ts -│ │ │ ├── issues.ts -│ │ │ ├── events.ts -│ │ │ ├── traces.ts # Trace + span listing -│ │ │ ├── logs.ts -│ │ │ ├── seer.ts -│ │ │ └── trials.ts +│ │ ├── api-client.ts # Sentry API client (ky-based) │ │ ├── region.ts # Multi-region resolution │ │ ├── telemetry.ts # Sentry SDK instrumentation │ │ ├── sentry-urls.ts # URL builders for Sentry -│ │ ├── hex-id.ts # Hex ID validation (32-char + 16-char span) -│ │ ├── trace-id.ts # Trace ID validation wrapper │ │ ├── db/ # SQLite database layer │ │ │ ├── instance.ts # Database singleton │ │ │ ├── schema.ts # Table definitions @@ -143,7 +125,6 @@ cli/ │ │ │ ├── user.ts # User info cache │ │ │ ├── regions.ts # Org→region URL cache │ │ │ ├── defaults.ts # Default org/project -│ │ │ ├── pagination.ts # Cursor pagination storage │ │ │ ├── dsn-cache.ts # DSN resolution cache │ │ │ ├── project-cache.ts # Project data cache │ │ │ ├── project-root-cache.ts # Project root cache @@ -173,12 +154,7 @@ cli/ │ │ │ ├── json.ts # JSON output │ │ │ ├── output.ts # Output utilities │ │ │ ├── seer.ts # Seer AI response formatting -│ │ │ ├── colors.ts # Terminal colors -│ │ │ ├── markdown.ts # Markdown → ANSI renderer -│ │ │ ├── trace.ts # Trace/span formatters -│ │ │ ├── time-utils.ts # Shared time/duration utils -│ │ │ ├── table.ts # Table rendering -│ │ │ └── log.ts # Log entry formatting +│ │ │ └── colors.ts # Terminal colors │ │ ├── oauth.ts # OAuth device flow │ │ ├── errors.ts # Error classes │ │ ├── resolve-target.ts # Org/project resolution @@ -221,122 +197,34 @@ cli/ ### CLI Commands (Stricli) -Commands use [Stricli](https://bloomberg.github.io/stricli/docs/getting-started/principles) wrapped by `src/lib/command.ts`. +Commands use `@stricli/core`. -**CRITICAL**: Import `buildCommand` from `../../lib/command.js`, **NEVER** from `@stricli/core` directly — the wrapper adds telemetry, `--json`/`--fields` injection, and output rendering. +**Stricli Documentation**: https://bloomberg.github.io/stricli/docs/getting-started/principles Pattern: ```typescript -import { buildCommand } from "../../lib/command.js"; +import { buildCommand } from "@stricli/core"; import type { SentryContext } from "../../context.js"; -import { CommandOutput } from "../../lib/formatters/output.js"; export const myCommand = buildCommand({ docs: { brief: "Short description", fullDescription: "Detailed description", }, - output: { - human: formatMyData, // (data: T) => string - jsonTransform: jsonTransformMyData, // optional: (data: T, fields?) => unknown - jsonExclude: ["humanOnlyField"], // optional: strip keys from JSON - }, parameters: { flags: { + json: { kind: "boolean", brief: "Output as JSON", default: false }, limit: { kind: "parsed", parse: Number, brief: "Max items", default: 10 }, }, }, - async *func(this: SentryContext, flags) { - const data = await fetchData(); - yield new CommandOutput(data); - return { hint: "Tip: use --json for machine-readable output" }; + async func(this: SentryContext, flags) { + const { process } = this; + // Implementation - use process.stdout.write() for output }, }); ``` -**Key rules:** -- Functions are `async *func()` generators — yield `new CommandOutput(data)`, return `{ hint }`. -- `output.human` receives the same data object that gets serialized to JSON — no divergent-data paths. -- The wrapper auto-injects `--json` and `--fields` flags. Do NOT add your own `json` flag. -- Do NOT use `stdout.write()` or `if (flags.json)` branching — the wrapper handles it. - -### Positional Arguments - -Use `parseSlashSeparatedArg` from `src/lib/arg-parsing.ts` for the standard `[//]` pattern. Required identifiers (trace IDs, span IDs) should be **positional args**, not flags. - -```typescript -import { parseSlashSeparatedArg, parseOrgProjectArg } from "../../lib/arg-parsing.js"; - -// "my-org/my-project/abc123" → { id: "abc123", targetArg: "my-org/my-project" } -const { id, targetArg } = parseSlashSeparatedArg(first, "Trace ID", USAGE_HINT); -const parsed = parseOrgProjectArg(targetArg); -// parsed.type: "auto-detect" | "explicit" | "project-search" | "org-all" -``` - -Reference: `span/list.ts`, `trace/view.ts`, `event/view.ts` - -### Markdown Rendering - -All non-trivial human output must use the markdown rendering pipeline: - -- Build markdown strings with helpers: `mdKvTable()`, `colorTag()`, `escapeMarkdownCell()`, `renderMarkdown()` -- **NEVER** use raw `muted()` / chalk in output strings — use `colorTag("muted", text)` inside markdown -- Tree-structured output (box-drawing characters) that can't go through `renderMarkdown()` should use the `plainSafeMuted` pattern: `isPlainOutput() ? text : muted(text)` -- `isPlainOutput()` precedence: `SENTRY_PLAIN_OUTPUT` > `NO_COLOR` > `FORCE_COLOR` > `!isTTY` - -Reference: `formatters/trace.ts` (`formatAncestorChain`), `formatters/human.ts` (`plainSafeMuted`) - -### List Command Pagination - -All list commands with API pagination MUST use the shared cursor infrastructure: - -```typescript -import { LIST_CURSOR_FLAG } from "../../lib/list-command.js"; -import { - buildPaginationContextKey, resolveOrgCursor, - setPaginationCursor, clearPaginationCursor, -} from "../../lib/db/pagination.js"; - -export const PAGINATION_KEY = "my-entity-list"; - -// In buildCommand: -flags: { cursor: LIST_CURSOR_FLAG }, -aliases: { c: "cursor" }, - -// In func(): -const contextKey = buildPaginationContextKey("entity", `${org}/${project}`, { - sort: flags.sort, q: flags.query, -}); -const cursor = resolveOrgCursor(flags.cursor, PAGINATION_KEY, contextKey); -const { data, nextCursor } = await listEntities(org, project, { cursor, ... }); -if (nextCursor) setPaginationCursor(PAGINATION_KEY, contextKey, nextCursor); -else clearPaginationCursor(PAGINATION_KEY, contextKey); -``` - -Show `-c last` in the hint footer when more pages are available. Include `nextCursor` in the JSON envelope. - -Reference template: `trace/list.ts`, `span/list.ts` - -### ID Validation - -Use shared validators from `src/lib/hex-id.ts`: -- `validateHexId(value, label)` — 32-char hex IDs (trace IDs, log IDs). Auto-strips UUID dashes. -- `validateSpanId(value)` — 16-char hex span IDs. Auto-strips dashes. -- `validateTraceId(value)` — thin wrapper around `validateHexId` in `src/lib/trace-id.ts`. - -All normalize to lowercase. Throw `ValidationError` on invalid input. - -### Sort Convention - -Use `"date"` for timestamp-based sort (not `"time"`). Export sort types from the API layer (e.g., `SpanSortValue` from `api/traces.ts`), import in commands. This matches `issue list`, `trace list`, and `span list`. - -### SKILL.md - -- Run `bun run generate:skill` after changing any command parameters, flags, or docs. -- CI check `bun run check:skill` will fail if SKILL.md is stale. -- Positional `placeholder` values must be descriptive: `"org/project/trace-id"` not `"args"`. - ### Zod Schemas for Validation All config and API types use Zod schemas: @@ -432,7 +320,7 @@ await setAuthToken(token, expiresIn); ```typescript import { z } from "zod"; -import { buildCommand } from "../../lib/command.js"; +import { buildCommand } from "@stricli/core"; import type { SentryContext } from "../../context.js"; import { getAuthToken } from "../../lib/config.js"; ``` @@ -451,8 +339,6 @@ Key rules when writing overrides: - `resolveCursor()` must be called **inside** the `org-all` override closure, not before `dispatchOrgScopedList`, so that `--cursor` validation errors fire correctly for non-org-all modes. - `handleProjectSearch` errors must use `"Project"` as the `ContextError` resource, not `config.entityName`. -3. **Standalone list commands** (e.g., `span list`, `trace list`) that don't use org-scoped dispatch wire pagination directly in `func()`. See the "List Command Pagination" section above for the pattern. - ## Commenting & Documentation (JSDoc-first) ### Default Rule