diff --git a/AGENTS.md b/AGENTS.md index 022325e49..6570cd4af 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -767,39 +767,58 @@ 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. + +* **DSN org prefix normalization in arg-parsing.ts**: Sentry DSN hosts encode org IDs as \`oNNNNN\` (e.g., \`o1081365.ingest.us.sentry.io\`). The Sentry API rejects the \`o\`-prefixed form. \`stripDsnOrgPrefix()\` in \`src/lib/arg-parsing.ts\` uses \`/^o(\d+)$/\` to strip the prefix — safe for slugs like \`organic\`. Applied in \`parseOrgProjectArg()\` and \`parseWithSlash()\`, covering all API call paths consuming \`parsed.org\`. - -* **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. + +* **GHCR versioned nightly tags for delta upgrade support**: GHCR nightly distribution uses three tag types: \`:nightly\` (rolling), \`:nightly-\\` (immutable), \`:patch-\\` (delta manifest). Delta patches use zig-bsdiff TRDIFF10 (zstd-compressed), ~50KB vs ~29MB full. Client bspatch via \`Bun.zstdDecompressSync()\`. N-1 patches only, full download fallback, SHA-256 verify, 60% size threshold. npm/Node excluded. Test mocks: use \`mockGhcrNightlyVersion()\` helper. - -* **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. + +* **Issue list auto-pagination beyond API's 100-item cap**: Sentry API silently caps \`limit\` at 100 per request. \`listIssuesAllPages()\` auto-paginates using Link headers, bounded by MAX\_PAGINATION\_PAGES (50). \`API\_MAX\_PER\_PAGE\` constant is shared across all paginated consumers. \`--limit\` means total results everywhere (max 1000, default 25). Org-all mode uses \`fetchOrgAllIssues()\`; explicit \`--cursor\` does single-page fetch to preserve cursor chain. - -* **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 }\`. + +* **Self-hosted OAuth device flow requires Sentry 26.1.0+ and SENTRY\_CLIENT\_ID**: Self-hosted OAuth device flow requires Sentry 26.1.0+ and both \`SENTRY\_URL\` and \`SENTRY\_CLIENT\_ID\` env vars. Users must create a public OAuth app in Settings → Developer Settings. The client ID is NOT optional for self-hosted. Fallback for older instances: \`sentry auth login --token\`. \`getSentryUrl()\` and \`getClientId()\` in \`src/lib/oauth.ts\` read lazily (not at module load) so URL parsing from arguments can set \`SENTRY\_URL\` after import. -### Decision + +* **Sentry CLI markdown-first formatting pipeline replaces ad-hoc ANSI**: Formatters build CommonMark strings; \`renderMarkdown()\` renders to ANSI for TTY or raw markdown for non-TTY. Key helpers: \`colorTag()\`, \`mdKvTable()\`, \`mdRow()\`, \`mdTableHeader()\` (\`:\` suffix = right-aligned), \`renderTextTable()\`. \`isPlainOutput()\` checks \`SENTRY\_PLAIN\_OUTPUT\` > \`NO\_COLOR\` > \`!isTTY\`. Batch path: \`formatXxxTable()\`. Streaming path: \`StreamingTable\` (TTY) or raw markdown rows (plain). Both share \`buildXxxRowCells()\`. - -* **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. + +* **Sentry issue stats field: time-series controlled by groupStatsPeriod**: The \`stats\` field on issues is \`{ '24h': \[\[ts, count], ...] }\`. Key depends on \`groupStatsPeriod\` param (\`""\`, \`"14d"\`, \`"24h"\`, \`"auto"\`). \`statsPeriod\` controls time window; \`groupStatsPeriod\` controls stats key. \*\*Critical\*\*: \`count\` is period-scoped — \`lifetime.count\` is the true lifetime total. Issue list table uses \`groupStatsPeriod: 'auto'\` for sparkline data. Column order: SHORT ID, ISSUE, SEEN, AGE, TREND, EVENTS, USERS, TRIAGE. TREND auto-hidden when terminal < 100 cols. \`--compact\` tri-state: explicit overrides; \`undefined\` triggers \`shouldAutoCompact(rowCount)\` — compact if \`3N + 3 > termHeight\`, false for non-TTY. Height is \`3N + 3\` (not \`3N + 4\`) because last data row has no trailing separator. + + +* **Sentry trace-logs API is org-scoped, not project-scoped**: The Sentry trace-logs endpoint (\`/organizations/{org}/trace-logs/\`) is org-scoped, so \`trace logs\` uses \`resolveOrg()\` not \`resolveOrgAndProject()\`. The endpoint is PRIVATE in Sentry source, excluded from the public OpenAPI schema — \`@sentry/api\` has no generated types. The hand-written \`TraceLogSchema\` in \`src/types/sentry.ts\` is required until Sentry makes it public. + + +* **withAuthGuard returns discriminated Result type, not fallback+onError**: \`withAuthGuard\(fn)\` in \`src/lib/errors.ts\` returns a discriminated Result: \`{ ok: true, value: T } | { ok: false, error: unknown }\`. AuthErrors always re-throw (triggers bin.ts auto-login). All other errors are captured. Callers inspect \`result.ok\` to degrade gracefully. Used across 12+ files. ### 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. + +* **Biome lint: Response.redirect() required, nested ternaries forbidden**: Biome lint rules that frequently trip up this codebase: (1) \`useResponseRedirect\`: use \`Response.redirect(url, status)\` not \`new Response\`. (2) \`noNestedTernary\`: use \`if/else\`. (3) \`noComputedPropertyAccess\`: use \`obj.property\` not \`obj\["property"]\`. (4) Max cognitive complexity 15 per function — extract helpers to stay under. + + +* **Bugbot flags defensive null-checks as dead code — keep them with JSDoc justification**: Cursor Bugbot and Sentry Seer repeatedly flag two false positives: (1) defensive null-checks as "dead code" — keep them with JSDoc explaining why the guard exists for future safety, especially when removing would require \`!\` assertions banned by \`noNonNullAssertion\`. (2) stderr spinner output during \`--json\` mode — always a false positive since progress goes to stderr, JSON to stdout. Reply explaining the rationale and resolve. - -* **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. + +* **Bun mock.module for node:tty requires default export and class stubs**: Bun testing gotchas: (1) \`mock.module()\` for CJS built-ins requires a \`default\` re-export plus all named exports. Missing any causes \`SyntaxError: Export named 'X' not found\`. Always check the real module's full export list. (2) \`Bun.mmap()\` always opens with PROT\_WRITE — macOS SIGKILL on signed Mach-O, Linux ETXTBSY. Fix: use \`new Uint8Array(await Bun.file(path).arrayBuffer())\` in bspatch.ts. (3) Wrap \`Bun.which()\` with optional \`pathEnv\` param for deterministic testing without mocks. ### 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\`. + +* **Branch naming and commit message conventions for Sentry CLI**: Branch naming: \`feat/\\` or \`fix/\-\\` (e.g., \`feat/ghcr-nightly-distribution\`, \`fix/268-limit-auto-pagination\`). Commit message format: \`type(scope): description (#issue)\` (e.g., \`fix(issue-list): auto-paginate --limit beyond 100 (#268)\`, \`feat(nightly): distribute via GHCR instead of GitHub Releases\`). Types seen: fix, refactor, meta, release, feat. PRs are created as drafts via \`gh pr create --draft\`. Implementation plans are attached to commits via \`git notes add\` rather than in PR body or commit message. + + +* **Codecov patch coverage only counts test:unit and test:isolated, not E2E**: CI coverage merges \`test:unit\` (\`test/lib test/commands test/types --coverage\`) and \`test:isolated\` (\`test/isolated --coverage\`) into \`coverage/merged.lcov\`. E2E tests (\`test/e2e\`) are NOT included in coverage reports. So func tests that spy on exports (e.g., \`spyOn(apiClient, 'getLogs')\`) give zero coverage to the mocked function's body. To cover \`api-client.ts\` function bodies in unit tests, mock \`globalThis.fetch\` + \`setOrgRegion()\` + \`setAuthToken()\` and call the real function. + + +* **Pagination contextKey must include all query-varying parameters with escaping**: Pagination \`contextKey\` must encode every query-varying parameter (sort, query, period) with \`escapeContextKeyValue()\` (replaces \`|\` with \`%7C\`). Always provide a fallback before escaping since \`flags.period\` may be \`undefined\` in tests despite having a default: \`flags.period ? escapeContextKeyValue(flags.period) : "90d"\`. + + +* **PR review workflow: reply, resolve, amend, force-push**: PR review workflow: (1) Read unresolved threads via GraphQL, (2) make code changes, (3) run lint+typecheck+tests, (4) create a SEPARATE commit per review round (not amend) for incremental review, (5) push normally, (6) reply to comments via REST API, (7) resolve threads via GraphQL \`resolveReviewThread\`. Only amend+force-push when user explicitly asks or pre-commit hook modified files. - -* **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. + +* **Stricli optional boolean flags produce tri-state (true/false/undefined)**: Stricli boolean flags with \`optional: true\` (no \`default\`) produce \`boolean | undefined\` in the flags type. \`--flag\` → \`true\`, \`--no-flag\` → \`false\`, omitted → \`undefined\`. This enables auto-detect patterns: explicit user choice overrides, \`undefined\` triggers heuristic. Used by \`--compact\` on issue list. The flag type must be \`readonly field?: boolean\` (not \`readonly field: boolean\`). This differs from \`default: false\` which always produces a defined boolean. - -* **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**: Stricli/Bun test patterns: (1) Command func tests: \`const func = await cmd.loader()\`, then \`func.call(mockContext, flags, ...args)\`. \`loader()\` return type union causes LSP errors — false positives that pass \`tsc\`. File naming: \`\*.func.test.ts\`. (2) ESM prevents \`vi.spyOn\` on Node built-in exports. Workaround: test subclass that overrides the method calling the built-in. (3) Follow-mode uses \`setTimeout\`-based scheduling; test with \`interceptSigint()\` helper. \`Bun.sleep()\` has no AbortSignal so \`setTimeout\`/\`clearTimeout\` required. diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index e74c0193c..1a0df5b89 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -599,7 +599,7 @@ sentry team list --json View Sentry logs -#### `sentry log list ` +#### `sentry log list ` List logs from a project @@ -607,7 +607,7 @@ List logs from a project - `-n, --limit - Number of log entries (1-1000) - (default: "100")` - `-q, --query - Filter query (Sentry search syntax)` - `-f, --follow - Stream logs (optionally specify poll interval in seconds)` -- `--trace - Filter logs by trace ID (32-character hex string)` +- `-t, --period - Time period (e.g., "90d", "14d", "24h"). Default: 90d (project mode), 14d (trace mode)` - `--fresh - Bypass cache, re-detect projects, and fetch fresh data` - `--json - Output as JSON` - `--fields - Comma-separated fields to include in JSON output (dot.notation supported)` diff --git a/src/commands/dashboard/list.ts b/src/commands/dashboard/list.ts index c1d46bac2..c30e96a1e 100644 --- a/src/commands/dashboard/list.ts +++ b/src/commands/dashboard/list.ts @@ -135,7 +135,10 @@ export const listCommand = buildCommand({ } const dashboards = await withProgress( - { message: `Fetching dashboards (up to ${flags.limit})...` }, + { + message: `Fetching dashboards (up to ${flags.limit})...`, + json: flags.json, + }, () => listDashboards(orgSlug, { perPage: flags.limit }) ); const url = buildDashboardsListUrl(orgSlug); diff --git a/src/commands/issue/list.ts b/src/commands/issue/list.ts index 63e48ecd6..eae3388ff 100644 --- a/src/commands/issue/list.ts +++ b/src/commands/issue/list.ts @@ -812,7 +812,7 @@ async function handleOrgAllIssues( setContext([org], []); const { issues, nextCursor } = await withProgress( - { message: `Fetching issues (up to ${flags.limit})...` }, + { message: `Fetching issues (up to ${flags.limit})...`, json: flags.json }, (setMessage) => fetchOrgAllIssues(org, flags, cursor, (fetched, limit) => setMessage( @@ -954,7 +954,7 @@ async function handleResolvedTargets( : "Fetching issues"; const { results, hasMore } = await withProgress( - { message: `${baseMessage} (up to ${flags.limit})...` }, + { message: `${baseMessage} (up to ${flags.limit})...`, json: flags.json }, (setMessage) => fetchWithBudget( activeTargets, diff --git a/src/commands/log/list.ts b/src/commands/log/list.ts index cf3388559..274a70035 100644 --- a/src/commands/log/list.ts +++ b/src/commands/log/list.ts @@ -3,7 +3,7 @@ * * List and stream logs from Sentry projects. * Supports real-time streaming with --follow flag. - * Supports --trace flag to filter logs by trace ID. + * Supports trace ID as a positional argument to filter logs by trace. */ // biome-ignore lint/performance/noNamespaceImport: Sentry SDK recommends namespace import @@ -11,7 +11,7 @@ import * as Sentry from "@sentry/bun"; import type { SentryContext } from "../../context.js"; import { listLogs, listTraceLogs } from "../../lib/api-client.js"; import { validateLimit } from "../../lib/arg-parsing.js"; -import { AuthError, ContextError, stringifyUnknown } from "../../lib/errors.js"; +import { AuthError, stringifyUnknown } from "../../lib/errors.js"; import { buildLogRowCells, createLogStreamingTable, @@ -34,19 +34,23 @@ import { 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 { - resolveOrg, - resolveOrgProjectFromArg, -} from "../../lib/resolve-target.js"; -import { validateTraceId } from "../../lib/trace-id.js"; + type ParsedTraceTarget, + parseTraceTarget, + resolveTraceOrg, + warnIfNormalized, +} from "../../lib/trace-target.js"; import { getUpdateNotification } from "../../lib/version-check.js"; type ListFlags = { readonly limit: number; readonly query?: string; readonly follow?: number; + readonly period?: string; readonly json: boolean; - readonly trace?: string; readonly fresh: boolean; readonly fields?: string[]; }; @@ -62,6 +66,8 @@ type LogListResult = { logs: LogLike[]; /** Trace ID, present for trace-filtered queries */ traceId?: string; + /** Whether more results are available beyond the limit */ + hasMore: boolean; }; /** Output yielded by log list: either a batch (single-fetch) or an individual item (follow). */ @@ -82,6 +88,12 @@ const DEFAULT_POLL_INTERVAL = 2; /** Command name used in resolver error messages */ const COMMAND_NAME = "log list"; +/** Usage hint for trace mode error messages */ +const TRACE_USAGE_HINT = "sentry log list [/]"; + +/** Default time period for trace-logs queries */ +const DEFAULT_TRACE_PERIOD = "14d"; + /** * Parse --limit flag, delegating range validation to shared utility. */ @@ -130,6 +142,78 @@ type FetchResult = { hint: string; }; +// --------------------------------------------------------------------------- +// 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 + */ +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 }; +} + +/** Default time period for project-scoped log queries */ +const DEFAULT_PROJECT_PERIOD = "90d"; + /** * Execute a single fetch of logs (non-streaming mode). * @@ -141,14 +225,15 @@ async function executeSingleFetch( project: string, flags: ListFlags ): Promise { + const period = flags.period ?? DEFAULT_PROJECT_PERIOD; const logs = await listLogs(org, project, { query: flags.query, limit: flags.limit, - statsPeriod: "90d", + statsPeriod: period, }); if (logs.length === 0) { - return { result: { logs: [] }, hint: "No logs found." }; + return { result: { logs: [], hasMore: false }, hint: "No logs found." }; } // Reverse for chronological order (API returns newest first, tail shows oldest first) @@ -158,7 +243,10 @@ async function executeSingleFetch( const countText = `Showing ${logs.length} log${logs.length === 1 ? "" : "s"}.`; const tip = hasMore ? " Use --limit to show more, or -f to follow." : ""; - return { result: { logs: chronological }, hint: `${countText}${tip}` }; + return { + result: { logs: chronological, hasMore }, + hint: `${countText}${tip}`, + }; } // --------------------------------------------------------------------------- @@ -368,7 +456,11 @@ async function* yieldTraceFollowItems( for await (const batch of generator) { if (!contextSent && batch.length > 0) { // First non-empty batch: yield as LogListResult to set trace context - yield new CommandOutput({ logs: batch, traceId }); + yield new CommandOutput({ + logs: batch, + traceId, + hasMore: false, + }); contextSent = true; } else { for (const item of batch) { @@ -378,11 +470,8 @@ async function* yieldTraceFollowItems( } } -/** Default time period for trace-logs queries */ -const DEFAULT_TRACE_PERIOD = "14d"; - /** - * Execute a single fetch of trace-filtered logs (non-streaming, --trace mode). + * Execute a single fetch of trace-filtered logs (non-streaming, trace mode). * Uses the dedicated trace-logs endpoint which is org-scoped. * * Returns the fetched logs, trace ID, and a human-readable hint. @@ -393,17 +482,21 @@ async function executeTraceSingleFetch( traceId: string, flags: ListFlags ): Promise { + // Use the explicit period if set, otherwise default to 14d for trace mode. + // The flag is optional (no default) so undefined means "not explicitly set". + const period = flags.period ?? DEFAULT_TRACE_PERIOD; + const logs = await listTraceLogs(org, traceId, { query: flags.query, limit: flags.limit, - statsPeriod: DEFAULT_TRACE_PERIOD, + statsPeriod: period, }); if (logs.length === 0) { return { - result: { logs: [], traceId }, + result: { logs: [], traceId, hasMore: false }, hint: - `No logs found for trace ${traceId} in the last ${DEFAULT_TRACE_PERIOD}.\n\n` + + `No logs found for trace ${traceId} in the last ${period}.\n\n` + "Try 'sentry trace logs' for more options (e.g., --period 30d).", }; } @@ -415,7 +508,7 @@ async function executeTraceSingleFetch( const tip = hasMore ? " Use --limit to show more." : ""; return { - result: { logs: chronological, traceId }, + result: { logs: chronological, traceId, hasMore }, hint: `${countText}${tip}`, }; } @@ -518,16 +611,18 @@ function createLogRenderer(): HumanRenderer { * Transform log output into the JSON shape. * * Discriminates between {@link LogListResult} (single-fetch) and bare - * {@link LogLike} items (follow mode). Single-fetch yields a JSON array; - * follow mode yields one JSON object per line (JSONL). + * {@link LogLike} items (follow mode). Single-fetch yields a JSON envelope + * with `data` and `hasMore`; follow mode yields one JSON object per line (JSONL). */ function jsonTransformLogOutput(data: LogOutput, fields?: string[]): unknown { if ("logs" in data && Array.isArray((data as LogListResult).logs)) { - // Batch (single-fetch): return array - const logs = (data as LogListResult).logs; - return fields && fields.length > 0 - ? logs.map((log) => filterFields(log, fields)) - : logs; + // Batch (single-fetch): return envelope with data + hasMore + const logList = data as LogListResult; + const items = + fields && fields.length > 0 + ? logList.logs.map((log) => filterFields(log, fields)) + : logList.logs; + return { data: items, hasMore: logList.hasMore }; } // Single item (follow mode): return bare object for JSONL return fields && fields.length > 0 ? filterFields(data, fields) : data; @@ -544,16 +639,15 @@ export const listCommand = buildListCommand("log", { " sentry log list # find project across all orgs\n\n" + `${TARGET_PATTERN_NOTE}\n\n` + "Trace filtering:\n" + - " When --trace is given, only org resolution is needed (the trace-logs\n" + - " endpoint is org-scoped). The positional target is treated as an org\n" + - " slug, not an org/project pair.\n\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 --trace abc123def456abc123def456abc123de # Filter by trace\n\n" + + " sentry log list abc123def456abc123def456abc123de # Filter by trace\n\n" + "Alias: `sentry logs` → `sentry log list`", }, output: { @@ -562,15 +656,12 @@ export const listCommand = buildListCommand("log", { }, parameters: { positional: { - kind: "tuple", - parameters: [ - { - placeholder: "org/project", - brief: "/ or (search)", - parse: String, - optional: true, - }, - ], + kind: "array", + parameter: { + placeholder: "org/project-or-trace-id", + brief: "[/[/]], /, or ", + parse: String, + }, }, flags: { limit: { @@ -592,10 +683,11 @@ export const listCommand = buildListCommand("log", { optional: true, inferEmpty: true, }, - trace: { + period: { kind: "parsed", - parse: validateTraceId, - brief: "Filter logs by trace ID (32-character hex string)", + parse: String, + brief: + 'Time period (e.g., "90d", "14d", "24h"). Default: 90d (project mode), 14d (trace mode)', optional: true, }, fresh: FRESH_FLAG, @@ -604,31 +696,26 @@ export const listCommand = buildListCommand("log", { n: "limit", q: "query", f: "follow", + t: "period", }, }, - async *func(this: SentryContext, flags: ListFlags, target?: string) { + async *func(this: SentryContext, flags: ListFlags, ...args: string[]) { applyFreshFlag(flags); const { cwd, setContext } = this; - if (flags.trace) { + const parsed = parseLogListArgs(args); + + if (parsed.mode === "trace") { // Trace mode: use the org-scoped trace-logs endpoint. - // The positional target is treated as an org slug (not org/project). - const resolved = await resolveOrg({ - org: target, + warnIfNormalized(parsed.parsed, "log.list"); + const { traceId, org } = await resolveTraceOrg( + parsed.parsed, cwd, - }); - if (!resolved) { - throw new ContextError("Organization", "sentry log list --trace ", [ - "Set a default org with 'sentry org list', or specify one explicitly", - `Example: sentry log list myorg --trace ${flags.trace}`, - ]); - } - const { org } = resolved; + TRACE_USAGE_HINT + ); setContext([org], []); if (flags.follow) { - const traceId = flags.trace; - // Banner (suppressed in JSON mode) writeFollowBanner( flags.follow ?? DEFAULT_POLL_INTERVAL, @@ -676,20 +763,21 @@ export const listCommand = buildListCommand("log", { return; } - const { result, hint } = await executeTraceSingleFetch( - org, - flags.trace, - flags + 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 }; } - // Standard project-scoped mode — kept in else-like block to avoid - // `org` shadowing the trace-mode `org` declaration above. + // Standard project-scoped mode { const { org, project } = await resolveOrgProjectFromArg( - target, + parsed.target, cwd, COMMAND_NAME ); @@ -719,7 +807,13 @@ export const listCommand = buildListCommand("log", { return; } - const { result, hint } = await executeSingleFetch(org, project, flags); + 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 }; } diff --git a/src/commands/org/list.ts b/src/commands/org/list.ts index 1352c2024..429b0c148 100644 --- a/src/commands/org/list.ts +++ b/src/commands/org/list.ts @@ -18,6 +18,7 @@ import { FRESH_ALIASES, FRESH_FLAG, } from "../../lib/list-command.js"; +import { withProgress } from "../../lib/polling.js"; import type { SentryOrganization, Writer } from "../../types/index.js"; type ListFlags = { @@ -130,7 +131,13 @@ export const listCommand = buildCommand({ async *func(this: SentryContext, flags: ListFlags) { applyFreshFlag(flags); - const orgs = await listOrganizationsUncached(); + const orgs = await withProgress( + { + message: `Fetching organizations (up to ${flags.limit})...`, + json: flags.json, + }, + () => listOrganizationsUncached() + ); const limitedOrgs = orgs.slice(0, flags.limit); // Check if user has orgs in multiple regions diff --git a/src/commands/project/list.ts b/src/commands/project/list.ts index 071acd74e..20c6ee9c4 100644 --- a/src/commands/project/list.ts +++ b/src/commands/project/list.ts @@ -54,6 +54,7 @@ import { type ListCommandMeta, type ListResult, } from "../../lib/org-list.js"; +import { withProgress } from "../../lib/polling.js"; import { type ResolvedTarget, resolveAllTargets, @@ -333,9 +334,12 @@ export async function handleAutoDetect( skippedSelfHosted, } = await resolveOrgsForAutoDetect(cwd); - const { projects: allProjects, nextCursor } = await fetchAutoDetectProjects( - orgsToFetch, - flags + const { projects: allProjects, nextCursor } = await withProgress( + { + message: `Fetching projects (up to ${flags.limit})...`, + json: flags.json, + }, + () => fetchAutoDetectProjects(orgsToFetch, flags) ); const filtered = filterByPlatform(allProjects, flags.platform); @@ -390,7 +394,10 @@ export async function handleExplicit( projectSlug: string, flags: ListFlags ): Promise> { - const projectResult = await withAuthGuard(() => getProject(org, projectSlug)); + const projectResult = await withProgress( + { message: "Fetching project...", json: flags.json }, + () => withAuthGuard(() => getProject(org, projectSlug)) + ); if (!projectResult.ok) { return { items: [], @@ -437,11 +444,17 @@ export async function handleOrgAll( options: OrgAllOptions ): Promise> { const { org, flags, contextKey, cursor } = options; - const response: PaginatedResponse = - await listProjectsPaginated(org, { - cursor, - perPage: flags.limit, - }); + const response: PaginatedResponse = await withProgress( + { + message: `Fetching projects (up to ${flags.limit})...`, + json: flags.json, + }, + () => + listProjectsPaginated(org, { + cursor, + perPage: flags.limit, + }) + ); const projects: ProjectWithOrg[] = response.data.map((p) => ({ ...p, @@ -498,7 +511,13 @@ export async function handleProjectSearch( projectSlug: string, flags: ListFlags ): Promise> { - const { projects } = await findProjectsBySlug(projectSlug); + const { projects } = await withProgress( + { + message: `Fetching projects (up to ${flags.limit})...`, + json: flags.json, + }, + () => findProjectsBySlug(projectSlug) + ); const filtered = filterByPlatform(projects, flags.platform); if (filtered.length === 0) { diff --git a/src/commands/span/list.ts b/src/commands/span/list.ts index 32b8c37b9..55e68b448 100644 --- a/src/commands/span/list.ts +++ b/src/commands/span/list.ts @@ -30,6 +30,7 @@ import { FRESH_FLAG, LIST_CURSOR_FLAG, } from "../../lib/list-command.js"; +import { withProgress } from "../../lib/polling.js"; import { parseTraceTarget, resolveTraceOrgProject, @@ -260,12 +261,16 @@ export const listCommand = buildCommand({ const cursor = resolveOrgCursor(flags.cursor, PAGINATION_KEY, contextKey); // Fetch spans from EAP endpoint - const { data: spanItems, nextCursor } = await listSpans(org, project, { - query: apiQuery, - sort: flags.sort, - limit: flags.limit, - cursor, - }); + 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, + }) + ); // Store or clear pagination cursor if (nextCursor) { diff --git a/src/commands/trace/list.ts b/src/commands/trace/list.ts index 45345caf9..7ff63811d 100644 --- a/src/commands/trace/list.ts +++ b/src/commands/trace/list.ts @@ -24,6 +24,7 @@ import { LIST_CURSOR_FLAG, TARGET_PATTERN_NOTE, } from "../../lib/list-command.js"; +import { withProgress } from "../../lib/polling.js"; import { resolveOrgProjectFromArg } from "../../lib/resolve-target.js"; import type { TransactionListItem } from "../../types/index.js"; @@ -245,12 +246,19 @@ export const listCommand = buildListCommand("trace", { }); const cursor = resolveOrgCursor(flags.cursor, PAGINATION_KEY, contextKey); - const { data: traces, nextCursor } = await listTransactions(org, project, { - query: flags.query, - limit: flags.limit, - sort: flags.sort, - cursor, - }); + const { data: traces, nextCursor } = await withProgress( + { + message: `Fetching traces (up to ${flags.limit})...`, + json: flags.json, + }, + () => + listTransactions(org, project, { + query: flags.query, + limit: flags.limit, + sort: flags.sort, + cursor, + }) + ); // Store or clear pagination cursor if (nextCursor) { diff --git a/src/commands/trace/logs.ts b/src/commands/trace/logs.ts index c78a1737f..76556c194 100644 --- a/src/commands/trace/logs.ts +++ b/src/commands/trace/logs.ts @@ -17,6 +17,7 @@ import { FRESH_ALIASES, FRESH_FLAG, } from "../../lib/list-command.js"; +import { withProgress } from "../../lib/polling.js"; import { buildTraceUrl } from "../../lib/sentry-urls.js"; import { parseTraceTarget, @@ -46,7 +47,7 @@ type LogLike = { type TraceLogsData = { logs: LogLike[]; traceId: string; - limit: number; + hasMore: boolean; /** Message shown when no logs found */ emptyMessage?: string; }; @@ -57,9 +58,8 @@ function formatTraceLogsHuman(data: TraceLogsData): string { return data.emptyMessage ?? "No logs found."; } const parts = [formatLogTable(data.logs, false)]; - const hasMore = data.logs.length >= data.limit; const countText = `Showing ${data.logs.length} log${data.logs.length === 1 ? "" : "s"} for trace ${data.traceId}.`; - const tip = hasMore ? " Use --limit to show more." : ""; + const tip = data.hasMore ? " Use --limit to show more." : ""; parts.push(formatFooter(`${countText}${tip}`)); return parts.join("").trimEnd(); } @@ -107,10 +107,11 @@ export const logsCommand = buildCommand({ output: { human: formatTraceLogsHuman, jsonTransform: (data: TraceLogsData, fields?: string[]) => { - if (fields && fields.length > 0) { - return data.logs.map((entry) => filterFields(entry, fields)); - } - return data.logs; + const items = + fields && fields.length > 0 + ? data.logs.map((entry) => filterFields(entry, fields)) + : data.logs; + return { data: items, hasMore: data.hasMore }; }, }, parameters: { @@ -171,14 +172,22 @@ export const logsCommand = buildCommand({ return; } - const logs = await listTraceLogs(org, traceId, { - statsPeriod: flags.period, - limit: flags.limit, - query: flags.query, - }); + const logs = await withProgress( + { + message: `Fetching trace logs (up to ${flags.limit})...`, + json: flags.json, + }, + () => + listTraceLogs(org, traceId, { + statsPeriod: flags.period, + limit: flags.limit, + query: flags.query, + }) + ); // Reverse to chronological order (API returns newest-first) const chronological = [...logs].reverse(); + const hasMore = chronological.length >= flags.limit; const emptyMessage = `No logs found for trace ${traceId} in the last ${flags.period}.\n\n` + @@ -187,7 +196,7 @@ export const logsCommand = buildCommand({ return yield new CommandOutput({ logs: chronological, traceId, - limit: flags.limit, + hasMore, emptyMessage, }); }, diff --git a/src/commands/trial/list.ts b/src/commands/trial/list.ts index c49b6e79e..c6944f5e0 100644 --- a/src/commands/trial/list.ts +++ b/src/commands/trial/list.ts @@ -13,6 +13,7 @@ import { ContextError } from "../../lib/errors.js"; import { colorTag } from "../../lib/formatters/markdown.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { type Column, writeTable } from "../../lib/formatters/table.js"; +import { withProgress } from "../../lib/polling.js"; import { resolveOrg } from "../../lib/resolve-target.js"; import { daysRemainingFromDate, @@ -220,7 +221,7 @@ export const listCommand = buildCommand({ ], }, }, - async *func(this: SentryContext, _flags: ListFlags, org?: string) { + async *func(this: SentryContext, flags: ListFlags, org?: string) { const resolved = await resolveOrg({ org, cwd: this.cwd, @@ -230,7 +231,10 @@ export const listCommand = buildCommand({ throw new ContextError("Organization", "sentry trial list "); } - const info = await getCustomerTrialInfo(resolved.org); + const info = await withProgress( + { message: "Fetching trials...", json: flags.json }, + () => getCustomerTrialInfo(resolved.org) + ); const productTrials = info.productTrials ?? []; const entries: TrialListEntry[] = deduplicateTrials( diff --git a/src/lib/hex-id.ts b/src/lib/hex-id.ts index 4d6d5fede..a0134a868 100644 --- a/src/lib/hex-id.ts +++ b/src/lib/hex-id.ts @@ -24,6 +24,24 @@ export const UUID_DASH_RE = /** Max display length for invalid IDs in error messages before truncation */ const MAX_DISPLAY_LENGTH = 40; +/** + * Normalize a potential hex ID: trim, lowercase, strip UUID dashes. + * Does NOT validate — call this before checking {@link HEX_ID_RE}. + * + * Extracted so that both {@link validateHexId} and non-throwing predicates + * (like `isTraceId`) share identical normalization logic. + * + * @param value - The raw string to normalize + * @returns The trimmed, lowercased string with UUID dashes stripped if applicable + */ +export function normalizeHexId(value: string): string { + let trimmed = value.trim().toLowerCase(); + if (UUID_DASH_RE.test(trimmed)) { + trimmed = trimmed.replace(/-/g, ""); + } + return trimmed; +} + /** * Validate that a string is a 32-character hexadecimal ID. * Trims whitespace and normalizes to lowercase before validation. @@ -44,25 +62,20 @@ const MAX_DISPLAY_LENGTH = 40; * @throws {ValidationError} If the format is invalid */ export function validateHexId(value: string, label: string): string { - let trimmed = value.trim().toLowerCase(); - - // Auto-correct UUID format: strip dashes (8-4-4-4-12 → 32 hex chars) - if (UUID_DASH_RE.test(trimmed)) { - trimmed = trimmed.replace(/-/g, ""); - } + const normalized = normalizeHexId(value); - if (!HEX_ID_RE.test(trimmed)) { + if (!HEX_ID_RE.test(normalized)) { const display = - trimmed.length > MAX_DISPLAY_LENGTH - ? `${trimmed.slice(0, MAX_DISPLAY_LENGTH - 3)}...` - : trimmed; + normalized.length > MAX_DISPLAY_LENGTH + ? `${normalized.slice(0, MAX_DISPLAY_LENGTH - 3)}...` + : normalized; throw new ValidationError( `Invalid ${label} "${display}". Expected a 32-character hexadecimal string.\n\n` + "Example: abc123def456abc123def456abc123de" ); } - return trimmed; + return normalized; } /** diff --git a/src/lib/org-list.ts b/src/lib/org-list.ts index 6f4bb0b75..6df4d99a6 100644 --- a/src/lib/org-list.ts +++ b/src/lib/org-list.ts @@ -50,6 +50,7 @@ import { } from "./errors.js"; import { filterFields } from "./formatters/json.js"; import { logger } from "./logger.js"; +import { withProgress } from "./polling.js"; import { resolveEffectiveOrg } from "./region.js"; import { resolveOrgsForListing } from "./resolve-target.js"; @@ -345,10 +346,17 @@ export async function handleOrgAll( ): Promise> { const { config, org, flags, contextKey, cursor } = options; - const response = await config.listPaginated(org, { - cursor, - perPage: flags.limit, - }); + const response = await withProgress( + { + message: `Fetching ${config.entityPlural} (up to ${flags.limit})...`, + json: flags.json, + }, + () => + config.listPaginated(org, { + cursor, + perPage: flags.limit, + }) + ); const { data: rawItems, nextCursor } = response; const items = rawItems.map((entity) => config.withOrg(entity, org)); @@ -402,15 +410,21 @@ export async function handleAutoDetect( skippedSelfHosted, } = await resolveOrgsForListing(undefined, cwd); - let allItems: TWithOrg[]; - if (orgsToFetch.length > 0) { - const results = await Promise.all( - orgsToFetch.map((org) => fetchOrgSafe(config, org)) - ); - allItems = results.flat(); - } else { - allItems = await fetchAllOrgs(config); - } + const allItems = await withProgress( + { + message: `Fetching ${config.entityPlural} (up to ${flags.limit})...`, + json: flags.json, + }, + async () => { + if (orgsToFetch.length > 0) { + const results = await Promise.all( + orgsToFetch.map((org) => fetchOrgSafe(config, org)) + ); + return results.flat(); + } + return fetchAllOrgs(config); + } + ); const limitCount = orgsToFetch.length > 1 ? flags.limit * orgsToFetch.length : flags.limit; @@ -527,7 +541,13 @@ export async function handleExplicitOrg( options: ExplicitOrgOptions ): Promise> { const { config, org, flags, noteOrgScoped = false } = options; - const items = await fetchOrgSafe(config, org); + const items = await withProgress( + { + message: `Fetching ${config.entityPlural} (up to ${flags.limit})...`, + json: flags.json, + }, + () => fetchOrgSafe(config, org) + ); const result = buildFetchedItemsResult({ config, @@ -576,7 +596,13 @@ export async function handleExplicitProject( "handleExplicitProject called but config.listForProject is not defined" ); } - const raw = await listForProject(org, project); + const raw = await withProgress( + { + message: `Fetching ${config.entityPlural} (up to ${flags.limit})...`, + json: flags.json, + }, + () => listForProject(org, project) + ); const items = raw.map((entity) => config.withOrg(entity, org)); const result = buildFetchedItemsResult({ @@ -622,7 +648,13 @@ export async function handleProjectSearch( } ): Promise> { const { flags, orgAllFallback } = options; - const { projects: matches, orgs } = await findProjectsBySlug(projectSlug); + const { projects: matches, orgs } = await withProgress( + { + message: `Fetching ${config.entityPlural} (up to ${flags.limit})...`, + json: flags.json, + }, + () => findProjectsBySlug(projectSlug) + ); if (matches.length === 0) { const matchingOrg = orgs.find((o) => o.slug === projectSlug); diff --git a/src/lib/polling.ts b/src/lib/polling.ts index f7348d5d8..4792d75a0 100644 --- a/src/lib/polling.ts +++ b/src/lib/polling.ts @@ -148,6 +148,9 @@ function startSpinner(initialMessage: string): { export type WithProgressOptions = { /** Initial spinner message */ message: string; + /** Suppress progress output (JSON mode). When true, the operation runs + * without a spinner — matching the behaviour of {@link poll}. */ + json?: boolean; }; /** @@ -157,6 +160,10 @@ export type WithProgressOptions = { * giving a consistent look across all CLI commands. Progress output goes * to stderr, so it never contaminates stdout (safe to use alongside JSON output). * + * When `options.json` is true the spinner is suppressed entirely, matching + * the behaviour of {@link poll}. This avoids noisy ANSI escape sequences on + * stderr when agents or CI pipelines consume `--json` output. + * * The callback receives a `setMessage` function to update the displayed * message as work progresses (e.g. to show page counts during pagination). * Progress is automatically cleared when the operation completes. @@ -182,6 +189,13 @@ export async function withProgress( options: WithProgressOptions, fn: (setMessage: (msg: string) => void) => Promise ): Promise { + if (options.json) { + // JSON mode: skip the spinner entirely, pass a no-op setMessage + return fn(() => { + /* spinner suppressed in JSON mode */ + }); + } + const spinner = startSpinner(options.message); try { diff --git a/src/lib/trace-id.ts b/src/lib/trace-id.ts index 71435f58b..5309b8050 100644 --- a/src/lib/trace-id.ts +++ b/src/lib/trace-id.ts @@ -2,10 +2,10 @@ * Trace ID Validation * * Re-exports shared hex ID validation specialized for trace IDs. - * Used by `trace logs` and `log list --trace` commands. + * Used by `trace logs`, `log list`, and `span list` commands. */ -import { HEX_ID_RE, validateHexId } from "./hex-id.js"; +import { HEX_ID_RE, normalizeHexId, validateHexId } from "./hex-id.js"; /** * Regex for a valid 32-character hexadecimal trace ID. @@ -13,6 +13,21 @@ import { HEX_ID_RE, validateHexId } from "./hex-id.js"; */ export const TRACE_ID_RE = HEX_ID_RE; +/** + * Non-throwing check: does the string look like a valid 32-char hex trace ID? + * + * Handles UUID-dash format (8-4-4-4-12) and whitespace trimming, matching + * the same normalization as {@link validateTraceId}. Use this when you need + * to disambiguate between a trace ID and another kind of identifier (e.g., + * a project slug) without throwing. + * + * @param value - The string to test + * @returns `true` if the value would pass {@link validateTraceId} + */ +export function isTraceId(value: string): boolean { + return HEX_ID_RE.test(normalizeHexId(value)); +} + /** * Validate that a string looks like a 32-character hex trace ID. * diff --git a/test/commands/log/list.test.ts b/test/commands/log/list.test.ts index 7038bf5a9..822f3e0cc 100644 --- a/test/commands/log/list.test.ts +++ b/test/commands/log/list.test.ts @@ -2,15 +2,20 @@ * Log List Command Tests * * Tests for the `sentry log list` command func() body, covering: - * - Standard project-scoped mode (no --trace) - * - Trace-filtered mode (--trace ) - * - Org resolution failure in trace mode + * - Standard project-scoped mode (positional org/project) + * - Trace-filtered mode (positional 32-char hex trace-id) + * - Positional argument disambiguation (trace vs project) + * - Period flag behavior * - Follow/streaming mode for both standard and trace modes * * Uses spyOn mocking to avoid real HTTP calls or database access. * Follow-mode tests use SIGINT to cleanly stop the setTimeout-based * poll loop — the promise resolves on SIGINT (normal termination). * AuthError tests verify that fetch failures reject the promise. + * + * Non-follow (single-fetch) tests mock `withProgress` from `polling.ts` + * to bypass the spinner. Follow-mode tests do NOT mock `withProgress` + * because follow mode uses its own streaming banner, not the spinner. */ import { @@ -29,8 +34,12 @@ import { AuthError, ContextError } from "../../../src/lib/errors.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as formatters from "../../../src/lib/formatters/index.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as polling from "../../../src/lib/polling.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as resolveTarget from "../../../src/lib/resolve-target.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as traceTarget from "../../../src/lib/trace-target.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as versionCheck from "../../../src/lib/version-check.js"; import type { SentryLog, TraceLog } from "../../../src/types/sentry.js"; @@ -112,6 +121,31 @@ function createMockContext() { }; } +/** No-op setMessage callback for withProgress mock */ +function noop() { + // no-op for test +} + +/** Passthrough mock for `withProgress` — bypasses spinner, calls fn directly */ +function mockWithProgress( + _opts: unknown, + fn: (setMessage: () => void) => unknown +) { + return fn(noop); +} + +/** Standard flags for non-follow batch mode (period omitted = use mode default) */ +const BATCH_FLAGS = { + json: true, + limit: 100, +} as const; + +/** Human-mode flags for non-follow batch mode (period omitted = use mode default) */ +const HUMAN_FLAGS = { + json: false, + limit: 100, +} as const; + /** Sample project-scoped logs (SentryLog) */ const sampleLogs: SentryLog[] = [ { @@ -201,35 +235,42 @@ const newerLogs: SentryLog[] = [ ]; // ============================================================================ -// Standard mode (no --trace) +// Standard mode (project-scoped, no trace-id positional) // ============================================================================ describe("listCommand.func — standard mode", () => { let listLogsSpy: ReturnType; let resolveOrgProjectSpy: ReturnType; + let withProgressSpy: ReturnType; beforeEach(() => { listLogsSpy = spyOn(apiClient, "listLogs"); resolveOrgProjectSpy = spyOn(resolveTarget, "resolveOrgProjectFromArg"); + withProgressSpy = spyOn(polling, "withProgress").mockImplementation( + mockWithProgress + ); }); afterEach(() => { listLogsSpy.mockRestore(); resolveOrgProjectSpy.mockRestore(); + withProgressSpy.mockRestore(); }); - test("outputs JSON array for --json", async () => { + test("outputs JSON envelope with data and hasMore for --json", async () => { listLogsSpy.mockResolvedValue(sampleLogs); resolveOrgProjectSpy.mockResolvedValue({ org: ORG, project: PROJECT }); const { context, stdoutWrite } = createMockContext(); const func = await listCommand.loader(); - await func.call(context, { json: true, limit: 100 }, `${ORG}/${PROJECT}`); + await func.call(context, BATCH_FLAGS, `${ORG}/${PROJECT}`); const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); const parsed = JSON.parse(output); - expect(Array.isArray(parsed)).toBe(true); - expect(parsed).toHaveLength(3); + expect(parsed).toHaveProperty("data"); + expect(parsed).toHaveProperty("hasMore"); + expect(Array.isArray(parsed.data)).toBe(true); + expect(parsed.data).toHaveLength(3); }); test("outputs JSON in chronological order (oldest first)", async () => { @@ -240,13 +281,13 @@ describe("listCommand.func — standard mode", () => { const { context, stdoutWrite } = createMockContext(); const func = await listCommand.loader(); - await func.call(context, { json: true, limit: 100 }, `${ORG}/${PROJECT}`); + await func.call(context, BATCH_FLAGS, `${ORG}/${PROJECT}`); const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); const parsed = JSON.parse(output); // After reversal, oldest should be first - expect(parsed[0]["sentry.item_id"]).toBe("item001"); - expect(parsed[2]["sentry.item_id"]).toBe("item003"); + expect(parsed.data[0]["sentry.item_id"]).toBe("item001"); + expect(parsed.data[2]["sentry.item_id"]).toBe("item003"); }); test("shows 'No logs found' for empty result (human mode)", async () => { @@ -255,7 +296,7 @@ describe("listCommand.func — standard mode", () => { const { context, stdoutWrite } = createMockContext(); const func = await listCommand.loader(); - await func.call(context, { json: false, limit: 100 }, `${ORG}/${PROJECT}`); + await func.call(context, HUMAN_FLAGS, `${ORG}/${PROJECT}`); const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); expect(output).toContain("No logs found"); @@ -267,7 +308,7 @@ describe("listCommand.func — standard mode", () => { const { context, stdoutWrite } = createMockContext(); const func = await listCommand.loader(); - await func.call(context, { json: false, limit: 100 }, `${ORG}/${PROJECT}`); + await func.call(context, HUMAN_FLAGS, `${ORG}/${PROJECT}`); const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); expect(output).toContain("Request received"); @@ -281,7 +322,7 @@ describe("listCommand.func — standard mode", () => { const { context, stdoutWrite } = createMockContext(); const func = await listCommand.loader(); - await func.call(context, { json: false, limit: 100 }, `${ORG}/${PROJECT}`); + await func.call(context, HUMAN_FLAGS, `${ORG}/${PROJECT}`); const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); expect(output).toContain("Showing 3 log"); @@ -306,13 +347,13 @@ describe("listCommand.func — standard mode", () => { const { context, stdoutWrite } = createMockContext(); const func = await listCommand.loader(); - await func.call(context, { json: false, limit: 100 }, `${ORG}/${PROJECT}`); + await func.call(context, HUMAN_FLAGS, `${ORG}/${PROJECT}`); const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); expect(output).not.toContain("Use --limit to show more"); }); - test("passes query and limit to listLogs", async () => { + test("passes query, limit, and period to listLogs", async () => { listLogsSpy.mockResolvedValue([]); resolveOrgProjectSpy.mockResolvedValue({ org: ORG, project: PROJECT }); @@ -337,66 +378,105 @@ describe("listCommand.func — standard mode", () => { const { context } = createMockContext(); const func = await listCommand.loader(); - await func.call(context, { json: true, limit: 100 }, `${ORG}/${PROJECT}`); + await func.call(context, BATCH_FLAGS, `${ORG}/${PROJECT}`); expect(context.setContext).toHaveBeenCalledWith([ORG], [PROJECT]); }); + + test("hasMore is true when results match limit", async () => { + listLogsSpy.mockResolvedValue(sampleLogs); + resolveOrgProjectSpy.mockResolvedValue({ org: ORG, project: PROJECT }); + + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, { json: true, limit: 3 }, `${ORG}/${PROJECT}`); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const parsed = JSON.parse(output); + expect(parsed.hasMore).toBe(true); + }); + + test("hasMore is false when fewer results than limit", async () => { + listLogsSpy.mockResolvedValue(sampleLogs); + resolveOrgProjectSpy.mockResolvedValue({ org: ORG, project: PROJECT }); + + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, BATCH_FLAGS, `${ORG}/${PROJECT}`); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const parsed = JSON.parse(output); + expect(parsed.hasMore).toBe(false); + }); }); // ============================================================================ -// Trace mode (--trace) +// Trace mode (positional trace-id) // ============================================================================ describe("listCommand.func — trace mode", () => { let listTraceLogsSpy: ReturnType; - let resolveOrgSpy: ReturnType; + let resolveTraceOrgSpy: ReturnType; + let warnIfNormalizedSpy: ReturnType; + let withProgressSpy: ReturnType; beforeEach(() => { listTraceLogsSpy = spyOn(apiClient, "listTraceLogs"); - resolveOrgSpy = spyOn(resolveTarget, "resolveOrg"); + resolveTraceOrgSpy = spyOn(traceTarget, "resolveTraceOrg"); + warnIfNormalizedSpy = spyOn( + traceTarget, + "warnIfNormalized" + ).mockReturnValue(undefined); + withProgressSpy = spyOn(polling, "withProgress").mockImplementation( + mockWithProgress + ); }); afterEach(() => { listTraceLogsSpy.mockRestore(); - resolveOrgSpy.mockRestore(); + resolveTraceOrgSpy.mockRestore(); + warnIfNormalizedSpy.mockRestore(); + withProgressSpy.mockRestore(); }); - test("outputs JSON array for --json --trace", async () => { + test("outputs JSON envelope with data and hasMore for --json", async () => { listTraceLogsSpy.mockResolvedValue(sampleTraceLogs); - resolveOrgSpy.mockResolvedValue({ org: ORG }); + resolveTraceOrgSpy.mockResolvedValue({ traceId: TRACE_ID, org: ORG }); const { context, stdoutWrite } = createMockContext(); const func = await listCommand.loader(); - await func.call(context, { json: true, limit: 100, trace: TRACE_ID }); + await func.call(context, BATCH_FLAGS, TRACE_ID); const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); const parsed = JSON.parse(output); - expect(Array.isArray(parsed)).toBe(true); - expect(parsed).toHaveLength(3); + expect(parsed).toHaveProperty("data"); + expect(parsed).toHaveProperty("hasMore"); + expect(Array.isArray(parsed.data)).toBe(true); + expect(parsed.data).toHaveLength(3); }); test("outputs JSON in chronological order (oldest first)", async () => { const newestFirst = [...sampleTraceLogs].reverse(); listTraceLogsSpy.mockResolvedValue(newestFirst); - resolveOrgSpy.mockResolvedValue({ org: ORG }); + resolveTraceOrgSpy.mockResolvedValue({ traceId: TRACE_ID, org: ORG }); const { context, stdoutWrite } = createMockContext(); const func = await listCommand.loader(); - await func.call(context, { json: true, limit: 100, trace: TRACE_ID }); + await func.call(context, BATCH_FLAGS, TRACE_ID); const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); const parsed = JSON.parse(output); - expect(parsed[0].id).toBe("log001"); - expect(parsed[2].id).toBe("log003"); + expect(parsed.data[0].id).toBe("log001"); + expect(parsed.data[2].id).toBe("log003"); }); test("shows empty-trace message in human mode", async () => { listTraceLogsSpy.mockResolvedValue([]); - resolveOrgSpy.mockResolvedValue({ org: ORG }); + resolveTraceOrgSpy.mockResolvedValue({ traceId: TRACE_ID, org: ORG }); const { context, stdoutWrite } = createMockContext(); const func = await listCommand.loader(); - await func.call(context, { json: false, limit: 100, trace: TRACE_ID }); + await func.call(context, HUMAN_FLAGS, TRACE_ID); const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); expect(output).toContain("No logs found"); @@ -405,11 +485,11 @@ describe("listCommand.func — trace mode", () => { test("renders trace log messages in human output", async () => { listTraceLogsSpy.mockResolvedValue(sampleTraceLogs); - resolveOrgSpy.mockResolvedValue({ org: ORG }); + resolveTraceOrgSpy.mockResolvedValue({ traceId: TRACE_ID, org: ORG }); const { context, stdoutWrite } = createMockContext(); const func = await listCommand.loader(); - await func.call(context, { json: false, limit: 100, trace: TRACE_ID }); + await func.call(context, HUMAN_FLAGS, TRACE_ID); const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); expect(output).toContain("Request received"); @@ -419,11 +499,11 @@ describe("listCommand.func — trace mode", () => { test("shows count footer with trace ID", async () => { listTraceLogsSpy.mockResolvedValue(sampleTraceLogs); - resolveOrgSpy.mockResolvedValue({ org: ORG }); + resolveTraceOrgSpy.mockResolvedValue({ traceId: TRACE_ID, org: ORG }); const { context, stdoutWrite } = createMockContext(); const func = await listCommand.loader(); - await func.call(context, { json: false, limit: 100, trace: TRACE_ID }); + await func.call(context, HUMAN_FLAGS, TRACE_ID); const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); expect(output).toContain("Showing 3 log"); @@ -432,28 +512,27 @@ describe("listCommand.func — trace mode", () => { test("shows --limit tip when trace results match limit", async () => { listTraceLogsSpy.mockResolvedValue(sampleTraceLogs); - resolveOrgSpy.mockResolvedValue({ org: ORG }); + resolveTraceOrgSpy.mockResolvedValue({ traceId: TRACE_ID, org: ORG }); const { context, stdoutWrite } = createMockContext(); const func = await listCommand.loader(); - await func.call(context, { json: false, limit: 3, trace: TRACE_ID }); + await func.call(context, { json: false, limit: 3 }, TRACE_ID); const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); expect(output).toContain("Use --limit to show more."); }); - test("passes traceId, limit, and query to listTraceLogs", async () => { + test("passes traceId, limit, and query to listTraceLogs with 14d default", async () => { listTraceLogsSpy.mockResolvedValue([]); - resolveOrgSpy.mockResolvedValue({ org: ORG }); + resolveTraceOrgSpy.mockResolvedValue({ traceId: TRACE_ID, org: ORG }); const { context } = createMockContext(); const func = await listCommand.loader(); - await func.call(context, { - json: false, - limit: 50, - trace: TRACE_ID, - query: "level:error", - }); + await func.call( + context, + { json: false, limit: 50, query: "level:error" }, + TRACE_ID + ); expect(listTraceLogsSpy).toHaveBeenCalledWith(ORG, TRACE_ID, { query: "level:error", @@ -464,72 +543,230 @@ describe("listCommand.func — trace mode", () => { test("calls setContext with org and empty project array", async () => { listTraceLogsSpy.mockResolvedValue([]); - resolveOrgSpy.mockResolvedValue({ org: ORG }); + resolveTraceOrgSpy.mockResolvedValue({ traceId: TRACE_ID, org: ORG }); const { context } = createMockContext(); const func = await listCommand.loader(); - await func.call(context, { json: true, limit: 100, trace: TRACE_ID }); + await func.call(context, BATCH_FLAGS, TRACE_ID); expect(context.setContext).toHaveBeenCalledWith([ORG], []); }); - test("uses positional target as org slug in trace mode", async () => { + test("uses positional org/trace-id to resolve trace org", async () => { listTraceLogsSpy.mockResolvedValue([]); - resolveOrgSpy.mockResolvedValue({ org: "my-org" }); + resolveTraceOrgSpy.mockResolvedValue({ traceId: TRACE_ID, org: "my-org" }); + const { context } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, BATCH_FLAGS, `my-org/${TRACE_ID}`); + + // resolveTraceOrg receives the parsed ParsedTraceTarget + expect(resolveTraceOrgSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: "org-scoped", + org: "my-org", + traceId: TRACE_ID, + }), + "/tmp", + expect.any(String) + ); + }); +}); + +// ============================================================================ +// Positional argument disambiguation +// ============================================================================ + +describe("listCommand.func — positional disambiguation", () => { + let listLogsSpy: ReturnType; + let listTraceLogsSpy: ReturnType; + let resolveOrgProjectSpy: ReturnType; + let resolveTraceOrgSpy: ReturnType; + let warnIfNormalizedSpy: ReturnType; + let withProgressSpy: ReturnType; + + beforeEach(() => { + listLogsSpy = spyOn(apiClient, "listLogs").mockResolvedValue([]); + listTraceLogsSpy = spyOn(apiClient, "listTraceLogs").mockResolvedValue([]); + resolveOrgProjectSpy = spyOn( + resolveTarget, + "resolveOrgProjectFromArg" + ).mockResolvedValue({ org: ORG, project: PROJECT }); + resolveTraceOrgSpy = spyOn( + traceTarget, + "resolveTraceOrg" + ).mockResolvedValue({ + traceId: TRACE_ID, + org: ORG, + }); + warnIfNormalizedSpy = spyOn( + traceTarget, + "warnIfNormalized" + ).mockReturnValue(undefined); + withProgressSpy = spyOn(polling, "withProgress").mockImplementation( + mockWithProgress + ); + }); + + afterEach(() => { + listLogsSpy.mockRestore(); + listTraceLogsSpy.mockRestore(); + resolveOrgProjectSpy.mockRestore(); + resolveTraceOrgSpy.mockRestore(); + warnIfNormalizedSpy.mockRestore(); + withProgressSpy.mockRestore(); + }); + + test("32-char hex string triggers trace mode", async () => { + const { context } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, BATCH_FLAGS, TRACE_ID); + + expect(listTraceLogsSpy).toHaveBeenCalled(); + expect(listLogsSpy).not.toHaveBeenCalled(); + }); + + test("non-hex string triggers project mode", async () => { + const { context } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, BATCH_FLAGS, `${ORG}/${PROJECT}`); + + expect(listLogsSpy).toHaveBeenCalled(); + expect(listTraceLogsSpy).not.toHaveBeenCalled(); + }); + + test("org/trace-id triggers trace mode", async () => { + const { context } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, BATCH_FLAGS, `${ORG}/${TRACE_ID}`); + + expect(listTraceLogsSpy).toHaveBeenCalled(); + expect(listLogsSpy).not.toHaveBeenCalled(); + }); + + test("org/project (non-hex) triggers project mode", async () => { + const { context } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, BATCH_FLAGS, "my-org/my-project"); + + expect(listLogsSpy).toHaveBeenCalled(); + expect(listTraceLogsSpy).not.toHaveBeenCalled(); + }); +}); + +// ============================================================================ +// Period flag behavior +// ============================================================================ + +describe("listCommand.func — period flag", () => { + let listTraceLogsSpy: ReturnType; + let resolveTraceOrgSpy: ReturnType; + let warnIfNormalizedSpy: ReturnType; + let withProgressSpy: ReturnType; + + beforeEach(() => { + listTraceLogsSpy = spyOn(apiClient, "listTraceLogs").mockResolvedValue([]); + resolveTraceOrgSpy = spyOn( + traceTarget, + "resolveTraceOrg" + ).mockResolvedValue({ + traceId: TRACE_ID, + org: ORG, + }); + warnIfNormalizedSpy = spyOn( + traceTarget, + "warnIfNormalized" + ).mockReturnValue(undefined); + withProgressSpy = spyOn(polling, "withProgress").mockImplementation( + mockWithProgress + ); + }); + + afterEach(() => { + listTraceLogsSpy.mockRestore(); + resolveTraceOrgSpy.mockRestore(); + warnIfNormalizedSpy.mockRestore(); + withProgressSpy.mockRestore(); + }); + + test("trace mode uses 14d default when period is omitted", async () => { + const { context } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, { json: true, limit: 100 }, TRACE_ID); + + expect(listTraceLogsSpy).toHaveBeenCalledWith(ORG, TRACE_ID, { + query: undefined, + limit: 100, + statsPeriod: "14d", + }); + }); + + test("trace mode uses explicit period when set to non-default", async () => { const { context } = createMockContext(); const func = await listCommand.loader(); await func.call( context, - { json: true, limit: 100, trace: TRACE_ID }, - "my-org" + { json: true, limit: 100, period: "30d" }, + TRACE_ID ); - expect(resolveOrgSpy).toHaveBeenCalledWith({ - org: "my-org", - cwd: "/tmp", + expect(listTraceLogsSpy).toHaveBeenCalledWith(ORG, TRACE_ID, { + query: undefined, + limit: 100, + statsPeriod: "30d", }); }); }); // ============================================================================ -// Org resolution failure in trace mode +// Trace mode org resolution failure // ============================================================================ describe("listCommand.func — trace mode org resolution failure", () => { - let resolveOrgSpy: ReturnType; + let resolveTraceOrgSpy: ReturnType; + let warnIfNormalizedSpy: ReturnType; + let withProgressSpy: ReturnType; beforeEach(() => { - resolveOrgSpy = spyOn(resolveTarget, "resolveOrg"); + resolveTraceOrgSpy = spyOn(traceTarget, "resolveTraceOrg"); + warnIfNormalizedSpy = spyOn( + traceTarget, + "warnIfNormalized" + ).mockReturnValue(undefined); + withProgressSpy = spyOn(polling, "withProgress").mockImplementation( + mockWithProgress + ); }); afterEach(() => { - resolveOrgSpy.mockRestore(); + resolveTraceOrgSpy.mockRestore(); + warnIfNormalizedSpy.mockRestore(); + withProgressSpy.mockRestore(); }); test("throws ContextError when org cannot be resolved", async () => { - resolveOrgSpy.mockResolvedValue(null); + resolveTraceOrgSpy.mockRejectedValue( + new ContextError("Organization", "sentry log list [/]") + ); const { context } = createMockContext(); const func = await listCommand.loader(); - await expect( - func.call(context, { json: false, limit: 100, trace: TRACE_ID }) - ).rejects.toThrow(ContextError); + await expect(func.call(context, HUMAN_FLAGS, TRACE_ID)).rejects.toThrow( + ContextError + ); }); test("ContextError mentions Organization", async () => { - resolveOrgSpy.mockResolvedValue(null); + resolveTraceOrgSpy.mockRejectedValue( + new ContextError("Organization", "sentry log list [/]") + ); const { context } = createMockContext(); const func = await listCommand.loader(); try { - await func.call(context, { - json: false, - limit: 100, - trace: TRACE_ID, - }); + await func.call(context, HUMAN_FLAGS, TRACE_ID); expect.unreachable("Should have thrown"); } catch (error) { expect(error).toBeInstanceOf(ContextError); @@ -539,12 +776,15 @@ describe("listCommand.func — trace mode org resolution failure", () => { }); // ============================================================================ -// Follow mode — standard (--follow, no --trace) +// Follow mode — standard (--follow, project-scoped) // // Strategy: SIGINT resolves the promise (normal termination). AuthError // from fetch rejects the promise. Tests use interceptSigint() to capture // the SIGINT handler and invoke it directly (process.emit("SIGINT") // kills the Bun test runner). +// +// Follow mode does NOT use withProgress (it has its own streaming banner), +// so withProgress is NOT mocked here. // ============================================================================ /** @@ -851,12 +1091,13 @@ describe("listCommand.func — follow mode (standard)", () => { }); // ============================================================================ -// Follow mode — trace (--follow + --trace) +// Follow mode — trace (--follow + positional trace-id) // ============================================================================ describe("listCommand.func — follow mode (trace)", () => { let listTraceLogsSpy: ReturnType; - let resolveOrgSpy: ReturnType; + let resolveTraceOrgSpy: ReturnType; + let warnIfNormalizedSpy: ReturnType; let isPlainSpy: ReturnType; let updateNotifSpy: ReturnType; let sigint: ReturnType; @@ -865,7 +1106,11 @@ describe("listCommand.func — follow mode (trace)", () => { beforeEach(() => { sigint = interceptSigint(); listTraceLogsSpy = spyOn(apiClient, "listTraceLogs"); - resolveOrgSpy = spyOn(resolveTarget, "resolveOrg"); + resolveTraceOrgSpy = spyOn(traceTarget, "resolveTraceOrg"); + warnIfNormalizedSpy = spyOn( + traceTarget, + "warnIfNormalized" + ).mockReturnValue(undefined); isPlainSpy = spyOn(formatters, "isPlainOutput").mockReturnValue(true); updateNotifSpy = spyOn( versionCheck, @@ -876,7 +1121,8 @@ describe("listCommand.func — follow mode (trace)", () => { afterEach(() => { listTraceLogsSpy.mockRestore(); - resolveOrgSpy.mockRestore(); + resolveTraceOrgSpy.mockRestore(); + warnIfNormalizedSpy.mockRestore(); isPlainSpy.mockRestore(); updateNotifSpy.mockRestore(); stderrSpy.mockRestore(); @@ -887,17 +1133,16 @@ describe("listCommand.func — follow mode (trace)", () => { json: false, limit: 100, follow: 1, - trace: TRACE_ID, } as const; test("writes initial trace logs then resolves on SIGINT", async () => { listTraceLogsSpy.mockResolvedValueOnce(sampleTraceLogs); - resolveOrgSpy.mockResolvedValue({ org: ORG }); + resolveTraceOrgSpy.mockResolvedValue({ traceId: TRACE_ID, org: ORG }); const { context, stdoutWrite } = createMockContext(); const func = await listCommand.loader(); - const promise = func.call(context, traceFollowFlags); + const promise = func.call(context, traceFollowFlags, TRACE_ID); await Bun.sleep(50); sigint.trigger(); await promise; @@ -908,12 +1153,12 @@ describe("listCommand.func — follow mode (trace)", () => { test("writes stderr banner with trace ID in follow mode", async () => { listTraceLogsSpy.mockResolvedValueOnce([]); - resolveOrgSpy.mockResolvedValue({ org: ORG }); + resolveTraceOrgSpy.mockResolvedValue({ traceId: TRACE_ID, org: ORG }); const { context } = createMockContext(); const func = await listCommand.loader(); - const promise = func.call(context, traceFollowFlags); + const promise = func.call(context, traceFollowFlags, TRACE_ID); await Bun.sleep(50); sigint.trigger(); await promise; @@ -934,12 +1179,12 @@ describe("listCommand.func — follow mode (trace)", () => { listTraceLogsSpy .mockResolvedValueOnce(sampleTraceLogs) .mockResolvedValueOnce(mixedLogs); - resolveOrgSpy.mockResolvedValue({ org: ORG }); + resolveTraceOrgSpy.mockResolvedValue({ traceId: TRACE_ID, org: ORG }); const { context, stdoutWrite } = createMockContext(); const func = await listCommand.loader(); - const promise = func.call(context, traceFollowFlags); + const promise = func.call(context, traceFollowFlags, TRACE_ID); // Wait for initial fetch + poll timer (1s) + poll execution await Bun.sleep(1200); sigint.trigger(); @@ -956,12 +1201,16 @@ describe("listCommand.func — follow mode (trace)", () => { listTraceLogsSpy .mockResolvedValueOnce(sampleTraceLogs) .mockResolvedValueOnce(newerTraceLogs); - resolveOrgSpy.mockResolvedValue({ org: ORG }); + resolveTraceOrgSpy.mockResolvedValue({ traceId: TRACE_ID, org: ORG }); const { context, stdoutWrite } = createMockContext(); const func = await listCommand.loader(); - const promise = func.call(context, { ...traceFollowFlags, json: true }); + const promise = func.call( + context, + { ...traceFollowFlags, json: true }, + TRACE_ID + ); // Wait for initial fetch + poll timer (1s) + poll execution await Bun.sleep(1200); sigint.trigger(); @@ -976,16 +1225,16 @@ describe("listCommand.func — follow mode (trace)", () => { return false; } }); - // First batch: 1 JSON line (array of 3 items from LogListResult) + // First batch: 1 JSON line (envelope with data array from LogListResult) // Poll batch: 1 JSON line per item (bare JSONL) expect(jsonLines.length).toBe(2); - // First line is an array (the initial trace batch) + // First line is an envelope with data array (the initial trace batch) const firstBatch = JSON.parse(jsonLines[0]); - expect(Array.isArray(firstBatch)).toBe(true); - expect(firstBatch).toHaveLength(3); + expect(firstBatch).toHaveProperty("data"); + expect(Array.isArray(firstBatch.data)).toBe(true); + expect(firstBatch.data).toHaveLength(3); // Second line is a bare object (polled item) const pollItem = JSON.parse(jsonLines[1]); - expect(Array.isArray(pollItem)).toBe(false); expect(pollItem.message).toBe("New poll result"); }); @@ -993,26 +1242,26 @@ describe("listCommand.func — follow mode (trace)", () => { listTraceLogsSpy .mockResolvedValueOnce([]) .mockRejectedValueOnce(new AuthError("expired")); - resolveOrgSpy.mockResolvedValue({ org: ORG }); + resolveTraceOrgSpy.mockResolvedValue({ traceId: TRACE_ID, org: ORG }); const { context } = createMockContext(); const func = await listCommand.loader(); - await expect(func.call(context, traceFollowFlags)).rejects.toThrow( - AuthError - ); + await expect( + func.call(context, traceFollowFlags, TRACE_ID) + ).rejects.toThrow(AuthError); }); test("continues polling after transient error (trace mode)", async () => { listTraceLogsSpy .mockResolvedValueOnce([]) .mockRejectedValueOnce(new Error("server error")); - resolveOrgSpy.mockResolvedValue({ org: ORG }); + resolveTraceOrgSpy.mockResolvedValue({ traceId: TRACE_ID, org: ORG }); const { context } = createMockContext(); const func = await listCommand.loader(); - const promise = func.call(context, traceFollowFlags); + const promise = func.call(context, traceFollowFlags, TRACE_ID); // Wait for initial fetch + poll timer (1s) + poll execution await Bun.sleep(1200); sigint.trigger(); @@ -1026,12 +1275,12 @@ describe("listCommand.func — follow mode (trace)", () => { test("uses 1m statsPeriod for initial trace follow fetch", async () => { listTraceLogsSpy.mockResolvedValueOnce([]); - resolveOrgSpy.mockResolvedValue({ org: ORG }); + resolveTraceOrgSpy.mockResolvedValue({ traceId: TRACE_ID, org: ORG }); const { context } = createMockContext(); const func = await listCommand.loader(); - const promise = func.call(context, traceFollowFlags); + const promise = func.call(context, traceFollowFlags, TRACE_ID); await Bun.sleep(50); sigint.trigger(); await promise; @@ -1047,12 +1296,12 @@ describe("listCommand.func — follow mode (trace)", () => { listTraceLogsSpy .mockResolvedValueOnce(sampleTraceLogs) .mockResolvedValueOnce([]); - resolveOrgSpy.mockResolvedValue({ org: ORG }); + resolveTraceOrgSpy.mockResolvedValue({ traceId: TRACE_ID, org: ORG }); const { context } = createMockContext(); const func = await listCommand.loader(); - const promise = func.call(context, traceFollowFlags); + const promise = func.call(context, traceFollowFlags, TRACE_ID); // Wait for initial fetch + poll timer (1s) + poll execution await Bun.sleep(1200); sigint.trigger(); @@ -1071,12 +1320,12 @@ describe("listCommand.func — follow mode (trace)", () => { listTraceLogsSpy .mockResolvedValueOnce([]) .mockResolvedValueOnce(newerTraceLogs); - resolveOrgSpy.mockResolvedValue({ org: ORG }); + resolveTraceOrgSpy.mockResolvedValue({ traceId: TRACE_ID, org: ORG }); const { context, stdoutWrite } = createMockContext(); const func = await listCommand.loader(); - const promise = func.call(context, traceFollowFlags); + const promise = func.call(context, traceFollowFlags, TRACE_ID); // Wait for initial fetch + poll timer (1s) + poll execution await Bun.sleep(1200); sigint.trigger(); diff --git a/test/commands/trace/logs.test.ts b/test/commands/trace/logs.test.ts index 31a54520b..d39146996 100644 --- a/test/commands/trace/logs.test.ts +++ b/test/commands/trace/logs.test.ts @@ -25,6 +25,8 @@ import { logsCommand } from "../../../src/commands/trace/logs.js"; import * as apiClient from "../../../src/lib/api-client.js"; import { ContextError } from "../../../src/lib/errors.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as polling from "../../../src/lib/polling.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as resolveTarget from "../../../src/lib/resolve-target.js"; import type { TraceLog } from "../../../src/types/sentry.js"; @@ -103,19 +105,28 @@ function collectMockOutput( describe("logsCommand.func", () => { let listTraceLogsSpy: ReturnType; let resolveOrgSpy: ReturnType; + let withProgressSpy: ReturnType; beforeEach(() => { listTraceLogsSpy = spyOn(apiClient, "listTraceLogs"); resolveOrgSpy = spyOn(resolveTarget, "resolveOrg"); + // Bypass the withProgress spinner to prevent real stderr timers + withProgressSpy = spyOn(polling, "withProgress").mockImplementation( + (_opts, fn) => + fn(() => { + /* no-op setMessage */ + }) + ); }); afterEach(() => { listTraceLogsSpy.mockRestore(); resolveOrgSpy.mockRestore(); + withProgressSpy.mockRestore(); }); describe("JSON output mode", () => { - test("outputs JSON array when --json flag is set", async () => { + test("outputs JSON envelope when --json flag is set", async () => { listTraceLogsSpy.mockResolvedValue(sampleLogs); resolveOrgSpy.mockResolvedValue({ org: ORG }); @@ -129,13 +140,16 @@ describe("logsCommand.func", () => { const output = collectMockOutput(stdoutWrite); const parsed = JSON.parse(output); - expect(Array.isArray(parsed)).toBe(true); - expect(parsed).toHaveLength(3); + expect(parsed).toHaveProperty("data"); + expect(parsed).toHaveProperty("hasMore"); + expect(Array.isArray(parsed.data)).toBe(true); + expect(parsed.data).toHaveLength(3); // formatTraceLogs reverses to chronological order for JSON output - expect(parsed[0].id).toBe("log003"); + expect(parsed.data[0].id).toBe("log003"); + expect(parsed.hasMore).toBe(false); }); - test("outputs empty JSON array when no logs found with --json", async () => { + test("outputs empty JSON envelope when no logs found with --json", async () => { listTraceLogsSpy.mockResolvedValue([]); resolveOrgSpy.mockResolvedValue({ org: ORG }); @@ -148,7 +162,8 @@ describe("logsCommand.func", () => { ); const output = collectMockOutput(stdoutWrite); - expect(JSON.parse(output)).toEqual([]); + const parsed = JSON.parse(output); + expect(parsed).toEqual({ data: [], hasMore: false }); }); }); diff --git a/test/e2e/log.test.ts b/test/e2e/log.test.ts index fb3d8745e..05081d9a5 100644 --- a/test/e2e/log.test.ts +++ b/test/e2e/log.test.ts @@ -82,9 +82,11 @@ describe("sentry log list", () => { ]); expect(result.exitCode).toBe(0); - // Should be valid JSON array - const data = JSON.parse(result.stdout); - expect(Array.isArray(data)).toBe(true); + // Should be valid JSON envelope with data array and hasMore boolean + const parsed = JSON.parse(result.stdout); + expect(parsed).toHaveProperty("data"); + expect(parsed).toHaveProperty("hasMore"); + expect(Array.isArray(parsed.data)).toBe(true); }); test("supports --limit flag", async () => { @@ -144,16 +146,14 @@ describe("sentry log list", () => { }); }); -describe("sentry log list --trace", () => { - test("filters logs by trace ID", async () => { +describe("sentry log list (trace mode)", () => { + test("filters logs by trace ID (positional)", async () => { await ctx.setAuthToken(TEST_TOKEN); const result = await ctx.run([ "log", "list", - TEST_ORG, - "--trace", - TEST_TRACE_ID, + `${TEST_ORG}/${TEST_TRACE_ID}`, ]); expect(result.exitCode).toBe(0); @@ -161,40 +161,22 @@ describe("sentry log list --trace", () => { expect(result.stdout).toContain("Trace log message"); }); - test("supports --json with --trace", async () => { + test("supports --json with trace ID positional", async () => { await ctx.setAuthToken(TEST_TOKEN); const result = await ctx.run([ "log", "list", - TEST_ORG, - "--trace", - TEST_TRACE_ID, + `${TEST_ORG}/${TEST_TRACE_ID}`, "--json", ]); expect(result.exitCode).toBe(0); - const data = JSON.parse(result.stdout); - expect(Array.isArray(data)).toBe(true); - expect(data.length).toBe(2); - }); - - test("validates trace ID format", async () => { - await ctx.setAuthToken(TEST_TOKEN); - - const result = await ctx.run([ - "log", - "list", - TEST_ORG, - "--trace", - "not-a-valid-trace-id", - ]); - - // Stricli uses exit code 252 for parse errors - expect(result.exitCode).not.toBe(0); - expect(result.stderr + result.stdout).toMatch( - /invalid trace id|32-character hex/i - ); + const parsed = JSON.parse(result.stdout); + expect(parsed).toHaveProperty("data"); + expect(parsed).toHaveProperty("hasMore"); + expect(Array.isArray(parsed.data)).toBe(true); + expect(parsed.data.length).toBe(2); }); test("shows empty state for unknown trace", async () => { @@ -203,21 +185,19 @@ describe("sentry log list --trace", () => { const result = await ctx.run([ "log", "list", - TEST_ORG, - "--trace", - "00000000000000000000000000000000", + `${TEST_ORG}/00000000000000000000000000000000`, ]); expect(result.exitCode).toBe(0); expect(result.stdout).toMatch(/no logs found/i); }); - test("shows --trace in help output", async () => { + test("help shows trace-id as positional argument", async () => { const result = await ctx.run(["log", "list", "--help"]); expect(result.exitCode).toBe(0); - expect(result.stdout).toMatch(/--trace/); - expect(result.stdout).toMatch(/trace id/i); + expect(result.stdout).toMatch(/trace-id/i); + expect(result.stdout).toMatch(/trace filtering/i); }); }); diff --git a/test/lib/org-list.test.ts b/test/lib/org-list.test.ts index abd43a48e..08f24e518 100644 --- a/test/lib/org-list.test.ts +++ b/test/lib/org-list.test.ts @@ -40,8 +40,29 @@ import { type OrgListConfig, } from "../../src/lib/org-list.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as polling from "../../src/lib/polling.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as region from "../../src/lib/region.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as resolveTarget from "../../src/lib/resolve-target.js"; +/** + * Bypass the withProgress spinner in all tests — prevents real stderr + * timers from piling up during full-suite runs and causing 5s timeouts. + */ +let withProgressSpy: ReturnType; +beforeEach(() => { + withProgressSpy = spyOn(polling, "withProgress").mockImplementation( + (_opts, fn) => + fn(() => { + /* no-op setMessage */ + }) + ); +}); +afterEach(() => { + withProgressSpy.mockRestore(); +}); + type FakeEntity = { id: string; name: string }; type FakeWithOrg = FakeEntity & { orgSlug: string }; @@ -625,12 +646,19 @@ describe("dispatchOrgScopedList", () => { let resolveAllTargetsSpy: ReturnType; let setPaginationCursorSpy: ReturnType; let clearPaginationCursorSpy: ReturnType; + let resolveEffectiveOrgSpy: ReturnType; beforeEach(() => { getDefaultOrganizationSpy = spyOn(defaults, "getDefaultOrganization"); resolveAllTargetsSpy = spyOn(resolveTarget, "resolveAllTargets"); setPaginationCursorSpy = spyOn(paginationDb, "setPaginationCursor"); clearPaginationCursorSpy = spyOn(paginationDb, "clearPaginationCursor"); + // Prevent resolveEffectiveOrg from making real HTTP calls during + // full-suite runs where earlier tests may leave auth state behind. + resolveEffectiveOrgSpy = spyOn( + region, + "resolveEffectiveOrg" + ).mockImplementation((org: string) => Promise.resolve(org)); getDefaultOrganizationSpy.mockResolvedValue(null); resolveAllTargetsSpy.mockResolvedValue({ targets: [] }); @@ -643,6 +671,7 @@ describe("dispatchOrgScopedList", () => { resolveAllTargetsSpy.mockRestore(); setPaginationCursorSpy.mockRestore(); clearPaginationCursorSpy.mockRestore(); + resolveEffectiveOrgSpy.mockRestore(); }); test("throws ValidationError when --cursor used outside org-all mode", async () => { diff --git a/test/lib/trace-id.test.ts b/test/lib/trace-id.test.ts index db69bc3fe..14d3870d3 100644 --- a/test/lib/trace-id.test.ts +++ b/test/lib/trace-id.test.ts @@ -8,7 +8,11 @@ import { describe, expect, test } from "bun:test"; import { array, constantFrom, assert as fcAssert, property } from "fast-check"; import { ValidationError } from "../../src/lib/errors.js"; -import { TRACE_ID_RE, validateTraceId } from "../../src/lib/trace-id.js"; +import { + isTraceId, + TRACE_ID_RE, + validateTraceId, +} from "../../src/lib/trace-id.js"; const HEX_CHARS = "0123456789abcdefABCDEF".split(""); const VALID_TRACE_ID = "aaaa1111bbbb2222cccc3333dddd4444"; @@ -127,6 +131,73 @@ describe("validateTraceId", () => { }); }); +describe("isTraceId", () => { + test("returns true for valid 32-char hex string", () => { + expect(isTraceId(VALID_TRACE_ID)).toBe(true); + }); + + test("returns true for uppercase hex", () => { + expect(isTraceId("AAAA1111BBBB2222CCCC3333DDDD4444")).toBe(true); + }); + + test("returns true for UUID-format trace ID", () => { + expect(isTraceId("ed29abc8-71c4-475b-9675-4655ef1a02d0")).toBe(true); + }); + + test("returns false for project slug", () => { + expect(isTraceId("my-project")).toBe(false); + }); + + test("returns false for org slug", () => { + expect(isTraceId("my-org")).toBe(false); + }); + + test("returns false for short hex", () => { + expect(isTraceId("abc123")).toBe(false); + }); + + test("returns false for empty string", () => { + expect(isTraceId("")).toBe(false); + }); + + test("returns false for 33-char hex", () => { + expect(isTraceId(`${VALID_TRACE_ID}a`)).toBe(false); + }); + + test("handles whitespace", () => { + expect(isTraceId(` ${VALID_TRACE_ID} `)).toBe(true); + }); +}); + +describe("property: isTraceId ↔ validateTraceId consistency", () => { + test("isTraceId(x) === true iff validateTraceId(x) does not throw", () => { + fcAssert( + property(validTraceIdArb, (id) => { + const isValid = isTraceId(id); + let validates = true; + try { + validateTraceId(id); + } catch { + validates = false; + } + expect(isValid).toBe(validates); + }), + { numRuns: 100 } + ); + }); + + test("isTraceId returns false for invalid inputs", () => { + for (const invalid of [ + "", + "abc", + "my-project", + "not-a-hex-string-at-all", + ]) { + expect(isTraceId(invalid)).toBe(false); + } + }); +}); + describe("property: validateTraceId", () => { test("accepts any 32-char hex string and normalizes to lowercase", () => { fcAssert(