diff --git a/AGENTS.md b/AGENTS.md index 74acde8d..48ec3ae5 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 986ba5fb..f5ff4605 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -635,6 +635,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 (<=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)` + +#### `sentry span view ` + +View details of specific spans + +**Flags:** +- `--spans - Span tree depth limit (number, "all" for unlimited, "no" to disable) - (default: "3")` +- `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` +- `--json - Output as JSON` +- `--fields - Comma-separated fields to include in JSON output (dot.notation supported)` + ### Trace View distributed traces @@ -806,6 +833,23 @@ 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 (<=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)` + ### Traces List recent traces in a project diff --git a/src/app.ts b/src/app.ts index 231367d6..431c14c9 100644 --- a/src/app.ts +++ b/src/app.ts @@ -24,6 +24,8 @@ import { projectRoute } from "./commands/project/index.js"; import { listCommand as projectListCommand } from "./commands/project/list.js"; import { repoRoute } from "./commands/repo/index.js"; import { listCommand as repoListCommand } from "./commands/repo/list.js"; +import { spanRoute } from "./commands/span/index.js"; +import { listCommand as spanListCommand } from "./commands/span/list.js"; import { teamRoute } from "./commands/team/index.js"; import { listCommand as teamListCommand } from "./commands/team/list.js"; import { traceRoute } from "./commands/trace/index.js"; @@ -50,6 +52,7 @@ const PLURAL_TO_SINGULAR: Record = { repos: "repo", teams: "team", logs: "log", + spans: "span", traces: "trace", trials: "trial", }; @@ -67,6 +70,7 @@ export const routes = buildRouteMap({ issue: issueRoute, event: eventRoute, log: logRoute, + span: spanRoute, trace: traceRoute, trial: trialRoute, init: initCommand, @@ -77,6 +81,7 @@ export const routes = buildRouteMap({ repos: repoListCommand, teams: teamListCommand, logs: logListCommand, + spans: spanListCommand, traces: traceListCommand, trials: trialListCommand, whoami: whoamiCommand, diff --git a/src/commands/span/index.ts b/src/commands/span/index.ts new file mode 100644 index 00000000..a0a30b2e --- /dev/null +++ b/src/commands/span/index.ts @@ -0,0 +1,24 @@ +/** + * sentry span + * + * View and explore individual spans within distributed traces. + */ + +import { buildRouteMap } from "@stricli/core"; +import { listCommand } from "./list.js"; +import { viewCommand } from "./view.js"; + +export const spanRoute = buildRouteMap({ + routes: { + list: listCommand, + view: viewCommand, + }, + docs: { + brief: "View spans in distributed traces", + fullDescription: + "View and explore individual spans within distributed traces.\n\n" + + "Commands:\n" + + " list List spans in a trace\n" + + " view View details of specific spans", + }, +}); diff --git a/src/commands/span/list.ts b/src/commands/span/list.ts new file mode 100644 index 00000000..b734b43b --- /dev/null +++ b/src/commands/span/list.ts @@ -0,0 +1,381 @@ +/** + * sentry span list + * + * List spans in a distributed trace with optional filtering and sorting. + */ + +import type { SentryContext } from "../../context.js"; +import type { SpanSortValue } from "../../lib/api/traces.js"; +import { listSpans } from "../../lib/api-client.js"; +import { + parseOrgProjectArg, + parseSlashSeparatedArg, + 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, + formatSpanTable, + spanListItemToFlatSpan, + translateSpanQuery, +} 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, + FRESH_FLAG, + LIST_CURSOR_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: SpanSortValue; + readonly cursor?: string; + readonly json: boolean; + readonly fresh: boolean; + readonly fields?: string[]; +}; + +/** Accepted values for the --sort flag (matches trace list) */ +const VALID_SORT_VALUES: SpanSortValue[] = ["date", "duration"]; + +/** + * CLI-side upper bound for --limit. + * + * 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; + +/** Default number of spans to show */ +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 [//]"; + +/** + * Parse positional arguments for span list. + * 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 + * @throws {ContextError} If no arguments provided + * @throws {ValidationError} If the trace ID format is invalid + */ +export function parsePositionalArgs(args: string[]): { + traceId: string; + targetArg: string | undefined; +} { + if (args.length === 0) { + throw new ContextError("Trace ID", USAGE_HINT); + } + + const first = args[0]; + if (first === undefined) { + throw new ContextError("Trace ID", USAGE_HINT); + } + + if (args.length === 1) { + const { id, targetArg } = parseSlashSeparatedArg( + first, + "Trace ID", + USAGE_HINT + ); + return { traceId: validateTraceId(id), targetArg }; + } + + const second = args[1]; + if (second === undefined) { + return { traceId: validateTraceId(first), targetArg: undefined }; + } + + // Two or more args — first is target, second is trace ID + return { traceId: validateTraceId(second), targetArg: first }; +} + +/** + * Parse --limit flag, delegating range validation to shared utility. + */ +function parseLimit(value: string): number { + return validateLimit(value, 1, MAX_LIMIT); +} + +/** + * Parse and validate sort flag value. + * + * @throws Error if value is not "date" or "duration" + */ +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 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 +// --------------------------------------------------------------------------- + +/** 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; + /** Opaque cursor for fetching the next page (null/undefined when no more) */ + nextCursor?: string | null; + /** 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, 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; + 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({ + 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" + + "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" + + ' 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, + jsonTransform: jsonTransformSpanList, + }, + parameters: { + positional: { + kind: "array", + parameter: { + placeholder: "org/project/trace-id", + brief: + "[//] - Target (optional) and trace ID (required)", + parse: String, + }, + }, + flags: { + limit: { + kind: "parsed", + parse: parseLimit, + brief: `Number of spans (<=${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 order: ${VALID_SORT_VALUES.join(", ")}`, + default: DEFAULT_SORT, + }, + cursor: LIST_CURSOR_FLAG, + fresh: FRESH_FLAG, + }, + aliases: { + ...FRESH_ALIASES, + n: "limit", + q: "query", + s: "sort", + c: "cursor", + }, + }, + async *func(this: SentryContext, flags: ListFlags, ...args: string[]) { + applyFreshFlag(flags); + const { cwd, setContext } = this; + const log = logger.withTag("span.list"); + + // Parse positional args + const { traceId, targetArg } = parsePositionalArgs(args); + const parsed = parseOrgProjectArg(targetArg); + if (parsed.type !== "auto-detect" && parsed.normalized) { + log.warn("Normalized slug (Sentry slugs use dashes, not underscores)"); + } + + // Resolve target + let target: { org: string; project: string } | null = null; + + switch (parsed.type) { + case "explicit": + target = { org: parsed.org, project: parsed.project }; + break; + + case "project-search": + target = await resolveProjectBySlug( + parsed.projectSlug, + USAGE_HINT, + `sentry span list /${parsed.projectSlug}/${traceId}` + ); + break; + + case "org-all": + throw new ContextError("Specific project", USAGE_HINT); + + case "auto-detect": + target = await resolveOrgAndProject({ cwd, usageHint: USAGE_HINT }); + break; + + default: { + const _exhaustiveCheck: never = parsed; + throw new ValidationError( + `Invalid target specification: ${_exhaustiveCheck}` + ); + } + } + + if (!target) { + throw new ContextError("Organization and project", USAGE_HINT); + } + + setContext([target.org], [target.project]); + + // Build server-side query + const queryParts = [`trace:${traceId}`]; + if (flags.query) { + queryParts.push(translateSpanQuery(flags.query)); + } + const apiQuery = queryParts.join(" "); + + // 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, + target.project, + { + 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; + + // Build hint footer + let hint: string | undefined; + 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} Next page: ${nextPageHint(target.org, target.project, traceId, flags)}` + : `${countText} Use 'sentry span view ${traceId} ' to view span details.`; + } + + yield new CommandOutput({ flatSpans, hasMore, nextCursor, traceId }); + return { hint }; + }, +}); diff --git a/src/commands/span/view.ts b/src/commands/span/view.ts new file mode 100644 index 00000000..819e4f30 --- /dev/null +++ b/src/commands/span/view.ts @@ -0,0 +1,345 @@ +/** + * sentry span view + * + * View detailed information about one or more spans within a trace. + */ + +import type { SentryContext } from "../../context.js"; +import { getDetailedTrace } from "../../lib/api-client.js"; +import { + parseOrgProjectArg, + parseSlashSeparatedArg, + spansFlag, +} from "../../lib/arg-parsing.js"; +import { buildCommand } from "../../lib/command.js"; +import { ContextError, ValidationError } from "../../lib/errors.js"; +import { + type FoundSpan, + findSpanById, + formatSimpleSpanTree, + formatSpanDetails, +} from "../../lib/formatters/index.js"; +import { filterFields } from "../../lib/formatters/json.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; +import { computeSpanDurationMs } from "../../lib/formatters/time-utils.js"; +import { validateSpanId } from "../../lib/hex-id.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 json: boolean; + readonly spans: number; + readonly fresh: boolean; + readonly fields?: string[]; +}; + +/** Usage hint for ContextError messages */ +const USAGE_HINT = + "sentry span view [//] [...]"; + +/** + * Parse positional arguments for span view. + * + * 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 + * + * @param args - Positional arguments from CLI + * @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("Trace ID and span ID", USAGE_HINT); + } + + const first = args[0]; + if (first === undefined) { + throw new ContextError("Trace ID and span ID", USAGE_HINT); + } + + // 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)); + + return { traceId, spanIds, targetArg }; +} + +/** + * 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, + 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}/${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}` + ); + } + } +} + +// --------------------------------------------------------------------------- +// 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[] { + return 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: computeSpanDurationMs(r.span), + 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, + })), + })); +} + +/** + * 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; +} + +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 # auto-detect\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" + + " sentry span view a1b2c3d4e5f67890\n" + + " sentry span view a1b2c3d4e5f67890 b2c3d4e5f6789012\n" + + " sentry span view sentry/my-project/ a1b2c3d4e5f67890", + }, + output: { + human: formatSpanViewHuman, + jsonTransform: jsonTransformSpanView, + }, + parameters: { + positional: { + kind: "array", + parameter: { + placeholder: "trace-id/span-id", + brief: + "[//] [...] - Trace ID and one or more span IDs", + parse: String, + }, + }, + flags: { + ...spansFlag, + fresh: FRESH_FLAG, + }, + aliases: { ...FRESH_ALIASES }, + }, + async *func(this: SentryContext, flags: ViewFlags, ...args: string[]) { + applyFreshFlag(flags); + const { cwd, setContext } = this; + const cmdLog = logger.withTag("span.view"); + + // 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, 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); + + yield new CommandOutput({ results, traceId, spansDepth: flags.spans }); + }, +}); diff --git a/src/commands/trace/view.ts b/src/commands/trace/view.ts index 7e87915d..5c97b76a 100644 --- a/src/commands/trace/view.ts +++ b/src/commands/trace/view.ts @@ -46,41 +46,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 @@ -90,7 +61,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; @@ -110,32 +81,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, }; } @@ -145,11 +105,9 @@ export function parsePositionalArgs(args: string[]): { : undefined; // Two or more args - first is target, second is trace ID - const validated = validateAndWarn(second); return { - traceId: validated.traceId, + traceId: validateTraceId(second), targetArg: first, - warning: validated.uuidWarning, suggestion, }; } diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 51da7f67..e71e39fb 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -89,6 +89,7 @@ export { } from "./api/teams.js"; export { getDetailedTrace, + listSpans, listTransactions, normalizeTraceSpan, } from "./api/traces.js"; diff --git a/src/lib/api/traces.ts b/src/lib/api/traces.ts index 0b8cc895..5d7bc213 100644 --- a/src/lib/api/traces.ts +++ b/src/lib/api/traces.ts @@ -1,10 +1,13 @@ /** - * Trace and Transaction API functions + * Trace, Transaction, and Span API functions * - * Functions for retrieving detailed traces and listing transactions. + * Functions for retrieving detailed traces, listing transactions, and listing spans. */ import { + type SpanListItem, + type SpansResponse, + SpansResponseSchema, type TraceSpan, type TransactionListItem, type TransactionsResponse, @@ -144,3 +147,76 @@ export async function listTransactions( const { nextCursor } = parseLinkHeader(headers.get("link") ?? null); return { data: response.data, nextCursor }; } + +// Span listing + +/** Fields to request from the spans API */ +const SPAN_FIELDS = [ + "id", + "parent_span", + "span.op", + "description", + "span.duration", + "timestamp", + "project", + "transaction", + "trace", +]; + +/** 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 */ + sort?: SpanSortValue; + /** Time period for spans (e.g., "7d", "24h") */ + statsPeriod?: string; + /** Pagination cursor to resume from a previous page */ + cursor?: string; +}; + +/** + * List spans using the EAP spans search endpoint. + * Uses the Explore/Events API with dataset=spans. + * + * @param orgSlug - Organization slug + * @param projectSlug - Project slug or numeric ID + * @param options - Query options (query, limit, sort, statsPeriod, cursor) + * @returns Paginated response with span items and optional next cursor + */ +export async function listSpans( + orgSlug: string, + projectSlug: string, + options: ListSpansOptions = {} +): Promise> { + const isNumericProject = isAllDigits(projectSlug); + const projectFilter = isNumericProject ? "" : `project:${projectSlug}`; + const fullQuery = [projectFilter, options.query].filter(Boolean).join(" "); + + const regionUrl = await resolveOrgRegion(orgSlug); + + const { data: response, headers } = await apiRequestToRegion( + regionUrl, + `/organizations/${orgSlug}/events/`, + { + params: { + dataset: "spans", + field: SPAN_FIELDS, + project: isNumericProject ? projectSlug : undefined, + query: fullQuery || undefined, + per_page: options.limit || 10, + statsPeriod: options.statsPeriod ?? "7d", + sort: options.sort === "duration" ? "-span.duration" : "-timestamp", + cursor: options.cursor, + }, + schema: SpansResponseSchema, + } + ); + + const { nextCursor } = parseLinkHeader(headers.get("link") ?? null); + return { data: response.data, nextCursor }; +} diff --git a/src/lib/arg-parsing.ts b/src/lib/arg-parsing.ts index 9bfb3d46..b9ee3209 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}`); diff --git a/src/lib/formatters/human.ts b/src/lib/formatters/human.ts index 30442411..bcb3856a 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, @@ -38,6 +39,7 @@ import { } from "./markdown.js"; import { sparkline } from "./sparkline.js"; import { type Column, writeTable } from "./table.js"; +import { computeSpanDurationMs, formatRelativeTime } from "./time-utils.js"; // Color tag maps @@ -198,43 +200,6 @@ 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; -} - // Issue Formatting /** Quantifier suffixes indexed by groups of 3 digits (K=10^3, M=10^6, …, E=10^18) */ @@ -1077,21 +1042,14 @@ 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. + * Apply muted styling only in TTY/colored mode. * - * @returns Duration in milliseconds, or undefined if not computable + * 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 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; +function plainSafeMuted(text: string): string { + return isPlainOutput() ? text : muted(text); } type FormatSpanOptions = { @@ -1115,14 +1073,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); @@ -1178,9 +1136,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; @@ -1200,7 +1158,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/index.ts b/src/lib/formatters/index.ts index 01258494..f487c9a3 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 00000000..0d68b259 --- /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 12b355d1..bcb31e7f 100644 --- a/src/lib/formatters/trace.ts +++ b/src/lib/formatters/trace.ts @@ -2,12 +2,18 @@ * 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 { formatRelativeTime } from "./human.js"; +import type { + SpanListItem, + TraceSpan, + TransactionListItem, +} from "../../types/index.js"; import { + colorTag, escapeMarkdownCell, + escapeMarkdownInline, isPlainOutput, mdKvTable, mdRow, @@ -16,7 +22,9 @@ import { renderMarkdown, stripColorTags, } 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. @@ -279,3 +287,246 @@ 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) +// --------------------------------------------------------------------------- + +/** 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; +}; + +/** 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?.toLowerCase() === spanId) { + return { span, depth, ancestors }; + } + for (const child of span.children ?? []) { + const found = search(child, depth + 1, [...ancestors, span]); + if (found) { + return found; + } + } + return null; + } + + for (const root of spans) { + const found = search(root, 0, []); + if (found) { + return found; + } + } + return null; +} + +/** Map of CLI shorthand keys to Sentry API span attribute names */ +const SPAN_KEY_ALIASES: Record = { + op: "span.op", + duration: "span.duration", +}; + +/** + * Translate CLI shorthand query keys to Sentry API span attribute names. + * Bare words pass through unchanged (server treats them as free-text search). + * + * @param query - Raw query string from --query flag + * @returns Translated query for the spans API + */ +export function translateSpanQuery(query: string): string { + const tokens = query.match(/(?:[^\s"]+|"[^"]*")+/g) ?? []; + return tokens + .map((token) => { + const colonIdx = token.indexOf(":"); + if (colonIdx === -1) { + return token; + } + let key = token.slice(0, colonIdx).toLowerCase(); + const rest = token.slice(colonIdx); + // 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(" "); +} + +/** + * Map a SpanListItem from the EAP spans endpoint to a FlatSpan for display. + * + * @param item - Span item from the spans search API + * @returns FlatSpan suitable for table display + */ +export function spanListItemToFlatSpan(item: SpanListItem): FlatSpan { + return { + span_id: item.id, + parent_span_id: item.parent_span ?? undefined, + op: item["span.op"] ?? undefined, + description: item.description ?? undefined, + duration_ms: item["span.duration"] ?? undefined, + start_timestamp: new Date(item.timestamp).getTime() / 1000, + project_slug: item.project, + transaction: item.transaction ?? undefined, + }; +} + +/** Column definitions for the flat span table */ +const SPAN_TABLE_COLUMNS: Column[] = [ + { + header: "Span ID", + value: (s) => `\`${s.span_id}\``, + minWidth: 18, + shrinkable: false, + }, + { + header: "Op", + value: (s) => escapeMarkdownCell(s.op || "—"), + minWidth: 6, + }, + { + header: "Description", + value: (s) => escapeMarkdownCell(s.description || "(no description)"), + truncate: true, + }, + { + header: "Duration", + value: (s) => + s.duration_ms !== undefined ? formatTraceDuration(s.duration_ms) : "—", + align: "right", + minWidth: 8, + shrinkable: false, + }, +]; + +/** + * 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 }); +} + +/** + * 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. + * + * Uses `colorTag()` + `renderMarkdown()` so output respects `NO_COLOR` + * and `isPlainOutput()` instead of leaking raw ANSI escapes. + */ +function formatAncestorChain(ancestors: TraceSpan[]): string { + const lines: string[] = ["", colorTag("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}${colorTag("muted", aOp)} — ${escapeMarkdownInline(aDesc)} ${colorTag("muted", `(${a.span_id})`)}` + ); + } + return `${renderMarkdown(lines.join("\n"))}\n`; +} + +/** + * Format a single span's details for human-readable output. + * + * @param span - The TraceSpan to format + * @param ancestors - Ancestor chain from root to parent + * @param traceId - The trace ID for context + * @returns Rendered terminal string + */ +export function formatSpanDetails( + span: TraceSpan, + ancestors: TraceSpan[], + traceId: string +): string { + const kvRows = buildSpanKvRows(span, traceId); + const md = `## Span \`${span.span_id}\`\n\n${mdKvTable(kvRows)}\n`; + let output = renderMarkdown(md); + + if (ancestors.length > 0) { + output += formatAncestorChain(ancestors); + } + + return output; +} diff --git a/src/lib/hex-id.ts b/src/lib/hex-id.ts index 0e21bc40..4d6d5fed 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/src/types/index.ts b/src/types/index.ts index 6db7ecf8..de47b5de 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -73,6 +73,8 @@ export type { SentryRepository, SentryTeam, SentryUser, + SpanListItem, + SpansResponse, StackFrame, Stacktrace, TraceContext, @@ -98,6 +100,8 @@ export { SentryRepositorySchema, SentryTeamSchema, SentryUserSchema, + SpanListItemSchema, + SpansResponseSchema, TraceLogSchema, TraceLogsResponseSchema, TransactionListItemSchema, diff --git a/src/types/sentry.ts b/src/types/sentry.ts index 2e1f36e8..b9bb0432 100644 --- a/src/types/sentry.ts +++ b/src/types/sentry.ts @@ -667,6 +667,36 @@ export const TransactionsResponseSchema = z.object({ export type TransactionsResponse = z.infer; +/** A single span item from the EAP spans search endpoint */ +export const SpanListItemSchema = z + .object({ + id: z.string(), + parent_span: z.string().nullable().optional(), + "span.op": z.string().nullable().optional(), + description: z.string().nullable().optional(), + "span.duration": z.number().nullable().optional(), + timestamp: z.string(), + project: z.string(), + transaction: z.string().nullable().optional(), + trace: z.string(), + }) + .passthrough(); + +export type SpanListItem = z.infer; + +/** Response from the spans events endpoint */ +export const SpansResponseSchema = z.object({ + data: z.array(SpanListItemSchema), + meta: z + .object({ + fields: z.record(z.string()).optional(), + }) + .passthrough() + .optional(), +}); + +export type SpansResponse = z.infer; + // Repository /** Repository provider (e.g., GitHub, GitLab) */ diff --git a/test/commands/span/list.test.ts b/test/commands/span/list.test.ts new file mode 100644 index 00000000..c2875fa3 --- /dev/null +++ b/test/commands/span/list.test.ts @@ -0,0 +1,360 @@ +/** + * 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 '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"); + }); + + 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: "date", + 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: "date", + 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: "date", + 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(); + }); + + 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"); + }); +}); diff --git a/test/commands/span/view.test.ts b/test/commands/span/view.test.ts new file mode 100644 index 00000000..699c1956 --- /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, + 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"; + +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/commands/trace/view.property.test.ts b/test/commands/trace/view.property.test.ts index b433d33d..b6fa2622 100644 --- a/test/commands/trace/view.property.test.ts +++ b/test/commands/trace/view.property.test.ts @@ -153,7 +153,6 @@ describe("parsePositionalArgs properties", () => { const uuid = toUuidFormat(hex); const result = parsePositionalArgs([uuid]); expect(result.traceId).toBe(hex); - expect(result.warning).toContain("Auto-corrected"); }), { numRuns: DEFAULT_NUM_RUNS } ); @@ -166,7 +165,6 @@ describe("parsePositionalArgs properties", () => { const result = parsePositionalArgs([target, uuid]); expect(result.traceId).toBe(hex); expect(result.targetArg).toBe(target); - expect(result.warning).toContain("Auto-corrected"); }), { numRuns: DEFAULT_NUM_RUNS } ); diff --git a/test/commands/trace/view.test.ts b/test/commands/trace/view.test.ts index 1f7ca22c..0daad540 100644 --- a/test/commands/trace/view.test.ts +++ b/test/commands/trace/view.test.ts @@ -141,30 +141,16 @@ describe("parsePositionalArgs", () => { expect(result.targetArg).toBeUndefined(); }); - test("returns warning when UUID dashes are stripped", () => { - const result = parsePositionalArgs([VALID_UUID]); - expect(result.warning).toBeDefined(); - expect(result.warning).toContain("Auto-corrected"); - expect(result.warning).toContain(VALID_UUID_STRIPPED); - }); - - test("no warning for plain 32-char hex", () => { - const result = parsePositionalArgs([VALID_TRACE_ID]); - expect(result.warning).toBeUndefined(); - }); - test("strips dashes from UUID trace ID (two-arg case)", () => { const result = parsePositionalArgs(["my-org/frontend", VALID_UUID]); expect(result.traceId).toBe(VALID_UUID_STRIPPED); expect(result.targetArg).toBe("my-org/frontend"); - expect(result.warning).toContain("Auto-corrected"); }); test("strips dashes from UUID in slash-separated form", () => { const result = parsePositionalArgs([`sentry/cli/${VALID_UUID}`]); expect(result.traceId).toBe(VALID_UUID_STRIPPED); expect(result.targetArg).toBe("sentry/cli"); - expect(result.warning).toContain("Auto-corrected"); }); test("handles real user input from CLI-7Z", () => { diff --git a/test/lib/formatters/human.utils.test.ts b/test/lib/formatters/human.utils.test.ts index 08006dd4..c0a92fe5 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 b4116ea6..1150ebf2 100644 --- a/test/lib/formatters/trace.test.ts +++ b/test/lib/formatters/trace.test.ts @@ -1,6 +1,9 @@ /** * Unit Tests for Trace Formatters * + * Tests for formatTraceDuration, formatTraceTable, formatTracesHeader, formatTraceRow, + * computeTraceSummary, formatTraceSummary, and translateSpanQuery. + * * Note: Core invariants (duration formatting, trace ID containment, row newline * termination, determinism, span counting) are tested via property-based tests * in trace.property.test.ts. These tests focus on specific format output values, @@ -8,15 +11,20 @@ */ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { computeSpanDurationMs } from "../../../src/lib/formatters/time-utils.js"; import { 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"; @@ -111,11 +119,8 @@ describe("formatTraceDuration", () => { }); test("handles seconds rollover (never produces '60s')", () => { - // 119500ms = 1m 59.5s, rounds to 2m 0s (not 1m 60s) expect(formatTraceDuration(119_500)).toBe("2m 0s"); - // 179500ms = 2m 59.5s, rounds to 3m 0s (not 2m 60s) expect(formatTraceDuration(179_500)).toBe("3m 0s"); - // 59500ms is < 60000 so uses seconds format expect(formatTraceDuration(59_500)).toBe("59.50s"); }); @@ -179,7 +184,6 @@ describe("formatTracesHeader (plain mode)", () => { test("emits markdown table header and separator", () => { const result = formatTracesHeader(); expect(result).toContain("| Trace ID | Transaction | Duration | When |"); - // Duration column is right-aligned (`:` suffix in TRACE_TABLE_COLS) expect(result).toContain("| --- | --- | ---: | --- |"); }); @@ -233,7 +237,6 @@ describe("computeTraceSummary", () => { makeSpan({ start_timestamp: 1000.0, timestamp: 1002.5 }), ]; const summary = computeTraceSummary("trace-id", spans); - // (1002.5 - 1000.0) * 1000 = 2500ms expect(summary.duration).toBe(2500); }); @@ -243,7 +246,6 @@ describe("computeTraceSummary", () => { makeSpan({ start_timestamp: 999.5, timestamp: 1003.0 }), ]; const summary = computeTraceSummary("trace-id", spans); - // (1003.0 - 999.5) * 1000 = 3500ms expect(summary.duration).toBe(3500); }); @@ -306,12 +308,10 @@ describe("computeTraceSummary", () => { makeSpan({ start_timestamp: 1000.0, timestamp: 1002.0 }), ]; const summary = computeTraceSummary("trace-id", spans); - // Only the valid span should contribute: (1002.0 - 1000.0) * 1000 = 2000ms expect(summary.duration).toBe(2000); }); test("falls back to timestamp when end_timestamp is 0", () => { - // end_timestamp: 0 should be treated as missing, falling back to timestamp const spans: TraceSpan[] = [ makeSpan({ start_timestamp: 1000.0, @@ -320,8 +320,6 @@ describe("computeTraceSummary", () => { }), ]; const summary = computeTraceSummary("trace-id", spans); - // Should use timestamp (1002.5), not end_timestamp (0) - // Duration: (1002.5 - 1000.0) * 1000 = 2500ms expect(summary.duration).toBe(2500); }); }); @@ -436,3 +434,201 @@ 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"' + ); + }); + + 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"); + }); +}); + +// --------------------------------------------------------------------------- +// 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(); + }); +}); diff --git a/test/lib/sentry-url-parser.property.test.ts b/test/lib/sentry-url-parser.property.test.ts index 1885e28d..3af7e815 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}$/);