Skip to content

Commit 5cd67b9

Browse files
committed
refactor(list): align all list commands to issue list standards
- Add isTraceId() non-throwing boolean helper to trace-id.ts - Rewrite log list: remove --trace flag, accept trace-id as positional arg (sentry log list <trace-id>, sentry log list <org>/<trace-id>), add --period/-t flag, JSON envelope { data, hasMore } - Add withProgress spinner to all list commands: trace list, span list, trace logs, org list, trial list, project list, and org-list.ts framework (team/repo list get spinners automatically) - Align trace logs JSON output to { data, hasMore } envelope - Fix pre-existing 5s timeout failures in dispatchOrgScopedList tests by mocking resolveEffectiveOrg and withProgress
1 parent 2382064 commit 5cd67b9

File tree

15 files changed

+781
-270
lines changed

15 files changed

+781
-270
lines changed

AGENTS.md

Lines changed: 35 additions & 42 deletions
Large diffs are not rendered by default.

plugins/sentry-cli/skills/sentry-cli/SKILL.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -554,15 +554,15 @@ sentry team list --json
554554

555555
View Sentry logs
556556

557-
#### `sentry log list <org/project>`
557+
#### `sentry log list <org/project-or-trace-id...>`
558558

559559
List logs from a project
560560

561561
**Flags:**
562562
- `-n, --limit <value> - Number of log entries (1-1000) - (default: "100")`
563563
- `-q, --query <value> - Filter query (Sentry search syntax)`
564564
- `-f, --follow <value> - Stream logs (optionally specify poll interval in seconds)`
565-
- `--trace <value> - Filter logs by trace ID (32-character hex string)`
565+
- `-t, --period <value> - Time period (e.g., "90d", "14d", "24h") - (default: "90d")`
566566
- `--fresh - Bypass cache, re-detect projects, and fetch fresh data`
567567
- `--json - Output as JSON`
568568
- `--fields <value> - Comma-separated fields to include in JSON output (dot.notation supported)`

src/commands/log/list.ts

Lines changed: 130 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,15 @@
33
*
44
* List and stream logs from Sentry projects.
55
* Supports real-time streaming with --follow flag.
6-
* Supports --trace flag to filter logs by trace ID.
6+
* Supports trace ID as a positional argument to filter logs by trace.
77
*/
88

99
// biome-ignore lint/performance/noNamespaceImport: Sentry SDK recommends namespace import
1010
import * as Sentry from "@sentry/bun";
1111
import type { SentryContext } from "../../context.js";
1212
import { listLogs, listTraceLogs } from "../../lib/api-client.js";
1313
import { validateLimit } from "../../lib/arg-parsing.js";
14-
import { AuthError, ContextError, stringifyUnknown } from "../../lib/errors.js";
14+
import { AuthError, stringifyUnknown } from "../../lib/errors.js";
1515
import {
1616
buildLogRowCells,
1717
createLogStreamingTable,
@@ -34,19 +34,23 @@ import {
3434
TARGET_PATTERN_NOTE,
3535
} from "../../lib/list-command.js";
3636
import { logger } from "../../lib/logger.js";
37+
import { withProgress } from "../../lib/polling.js";
38+
import { resolveOrgProjectFromArg } from "../../lib/resolve-target.js";
39+
import { isTraceId } from "../../lib/trace-id.js";
3740
import {
38-
resolveOrg,
39-
resolveOrgProjectFromArg,
40-
} from "../../lib/resolve-target.js";
41-
import { validateTraceId } from "../../lib/trace-id.js";
41+
type ParsedTraceTarget,
42+
parseTraceTarget,
43+
resolveTraceOrg,
44+
warnIfNormalized,
45+
} from "../../lib/trace-target.js";
4246
import { getUpdateNotification } from "../../lib/version-check.js";
4347

4448
type ListFlags = {
4549
readonly limit: number;
4650
readonly query?: string;
4751
readonly follow?: number;
52+
readonly period: string;
4853
readonly json: boolean;
49-
readonly trace?: string;
5054
readonly fresh: boolean;
5155
readonly fields?: string[];
5256
};
@@ -62,6 +66,8 @@ type LogListResult = {
6266
logs: LogLike[];
6367
/** Trace ID, present for trace-filtered queries */
6468
traceId?: string;
69+
/** Whether more results are available beyond the limit */
70+
hasMore: boolean;
6571
};
6672

6773
/** Output yielded by log list: either a batch (single-fetch) or an individual item (follow). */
@@ -82,6 +88,12 @@ const DEFAULT_POLL_INTERVAL = 2;
8288
/** Command name used in resolver error messages */
8389
const COMMAND_NAME = "log list";
8490

91+
/** Usage hint for trace mode error messages */
92+
const TRACE_USAGE_HINT = "sentry log list [<org>/]<trace-id>";
93+
94+
/** Default time period for trace-logs queries */
95+
const DEFAULT_TRACE_PERIOD = "14d";
96+
8597
/**
8698
* Parse --limit flag, delegating range validation to shared utility.
8799
*/
@@ -130,6 +142,56 @@ type FetchResult = {
130142
hint: string;
131143
};
132144

145+
// ---------------------------------------------------------------------------
146+
// Positional argument disambiguation
147+
// ---------------------------------------------------------------------------
148+
149+
/**
150+
* Parsed result from log list positional arguments.
151+
*
152+
* Discriminated on `mode`:
153+
* - `"project"` — standard project-scoped log listing (existing path)
154+
* - `"trace"` — trace-filtered log listing via trace-logs endpoint
155+
*/
156+
type ParsedLogArgs =
157+
| { mode: "project"; target?: string }
158+
| { mode: "trace"; parsed: ParsedTraceTarget };
159+
160+
/**
161+
* Disambiguate log list positional arguments.
162+
*
163+
* Checks if the "tail" segment (last part after `/`, or the entire arg
164+
* if no `/`) looks like a 32-char hex trace ID. If so, delegates to
165+
* {@link parseTraceTarget} for full trace target parsing. Otherwise,
166+
* treats the argument as a project target.
167+
*
168+
* @param args - Positional arguments from CLI
169+
* @returns Parsed args with mode discrimination
170+
*/
171+
function parseLogListArgs(args: string[]): ParsedLogArgs {
172+
if (args.length === 0) {
173+
return { mode: "project" };
174+
}
175+
176+
const first = args[0];
177+
if (first === undefined) {
178+
return { mode: "project" };
179+
}
180+
181+
// Check the tail segment: last part after `/`, or the entire arg
182+
const lastSlash = first.lastIndexOf("/");
183+
const tail = lastSlash === -1 ? first : first.slice(lastSlash + 1);
184+
185+
if (isTraceId(tail)) {
186+
return {
187+
mode: "trace",
188+
parsed: parseTraceTarget(args, TRACE_USAGE_HINT),
189+
};
190+
}
191+
192+
return { mode: "project", target: first };
193+
}
194+
133195
/**
134196
* Execute a single fetch of logs (non-streaming mode).
135197
*
@@ -144,11 +206,11 @@ async function executeSingleFetch(
144206
const logs = await listLogs(org, project, {
145207
query: flags.query,
146208
limit: flags.limit,
147-
statsPeriod: "90d",
209+
statsPeriod: flags.period,
148210
});
149211

150212
if (logs.length === 0) {
151-
return { result: { logs: [] }, hint: "No logs found." };
213+
return { result: { logs: [], hasMore: false }, hint: "No logs found." };
152214
}
153215

154216
// Reverse for chronological order (API returns newest first, tail shows oldest first)
@@ -158,7 +220,10 @@ async function executeSingleFetch(
158220
const countText = `Showing ${logs.length} log${logs.length === 1 ? "" : "s"}.`;
159221
const tip = hasMore ? " Use --limit to show more, or -f to follow." : "";
160222

161-
return { result: { logs: chronological }, hint: `${countText}${tip}` };
223+
return {
224+
result: { logs: chronological, hasMore },
225+
hint: `${countText}${tip}`,
226+
};
162227
}
163228

164229
// ---------------------------------------------------------------------------
@@ -368,7 +433,11 @@ async function* yieldTraceFollowItems<T extends LogLike>(
368433
for await (const batch of generator) {
369434
if (!contextSent && batch.length > 0) {
370435
// First non-empty batch: yield as LogListResult to set trace context
371-
yield new CommandOutput<LogOutput>({ logs: batch, traceId });
436+
yield new CommandOutput<LogOutput>({
437+
logs: batch,
438+
traceId,
439+
hasMore: false,
440+
});
372441
contextSent = true;
373442
} else {
374443
for (const item of batch) {
@@ -378,11 +447,8 @@ async function* yieldTraceFollowItems<T extends LogLike>(
378447
}
379448
}
380449

381-
/** Default time period for trace-logs queries */
382-
const DEFAULT_TRACE_PERIOD = "14d";
383-
384450
/**
385-
* Execute a single fetch of trace-filtered logs (non-streaming, --trace mode).
451+
* Execute a single fetch of trace-filtered logs (non-streaming, trace mode).
386452
* Uses the dedicated trace-logs endpoint which is org-scoped.
387453
*
388454
* Returns the fetched logs, trace ID, and a human-readable hint.
@@ -393,17 +459,21 @@ async function executeTraceSingleFetch(
393459
traceId: string,
394460
flags: ListFlags
395461
): Promise<FetchResult> {
462+
// In trace mode, use a shorter default period if the user hasn't
463+
// explicitly changed it from the command-level default of "90d".
464+
const period = flags.period === "90d" ? DEFAULT_TRACE_PERIOD : flags.period;
465+
396466
const logs = await listTraceLogs(org, traceId, {
397467
query: flags.query,
398468
limit: flags.limit,
399-
statsPeriod: DEFAULT_TRACE_PERIOD,
469+
statsPeriod: period,
400470
});
401471

402472
if (logs.length === 0) {
403473
return {
404-
result: { logs: [], traceId },
474+
result: { logs: [], traceId, hasMore: false },
405475
hint:
406-
`No logs found for trace ${traceId} in the last ${DEFAULT_TRACE_PERIOD}.\n\n` +
476+
`No logs found for trace ${traceId} in the last ${period}.\n\n` +
407477
"Try 'sentry trace logs' for more options (e.g., --period 30d).",
408478
};
409479
}
@@ -415,7 +485,7 @@ async function executeTraceSingleFetch(
415485
const tip = hasMore ? " Use --limit to show more." : "";
416486

417487
return {
418-
result: { logs: chronological, traceId },
488+
result: { logs: chronological, traceId, hasMore },
419489
hint: `${countText}${tip}`,
420490
};
421491
}
@@ -518,16 +588,18 @@ function createLogRenderer(): HumanRenderer<LogOutput> {
518588
* Transform log output into the JSON shape.
519589
*
520590
* Discriminates between {@link LogListResult} (single-fetch) and bare
521-
* {@link LogLike} items (follow mode). Single-fetch yields a JSON array;
522-
* follow mode yields one JSON object per line (JSONL).
591+
* {@link LogLike} items (follow mode). Single-fetch yields a JSON envelope
592+
* with `data` and `hasMore`; follow mode yields one JSON object per line (JSONL).
523593
*/
524594
function jsonTransformLogOutput(data: LogOutput, fields?: string[]): unknown {
525595
if ("logs" in data && Array.isArray((data as LogListResult).logs)) {
526-
// Batch (single-fetch): return array
527-
const logs = (data as LogListResult).logs;
528-
return fields && fields.length > 0
529-
? logs.map((log) => filterFields(log, fields))
530-
: logs;
596+
// Batch (single-fetch): return envelope with data + hasMore
597+
const logList = data as LogListResult;
598+
const items =
599+
fields && fields.length > 0
600+
? logList.logs.map((log) => filterFields(log, fields))
601+
: logList.logs;
602+
return { data: items, hasMore: logList.hasMore };
531603
}
532604
// Single item (follow mode): return bare object for JSONL
533605
return fields && fields.length > 0 ? filterFields(data, fields) : data;
@@ -544,16 +616,15 @@ export const listCommand = buildListCommand("log", {
544616
" sentry log list <project> # find project across all orgs\n\n" +
545617
`${TARGET_PATTERN_NOTE}\n\n` +
546618
"Trace filtering:\n" +
547-
" When --trace is given, only org resolution is needed (the trace-logs\n" +
548-
" endpoint is org-scoped). The positional target is treated as an org\n" +
549-
" slug, not an org/project pair.\n\n" +
619+
" sentry log list <trace-id> # Filter by trace (auto-detect org)\n" +
620+
" sentry log list <org>/<trace-id> # Filter by trace (explicit org)\n\n" +
550621
"Examples:\n" +
551622
" sentry log list # List last 100 logs\n" +
552623
" sentry log list -f # Stream logs (2s poll interval)\n" +
553624
" sentry log list -f 5 # Stream logs (5s poll interval)\n" +
554625
" sentry log list --limit 50 # Show last 50 logs\n" +
555626
" sentry log list -q 'level:error' # Filter to errors only\n" +
556-
" sentry log list --trace abc123def456abc123def456abc123de # Filter by trace\n\n" +
627+
" sentry log list abc123def456abc123def456abc123de # Filter by trace\n\n" +
557628
"Alias: `sentry logs` → `sentry log list`",
558629
},
559630
output: {
@@ -562,15 +633,12 @@ export const listCommand = buildListCommand("log", {
562633
},
563634
parameters: {
564635
positional: {
565-
kind: "tuple",
566-
parameters: [
567-
{
568-
placeholder: "org/project",
569-
brief: "<org>/<project> or <project> (search)",
570-
parse: String,
571-
optional: true,
572-
},
573-
],
636+
kind: "array",
637+
parameter: {
638+
placeholder: "org/project-or-trace-id",
639+
brief: "[<org>/[<project>/]]<trace-id>, <org>/<project>, or <project>",
640+
parse: String,
641+
},
574642
},
575643
flags: {
576644
limit: {
@@ -592,43 +660,38 @@ export const listCommand = buildListCommand("log", {
592660
optional: true,
593661
inferEmpty: true,
594662
},
595-
trace: {
663+
period: {
596664
kind: "parsed",
597-
parse: validateTraceId,
598-
brief: "Filter logs by trace ID (32-character hex string)",
599-
optional: true,
665+
parse: String,
666+
brief: 'Time period (e.g., "90d", "14d", "24h")',
667+
default: "90d",
600668
},
601669
fresh: FRESH_FLAG,
602670
},
603671
aliases: {
604672
n: "limit",
605673
q: "query",
606674
f: "follow",
675+
t: "period",
607676
},
608677
},
609-
async *func(this: SentryContext, flags: ListFlags, target?: string) {
678+
async *func(this: SentryContext, flags: ListFlags, ...args: string[]) {
610679
applyFreshFlag(flags);
611680
const { cwd, setContext } = this;
612681

613-
if (flags.trace) {
682+
const parsed = parseLogListArgs(args);
683+
684+
if (parsed.mode === "trace") {
614685
// Trace mode: use the org-scoped trace-logs endpoint.
615-
// The positional target is treated as an org slug (not org/project).
616-
const resolved = await resolveOrg({
617-
org: target,
686+
warnIfNormalized(parsed.parsed, "log.list");
687+
const { traceId, org } = await resolveTraceOrg(
688+
parsed.parsed,
618689
cwd,
619-
});
620-
if (!resolved) {
621-
throw new ContextError("Organization", "sentry log list --trace <id>", [
622-
"Set a default org with 'sentry org list', or specify one explicitly",
623-
`Example: sentry log list myorg --trace ${flags.trace}`,
624-
]);
625-
}
626-
const { org } = resolved;
690+
TRACE_USAGE_HINT
691+
);
627692
setContext([org], []);
628693

629694
if (flags.follow) {
630-
const traceId = flags.trace;
631-
632695
// Banner (suppressed in JSON mode)
633696
writeFollowBanner(
634697
flags.follow ?? DEFAULT_POLL_INTERVAL,
@@ -676,20 +739,18 @@ export const listCommand = buildListCommand("log", {
676739
return;
677740
}
678741

679-
const { result, hint } = await executeTraceSingleFetch(
680-
org,
681-
flags.trace,
682-
flags
742+
const { result, hint } = await withProgress(
743+
{ message: "Fetching logs..." },
744+
() => executeTraceSingleFetch(org, traceId, flags)
683745
);
684746
yield new CommandOutput(result);
685747
return { hint };
686748
}
687749

688-
// Standard project-scoped mode — kept in else-like block to avoid
689-
// `org` shadowing the trace-mode `org` declaration above.
750+
// Standard project-scoped mode
690751
{
691752
const { org, project } = await resolveOrgProjectFromArg(
692-
target,
753+
parsed.target,
693754
cwd,
694755
COMMAND_NAME
695756
);
@@ -719,7 +780,10 @@ export const listCommand = buildListCommand("log", {
719780
return;
720781
}
721782

722-
const { result, hint } = await executeSingleFetch(org, project, flags);
783+
const { result, hint } = await withProgress(
784+
{ message: "Fetching logs..." },
785+
() => executeSingleFetch(org, project, flags)
786+
);
723787
yield new CommandOutput(result);
724788
return { hint };
725789
}

0 commit comments

Comments
 (0)