From 1cad1707e7683595d8149ba83507272d26d151f9 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Mon, 13 Apr 2026 23:25:12 +0000 Subject: [PATCH 1/4] feat(trace): consistent project filtering across trace commands (#737) When a user provides `org/project/trace-id`, all trace commands now filter results to that project via API-level filtering: - `trace view`: resolves project slug to numeric ID, passes to the trace detail API instead of hardcoded `project: -1` - `trace logs` / `log list` (trace mode): prepends `project:{slug}` to the query param sent to the trace-logs endpoint - `span list`: already worked, no changes to filtering logic Additional UX improvements: - Orphan parent indicator in span tree when API filters by project and root spans have parents in other projects - Conditional "Project" column in span table when spans come from multiple projects - Contextual hints with real org/project/trace values for copy-paste - Multi-project `--query "project:[a,b]"` syntax documented in flag briefs and doc fragments Closes #737 --- AGENTS.md | 80 +++++-------------- docs/src/fragments/commands/span.md | 13 +++ docs/src/fragments/commands/trace.md | 16 ++++ plugins/sentry-cli/skills/sentry-cli/SKILL.md | 2 +- .../skills/sentry-cli/references/log.md | 2 +- .../skills/sentry-cli/references/span.md | 11 ++- .../skills/sentry-cli/references/trace.md | 16 +++- src/commands/log/list.ts | 65 +++++++++++++-- src/commands/span/list.ts | 2 +- src/commands/trace/logs.ts | 46 ++++++++--- src/commands/trace/view.ts | 55 ++++++++++--- src/lib/api/traces.ts | 20 +++-- src/lib/formatters/human.ts | 7 ++ src/lib/formatters/trace.ts | 23 +++++- test/commands/trace/view.func.test.ts | 20 +++-- 15 files changed, 267 insertions(+), 111 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 312842b30..d904e5b65 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -984,68 +984,62 @@ mock.module("./some-module", () => ({ ### Architecture - -* **commandPrefix on SentryContext enables command identity in buildCommand wrapper**: \`SentryContext.commandPrefix\` (optional \`readonly string\[]\`) is populated in \`forCommand()\` in \`context.ts\` — Stricli calls this with the full prefix (e.g., \`\["sentry", "issue", "list"]\`) before running the command. This enables the \`buildCommand\` wrapper to identify which command is executing for help recovery and telemetry. Previously, \`forCommand\` only set telemetry span names. - -* **Dashboard widget interval computed from terminal width and layout before API calls**: Dashboard chart widgets compute optimal \`interval\` before making API calls using terminal width and widget layout. Formula: \`colWidth = floor(layout.w / 6 \* termWidth)\`, \`chartWidth = colWidth - 4 - gutterW\` (~5-7), \`idealSeconds = periodSeconds / chartWidth\`. Snaps to nearest Sentry interval bucket (\`1m\`, \`5m\`, \`15m\`, \`30m\`, \`1h\`, \`4h\`, \`1d\`). Lives in \`computeOptimalInterval()\` in \`src/lib/api/dashboards.ts\`. \`periodToSeconds()\` parses \`"24h"\`, \`"7d"\` etc. The \`PERIOD\_RE\` regex is hoisted to module scope (Biome requires top-level regex). \`WidgetQueryParams\` gains optional \`interval?: string\` field; \`queryWidgetTimeseries\` uses \`params.interval ?? widget.interval\` for the API call. \`queryAllWidgets\` computes per-widget intervals using \`getTermWidth()\` logic (min 80, fallback 100). - - -* **defaultCommand:help blocks Stricli fuzzy matching for top-level typos**: Fuzzy matching across CLI subsystems: (1) Stricli built-in Damerau-Levenshtein for subcommand/flag typos within known routes. (2) \`defaultCommand: "help"\` bypasses this for top-level typos — fixed by \`resolveCommandPath()\` in \`introspect.ts\` returning \`UnresolvedPath\` with suggestions via \`fuzzyMatch()\` from \`fuzzy.ts\` (up to 3). Covers \`sentry \\` and \`sentry help \\`. (3) \`fuzzyMatch()\` in \`complete.ts\` for tab-completion (Levenshtein+prefix+contains). (4) \`levenshtein()\` in \`platforms.ts\` for platform suggestions. (5) Plural alias detection in \`app.ts\`. JSON includes \`suggestions\` array. +* **Dashboard widget interval computed from terminal width and layout before API calls**: Dashboard widget interval from terminal width: \`computeOptimalInterval()\` in \`src/lib/api/dashboards.ts\` calculates chart interval before API calls. Formula: \`colWidth = floor(layout.w / 6 \* termWidth)\`, \`chartWidth = colWidth - 4 - gutterW\`, \`idealSeconds = periodSeconds / chartWidth\`. Snaps to nearest Sentry bucket (1m–1d). \`periodToSeconds()\` parses \`"24h"\`, \`"7d"\` etc. \`queryWidgetTimeseries\` uses \`params.interval ?? widget.interval\`. -* **DSN org prefix normalization in arg-parsing.ts**: DSN org ID normalization has four code paths: (1) \`extractOrgIdFromHost\` in \`dsn/parser.ts\` strips \`o\` prefix during DSN parsing → bare \`"1081365"\`. (2) \`stripDsnOrgPrefix()\` strips \`o\` from user-typed inputs like \`o1081365/\`, applied in \`parseOrgProjectArg()\` and \`resolveEffectiveOrg()\`. (3) \`normalizeNumericOrg()\` in \`resolve-target.ts\` handles bare numeric IDs from cold-cache DSN detection — checks \`getOrgByNumericId()\` from DB cache, falls back to \`listOrganizationsUncached()\` to populate the mapping. Called from \`resolveOrg()\` step 4 (DSN auto-detect path). (4) Dashboard's \`resolveOrgFromTarget()\` pipes explicit org through \`resolveEffectiveOrg()\` for \`o\`-prefixed forms. Critical: many API endpoints reject numeric org IDs with 404/403 — always normalize to slugs before API calls. +* **DSN org prefix normalization in arg-parsing.ts**: DSN org prefix normalization — four code paths: (1) \`extractOrgIdFromHost\` strips \`o\` prefix during DSN parsing. (2) \`stripDsnOrgPrefix()\` handles user-typed \`o1081365/\` in \`parseOrgProjectArg()\`. (3) \`normalizeNumericOrg()\` in \`resolve-target.ts\` resolves bare numeric IDs via DB cache or uncached API call. (4) Dashboard's \`resolveOrgFromTarget()\` pipes through \`resolveEffectiveOrg()\`. Critical: many API endpoints reject numeric org IDs with 404/403 — always normalize to slugs before API calls. -* **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. +* **GHCR versioned nightly tags for delta upgrade support**: GHCR nightly with delta upgrades: Three tag types: \`:nightly\` (rolling), \`:nightly-\\` (immutable), \`:patch-\\` (delta). Delta patches use TRDIFF10 (zstd-compressed), ~50KB vs ~29MB full. Client via \`Bun.zstdDecompressSync()\`. N-1 only, full fallback, SHA-256 verify, 60% size threshold. npm/Node excluded. Test: \`mockGhcrNightlyVersion()\`. * **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. -* **resolveProjectBySlug carries full projectData to avoid redundant getProject calls**: \`resolveProjectBySlug()\` returns \`{ org, project, projectData: SentryProject }\` — the full project object from \`findProjectsBySlug()\`. \`ResolvedOrgProject\` and \`ResolvedTarget\` have optional \`projectData?\` (populated only in project-search path, not explicit/auto-detect). Downstream commands (\`project/view\`, \`project/delete\`, \`dashboard/create\`) use \`projectData\` when available to skip redundant \`getProject()\` API calls (~500-800ms savings). Pattern: \`resolved.projectData ?? await getProject(org, project)\` for callers that need both paths. +* **resolveProjectBySlug carries full projectData to avoid redundant getProject calls**: resolveProjectBySlug carries full projectData to skip redundant API calls: Returns \`{ org, project, projectData: SentryProject }\` from \`findProjectsBySlug()\`. \`ResolvedOrgProject\`/\`ResolvedTarget\` have optional \`projectData?\` (populated only in project-search path). Downstream commands use \`resolved.projectData ?? await getProject(org, project)\` to save ~500-800ms. -* **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. +* **Self-hosted OAuth device flow requires Sentry 26.1.0+ and SENTRY\_CLIENT\_ID**: Self-hosted OAuth requires Sentry 26.1.0+ with \`SENTRY\_URL\` and \`SENTRY\_CLIENT\_ID\` env vars. Users must create a public OAuth app in Settings → Developer Settings. Client ID NOT optional for self-hosted. Fallback: \`sentry auth login --token\`. \`getSentryUrl()\`/\`getClientId()\` read lazily so URL parsing from arguments can set \`SENTRY\_URL\` after import. * **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()\`. -* **Sentry dashboard API rejects discover/transaction-like widget types — use spans**: The Sentry Dashboard API rejects \`widgetType: 'discover'\` and \`widgetType: 'transaction-like'\` as deprecated. Use \`widgetType: 'spans'\` for new widgets. The codebase splits types into \`WIDGET\_TYPES\` (active, for creation) and \`ALL\_WIDGET\_TYPES\` (including deprecated, for parsing server responses). \`DashboardWidgetInputSchema\` must use \`ALL\_WIDGET\_TYPES\` so editing existing widgets with deprecated types passes Zod validation. \`validateWidgetEnums()\` in \`resolve.ts\` rejects deprecated types for new widget creation — but accepts \`skipDeprecatedCheck: true\` for the edit path, where \`effectiveDataset\` may inherit a deprecated type from the existing widget. Cross-validation (display vs dataset compatibility) still runs on effective values. Tests must use \`error-events\` instead of \`discover\`; it shares \`DISCOVER\_AGGREGATE\_FUNCTIONS\` including \`failure\_rate\`. +* **Sentry dashboard API rejects discover/transaction-like widget types — use spans**: Dashboard API dataset and sort gotchas: (1) \`widgetType: 'discover'\` and \`'transaction-like'\` rejected as deprecated — use \`'spans'\`. Codebase splits \`WIDGET\_TYPES\` (active) vs \`ALL\_WIDGET\_TYPES\` (including deprecated for parsing). \`validateWidgetEnums()\` rejects deprecated for creation, accepts \`skipDeprecatedCheck\` for edits. Tests must use \`error-events\` not \`discover\`. (2) \`sort\` API param only supported on \`spans\` dataset — guard with \`dataset === 'spans'\` in both \`queryWidgetTimeseries\` and \`queryWidgetTable\`. (3) \`tracemetrics\` dataset uses comma-separated aggregate format; only supports line/area/bar/table/big\_number displays; widgets always require \`sort\`. -* **Sentry issue stats field: time-series controlled by groupStatsPeriod**: Sentry issue stats and list table layout: \`stats\` key depends on \`groupStatsPeriod\` (\`""\`, \`"14d"\`, \`"24h"\`, \`"auto"\`); \`statsPeriod\` controls window. \*\*Critical\*\*: \`count\` is period-scoped — use \`lifetime.count\` for true total. Issue list uses \`groupStatsPeriod: 'auto'\` for sparklines. Columns: SHORT ID, ISSUE, SEEN, AGE, TREND, EVENTS, USERS, TRIAGE. TREND hidden < 100 cols. \`--compact\` tri-state: explicit overrides; \`undefined\` triggers \`shouldAutoCompact(rowCount)\` — compact if \`3N + 3 > termHeight\`. Height formula \`3N + 3\` (last row has no trailing separator). +* **Sentry issue stats field: time-series controlled by groupStatsPeriod**: Issue stats and list layout: \`stats\` depends on \`groupStatsPeriod\` (\`""\`, \`"14d"\`, \`"24h"\`, \`"auto"\`). Critical: \`count\` is period-scoped — use \`lifetime.count\` for true total. \`--compact\` is tri-state (\`optional: true\`): explicit overrides, \`undefined\` triggers \`shouldAutoCompact(rowCount)\` — compact if \`3N + 3 > termHeight\`. TREND column hidden < 100 cols. Stricli boolean flags with \`optional: true\` produce \`boolean | undefined\` enabling this auto-detect pattern. * **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. -* **SKILL.md is fully generated — edit source files, not output**: The skill files under \`plugins/sentry-cli/skills/sentry-cli/\` (SKILL.md + references/\*.md) are fully generated by \`bun run generate:skill\` (script/generate-skill.ts). CI runs this after every push via a \`github-actions\[bot]\` commit, overwriting any manual edits. To change skill content, edit the \*\*sources\*\*: (1) \`docs/src/content/docs/agent-guidance.md\` — embedded into SKILL.md's Agent Guidance section with heading levels bumped. (2) \`src/commands/\*/\` flag \`brief\` strings — generate the reference file flag descriptions. (3) \`docs/src/content/docs/commands/\*.md\` — examples extracted per command via marked AST parsing. After editing sources, run \`bun run generate:skill\` locally and commit both source and generated files. CI's \`bun run check:skill\` fails if generated files are stale. +* **SKILL.md is fully generated — edit source files, not output**: SKILL.md is fully generated — edit sources not output: Skill files under \`plugins/sentry-cli/skills/sentry-cli/\` generated by \`bun run generate:skill\`. CI auto-commits. Edit sources: (1) \`docs/src/content/docs/agent-guidance.md\` for SKILL.md content, (2) \`src/commands/\*/\` flag \`brief\` strings for reference descriptions, (3) \`docs/src/content/docs/commands/\*.md\` for examples. \`bun run check:skill\` fails if stale. -* **Stricli route errors are uninterceptable — only post-run detection works**: Stricli route errors, exit codes, and OutputError — error propagation gaps: (1) Route failures are uninterceptable — Stricli writes to stderr and returns \`ExitCode.UnknownCommand\` internally. Only post-\`run()\` \`process.exitCode\` check works. \`exceptionWhileRunningCommand\` only fires for errors in command \`func()\`. (2) \`ExitCode.UnknownCommand\` is \`-5\`. Bun reads \`251\` (unsigned byte), Node reads \`-5\` — compare both. (3) \`OutputError\` in \`handleOutputError\` calls \`process.exit()\` immediately, bypassing telemetry and \`exceptionWhileRunningCommand\`. Top-level typos via \`defaultCommand:help\` → \`OutputError\` → \`process.exit(1)\` skip all error reporting. +* **Stricli route errors are uninterceptable — only post-run detection works**: Stricli error propagation gaps and fuzzy matching: (1) Route failures uninterceptable — Stricli writes stderr, returns \`ExitCode.UnknownCommand\` (-5 / 251 in Bun). Only post-\`run()\` \`process.exitCode\` check works. (2) \`OutputError\` calls \`process.exit()\` immediately, bypassing telemetry. (3) \`defaultCommand: 'help'\` bypasses built-in fuzzy matching for top-level typos — fixed by \`resolveCommandPath()\` in \`introspect.ts\` with \`fuzzyMatch()\` suggestions (up to 3). JSON includes \`suggestions\` array. (4) Plural alias detection in \`app.ts\`. -* **Three Sentry APIs for span custom attributes with different capabilities**: Three Sentry APIs for span data with different custom attribute support: (1) \`/trace/{traceId}/\` — hierarchical tree; \`additional\_attributes\` query param enumerates requested attributes. Returns \`measurements\` (web vitals, zero-filled on non-browser spans — \`filterSpanMeasurements()\` strips zeros in JSON). (2) \`/projects/{org}/{project}/trace-items/{itemId}/?trace\_id={id}\&item\_type=spans\` — single span full detail; returns ALL attributes as \`{name, type, value}\[]\` automatically. CLI's \`span view\` uses this via \`getSpanDetails()\`. (3) \`/events/?dataset=spans\&field=X\` — list/search; requires explicit \`field\` params. - - -* **Two independent Sentry capture sites with inconsistent filters**: \`exceptionWhileRunningCommand\` in \`app.ts:297-349\` is the primary capture point — Stricli calls it for errors from command \`func()\`, does NOT re-throw (except OutputError and AuthError). \`withTelemetry\` in \`telemetry.ts:148-164\` is the secondary capture point — catches errors that escape Stricli (re-thrown AuthError, OutputError, middleware errors). The gap: \`app.ts\` captures ALL non-OutputError/AuthError errors including expected user errors, while \`withTelemetry\` has \`isClientApiError\` filter. Since Stricli doesn't re-throw, most command errors never reach \`withTelemetry\` — its filters are mostly dead code for command errors. Fix telemetry noise in \`app.ts\`, not \`telemetry.ts\`. +* **Three Sentry APIs for span custom attributes with different capabilities**: Three Sentry span APIs with different capabilities: (1) \`/trace/{traceId}/\` — hierarchical tree; \`additional\_attributes\` enumerates requested attrs; returns \`measurements\` (zero-filled on non-browser, stripped by \`filterSpanMeasurements()\`). (2) \`/projects/{org}/{project}/trace-items/{itemId}/\` — single span full detail; ALL attributes as \`{name,type,value}\[]\` automatically. (3) \`/events/?dataset=spans\&field=X\` — list/search; explicit \`field\` params. \`--fields\` flag has dual role in span list: filters JSON output AND requests extra API fields via \`extractExtraApiFields()\`. \`FIELD\_GROUP\_ALIASES\` supports shorthand expansion. * **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. -* **withTracing sets span status based on exceptions only, not HTTP response codes**: The \`withTracing\`/\`withHttpSpan\` helpers in \`telemetry.ts\` set span status purely based on whether the callback throws: return → OK (code 1), throw → Error (code 2). Since \`createAuthenticatedFetch\` returns the Response object without throwing on 4xx (the \`response.ok\` check happens later in \`apiRequestToRegion\`), all 4xx HTTP spans were incorrectly marked "ok". Fixed by switching \`createAuthenticatedFetch\` to \`withTracingSpan\` to access the span directly, setting \`http.response.status\_code\` attribute and \`span.setStatus({ code: 2 })\` for non-ok responses. OAuth callers (\`oauth.ts\`) are unaffected — they throw inside the callback on non-ok responses, so \`withHttpSpan\` correctly marks those spans as errors. +* **withTracing sets span status based on exceptions only, not HTTP response codes**: withTracing marks span status by exception only, not HTTP codes: \`withTracing\`/\`withHttpSpan\` set status based on throw vs return. Since \`createAuthenticatedFetch\` returns Response without throwing on 4xx, those spans were marked OK. Fixed by switching to \`withTracingSpan\` to set \`http.response.status\_code\` and \`span.setStatus({ code: 2 })\` for non-ok responses. OAuth callers unaffected (they throw on non-ok). ### Decision -* **400 Bad Request from Sentry API indicates a CLI bug, not a user error**: The project convention is: 400 Bad Request = CLI bug (malformed request the CLI should never send), 401-499 = user error (wrong ID, no access, rate limited). \`exceptionWhileRunningCommand\` in \`app.ts:334\` calls \`Sentry.captureException()\` unconditionally for all errors except OutputError, re-thrown AuthError, and synonym matches. This means ContextError, ResolutionError, ValidationError, SeerError, and 401-499 ApiErrors are all captured as exceptions despite being expected user errors. The fix: add \`isExpectedUserError()\` guard before \`captureException\` that returns true for those types. Keep capturing 400 (CLI bug), 5xx (server error), and unknown errors. Record skipped errors as breadcrumbs for volume tracking. +* **400 Bad Request from Sentry API indicates a CLI bug, not a user error**: Telemetry error capture — 400 convention and structured diagnostics: Convention: 400 = CLI bug (capture), 401-499 = user error (skip). \`isUserApiError()\` in \`telemetry.ts\` uses \`> 400\` (exclusive). Primary capture in \`exceptionWhileRunningCommand\` (\`app.ts\`) needs \`isExpectedUserError()\` guard — skip ContextError, ResolutionError, ValidationError, SeerError, 401-499 ApiErrors. Keep capturing 400, 5xx, unknown. Record skipped as breadcrumbs. For \`ApiError\`, call \`Sentry.setContext('api\_error', { status, endpoint, detail })\` before \`captureException\` — SDK doesn't capture custom properties. Secondary capture in \`withTelemetry\` mostly dead code for command errors (Stricli doesn't re-throw). Fix noise in \`app.ts\`. * **CLI UX philosophy: auto-recover when intent is clear, warn gently**: Core UX principle: don't fail or educate users with errors if their intent is clear. Do the intent and gently nudge them via \`log.warn()\` to stderr. Keep errors in Sentry telemetry for UX visibility and product decisions (e.g., SeerError kept for demand/upsell tracking). When asked to fix a Sentry issue, the goal is finding the underlying UX problem — not suppressing telemetry. Three recovery tiers: (1) auto-correct when semantics are identical (AND→space), (2) auto-recover with warning when match is unambiguous (fuzzy single match), (3) helpful error only when intent genuinely can't be fulfilled (OR operator). Model after \`gh\` CLI conventions. + +* **Trace-related commands must handle project consistently across CLI**: Trace project filtering — simplified API-level approach: \`getDetailedTrace\` accepts optional numeric \`projectId\` param (replacing hardcoded \`project: -1\`). For \`trace view org/project/trace-id\`, resolve slug→numeric ID via \`getProject(org, slug)\` (one extra call). API returns only spans from that project. Orphaned spans (parent in another project) detected client-side by checking if \`parent\_span\_id\` has no matching \`span\_id\` in results — show "↑ parent in another project" annotation, no supplemental fetch. Hint: run without project segment for full cross-project trace. \`listSpans\`/\`getSpanDetails\` accept project params for server-side filtering. \`listTraceLogs\` is org-scoped with no project param. Multi-project filtering: use \`--query 'project:\[cli,backend]'\` (Sentry in-list syntax) for \`span list\`, \`trace logs\`, \`log list\`. Document both in \`--help\` and navigation hints. + ### Gotcha @@ -1055,64 +1049,28 @@ mock.module("./some-module", () => ({ * **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. -* **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. - - -* **CLI telemetry command tags use sentry. prefix with dots not bare names**: The \`buildCommand\` wrapper sets the \`command\` telemetry tag using the full Stricli command prefix joined with dots: \`sentry.issue.explain\`, \`sentry.issue.list\`, \`sentry.api\`, etc. — NOT bare names like \`issue.explain\`. When querying Sentry Discover or building dashboard widgets, always use the \`sentry.\` prefix. Verify actual tag values with a Discover query (\`field:command, count()\`, grouped by \`command\`) before assuming the format. - - -* **Dashboard queryWidgetTable must guard sort param by dataset like queryWidgetTimeseries**: The Sentry events API \`sort\` parameter is only supported on the \`spans\` dataset. Passing \`sort\` to \`errors\` or \`discover\` datasets returns 400 Bad Request. In \`src/lib/api/dashboards.ts\`, \`queryWidgetTimeseries\` correctly guards this (line 387: \`if (dataset === 'spans')\`), but \`queryWidgetTable\` must also apply the same guard. Without it, any table/big\_number widget with \`orderby\` set on a non-spans dataset triggers a 400 that gets caught and silently displayed as a widget error. The fix: \`sort: dataset === 'spans' ? query?.orderby || undefined : undefined\`. - - -* **Dashboard tracemetrics dataset uses comma-separated aggregate format**: SDK v10+ custom metrics (, , ) emit envelope items. Dashboard widgets for these MUST use with aggregate format — e.g., . The parameter must match the SDK emission exactly: if no unit specified, for memory metrics, for uptime. only supports , , , , display types — no or . Widgets with always require . Sort expressions must reference aggregates present in . - - -* **isClientApiError treats 400 as user error contradicting project convention**: \`isClientApiError()\` was renamed to \`isUserApiError()\` in \`telemetry.ts\` and the boundary changed from \`>= 400\` to \`> 400\` (exclusive) to match the project convention that 400 = CLI bug. PR #729 merged. The function now correctly excludes 400 Bad Request from the "user error" classification, ensuring 400s are captured as Sentry exceptions while 401-499 are treated as expected user errors (wrong ID, no access, rate limited). Both call sites in \`withTelemetry\` were updated. - - -* **Sentry backend /api/0/auth/ can return 400 despite successful token authentication**: The Sentry backend's \`AuthIndexEndpoint\` (\`GET /api/0/auth/\`) overrides \`authentication\_classes\` to only \`(QuietBasicAuthentication, SessionAuthentication)\`, excluding \`UserAuthTokenAuthentication\`. When the CLI sends \`Authorization: Bearer \\`: (1) \`QuietBasicAuthentication\` skips (not "Basic"), (2) \`SessionAuthentication\` skips (no cookie), (3) DRF sets \`AnonymousUser\`, (4) \`get()\` returns 400. The token DB lookups visible in traces are from Django middleware before DRF's pipeline — DRF doesn't carry them over (no \`\_\_from\_api\_client\_\_\` flag). Fix: add \`UserAuthTokenAuthentication\` first in the tuple. Secondary gotcha: org-scoped tokens would still fail because \`/api/0/auth/\` (\`sentry-api-0-auth\`) isn't in the org-endpoint allowlist checked by \`authenticate\_token()\`. This is a server-side bug, not a CLI bug. +* **Bun mock.module for node:tty requires default export and class stubs**: Bun testing gotchas: (1) \`mock.module()\` for CJS built-ins requires \`default\` re-export plus all named exports — missing any causes SyntaxError. (2) \`Bun.mmap()\` always opens PROT\_WRITE — macOS SIGKILL, Linux ETXTBSY. Fix: \`new Uint8Array(await Bun.file(path).arrayBuffer())\`. (3) Wrap \`Bun.which()\` with optional \`pathEnv\` for deterministic testing. * **Sentry issue descriptions must not contain real org/project names (PII)**: Sentry issue events contain real organization and project slugs which are PII. When referencing Sentry issues in PR descriptions, commit messages, or code comments, always redact real org/project names with generic placeholders (e.g., \`'my-org'\`, \`'my-project'\`). Use \`\*\*\*\` or descriptive placeholders in issue titles. This applies to both automated tooling output and manual references. The user caught real names like \`d4swing\`, \`webscnd\`, \`heyinc\` leaking into a PR description. -* **Sentry issue list --query passes OR/AND operators to API causing 400**: Sentry issue search does NOT support AND/OR — disabled via \`SearchConfig.allow\_boolean=False\`. Backend returns 400. CLI's \`sanitizeQuery()\` auto-strips AND (case-insensitive, same semantics as implicit space-join) with \`log.warn()\`, throws \`ValidationError\` for OR (different semantics). Alternative: \`key:\[val1,val2]\` in-list syntax. The \`--json\` envelope includes \`\_searchSyntax\` with machine-readable query capabilities (operators, filter types, common filters, docs/grammar links) as an easter egg for agents. \`fullDescription\` and docs fragment include query syntax reference with examples. +* **Sentry issue list --query passes OR/AND operators to API causing 400**: Issue search query sanitization and \_searchSyntax: Sentry issue search does NOT support AND/OR (\`SearchConfig.allow\_boolean=False\`, returns 400). \`sanitizeQuery()\` auto-strips AND with \`log.warn()\`, throws \`ValidationError\` for OR. Tokenizer regex \`/\S\*"\[^"]\*"\S\*|\S+/g\` respects quoted strings, matches backend's \`split\_query\_into\_tokens()\`. Alternative: \`key:\[val1,val2]\` in-list syntax. When \`issue list --json\` returns empty results, envelope includes \`\_searchSyntax\` with machine-readable query capabilities to help AI agents. Omitted when results are non-empty. * **spansIndexed is not a valid Sentry dataset — use spans**: The Sentry Events/Explore API accepts 5 dataset values: \`spans\`, \`transactions\`, \`logs\`, \`errors\`, \`discover\`. The name \`spansIndexed\` is invalid and returns a generic HTTP 500 "Internal error" with no helpful validation message. This trips up AI agents and users. Valid datasets are documented in \`src/lib/api/datasets.ts\` (\`EVENTS\_API\_DATASETS\` constant) and in \`docs/commands/api.md\`. ### Pattern - -* **--fields dual role: output filtering + API field selection for span list**: --fields dual role in span list: filters JSON output AND requests extra API fields. \`extractExtraApiFields()\` checks names against \`OUTPUT\_TO\_API\_FIELD\` mapping. Unknown names are treated as custom attributes added to the \`field\` API param. \`FIELD\_GROUP\_ALIASES\` supports shorthand expansion (e.g., \`gen\_ai\` → 4 fields). Extra fields survive Zod via \`SpanListItemSchema.passthrough()\` and are forwarded by \`spanListItemToFlatSpan()\`. \`formatSpanTable()\` dynamically adds columns. - - -* **--since is an alias for --period via shared PERIOD\_ALIASES**: \`PERIOD\_ALIASES\` in \`src/lib/list-command.ts\` maps both \`t\` and \`since\` to \`period\`. All commands using \`LIST\_PERIOD\_FLAG\` get \`--since\` as an alias for \`--period\` automatically via spread \`...PERIOD\_ALIASES\`. This was added because AI agents and humans naturally try \`--since 1h\` instead of \`--period 1h\`. - -* **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. +* **Branch naming and commit message conventions for Sentry CLI**: Branch naming: \`feat/\\` or \`fix/\-\\`. Commit format: \`type(scope): description (#issue)\`. Types: fix, refactor, meta, release, feat. PRs as drafts via \`gh pr create --draft\`. Plans via \`git notes add\`. PR review: separate commit per round (not amend), reply via REST, resolve threads via GraphQL \`resolveReviewThread\`. * **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. - -* **Issue list JSON envelope includes \_searchSyntax easter egg for agents**: When \`issue list --json\` is used and the result set is \*\*empty\*\*, the JSON envelope includes a \`\_searchSyntax\` field with machine-readable query capabilities: supported operators, filter types (\`key:value\`, \`key:\[v1,v2]\`, \`has:key\`, \`is:status\`), common filters, and links to Sentry's PEG grammar and search docs. This helps AI agents construct valid queries when they're stuck. When results are non-empty, \`\_searchSyntax\` is omitted to avoid JSON bloat. Implemented via \`jsonTransformIssueList\` which wraps \`jsonTransformListResult\` and conditionally merges the syntax object. Changed in PR #738 — previously emitted on every response. - * **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. - - -* **Query sanitization uses tokenization to respect quoted strings**: CLI's \`sanitizeQuery()\` regex \`/\S\*"\[^"]\*"\S\*|\S+/g\` is functionally equivalent to Sentry backend's \`split\_query\_into\_tokens()\` in \`search/utils.py\` for AND/OR detection. Known gaps: no single-quote support, no colon-space joining (\`key: value\`), no escaped quotes — none affect boolean operator detection. AND/OR matching is case-insensitive (\`token.toUpperCase()\`) matching the PEG grammar's \`"OR"i\`/\`"AND"i\`. The PEG grammar lives at \`static/app/utils/tokenizeSearch.tsx\` (frontend Peggy) and \`src/sentry/search/events/filter.py\` (backend Parsimonious) — no standalone package exists. Issue search uses the simpler \`tokenize\_query()\` which also skips AND/OR tokens. JSDoc in \`sanitizeQuery\` links to these sources with file paths and a note about potential future PEG port. - -* **Redact sensitive flags in raw argv before sending to telemetry**: Telemetry context and argv redaction patterns: \`withTelemetry\` calls \`initTelemetryContext()\` BEFORE the callback — user ID, email, instance ID, runtime, and is\_self\_hosted tags are automatically set. For org context, read \`getDefaultOrganization()\` from SQLite (no API call). When sending raw argv, redact sensitive flags: \`SENSITIVE\_FLAGS\` in \`telemetry.ts\` (currently \`token\`). Scan for \`--token\`/\`-token\`, replace following value with \`\[REDACTED]\`. Handle both \`--flag value\` and \`--flag=value\` forms. \`setFlagContext\` handles parsed flags separately. - - -* **Set Sentry context for ApiError before captureException for structured diagnostics**: When \`Sentry.captureException(exc)\` is called for an \`ApiError\`, the SDK only captures \`name\`, \`message\`, and \`stacktrace\` — custom properties like \`status\`, \`endpoint\`, and \`detail\` are lost. Always call \`Sentry.setContext('api\_error', { status, endpoint, detail })\` before \`captureException\` so these fields appear as structured context in the Sentry event. Added in \`exceptionWhileRunningCommand\` in \`app.ts\`. Import \`ApiError\` from \`./lib/errors.js\` (alongside existing \`CliError\`, \`AuthError\` imports). Without this, events show only 'API request failed: 400 Bad Request' with no way to identify which endpoint failed or what the server response said. - - -* **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. +* **Redact sensitive flags in raw argv before sending to telemetry**: Telemetry argv redaction: \`withTelemetry\` calls \`initTelemetryContext()\` before callback — auto-sets user ID, email, runtime, is\_self\_hosted. Org context from \`getDefaultOrganization()\` (SQLite, no API). \`SENSITIVE\_FLAGS\` in \`telemetry.ts\` (currently \`token\`) — scan for \`--token\`/\`-token\`, replace value with \`\[REDACTED]\`. Handles both \`--flag value\` and \`--flag=value\` forms. \`setFlagContext\` handles parsed flags separately. Command tag format: \`sentry.issue.list\` (dot-joined with \`sentry.\` prefix). diff --git a/docs/src/fragments/commands/span.md b/docs/src/fragments/commands/span.md index 65598084f..2cece9e9f 100644 --- a/docs/src/fragments/commands/span.md +++ b/docs/src/fragments/commands/span.md @@ -21,6 +21,19 @@ sentry span list abc123def456abc123def456abc12345 sentry span list -c next ``` +### Filter by project in a trace + +```bash +# Show only spans from one project within a trace +sentry span list my-org/cli-server/abc123def456abc123def456abc12345 + +# Or use --query to filter by project +sentry span list abc123def456abc123def456abc12345 -q "project:cli-server" + +# Multiple projects at once +sentry span list abc123def456abc123def456abc12345 -q "project:[cli-server,api]" +``` + ### View spans ```bash diff --git a/docs/src/fragments/commands/trace.md b/docs/src/fragments/commands/trace.md index b426c61b1..69e4398af 100644 --- a/docs/src/fragments/commands/trace.md +++ b/docs/src/fragments/commands/trace.md @@ -31,6 +31,22 @@ sentry trace view abc123def456abc123def456abc12345 -w sentry trace view PROJ-123 ``` +### Cross-project traces + +```bash +# Filter trace view to one project's spans +sentry trace view my-org/cli-server/abc123def456abc123def456abc12345 + +# Full trace across all projects (default) +sentry trace view my-org/abc123def456abc123def456abc12345 + +# Filter trace logs by project +sentry trace logs my-org/cli-server/abc123def456abc123def456abc12345 + +# Multiple projects via --query +sentry trace logs abc123def456abc123def456abc12345 -q "project:[cli-server,api]" +``` + ### View trace logs ```bash diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 61b8d32ed..eb9b9021d 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -408,7 +408,7 @@ View distributed traces - `sentry trace list ` — List recent traces in a project - `sentry trace view ` — View details of a specific trace -- `sentry trace logs ` — View logs associated with a trace +- `sentry trace logs ` — View logs associated with a trace → Full flags and examples: `references/trace.md` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/log.md b/plugins/sentry-cli/skills/sentry-cli/references/log.md index dd35fb21b..551f4dd75 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/log.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/log.md @@ -17,7 +17,7 @@ List logs from a project **Flags:** - `-n, --limit - Number of log entries (1-1000) - (default: "100")` -- `-q, --query - Filter query (Sentry search syntax)` +- `-q, --query - Filter query (e.g., "level:error", "project:backend", "project:[a,b]")` - `-f, --follow - Stream logs (optionally specify poll interval in seconds)` - `-t, --period - Time range: "7d", "2026-03-01..2026-04-01", ">=2026-03-01"` - `-s, --sort - Sort order: "newest" (default) or "oldest" - (default: "newest")` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/span.md b/plugins/sentry-cli/skills/sentry-cli/references/span.md index 1954e4739..b9e51a382 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/span.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/span.md @@ -17,7 +17,7 @@ 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")` +- `-q, --query - Filter spans (e.g., "op:db", "project:backend", "project:[cli,api]")` - `-s, --sort - Sort order: date, duration - (default: "date")` - `-t, --period - Time range: "7d", "2026-03-01..2026-04-01", ">=2026-03-01" - (default: "7d")` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` @@ -54,6 +54,15 @@ sentry span list abc123def456abc123def456abc12345 # Paginate through results sentry span list -c next + +# Show only spans from one project within a trace +sentry span list my-org/cli-server/abc123def456abc123def456abc12345 + +# Or use --query to filter by project +sentry span list abc123def456abc123def456abc12345 -q "project:cli-server" + +# Multiple projects at once +sentry span list abc123def456abc123def456abc12345 -q "project:[cli-server,api]" ``` ### `sentry span view ` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/trace.md b/plugins/sentry-cli/skills/sentry-cli/references/trace.md index 2d034feba..4c2357e46 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/trace.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/trace.md @@ -71,9 +71,21 @@ sentry trace view abc123def456abc123def456abc12345 -w # Auto-recover from an issue short ID sentry trace view PROJ-123 + +# Filter trace view to one project's spans +sentry trace view my-org/cli-server/abc123def456abc123def456abc12345 + +# Full trace across all projects (default) +sentry trace view my-org/abc123def456abc123def456abc12345 + +# Filter trace logs by project +sentry trace logs my-org/cli-server/abc123def456abc123def456abc12345 + +# Multiple projects via --query +sentry trace logs abc123def456abc123def456abc12345 -q "project:[cli-server,api]" ``` -### `sentry trace logs ` +### `sentry trace logs ` View logs associated with a trace @@ -81,7 +93,7 @@ View logs associated with a trace - `-w, --web - Open trace in browser` - `-t, --period - Time range: "7d", "2026-03-01..2026-04-01", ">=2026-03-01" - (default: "14d")` - `-n, --limit - Number of log entries (<=1000) - (default: "100")` -- `-q, --query - Additional filter query (Sentry search syntax)` +- `-q, --query - Filter query (e.g., "level:error", "project:backend", "project:[a,b]")` - `-s, --sort - Sort order: "newest" (default) or "oldest" - (default: "newest")` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` diff --git a/src/commands/log/list.ts b/src/commands/log/list.ts index e55c3856e..6c1719e93 100644 --- a/src/commands/log/list.ts +++ b/src/commands/log/list.ts @@ -94,11 +94,26 @@ const DEFAULT_POLL_INTERVAL = 2; const COMMAND_NAME = "log list"; /** Usage hint for trace mode error messages */ -const TRACE_USAGE_HINT = "sentry log list [/]"; +const TRACE_USAGE_HINT = "sentry log list [/[/]]"; /** Default time period for trace-logs queries */ const DEFAULT_TRACE_PERIOD = "14d"; +/** + * Prepend `project:{slug}` to a query string when a project filter is specified. + * Returns the original query unchanged when no project is given. + */ +function buildProjectQuery( + query: string | undefined, + projectFilter: string | undefined +): string | undefined { + if (!projectFilter) { + return query; + } + const pf = `project:${projectFilter}`; + return query ? `${pf} ${query}` : pf; +} + /** * Parse --limit flag, delegating range validation to shared utility. */ @@ -440,21 +455,33 @@ async function* yieldTraceFollowItems( } } +/** Options for {@link executeTraceSingleFetch}. */ +type TraceFetchOptions = { + flags: ListFlags; + timeRange: TimeRange; + /** Project slug for API-level filtering (from org/project/trace-id syntax) */ + projectFilter?: string; +}; + /** * Execute a single fetch of trace-filtered logs (non-streaming, trace mode). * Uses the dedicated trace-logs endpoint which is org-scoped. * + * When `projectFilter` is provided, `project:{slug}` is prepended to the query + * for API-level filtering, and the hint includes a copy-pasteable unfiltered command. + * * Returns the fetched logs, trace ID, and a human-readable hint. * The caller (via the output config) handles rendering to stdout. */ async function executeTraceSingleFetch( org: string, traceId: string, - flags: ListFlags, - timeRange: TimeRange + options: TraceFetchOptions ): Promise { + const { flags, timeRange, projectFilter } = options; + const query = buildProjectQuery(flags.query, projectFilter); const logs = await listTraceLogs(org, traceId, { - query: flags.query, + query, limit: flags.limit, ...timeRangeToApiParams(timeRange), sort: flags.sort, @@ -478,9 +505,17 @@ async function executeTraceSingleFetch( const countText = `Showing ${logs.length} log${logs.length === 1 ? "" : "s"} for trace ${traceId}.`; const tip = hasMore ? " Use --limit to show more." : ""; + // Build hint with real values for easy copy-paste + let hint = `${countText}${tip}`; + if (projectFilter) { + hint += `\nFiltered to project '${projectFilter}'. Full trace logs: sentry log list ${org}/${traceId}`; + } else { + hint += `\nFilter by project: sentry log list ${org}//${traceId}`; + } + return { result: { logs, traceId, hasMore }, - hint: `${countText}${tip}`, + hint, }; } @@ -648,7 +683,8 @@ export const listCommand = buildListCommand( query: { kind: "parsed", parse: String, - brief: "Filter query (Sentry search syntax)", + brief: + 'Filter query (e.g., "level:error", "project:backend", "project:[a,b]")', optional: true, }, follow: { @@ -717,6 +753,14 @@ export const listCommand = buildListCommand( cwd, TRACE_USAGE_HINT ); + + // Capture explicit project for API-level filtering + const projectFilter = + parsed.parsed.type === "explicit" ? parsed.parsed.project : undefined; + + // Prepend project filter to the query when user explicitly specified a project + const traceQuery = buildProjectQuery(flags.query, projectFilter); + if (flags.follow) { // Banner (suppressed in JSON mode) writeFollowBanner( @@ -735,7 +779,7 @@ export const listCommand = buildListCommand( ?.abortSignal, fetch: (statsPeriod) => listTraceLogs(org, traceId, { - query: flags.query, + query: traceQuery, limit: flags.limit, statsPeriod, }), @@ -772,7 +816,12 @@ export const listCommand = buildListCommand( message: `Fetching logs (up to ${flags.limit})...`, json: flags.json, }, - () => executeTraceSingleFetch(org, traceId, flags, timeRange) + () => + executeTraceSingleFetch(org, traceId, { + flags, + timeRange, + projectFilter, + }) ); yield new CommandOutput(result); return { hint }; diff --git a/src/commands/span/list.ts b/src/commands/span/list.ts index 41713796b..c92d03c6b 100644 --- a/src/commands/span/list.ts +++ b/src/commands/span/list.ts @@ -576,7 +576,7 @@ export const listCommand = buildListCommand("span", { kind: "parsed", parse: String, brief: - 'Filter spans (e.g., "op:db", "duration:>100ms", "project:backend")', + 'Filter spans (e.g., "op:db", "project:backend", "project:[cli,api]")', optional: true, }, sort: { diff --git a/src/commands/trace/logs.ts b/src/commands/trace/logs.ts index 66d26ca82..e760be353 100644 --- a/src/commands/trace/logs.ts +++ b/src/commands/trace/logs.ts @@ -79,7 +79,7 @@ function formatTraceLogsHuman(data: TraceLogsData): string { const DEFAULT_PERIOD = "14d"; /** Usage hint shown in error messages */ -const USAGE_HINT = "sentry trace logs [/]"; +const USAGE_HINT = "sentry trace logs [/[/]]"; /** * Parse --limit flag, delegating range validation to shared utility. @@ -93,15 +93,17 @@ export const logsCommand = buildCommand({ brief: "View logs associated with a trace", fullDescription: "View logs associated with a specific distributed trace.\n\n" + - "Uses the dedicated trace-logs endpoint, which is org-scoped and\n" + - "automatically queries all projects — no project flag needed.\n\n" + "Target specification:\n" + - " sentry trace logs # auto-detect org\n" + - " sentry trace logs / # explicit org\n\n" + + " sentry trace logs # auto-detect org\n" + + " sentry trace logs / # explicit org\n" + + " sentry trace logs // # filter to project\n\n" + + "When a project is specified, only logs from that project are shown.\n" + + "Use --query 'project:[a,b]' to filter to multiple projects.\n\n" + "The trace ID is the 32-character hexadecimal identifier.\n\n" + "Examples:\n" + " sentry trace logs abc123def456abc123def456abc123de\n" + " sentry trace logs myorg/abc123def456abc123def456abc123de\n" + + " sentry trace logs myorg/backend/abc123def456abc123def456abc123de\n" + " sentry trace logs --period 7d abc123def456abc123def456abc123de\n" + " sentry trace logs --json abc123def456abc123def456abc123de", }, @@ -119,8 +121,9 @@ export const logsCommand = buildCommand({ positional: { kind: "array", parameter: { - placeholder: "org/trace-id", - brief: "[/] - Optional org and required trace ID", + placeholder: "org/project/trace-id", + brief: + "[/[/]] - Optional org/project and required trace ID", parse: String, }, }, @@ -145,7 +148,8 @@ export const logsCommand = buildCommand({ query: { kind: "parsed", parse: String, - brief: "Additional filter query (Sentry search syntax)", + brief: + 'Filter query (e.g., "level:error", "project:backend", "project:[a,b]")', optional: true, }, sort: { @@ -170,15 +174,25 @@ export const logsCommand = buildCommand({ const { cwd } = this; const timeRange = parsePeriod(flags.period); - // Parse and resolve org/trace-id + // Parse and resolve org/trace-id (project captured for filtering) const parsed = parseTraceTarget(args, USAGE_HINT); warnIfNormalized(parsed, "trace.logs"); const { traceId, org } = await resolveTraceOrg(parsed, cwd, USAGE_HINT); + const projectFilter = + parsed.type === "explicit" ? parsed.project : undefined; + if (flags.web) { await openInBrowser(buildTraceUrl(org, traceId), "trace"); return; } + // Prepend project filter to the query when user explicitly specified a project + let query = flags.query; + if (projectFilter) { + const pf = `project:${projectFilter}`; + query = query ? `${pf} ${query}` : pf; + } + const logs = await withProgress( { message: `Fetching trace logs (up to ${flags.limit})...`, @@ -188,7 +202,7 @@ export const logsCommand = buildCommand({ listTraceLogs(org, traceId, { ...timeRangeToApiParams(timeRange), limit: flags.limit, - query: flags.query, + query, sort: flags.sort, }) ); @@ -199,11 +213,21 @@ export const logsCommand = buildCommand({ `No logs found for trace ${traceId} in the last ${flags.period}.\n\n` + `Try a longer period: sentry trace logs --period 30d ${traceId}`; - return yield new CommandOutput({ + yield new CommandOutput({ logs, traceId, hasMore, emptyMessage, }); + + // Build hint with real values for easy copy-paste + if (projectFilter) { + return { + hint: `Filtered to project '${projectFilter}'. Full trace logs: sentry trace logs ${org}/${traceId}`, + }; + } + return { + hint: `Filter by project: sentry trace logs ${org}//${traceId}`, + }; }, }); diff --git a/src/commands/trace/view.ts b/src/commands/trace/view.ts index 4953cf1f7..0b50fbf55 100644 --- a/src/commands/trace/view.ts +++ b/src/commands/trace/view.ts @@ -11,6 +11,7 @@ import { getDetailedTrace, getIssueByShortId, getLatestEvent, + getProject, type TraceItemDetail, } from "../../lib/api-client.js"; import { @@ -56,6 +57,27 @@ type ViewFlags = { /** Usage hint for ContextError messages */ const USAGE_HINT = "sentry trace view [//]"; +/** Resolved trace target with optional project filter. */ +/** + * Build a contextual hint with real values for easy copy-paste. + */ +function buildViewHint( + traceId: string, + org: string, + projectFilter: string | undefined, + summary: ReturnType +): string { + if (projectFilter) { + return `Filtered to project '${projectFilter}'. Full trace: sentry trace view ${org}/${traceId}`; + } + if (summary.projects.length > 1) { + const projectList = summary.projects.join(", "); + const firstProject = summary.projects[0]; + return `This trace spans ${summary.projects.length} projects (${projectList}). Filter: sentry trace view ${org}/${firstProject}/${traceId}`; + } + return `Tip: Open in browser with 'sentry trace view --web ${traceId}'`; +} + /** * Standard field names in trace view output (summary keys + "spans"). * @@ -360,6 +382,8 @@ type ResolvedTrace = { traceId: string; org: string; project?: string; + /** Project slug when user explicitly provided org/project/trace-id */ + projectFilter?: string; }; /** @@ -499,9 +523,13 @@ export const viewCommand = buildCommand({ } else { const parsed = parseTraceTarget(correctedArgs, USAGE_HINT); warnIfNormalized(parsed, "trace.view"); - resolved = await resolveTraceOrgProject(parsed, cwd, USAGE_HINT); + const target = await resolveTraceOrgProject(parsed, cwd, USAGE_HINT); + resolved = { + ...target, + projectFilter: parsed.type === "explicit" ? target.project : undefined, + }; } - const { traceId, org, project } = resolved; + const { traceId, org, project, projectFilter } = resolved; if (flags.web) { await openInBrowser(buildTraceUrl(org, traceId), "trace"); @@ -512,15 +540,20 @@ export const viewCommand = buildCommand({ // trace API populates them on each span for JSON consumers. const additionalAttributes = extractAdditionalAttributes(flags.fields); + // Resolve numeric project ID for API-level filtering + let numericProjectId: number | undefined; + if (projectFilter) { + const projectData = await getProject(org, projectFilter); + numericProjectId = Number(projectData.id); + } + // The trace API requires a timestamp to help locate the trace data. // Use current time - the API will search around this timestamp. const timestamp = Math.floor(Date.now() / 1000); - const spans = await getDetailedTrace( - org, - traceId, - timestamp, - additionalAttributes - ); + const spans = await getDetailedTrace(org, traceId, timestamp, { + additionalAttributes, + projectId: numericProjectId, + }); if (spans.length === 0) { throw new ValidationError( @@ -553,10 +586,6 @@ export const viewCommand = buildCommand({ spanTreeLines, details: spanDetails, }); - return { - hint: shouldFetchDetails - ? `Tip: Open in browser with 'sentry trace view --web ${traceId}'` - : "Tip: Use --full to fetch span attributes, or --json for complete data", - }; + return { hint: buildViewHint(traceId, org, projectFilter, summary) }; }, }); diff --git a/src/lib/api/traces.ts b/src/lib/api/traces.ts index 00b4f7c12..e800cccff 100644 --- a/src/lib/api/traces.ts +++ b/src/lib/api/traces.ts @@ -88,23 +88,33 @@ export type TraceItemDetail = { links: unknown; }; +/** Options for {@link getDetailedTrace}. */ +type GetDetailedTraceOptions = { + /** Extra attribute names to include on each span */ + additionalAttributes?: string[]; + /** Numeric project ID to filter spans. Omit or -1 for all projects. */ + projectId?: number; +}; + /** * Get detailed trace with nested children structure. * This is an internal endpoint not covered by the public API. * Uses region-aware routing for multi-region support. * + * When `projectId` is provided, the API returns only spans belonging to that + * project. Pass `-1` (or omit) to fetch spans from all projects. + * * @param orgSlug - Organization slug * @param traceId - The trace ID (from event.contexts.trace.trace_id) * @param timestamp - Unix timestamp (seconds) from the event's dateCreated - * @param additionalAttributes - Extra attribute names to include on each span - * (passed as repeated `additional_attributes` query params to the API) + * @param options - Optional additional attributes and project filter * @returns Array of root spans with nested children */ export async function getDetailedTrace( orgSlug: string, traceId: string, timestamp: number, - additionalAttributes?: string[] + options: GetDetailedTraceOptions = {} ): Promise { const regionUrl = await resolveOrgRegion(orgSlug); @@ -115,8 +125,8 @@ export async function getDetailedTrace( params: { timestamp, limit: 10_000, - project: -1, - additional_attributes: additionalAttributes, + project: options.projectId ?? -1, + additional_attributes: options.additionalAttributes, }, } ); diff --git a/src/lib/formatters/human.ts b/src/lib/formatters/human.ts index a46eab05b..4bd9f73e4 100644 --- a/src/lib/formatters/human.ts +++ b/src/lib/formatters/human.ts @@ -1160,6 +1160,13 @@ export function formatSimpleSpanTree( lines.push(""); lines.push(`${plainSafeMuted("Trace —")} ${traceId}`); + // When API filters by project, some root spans may have a parent_span_id + // pointing to a span in another project that wasn't returned. + const hasOrphanedParent = spans.some((s) => s.parent_span_id); + if (hasOrphanedParent) { + lines.push(plainSafeMuted("⤴ parent span in another project")); + } + const totalRootSpans = spans.length; const truncated = totalRootSpans > MAX_ROOT_SPANS; const displaySpans = truncated ? spans.slice(0, MAX_ROOT_SPANS) : spans; diff --git a/src/lib/formatters/trace.ts b/src/lib/formatters/trace.ts index 4b9b133f3..c0521791b 100644 --- a/src/lib/formatters/trace.ts +++ b/src/lib/formatters/trace.ts @@ -415,6 +415,16 @@ export function spanListItemToFlatSpan( return flat; } +/** + * Project column — auto-prepended when spans come from multiple projects. + * @see formatSpanTable + */ +const PROJECT_COLUMN: Column = { + header: "Project", + value: (s) => escapeMarkdownCell(s.project_slug || "—"), + minWidth: 8, +}; + /** Column definitions for the flat span table */ const SPAN_TABLE_COLUMNS: Column[] = [ { @@ -468,6 +478,9 @@ function buildExtraColumns(extraColumns: string[]): Column[] { /** * Format a flat span list as a rendered table string. * + * When spans come from multiple projects, a "Project" column is automatically + * prepended so the user can see which service each span belongs to. + * * When `extraColumns` are provided, additional columns are appended after the * standard columns for custom attributes requested via `--fields`. * @@ -482,9 +495,15 @@ export function formatSpanTable( spans: FlatSpan[], extraColumns?: string[] ): string { + // Auto-add Project column when spans come from multiple projects + const projects = new Set(spans.map((s) => s.project_slug).filter(Boolean)); + const baseColumns = + projects.size > 1 + ? [PROJECT_COLUMN, ...SPAN_TABLE_COLUMNS] + : SPAN_TABLE_COLUMNS; const columns = extraColumns?.length - ? [...SPAN_TABLE_COLUMNS, ...buildExtraColumns(extraColumns)] - : SPAN_TABLE_COLUMNS; + ? [...baseColumns, ...buildExtraColumns(extraColumns)] + : baseColumns; return formatTable(spans, columns, { truncate: true }); } diff --git a/test/commands/trace/view.func.test.ts b/test/commands/trace/view.func.test.ts index b75ddc3dd..63055c201 100644 --- a/test/commands/trace/view.func.test.ts +++ b/test/commands/trace/view.func.test.ts @@ -89,6 +89,7 @@ describe("viewCommand.func", () => { let fetchMultiSpanDetailsSpy: ReturnType; let getIssueByShortIdSpy: ReturnType; let getLatestEventSpy: ReturnType; + let getProjectSpy: ReturnType; let findProjectsBySlugSpy: ReturnType; let resolveOrgAndProjectSpy: ReturnType; let resolveOrgSpy: ReturnType; @@ -139,12 +140,20 @@ describe("viewCommand.func", () => { ).mockResolvedValue(new Map()); getIssueByShortIdSpy = spyOn(apiClient, "getIssueByShortId"); getLatestEventSpy = spyOn(apiClient, "getLatestEvent"); + getProjectSpy = spyOn(apiClient, "getProject"); findProjectsBySlugSpy = spyOn(apiClient, "findProjectsBySlug"); resolveOrgAndProjectSpy = spyOn(resolveTarget, "resolveOrgAndProject"); resolveOrgSpy = spyOn(resolveTarget, "resolveOrg"); openInBrowserSpy = spyOn(browser, "openInBrowser"); setOrgRegion("test-org", DEFAULT_SENTRY_URL); setOrgRegion("my-org", DEFAULT_SENTRY_URL); + + // Mock getProject for explicit org/project/trace-id targets + getProjectSpy.mockResolvedValue({ + id: "42", + slug: "test-project", + name: "Test Project", + }); }); afterEach(() => { @@ -152,6 +161,7 @@ describe("viewCommand.func", () => { fetchMultiSpanDetailsSpy.mockRestore(); getIssueByShortIdSpy.mockRestore(); getLatestEventSpy.mockRestore(); + getProjectSpy.mockRestore(); findProjectsBySlugSpy.mockRestore(); resolveOrgAndProjectSpy.mockRestore(); resolveOrgSpy.mockRestore(); @@ -192,8 +202,8 @@ describe("viewCommand.func", () => { const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); expect(output).toContain("aaaa1111bbbb2222cccc3333dddd4444"); - // Human mode without --full shows hint about --full/--json - expect(output).toContain("--full"); + // Explicit org/project triggers project-filtered hint + expect(output).toContain("Filtered to project"); }); test("throws ValidationError when no spans found", async () => { @@ -267,8 +277,8 @@ describe("viewCommand.func", () => { // Summary should be present expect(output).toContain("aaaa1111bbbb2222cccc3333dddd4444"); // Span tree details should not appear (no span_id rendered) - // The footer should still be present (hint about --full in human mode) - expect(output).toContain("--full"); + // The footer should still be present (project-filtered hint for explicit target) + expect(output).toContain("Filtered to project"); }); test("throws ContextError for org-all target", async () => { @@ -405,7 +415,7 @@ describe("viewCommand.func", () => { "test-org", traceIdFromEvent, expect.any(Number), - undefined + { additionalAttributes: undefined, projectId: undefined } ); const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); From 7a3d65fe79e13d3541ee19d61698c5b8570e3ca2 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Mon, 13 Apr 2026 23:38:14 +0000 Subject: [PATCH 2/4] fix: only show orphan parent indicator when project filter is active The hasOrphanedParent check ran unconditionally, showing a misleading "parent span in another project" message on normal unfiltered traces where root spans legitimately have parent_span_id at service boundaries. Now gated behind a projectFiltered option passed by trace view only when the user explicitly provided org/project/trace-id. --- src/commands/trace/view.ts | 4 +++- src/lib/formatters/human.ts | 23 +++++++++++++++++++---- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/src/commands/trace/view.ts b/src/commands/trace/view.ts index 0b50fbf55..c6f8181ba 100644 --- a/src/commands/trace/view.ts +++ b/src/commands/trace/view.ts @@ -567,7 +567,9 @@ export const viewCommand = buildCommand({ // Format span tree (unless disabled with --spans 0 or --spans no) const spanTreeLines = flags.spans > 0 - ? formatSimpleSpanTree(traceId, spans, flags.spans) + ? formatSimpleSpanTree(traceId, spans, flags.spans, { + projectFiltered: !!projectFilter, + }) : undefined; // Fetch per-span details when --full is set or --json auto-enables it diff --git a/src/lib/formatters/human.ts b/src/lib/formatters/human.ts index 4bd9f73e4..22ca0e97d 100644 --- a/src/lib/formatters/human.ts +++ b/src/lib/formatters/human.ts @@ -1138,10 +1138,23 @@ const MAX_ROOT_SPANS = 50; * @param maxDepth - Maximum nesting depth to display (default: unlimited). 0 = disabled, Infinity = unlimited. * @returns Array of formatted lines ready for display */ +/** Options for {@link formatSimpleSpanTree}. */ +type SpanTreeOptions = { + /** + * When true, the tree was produced by a project-filtered API call. + * Root spans with `parent_span_id` are annotated as having a parent + * in another project. Without this flag the annotation is suppressed + * because root spans in unfiltered traces can legitimately have + * `parent_span_id` at service boundaries. + */ + projectFiltered?: boolean; +}; + export function formatSimpleSpanTree( traceId: string, spans: TraceSpan[], - maxDepth = Number.MAX_SAFE_INTEGER + maxDepth = Number.MAX_SAFE_INTEGER, + options: SpanTreeOptions = {} ): string[] { return withSerializeSpan("formatSimpleSpanTree", () => { // maxDepth = 0 means disabled (caller should skip, but handle gracefully) @@ -1160,10 +1173,12 @@ export function formatSimpleSpanTree( lines.push(""); lines.push(`${plainSafeMuted("Trace —")} ${traceId}`); - // When API filters by project, some root spans may have a parent_span_id + // When API filters by project, root spans may have a parent_span_id // pointing to a span in another project that wasn't returned. - const hasOrphanedParent = spans.some((s) => s.parent_span_id); - if (hasOrphanedParent) { + // Only show this annotation when a project filter is active — in + // unfiltered traces, root spans at service boundaries legitimately + // have parent_span_id without implying missing data. + if (options.projectFiltered && spans.some((s) => s.parent_span_id)) { lines.push(plainSafeMuted("⤴ parent span in another project")); } From d33050063a5746a3398b85f02acadb6f22afaaa1 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Mon, 13 Apr 2026 23:48:24 +0000 Subject: [PATCH 3/4] fix: deduplicate buildProjectQuery and remove orphaned JSDoc Extract buildProjectQuery to arg-parsing.ts as a shared helper. Remove inline duplicate from trace/logs.ts and local copy from log/list.ts. Both now import from the shared location. Also remove orphaned JSDoc comment left over from conflict resolution. --- src/commands/log/list.ts | 21 +++++---------------- src/commands/trace/logs.ts | 12 ++++++------ src/commands/trace/view.ts | 1 - src/lib/arg-parsing.ts | 22 ++++++++++++++++++++++ 4 files changed, 33 insertions(+), 23 deletions(-) diff --git a/src/commands/log/list.ts b/src/commands/log/list.ts index 6c1719e93..d46420144 100644 --- a/src/commands/log/list.ts +++ b/src/commands/log/list.ts @@ -14,7 +14,11 @@ import { listLogs, listTraceLogs, } from "../../lib/api-client.js"; -import { parseLogSort, validateLimit } from "../../lib/arg-parsing.js"; +import { + buildProjectQuery, + parseLogSort, + validateLimit, +} from "../../lib/arg-parsing.js"; import { AuthError, stringifyUnknown, @@ -99,21 +103,6 @@ const TRACE_USAGE_HINT = "sentry log list [/[/]]"; /** Default time period for trace-logs queries */ const DEFAULT_TRACE_PERIOD = "14d"; -/** - * Prepend `project:{slug}` to a query string when a project filter is specified. - * Returns the original query unchanged when no project is given. - */ -function buildProjectQuery( - query: string | undefined, - projectFilter: string | undefined -): string | undefined { - if (!projectFilter) { - return query; - } - const pf = `project:${projectFilter}`; - return query ? `${pf} ${query}` : pf; -} - /** * Parse --limit flag, delegating range validation to shared utility. */ diff --git a/src/commands/trace/logs.ts b/src/commands/trace/logs.ts index e760be353..8a8c52fdf 100644 --- a/src/commands/trace/logs.ts +++ b/src/commands/trace/logs.ts @@ -6,7 +6,11 @@ import type { SentryContext } from "../../context.js"; import { type LogSortDirection, listTraceLogs } from "../../lib/api-client.js"; -import { parseLogSort, validateLimit } from "../../lib/arg-parsing.js"; +import { + buildProjectQuery, + parseLogSort, + validateLimit, +} from "../../lib/arg-parsing.js"; import { openInBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; import { filterFields } from "../../lib/formatters/json.js"; @@ -187,11 +191,7 @@ export const logsCommand = buildCommand({ } // Prepend project filter to the query when user explicitly specified a project - let query = flags.query; - if (projectFilter) { - const pf = `project:${projectFilter}`; - query = query ? `${pf} ${query}` : pf; - } + const query = buildProjectQuery(flags.query, projectFilter); const logs = await withProgress( { diff --git a/src/commands/trace/view.ts b/src/commands/trace/view.ts index c6f8181ba..eefcad4a4 100644 --- a/src/commands/trace/view.ts +++ b/src/commands/trace/view.ts @@ -57,7 +57,6 @@ type ViewFlags = { /** Usage hint for ContextError messages */ const USAGE_HINT = "sentry trace view [//]"; -/** Resolved trace target with optional project filter. */ /** * Build a contextual hint with real values for easy copy-paste. */ diff --git a/src/lib/arg-parsing.ts b/src/lib/arg-parsing.ts index 9c0d80cb1..6603d73d3 100644 --- a/src/lib/arg-parsing.ts +++ b/src/lib/arg-parsing.ts @@ -975,3 +975,25 @@ export function parseIssueArg(arg: string): ParsedIssueArg { // 5. No dash, no slash → suffix only (needs DSN context) return { type: "suffix-only", suffix: arg.toUpperCase() }; } + +// --------------------------------------------------------------------------- +// Query helpers +// --------------------------------------------------------------------------- + +/** + * Prepend `project:{slug}` to a query string when a project filter is specified. + * Returns the original query unchanged when no project is given. + * + * Used by trace-scoped commands (`trace logs`, `log list` trace mode) to apply + * the project from `org/project/trace-id` positional syntax as an API filter. + */ +export function buildProjectQuery( + query: string | undefined, + projectFilter: string | undefined +): string | undefined { + if (!projectFilter) { + return query; + } + const pf = `project:${projectFilter}`; + return query ? `${pf} ${query}` : pf; +} From c285369765f0806ba63d80434939c0bebcbb4030 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Mon, 13 Apr 2026 23:56:29 +0000 Subject: [PATCH 4/4] fix: move SpanTreeOptions type above formatSimpleSpanTree JSDoc The type was inserted between the JSDoc block and the function, orphaning the documentation from its declaration. --- src/lib/formatters/human.ts | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/src/lib/formatters/human.ts b/src/lib/formatters/human.ts index 22ca0e97d..176515795 100644 --- a/src/lib/formatters/human.ts +++ b/src/lib/formatters/human.ts @@ -1126,18 +1126,6 @@ function formatSpanSimple(span: TraceSpan, opts: FormatSpanOptions): void { */ const MAX_ROOT_SPANS = 50; -/** - * Format trace as a simple tree with "op — description (duration)" per span. - * Durations are shown when available, omitted otherwise. - * - * Root spans are capped at {@link MAX_ROOT_SPANS} to prevent terminal flooding - * when traces contain thousands of flat spans. - * - * @param traceId - The trace ID for the header - * @param spans - Root-level spans from the /trace/ API - * @param maxDepth - Maximum nesting depth to display (default: unlimited). 0 = disabled, Infinity = unlimited. - * @returns Array of formatted lines ready for display - */ /** Options for {@link formatSimpleSpanTree}. */ type SpanTreeOptions = { /** @@ -1150,6 +1138,19 @@ type SpanTreeOptions = { projectFiltered?: boolean; }; +/** + * Format trace as a simple tree with "op — description (duration)" per span. + * Durations are shown when available, omitted otherwise. + * + * Root spans are capped at {@link MAX_ROOT_SPANS} to prevent terminal flooding + * when traces contain thousands of flat spans. + * + * @param traceId - The trace ID for the header + * @param spans - Root-level spans from the /trace/ API + * @param maxDepth - Maximum nesting depth to display (default: unlimited). 0 = disabled, Infinity = unlimited. + * @param options - Optional display options (e.g., project filter indicator) + * @returns Array of formatted lines ready for display + */ export function formatSimpleSpanTree( traceId: string, spans: TraceSpan[],