diff --git a/AGENTS.md b/AGENTS.md index 584d060c6..b94c0e0ba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -781,4 +781,69 @@ mock.module("./some-module", () => ({ | Add documentation | `docs/src/content/docs/` | +## Long-term Knowledge + +### Architecture + + +* **API client wraps all errors as CliError subclasses — no raw exceptions escape**: The API client (src/lib/api-client.ts) wraps ALL errors as CliError subclasses (ApiError or AuthError) — no raw exceptions escape. Commands don't need try-catch for error display; the central handler in app.ts formats CliError cleanly. Only add try-catch when a command needs to handle errors specially (e.g., login continuing despite user-info fetch failure). + + +* **Sentry API: events require org+project, issues have legacy global endpoint**: Sentry API scoping: Events require org+project in URL path (\`/projects/{org}/{project}/events/{id}/\`). Issues use legacy global endpoint (\`/api/0/issues/{id}/\`) without org context. Traces need only org (\`/organizations/{org}/trace/{traceId}/\`). Two-step lookup for events: fetch issue → extract org/project from response → fetch event. Cross-project event search possible via Discover endpoint \`/organizations/{org}/events/\` with \`query=id:{eventId}\`. + + +* **Sentry CLI authenticated fetch architecture with response caching**: \`createAuthenticatedFetch()\` wraps fetch with auth, 30s timeout, retry (max 2), 401 refresh, and span tracing. Response caching integrates BEFORE auth/retry via \`http-cache-semantics\` (RFC 7234) with filesystem storage at \`~/.sentry/cache/responses/\`. URL-based fallback TTL tiers: immutable (24hr), stable (5min), volatile (60s), no-cache (0). Only GET 2xx cached. \`--fresh\` and \`SENTRY\_NO\_CACHE=1\` bypass cache. Cache cleared on login/logout. \`hasServerCacheDirectives(policy)\` distinguishes \`max-age=0\` from missing headers. + + +* **Sentry CLI has two distribution channels with different runtimes**: Sentry CLI ships two ways: (1) Standalone binary via \`Bun.build()\` with \`compile: true\`. (2) npm package via esbuild producing CJS \`dist/bin.cjs\` for Node 22+, with Bun API polyfills from \`script/node-polyfills.ts\`. \`Bun.$\` has NO polyfill — use \`execSync\` instead. \`require()\` in ESM is safe (Bun native, esbuild resolves at bundle time). + + +* **Sentry CLI resolve-target cascade has 5 priority levels with env var support**: Resolve-target cascade (src/lib/resolve-target.ts) has 5 priority levels: (1) Explicit CLI flags, (2) SENTRY\_ORG/SENTRY\_PROJECT env vars, (3) SQLite config defaults, (4) DSN auto-detection, (5) Directory name inference. SENTRY\_PROJECT supports combo notation \`org/project\` — when used, SENTRY\_ORG is ignored. If combo parse fails (e.g. \`org/\`), the entire value is discarded. The \`resolveFromEnvVars()\` helper is injected into all four resolution functions. + +### Decision + + +* **Issue list global limit with fair per-project distribution and representation guarantees**: \`issue list --limit\` is a global total across all detected projects. \`fetchWithBudget\` Phase 1 divides evenly, Phase 2 redistributes surplus via cursor resume. \`trimWithProjectGuarantee\` ensures at least 1 issue per project before filling remaining slots. JSON output wraps in \`{ data, hasMore }\` with optional \`errors\` array. Compound cursor (pipe-separated) enables \`-c last\` for multi-target pagination, keyed by sorted target fingerprint. + + +* **Sentry CLI config dir should stay at ~/.sentry/, not move to XDG**: Config dir stays at \`~/.sentry/\` (not XDG). The readonly DB errors on macOS are from \`sudo brew install\` creating root-owned files. Fixes: (1) bestEffort() makes setup steps non-fatal, (2) tryRepairReadonly() detects root-owned files and prints \`sudo chown\` instructions, (3) \`sentry cli fix\` handles ownership repair. Ownership must be checked BEFORE permissions — root-owned files cause chmod to EPERM. + +### Gotcha + + +* **brew is not in VALID\_METHODS but Homebrew formula passes --method brew**: Homebrew install: \`isHomebrewInstall()\` detects via Cellar realpath (checked before stored install info). Upgrade command tells users \`brew upgrade getsentry/tools/sentry\`. Formula runs \`sentry cli setup --method brew --no-modify-path\` as post\_install. Version pinning throws 'unsupported\_operation'. Uses .gz artifacts. Tap at getsentry/tools. + + +* **Bun mock.module() leaks globally across test files in same process**: Bun's mock.module() replaces modules globally and leaks across test files in the same process. Solution: tests using mock.module() must run in a separate \`bun test\` invocation. In package.json, use \`bun run test:unit && bun run test:isolated\` instead of \`bun test\`. The \`test/isolated/\` directory exists for these tests. This was the root cause of ~100 test failures (getsentry/cli#258). + + +* **Making clearAuth() async breaks model-based tests — use non-async Promise\ return instead**: Making \`clearAuth()\` \`async\` breaks fast-check model-based tests — real async yields (macrotasks) during \`asyncModelRun\` cause \`createIsolatedDbContext\` cleanup to interleave. Fix: keep non-async, return \`clearResponseCache().catch(...)\` directly. Model-based tests should NOT await it. Also: model-based tests need explicit timeouts (e.g., \`30\_000\`) — Bun's default 5s causes false failures during shrinking. + + +* **Multiregion mock must include all control silo API routes**: When changing which Sentry API endpoint a function uses, mock routes must be updated in BOTH \`test/mocks/routes.ts\` (single-region) AND \`test/mocks/multiregion.ts\` \`createControlSiloRoutes()\`. Missing the multiregion mock causes 404s in multi-region test scenarios. + + +* **Sentry /users/me/ endpoint returns 403 for OAuth tokens — use /auth/ instead**: The Sentry \`/users/me/\` endpoint returns 403 for OAuth tokens. Use \`/auth/\` instead — it works with ALL token types and lives on the control silo. In the CLI, \`getControlSiloUrl()\` handles routing correctly. \`SentryUserSchema\` (with \`.passthrough()\`) handles the \`/auth/\` response since it only requires \`id\`. + + +* **Stricli command context uses this.stdout not this.process.stdout**: In Stricli command \`func()\` handlers, use \`this.stdout\` and \`this.stderr\` directly — NOT \`this.process.stdout\`. The \`SentryContext\` interface has both \`process\` and \`stdout\`/\`stderr\` as separate top-level properties. Test mock contexts typically provide \`stdout\` but not a full \`process\` object, so \`this.process.stdout\` causes \`TypeError: undefined is not an object\` at runtime in tests even though TypeScript doesn't flag it. + + +* **Stricli defaultCommand blends default command flags into route completions**: When a Stricli route map has \`defaultCommand\` set, requesting completions for that route (e.g. \`\["issues", ""]\`) returns both the subcommand names AND the default command's flags/positional completions. This means completion tests that compare against \`extractCommandTree()\` subcommand lists will fail for groups with defaultCommand, since the actual completions include extra entries like \`--limit\`, \`--query\`, etc. Solution: track \`hasDefaultCommand\` in the command tree and skip strict subcommand-matching assertions for those groups. + +### Pattern + + +* **Extract logic from Stricli func() handlers into standalone functions for testability**: Stricli command \`func()\` handlers are hard to unit test because they require full command context setup. To boost coverage, extract flag validation and body-building logic into standalone exported functions (e.g., \`resolveBody()\` extracted from the \`api\` command's \`func()\`). This moved ~20 lines of mutual-exclusivity checks and flag routing from an untestable handler into a directly testable pure function. Property-based tests on the extracted function drove patch coverage from 78% to 97%. The general pattern: keep \`func()\` as a thin orchestrator that calls exported helpers. This also keeps biome complexity under the limit (max 15). + + +* **Non-essential DB cache writes should be guarded with try-catch**: Non-essential DB cache writes (e.g., \`setUserInfo()\` in whoami.ts and login.ts) must be wrapped in try-catch. If the DB is broken, the cache write shouldn't crash the command when its primary operation already succeeded. In login.ts specifically, \`getCurrentUser()\` failure after token save must not block authentication — wrap in try-catch, log warning to stderr, let login succeed. This differs from \`getUserRegions()\` failure which should \`clearAuth()\` and fail hard (indicates invalid token). + + +* **Stricli buildCommand output config injects json flag into func params**: When a Stricli command uses \`output: { json: true, human: formatFn }\`, the framework injects \`--json\` and \`--fields\` flags automatically. The \`func\` handler receives these as its first parameter. Type it explicitly (e.g., \`flags: { json?: boolean }\`) rather than \`\_flags: unknown\` to access the json flag for conditional behavior (e.g., skipping interactive output in JSON mode). The \`human\` formatter runs on the returned \`data\` for non-JSON output. Commands that produce interactive side effects (browser prompts, QR codes) should check \`flags.json\` and skip them when true. + +### Preference + + +* **PR workflow: address review comments, resolve threads, wait for CI**: User's PR workflow after creation: (1) Wait for CI checks to pass, (2) Check for unresolved review comments via \`gh api\` for PR review comments, (3) Fix issues in follow-up commits (not amends), (4) Reply to the comment thread explaining the fix, (5) Resolve the thread programmatically via \`gh api graphql\` with \`resolveReviewThread\` mutation, (6) Push and wait for CI again, (7) Final sweep for any remaining unresolved comments. Use \`git notes add\` to attach implementation plans to commits. Branch naming: \`fix/descriptive-slug\` or \`feat/descriptive-slug\`. diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 05e516270..4b9834384 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -234,9 +234,9 @@ View Sentry logs ### Span -View spans in distributed traces +List and view spans in projects or traces -- `sentry span list ` — List spans in a trace +- `sentry span list ` — List spans in a project or trace - `sentry span view ` — View details of specific spans → Full flags and examples: `references/traces.md` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/issues.md b/plugins/sentry-cli/skills/sentry-cli/references/issues.md index 0918b7ffb..7d4ffab76 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/issues.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/issues.md @@ -21,8 +21,8 @@ List issues in a project - `-s, --sort - Sort by: date, new, freq, user - (default: "date")` - `-t, --period - Time period for issue activity (e.g. 24h, 14d, 90d) - (default: "90d")` - `-c, --cursor - Pagination cursor for / or multi-target modes (use "last" to continue)` -- `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` - `--compact - Single-line rows for compact output (auto-detects if omitted)` +- `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` **Examples:** diff --git a/plugins/sentry-cli/skills/sentry-cli/references/projects.md b/plugins/sentry-cli/skills/sentry-cli/references/projects.md index 243a2de4b..2117b7f99 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/projects.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/projects.md @@ -34,9 +34,9 @@ List projects **Flags:** - `-n, --limit - Maximum number of projects to list - (default: "30")` -- `-c, --cursor - Pagination cursor (use "last" to continue from previous page)` - `-p, --platform - Filter by platform (e.g., javascript, python)` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` +- `-c, --cursor - Pagination cursor (use "last" to continue from previous page)` **Examples:** diff --git a/plugins/sentry-cli/skills/sentry-cli/references/teams.md b/plugins/sentry-cli/skills/sentry-cli/references/teams.md index ce529f982..0323991b2 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/teams.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/teams.md @@ -19,8 +19,8 @@ List repositories **Flags:** - `-n, --limit - Maximum number of repositories to list - (default: "30")` -- `-c, --cursor - Pagination cursor (use "last" to continue from previous page)` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` +- `-c, --cursor - Pagination cursor (use "last" to continue from previous page)` ### `sentry team list ` @@ -28,8 +28,8 @@ List teams **Flags:** - `-n, --limit - Maximum number of teams to list - (default: "30")` -- `-c, --cursor - Pagination cursor (use "last" to continue from previous page)` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` +- `-c, --cursor - Pagination cursor (use "last" to continue from previous page)` **Examples:** diff --git a/plugins/sentry-cli/skills/sentry-cli/references/traces.md b/plugins/sentry-cli/skills/sentry-cli/references/traces.md index a874946f4..1d7b4566e 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/traces.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/traces.md @@ -9,20 +9,21 @@ requires: # Trace & Span Commands -View spans in distributed traces +List and view spans in projects or traces View distributed traces ### `sentry span list ` -List spans in a trace +List spans in a project or 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)` +- `-t, --period - Time period (e.g., "1h", "24h", "7d", "30d") - (default: "7d")` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` +- `-c, --cursor - Pagination cursor (use "last" to continue from previous page)` ### `sentry span view ` @@ -40,8 +41,9 @@ List recent traces in a project - `-n, --limit - Number of traces (1-1000) - (default: "20")` - `-q, --query - Search query (Sentry search syntax)` - `-s, --sort - Sort by: date, duration - (default: "date")` -- `-c, --cursor - Pagination cursor (use "last" to continue from previous page)` +- `-t, --period - Time period (e.g., "1h", "24h", "7d", "30d") - (default: "7d")` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` +- `-c, --cursor - Pagination cursor (use "last" to continue from previous page)` ### `sentry trace view ` diff --git a/src/commands/issue/list.ts b/src/commands/issue/list.ts index b75a5c3c0..899cf9259 100644 --- a/src/commands/issue/list.ts +++ b/src/commands/issue/list.ts @@ -46,11 +46,8 @@ import { type OutputConfig, } from "../../lib/formatters/output.js"; import { - applyFreshFlag, buildListCommand, buildListLimitFlag, - FRESH_ALIASES, - FRESH_FLAG, LIST_BASE_ALIASES, LIST_TARGET_POSITIONAL, parseCursorFlag, @@ -1298,7 +1295,6 @@ export const listCommand = buildListCommand("issue", { 'Pagination cursor for / or multi-target modes (use "last" to continue)', optional: true, }, - fresh: FRESH_FLAG, compact: { kind: "boolean", brief: "Single-line rows for compact output (auto-detects if omitted)", @@ -1307,14 +1303,12 @@ export const listCommand = buildListCommand("issue", { }, aliases: { ...LIST_BASE_ALIASES, - ...FRESH_ALIASES, q: "query", s: "sort", t: "period", }, }, async *func(this: SentryContext, flags: ListFlags, target?: string) { - applyFreshFlag(flags); const { cwd, setContext } = this; const parsed = parseOrgProjectArg(target); diff --git a/src/commands/log/list.ts b/src/commands/log/list.ts index 274a70035..54d577901 100644 --- a/src/commands/log/list.ts +++ b/src/commands/log/list.ts @@ -28,18 +28,14 @@ import { } from "../../lib/formatters/output.js"; import type { StreamingTable } from "../../lib/formatters/text-table.js"; import { - applyFreshFlag, buildListCommand, - FRESH_FLAG, TARGET_PATTERN_NOTE, } from "../../lib/list-command.js"; import { logger } from "../../lib/logger.js"; import { withProgress } from "../../lib/polling.js"; import { resolveOrgProjectFromArg } from "../../lib/resolve-target.js"; -import { isTraceId } from "../../lib/trace-id.js"; import { - type ParsedTraceTarget, - parseTraceTarget, + parseDualModeArgs, resolveTraceOrg, warnIfNormalized, } from "../../lib/trace-target.js"; @@ -146,69 +142,16 @@ type FetchResult = { // Positional argument disambiguation // --------------------------------------------------------------------------- -/** - * Parsed result from log list positional arguments. - * - * Discriminated on `mode`: - * - `"project"` — standard project-scoped log listing (existing path) - * - `"trace"` — trace-filtered log listing via trace-logs endpoint - */ -type ParsedLogArgs = - | { mode: "project"; target?: string } - | { mode: "trace"; parsed: ParsedTraceTarget }; - /** * Disambiguate log list positional arguments. * - * Detects trace mode by checking whether any argument segment looks like - * a 32-char hex trace ID: - * - * - **Single arg**: checks the tail segment (last part after `/`, or the - * entire arg). ``, `/`, `//`. - * - **Two+ args**: checks the last positional (` ` or - * `/ ` space-separated forms). - * - **No match**: treats the argument as a project target. - * - * When trace mode is detected, delegates to {@link parseTraceTarget} for - * full parsing and validation. - * - * @param args - Positional arguments from CLI - * @returns Parsed args with mode discrimination + * Thin wrapper around {@link parseDualModeArgs} that binds the + * trace-mode usage hint for log list. */ -function parseLogListArgs(args: string[]): ParsedLogArgs { - if (args.length === 0) { - return { mode: "project" }; - } - - const first = args[0]; - if (first === undefined) { - return { mode: "project" }; - } - - // Two+ args: check if the last arg is a trace ID (space-separated form) - // e.g., `sentry log list my-org abc123...` or `sentry log list my-org/proj abc123...` - if (args.length >= 2) { - const last = args.at(-1); - if (last && isTraceId(last)) { - return { - mode: "trace", - parsed: parseTraceTarget(args, TRACE_USAGE_HINT), - }; - } - } - - // Single arg: check the tail segment (last part after `/`, or the entire arg) - const lastSlash = first.lastIndexOf("/"); - const tail = lastSlash === -1 ? first : first.slice(lastSlash + 1); - - if (isTraceId(tail)) { - return { - mode: "trace", - parsed: parseTraceTarget(args, TRACE_USAGE_HINT), - }; - } - - return { mode: "project", target: first }; +function parseLogListArgs( + args: string[] +): ReturnType { + return parseDualModeArgs(args, TRACE_USAGE_HINT); } /** Default time period for project-scoped log queries */ @@ -628,194 +571,200 @@ function jsonTransformLogOutput(data: LogOutput, fields?: string[]): unknown { return fields && fields.length > 0 ? filterFields(data, fields) : data; } -export const listCommand = buildListCommand("log", { - docs: { - brief: "List logs from a project", - fullDescription: - "List and stream logs from Sentry projects.\n\n" + - "Target patterns:\n" + - " sentry log list # auto-detect from DSN or config\n" + - " sentry log list / # explicit org and project\n" + - " sentry log list # find project across all orgs\n\n" + - `${TARGET_PATTERN_NOTE}\n\n` + - "Trace filtering:\n" + - " sentry log list # Filter by trace (auto-detect org)\n" + - " sentry log list / # Filter by trace (explicit org)\n\n" + - "Examples:\n" + - " sentry log list # List last 100 logs\n" + - " sentry log list -f # Stream logs (2s poll interval)\n" + - " sentry log list -f 5 # Stream logs (5s poll interval)\n" + - " sentry log list --limit 50 # Show last 50 logs\n" + - " sentry log list -q 'level:error' # Filter to errors only\n" + - " sentry log list abc123def456abc123def456abc123de # Filter by trace\n\n" + - "Alias: `sentry logs` → `sentry log list`", - }, - output: { - human: createLogRenderer, - jsonTransform: jsonTransformLogOutput, - }, - parameters: { - positional: { - kind: "array", - parameter: { - placeholder: "org/project-or-trace-id", - brief: "[/[/]], /, or ", - parse: String, - }, +export const listCommand = buildListCommand( + "log", + { + docs: { + brief: "List logs from a project", + fullDescription: + "List and stream logs from Sentry projects.\n\n" + + "Target patterns:\n" + + " sentry log list # auto-detect from DSN or config\n" + + " sentry log list / # explicit org and project\n" + + " sentry log list # find project across all orgs\n\n" + + `${TARGET_PATTERN_NOTE}\n\n` + + "Trace filtering:\n" + + " sentry log list # Filter by trace (auto-detect org)\n" + + " sentry log list / # Filter by trace (explicit org)\n\n" + + "Examples:\n" + + " sentry log list # List last 100 logs\n" + + " sentry log list -f # Stream logs (2s poll interval)\n" + + " sentry log list -f 5 # Stream logs (5s poll interval)\n" + + " sentry log list --limit 50 # Show last 50 logs\n" + + " sentry log list -q 'level:error' # Filter to errors only\n" + + " sentry log list abc123def456abc123def456abc123de # Filter by trace\n\n" + + "Alias: `sentry logs` → `sentry log list`", }, - flags: { - limit: { - kind: "parsed", - parse: parseLimit, - brief: `Number of log entries (${MIN_LIMIT}-${MAX_LIMIT})`, - default: String(DEFAULT_LIMIT), - }, - query: { - kind: "parsed", - parse: String, - brief: "Filter query (Sentry search syntax)", - optional: true, + output: { + human: createLogRenderer, + jsonTransform: jsonTransformLogOutput, + }, + parameters: { + positional: { + kind: "array", + parameter: { + placeholder: "org/project-or-trace-id", + brief: + "[/[/]], /, or ", + parse: String, + }, }, - follow: { - kind: "parsed", - parse: parseFollow, - brief: "Stream logs (optionally specify poll interval in seconds)", - optional: true, - inferEmpty: true, + flags: { + limit: { + kind: "parsed", + parse: parseLimit, + brief: `Number of log entries (${MIN_LIMIT}-${MAX_LIMIT})`, + default: String(DEFAULT_LIMIT), + }, + query: { + kind: "parsed", + parse: String, + brief: "Filter query (Sentry search syntax)", + optional: true, + }, + follow: { + kind: "parsed", + parse: parseFollow, + brief: "Stream logs (optionally specify poll interval in seconds)", + optional: true, + inferEmpty: true, + }, + period: { + kind: "parsed", + parse: String, + brief: + 'Time period (e.g., "90d", "14d", "24h"). Default: 90d (project mode), 14d (trace mode)', + optional: true, + }, }, - period: { - kind: "parsed", - parse: String, - brief: - 'Time period (e.g., "90d", "14d", "24h"). Default: 90d (project mode), 14d (trace mode)', - optional: true, + aliases: { + n: "limit", + q: "query", + f: "follow", + t: "period", }, - fresh: FRESH_FLAG, - }, - aliases: { - n: "limit", - q: "query", - f: "follow", - t: "period", }, - }, - async *func(this: SentryContext, flags: ListFlags, ...args: string[]) { - applyFreshFlag(flags); - const { cwd, setContext } = this; - - const parsed = parseLogListArgs(args); - - if (parsed.mode === "trace") { - // Trace mode: use the org-scoped trace-logs endpoint. - warnIfNormalized(parsed.parsed, "log.list"); - const { traceId, org } = await resolveTraceOrg( - parsed.parsed, - cwd, - TRACE_USAGE_HINT - ); - setContext([org], []); - - if (flags.follow) { - // Banner (suppressed in JSON mode) - writeFollowBanner( - flags.follow ?? DEFAULT_POLL_INTERVAL, - `Streaming logs for trace ${traceId}...`, - flags.json + async *func(this: SentryContext, flags: ListFlags, ...args: string[]) { + const { cwd, setContext } = this; + + const parsed = parseLogListArgs(args); + + if (parsed.mode === "trace") { + // Trace mode: use the org-scoped trace-logs endpoint. + warnIfNormalized(parsed.parsed, "log.list"); + const { traceId, org } = await resolveTraceOrg( + parsed.parsed, + cwd, + TRACE_USAGE_HINT ); - - // Track IDs of logs seen without timestamp_precise so they are - // shown once but not duplicated on subsequent polls. - const seenWithoutTs = new Set(); - const generator = generateFollowLogs({ - flags, - onDiagnostic: (msg) => logger.warn(msg), - fetch: (statsPeriod) => - listTraceLogs(org, traceId, { - query: flags.query, - limit: flags.limit, - statsPeriod, - }), - extractNew: (logs, lastTs) => - logs.filter((l) => { - if (l.timestamp_precise !== undefined) { - return l.timestamp_precise > lastTs; - } - // No precise timestamp — deduplicate by id - if (!l.id) { - return true; // Can't dedup without id, include it - } - if (seenWithoutTs.has(l.id)) { - return false; - } - seenWithoutTs.add(l.id); - return true; - }), - onInitialLogs: (logs) => { - for (const l of logs) { - if (l.timestamp_precise === undefined && l.id) { + setContext([org], []); + + if (flags.follow) { + // Banner (suppressed in JSON mode) + writeFollowBanner( + flags.follow ?? DEFAULT_POLL_INTERVAL, + `Streaming logs for trace ${traceId}...`, + flags.json + ); + + // Track IDs of logs seen without timestamp_precise so they are + // shown once but not duplicated on subsequent polls. + const seenWithoutTs = new Set(); + const generator = generateFollowLogs({ + flags, + onDiagnostic: (msg) => logger.warn(msg), + fetch: (statsPeriod) => + listTraceLogs(org, traceId, { + query: flags.query, + limit: flags.limit, + statsPeriod, + }), + extractNew: (logs, lastTs) => + logs.filter((l) => { + if (l.timestamp_precise !== undefined) { + return l.timestamp_precise > lastTs; + } + // No precise timestamp — deduplicate by id + if (!l.id) { + return true; // Can't dedup without id, include it + } + if (seenWithoutTs.has(l.id)) { + return false; + } seenWithoutTs.add(l.id); + return true; + }), + onInitialLogs: (logs) => { + for (const l of logs) { + if (l.timestamp_precise === undefined && l.id) { + seenWithoutTs.add(l.id); + } } - } - }, - }); - - yield* yieldTraceFollowItems(generator, traceId); - return; - } + }, + }); - const { result, hint } = await withProgress( - { - message: `Fetching logs (up to ${flags.limit})...`, - json: flags.json, - }, - () => executeTraceSingleFetch(org, traceId, flags) - ); - yield new CommandOutput(result); - return { hint }; - } + yield* yieldTraceFollowItems(generator, traceId); + return; + } - // Standard project-scoped mode - { - const { org, project } = await resolveOrgProjectFromArg( - parsed.target, - cwd, - COMMAND_NAME - ); - setContext([org], [project]); + const { result, hint } = await withProgress( + { + message: `Fetching logs (up to ${flags.limit})...`, + json: flags.json, + }, + () => executeTraceSingleFetch(org, traceId, flags) + ); + yield new CommandOutput(result); + return { hint }; + } - if (flags.follow) { - writeFollowBanner( - flags.follow ?? DEFAULT_POLL_INTERVAL, - "Streaming logs...", - flags.json + // Standard project-scoped mode + { + const { org, project } = await resolveOrgProjectFromArg( + parsed.target, + cwd, + COMMAND_NAME ); + setContext([org], [project]); + + if (flags.follow) { + writeFollowBanner( + flags.follow ?? DEFAULT_POLL_INTERVAL, + "Streaming logs...", + flags.json + ); + + const generator = generateFollowLogs({ + flags, + onDiagnostic: (msg) => logger.warn(msg), + fetch: (statsPeriod, afterTimestamp) => + listLogs(org, project, { + query: flags.query, + limit: flags.limit, + statsPeriod, + afterTimestamp, + }), + extractNew: (logs) => logs, + }); + + yield* yieldFollowItems(generator); + return; + } - const generator = generateFollowLogs({ - flags, - onDiagnostic: (msg) => logger.warn(msg), - fetch: (statsPeriod, afterTimestamp) => - listLogs(org, project, { - query: flags.query, - limit: flags.limit, - statsPeriod, - afterTimestamp, - }), - extractNew: (logs) => logs, - }); - - yield* yieldFollowItems(generator); - return; + const { result, hint } = await withProgress( + { + message: `Fetching logs (up to ${flags.limit})...`, + json: flags.json, + }, + () => executeSingleFetch(org, project, flags) + ); + yield new CommandOutput(result); + return { hint }; } - - const { result, hint } = await withProgress( - { - message: `Fetching logs (up to ${flags.limit})...`, - json: flags.json, - }, - () => executeSingleFetch(org, project, flags) - ); - yield new CommandOutput(result); - return { hint }; - } + }, }, -}); + { + noCursorFlag: true, + noFreshAlias: true, + } +); diff --git a/src/commands/project/list.ts b/src/commands/project/list.ts index 655bb92fc..daaff3fbd 100644 --- a/src/commands/project/list.ts +++ b/src/commands/project/list.ts @@ -38,13 +38,9 @@ import { } from "../../lib/formatters/output.js"; import { type Column, formatTable } from "../../lib/formatters/table.js"; import { - applyFreshFlag, buildListCommand, buildListLimitFlag, - FRESH_ALIASES, - FRESH_FLAG, LIST_BASE_ALIASES, - LIST_CURSOR_FLAG, LIST_TARGET_POSITIONAL, targetPatternExplanation, } from "../../lib/list-command.js"; @@ -601,19 +597,16 @@ export const listCommand = buildListCommand("project", { positional: LIST_TARGET_POSITIONAL, flags: { limit: buildListLimitFlag("projects"), - cursor: LIST_CURSOR_FLAG, platform: { kind: "parsed", parse: String, brief: "Filter by platform (e.g., javascript, python)", optional: true, }, - fresh: FRESH_FLAG, }, - aliases: { ...LIST_BASE_ALIASES, ...FRESH_ALIASES, p: "platform" }, + aliases: { ...LIST_BASE_ALIASES, p: "platform" }, }, async *func(this: SentryContext, flags: ListFlags, target?: string) { - applyFreshFlag(flags); const { cwd } = this; const parsed = parseOrgProjectArg(target); diff --git a/src/commands/span/index.ts b/src/commands/span/index.ts index 1d35aab72..2105faa7a 100644 --- a/src/commands/span/index.ts +++ b/src/commands/span/index.ts @@ -1,7 +1,7 @@ /** * sentry span * - * View and explore individual spans within distributed traces. + * List and explore individual spans within distributed traces or across projects. */ import { buildRouteMap } from "@stricli/core"; @@ -14,11 +14,11 @@ export const spanRoute = buildRouteMap({ view: viewCommand, }, docs: { - brief: "View spans in distributed traces", + brief: "List and view spans in projects or traces", fullDescription: - "View and explore individual spans within distributed traces.\n\n" + + "List and explore individual spans within distributed traces or across projects.\n\n" + "Commands:\n" + - " list List spans in a trace\n" + + " list List spans in a project or trace\n" + " view View details of specific spans\n\n" + "Alias: `sentry spans` → `sentry span list`", }, diff --git a/src/commands/span/list.ts b/src/commands/span/list.ts index 55e68b448..adfac40e9 100644 --- a/src/commands/span/list.ts +++ b/src/commands/span/list.ts @@ -1,14 +1,19 @@ /** * sentry span list * - * List spans in a distributed trace with optional filtering and sorting. + * List spans from a Sentry project, or within a specific trace. + * + * Dual-mode command (like `log list`): + * - **Project mode** (no trace ID): lists spans across the entire project + * - **Trace mode** (trace ID provided): lists spans within a specific trace + * + * Disambiguation uses {@link isTraceId} to detect 32-char hex trace IDs. */ import type { SentryContext } from "../../context.js"; import type { SpanSortValue } from "../../lib/api/traces.js"; import { listSpans } from "../../lib/api-client.js"; import { validateLimit } from "../../lib/arg-parsing.js"; -import { buildCommand } from "../../lib/command.js"; import { buildPaginationContextKey, clearPaginationCursor, @@ -25,14 +30,16 @@ 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, + buildListCommand, + LIST_PERIOD_FLAG, + PERIOD_ALIASES, + TARGET_PATTERN_NOTE, } from "../../lib/list-command.js"; import { withProgress } from "../../lib/polling.js"; +import { resolveOrgProjectFromArg } from "../../lib/resolve-target.js"; import { - parseTraceTarget, + type ParsedTraceTarget, + parseDualModeArgs, resolveTraceOrgProject, warnIfNormalized, } from "../../lib/trace-target.js"; @@ -41,6 +48,7 @@ type ListFlags = { readonly limit: number; readonly query?: string; readonly sort: SpanSortValue; + readonly period: string; readonly cursor?: string; readonly json: boolean; readonly fresh: boolean; @@ -64,11 +72,20 @@ const DEFAULT_LIMIT = 25; /** Default sort order for span results */ const DEFAULT_SORT: SpanSortValue = "date"; -/** Pagination storage key for cursor resume */ +/** Default time period for span queries */ +const DEFAULT_PERIOD = "7d"; + +/** Pagination storage key for trace-scoped span listing */ export const PAGINATION_KEY = "span-list"; -/** Usage hint for ContextError messages */ -const USAGE_HINT = "sentry span list [//]"; +/** Pagination storage key for project-scoped span listing */ +export const PROJECT_PAGINATION_KEY = "span-search"; + +/** Command name used in resolver error messages (project mode) */ +const COMMAND_NAME = "span list"; + +/** Usage hint for trace-mode ContextError messages */ +const TRACE_USAGE_HINT = "sentry span list [//]"; /** * Parse --limit flag, delegating range validation to shared utility. @@ -91,14 +108,27 @@ export function parseSort(value: string): SpanSortValue { return value as SpanSortValue; } -/** Build the CLI hint for fetching the next page, preserving active flags. */ -function nextPageHint( - org: string, - project: string, - traceId: string, - flags: Pick +/** + * Disambiguate span list positional arguments. + * + * Thin wrapper around {@link parseDualModeArgs} that binds the + * trace-mode usage hint for span list. + */ +export function parseSpanListArgs( + args: string[] +): ReturnType { + return parseDualModeArgs(args, TRACE_USAGE_HINT); +} + +// --------------------------------------------------------------------------- +// Next-page hints +// --------------------------------------------------------------------------- + +/** Append active non-default flags to a base next-page command. */ +function appendFlagHints( + base: 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}`); @@ -106,9 +136,34 @@ function nextPageHint( if (flags.query) { parts.push(`-q "${flags.query}"`); } + if (flags.period !== DEFAULT_PERIOD) { + parts.push(`--period ${flags.period}`); + } return parts.length > 0 ? `${base} ${parts.join(" ")}` : base; } +/** Build the CLI hint for fetching the next page in trace mode. */ +function traceNextPageHint( + org: string, + project: string, + traceId: string, + flags: Pick +): string { + return appendFlagHints( + `sentry span list ${org}/${project}/${traceId} -c last`, + flags + ); +} + +/** Build the CLI hint for fetching the next page in project mode. */ +function projectNextPageHint( + org: string, + project: string, + flags: Pick +): string { + return appendFlagHints(`sentry span list ${org}/${project} -c last`, flags); +} + // --------------------------------------------------------------------------- // Output config types and formatters // --------------------------------------------------------------------------- @@ -121,8 +176,12 @@ type SpanListData = { hasMore: boolean; /** Opaque cursor for fetching the next page (null/undefined when no more) */ nextCursor?: string | null; - /** The trace ID being queried */ - traceId: string; + /** The trace ID being queried (only in trace mode) */ + traceId?: string; + /** Org slug for project-mode header */ + org?: string; + /** Project slug for project-mode header */ + project?: string; }; /** @@ -133,10 +192,16 @@ type SpanListData = { */ function formatSpanListHuman(data: SpanListData): string { if (data.flatSpans.length === 0) { - return "No spans matched the query."; + return data.hasMore + ? "No spans on this page." + : "No spans matched the query."; } const parts: string[] = []; - parts.push(renderMarkdown(`Spans in trace \`${data.traceId}\`:\n`)); + if (data.traceId) { + parts.push(renderMarkdown(`Spans in trace \`${data.traceId}\`:\n`)); + } else { + parts.push(`Spans in ${data.org}/${data.project}:\n\n`); + } parts.push(formatSpanTable(data.flatSpans)); return parts.join("\n"); } @@ -166,24 +231,177 @@ function jsonTransformSpanList(data: SpanListData, fields?: string[]): unknown { return envelope; } -export const listCommand = buildCommand({ +// --------------------------------------------------------------------------- +// Mode handlers — extracted from func() to stay under biome complexity limit +// --------------------------------------------------------------------------- + +/** Shared context passed to mode handlers from the Stricli command function. */ +type ModeContext = { + cwd: string; + flags: ListFlags; + setContext: (orgs: string[], projects: string[]) => void; +}; + +/** + * Handle trace mode: list spans within a specific trace. + * + * Resolves the trace target, builds a query prefixed with `trace:{id}`, + * and returns the result with a trace-specific header and hints. + */ +async function handleTraceMode( + parsed: ParsedTraceTarget, + ctx: ModeContext +): Promise<{ output: SpanListData; hint?: string }> { + const { flags, cwd } = ctx; + warnIfNormalized(parsed, "span.list"); + const { traceId, org, project } = await resolveTraceOrgProject( + parsed, + cwd, + TRACE_USAGE_HINT + ); + ctx.setContext([org], [project]); + + const queryParts = [`trace:${traceId}`]; + if (flags.query) { + queryParts.push(translateSpanQuery(flags.query)); + } + const apiQuery = queryParts.join(" "); + + const contextKey = buildPaginationContextKey( + "span", + `${org}/${project}/${traceId}`, + { sort: flags.sort, q: flags.query, period: flags.period } + ); + const cursor = resolveOrgCursor(flags.cursor, PAGINATION_KEY, contextKey); + + const { data: spanItems, nextCursor } = await withProgress( + { message: `Fetching spans (up to ${flags.limit})...`, json: flags.json }, + () => + listSpans(org, project, { + query: apiQuery, + sort: flags.sort, + limit: flags.limit, + cursor, + statsPeriod: flags.period, + }) + ); + + if (nextCursor) { + setPaginationCursor(PAGINATION_KEY, contextKey, nextCursor); + } else { + clearPaginationCursor(PAGINATION_KEY, contextKey); + } + + const flatSpans = spanItems.map(spanListItemToFlatSpan); + const hasMore = !!nextCursor; + + let hint: string | undefined; + if (flatSpans.length === 0 && hasMore) { + hint = `Try the next page: ${traceNextPageHint(org, project, traceId, flags)}`; + } else if (flatSpans.length > 0) { + const countText = `Showing ${flatSpans.length} span${flatSpans.length === 1 ? "" : "s"}.`; + hint = hasMore + ? `${countText} Next page: ${traceNextPageHint(org, project, traceId, flags)}` + : `${countText} Use 'sentry span view ${traceId} ' to view span details.`; + } + + return { output: { flatSpans, hasMore, nextCursor, traceId }, hint }; +} + +/** + * Handle project mode: list spans across the entire project. + * + * Resolves the org/project target and queries the spans dataset + * without a trace ID filter. + */ +async function handleProjectMode( + target: string | undefined, + ctx: ModeContext +): Promise<{ output: SpanListData; hint?: string }> { + const { flags, cwd } = ctx; + const { org, project } = await resolveOrgProjectFromArg( + target, + cwd, + COMMAND_NAME + ); + ctx.setContext([org], [project]); + + const apiQuery = flags.query ? translateSpanQuery(flags.query) : undefined; + + const contextKey = buildPaginationContextKey( + "span-search", + `${org}/${project}`, + { sort: flags.sort, q: flags.query, period: flags.period } + ); + const cursor = resolveOrgCursor( + flags.cursor, + PROJECT_PAGINATION_KEY, + contextKey + ); + + const { data: spanItems, nextCursor } = await withProgress( + { message: `Fetching spans (up to ${flags.limit})...`, json: flags.json }, + () => + listSpans(org, project, { + query: apiQuery, + sort: flags.sort, + limit: flags.limit, + cursor, + statsPeriod: flags.period, + }) + ); + + if (nextCursor) { + setPaginationCursor(PROJECT_PAGINATION_KEY, contextKey, nextCursor); + } else { + clearPaginationCursor(PROJECT_PAGINATION_KEY, contextKey); + } + + const flatSpans = spanItems.map(spanListItemToFlatSpan); + const hasMore = !!nextCursor; + + let hint: string | undefined; + if (flatSpans.length === 0 && hasMore) { + hint = `Try the next page: ${projectNextPageHint(org, project, flags)}`; + } else if (flatSpans.length > 0) { + const countText = `Showing ${flatSpans.length} span${flatSpans.length === 1 ? "" : "s"}.`; + hint = hasMore + ? `${countText} Next page: ${projectNextPageHint(org, project, flags)}` + : `${countText} Use 'sentry span view ' to view span details.`; + } + + return { output: { flatSpans, hasMore, nextCursor, org, project }, hint }; +} + +// --------------------------------------------------------------------------- +// Command definition +// --------------------------------------------------------------------------- + +export const listCommand = buildListCommand("span", { docs: { - brief: "List spans in a trace", + brief: "List spans in a project or 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" + + "List spans from a Sentry project, or within a specific trace.\n\n" + + "Project mode (no trace ID):\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" + + `${TARGET_PATTERN_NOTE}\n\n` + + "Trace mode (provide a 32-char trace ID):\n" + + " sentry span list # auto-detect org/project\n" + + " sentry span list // # explicit\n" + + " sentry span list # find project + trace\n\n" + "Pagination:\n" + - " sentry span list -c last # fetch next page\n\n" + + " sentry span list -c last # fetch next page (project mode)\n" + + " sentry span list -c last # fetch next page (trace mode)\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\n\n' + + " sentry span list # List recent spans in project\n" + + ' sentry span list -q "op:db" # Find all DB spans\n' + + ' sentry span list -q "duration:>100ms" # Slow spans\n' + + " sentry span list --period 24h # Last 24 hours only\n" + + " sentry span list --sort duration # Sort by slowest first\n" + + " sentry span list # Spans in a specific trace\n" + + ' sentry span list -q "op:db" # DB spans in a trace\n\n' + "Alias: `sentry spans` → `sentry span list`", }, output: { @@ -195,8 +413,7 @@ export const listCommand = buildCommand({ kind: "array", parameter: { placeholder: "org/project/trace-id", - brief: - "[//] - Target (optional) and trace ID (required)", + brief: "[/] or [//]", parse: String, }, }, @@ -220,80 +437,26 @@ export const listCommand = buildCommand({ brief: `Sort order: ${VALID_SORT_VALUES.join(", ")}`, default: DEFAULT_SORT, }, - cursor: LIST_CURSOR_FLAG, - fresh: FRESH_FLAG, + period: LIST_PERIOD_FLAG, }, aliases: { - ...FRESH_ALIASES, + ...PERIOD_ALIASES, n: "limit", q: "query", s: "sort", - c: "cursor", }, }, async *func(this: SentryContext, flags: ListFlags, ...args: string[]) { - applyFreshFlag(flags); const { cwd, setContext } = this; + const parsed = parseSpanListArgs(args); + const modeCtx: ModeContext = { cwd, flags, setContext }; - // Parse and resolve org/project/trace-id - const parsed = parseTraceTarget(args, USAGE_HINT); - warnIfNormalized(parsed, "span.list"); - const { traceId, org, project } = await resolveTraceOrgProject( - parsed, - cwd, - USAGE_HINT - ); - setContext([org], [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", - `${org}/${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 withProgress( - { message: `Fetching spans (up to ${flags.limit})...`, json: flags.json }, - () => - listSpans(org, project, { - query: apiQuery, - sort: flags.sort, - limit: flags.limit, - cursor, - }) - ); + const { output, hint } = + parsed.mode === "trace" + ? await handleTraceMode(parsed.parsed, modeCtx) + : await handleProjectMode(parsed.target, modeCtx); - // 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(org, project, traceId, flags)}`; - } else if (flatSpans.length > 0) { - const countText = `Showing ${flatSpans.length} span${flatSpans.length === 1 ? "" : "s"}.`; - hint = hasMore - ? `${countText} Next page: ${nextPageHint(org, project, traceId, flags)}` - : `${countText} Use 'sentry span view ${traceId} ' to view span details.`; - } - - yield new CommandOutput({ flatSpans, hasMore, nextCursor, traceId }); + yield new CommandOutput(output); return { hint }; }, }); diff --git a/src/commands/trace/list.ts b/src/commands/trace/list.ts index 7ff63811d..3a9b3fa54 100644 --- a/src/commands/trace/list.ts +++ b/src/commands/trace/list.ts @@ -17,11 +17,9 @@ import { formatTraceTable } from "../../lib/formatters/index.js"; import { filterFields } from "../../lib/formatters/json.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { - applyFreshFlag, buildListCommand, - FRESH_ALIASES, - FRESH_FLAG, - LIST_CURSOR_FLAG, + LIST_PERIOD_FLAG, + PERIOD_ALIASES, TARGET_PATTERN_NOTE, } from "../../lib/list-command.js"; import { withProgress } from "../../lib/polling.js"; @@ -32,6 +30,7 @@ type ListFlags = { readonly limit: number; readonly query?: string; readonly sort: "date" | "duration"; + readonly period: string; readonly json: boolean; readonly cursor?: string; readonly fresh: boolean; @@ -77,11 +76,14 @@ const COMMAND_NAME = "trace list"; /** Command key for pagination cursor storage */ export const PAGINATION_KEY = "trace-list"; +/** Default time period for trace queries */ +const DEFAULT_PERIOD = "7d"; + /** Build the CLI hint for fetching the next page, preserving active flags. */ function nextPageHint( org: string, project: string, - flags: Pick + flags: Pick ): string { const base = `sentry trace list ${org}/${project} -c last`; const parts: string[] = []; @@ -91,6 +93,9 @@ function nextPageHint( if (flags.query) { parts.push(`-q "${flags.query}"`); } + if (flags.period !== DEFAULT_PERIOD) { + parts.push(`--period ${flags.period}`); + } return parts.length > 0 ? `${base} ${parts.join(" ")}` : base; } @@ -178,6 +183,7 @@ export const listCommand = buildListCommand("trace", { " sentry trace list # List last 10 traces\n" + " sentry trace list --limit 50 # Show more traces\n" + " sentry trace list --sort duration # Sort by slowest first\n" + + " sentry trace list --period 24h # Last 24 hours only\n" + ' sentry trace list -q "transaction:GET /api/users" # Filter by transaction\n\n' + "Alias: `sentry traces` → `sentry trace list`", }, @@ -216,19 +222,16 @@ export const listCommand = buildListCommand("trace", { brief: "Sort by: date, duration", default: "date" as const, }, - cursor: LIST_CURSOR_FLAG, - fresh: FRESH_FLAG, + period: LIST_PERIOD_FLAG, }, aliases: { - ...FRESH_ALIASES, + ...PERIOD_ALIASES, n: "limit", q: "query", s: "sort", - c: "cursor", }, }, async *func(this: SentryContext, flags: ListFlags, target?: string) { - applyFreshFlag(flags); const { cwd, setContext } = this; // Resolve org/project from positional arg, config, or DSN auto-detection @@ -243,6 +246,7 @@ export const listCommand = buildListCommand("trace", { const contextKey = buildPaginationContextKey("trace", `${org}/${project}`, { sort: flags.sort, q: flags.query, + period: flags.period, }); const cursor = resolveOrgCursor(flags.cursor, PAGINATION_KEY, contextKey); @@ -257,6 +261,7 @@ export const listCommand = buildListCommand("trace", { limit: flags.limit, sort: flags.sort, cursor, + statsPeriod: flags.period, }) ); diff --git a/src/lib/list-command.ts b/src/lib/list-command.ts index 637be6fe5..c4a7db108 100644 --- a/src/lib/list-command.ts +++ b/src/lib/list-command.ts @@ -215,6 +215,36 @@ export function buildListLimitFlag( }; } +/** + * The `--period` / `-t` flag for list commands that query time-bounded data. + * + * Controls the `statsPeriod` parameter sent to the Sentry Events API. + * Accepts Sentry duration strings like `"1h"`, `"24h"`, `"7d"`, `"30d"`. + * + * Default is `"7d"` (7 days). Commands that need a different default (e.g., + * `issue list` uses `"90d"`) should define their own flag inline. + * + * @example + * ```ts + * flags: { ..., period: LIST_PERIOD_FLAG }, + * aliases: { ...PERIOD_ALIASES }, + * ``` + */ +export const LIST_PERIOD_FLAG = { + kind: "parsed" as const, + parse: String, + brief: 'Time period (e.g., "1h", "24h", "7d", "30d")', + default: "7d", +}; + +/** + * Alias map for the `--period` flag: `-t` → `--period`. + * + * Exported separately from `LIST_BASE_ALIASES` because not all list commands + * need a period flag, and some commands already use `-t` for other purposes. + */ +export const PERIOD_ALIASES = { t: "period" } as const; + /** * Alias map shared by all list commands. * `-n` → `--limit`, `-c` → `--cursor`. @@ -333,29 +363,35 @@ type ListCommandFunction< ) => AsyncGenerator; /** - * Build a Stricli command for a list endpoint with automatic plural-alias - * interception. + * Options for controlling which flags {@link buildListCommand} auto-injects. * - * This is a drop-in replacement for `buildCommand` that wraps the command - * function to intercept subcommand names passed through plural aliases. - * For example, when `sentry projects list` passes "list" as a positional - * target to the project list command, it is intercepted and treated as - * auto-detect mode with a command-specific hint on stderr. + * By default, `buildListCommand` adds `--fresh`, `--cursor`, and their aliases + * (`-f`, `-c`). Use these options to opt out when a command has conflicts. + */ +export type ListCommandOptions = { + /** Skip injecting `--cursor` flag and `-c` alias (e.g., log list uses streaming). */ + noCursorFlag?: boolean; + /** Skip injecting `-f` alias for `--fresh` (e.g., log list uses `-f` for `--follow`). */ + noFreshAlias?: boolean; +}; + +/** + * Build a Stricli command for a list endpoint with automatic plural-alias + * interception and common flag injection. * - * Usage: - * ```ts - * // Before: - * import { buildCommand } from "../../lib/command.js"; - * export const listCommand = buildCommand({ ... }); + * This is a drop-in replacement for `buildCommand` that: + * 1. Intercepts subcommand names passed through plural aliases + * 2. Auto-injects `--fresh` and `--cursor` flags (unless already defined) + * 3. Auto-injects `-f` and `-c` aliases (with opt-outs via `options`) + * 4. Auto-calls `applyFreshFlag(flags)` before the command function runs * - * // After: - * import { buildListCommand } from "../../lib/list-command.js"; - * export const listCommand = buildListCommand("project", { ... }); - * ``` + * Commands that define their own `cursor` or `fresh` flags (e.g., with a + * custom `brief`) keep theirs — auto-injection skips flags already present. * * @param routeName - Singular route name (e.g. "project", "issue") for the * hint message and subcommand lookup * @param builderArgs - Same arguments as `buildCommand` from `lib/command.js` + * @param options - Control which flags are auto-injected */ export function buildListCommand< const FLAGS extends BaseFlags = NonNullable, @@ -372,12 +408,50 @@ export function buildListCommand< readonly func: ListCommandFunction; // biome-ignore lint/suspicious/noExplicitAny: OutputConfig is generic but type is erased at the builder level readonly output?: OutputConfig; - } + }, + options?: ListCommandOptions ): Command { const originalFunc = builderArgs.func; + // Auto-inject common flags and aliases into parameters + const params = (builderArgs.parameters ?? {}) as Record; + const existingFlags = (params.flags ?? {}) as Record; + const existingAliases = (params.aliases ?? {}) as Record; + + const mergedFlags = { ...existingFlags }; + const mergedAliases = { ...existingAliases }; + + // Always inject --fresh unless the command already defines it + if (!("fresh" in mergedFlags)) { + mergedFlags.fresh = FRESH_FLAG; + } + + // Inject --cursor unless opted out or already defined + if (!(options?.noCursorFlag || "cursor" in mergedFlags)) { + mergedFlags.cursor = LIST_CURSOR_FLAG; + } + + // Inject -f alias unless opted out or already defined + if (!(options?.noFreshAlias || "f" in mergedAliases)) { + mergedAliases.f = "fresh"; + } + + // Inject -c alias unless cursor is opted out or already defined + if (!(options?.noCursorFlag || "c" in mergedAliases)) { + mergedAliases.c = "cursor"; + } + + const mergedParams = { + ...params, + flags: mergedFlags, + aliases: mergedAliases, + }; + // biome-ignore lint/suspicious/noExplicitAny: Stricli's CommandFunction type is complex const wrappedFunc = function (this: CONTEXT, flags: FLAGS, ...args: any[]) { + // Auto-apply fresh flag before command runs + applyFreshFlag(flags as unknown as { readonly fresh: boolean }); + // The first positional arg is always the target (org/project pattern). // Intercept it to handle plural alias confusion. if ( @@ -397,6 +471,7 @@ export function buildListCommand< return buildCommand({ ...builderArgs, + parameters: mergedParams, func: wrappedFunc, output: builderArgs.output, }); @@ -483,10 +558,8 @@ export function buildOrgListCommand( positional: LIST_TARGET_POSITIONAL, flags: { limit: buildListLimitFlag(config.entityPlural), - cursor: LIST_CURSOR_FLAG, - fresh: FRESH_FLAG, }, - aliases: { ...LIST_BASE_ALIASES, ...FRESH_ALIASES }, + aliases: LIST_BASE_ALIASES, }, async *func( this: SentryContext, @@ -499,7 +572,6 @@ export function buildOrgListCommand( }, target?: string ) { - applyFreshFlag(flags); const { cwd } = this; const parsed = parseOrgProjectArg(target); const result = await dispatchOrgScopedList({ diff --git a/src/lib/trace-target.ts b/src/lib/trace-target.ts index dc89f8b08..cada90b81 100644 --- a/src/lib/trace-target.ts +++ b/src/lib/trace-target.ts @@ -24,7 +24,7 @@ import { resolveOrgAndProject, resolveProjectBySlug, } from "./resolve-target.js"; -import { validateTraceId } from "./trace-id.js"; +import { isTraceId, validateTraceId } from "./trace-id.js"; /** Match `[]` in usageHint — captures bracket content + trailing placeholder */ const USAGE_TARGET_RE = /\[.*\]<[^>]+>/; @@ -397,3 +397,79 @@ export async function resolveTraceOrg( } } } + +// --------------------------------------------------------------------------- +// Dual-mode argument disambiguation (project vs trace) +// --------------------------------------------------------------------------- + +/** + * Result from dual-mode argument disambiguation. + * + * Used by commands that support both project-scoped listing (no trace ID) + * and trace-scoped listing (trace ID provided), like `span list` and + * `log list`. + * + * Discriminated on `mode`: + * - `"project"` — no trace ID detected; `target` is the optional org/project arg + * - `"trace"` — a 32-char hex trace ID was found; `parsed` contains the full target + */ +export type ParsedDualModeArgs = + | { mode: "project"; target?: string } + | { mode: "trace"; parsed: ParsedTraceTarget }; + +/** + * Disambiguate positional arguments for dual-mode list commands. + * + * Detects trace mode by checking whether any argument segment looks like + * a 32-char hex trace ID via {@link isTraceId}: + * + * - **No args**: project mode (auto-detect org/project) + * - **Two+ args**: checks the last positional. If it's a trace ID → trace + * mode (space-separated form like ` `). + * - **Single arg**: checks the tail segment (last part after `/`). If it + * looks like a trace ID → trace mode. Otherwise → project target. + * + * When trace mode is detected, delegates to {@link parseTraceTarget} for + * full parsing and validation. + * + * @param args - Positional arguments from CLI + * @param traceUsageHint - Usage hint for trace-mode error messages + * @returns Parsed args with mode discrimination + */ +export function parseDualModeArgs( + args: string[], + traceUsageHint: string +): ParsedDualModeArgs { + if (args.length === 0) { + return { mode: "project" }; + } + + const first = args[0]; + if (first === undefined) { + return { mode: "project" }; + } + + // Two+ args: check if the last arg is a trace ID (space-separated form) + if (args.length >= 2) { + const last = args.at(-1); + if (last && isTraceId(last)) { + return { + mode: "trace", + parsed: parseTraceTarget(args, traceUsageHint), + }; + } + } + + // Single arg: check the tail segment (last part after "/", or entire arg) + const lastSlash = first.lastIndexOf("/"); + const tail = lastSlash === -1 ? first : first.slice(lastSlash + 1); + if (isTraceId(tail)) { + return { + mode: "trace", + parsed: parseTraceTarget(args, traceUsageHint), + }; + } + + // Not a trace ID → treat as project target + return { mode: "project", target: first }; +} diff --git a/test/commands/span/list.test.ts b/test/commands/span/list.test.ts index 49d6d91e8..0d9678423 100644 --- a/test/commands/span/list.test.ts +++ b/test/commands/span/list.test.ts @@ -1,8 +1,11 @@ /** * Span List Command Tests * - * Tests for positional argument parsing, sort flag parsing, - * and the command func body in src/commands/span/list.ts. + * Tests for the dual-mode span list command: + * - parseSort: sort flag validation + * - parseSpanListArgs: positional argument disambiguation (project vs trace mode) + * - listCommand.func (trace mode): existing trace-scoped behavior + * - listCommand.func (project mode): new project-scoped behavior */ import { @@ -14,7 +17,11 @@ import { spyOn, test, } from "bun:test"; -import { listCommand, parseSort } from "../../../src/commands/span/list.js"; +import { + listCommand, + parseSort, + parseSpanListArgs, +} from "../../../src/commands/span/list.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as apiClient from "../../../src/lib/api-client.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking @@ -24,6 +31,10 @@ const VALID_TRACE_ID = "aaaa1111bbbb2222cccc3333dddd4444"; // Note: parseTraceTarget parsing tests are in test/lib/trace-target.test.ts +// ============================================================================ +// parseSort +// ============================================================================ + describe("parseSort", () => { test("accepts 'date'", () => { expect(parseSort("date")).toBe("date"); @@ -46,9 +57,70 @@ describe("parseSort", () => { }); }); -// --------------------------------------------------------------------------- -// listCommand.func — tests the command body with mocked APIs -// --------------------------------------------------------------------------- +// ============================================================================ +// parseSpanListArgs — positional argument disambiguation +// ============================================================================ + +describe("parseSpanListArgs", () => { + test("no args → project mode", () => { + const result = parseSpanListArgs([]); + expect(result).toEqual({ mode: "project" }); + }); + + test("org/project → project mode with target", () => { + const result = parseSpanListArgs(["my-org/my-project"]); + expect(result.mode).toBe("project"); + if (result.mode === "project") { + expect(result.target).toBe("my-org/my-project"); + } + }); + + test("bare project name → project mode with target", () => { + const result = parseSpanListArgs(["my-project"]); + expect(result.mode).toBe("project"); + if (result.mode === "project") { + expect(result.target).toBe("my-project"); + } + }); + + test("trace ID → trace mode", () => { + const result = parseSpanListArgs([VALID_TRACE_ID]); + expect(result.mode).toBe("trace"); + }); + + test("org/project/trace-id → trace mode", () => { + const result = parseSpanListArgs([`my-org/my-project/${VALID_TRACE_ID}`]); + expect(result.mode).toBe("trace"); + }); + + test("project + trace-id (space-separated) → trace mode", () => { + const result = parseSpanListArgs(["my-project", VALID_TRACE_ID]); + expect(result.mode).toBe("trace"); + }); + + test("org/project + trace-id (space-separated) → trace mode", () => { + const result = parseSpanListArgs(["my-org/my-project", VALID_TRACE_ID]); + expect(result.mode).toBe("trace"); + }); + + test("short non-hex string → project mode", () => { + const result = parseSpanListArgs(["frontend"]); + expect(result.mode).toBe("project"); + if (result.mode === "project") { + expect(result.target).toBe("frontend"); + } + }); + + test("32-char non-hex string → project mode", () => { + // 32 chars but not valid hex + const result = parseSpanListArgs(["abcdefghijklmnopqrstuvwxyz123456"]); + expect(result.mode).toBe("project"); + }); +}); + +// ============================================================================ +// listCommand.func — trace mode (backwards compatibility) +// ============================================================================ type ListFunc = ( this: unknown, @@ -56,7 +128,7 @@ type ListFunc = ( ...args: string[] ) => Promise; -describe("listCommand.func", () => { +describe("listCommand.func (trace mode)", () => { let func: ListFunc; let listSpansSpy: ReturnType; let resolveOrgAndProjectSpy: ReturnType; @@ -122,6 +194,7 @@ describe("listCommand.func", () => { { limit: 25, sort: "date", + period: "7d", fresh: false, }, VALID_TRACE_ID @@ -151,6 +224,7 @@ describe("listCommand.func", () => { limit: 25, query: "op:db", sort: "date", + period: "7d", fresh: false, }, VALID_TRACE_ID @@ -175,6 +249,7 @@ describe("listCommand.func", () => { { limit: 25, sort: "date", + period: "7d", fresh: false, }, `my-org/my-project/${VALID_TRACE_ID}` @@ -209,6 +284,7 @@ describe("listCommand.func", () => { { limit: 25, sort: "date", + period: "7d", cursor: "1735689600:0:0", fresh: false, }, @@ -244,6 +320,7 @@ describe("listCommand.func", () => { { limit: 1, sort: "date", + period: "7d", json: true, fresh: false, }, @@ -276,6 +353,7 @@ describe("listCommand.func", () => { { limit: 1, sort: "date", + period: "7d", fresh: false, }, VALID_TRACE_ID @@ -284,4 +362,306 @@ describe("listCommand.func", () => { const output = getStdout(); expect(output).toContain("-c last"); }); + + test("passes statsPeriod to listSpans", async () => { + listSpansSpy.mockResolvedValue({ data: [], nextCursor: undefined }); + + const { context } = createContext(); + + await func.call( + context, + { + limit: 25, + sort: "date", + period: "24h", + fresh: false, + }, + VALID_TRACE_ID + ); + + expect(listSpansSpy).toHaveBeenCalledWith( + "test-org", + "test-project", + expect.objectContaining({ + statsPeriod: "24h", + }) + ); + }); +}); + +// ============================================================================ +// listCommand.func — project mode (new) +// ============================================================================ + +describe("listCommand.func (project mode)", () => { + 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 without trace filter when no trace ID given", async () => { + listSpansSpy.mockResolvedValue({ + data: [ + { + id: "a1b2c3d4e5f67890", + "span.op": "db", + description: "SELECT * FROM users", + "span.duration": 45, + timestamp: "2024-01-15T10:30:00+00:00", + project: "test-project", + trace: VALID_TRACE_ID, + }, + ], + nextCursor: undefined, + }); + + const { context, getStdout } = createContext(); + + // No positional args → project mode via auto-detect + await func.call(context, { + limit: 25, + sort: "date", + period: "7d", + fresh: false, + }); + + // Should NOT have trace: prefix in the query + const callArgs = listSpansSpy.mock.calls[0]; + const query = callArgs[2]?.query; + expect(query).toBeUndefined(); + + const output = getStdout(); + expect(output).toContain("a1b2c3d4e5f67890"); + }); + + test("uses explicit org/project in project mode", async () => { + listSpansSpy.mockResolvedValue({ data: [], nextCursor: undefined }); + + const { context } = createContext(); + + await func.call( + context, + { + limit: 25, + sort: "date", + period: "7d", + fresh: false, + }, + "my-org/my-project" + ); + + expect(listSpansSpy).toHaveBeenCalledWith( + "my-org", + "my-project", + expect.anything() + ); + // Should NOT have called resolveOrgAndProject since target is explicit + expect(resolveOrgAndProjectSpy).not.toHaveBeenCalled(); + }); + + test("translates query in project mode", async () => { + listSpansSpy.mockResolvedValue({ data: [], nextCursor: undefined }); + + const { context } = createContext(); + + await func.call( + context, + { + limit: 25, + query: "op:db duration:>100ms", + sort: "date", + period: "7d", + fresh: false, + }, + "my-org/my-project" + ); + + expect(listSpansSpy).toHaveBeenCalledWith( + "my-org", + "my-project", + expect.objectContaining({ + query: "span.op:db span.duration:>100ms", + }) + ); + }); + + test("passes statsPeriod in project mode", async () => { + listSpansSpy.mockResolvedValue({ data: [], nextCursor: undefined }); + + const { context } = createContext(); + + await func.call( + context, + { + limit: 25, + sort: "date", + period: "30d", + fresh: false, + }, + "my-org/my-project" + ); + + expect(listSpansSpy).toHaveBeenCalledWith( + "my-org", + "my-project", + expect.objectContaining({ + statsPeriod: "30d", + }) + ); + }); + + test("shows project header in human output", 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: "my-project", + trace: VALID_TRACE_ID, + }, + ], + nextCursor: undefined, + }); + + const { context, getStdout } = createContext(); + + await func.call( + context, + { + limit: 25, + sort: "date", + period: "7d", + fresh: false, + }, + "my-org/my-project" + ); + + const output = getStdout(); + expect(output).toContain("Spans in my-org/my-project:"); + // Should NOT contain "Spans in trace" + expect(output).not.toContain("Spans in trace"); + }); + + test("JSON output has correct envelope in project mode", 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", + period: "7d", + json: true, + fresh: false, + }); + + const output = getStdout(); + const parsed = JSON.parse(output); + expect(parsed.hasMore).toBe(true); + expect(parsed.nextCursor).toBe("1735689600:0:1"); + expect(Array.isArray(parsed.data)).toBe(true); + }); + + test("shows 'No spans matched' when empty in project mode", async () => { + listSpansSpy.mockResolvedValue({ data: [], nextCursor: undefined }); + + const { context, getStdout } = createContext(); + + await func.call( + context, + { + limit: 25, + sort: "date", + period: "7d", + fresh: false, + }, + "my-org/my-project" + ); + + const output = getStdout(); + expect(output).toContain("No spans matched the query."); + }); + + test("hint shows -c last with project target 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", + period: "7d", + fresh: false, + }, + "my-org/my-project" + ); + + const output = getStdout(); + expect(output).toContain("-c last"); + expect(output).toContain("sentry span list my-org/my-project"); + // Should NOT contain a trace ID in the next-page hint + expect(output).not.toContain(VALID_TRACE_ID); + }); }); diff --git a/test/commands/trace/list.test.ts b/test/commands/trace/list.test.ts index e9288240b..796014ce9 100644 --- a/test/commands/trace/list.test.ts +++ b/test/commands/trace/list.test.ts @@ -537,4 +537,43 @@ describe("listCommand.func", () => { expect(parsed.hasMore).toBe(false); expect(parsed.nextCursor).toBeUndefined(); }); + + test("passes statsPeriod to listTransactions when --period is set", async () => { + listTransactionsSpy.mockResolvedValue({ data: [] }); + + const { context } = createMockContext(); + const func = await listCommand.loader(); + await func.call( + context, + { limit: 20, sort: "date", json: false, period: "24h" }, + "test-org/test-project" + ); + + expect(listTransactionsSpy).toHaveBeenCalledWith( + "test-org", + "test-project", + expect.objectContaining({ + statsPeriod: "24h", + }) + ); + }); + + test("next page hint includes --period when non-default", async () => { + listTransactionsSpy.mockResolvedValue({ + data: sampleTraces, + nextCursor: "1735689600:0:0", + }); + + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call( + context, + { limit: 20, sort: "date", json: false, period: "30d" }, + "test-org/test-project" + ); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(output).toContain("--period 30d"); + expect(output).toContain("-c last"); + }); });