From 43ec83618490382e94171c6697775f95a17b59df Mon Sep 17 00:00:00 2001 From: David Cramer Date: Fri, 1 May 2026 13:15:29 -0700 Subject: [PATCH 01/26] feat(replay): Add replay list and view commands Add first-class replay commands so users can query replay data directly instead of only\nseeing replay links hanging off issue and event output.\n\nWire the org replay list and detail APIs into a typed client, add replay\nschemas and command output, and surface replay-native hints from event\nview. Regenerate the replay command docs and skill references.\n\nCo-Authored-By: OpenAI Codex --- docs/src/content/docs/contributing.md | 1 + plugins/sentry-cli/skills/sentry-cli/SKILL.md | 9 + .../skills/sentry-cli/references/replay.md | 107 +++++ src/app.ts | 6 + src/commands/event/view.ts | 30 +- src/commands/replay/index.ts | 27 ++ src/commands/replay/list.ts | 407 ++++++++++++++++ src/commands/replay/view.ts | 443 ++++++++++++++++++ src/lib/api-client.ts | 7 + src/lib/api/replays.ts | 126 +++++ src/lib/formatters/human.ts | 2 +- src/lib/sentry-urls.ts | 14 + src/types/index.ts | 28 ++ src/types/replay.ts | 262 +++++++++++ test/commands/replay/list.test.ts | 144 ++++++ test/commands/replay/view.test.ts | 154 ++++++ test/lib/api/replays.test.ts | 156 ++++++ 17 files changed, 1918 insertions(+), 5 deletions(-) create mode 100644 plugins/sentry-cli/skills/sentry-cli/references/replay.md create mode 100644 src/commands/replay/index.ts create mode 100644 src/commands/replay/list.ts create mode 100644 src/commands/replay/view.ts create mode 100644 src/lib/api/replays.ts create mode 100644 src/types/replay.ts create mode 100644 test/commands/replay/list.test.ts create mode 100644 test/commands/replay/view.test.ts create mode 100644 test/lib/api/replays.test.ts diff --git a/docs/src/content/docs/contributing.md b/docs/src/content/docs/contributing.md index a4adc683c..042ac62d5 100644 --- a/docs/src/content/docs/contributing.md +++ b/docs/src/content/docs/contributing.md @@ -60,6 +60,7 @@ cli/ │ │ ├── org/ # list, view │ │ ├── project/ # create, delete, list, view │ │ ├── release/ # list, view, create, finalize, delete, deploy, deploys, set-commits, propose-version +│ │ ├── replay/ # list, view │ │ ├── repo/ # list │ │ ├── sourcemap/ # inject, upload │ │ ├── span/ # list, view diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 8cc3670ac..382b248f9 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -363,6 +363,15 @@ Manage Sentry dashboards → Full flags and examples: `references/dashboard.md` +### Replay + +Search and inspect Session Replays + +- `sentry replay list ` — List recent Session Replays +- `sentry replay view ` — View a Session Replay + +→ Full flags and examples: `references/replay.md` + ### Release Work with Sentry releases diff --git a/plugins/sentry-cli/skills/sentry-cli/references/replay.md b/plugins/sentry-cli/skills/sentry-cli/references/replay.md new file mode 100644 index 000000000..165d0fd64 --- /dev/null +++ b/plugins/sentry-cli/skills/sentry-cli/references/replay.md @@ -0,0 +1,107 @@ +--- +name: sentry-cli-replay +version: 0.31.0-dev.0 +description: Search and inspect Session Replays +requires: + bins: ["sentry"] + auth: true +--- + +# Replay Commands + +Search and inspect Session Replays + +### `sentry replay list ` + +List recent Session Replays + +**Flags:** +- `-n, --limit - Number of replays (1-1000) - (default: "25")` +- `-q, --query - Search query (Sentry replay search syntax)` +- `-s, --sort - Sort by: date, oldest, duration, errors, segments, activity - (default: "date")` +- `-t, --period - Time range: "7d", "2026-04-01..2026-05-01", ">=2026-04-01" - (default: "7d")` +- `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` +- `-c, --cursor - Navigate pages: "next", "prev", "first" (or raw cursor string)` + +**JSON Fields** (use `--json --fields` to select specific fields): + +| Field | Type | Description | +|-------|------|-------------| +| `activity` | number \| null | Replay activity score | +| `browser` | object | Browser metadata | +| `count_dead_clicks` | number \| null | Dead click count | +| `count_errors` | number \| null | Associated error count | +| `count_infos` | number \| null | Info event count | +| `count_rage_clicks` | number \| null | Rage click count | +| `count_segments` | number \| null | Recording segment count | +| `count_urls` | number \| null | Visited URL count | +| `count_warnings` | number \| null | Warning event count | +| `device` | object | Device metadata | +| `dist` | string \| null | Distribution | +| `duration` | number \| null | Replay duration in seconds | +| `environment` | string \| null | Environment | +| `error_ids` | array | Linked error IDs | +| `finished_at` | string \| null | Replay finish timestamp | +| `has_viewed` | boolean \| null | Whether the current user has viewed the replay | +| `id` | string | Replay ID | +| `info_ids` | array | Linked info event IDs | +| `is_archived` | boolean \| null | Archived flag | +| `os` | object | Operating system metadata | +| `platform` | string \| null | Platform | +| `project_id` | string \| null | Numeric project ID | +| `releases` | array | Associated releases | +| `sdk` | object | SDK metadata | +| `started_at` | string \| null | Replay start timestamp | +| `tags` | unknown | Replay tags | +| `trace_ids` | array | Linked trace IDs | +| `urls` | array | Visited URLs | +| `user` | object | User metadata | +| `warning_ids` | array | Linked warning event IDs | + +### `sentry replay view ` + +View a Session Replay + +**Flags:** +- `-w, --web - Open in browser` +- `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` + +**JSON Fields** (use `--json --fields` to select specific fields): + +| Field | Type | Description | +|-------|------|-------------| +| `activity` | number \| null | Replay activity score | +| `browser` | object | Browser metadata | +| `count_dead_clicks` | number \| null | Dead click count | +| `count_errors` | number \| null | Associated error count | +| `count_infos` | number \| null | Info event count | +| `count_rage_clicks` | number \| null | Rage click count | +| `count_segments` | number \| null | Recording segment count | +| `count_urls` | number \| null | Visited URL count | +| `count_warnings` | number \| null | Warning event count | +| `device` | object | Device metadata | +| `dist` | string \| null | Distribution | +| `duration` | number \| null | Replay duration in seconds | +| `environment` | string \| null | Environment | +| `error_ids` | array | Linked error IDs | +| `finished_at` | string \| null | Replay finish timestamp | +| `has_viewed` | boolean \| null | Whether the current user has viewed the replay | +| `id` | string | Replay ID | +| `info_ids` | array | Linked info event IDs | +| `is_archived` | boolean \| null | Archived flag | +| `os` | object | Operating system metadata | +| `platform` | string \| null | Platform | +| `project_id` | string \| null | Numeric project ID | +| `releases` | array | Associated releases | +| `sdk` | object | SDK metadata | +| `started_at` | string \| null | Replay start timestamp | +| `tags` | unknown | Replay tags | +| `trace_ids` | array | Linked trace IDs | +| `urls` | array | Visited URLs | +| `user` | object | User metadata | +| `warning_ids` | array | Linked warning event IDs | +| `clicks` | array | Replay click summaries | +| `ota_updates` | object | OTA update metadata | +| `replay_type` | string \| null | Replay type | + +All commands also support `--json`, `--fields`, `--help`, `--log-level`, and `--verbose` flags. diff --git a/src/app.ts b/src/app.ts index 52174001e..d8e49cb5b 100644 --- a/src/app.ts +++ b/src/app.ts @@ -24,6 +24,8 @@ import { orgRoute } from "./commands/org/index.js"; import { listCommand as orgListCommand } from "./commands/org/list.js"; import { projectRoute } from "./commands/project/index.js"; import { listCommand as projectListCommand } from "./commands/project/list.js"; +import { replayRoute } from "./commands/replay/index.js"; +import { listCommand as replayListCommand } from "./commands/replay/list.js"; import { releaseRoute } from "./commands/release/index.js"; import { listCommand as releaseListCommand } from "./commands/release/list.js"; import { repoRoute } from "./commands/repo/index.js"; @@ -70,6 +72,7 @@ const PLURAL_TO_SINGULAR: Record = { repos: "repo", teams: "team", logs: "log", + replays: "replay", spans: "span", traces: "trace", @@ -85,6 +88,7 @@ export const routes = buildRouteMap({ dashboard: dashboardRoute, org: orgRoute, project: projectRoute, + replay: replayRoute, release: releaseRoute, repo: repoRoute, team: teamRoute, @@ -105,6 +109,7 @@ export const routes = buildRouteMap({ issues: issueListCommand, orgs: orgListCommand, projects: projectListCommand, + replays: replayListCommand, releases: releaseListCommand, repos: repoListCommand, teams: teamListCommand, @@ -126,6 +131,7 @@ export const routes = buildRouteMap({ issues: true, orgs: true, projects: true, + replays: true, releases: true, repos: true, teams: true, diff --git a/src/commands/event/view.ts b/src/commands/event/view.ts index 21c46488a..83583a7ac 100644 --- a/src/commands/event/view.ts +++ b/src/commands/event/view.ts @@ -148,6 +148,16 @@ export function jsonTransformEventView( } return data.events.map(transform); } + +/** + * Build a CLI-native replay hint when the event is linked to a replay. + */ +function replayHint(org: string, event: SentryEvent): string | undefined { + const replayId = event.tags?.find((tag) => tag.key === "replayId")?.value; + return replayId + ? `Related replay: sentry replay view ${org}/${replayId}` + : undefined; +} /** Usage hint for ContextError messages */ const USAGE_HINT = "sentry event view / "; @@ -987,7 +997,14 @@ export const viewCommand = buildCommand({ events: [issueShortcut.data], requestedCount: 1, }); - return { hint: issueShortcut.hint }; + return { + hint: [ + issueShortcut.hint, + replayHint(issueShortcut.org, issueShortcut.data.event), + ] + .filter(Boolean) + .join(" | "), + }; } // Validate + attempt recovery. `skipValidation` is true when the ID is @@ -1043,9 +1060,14 @@ export const viewCommand = buildCommand({ requestedCount: allEventIds.length, }); return { - hint: target.detectedFrom - ? `Detected from ${target.detectedFrom}` - : undefined, + hint: [ + target.detectedFrom + ? `Detected from ${target.detectedFrom}` + : undefined, + replayHint(target.org, event), + ] + .filter(Boolean) + .join(" | "), }; }, }); diff --git a/src/commands/replay/index.ts b/src/commands/replay/index.ts new file mode 100644 index 000000000..a5c658703 --- /dev/null +++ b/src/commands/replay/index.ts @@ -0,0 +1,27 @@ +/** + * sentry replay + * + * Search and inspect Session Replays. + */ + +import { buildRouteMap } from "../../lib/route-map.js"; +import { listCommand } from "./list.js"; +import { viewCommand } from "./view.js"; + +export const replayRoute = buildRouteMap({ + routes: { + list: listCommand, + view: viewCommand, + }, + defaultCommand: "view", + docs: { + brief: "Search and inspect Session Replays", + fullDescription: + "Search and inspect Session Replays from your Sentry organization.\n\n" + + "Commands:\n" + + " list List recent replays in an org or project\n" + + " view View details of a specific replay\n\n" + + "Alias: `sentry replays` → `sentry replay list`", + hideRoute: {}, + }, +}); diff --git a/src/commands/replay/list.ts b/src/commands/replay/list.ts new file mode 100644 index 000000000..f18e8a7fa --- /dev/null +++ b/src/commands/replay/list.ts @@ -0,0 +1,407 @@ +/** + * sentry replay list + * + * List Session Replays from Sentry. + */ + +import type { Column } from "../../lib/formatters/table.js"; +import type { SentryContext } from "../../context.js"; +import { listReplays, type ReplaySortValue } from "../../lib/api-client.js"; +import { validateLimit } from "../../lib/arg-parsing.js"; +import { + advancePaginationState, + buildPaginationContextKey, + hasPreviousPage, + resolveCursor, +} from "../../lib/db/pagination.js"; +import { + escapeMarkdownCell, + formatRelativeTime, + formatTable, +} from "../../lib/formatters/index.js"; +import { filterFields } from "../../lib/formatters/json.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; +import { + appendQueryHint, + appendSortHint, + buildListCommand, + LIST_DEFAULT_LIMIT, + LIST_MAX_LIMIT, + LIST_MIN_LIMIT, + LIST_PERIOD_FLAG, + paginationHint, + PERIOD_ALIASES, + targetPatternExplanation, +} from "../../lib/list-command.js"; +import { withProgress } from "../../lib/polling.js"; +import { resolveOrgOptionalProjectFromArg } from "../../lib/resolve-target.js"; +import { sanitizeQuery } from "../../lib/search-query.js"; +import { + appendPeriodHint, + serializeTimeRange, + type TimeRange, + timeRangeToApiParams, +} from "../../lib/time-range.js"; +import { type ReplayListItem, ReplayListItemSchema } from "../../types/index.js"; + +type ListFlags = { + readonly limit: number; + readonly query?: string; + readonly sort: ReplaySortValue; + readonly period: TimeRange; + readonly json: boolean; + readonly cursor?: string; + readonly fresh: boolean; + readonly fields?: string[]; +}; + +type ReplayListResult = { + replays: ReplayListItem[]; + hasMore: boolean; + hasPrev: boolean; + nextCursor?: string | null; + org: string; + project?: string; +}; + +type ReplaySortKey = + | "date" + | "oldest" + | "duration" + | "errors" + | "segments" + | "activity"; + +const SORT_MAP: Record = { + date: "-started_at", + oldest: "started_at", + duration: "-duration", + errors: "-count_errors", + segments: "-count_segments", + activity: "-activity", +}; + +const DEFAULT_PERIOD = "7d"; +const DEFAULT_SORT: ReplaySortValue = SORT_MAP.date; +const PAGINATION_KEY = "replay-list"; +const COMMAND_NAME = "replay list"; + +function parseLimit(value: string): number { + return validateLimit(value, LIST_MIN_LIMIT, LIST_MAX_LIMIT); +} + +/** + * Parse user-facing replay sort values into API sort expressions. + */ +export function parseSort(value: string): ReplaySortValue { + const normalized = value.toLowerCase() as ReplaySortKey; + const mapped = SORT_MAP[normalized]; + if (!mapped) { + throw new Error( + `Invalid sort value. Must be one of: ${Object.keys(SORT_MAP).join(", ")}` + ); + } + return mapped; +} + +function formatCount(value: number | null | undefined): string { + return value === null || value === undefined ? "0" : String(value); +} + +function formatReplayDuration(seconds: number | null | undefined): string { + if (seconds === null || seconds === undefined) { + return "—"; + } + const rounded = Math.max(0, Math.round(seconds)); + if (rounded < 60) { + return `${rounded}s`; + } + if (rounded < 3600) { + const minutes = Math.floor(rounded / 60); + const remaining = rounded % 60; + return remaining > 0 ? `${minutes}m ${remaining}s` : `${minutes}m`; + } + if (rounded < 86_400) { + const hours = Math.floor(rounded / 3600); + const remainingMinutes = Math.floor((rounded % 3600) / 60); + return remainingMinutes > 0 + ? `${hours}h ${remainingMinutes}m` + : `${hours}h`; + } + + const days = Math.floor(rounded / 86_400); + const remainingHours = Math.floor((rounded % 86_400) / 3600); + return remainingHours > 0 ? `${days}d ${remainingHours}h` : `${days}d`; +} + +function replayUserLabel(replay: ReplayListItem): string { + const user = replay.user; + if (!user) { + return "—"; + } + return ( + user.display_name ?? + user.username ?? + user.email ?? + user.id ?? + user.ip ?? + "—" + ); +} + +const REPLAY_COLUMNS: Column[] = [ + { + header: "ID", + value: (replay) => `\`${replay.id.slice(0, 8)}\``, + minWidth: 8, + shrinkable: false, + }, + { + header: "STARTED", + value: (replay) => formatRelativeTime(replay.started_at ?? undefined), + minWidth: 10, + }, + { + header: "DURATION", + value: (replay) => formatReplayDuration(replay.duration), + minWidth: 10, + }, + { + header: "ERRORS:", + value: (replay) => formatCount(replay.count_errors), + align: "right", + minWidth: 6, + }, + { + header: "SEGMENTS:", + value: (replay) => formatCount(replay.count_segments), + align: "right", + minWidth: 8, + }, + { + header: "USER", + value: (replay) => escapeMarkdownCell(replayUserLabel(replay)), + minWidth: 16, + truncate: true, + }, + { + header: "PROJECT", + value: (replay) => replay.project_id ?? "—", + minWidth: 7, + }, +]; + +function formatScope(org: string, project?: string): string { + return project ? `${org}/${project}` : `${org}/`; +} + +function appendReplayFlags( + base: string, + flags: Pick +): string { + const parts: string[] = []; + appendQueryHint(parts, flags.query); + appendSortHint(parts, flags.sort, DEFAULT_SORT); + appendPeriodHint(parts, flags.period, DEFAULT_PERIOD); + return parts.length > 0 ? `${base} ${parts.join(" ")}` : base; +} + +function nextPageHint( + org: string, + project: string | undefined, + flags: Pick +): string { + return appendReplayFlags( + `sentry replay list ${formatScope(org, project)} -c next`, + flags + ); +} + +function prevPageHint( + org: string, + project: string | undefined, + flags: Pick +): string { + return appendReplayFlags( + `sentry replay list ${formatScope(org, project)} -c prev`, + flags + ); +} + +function formatReplayListHuman(result: ReplayListResult): string { + const { replays, hasMore, org, project } = result; + if (replays.length === 0) { + return hasMore ? "No replays on this page." : "No replays found."; + } + + const scope = project ? `${org}/${project}` : `${org} (all projects)`; + return `Recent replays in ${scope}:\n\n${formatTable(replays, REPLAY_COLUMNS, { truncate: true })}`; +} + +function jsonTransformReplayList( + result: ReplayListResult, + fields?: string[] +): unknown { + const items = + fields && fields.length > 0 + ? result.replays.map((replay) => filterFields(replay, fields)) + : result.replays; + + const envelope: Record = { + data: items, + hasMore: result.hasMore, + hasPrev: result.hasPrev, + }; + if ( + result.nextCursor !== null && + result.nextCursor !== undefined && + result.nextCursor !== "" + ) { + envelope.nextCursor = result.nextCursor; + } + return envelope; +} + +export const listCommand = buildListCommand("replay", { + docs: { + brief: "List recent Session Replays", + fullDescription: + "List recent Session Replays from Sentry.\n\n" + + "Target patterns:\n" + + " sentry replay list # auto-detect org from config or DSN\n" + + " sentry replay list / # list all org replays\n" + + " sentry replay list / # list replays for one project\n" + + " sentry replay list # find project across all orgs\n\n" + + `${targetPatternExplanation()}\n\n` + + "Examples:\n" + + " sentry replay list\n" + + " sentry replay list sentry/\n" + + " sentry replay list sentry/cli --limit 50\n" + + " sentry replay list sentry/cli --sort duration\n" + + ' sentry replay list sentry/cli -q "user.email:foo@example.com"\n' + + " sentry replay list sentry/cli --period 24h\n\n" + + "Alias: `sentry replays` → `sentry replay list`", + }, + output: { + human: formatReplayListHuman, + jsonTransform: jsonTransformReplayList, + schema: ReplayListItemSchema, + }, + parameters: { + positional: { + kind: "tuple", + parameters: [ + { + placeholder: "org/project", + brief: "/, /, or (search)", + parse: String, + optional: true, + }, + ], + }, + flags: { + limit: { + kind: "parsed", + parse: parseLimit, + brief: `Number of replays (${LIST_MIN_LIMIT}-${LIST_MAX_LIMIT})`, + default: String(LIST_DEFAULT_LIMIT), + }, + query: { + kind: "parsed", + parse: sanitizeQuery, + brief: "Search query (Sentry replay search syntax)", + optional: true, + }, + sort: { + kind: "parsed", + parse: parseSort, + brief: "Sort by: date, oldest, duration, errors, segments, activity", + default: "date", + }, + period: LIST_PERIOD_FLAG, + }, + aliases: { + ...PERIOD_ALIASES, + n: "limit", + q: "query", + s: "sort", + }, + }, + async *func(this: SentryContext, flags: ListFlags, target?: string) { + const { cwd } = this; + const timeRange = flags.period; + const { query } = flags; + + const resolved = await resolveOrgOptionalProjectFromArg( + target, + cwd, + COMMAND_NAME + ); + + const contextKey = buildPaginationContextKey( + "replay", + formatScope(resolved.org, resolved.project), + { + sort: flags.sort, + q: query, + period: serializeTimeRange(timeRange), + } + ); + const { cursor, direction } = resolveCursor( + flags.cursor, + PAGINATION_KEY, + contextKey + ); + + const { data: replays, nextCursor } = await withProgress( + { + message: `Fetching replays (up to ${flags.limit})...`, + json: flags.json, + }, + () => + listReplays(resolved.org, { + limit: flags.limit, + query, + projectSlugs: resolved.project ? [resolved.project] : undefined, + sort: flags.sort, + cursor, + ...timeRangeToApiParams(timeRange), + }) + ); + + advancePaginationState(PAGINATION_KEY, contextKey, direction, nextCursor); + const hasPrev = hasPreviousPage(PAGINATION_KEY, contextKey); + const hasMore = !!nextCursor; + + const nav = paginationHint({ + hasMore, + hasPrev, + prevHint: prevPageHint(resolved.org, resolved.project, flags), + nextHint: nextPageHint(resolved.org, resolved.project, flags), + }); + + let hint: string | undefined; + if (replays.length === 0 && nav) { + hint = `No replays on this page. ${nav}`; + } else if (replays.length > 0) { + const countText = `Showing ${replays.length} replay${replays.length === 1 ? "" : "s"}.`; + const firstReplay = replays[0]; + const replayHint = firstReplay + ? `Use 'sentry replay view ${resolved.org}/${firstReplay.id}' for details.` + : undefined; + hint = nav + ? `${countText} ${nav}` + : [countText, replayHint].filter(Boolean).join(" "); + } + + yield new CommandOutput({ + replays, + hasMore, + hasPrev, + nextCursor, + org: resolved.org, + project: resolved.project, + }); + return { hint }; + }, +}); diff --git a/src/commands/replay/view.ts b/src/commands/replay/view.ts new file mode 100644 index 000000000..62fd05ede --- /dev/null +++ b/src/commands/replay/view.ts @@ -0,0 +1,443 @@ +/** + * sentry replay view + * + * View detailed information about a Session Replay. + */ + +import type { SentryContext } from "../../context.js"; +import { getReplay } from "../../lib/api-client.js"; +import { + detectSwappedViewArgs, + parseSlashSeparatedArg, +} from "../../lib/arg-parsing.js"; +import { openInBrowser } from "../../lib/browser.js"; +import { buildCommand } from "../../lib/command.js"; +import { + ApiError, + ContextError, + ResolutionError, +} from "../../lib/errors.js"; +import { + escapeMarkdownCell, + escapeMarkdownInline, + mdKvTable, + renderMarkdown, +} from "../../lib/formatters/index.js"; +import { filterFields } from "../../lib/formatters/json.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; +import { validateHexId } from "../../lib/hex-id.js"; +import { + applyFreshFlag, + FRESH_ALIASES, + FRESH_FLAG, +} from "../../lib/list-command.js"; +import { resolveOrgOptionalProjectFromArg } from "../../lib/resolve-target.js"; +import { buildReplayUrl } from "../../lib/sentry-urls.js"; +import type { ReplayDetails } from "../../types/index.js"; +import { ReplayDetailsSchema } from "../../types/index.js"; + +type ViewFlags = { + readonly json: boolean; + readonly web: boolean; + readonly fresh: boolean; + readonly fields?: string[]; +}; + +type ParsedPositionalArgs = { + replayId: string; + targetArg: string | undefined; + warning?: string; +}; + +const USAGE_HINT = "sentry replay view [//]"; +const REPLAY_ID_SEGMENT_RE = /^[0-9a-fA-F-]{16,36}$/; + +function pluralize(value: number, singular: string): string { + return `${value} ${singular}${value === 1 ? "" : "s"}`; +} + +function formatReplayDuration(seconds: number): string { + const rounded = Math.max(0, Math.round(seconds)); + if (rounded < 60) { + return pluralize(rounded, "second"); + } + + const minutes = Math.floor(rounded / 60); + const remainingSeconds = rounded % 60; + if (minutes < 60) { + return remainingSeconds > 0 + ? `${pluralize(minutes, "minute")} and ${pluralize(remainingSeconds, "second")}` + : pluralize(minutes, "minute"); + } + + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + if (hours < 24) { + return remainingMinutes > 0 + ? `${pluralize(hours, "hour")} and ${pluralize(remainingMinutes, "minute")}` + : pluralize(hours, "hour"); + } + + const days = Math.floor(hours / 24); + const remainingHours = hours % 24; + return remainingHours > 0 + ? `${pluralize(days, "day")} and ${pluralize(remainingHours, "hour")}` + : pluralize(days, "day"); +} + +function parseSingleArg(arg: string): ParsedPositionalArgs { + const trimmed = arg.trim(); + if (!trimmed) { + throw new ContextError("Replay ID", USAGE_HINT, []); + } + + const slashIdx = trimmed.indexOf("/"); + if (slashIdx !== -1 && trimmed.indexOf("/", slashIdx + 1) === -1) { + const org = trimmed.slice(0, slashIdx); + const replayId = trimmed.slice(slashIdx + 1); + if (!replayId || !REPLAY_ID_SEGMENT_RE.test(replayId)) { + throw new ContextError("Replay ID", USAGE_HINT, []); + } + return { replayId, targetArg: `${org}/` }; + } + + const { id: replayId, targetArg } = parseSlashSeparatedArg( + trimmed, + "Replay ID", + USAGE_HINT + ); + return { replayId, targetArg }; +} + +/** + * Parse replay view positional arguments. + * + * Supports: + * - `` + * - `/` + * - `//` + * - ` ` + */ +export function parsePositionalArgs(args: string[]): ParsedPositionalArgs { + if (args.length === 0) { + throw new ContextError("Replay ID", USAGE_HINT, []); + } + + const first = args[0]; + if (!first) { + throw new ContextError("Replay ID", USAGE_HINT, []); + } + + if (args.length === 1) { + return parseSingleArg(first); + } + + const second = args[1]; + if (!second) { + throw new ContextError("Replay ID", USAGE_HINT, []); + } + + const warning = + args.length === 2 ? detectSwappedViewArgs(first, second) : null; + if (warning) { + return { + replayId: first, + targetArg: second, + warning, + }; + } + + return { replayId: second, targetArg: first }; +} + +function replayUserLabel(replay: ReplayDetails): string | undefined { + const user = replay.user; + if (!user) { + return; + } + return ( + user.display_name ?? + user.username ?? + user.email ?? + user.id ?? + user.ip ?? + undefined + ); +} + +function formatList(values: string[] | undefined): string | undefined { + if (!values || values.length === 0) { + return; + } + return values.map((value) => `- \`${value}\``).join("\n"); +} + +function formatReplayDetails(replay: ReplayDetails): string { + const lines: string[] = []; + const kvRows: [string, string][] = [["Replay ID", `\`${replay.id}\``]]; + + if (replay.started_at) { + kvRows.push(["Started", new Date(replay.started_at).toLocaleString()]); + } + if (replay.finished_at) { + kvRows.push(["Finished", new Date(replay.finished_at).toLocaleString()]); + } + if (replay.duration !== null && replay.duration !== undefined) { + kvRows.push(["Duration", formatReplayDuration(replay.duration)]); + } + if (replay.environment) { + kvRows.push(["Environment", escapeMarkdownCell(replay.environment)]); + } + if (replay.platform) { + kvRows.push(["Platform", escapeMarkdownCell(replay.platform)]); + } + if (replay.project_id) { + kvRows.push(["Project ID", replay.project_id]); + } + if (replay.replay_type) { + kvRows.push(["Replay Type", escapeMarkdownCell(replay.replay_type)]); + } + if (replay.is_archived !== undefined && replay.is_archived !== null) { + kvRows.push(["Archived", replay.is_archived ? "Yes" : "No"]); + } + if (replay.has_viewed !== undefined && replay.has_viewed !== null) { + kvRows.push(["Viewed", replay.has_viewed ? "Yes" : "No"]); + } + if (replay.count_errors !== null && replay.count_errors !== undefined) { + kvRows.push(["Errors", String(replay.count_errors)]); + } + if (replay.count_segments !== null && replay.count_segments !== undefined) { + kvRows.push(["Segments", String(replay.count_segments)]); + } + if ( + replay.count_rage_clicks !== null && + replay.count_rage_clicks !== undefined + ) { + kvRows.push(["Rage Clicks", String(replay.count_rage_clicks)]); + } + if ( + replay.count_dead_clicks !== null && + replay.count_dead_clicks !== undefined + ) { + kvRows.push(["Dead Clicks", String(replay.count_dead_clicks)]); + } + + lines.push(`## Replay \`${replay.id.slice(0, 8)}\``); + lines.push(""); + lines.push(mdKvTable(kvRows)); + + const userRows: [string, string][] = []; + if (replayUserLabel(replay)) { + userRows.push(["User", escapeMarkdownCell(replayUserLabel(replay) ?? "")]); + } + if (replay.user?.email) { + userRows.push(["Email", escapeMarkdownCell(replay.user.email)]); + } + if (replay.user?.ip) { + userRows.push(["IP", escapeMarkdownCell(replay.user.ip)]); + } + if (replay.user?.geo) { + const geoParts = [ + replay.user.geo.city, + replay.user.geo.region, + replay.user.geo.country_code, + ].filter(Boolean); + if (geoParts.length > 0) { + userRows.push(["Location", escapeMarkdownCell(geoParts.join(", "))]); + } + } + if (userRows.length > 0) { + lines.push(""); + lines.push(mdKvTable(userRows, "User")); + } + + const clientRows: [string, string][] = []; + if (replay.browser?.name || replay.browser?.version) { + clientRows.push([ + "Browser", + escapeMarkdownCell( + [replay.browser.name, replay.browser.version].filter(Boolean).join(" ") + ), + ]); + } + if (replay.os?.name || replay.os?.version) { + clientRows.push([ + "OS", + escapeMarkdownCell( + [replay.os.name, replay.os.version].filter(Boolean).join(" ") + ), + ]); + } + if (replay.device?.family || replay.device?.name || replay.device?.model_id) { + clientRows.push([ + "Device", + escapeMarkdownCell( + [ + replay.device.brand, + replay.device.family, + replay.device.name, + replay.device.model_id, + ] + .filter(Boolean) + .join(" ") + ), + ]); + } + if (replay.sdk?.name || replay.sdk?.version) { + clientRows.push([ + "SDK", + escapeMarkdownCell( + [replay.sdk.name, replay.sdk.version].filter(Boolean).join(" ") + ), + ]); + } + if (replay.dist) { + clientRows.push(["Dist", escapeMarkdownCell(replay.dist)]); + } + if (clientRows.length > 0) { + lines.push(""); + lines.push(mdKvTable(clientRows, "Client")); + } + + const releases = formatList(replay.releases); + if (releases) { + lines.push(""); + lines.push("### Releases"); + lines.push(""); + lines.push(releases); + } + + const urls = formatList(replay.urls); + if (urls) { + lines.push(""); + lines.push("### URLs"); + lines.push(""); + lines.push(urls); + } + + const traces = formatList(replay.trace_ids); + if (traces) { + lines.push(""); + lines.push("### Trace IDs"); + lines.push(""); + lines.push(traces); + } + + const errors = formatList(replay.error_ids); + if (errors) { + lines.push(""); + lines.push("### Error IDs"); + lines.push(""); + lines.push(errors); + } + + if ( + replay.tags && + !Array.isArray(replay.tags) && + Object.keys(replay.tags).length + ) { + lines.push(""); + lines.push("### Tags"); + lines.push(""); + for (const [key, values] of Object.entries(replay.tags).sort()) { + lines.push( + `- \`${escapeMarkdownInline(key)}\`: ${values.map((value) => `\`${escapeMarkdownInline(value)}\``).join(", ")}` + ); + } + } + + return renderMarkdown(lines.join("\n")); +} + +function replayHint(org: string, replay: ReplayDetails): string | undefined { + const traceId = replay.trace_ids?.[0]; + if (traceId) { + return `Related trace: sentry trace view ${org}/${traceId}`; + } + return; +} + +export const viewCommand = buildCommand({ + docs: { + brief: "View a Session Replay", + fullDescription: + "View detailed information about a Session Replay.\n\n" + + "Replay ID formats:\n" + + " - auto-detect org from config or DSN\n" + + " / - explicit organization\n" + + " // - explicit org/project context\n\n" + + "Examples:\n" + + " sentry replay view 346789a703f6454384f1de473b8b9fcc\n" + + " sentry replay view sentry/346789a703f6454384f1de473b8b9fcc\n" + + " sentry replay view sentry/cli/346789a703f6454384f1de473b8b9fcc\n" + + " sentry replay view --web sentry/346789a703f6454384f1de473b8b9fcc", + }, + output: { + human: formatReplayDetails, + jsonTransform: (replay: ReplayDetails, fields?: string[]) => + fields && fields.length > 0 ? filterFields(replay, fields) : replay, + schema: ReplayDetailsSchema, + }, + parameters: { + positional: { + kind: "array", + parameter: { + placeholder: "org/project/replay-id", + brief: + "[/] - Target (optional) and replay ID (required)", + parse: String, + }, + }, + flags: { + web: { + kind: "boolean", + brief: "Open in browser", + default: false, + }, + fresh: FRESH_FLAG, + }, + aliases: { ...FRESH_ALIASES, w: "web" }, + }, + async *func(this: SentryContext, flags: ViewFlags, ...args: string[]) { + applyFreshFlag(flags); + const { cwd } = this; + + const parsedArgs = parsePositionalArgs(args); + if (parsedArgs.warning) { + this.stderr.write(`${parsedArgs.warning}\n`); + } + + const replayId = validateHexId(parsedArgs.replayId, "replay ID"); + const resolved = await resolveOrgOptionalProjectFromArg( + parsedArgs.targetArg, + cwd, + "replay view" + ); + + if (flags.web) { + await openInBrowser(buildReplayUrl(resolved.org, replayId), "replay"); + return; + } + + let replay: ReplayDetails; + try { + replay = await getReplay(resolved.org, replayId); + } catch (error) { + if (error instanceof ApiError && error.status === 404) { + throw new ResolutionError( + `Replay '${replayId}'`, + "not found", + `sentry replay view ${resolved.org}/${replayId}`, + [ + "Check that you are querying the right organization", + "The replay may be past your retention window", + ] + ); + } + throw error; + } + + yield new CommandOutput(replay); + return { hint: replayHint(resolved.org, replay) }; + }, +}); diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index de0688981..8b42a9e44 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -12,6 +12,7 @@ * - repositories: repository listing * - issues: issue listing, lookup, status updates * - events: event retrieval and resolution + * - replays: replay listing and detail lookup * - traces: trace details and transactions * - logs: log listing, detailed fetch, trace-logs * - seer: Seer AI root cause analysis and planning @@ -78,6 +79,12 @@ export { listLogs, listTraceLogs, } from "./api/logs.js"; +export { + getReplay, + listReplays, + type ListReplaysOptions, + type ReplaySortValue, +} from "./api/replays.js"; export { getOrganization, getUserRegions, diff --git a/src/lib/api/replays.ts b/src/lib/api/replays.ts new file mode 100644 index 000000000..cb40f55d5 --- /dev/null +++ b/src/lib/api/replays.ts @@ -0,0 +1,126 @@ +/** + * Replay API functions + * + * Functions for listing and retrieving Session Replays. + */ + +import { + REPLAY_LIST_FIELDS, + ReplayDetailsResponseSchema, + type ReplayDetails, + ReplayListResponseSchema, + type ReplayListItem, +} from "../../types/index.js"; + +import { resolveOrgRegion } from "../region.js"; + +import { + API_MAX_PER_PAGE, + apiRequestToRegion, + autoPaginate, + type PaginatedResponse, + parseLinkHeader, +} from "./infrastructure.js"; + +/** Sort values supported by the CLI replay list command. */ +export type ReplaySortValue = + | "-started_at" + | "started_at" + | "-duration" + | "-count_errors" + | "-count_segments" + | "-activity"; + +/** Options for {@link listReplays}. */ +export type ListReplaysOptions = { + /** Limit total rows returned across auto-paginated pages. */ + limit?: number; + /** Structured replay query using Sentry search syntax. */ + query?: string; + /** Project slugs to filter by. */ + projectSlugs?: string[]; + /** Sort expression for the replay index endpoint. */ + sort?: ReplaySortValue; + /** Pagination cursor from a previous response. */ + cursor?: string; + /** Relative time period (e.g. "7d", "24h"). Overrides start/end on the API. */ + statsPeriod?: string; + /** Absolute start datetime (ISO-8601). Mutually exclusive with statsPeriod. */ + start?: string; + /** Absolute end datetime (ISO-8601). Mutually exclusive with statsPeriod. */ + end?: string; +}; + +/** + * Fetch a single page of replays from the organization replay index. + */ +async function fetchReplayPage( + regionUrl: string, + orgSlug: string, + options: ListReplaysOptions, + perPage: number, + cursor?: string +): Promise> { + const { data, headers } = await apiRequestToRegion( + regionUrl, + `/organizations/${orgSlug}/replays/`, + { + params: { + cursor, + field: [...REPLAY_LIST_FIELDS], + per_page: perPage, + projectSlug: options.projectSlugs, + query: options.query, + sort: options.sort ?? "-started_at", + statsPeriod: + options.start || options.end + ? undefined + : (options.statsPeriod ?? "7d"), + start: options.start, + end: options.end, + }, + schema: ReplayListResponseSchema, + } + ); + + const { nextCursor } = parseLinkHeader(headers.get("link") ?? null); + return { data: data.data, nextCursor }; +} + +/** + * List replays for an organization, optionally filtered to one or more projects. + * + * Auto-paginates when `limit` exceeds the API's per-page cap. + */ +export async function listReplays( + orgSlug: string, + options: ListReplaysOptions = {} +): Promise> { + const limit = options.limit ?? 25; + const perPage = Math.min(limit, API_MAX_PER_PAGE); + const regionUrl = await resolveOrgRegion(orgSlug); + + return autoPaginate( + (cursor) => fetchReplayPage(regionUrl, orgSlug, options, perPage, cursor), + limit, + options.cursor + ); +} + +/** + * Fetch a single replay by ID. + */ +export async function getReplay( + orgSlug: string, + replayId: string +): Promise { + const regionUrl = await resolveOrgRegion(orgSlug); + const { data } = await apiRequestToRegion( + regionUrl, + `/organizations/${orgSlug}/replays/${replayId}/`, + { + schema: ReplayDetailsResponseSchema, + } + ); + return data.data; +} diff --git a/src/lib/formatters/human.ts b/src/lib/formatters/human.ts index bdae0a41e..7ec97148e 100644 --- a/src/lib/formatters/human.ts +++ b/src/lib/formatters/human.ts @@ -1332,7 +1332,7 @@ function buildReplayMarkdown( if (issuePermalink) { const match = BASE_URL_REGEX.exec(issuePermalink); if (match?.[1]) { - lines.push(`**Link:** ${match[1]}/replays/${replayTag.value}/`); + lines.push(`**Link:** ${match[1]}/explore/replays/${replayTag.value}/`); } } diff --git a/src/lib/sentry-urls.ts b/src/lib/sentry-urls.ts index 11e8df400..79b710cf1 100644 --- a/src/lib/sentry-urls.ts +++ b/src/lib/sentry-urls.ts @@ -256,6 +256,20 @@ export function buildLogsUrl(orgSlug: string, logId?: string): string { return logId ? `${base}?query=sentry.item_id:${logId}` : base; } +/** + * Build URL to view a replay in the Replay explorer. + * + * @param orgSlug - Organization slug + * @param replayId - Replay ID (32-character hex string) + * @returns Full URL to the replay detail view + */ +export function buildReplayUrl(orgSlug: string, replayId: string): string { + if (isSaaS()) { + return `${getOrgBaseUrl(orgSlug)}/explore/replays/${replayId}/`; + } + return `${getSentryBaseUrl()}/organizations/${orgSlug}/explore/replays/${replayId}/`; +} + // Dashboard URLs /** diff --git a/src/types/index.ts b/src/types/index.ts index c3b2f2390..71f3cc902 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -34,6 +34,34 @@ export { DashboardWidgetQuerySchema, DashboardWidgetSchema, } from "./dashboard.js"; +// Replay types and schemas +export type { + ReplayBrowser, + ReplayDetails, + ReplayDetailsResponse, + ReplayDevice, + ReplayGeo, + ReplayListItem, + ReplayListResponse, + ReplayOs, + ReplayOtaUpdates, + ReplaySdk, + ReplayUser, +} from "./replay.js"; +export { + ReplayBrowserSchema, + ReplayDetailsResponseSchema, + ReplayDetailsSchema, + ReplayDeviceSchema, + ReplayGeoSchema, + ReplayListItemSchema, + ReplayListResponseSchema, + REPLAY_LIST_FIELDS, + ReplayOsSchema, + ReplayOtaUpdatesSchema, + ReplaySdkSchema, + ReplayUserSchema, +} from "./replay.js"; // OAuth types and schemas export type { DeviceCodeResponse, diff --git a/src/types/replay.ts b/src/types/replay.ts new file mode 100644 index 000000000..8a73b2385 --- /dev/null +++ b/src/types/replay.ts @@ -0,0 +1,262 @@ +import { z } from "zod"; + +/** + * User geo metadata attached to a replay. + */ +export const ReplayGeoSchema = z + .object({ + city: z.string().nullish().describe("City"), + country_code: z.string().nullish().describe("Country code"), + region: z.string().nullish().describe("Region"), + subdivision: z.string().nullish().describe("Subdivision"), + }) + .passthrough(); + +/** + * User metadata attached to a replay. + */ +export const ReplayUserSchema = z + .object({ + id: z.string().nullish().describe("User ID"), + username: z.string().nullish().describe("Username"), + email: z.string().nullish().describe("Email"), + ip: z.string().nullish().describe("IP address"), + display_name: z.string().nullish().describe("Display name"), + geo: ReplayGeoSchema.optional().describe("Geo metadata"), + }) + .passthrough(); + +/** + * Browser metadata attached to a replay. + */ +export const ReplayBrowserSchema = z + .object({ + name: z.string().nullish().describe("Browser name"), + version: z.string().nullish().describe("Browser version"), + }) + .passthrough(); + +/** + * Operating system metadata attached to a replay. + */ +export const ReplayOsSchema = z + .object({ + name: z.string().nullish().describe("OS name"), + version: z.string().nullish().describe("OS version"), + }) + .passthrough(); + +/** + * SDK metadata attached to a replay. + */ +export const ReplaySdkSchema = z + .object({ + name: z.string().nullish().describe("SDK name"), + version: z.string().nullish().describe("SDK version"), + }) + .passthrough(); + +/** + * Device metadata attached to a replay. + */ +export const ReplayDeviceSchema = z + .object({ + brand: z.string().nullish().describe("Device brand"), + family: z.string().nullish().describe("Device family"), + model: z.string().nullish().describe("Device model"), + model_id: z.string().nullish().describe("Device model identifier"), + name: z.string().nullish().describe("Device name"), + }) + .passthrough(); + +/** + * OTA update metadata attached to a replay. + */ +export const ReplayOtaUpdatesSchema = z + .object({ + channel: z.string().nullish().describe("OTA update channel"), + runtime_version: z.string().nullish().describe("OTA runtime version"), + update_id: z.string().nullish().describe("OTA update ID"), + }) + .passthrough(); + +/** + * Replay tags keyed by tag name. + * + * Archived replay rows sometimes return an empty array instead of a tag map, + * so the schema accepts both shapes. + */ +export const ReplayTagsSchema = z.union([ + z.record(z.array(z.string())).describe("Replay tags"), + z.array(z.unknown()).describe("Archived replay tags placeholder"), +]); + +/** + * Known root fields that the replay list endpoint accepts in repeated `field=` + * query params. + * + * These are intentionally the root field names expected by the backend + * validator, not dotted nested field names. + */ +export const REPLAY_LIST_FIELDS = [ + "activity", + "browser", + "count_dead_clicks", + "count_errors", + "count_infos", + "count_rage_clicks", + "count_segments", + "count_urls", + "count_warnings", + "device", + "dist", + "duration", + "environment", + "error_ids", + "finished_at", + "has_viewed", + "id", + "info_ids", + "is_archived", + "os", + "platform", + "project_id", + "releases", + "sdk", + "started_at", + "tags", + "trace_ids", + "urls", + "user", + "warning_ids", +] as const; + +/** + * A single replay row returned by the organization replay index endpoint. + * + * Duration is in seconds, matching the backend replay interchange format. + */ +export const ReplayListItemSchema = z + .object({ + activity: z.number().nullable().optional().describe("Replay activity score"), + browser: ReplayBrowserSchema.optional().describe("Browser metadata"), + count_dead_clicks: z + .number() + .nullable() + .optional() + .describe("Dead click count"), + count_errors: z + .number() + .nullable() + .optional() + .describe("Associated error count"), + count_infos: z.number().nullable().optional().describe("Info event count"), + count_rage_clicks: z + .number() + .nullable() + .optional() + .describe("Rage click count"), + count_segments: z + .number() + .nullable() + .optional() + .describe("Recording segment count"), + count_urls: z.number().nullable().optional().describe("Visited URL count"), + count_warnings: z + .number() + .nullable() + .optional() + .describe("Warning event count"), + device: ReplayDeviceSchema.optional().describe("Device metadata"), + dist: z.string().nullable().optional().describe("Distribution"), + duration: z + .number() + .nullable() + .optional() + .describe("Replay duration in seconds"), + environment: z.string().nullable().optional().describe("Environment"), + error_ids: z.array(z.string()).optional().describe("Linked error IDs"), + finished_at: z + .string() + .nullable() + .optional() + .describe("Replay finish timestamp"), + has_viewed: z + .boolean() + .nullable() + .optional() + .describe("Whether the current user has viewed the replay"), + id: z.string().describe("Replay ID"), + info_ids: z.array(z.string()).optional().describe("Linked info event IDs"), + is_archived: z.boolean().nullable().optional().describe("Archived flag"), + os: ReplayOsSchema.optional().describe("Operating system metadata"), + platform: z.string().nullable().optional().describe("Platform"), + project_id: z + .string() + .nullable() + .optional() + .describe("Numeric project ID"), + releases: z.array(z.string()).optional().describe("Associated releases"), + sdk: ReplaySdkSchema.optional().describe("SDK metadata"), + started_at: z + .string() + .nullable() + .optional() + .describe("Replay start timestamp"), + tags: ReplayTagsSchema.optional().describe("Replay tags"), + trace_ids: z.array(z.string()).optional().describe("Linked trace IDs"), + urls: z.array(z.string()).optional().describe("Visited URLs"), + user: ReplayUserSchema.optional().describe("User metadata"), + warning_ids: z + .array(z.string()) + .optional() + .describe("Linked warning event IDs"), + }) + .passthrough() + .describe("Replay list row"); + +/** + * Click selector summaries attached to replay detail responses. + */ +export const ReplayClickSchema = z + .record(z.unknown()) + .describe("Replay click selector summary"); + +/** + * Full replay metadata returned by the replay detail endpoint. + */ +export const ReplayDetailsSchema = ReplayListItemSchema.extend({ + clicks: z.array(ReplayClickSchema).optional().describe("Replay click summaries"), + ota_updates: ReplayOtaUpdatesSchema.optional().describe("OTA update metadata"), + replay_type: z + .string() + .nullable() + .optional() + .describe("Replay type"), +}).describe("Replay details"); + +/** Envelope returned by the replay index endpoint. */ +export const ReplayListResponseSchema = z + .object({ + data: z.array(ReplayListItemSchema), + }) + .passthrough(); + +/** Envelope returned by the replay detail endpoint. */ +export const ReplayDetailsResponseSchema = z + .object({ + data: ReplayDetailsSchema, + }) + .passthrough(); + +export type ReplayGeo = z.infer; +export type ReplayUser = z.infer; +export type ReplayBrowser = z.infer; +export type ReplayOs = z.infer; +export type ReplaySdk = z.infer; +export type ReplayDevice = z.infer; +export type ReplayOtaUpdates = z.infer; +export type ReplayListItem = z.infer; +export type ReplayDetails = z.infer; +export type ReplayListResponse = z.infer; +export type ReplayDetailsResponse = z.infer; diff --git a/test/commands/replay/list.test.ts b/test/commands/replay/list.test.ts new file mode 100644 index 000000000..63d7539e0 --- /dev/null +++ b/test/commands/replay/list.test.ts @@ -0,0 +1,144 @@ +/** + * Replay List Command Tests + */ + +import { + afterEach, + beforeEach, + describe, + expect, + mock, + spyOn, + test, +} from "bun:test"; +import { listCommand, parseSort } from "../../../src/commands/replay/list.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as apiClient from "../../../src/lib/api-client.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as paginationDb from "../../../src/lib/db/pagination.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as resolveTarget from "../../../src/lib/resolve-target.js"; +import { parsePeriod } from "../../../src/lib/time-range.js"; +import type { ReplayListItem } from "../../../src/types/index.js"; + +describe("parseSort", () => { + test("accepts supported sort values", () => { + expect(parseSort("date")).toBe("-started_at"); + expect(parseSort("duration")).toBe("-duration"); + expect(parseSort("errors")).toBe("-count_errors"); + }); + + test("throws for invalid sort value", () => { + expect(() => parseSort("invalid")).toThrow("Invalid sort value"); + }); +}); + +describe("listCommand.func", () => { + let listReplaysSpy: ReturnType; + let resolveTargetSpy: ReturnType; + let resolveCursorSpy: ReturnType; + let advancePaginationStateSpy: ReturnType; + let hasPreviousPageSpy: ReturnType; + + const sampleReplays: ReplayListItem[] = [ + { + id: "346789a703f6454384f1de473b8b9fcc", + count_errors: 2, + count_segments: 5, + duration: 125, + started_at: "2025-01-30T14:32:15+00:00", + project_id: "42", + user: { display_name: "Test User" }, + }, + ]; + + function createMockContext() { + const stdoutWrite = mock(() => true); + return { + context: { + stdout: { write: stdoutWrite }, + stderr: { write: mock(() => true) }, + cwd: "/tmp", + }, + stdoutWrite, + }; + } + + beforeEach(() => { + listReplaysSpy = spyOn(apiClient, "listReplays"); + resolveTargetSpy = spyOn( + resolveTarget, + "resolveOrgOptionalProjectFromArg" + ); + resolveCursorSpy = spyOn(paginationDb, "resolveCursor").mockReturnValue({ + cursor: undefined, + direction: "next" as const, + }); + advancePaginationStateSpy = spyOn( + paginationDb, + "advancePaginationState" + ).mockReturnValue(undefined); + hasPreviousPageSpy = spyOn(paginationDb, "hasPreviousPage").mockReturnValue( + false + ); + }); + + afterEach(() => { + listReplaysSpy.mockRestore(); + resolveTargetSpy.mockRestore(); + resolveCursorSpy.mockRestore(); + advancePaginationStateSpy.mockRestore(); + hasPreviousPageSpy.mockRestore(); + }); + + test("renders JSON output and forwards project scope", async () => { + resolveTargetSpy.mockResolvedValue({ org: "test-org", project: "cli" }); + listReplaysSpy.mockResolvedValue({ + data: sampleReplays, + nextCursor: "0:25:0", + }); + + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call( + context, + { limit: 25, json: true, period: parsePeriod("7d"), sort: "-started_at" }, + "test-org/cli" + ); + + expect(listReplaysSpy).toHaveBeenCalledWith("test-org", { + limit: 25, + projectSlugs: ["cli"], + query: undefined, + sort: "-started_at", + cursor: undefined, + statsPeriod: "7d", + }); + + const output = stdoutWrite.mock.calls.map((call) => call[0]).join(""); + const parsed = JSON.parse(output); + expect(parsed.hasMore).toBe(true); + expect(parsed.hasPrev).toBe(false); + expect(parsed.nextCursor).toBe("0:25:0"); + expect(parsed.data[0].id).toBe(sampleReplays[0]?.id); + }); + + test("renders human output with a replay hint", async () => { + resolveTargetSpy.mockResolvedValue({ org: "test-org", project: "cli" }); + listReplaysSpy.mockResolvedValue({ data: sampleReplays }); + + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call( + context, + { limit: 25, json: false, period: parsePeriod("7d"), sort: "-started_at" }, + "test-org/cli" + ); + + const output = stdoutWrite.mock.calls.map((call) => call[0]).join(""); + expect(output).toContain("Recent replays in test-org/cli:"); + expect(output).toContain("Test User"); + expect(output).toContain("Showing 1 replay."); + expect(output).toContain("sentry replay view test-org/"); + }); +}); diff --git a/test/commands/replay/view.test.ts b/test/commands/replay/view.test.ts new file mode 100644 index 000000000..91ba2c88f --- /dev/null +++ b/test/commands/replay/view.test.ts @@ -0,0 +1,154 @@ +/** + * Replay View Command Tests + */ + +import { + afterEach, + beforeEach, + describe, + expect, + mock, + spyOn, + test, +} from "bun:test"; +import { + parsePositionalArgs, + viewCommand, +} from "../../../src/commands/replay/view.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as apiClient from "../../../src/lib/api-client.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as browser from "../../../src/lib/browser.js"; +import { + ApiError, + ContextError, + ResolutionError, +} from "../../../src/lib/errors.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as resolveTarget from "../../../src/lib/resolve-target.js"; +import type { ReplayDetails } from "../../../src/types/index.js"; + +const REPLAY_ID = "346789a703f6454384f1de473b8b9fcc"; + +function sampleReplay(overrides: Partial = {}): ReplayDetails { + return { + id: REPLAY_ID, + count_errors: 2, + count_segments: 5, + duration: 125, + started_at: "2025-01-30T14:32:15+00:00", + project_id: "42", + trace_ids: ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"], + user: { display_name: "Test User" }, + ...overrides, + }; +} + +describe("parsePositionalArgs", () => { + test("parses replay ID only", () => { + const result = parsePositionalArgs([REPLAY_ID]); + expect(result.replayId).toBe(REPLAY_ID); + expect(result.targetArg).toBeUndefined(); + }); + + test("parses org/replay-id shorthand", () => { + const result = parsePositionalArgs([`test-org/${REPLAY_ID}`]); + expect(result.replayId).toBe(REPLAY_ID); + expect(result.targetArg).toBe("test-org/"); + }); + + test("parses org/project/replay-id form", () => { + const result = parsePositionalArgs([`test-org/cli/${REPLAY_ID}`]); + expect(result.replayId).toBe(REPLAY_ID); + expect(result.targetArg).toBe("test-org/cli"); + }); + + test("detects swapped args", () => { + const result = parsePositionalArgs([REPLAY_ID, "test-org/cli"]); + expect(result.replayId).toBe(REPLAY_ID); + expect(result.targetArg).toBe("test-org/cli"); + expect(result.warning).toContain("reversed"); + }); + + test("throws ContextError for org/project with no replay ID", () => { + expect(() => parsePositionalArgs(["test-org/cli"])).toThrow(ContextError); + }); +}); + +describe("viewCommand.func", () => { + let getReplaySpy: ReturnType; + let resolveTargetSpy: ReturnType; + let openInBrowserSpy: ReturnType; + + function createMockContext() { + const stdoutWrite = mock(() => true); + return { + context: { + stdout: { write: stdoutWrite }, + stderr: { write: mock(() => true) }, + cwd: "/tmp", + }, + stdoutWrite, + }; + } + + beforeEach(() => { + getReplaySpy = spyOn(apiClient, "getReplay"); + resolveTargetSpy = spyOn( + resolveTarget, + "resolveOrgOptionalProjectFromArg" + ); + openInBrowserSpy = spyOn(browser, "openInBrowser").mockResolvedValue(); + }); + + afterEach(() => { + getReplaySpy.mockRestore(); + resolveTargetSpy.mockRestore(); + openInBrowserSpy.mockRestore(); + }); + + test("renders JSON output", async () => { + resolveTargetSpy.mockResolvedValue({ org: "test-org", project: "cli" }); + getReplaySpy.mockResolvedValue(sampleReplay()); + + const { context, stdoutWrite } = createMockContext(); + const func = await viewCommand.loader(); + await func.call(context, { json: true, web: false, fresh: false }, REPLAY_ID); + + const output = stdoutWrite.mock.calls.map((call) => call[0]).join(""); + const parsed = JSON.parse(output); + expect(parsed.id).toBe(REPLAY_ID); + expect(parsed.trace_ids[0]).toBe("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + }); + + test("opens the replay in the browser with --web", async () => { + resolveTargetSpy.mockResolvedValue({ org: "test-org", project: "cli" }); + + const { context } = createMockContext(); + const func = await viewCommand.loader(); + await func.call( + context, + { json: false, web: true, fresh: false }, + `test-org/${REPLAY_ID}` + ); + + expect(openInBrowserSpy).toHaveBeenCalledWith( + "https://test-org.sentry.io/explore/replays/346789a703f6454384f1de473b8b9fcc/", + "replay" + ); + }); + + test("converts missing replays into ResolutionError", async () => { + resolveTargetSpy.mockResolvedValue({ org: "test-org", project: "cli" }); + getReplaySpy.mockRejectedValue( + new ApiError("Failed to get replay", 404, "Not Found") + ); + + const { context } = createMockContext(); + const func = await viewCommand.loader(); + + await expect( + func.call(context, { json: false, web: false, fresh: false }, REPLAY_ID) + ).rejects.toThrow(ResolutionError); + }); +}); diff --git a/test/lib/api/replays.test.ts b/test/lib/api/replays.test.ts new file mode 100644 index 000000000..4168bbe7b --- /dev/null +++ b/test/lib/api/replays.test.ts @@ -0,0 +1,156 @@ +/** + * Tests for the replay API helpers. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { getReplay, listReplays } from "../../../src/lib/api/replays.js"; +import { DEFAULT_SENTRY_URL } from "../../../src/lib/constants.js"; +import { setAuthToken } from "../../../src/lib/db/auth.js"; +import { setOrgRegion } from "../../../src/lib/db/regions.js"; +import { mockFetch, useTestConfigDir } from "../../helpers.js"; + +useTestConfigDir("replays-api-test-"); + +const REPLAY_ID = "346789a703f6454384f1de473b8b9fcc"; + +function replayRow(id = REPLAY_ID) { + return { + id, + count_errors: 2, + count_segments: 4, + duration: 95, + started_at: "2025-01-30T14:32:15+00:00", + user: { display_name: "Test User" }, + }; +} + +describe("listReplays", () => { + let originalFetch: typeof globalThis.fetch; + + beforeEach(async () => { + originalFetch = globalThis.fetch; + await setAuthToken("test-token"); + setOrgRegion("test-org", DEFAULT_SENTRY_URL); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + test("calls the organization replay index with repeated field params", async () => { + let capturedUrl = ""; + + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + capturedUrl = req.url; + return new Response(JSON.stringify({ data: [replayRow()] }), { + status: 200, + headers: { + "Content-Type": "application/json", + Link: `; rel="next"; results="true"; cursor="0:25:0"`, + }, + }); + }); + + const result = await listReplays("test-org", { + limit: 25, + projectSlugs: ["cli"], + query: "count_errors:>0", + sort: "-count_errors", + statsPeriod: "24h", + }); + + const url = new URL(capturedUrl); + expect(url.pathname).toContain("/api/0/organizations/test-org/replays/"); + expect(url.searchParams.get("projectSlug")).toBe("cli"); + expect(url.searchParams.get("query")).toBe("count_errors:>0"); + expect(url.searchParams.get("sort")).toBe("-count_errors"); + expect(url.searchParams.get("statsPeriod")).toBe("24h"); + expect(url.searchParams.get("per_page")).toBe("25"); + expect(url.searchParams.getAll("field")).toContain("id"); + expect(url.searchParams.getAll("field")).toContain("user"); + expect(result.data).toHaveLength(1); + expect(result.nextCursor).toBe("0:25:0"); + }); + + test("auto-paginates when limit exceeds the API cap", async () => { + const capturedUrls: string[] = []; + let callIndex = 0; + + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + capturedUrls.push(req.url); + + const body = + callIndex === 0 + ? { + data: Array.from({ length: 100 }, (_, index) => + replayRow(index.toString(16).padStart(32, "a").slice(-32)) + ), + } + : { + data: Array.from({ length: 50 }, (_, index) => + replayRow(index.toString(16).padStart(32, "b").slice(-32)) + ), + }; + const headers = + callIndex === 0 + ? { + Link: `; rel="next"; results="true"; cursor="0:100:0"`, + } + : { + Link: `; rel="next"; results="false"; cursor=""`, + }; + callIndex += 1; + + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json", ...headers }, + }); + }); + + const result = await listReplays("test-org", { limit: 150 }); + + expect(result.data).toHaveLength(150); + expect(result.nextCursor).toBeUndefined(); + expect(capturedUrls).toHaveLength(2); + expect(capturedUrls.every((url) => url.includes("per_page=100"))).toBe( + true + ); + }); +}); + +describe("getReplay", () => { + let originalFetch: typeof globalThis.fetch; + + beforeEach(async () => { + originalFetch = globalThis.fetch; + await setAuthToken("test-token"); + setOrgRegion("test-org", DEFAULT_SENTRY_URL); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + test("calls the replay detail endpoint", async () => { + let capturedUrl = ""; + + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + capturedUrl = req.url; + return new Response(JSON.stringify({ data: replayRow() }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + const replay = await getReplay("test-org", REPLAY_ID); + + expect(capturedUrl).toContain( + `/api/0/organizations/test-org/replays/${REPLAY_ID}/` + ); + expect(replay.id).toBe(REPLAY_ID); + expect(replay.count_errors).toBe(2); + }); +}); From 5d25a37f4729de7f54500131b04f282f015fe306 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Fri, 1 May 2026 13:23:51 -0700 Subject: [PATCH 02/26] fix(replay): Address replay validation drift Align the new replay route with the repo's completion, fragment, and\nformatting expectations so CI can validate it cleanly.\n\nAdd the missing replay command fragment, keep the completion metadata in\nsync with the replay default view route, refactor replay view to satisfy\nlint rules, and regenerate the replay skill reference from the updated\nexamples.\n\nCo-Authored-By: OpenAI Codex --- docs/src/fragments/commands/replay.md | 38 ++ .../skills/sentry-cli/references/replay.md | 36 ++ src/app.ts | 4 +- src/commands/replay/list.ts | 9 +- src/commands/replay/view.ts | 373 ++++++++++-------- src/lib/api-client.ts | 12 +- src/lib/api/replays.ts | 18 +- src/lib/complete.ts | 2 + src/types/index.ts | 24 +- src/types/replay.ts | 25 +- test/commands/replay/list.test.ts | 12 +- test/commands/replay/view.test.ts | 11 +- test/lib/completions.property.test.ts | 1 + test/lib/formatters/human.details.test.ts | 4 +- 14 files changed, 353 insertions(+), 216 deletions(-) create mode 100644 docs/src/fragments/commands/replay.md diff --git a/docs/src/fragments/commands/replay.md b/docs/src/fragments/commands/replay.md new file mode 100644 index 000000000..b57df2515 --- /dev/null +++ b/docs/src/fragments/commands/replay.md @@ -0,0 +1,38 @@ + +## Examples + +### List replays + +```bash +# List recent replays for a project +sentry replay list my-org/frontend + +# Search across all projects in an org +sentry replay list my-org/ --query "environment:production" + +# Change the time window and sort +sentry replay list my-org/frontend --period 24h --sort errors + +# Paginate through results +sentry replay list my-org/frontend -c next +sentry replay list my-org/frontend -c prev + +# Output machine-readable data +sentry replay list my-org/frontend --json +``` + +### View a replay + +```bash +# View a replay by ID using auto-detected org/project context +sentry replay view 346789a703f6454384f1de473b8b9fcc + +# View a replay with an explicit org +sentry replay view my-org/346789a703f6454384f1de473b8b9fcc + +# View a replay with explicit org/project context +sentry replay view my-org/frontend/346789a703f6454384f1de473b8b9fcc + +# Open a replay in the browser +sentry replay view my-org/346789a703f6454384f1de473b8b9fcc --web +``` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/replay.md b/plugins/sentry-cli/skills/sentry-cli/references/replay.md index 165d0fd64..bd920c804 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/replay.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/replay.md @@ -58,6 +58,26 @@ List recent Session Replays | `user` | object | User metadata | | `warning_ids` | array | Linked warning event IDs | +**Examples:** + +```bash +# List recent replays for a project +sentry replay list my-org/frontend + +# Search across all projects in an org +sentry replay list my-org/ --query "environment:production" + +# Change the time window and sort +sentry replay list my-org/frontend --period 24h --sort errors + +# Paginate through results +sentry replay list my-org/frontend -c next +sentry replay list my-org/frontend -c prev + +# Output machine-readable data +sentry replay list my-org/frontend --json +``` + ### `sentry replay view ` View a Session Replay @@ -104,4 +124,20 @@ View a Session Replay | `ota_updates` | object | OTA update metadata | | `replay_type` | string \| null | Replay type | +**Examples:** + +```bash +# View a replay by ID using auto-detected org/project context +sentry replay view 346789a703f6454384f1de473b8b9fcc + +# View a replay with an explicit org +sentry replay view my-org/346789a703f6454384f1de473b8b9fcc + +# View a replay with explicit org/project context +sentry replay view my-org/frontend/346789a703f6454384f1de473b8b9fcc + +# Open a replay in the browser +sentry replay view my-org/346789a703f6454384f1de473b8b9fcc --web +``` + All commands also support `--json`, `--fields`, `--help`, `--log-level`, and `--verbose` flags. diff --git a/src/app.ts b/src/app.ts index d8e49cb5b..ea537a416 100644 --- a/src/app.ts +++ b/src/app.ts @@ -24,10 +24,10 @@ import { orgRoute } from "./commands/org/index.js"; import { listCommand as orgListCommand } from "./commands/org/list.js"; import { projectRoute } from "./commands/project/index.js"; import { listCommand as projectListCommand } from "./commands/project/list.js"; -import { replayRoute } from "./commands/replay/index.js"; -import { listCommand as replayListCommand } from "./commands/replay/list.js"; import { releaseRoute } from "./commands/release/index.js"; import { listCommand as releaseListCommand } from "./commands/release/list.js"; +import { replayRoute } from "./commands/replay/index.js"; +import { listCommand as replayListCommand } from "./commands/replay/list.js"; import { repoRoute } from "./commands/repo/index.js"; import { listCommand as repoListCommand } from "./commands/repo/list.js"; import { schemaCommand } from "./commands/schema.js"; diff --git a/src/commands/replay/list.ts b/src/commands/replay/list.ts index f18e8a7fa..e07f3fe89 100644 --- a/src/commands/replay/list.ts +++ b/src/commands/replay/list.ts @@ -4,7 +4,6 @@ * List Session Replays from Sentry. */ -import type { Column } from "../../lib/formatters/table.js"; import type { SentryContext } from "../../context.js"; import { listReplays, type ReplaySortValue } from "../../lib/api-client.js"; import { validateLimit } from "../../lib/arg-parsing.js"; @@ -21,6 +20,7 @@ import { } from "../../lib/formatters/index.js"; import { filterFields } from "../../lib/formatters/json.js"; import { CommandOutput } from "../../lib/formatters/output.js"; +import type { Column } from "../../lib/formatters/table.js"; import { appendQueryHint, appendSortHint, @@ -29,8 +29,8 @@ import { LIST_MAX_LIMIT, LIST_MIN_LIMIT, LIST_PERIOD_FLAG, - paginationHint, PERIOD_ALIASES, + paginationHint, targetPatternExplanation, } from "../../lib/list-command.js"; import { withProgress } from "../../lib/polling.js"; @@ -42,7 +42,10 @@ import { type TimeRange, timeRangeToApiParams, } from "../../lib/time-range.js"; -import { type ReplayListItem, ReplayListItemSchema } from "../../types/index.js"; +import { + type ReplayListItem, + ReplayListItemSchema, +} from "../../types/index.js"; type ListFlags = { readonly limit: number; diff --git a/src/commands/replay/view.ts b/src/commands/replay/view.ts index 62fd05ede..e15a1630b 100644 --- a/src/commands/replay/view.ts +++ b/src/commands/replay/view.ts @@ -12,11 +12,7 @@ import { } from "../../lib/arg-parsing.js"; import { openInBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; -import { - ApiError, - ContextError, - ResolutionError, -} from "../../lib/errors.js"; +import { ApiError, ContextError, ResolutionError } from "../../lib/errors.js"; import { escapeMarkdownCell, escapeMarkdownInline, @@ -49,6 +45,8 @@ type ParsedPositionalArgs = { warning?: string; }; +type MarkdownRow = [string, string]; + const USAGE_HINT = "sentry replay view [//]"; const REPLAY_ID_SEGMENT_RE = /^[0-9a-fA-F-]{16,36}$/; @@ -94,11 +92,11 @@ function parseSingleArg(arg: string): ParsedPositionalArgs { const slashIdx = trimmed.indexOf("/"); if (slashIdx !== -1 && trimmed.indexOf("/", slashIdx + 1) === -1) { const org = trimmed.slice(0, slashIdx); - const replayId = trimmed.slice(slashIdx + 1); - if (!replayId || !REPLAY_ID_SEGMENT_RE.test(replayId)) { + const replaySegment = trimmed.slice(slashIdx + 1); + if (!(replaySegment && REPLAY_ID_SEGMENT_RE.test(replaySegment))) { throw new ContextError("Replay ID", USAGE_HINT, []); } - return { replayId, targetArg: `${org}/` }; + return { replayId: replaySegment, targetArg: `${org}/` }; } const { id: replayId, targetArg } = parseSlashSeparatedArg( @@ -172,179 +170,226 @@ function formatList(values: string[] | undefined): string | undefined { return values.map((value) => `- \`${value}\``).join("\n"); } -function formatReplayDetails(replay: ReplayDetails): string { - const lines: string[] = []; - const kvRows: [string, string][] = [["Replay ID", `\`${replay.id}\``]]; - - if (replay.started_at) { - kvRows.push(["Started", new Date(replay.started_at).toLocaleString()]); - } - if (replay.finished_at) { - kvRows.push(["Finished", new Date(replay.finished_at).toLocaleString()]); - } - if (replay.duration !== null && replay.duration !== undefined) { - kvRows.push(["Duration", formatReplayDuration(replay.duration)]); - } - if (replay.environment) { - kvRows.push(["Environment", escapeMarkdownCell(replay.environment)]); - } - if (replay.platform) { - kvRows.push(["Platform", escapeMarkdownCell(replay.platform)]); - } - if (replay.project_id) { - kvRows.push(["Project ID", replay.project_id]); - } - if (replay.replay_type) { - kvRows.push(["Replay Type", escapeMarkdownCell(replay.replay_type)]); - } - if (replay.is_archived !== undefined && replay.is_archived !== null) { - kvRows.push(["Archived", replay.is_archived ? "Yes" : "No"]); - } - if (replay.has_viewed !== undefined && replay.has_viewed !== null) { - kvRows.push(["Viewed", replay.has_viewed ? "Yes" : "No"]); - } - if (replay.count_errors !== null && replay.count_errors !== undefined) { - kvRows.push(["Errors", String(replay.count_errors)]); - } - if (replay.count_segments !== null && replay.count_segments !== undefined) { - kvRows.push(["Segments", String(replay.count_segments)]); +function pushMarkdownRow( + rows: MarkdownRow[], + label: string, + value: string | undefined +): void { + if (!value) { + return; } - if ( - replay.count_rage_clicks !== null && - replay.count_rage_clicks !== undefined - ) { - kvRows.push(["Rage Clicks", String(replay.count_rage_clicks)]); + rows.push([label, value]); +} + +function formatYesNo(value: boolean | null | undefined): string | undefined { + if (value === null || value === undefined) { + return; } - if ( - replay.count_dead_clicks !== null && - replay.count_dead_clicks !== undefined - ) { - kvRows.push(["Dead Clicks", String(replay.count_dead_clicks)]); + return value ? "Yes" : "No"; +} + +function formatNullableCount( + value: number | null | undefined +): string | undefined { + if (value === null || value === undefined) { + return; } + return String(value); +} - lines.push(`## Replay \`${replay.id.slice(0, 8)}\``); - lines.push(""); - lines.push(mdKvTable(kvRows)); +function formatJoinedMarkdown( + values: Array +): string | undefined { + const joined = values.filter(Boolean).join(" "); + return joined ? escapeMarkdownCell(joined) : undefined; +} - const userRows: [string, string][] = []; - if (replayUserLabel(replay)) { - userRows.push(["User", escapeMarkdownCell(replayUserLabel(replay) ?? "")]); - } - if (replay.user?.email) { - userRows.push(["Email", escapeMarkdownCell(replay.user.email)]); - } - if (replay.user?.ip) { - userRows.push(["IP", escapeMarkdownCell(replay.user.ip)]); - } - if (replay.user?.geo) { - const geoParts = [ - replay.user.geo.city, - replay.user.geo.region, - replay.user.geo.country_code, - ].filter(Boolean); - if (geoParts.length > 0) { - userRows.push(["Location", escapeMarkdownCell(geoParts.join(", "))]); - } - } - if (userRows.length > 0) { - lines.push(""); - lines.push(mdKvTable(userRows, "User")); +function formatReplayLocation(replay: ReplayDetails): string | undefined { + const geo = replay.user?.geo; + if (!geo) { + return; } - const clientRows: [string, string][] = []; - if (replay.browser?.name || replay.browser?.version) { - clientRows.push([ - "Browser", - escapeMarkdownCell( - [replay.browser.name, replay.browser.version].filter(Boolean).join(" ") - ), - ]); - } - if (replay.os?.name || replay.os?.version) { - clientRows.push([ - "OS", - escapeMarkdownCell( - [replay.os.name, replay.os.version].filter(Boolean).join(" ") - ), - ]); - } - if (replay.device?.family || replay.device?.name || replay.device?.model_id) { - clientRows.push([ - "Device", - escapeMarkdownCell( - [ - replay.device.brand, - replay.device.family, - replay.device.name, - replay.device.model_id, - ] - .filter(Boolean) - .join(" ") - ), - ]); - } - if (replay.sdk?.name || replay.sdk?.version) { - clientRows.push([ - "SDK", - escapeMarkdownCell( - [replay.sdk.name, replay.sdk.version].filter(Boolean).join(" ") - ), - ]); - } - if (replay.dist) { - clientRows.push(["Dist", escapeMarkdownCell(replay.dist)]); - } - if (clientRows.length > 0) { - lines.push(""); - lines.push(mdKvTable(clientRows, "Client")); - } + const location = [geo.city, geo.region, geo.country_code] + .filter(Boolean) + .join(", "); + return location ? escapeMarkdownCell(location) : undefined; +} - const releases = formatList(replay.releases); - if (releases) { - lines.push(""); - lines.push("### Releases"); - lines.push(""); - lines.push(releases); - } +function buildReplayOverviewRows(replay: ReplayDetails): MarkdownRow[] { + const rows: MarkdownRow[] = [["Replay ID", `\`${replay.id}\``]]; - const urls = formatList(replay.urls); - if (urls) { - lines.push(""); - lines.push("### URLs"); - lines.push(""); - lines.push(urls); - } + pushMarkdownRow( + rows, + "Started", + replay.started_at ? new Date(replay.started_at).toLocaleString() : undefined + ); + pushMarkdownRow( + rows, + "Finished", + replay.finished_at + ? new Date(replay.finished_at).toLocaleString() + : undefined + ); + pushMarkdownRow( + rows, + "Duration", + replay.duration !== null && replay.duration !== undefined + ? formatReplayDuration(replay.duration) + : undefined + ); + pushMarkdownRow( + rows, + "Environment", + replay.environment ? escapeMarkdownCell(replay.environment) : undefined + ); + pushMarkdownRow( + rows, + "Platform", + replay.platform ? escapeMarkdownCell(replay.platform) : undefined + ); + pushMarkdownRow(rows, "Project ID", replay.project_id ?? undefined); + pushMarkdownRow( + rows, + "Replay Type", + replay.replay_type ? escapeMarkdownCell(replay.replay_type) : undefined + ); + pushMarkdownRow(rows, "Archived", formatYesNo(replay.is_archived)); + pushMarkdownRow(rows, "Viewed", formatYesNo(replay.has_viewed)); + pushMarkdownRow(rows, "Errors", formatNullableCount(replay.count_errors)); + pushMarkdownRow(rows, "Segments", formatNullableCount(replay.count_segments)); + pushMarkdownRow( + rows, + "Rage Clicks", + formatNullableCount(replay.count_rage_clicks) + ); + pushMarkdownRow( + rows, + "Dead Clicks", + formatNullableCount(replay.count_dead_clicks) + ); + + return rows; +} - const traces = formatList(replay.trace_ids); - if (traces) { - lines.push(""); - lines.push("### Trace IDs"); - lines.push(""); - lines.push(traces); +function buildReplayUserRows(replay: ReplayDetails): MarkdownRow[] { + const rows: MarkdownRow[] = []; + const userLabel = replayUserLabel(replay); + pushMarkdownRow( + rows, + "User", + userLabel ? escapeMarkdownCell(userLabel) : undefined + ); + pushMarkdownRow( + rows, + "Email", + replay.user?.email ? escapeMarkdownCell(replay.user.email) : undefined + ); + pushMarkdownRow( + rows, + "IP", + replay.user?.ip ? escapeMarkdownCell(replay.user.ip) : undefined + ); + pushMarkdownRow(rows, "Location", formatReplayLocation(replay)); + return rows; +} + +function buildReplayClientRows(replay: ReplayDetails): MarkdownRow[] { + const rows: MarkdownRow[] = []; + pushMarkdownRow( + rows, + "Browser", + formatJoinedMarkdown([replay.browser?.name, replay.browser?.version]) + ); + pushMarkdownRow( + rows, + "OS", + formatJoinedMarkdown([replay.os?.name, replay.os?.version]) + ); + pushMarkdownRow( + rows, + "Device", + formatJoinedMarkdown([ + replay.device?.brand, + replay.device?.family, + replay.device?.name, + replay.device?.model_id, + ]) + ); + pushMarkdownRow( + rows, + "SDK", + formatJoinedMarkdown([replay.sdk?.name, replay.sdk?.version]) + ); + pushMarkdownRow( + rows, + "Dist", + replay.dist ? escapeMarkdownCell(replay.dist) : undefined + ); + return rows; +} + +function pushKvSection( + lines: string[], + rows: MarkdownRow[], + title?: string +): void { + if (rows.length === 0) { + return; } + lines.push(""); + lines.push(mdKvTable(rows, title)); +} - const errors = formatList(replay.error_ids); - if (errors) { - lines.push(""); - lines.push("### Error IDs"); - lines.push(""); - lines.push(errors); +function pushListSection( + lines: string[], + title: string, + values: string[] | undefined +): void { + const content = formatList(values); + if (!content) { + return; } + lines.push(""); + lines.push(`### ${title}`); + lines.push(""); + lines.push(content); +} +function pushTagsSection(lines: string[], replay: ReplayDetails): void { if ( - replay.tags && - !Array.isArray(replay.tags) && - Object.keys(replay.tags).length + !replay.tags || + Array.isArray(replay.tags) || + Object.keys(replay.tags).length === 0 ) { - lines.push(""); - lines.push("### Tags"); - lines.push(""); - for (const [key, values] of Object.entries(replay.tags).sort()) { - lines.push( - `- \`${escapeMarkdownInline(key)}\`: ${values.map((value) => `\`${escapeMarkdownInline(value)}\``).join(", ")}` - ); - } + return; + } + + lines.push(""); + lines.push("### Tags"); + lines.push(""); + for (const [key, values] of Object.entries(replay.tags).sort()) { + lines.push( + `- \`${escapeMarkdownInline(key)}\`: ${values.map((value) => `\`${escapeMarkdownInline(value)}\``).join(", ")}` + ); } +} + +function formatReplayDetails(replay: ReplayDetails): string { + const lines: string[] = []; + + lines.push(`## Replay \`${replay.id.slice(0, 8)}\``); + lines.push(""); + lines.push(mdKvTable(buildReplayOverviewRows(replay))); + + pushKvSection(lines, buildReplayUserRows(replay), "User"); + pushKvSection(lines, buildReplayClientRows(replay), "Client"); + + pushListSection(lines, "Releases", replay.releases); + pushListSection(lines, "URLs", replay.urls); + pushListSection(lines, "Trace IDs", replay.trace_ids); + pushListSection(lines, "Error IDs", replay.error_ids); + pushTagsSection(lines, replay); return renderMarkdown(lines.join("\n")); } diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 8b42a9e44..6ceab1564 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -79,12 +79,6 @@ export { listLogs, listTraceLogs, } from "./api/logs.js"; -export { - getReplay, - listReplays, - type ListReplaysOptions, - type ReplaySortValue, -} from "./api/replays.js"; export { getOrganization, getUserRegions, @@ -126,6 +120,12 @@ export { setCommitsWithRefs, updateRelease, } from "./api/releases.js"; +export { + getReplay, + type ListReplaysOptions, + listReplays, + type ReplaySortValue, +} from "./api/replays.js"; export { listAllRepositories, listRepositories, diff --git a/src/lib/api/replays.ts b/src/lib/api/replays.ts index cb40f55d5..29ed342fe 100644 --- a/src/lib/api/replays.ts +++ b/src/lib/api/replays.ts @@ -6,10 +6,10 @@ import { REPLAY_LIST_FIELDS, - ReplayDetailsResponseSchema, type ReplayDetails, - ReplayListResponseSchema, + ReplayDetailsResponseSchema, type ReplayListItem, + ReplayListResponseSchema, } from "../../types/index.js"; import { resolveOrgRegion } from "../region.js"; @@ -51,16 +51,21 @@ export type ListReplaysOptions = { end?: string; }; +type FetchReplayPageOptions = { + options: ListReplaysOptions; + perPage: number; + cursor?: string; +}; + /** * Fetch a single page of replays from the organization replay index. */ async function fetchReplayPage( regionUrl: string, orgSlug: string, - options: ListReplaysOptions, - perPage: number, - cursor?: string + page: FetchReplayPageOptions ): Promise> { + const { cursor, options, perPage } = page; const { data, headers } = await apiRequestToRegion( regionUrl, `/organizations/${orgSlug}/replays/`, @@ -101,7 +106,8 @@ export async function listReplays( const regionUrl = await resolveOrgRegion(orgSlug); return autoPaginate( - (cursor) => fetchReplayPage(regionUrl, orgSlug, options, perPage, cursor), + (cursor) => + fetchReplayPage(regionUrl, orgSlug, { options, perPage, cursor }), limit, options.cursor ); diff --git a/src/lib/complete.ts b/src/lib/complete.ts index 746d0cdf5..b185e11ab 100644 --- a/src/lib/complete.ts +++ b/src/lib/complete.ts @@ -101,6 +101,8 @@ export const ORG_PROJECT_COMMANDS = new Set([ "project view", "project delete", "project create", + "replay list", + "replay view", "trace list", "trace view", "trace logs", diff --git a/src/types/index.ts b/src/types/index.ts index 71f3cc902..96e3d0507 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -34,6 +34,17 @@ export { DashboardWidgetQuerySchema, DashboardWidgetSchema, } from "./dashboard.js"; +// OAuth types and schemas +export type { + DeviceCodeResponse, + TokenErrorResponse, + TokenResponse, +} from "./oauth.js"; +export { + DeviceCodeResponseSchema, + TokenErrorResponseSchema, + TokenResponseSchema, +} from "./oauth.js"; // Replay types and schemas export type { ReplayBrowser, @@ -49,6 +60,7 @@ export type { ReplayUser, } from "./replay.js"; export { + REPLAY_LIST_FIELDS, ReplayBrowserSchema, ReplayDetailsResponseSchema, ReplayDetailsSchema, @@ -56,23 +68,11 @@ export { ReplayGeoSchema, ReplayListItemSchema, ReplayListResponseSchema, - REPLAY_LIST_FIELDS, ReplayOsSchema, ReplayOtaUpdatesSchema, ReplaySdkSchema, ReplayUserSchema, } from "./replay.js"; -// OAuth types and schemas -export type { - DeviceCodeResponse, - TokenErrorResponse, - TokenResponse, -} from "./oauth.js"; -export { - DeviceCodeResponseSchema, - TokenErrorResponseSchema, - TokenResponseSchema, -} from "./oauth.js"; export type { AutofixResponse, AutofixState, diff --git a/src/types/replay.ts b/src/types/replay.ts index 8a73b2385..1c74d896f 100644 --- a/src/types/replay.ts +++ b/src/types/replay.ts @@ -138,7 +138,11 @@ export const REPLAY_LIST_FIELDS = [ */ export const ReplayListItemSchema = z .object({ - activity: z.number().nullable().optional().describe("Replay activity score"), + activity: z + .number() + .nullable() + .optional() + .describe("Replay activity score"), browser: ReplayBrowserSchema.optional().describe("Browser metadata"), count_dead_clicks: z .number() @@ -191,11 +195,7 @@ export const ReplayListItemSchema = z is_archived: z.boolean().nullable().optional().describe("Archived flag"), os: ReplayOsSchema.optional().describe("Operating system metadata"), platform: z.string().nullable().optional().describe("Platform"), - project_id: z - .string() - .nullable() - .optional() - .describe("Numeric project ID"), + project_id: z.string().nullable().optional().describe("Numeric project ID"), releases: z.array(z.string()).optional().describe("Associated releases"), sdk: ReplaySdkSchema.optional().describe("SDK metadata"), started_at: z @@ -226,13 +226,14 @@ export const ReplayClickSchema = z * Full replay metadata returned by the replay detail endpoint. */ export const ReplayDetailsSchema = ReplayListItemSchema.extend({ - clicks: z.array(ReplayClickSchema).optional().describe("Replay click summaries"), - ota_updates: ReplayOtaUpdatesSchema.optional().describe("OTA update metadata"), - replay_type: z - .string() - .nullable() + clicks: z + .array(ReplayClickSchema) .optional() - .describe("Replay type"), + .describe("Replay click summaries"), + ota_updates: ReplayOtaUpdatesSchema.optional().describe( + "OTA update metadata" + ), + replay_type: z.string().nullable().optional().describe("Replay type"), }).describe("Replay details"); /** Envelope returned by the replay index endpoint. */ diff --git a/test/commands/replay/list.test.ts b/test/commands/replay/list.test.ts index 63d7539e0..98ac317de 100644 --- a/test/commands/replay/list.test.ts +++ b/test/commands/replay/list.test.ts @@ -66,10 +66,7 @@ describe("listCommand.func", () => { beforeEach(() => { listReplaysSpy = spyOn(apiClient, "listReplays"); - resolveTargetSpy = spyOn( - resolveTarget, - "resolveOrgOptionalProjectFromArg" - ); + resolveTargetSpy = spyOn(resolveTarget, "resolveOrgOptionalProjectFromArg"); resolveCursorSpy = spyOn(paginationDb, "resolveCursor").mockReturnValue({ cursor: undefined, direction: "next" as const, @@ -131,7 +128,12 @@ describe("listCommand.func", () => { const func = await listCommand.loader(); await func.call( context, - { limit: 25, json: false, period: parsePeriod("7d"), sort: "-started_at" }, + { + limit: 25, + json: false, + period: parsePeriod("7d"), + sort: "-started_at", + }, "test-org/cli" ); diff --git a/test/commands/replay/view.test.ts b/test/commands/replay/view.test.ts index 91ba2c88f..8f0583738 100644 --- a/test/commands/replay/view.test.ts +++ b/test/commands/replay/view.test.ts @@ -94,10 +94,7 @@ describe("viewCommand.func", () => { beforeEach(() => { getReplaySpy = spyOn(apiClient, "getReplay"); - resolveTargetSpy = spyOn( - resolveTarget, - "resolveOrgOptionalProjectFromArg" - ); + resolveTargetSpy = spyOn(resolveTarget, "resolveOrgOptionalProjectFromArg"); openInBrowserSpy = spyOn(browser, "openInBrowser").mockResolvedValue(); }); @@ -113,7 +110,11 @@ describe("viewCommand.func", () => { const { context, stdoutWrite } = createMockContext(); const func = await viewCommand.loader(); - await func.call(context, { json: true, web: false, fresh: false }, REPLAY_ID); + await func.call( + context, + { json: true, web: false, fresh: false }, + REPLAY_ID + ); const output = stdoutWrite.mock.calls.map((call) => call[0]).join(""); const parsed = JSON.parse(output); diff --git a/test/lib/completions.property.test.ts b/test/lib/completions.property.test.ts index d6827f2ca..7c46c133c 100644 --- a/test/lib/completions.property.test.ts +++ b/test/lib/completions.property.test.ts @@ -187,6 +187,7 @@ describe("proposeCompletions: Stricli integration", () => { "event", "org", "project", + "replay", "dashboard", "trace", "span", diff --git a/test/lib/formatters/human.details.test.ts b/test/lib/formatters/human.details.test.ts index a6b0eaecc..5423a81cf 100644 --- a/test/lib/formatters/human.details.test.ts +++ b/test/lib/formatters/human.details.test.ts @@ -800,7 +800,9 @@ describe("formatEventDetails", () => { ); expect(result).toContain("Replay"); expect(result).toContain("replay-uuid-123"); - expect(result).toContain("https://acme.sentry.io/replays/replay-uuid-123/"); + expect(result).toContain( + "https://acme.sentry.io/explore/replays/replay-uuid-123/" + ); }); test("includes tags when present", () => { From 1d75923df38a090e9af2acdb964ed0cae4bc2f10 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Fri, 1 May 2026 13:49:42 -0700 Subject: [PATCH 03/26] feat(replay): Improve replay lookup and linkage Accept replay URLs in replay view and harden the replay API layer for archived and mixed-shape payloads. This keeps replay lookup aligned with the existing Sentry URL parsing flow instead of forcing users back to raw IDs. Expose related replays more consistently from events and issues so replay data is easier to discover and chain into replay view from the CLI. Co-Authored-By: OpenAI Codex --- plugins/sentry-cli/skills/sentry-cli/SKILL.md | 2 +- .../skills/sentry-cli/references/replay.md | 24 +-- src/commands/event/view.ts | 3 +- src/commands/issue/view.ts | 93 ++++++++++-- src/commands/replay/list.ts | 9 +- src/commands/replay/view.ts | 51 +++++-- src/lib/api-client.ts | 1 + src/lib/api/replays.ts | 59 +++++++- src/lib/formatters/human.ts | 9 +- src/lib/replay-id.ts | 70 +++++++++ src/lib/sentry-url-parser.ts | 37 ++++- src/types/index.ts | 4 + src/types/replay.ts | 141 +++++++++++++++--- test/commands/issue/view.func.test.ts | 119 +++++++++++++++ test/commands/replay/list.test.ts | 6 + test/commands/replay/view.test.ts | 38 +++++ test/lib/api/replays.test.ts | 91 ++++++++++- test/lib/formatters/human.details.test.ts | 45 +++++- test/lib/sentry-url-parser.property.test.ts | 19 +++ test/lib/sentry-url-parser.test.ts | 57 +++++++ 20 files changed, 802 insertions(+), 76 deletions(-) create mode 100644 src/lib/replay-id.ts create mode 100644 test/commands/issue/view.func.test.ts diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 382b248f9..d89f44bef 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -368,7 +368,7 @@ Manage Sentry dashboards Search and inspect Session Replays - `sentry replay list ` — List recent Session Replays -- `sentry replay view ` — View a Session Replay +- `sentry replay view ` — View a Session Replay → Full flags and examples: `references/replay.md` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/replay.md b/plugins/sentry-cli/skills/sentry-cli/references/replay.md index bd920c804..5cb4170c9 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/replay.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/replay.md @@ -28,7 +28,7 @@ List recent Session Replays | Field | Type | Description | |-------|------|-------------| | `activity` | number \| null | Replay activity score | -| `browser` | object | Browser metadata | +| `browser` | object \| null | Browser metadata | | `count_dead_clicks` | number \| null | Dead click count | | `count_errors` | number \| null | Associated error count | | `count_infos` | number \| null | Info event count | @@ -36,7 +36,7 @@ List recent Session Replays | `count_segments` | number \| null | Recording segment count | | `count_urls` | number \| null | Visited URL count | | `count_warnings` | number \| null | Warning event count | -| `device` | object | Device metadata | +| `device` | object \| null | Device metadata | | `dist` | string \| null | Distribution | | `duration` | number \| null | Replay duration in seconds | | `environment` | string \| null | Environment | @@ -46,16 +46,16 @@ List recent Session Replays | `id` | string | Replay ID | | `info_ids` | array | Linked info event IDs | | `is_archived` | boolean \| null | Archived flag | -| `os` | object | Operating system metadata | +| `os` | object \| null | Operating system metadata | | `platform` | string \| null | Platform | | `project_id` | string \| null | Numeric project ID | | `releases` | array | Associated releases | -| `sdk` | object | SDK metadata | +| `sdk` | object \| null | SDK metadata | | `started_at` | string \| null | Replay start timestamp | | `tags` | unknown | Replay tags | | `trace_ids` | array | Linked trace IDs | | `urls` | array | Visited URLs | -| `user` | object | User metadata | +| `user` | object \| null | User metadata | | `warning_ids` | array | Linked warning event IDs | **Examples:** @@ -78,7 +78,7 @@ sentry replay list my-org/frontend -c prev sentry replay list my-org/frontend --json ``` -### `sentry replay view ` +### `sentry replay view ` View a Session Replay @@ -91,7 +91,7 @@ View a Session Replay | Field | Type | Description | |-------|------|-------------| | `activity` | number \| null | Replay activity score | -| `browser` | object | Browser metadata | +| `browser` | object \| null | Browser metadata | | `count_dead_clicks` | number \| null | Dead click count | | `count_errors` | number \| null | Associated error count | | `count_infos` | number \| null | Info event count | @@ -99,7 +99,7 @@ View a Session Replay | `count_segments` | number \| null | Recording segment count | | `count_urls` | number \| null | Visited URL count | | `count_warnings` | number \| null | Warning event count | -| `device` | object | Device metadata | +| `device` | object \| null | Device metadata | | `dist` | string \| null | Distribution | | `duration` | number \| null | Replay duration in seconds | | `environment` | string \| null | Environment | @@ -109,19 +109,19 @@ View a Session Replay | `id` | string | Replay ID | | `info_ids` | array | Linked info event IDs | | `is_archived` | boolean \| null | Archived flag | -| `os` | object | Operating system metadata | +| `os` | object \| null | Operating system metadata | | `platform` | string \| null | Platform | | `project_id` | string \| null | Numeric project ID | | `releases` | array | Associated releases | -| `sdk` | object | SDK metadata | +| `sdk` | object \| null | SDK metadata | | `started_at` | string \| null | Replay start timestamp | | `tags` | unknown | Replay tags | | `trace_ids` | array | Linked trace IDs | | `urls` | array | Visited URLs | -| `user` | object | User metadata | +| `user` | object \| null | User metadata | | `warning_ids` | array | Linked warning event IDs | | `clicks` | array | Replay click summaries | -| `ota_updates` | object | OTA update metadata | +| `ota_updates` | object \| null | OTA update metadata | | `replay_type` | string \| null | Replay type | **Examples:** diff --git a/src/commands/event/view.ts b/src/commands/event/view.ts index 83583a7ac..ac56672c1 100644 --- a/src/commands/event/view.ts +++ b/src/commands/event/view.ts @@ -49,6 +49,7 @@ import { } from "../../lib/list-command.js"; import { logger } from "../../lib/logger.js"; import { resolveEffectiveOrg } from "../../lib/region.js"; +import { getReplayIdFromEvent } from "../../lib/replay-id.js"; import { resolveOrg, resolveOrgAndProject, @@ -153,7 +154,7 @@ export function jsonTransformEventView( * Build a CLI-native replay hint when the event is linked to a replay. */ function replayHint(org: string, event: SentryEvent): string | undefined { - const replayId = event.tags?.find((tag) => tag.key === "replayId")?.value; + const replayId = getReplayIdFromEvent(event); return replayId ? `Related replay: sentry replay view ${org}/${replayId}` : undefined; diff --git a/src/commands/issue/view.ts b/src/commands/issue/view.ts index 28af5a22a..5e25bc1ad 100644 --- a/src/commands/issue/view.ts +++ b/src/commands/issue/view.ts @@ -5,7 +5,7 @@ */ import type { SentryContext } from "../../context.js"; -import { getLatestEvent } from "../../lib/api-client.js"; +import { getLatestEvent, listReplayIdsForIssue } from "../../lib/api-client.js"; import { spansFlag } from "../../lib/arg-parsing.js"; import { openInBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; @@ -14,6 +14,7 @@ import { formatIssueDetails, isPlainOutput, muted, + renderMarkdown, } from "../../lib/formatters/index.js"; import { filterFields } from "../../lib/formatters/json.js"; import { CommandOutput } from "../../lib/formatters/output.js"; @@ -22,6 +23,7 @@ import { FRESH_ALIASES, FRESH_FLAG, } from "../../lib/list-command.js"; +import { collectReplayIds, getReplayIdFromEvent } from "../../lib/replay-id.js"; import { getSpanTreeLines } from "../../lib/span-tree.js"; import type { SentryEvent, SentryIssue } from "../../types/index.js"; import { issueIdPositional, resolveIssue } from "./utils.js"; @@ -53,15 +55,62 @@ async function tryGetLatestEvent( } } +/** + * Try to fetch replay IDs related to an issue. + * Returns an empty array if the fetch fails (non-blocking). + */ +async function tryListReplayIdsForIssue( + orgSlug: string, + issueId: string +): Promise { + try { + return await listReplayIdsForIssue(orgSlug, issueId); + } catch { + return []; + } +} + /** Return type for issue view — includes all data both renderers need */ type IssueViewData = { + org: string | null; issue: SentryIssue; event: SentryEvent | null; + replayIds: string[]; trace: { traceId: string; spans: unknown[] } | null; /** Pre-formatted span tree lines for human output (not serialized) */ spanTreeLines?: string[]; }; +const MAX_REPLAY_IDS_SHOWN = 3; + +function formatReplaySection(org: string | null, replayIds: string[]): string { + if (replayIds.length === 0) { + return ""; + } + + const visibleReplayIds = replayIds.slice(0, MAX_REPLAY_IDS_SHOWN); + const lines = ["### Related Replays", ""]; + + for (const replayId of visibleReplayIds) { + if (org) { + lines.push( + `- \`${replayId}\` (view: \`sentry replay view ${org}/${replayId}\`)` + ); + } else { + lines.push(`- \`${replayId}\``); + } + } + + const remainingCount = replayIds.length - visibleReplayIds.length; + if (remainingCount > 0) { + lines.push( + `- ${remainingCount} more related replay${remainingCount === 1 ? "" : "s"}` + ); + } + + return renderMarkdown(lines.join("\n")); +} + /** * Format issue view data for human-readable terminal output. * @@ -69,6 +118,9 @@ type IssueViewData = { */ function formatIssueView(data: IssueViewData): string { const parts: string[] = []; + const eventReplayId = data.event + ? getReplayIdFromEvent(data.event) + : undefined; parts.push(formatIssueDetails(data.issue)); @@ -78,6 +130,14 @@ function formatIssueView(data: IssueViewData): string { ); } + const additionalReplayIds = eventReplayId + ? data.replayIds.filter((replayId) => replayId !== eventReplayId) + : data.replayIds; + const replaySection = formatReplaySection(data.org, additionalReplayIds); + if (replaySection) { + parts.push(replaySection); + } + if (data.spanTreeLines && data.spanTreeLines.length > 0) { parts.push(data.spanTreeLines.join("\n")); } @@ -89,9 +149,9 @@ function formatIssueView(data: IssueViewData): string { * Transform issue view data for JSON output. * * Flattens the issue as the primary object so that `--fields shortId,title` - * works directly on issue properties. The `event` and `trace` enrichment - * data are attached as nested keys, accessible via `--fields event.id` - * or `--fields trace.traceId`. + * works directly on issue properties. The `event`, `trace`, `org`, and + * `replayIds` enrichment data are attached as sibling keys, accessible via + * `--fields event.id`, `--fields trace.traceId`, or `--fields replayIds`. * * Without this transform, `--fields shortId` would return `{}` because * the raw yield shape is `{ issue, event, trace }` and `shortId` lives @@ -101,8 +161,14 @@ function jsonTransformIssueView( data: IssueViewData, fields?: string[] ): unknown { - const { issue, event, trace } = data; - const result: Record = { ...issue, event, trace }; + const { issue, event, org, replayIds, trace } = data; + const result: Record = { + ...issue, + event, + org, + replayIds, + trace, + }; if (fields && fields.length > 0) { return filterFields(result, fields); } @@ -161,9 +227,16 @@ export const viewCommand = buildCommand({ } // Fetch the latest event for full context (requires org slug) - const event = orgSlug - ? await tryGetLatestEvent(orgSlug, issue.id) - : undefined; + const [event, relatedReplayIds] = orgSlug + ? await Promise.all([ + tryGetLatestEvent(orgSlug, issue.id), + tryListReplayIdsForIssue(orgSlug, issue.id), + ]) + : [undefined, []]; + const replayIds = collectReplayIds([ + event ? getReplayIdFromEvent(event) : undefined, + ...relatedReplayIds, + ]); // Fetch span tree data (for both JSON and human output) // Skip when spans=0 (disabled via --spans no or --spans 0) @@ -191,8 +264,10 @@ export const viewCommand = buildCommand({ : null; yield new CommandOutput({ + org: orgSlug ?? null, issue, event: event ?? null, + replayIds, trace, spanTreeLines, }); diff --git a/src/commands/replay/list.ts b/src/commands/replay/list.ts index e07f3fe89..c7b8f485a 100644 --- a/src/commands/replay/list.ts +++ b/src/commands/replay/list.ts @@ -44,7 +44,7 @@ import { } from "../../lib/time-range.js"; import { type ReplayListItem, - ReplayListItemSchema, + ReplayListItemOutputSchema, } from "../../types/index.js"; type ListFlags = { @@ -189,7 +189,10 @@ const REPLAY_COLUMNS: Column[] = [ }, { header: "PROJECT", - value: (replay) => replay.project_id ?? "—", + value: (replay) => + replay.project_id !== null && replay.project_id !== undefined + ? String(replay.project_id) + : "—", minWidth: 7, }, ]; @@ -288,7 +291,7 @@ export const listCommand = buildListCommand("replay", { output: { human: formatReplayListHuman, jsonTransform: jsonTransformReplayList, - schema: ReplayListItemSchema, + schema: ReplayListItemOutputSchema, }, parameters: { positional: { diff --git a/src/commands/replay/view.ts b/src/commands/replay/view.ts index e15a1630b..1e982cb57 100644 --- a/src/commands/replay/view.ts +++ b/src/commands/replay/view.ts @@ -27,10 +27,15 @@ import { FRESH_ALIASES, FRESH_FLAG, } from "../../lib/list-command.js"; +import { normalizeReplayId } from "../../lib/replay-id.js"; import { resolveOrgOptionalProjectFromArg } from "../../lib/resolve-target.js"; +import { + applySentryUrlContext, + parseSentryUrl, +} from "../../lib/sentry-url-parser.js"; import { buildReplayUrl } from "../../lib/sentry-urls.js"; import type { ReplayDetails } from "../../types/index.js"; -import { ReplayDetailsSchema } from "../../types/index.js"; +import { ReplayDetailsOutputSchema } from "../../types/index.js"; type ViewFlags = { readonly json: boolean; @@ -47,9 +52,8 @@ type ParsedPositionalArgs = { type MarkdownRow = [string, string]; -const USAGE_HINT = "sentry replay view [//]"; -const REPLAY_ID_SEGMENT_RE = /^[0-9a-fA-F-]{16,36}$/; - +const USAGE_HINT = + "sentry replay view [//] | "; function pluralize(value: number, singular: string): string { return `${value} ${singular}${value === 1 ? "" : "s"}`; } @@ -93,7 +97,7 @@ function parseSingleArg(arg: string): ParsedPositionalArgs { if (slashIdx !== -1 && trimmed.indexOf("/", slashIdx + 1) === -1) { const org = trimmed.slice(0, slashIdx); const replaySegment = trimmed.slice(slashIdx + 1); - if (!(replaySegment && REPLAY_ID_SEGMENT_RE.test(replaySegment))) { + if (!(replaySegment && normalizeReplayId(replaySegment))) { throw new ContextError("Replay ID", USAGE_HINT, []); } return { replayId: replaySegment, targetArg: `${org}/` }; @@ -115,6 +119,7 @@ function parseSingleArg(arg: string): ParsedPositionalArgs { * - `/` * - `//` * - ` ` + * - `` */ export function parsePositionalArgs(args: string[]): ParsedPositionalArgs { if (args.length === 0) { @@ -126,6 +131,17 @@ export function parsePositionalArgs(args: string[]): ParsedPositionalArgs { throw new ContextError("Replay ID", USAGE_HINT, []); } + const urlParsed = parseSentryUrl(first); + if (urlParsed) { + applySentryUrlContext(urlParsed.baseUrl); + if (urlParsed.replayId && urlParsed.org) { + return { replayId: urlParsed.replayId, targetArg: `${urlParsed.org}/` }; + } + throw new ContextError("Replay ID", USAGE_HINT, [ + "Pass a replay URL: https://sentry.io/organizations/{org}/explore/replays/{replayId}/", + ]); + } + if (args.length === 1) { return parseSingleArg(first); } @@ -248,7 +264,13 @@ function buildReplayOverviewRows(replay: ReplayDetails): MarkdownRow[] { "Platform", replay.platform ? escapeMarkdownCell(replay.platform) : undefined ); - pushMarkdownRow(rows, "Project ID", replay.project_id ?? undefined); + pushMarkdownRow( + rows, + "Project ID", + replay.project_id !== null && replay.project_id !== undefined + ? String(replay.project_id) + : undefined + ); pushMarkdownRow( rows, "Replay Type", @@ -357,11 +379,7 @@ function pushListSection( } function pushTagsSection(lines: string[], replay: ReplayDetails): void { - if ( - !replay.tags || - Array.isArray(replay.tags) || - Object.keys(replay.tags).length === 0 - ) { + if (Object.keys(replay.tags).length === 0) { return; } @@ -410,26 +428,27 @@ export const viewCommand = buildCommand({ "Replay ID formats:\n" + " - auto-detect org from config or DSN\n" + " / - explicit organization\n" + - " // - explicit org/project context\n\n" + + " // - explicit org/project context\n" + + " - parse org and replay ID from a Sentry URL\n\n" + "Examples:\n" + " sentry replay view 346789a703f6454384f1de473b8b9fcc\n" + " sentry replay view sentry/346789a703f6454384f1de473b8b9fcc\n" + " sentry replay view sentry/cli/346789a703f6454384f1de473b8b9fcc\n" + + " sentry replay view https://sentry.io/organizations/sentry/explore/replays/346789a703f6454384f1de473b8b9fcc/\n" + " sentry replay view --web sentry/346789a703f6454384f1de473b8b9fcc", }, output: { human: formatReplayDetails, jsonTransform: (replay: ReplayDetails, fields?: string[]) => fields && fields.length > 0 ? filterFields(replay, fields) : replay, - schema: ReplayDetailsSchema, + schema: ReplayDetailsOutputSchema, }, parameters: { positional: { kind: "array", parameter: { - placeholder: "org/project/replay-id", - brief: - "[/] - Target (optional) and replay ID (required)", + placeholder: "replay-id-or-url", + brief: "[/] or ", parse: String, }, }, diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 6ceab1564..233910a5d 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -123,6 +123,7 @@ export { export { getReplay, type ListReplaysOptions, + listReplayIdsForIssue, listReplays, type ReplaySortValue, } from "./api/replays.js"; diff --git a/src/lib/api/replays.ts b/src/lib/api/replays.ts index 29ed342fe..26387be5d 100644 --- a/src/lib/api/replays.ts +++ b/src/lib/api/replays.ts @@ -4,11 +4,15 @@ * Functions for listing and retrieving Session Replays. */ +import type { z } from "zod"; import { REPLAY_LIST_FIELDS, type ReplayDetails, + type ReplayDetailsResponse, ReplayDetailsResponseSchema, + ReplayIdsByResourceSchema, type ReplayListItem, + type ReplayListResponse, ReplayListResponseSchema, } from "../../types/index.js"; @@ -57,6 +61,23 @@ type FetchReplayPageOptions = { cursor?: string; }; +function normalizeReplayProjectId< + T extends { project_id?: string | number | null }, +>(replay: T): T { + if ( + replay.project_id === null || + replay.project_id === undefined || + typeof replay.project_id === "string" + ) { + return replay; + } + + return { + ...replay, + project_id: String(replay.project_id), + }; +} + /** * Fetch a single page of replays from the organization replay index. */ @@ -84,12 +105,15 @@ async function fetchReplayPage( start: options.start, end: options.end, }, - schema: ReplayListResponseSchema, + schema: ReplayListResponseSchema as z.ZodType, } ); const { nextCursor } = parseLinkHeader(headers.get("link") ?? null); - return { data: data.data, nextCursor }; + return { + data: data.data.map(normalizeReplayProjectId), + nextCursor, + }; } /** @@ -125,8 +149,35 @@ export async function getReplay( regionUrl, `/organizations/${orgSlug}/replays/${replayId}/`, { - schema: ReplayDetailsResponseSchema, + schema: ReplayDetailsResponseSchema as z.ZodType, } ); - return data.data; + return normalizeReplayProjectId(data.data); +} + +/** + * List replay IDs related to a single issue. + */ +export async function listReplayIdsForIssue( + orgSlug: string, + issueId: string | number +): Promise { + const normalizedIssueId = String(issueId); + const regionUrl = await resolveOrgRegion(orgSlug); + const { data } = await apiRequestToRegion( + regionUrl, + `/organizations/${orgSlug}/replay-count/`, + { + params: { + data_source: "discover", + project: "-1", + query: `issue.id:[${normalizedIssueId}]`, + returnIds: true, + statsPeriod: "90d", + }, + schema: ReplayIdsByResourceSchema, + } + ); + + return data[normalizedIssueId] ?? []; } diff --git a/src/lib/formatters/human.ts b/src/lib/formatters/human.ts index 7ec97148e..66d76fb50 100644 --- a/src/lib/formatters/human.ts +++ b/src/lib/formatters/human.ts @@ -29,6 +29,7 @@ import type { Writer, } from "../../types/index.js"; import { resolveOrgDisplayName } from "../api-client.js"; +import { getReplayIdFromEvent } from "../replay-id.js"; import { withSerializeSpan } from "../telemetry.js"; import { type FixabilityTier, muted } from "./colors.js"; import { @@ -1319,20 +1320,20 @@ function buildReplayMarkdown( event: SentryEvent, issuePermalink?: string ): string { - const replayTag = event.tags?.find((t) => t.key === "replayId"); - if (!replayTag?.value) { + const replayId = getReplayIdFromEvent(event); + if (!replayId) { return ""; } const lines: string[] = []; lines.push("### Replay"); lines.push(""); - lines.push(`**ID:** \`${replayTag.value}\``); + lines.push(`**ID:** \`${replayId}\``); if (issuePermalink) { const match = BASE_URL_REGEX.exec(issuePermalink); if (match?.[1]) { - lines.push(`**Link:** ${match[1]}/explore/replays/${replayTag.value}/`); + lines.push(`**Link:** ${match[1]}/explore/replays/${replayId}/`); } } diff --git a/src/lib/replay-id.ts b/src/lib/replay-id.ts new file mode 100644 index 000000000..dabf0900d --- /dev/null +++ b/src/lib/replay-id.ts @@ -0,0 +1,70 @@ +import type { SentryEvent } from "../types/index.js"; +import { normalizeHexId } from "./hex-id.js"; + +const REPLAY_ID_RE = /^[0-9a-f]{32}$/; + +/** + * Normalize a replay ID to the canonical 32-character lowercase hex form. + * + * Accepts both bare hex IDs and UUID-style IDs with dashes. + */ +export function normalizeReplayId( + value: string | null | undefined +): string | undefined { + if (!value) { + return; + } + + const normalized = normalizeHexId(value.trim()); + return REPLAY_ID_RE.test(normalized) ? normalized : undefined; +} + +function getReplayIdFromReplayContext( + event: Pick +): string | undefined { + const replayContext = event.contexts?.replay; + if (!replayContext || typeof replayContext !== "object") { + return; + } + + const replayId = (replayContext as { replay_id?: unknown }).replay_id; + return typeof replayId === "string" ? replayId : undefined; +} + +/** + * Extract the best replay ID from an event's known replay linkage fields. + */ +export function getReplayIdFromEvent( + event: Pick +): string | undefined { + const tagReplayId = event.tags?.find( + (tag) => tag.key === "replayId" || tag.key === "replay.id" + )?.value; + + return collectReplayIds([ + tagReplayId, + getReplayIdFromReplayContext(event), + ])[0]; +} + +/** + * Normalize and deduplicate replay IDs while preserving first-seen order. + */ +export function collectReplayIds( + values: Iterable +): string[] { + const seen = new Set(); + const replayIds: string[] = []; + + for (const value of values) { + const replayId = normalizeReplayId(value); + if (!replayId || seen.has(replayId)) { + continue; + } + + seen.add(replayId); + replayIds.push(replayId); + } + + return replayIds; +} diff --git a/src/lib/sentry-url-parser.ts b/src/lib/sentry-url-parser.ts index 93d554163..fe36771c0 100644 --- a/src/lib/sentry-url-parser.ts +++ b/src/lib/sentry-url-parser.ts @@ -1,7 +1,7 @@ /** * Sentry URL Parser * - * Extracts org, project, issue, event, and trace identifiers from Sentry web URLs. + * Extracts org, project, issue, event, replay, and trace identifiers from Sentry web URLs. * Supports both SaaS (*.sentry.io) and self-hosted instances. * * For self-hosted URLs, also configures the SENTRY_URL environment variable @@ -35,6 +35,8 @@ export type ParsedSentryUrl = { project?: string; /** Trace ID from /organizations/{org}/traces/{traceId}/ paths */ traceId?: string; + /** Replay ID from replay detail URLs */ + replayId?: string; /** Share ID from /share/issue/{shareId}/ paths (32-char hex string) */ shareId?: string; /** Dashboard ID from /dashboard/{id}/ paths (numeric string) */ @@ -68,6 +70,11 @@ function matchOrganizationsPath( return { baseUrl, org, traceId: segments[3] }; } + const replayPath = matchReplayPath(segments, 2); + if (replayPath) { + return { baseUrl, org, ...replayPath }; + } + // /organizations/{org}/dashboard/{id}/ if (segments[2] === "dashboard" && segments[3]) { return { baseUrl, org, dashboardId: segments[3] }; @@ -118,6 +125,11 @@ function matchSubdomainPath( if (segments[0] === "traces" && segments[1]) { return { traceId: segments[1] }; } + + const replayPath = matchReplayPath(segments, 0); + if (replayPath) { + return replayPath; + } // /settings/projects/{project}/ (org-scoped subdomain settings URL) if (segments[0] === "settings" && segments[1] === "projects" && segments[2]) { return { project: segments[2] }; @@ -137,6 +149,25 @@ function matchSubdomainPath( return null; } +function matchReplayPath( + segments: string[], + startIndex: number +): Pick | null { + if ( + segments[startIndex] === "explore" && + segments[startIndex + 1] === "replays" && + segments[startIndex + 2] + ) { + return { replayId: segments[startIndex + 2] }; + } + + if (segments[startIndex] === "replays" && segments[startIndex + 1]) { + return { replayId: segments[startIndex + 1] }; + } + + return null; +} + /** * Try to extract org from a SaaS subdomain-style URL. * @@ -198,6 +229,8 @@ function matchSharePath( * - `/organizations/{org}/issues/{id}/events/{eventId}/` * - `/settings/{org}/projects/{project}/` * - `/organizations/{org}/traces/{traceId}/` + * - `/organizations/{org}/explore/replays/{replayId}/` + * - `/organizations/{org}/replays/{replayId}/` * - `/organizations/{org}/dashboard/{id}/` * - `/organizations/{org}/` * - `/share/issue/{shareId}/` @@ -205,6 +238,8 @@ function matchSharePath( * Also recognizes SaaS subdomain-style URLs: * - `https://{org}.sentry.io/issues/{id}/` * - `https://{org}.sentry.io/traces/{traceId}/` + * - `https://{org}.sentry.io/explore/replays/{replayId}/` + * - `https://{org}.sentry.io/replays/{replayId}/` * - `https://{org}.sentry.io/issues/{id}/events/{eventId}/` * - `https://{org}.sentry.io/dashboard/{id}/` * - `https://{org}.sentry.io/share/issue/{shareId}/` diff --git a/src/types/index.ts b/src/types/index.ts index 96e3d0507..1a74d6a71 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -52,6 +52,7 @@ export type { ReplayDetailsResponse, ReplayDevice, ReplayGeo, + ReplayIdsByResource, ReplayListItem, ReplayListResponse, ReplayOs, @@ -62,10 +63,13 @@ export type { export { REPLAY_LIST_FIELDS, ReplayBrowserSchema, + ReplayDetailsOutputSchema, ReplayDetailsResponseSchema, ReplayDetailsSchema, ReplayDeviceSchema, ReplayGeoSchema, + ReplayIdsByResourceSchema, + ReplayListItemOutputSchema, ReplayListItemSchema, ReplayListResponseSchema, ReplayOsSchema, diff --git a/src/types/replay.ts b/src/types/replay.ts index 1c74d896f..a11fef1d6 100644 --- a/src/types/replay.ts +++ b/src/types/replay.ts @@ -22,7 +22,7 @@ export const ReplayUserSchema = z email: z.string().nullish().describe("Email"), ip: z.string().nullish().describe("IP address"), display_name: z.string().nullish().describe("Display name"), - geo: ReplayGeoSchema.optional().describe("Geo metadata"), + geo: ReplayGeoSchema.nullish().describe("Geo metadata"), }) .passthrough(); @@ -84,12 +84,12 @@ export const ReplayOtaUpdatesSchema = z * Replay tags keyed by tag name. * * Archived replay rows sometimes return an empty array instead of a tag map, - * so the schema accepts both shapes. + * so the schema falls back to an empty tag object for those placeholders. */ -export const ReplayTagsSchema = z.union([ - z.record(z.array(z.string())).describe("Replay tags"), - z.array(z.unknown()).describe("Archived replay tags placeholder"), -]); +export const ReplayTagsSchema = z + .record(z.array(z.string())) + .catch({}) + .describe("Replay tags"); /** * Known root fields that the replay list endpoint accepts in repeated `field=` @@ -143,7 +143,7 @@ export const ReplayListItemSchema = z .nullable() .optional() .describe("Replay activity score"), - browser: ReplayBrowserSchema.optional().describe("Browser metadata"), + browser: ReplayBrowserSchema.nullish().describe("Browser metadata"), count_dead_clicks: z .number() .nullable() @@ -171,7 +171,7 @@ export const ReplayListItemSchema = z .nullable() .optional() .describe("Warning event count"), - device: ReplayDeviceSchema.optional().describe("Device metadata"), + device: ReplayDeviceSchema.nullish().describe("Device metadata"), dist: z.string().nullable().optional().describe("Distribution"), duration: z .number() @@ -179,7 +179,7 @@ export const ReplayListItemSchema = z .optional() .describe("Replay duration in seconds"), environment: z.string().nullable().optional().describe("Environment"), - error_ids: z.array(z.string()).optional().describe("Linked error IDs"), + error_ids: z.array(z.string()).catch([]).describe("Linked error IDs"), finished_at: z .string() .nullable() @@ -191,25 +191,29 @@ export const ReplayListItemSchema = z .optional() .describe("Whether the current user has viewed the replay"), id: z.string().describe("Replay ID"), - info_ids: z.array(z.string()).optional().describe("Linked info event IDs"), + info_ids: z.array(z.string()).catch([]).describe("Linked info event IDs"), is_archived: z.boolean().nullable().optional().describe("Archived flag"), - os: ReplayOsSchema.optional().describe("Operating system metadata"), + os: ReplayOsSchema.nullish().describe("Operating system metadata"), platform: z.string().nullable().optional().describe("Platform"), - project_id: z.string().nullable().optional().describe("Numeric project ID"), + project_id: z + .union([z.string(), z.number()]) + .nullable() + .optional() + .describe("Numeric project ID"), releases: z.array(z.string()).optional().describe("Associated releases"), - sdk: ReplaySdkSchema.optional().describe("SDK metadata"), + sdk: ReplaySdkSchema.nullish().describe("SDK metadata"), started_at: z .string() .nullable() .optional() .describe("Replay start timestamp"), - tags: ReplayTagsSchema.optional().describe("Replay tags"), - trace_ids: z.array(z.string()).optional().describe("Linked trace IDs"), - urls: z.array(z.string()).optional().describe("Visited URLs"), - user: ReplayUserSchema.optional().describe("User metadata"), + tags: ReplayTagsSchema.describe("Replay tags"), + trace_ids: z.array(z.string()).catch([]).describe("Linked trace IDs"), + urls: z.array(z.string()).catch([]).describe("Visited URLs"), + user: ReplayUserSchema.nullish().describe("User metadata"), warning_ids: z .array(z.string()) - .optional() + .catch([]) .describe("Linked warning event IDs"), }) .passthrough() @@ -230,9 +234,7 @@ export const ReplayDetailsSchema = ReplayListItemSchema.extend({ .array(ReplayClickSchema) .optional() .describe("Replay click summaries"), - ota_updates: ReplayOtaUpdatesSchema.optional().describe( - "OTA update metadata" - ), + ota_updates: ReplayOtaUpdatesSchema.nullish().describe("OTA update metadata"), replay_type: z.string().nullable().optional().describe("Replay type"), }).describe("Replay details"); @@ -250,6 +252,102 @@ export const ReplayDetailsResponseSchema = z }) .passthrough(); +/** + * Documentation-oriented replay list schema used for `--help` and SKILL docs. + * + * Keeps the field types explicit even though the runtime parser accepts a few + * legacy/nullish payload variants from archived replay rows. + */ +export const ReplayListItemOutputSchema = z + .object({ + activity: z + .number() + .nullable() + .optional() + .describe("Replay activity score"), + browser: ReplayBrowserSchema.nullish().describe("Browser metadata"), + count_dead_clicks: z + .number() + .nullable() + .optional() + .describe("Dead click count"), + count_errors: z + .number() + .nullable() + .optional() + .describe("Associated error count"), + count_infos: z.number().nullable().optional().describe("Info event count"), + count_rage_clicks: z + .number() + .nullable() + .optional() + .describe("Rage click count"), + count_segments: z + .number() + .nullable() + .optional() + .describe("Recording segment count"), + count_urls: z.number().nullable().optional().describe("Visited URL count"), + count_warnings: z + .number() + .nullable() + .optional() + .describe("Warning event count"), + device: ReplayDeviceSchema.nullish().describe("Device metadata"), + dist: z.string().nullable().optional().describe("Distribution"), + duration: z + .number() + .nullable() + .optional() + .describe("Replay duration in seconds"), + environment: z.string().nullable().optional().describe("Environment"), + error_ids: z.array(z.string()).describe("Linked error IDs"), + finished_at: z + .string() + .nullable() + .optional() + .describe("Replay finish timestamp"), + has_viewed: z + .boolean() + .nullable() + .optional() + .describe("Whether the current user has viewed the replay"), + id: z.string().describe("Replay ID"), + info_ids: z.array(z.string()).describe("Linked info event IDs"), + is_archived: z.boolean().nullable().optional().describe("Archived flag"), + os: ReplayOsSchema.nullish().describe("Operating system metadata"), + platform: z.string().nullable().optional().describe("Platform"), + project_id: z.string().nullable().optional().describe("Numeric project ID"), + releases: z.array(z.string()).optional().describe("Associated releases"), + sdk: ReplaySdkSchema.nullish().describe("SDK metadata"), + started_at: z + .string() + .nullable() + .optional() + .describe("Replay start timestamp"), + tags: z.record(z.array(z.string())).describe("Replay tags"), + trace_ids: z.array(z.string()).describe("Linked trace IDs"), + urls: z.array(z.string()).describe("Visited URLs"), + user: ReplayUserSchema.nullish().describe("User metadata"), + warning_ids: z.array(z.string()).describe("Linked warning event IDs"), + }) + .describe("Replay list row"); + +/** Documentation-oriented replay detail schema used for command metadata. */ +export const ReplayDetailsOutputSchema = ReplayListItemOutputSchema.extend({ + clicks: z + .array(ReplayClickSchema) + .optional() + .describe("Replay click summaries"), + ota_updates: ReplayOtaUpdatesSchema.nullish().describe("OTA update metadata"), + replay_type: z.string().nullable().optional().describe("Replay type"), +}).describe("Replay details"); + +/** Replay IDs keyed by resource identifier (issue ID, trace ID, replay ID). */ +export const ReplayIdsByResourceSchema = z + .record(z.string(), z.array(z.string())) + .describe("Replay IDs grouped by resource identifier"); + export type ReplayGeo = z.infer; export type ReplayUser = z.infer; export type ReplayBrowser = z.infer; @@ -261,3 +359,4 @@ export type ReplayListItem = z.infer; export type ReplayDetails = z.infer; export type ReplayListResponse = z.infer; export type ReplayDetailsResponse = z.infer; +export type ReplayIdsByResource = z.infer; diff --git a/test/commands/issue/view.func.test.ts b/test/commands/issue/view.func.test.ts new file mode 100644 index 000000000..f8462569f --- /dev/null +++ b/test/commands/issue/view.func.test.ts @@ -0,0 +1,119 @@ +/** + * Tests for the issue view command's replay integration. + */ + +import { + afterEach, + beforeEach, + describe, + expect, + mock, + spyOn, + test, +} from "bun:test"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as issueUtils from "../../../src/commands/issue/utils.js"; +import { viewCommand } from "../../../src/commands/issue/view.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as apiClient from "../../../src/lib/api-client.js"; +import type { SentryEvent, SentryIssue } from "../../../src/types/index.js"; + +const REPLAY_ID = "346789a703f6454384f1de473b8b9fcc"; +const SECOND_REPLAY_ID = "aaaaaaaa03f6454384f1de473b8b9fcc"; +const DASHED_REPLAY_ID = `${REPLAY_ID.slice(0, 8)}-${REPLAY_ID.slice(8, 12)}-${REPLAY_ID.slice(12, 16)}-${REPLAY_ID.slice(16, 20)}-${REPLAY_ID.slice(20)}`; + +function sampleIssue(overrides: Partial = {}): SentryIssue { + return { + id: "12345", + shortId: "CLI-123", + title: "Replay-linked issue", + permalink: "https://sentry.io/organizations/test-org/issues/12345/", + ...overrides, + }; +} + +function sampleEvent(overrides: Partial = {}): SentryEvent { + return { + eventID: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + title: "Latest event", + tags: [{ key: "replay.id", value: REPLAY_ID }], + ...overrides, + }; +} + +describe("issue view replay integration", () => { + let resolveIssueSpy: ReturnType; + let getLatestEventSpy: ReturnType; + let listReplayIdsForIssueSpy: ReturnType; + + function createMockContext() { + const stdoutWrite = mock(() => true); + return { + context: { + stdout: { write: stdoutWrite }, + stderr: { write: mock(() => true) }, + cwd: "/tmp", + }, + stdoutWrite, + }; + } + + beforeEach(() => { + resolveIssueSpy = spyOn(issueUtils, "resolveIssue"); + getLatestEventSpy = spyOn(apiClient, "getLatestEvent"); + listReplayIdsForIssueSpy = spyOn(apiClient, "listReplayIdsForIssue"); + }); + + afterEach(() => { + resolveIssueSpy.mockRestore(); + getLatestEventSpy.mockRestore(); + listReplayIdsForIssueSpy.mockRestore(); + }); + + test("includes deduplicated replay IDs in JSON output", async () => { + resolveIssueSpy.mockResolvedValue({ + org: "test-org", + issue: sampleIssue(), + }); + getLatestEventSpy.mockResolvedValue(sampleEvent()); + listReplayIdsForIssueSpy.mockResolvedValue([ + DASHED_REPLAY_ID, + SECOND_REPLAY_ID, + ]); + + const { context, stdoutWrite } = createMockContext(); + const func = await viewCommand.loader(); + await func.call( + context, + { json: true, web: false, spans: 0, fresh: false }, + "CLI-123" + ); + + const output = stdoutWrite.mock.calls.map((call) => call[0]).join(""); + const parsed = JSON.parse(output); + expect(parsed.org).toBe("test-org"); + expect(parsed.replayIds).toEqual([REPLAY_ID, SECOND_REPLAY_ID]); + }); + + test("renders additional related replays in human output", async () => { + resolveIssueSpy.mockResolvedValue({ + org: "test-org", + issue: sampleIssue(), + }); + getLatestEventSpy.mockResolvedValue(sampleEvent()); + listReplayIdsForIssueSpy.mockResolvedValue([REPLAY_ID, SECOND_REPLAY_ID]); + + const { context, stdoutWrite } = createMockContext(); + const func = await viewCommand.loader(); + await func.call( + context, + { json: false, web: false, spans: 0, fresh: false }, + "CLI-123" + ); + + const output = stdoutWrite.mock.calls.map((call) => call[0]).join(""); + expect(output).toContain("Related Replays"); + expect(output).toContain(SECOND_REPLAY_ID); + expect(output).toContain(`sentry replay view test-org/${SECOND_REPLAY_ID}`); + }); +}); diff --git a/test/commands/replay/list.test.ts b/test/commands/replay/list.test.ts index 98ac317de..bcd999955 100644 --- a/test/commands/replay/list.test.ts +++ b/test/commands/replay/list.test.ts @@ -46,9 +46,15 @@ describe("listCommand.func", () => { count_errors: 2, count_segments: 5, duration: 125, + error_ids: [], + info_ids: [], started_at: "2025-01-30T14:32:15+00:00", + tags: {}, project_id: "42", + trace_ids: [], + urls: [], user: { display_name: "Test User" }, + warning_ids: [], }, ]; diff --git a/test/commands/replay/view.test.ts b/test/commands/replay/view.test.ts index 8f0583738..a37c4772e 100644 --- a/test/commands/replay/view.test.ts +++ b/test/commands/replay/view.test.ts @@ -36,10 +36,15 @@ function sampleReplay(overrides: Partial = {}): ReplayDetails { count_errors: 2, count_segments: 5, duration: 125, + error_ids: [], + info_ids: [], started_at: "2025-01-30T14:32:15+00:00", + tags: {}, project_id: "42", trace_ids: ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"], + urls: [], user: { display_name: "Test User" }, + warning_ids: [], ...overrides, }; } @@ -63,6 +68,22 @@ describe("parsePositionalArgs", () => { expect(result.targetArg).toBe("test-org/cli"); }); + test("parses replay URL", () => { + const result = parsePositionalArgs([ + `https://sentry.io/organizations/test-org/explore/replays/${REPLAY_ID}/`, + ]); + expect(result.replayId).toBe(REPLAY_ID); + expect(result.targetArg).toBe("test-org/"); + }); + + test("parses legacy replay URL", () => { + const result = parsePositionalArgs([ + `https://test-org.sentry.io/replays/${REPLAY_ID}/`, + ]); + expect(result.replayId).toBe(REPLAY_ID); + expect(result.targetArg).toBe("test-org/"); + }); + test("detects swapped args", () => { const result = parsePositionalArgs([REPLAY_ID, "test-org/cli"]); expect(result.replayId).toBe(REPLAY_ID); @@ -139,6 +160,23 @@ describe("viewCommand.func", () => { ); }); + test("opens the replay URL target from a replay URL with --web", async () => { + resolveTargetSpy.mockResolvedValue({ org: "test-org", project: undefined }); + + const { context } = createMockContext(); + const func = await viewCommand.loader(); + await func.call( + context, + { json: false, web: true, fresh: false }, + `https://sentry.io/organizations/test-org/explore/replays/${REPLAY_ID}/` + ); + + expect(openInBrowserSpy).toHaveBeenCalledWith( + "https://test-org.sentry.io/explore/replays/346789a703f6454384f1de473b8b9fcc/", + "replay" + ); + }); + test("converts missing replays into ResolutionError", async () => { resolveTargetSpy.mockResolvedValue({ org: "test-org", project: "cli" }); getReplaySpy.mockRejectedValue( diff --git a/test/lib/api/replays.test.ts b/test/lib/api/replays.test.ts index 4168bbe7b..f8f498e13 100644 --- a/test/lib/api/replays.test.ts +++ b/test/lib/api/replays.test.ts @@ -3,7 +3,11 @@ */ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { getReplay, listReplays } from "../../../src/lib/api/replays.js"; +import { + getReplay, + listReplayIdsForIssue, + listReplays, +} from "../../../src/lib/api/replays.js"; import { DEFAULT_SENTRY_URL } from "../../../src/lib/constants.js"; import { setAuthToken } from "../../../src/lib/db/auth.js"; import { setOrgRegion } from "../../../src/lib/db/regions.js"; @@ -153,4 +157,89 @@ describe("getReplay", () => { expect(replay.id).toBe(REPLAY_ID); expect(replay.count_errors).toBe(2); }); + + test("normalizes archived replay payload oddities", async () => { + globalThis.fetch = mockFetch( + async () => + new Response( + JSON.stringify({ + data: { + ...replayRow(), + error_ids: undefined, + info_ids: undefined, + project_id: 42, + tags: [], + trace_ids: undefined, + urls: null, + warning_ids: undefined, + }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ) + ); + + const replay = await getReplay("test-org", REPLAY_ID); + + expect(replay.project_id).toBe("42"); + expect(replay.tags).toEqual({}); + expect(replay.urls).toEqual([]); + expect(replay.error_ids).toEqual([]); + expect(replay.info_ids).toEqual([]); + expect(replay.trace_ids).toEqual([]); + expect(replay.warning_ids).toEqual([]); + }); +}); + +describe("listReplayIdsForIssue", () => { + let originalFetch: typeof globalThis.fetch; + + beforeEach(async () => { + originalFetch = globalThis.fetch; + await setAuthToken("test-token"); + setOrgRegion("test-org", DEFAULT_SENTRY_URL); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + test("calls the replay-count endpoint with the issue query", async () => { + let capturedUrl = ""; + + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + capturedUrl = req.url; + return new Response( + JSON.stringify({ + "12345": [ + "346789a703f6454384f1de473b8b9fcc", + "aaaaaaaa03f6454384f1de473b8b9fcc", + ], + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + }); + + const replayIds = await listReplayIdsForIssue("test-org", "12345"); + const url = new URL(capturedUrl); + + expect(url.pathname).toContain( + "/api/0/organizations/test-org/replay-count/" + ); + expect(url.searchParams.get("returnIds")).toBe("true"); + expect(url.searchParams.get("query")).toBe("issue.id:[12345]"); + expect(url.searchParams.get("data_source")).toBe("discover"); + expect(url.searchParams.get("statsPeriod")).toBe("90d"); + expect(url.searchParams.getAll("project")).toEqual(["-1"]); + expect(replayIds).toEqual([ + "346789a703f6454384f1de473b8b9fcc", + "aaaaaaaa03f6454384f1de473b8b9fcc", + ]); + }); }); diff --git a/test/lib/formatters/human.details.test.ts b/test/lib/formatters/human.details.test.ts index 5423a81cf..07a25e604 100644 --- a/test/lib/formatters/human.details.test.ts +++ b/test/lib/formatters/human.details.test.ts @@ -792,17 +792,56 @@ describe("formatEventDetails", () => { const result = stripAnsi( formatEventDetails( createMockEvent({ - tags: [{ key: "replayId", value: "replay-uuid-123" }], + tags: [ + { + key: "replayId", + value: "346789a703f6454384f1de473b8b9fcc", + }, + ], }), "Latest Event", "https://acme.sentry.io/issues/789/" ) ); expect(result).toContain("Replay"); - expect(result).toContain("replay-uuid-123"); + expect(result).toContain("346789a703f6454384f1de473b8b9fcc"); expect(result).toContain( - "https://acme.sentry.io/explore/replays/replay-uuid-123/" + "https://acme.sentry.io/explore/replays/346789a703f6454384f1de473b8b9fcc/" + ); + }); + + test("includes replay link from replay.id tag", () => { + const result = stripAnsi( + formatEventDetails( + createMockEvent({ + tags: [ + { + key: "replay.id", + value: "346789a703f6454384f1de473b8b9fcc", + }, + ], + }), + "Latest Event", + "https://acme.sentry.io/issues/789/" + ) + ); + expect(result).toContain("346789a703f6454384f1de473b8b9fcc"); + }); + + test("includes replay link from replay context", () => { + const result = stripAnsi( + formatEventDetails( + createMockEvent({ + contexts: { + replay: { replay_id: "346789a703f6454384f1de473b8b9fcc" }, + }, + tags: [], + }), + "Latest Event", + "https://acme.sentry.io/issues/789/" + ) ); + expect(result).toContain("346789a703f6454384f1de473b8b9fcc"); }); test("includes tags when present", () => { diff --git a/test/lib/sentry-url-parser.property.test.ts b/test/lib/sentry-url-parser.property.test.ts index 3af7e815f..9925c61af 100644 --- a/test/lib/sentry-url-parser.property.test.ts +++ b/test/lib/sentry-url-parser.property.test.ts @@ -16,6 +16,7 @@ import { parseSentryUrl } from "../../src/lib/sentry-url-parser.js"; import { buildOrgUrl, buildProjectUrl, + buildReplayUrl, buildTraceUrl, } from "../../src/lib/sentry-urls.js"; import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; @@ -39,6 +40,9 @@ const projectSlugArb = stringMatching(/^[a-z][a-z0-9-]{1,20}[a-z0-9]$/).filter( /** Generates valid 32-character hex trace IDs */ const traceIdArb = stringMatching(/^[0-9a-f]{32}$/); +/** Generates valid 32-character hex replay IDs */ +const replayIdArb = stringMatching(/^[0-9a-f]{32}$/); + /** Generates numeric issue IDs */ const numericIdArb = stringMatching(/^[1-9][0-9]{0,10}$/); @@ -92,6 +96,21 @@ describe("parseSentryUrl round-trip properties", () => { ); }); + test("buildReplayUrl → parseSentryUrl extracts org and replayId", async () => { + await fcAssert( + property(tuple(orgSlugArb, replayIdArb), ([org, replayId]) => { + const url = buildReplayUrl(org, replayId); + const parsed = parseSentryUrl(url); + + expect(parsed).not.toBeNull(); + expect(parsed?.org).toBe(org); + expect(parsed?.replayId).toBe(replayId); + expect(parsed?.issueId).toBeUndefined(); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + test("issue URL round-trip: org and numeric issueId extracted", async () => { await fcAssert( property(tuple(orgSlugArb, numericIdArb), ([org, issueId]) => { diff --git a/test/lib/sentry-url-parser.test.ts b/test/lib/sentry-url-parser.test.ts index c38dd1077..863685f6c 100644 --- a/test/lib/sentry-url-parser.test.ts +++ b/test/lib/sentry-url-parser.test.ts @@ -185,6 +185,41 @@ describe("parseSentryUrl", () => { }); }); + describe("replay URLs", () => { + test("/organizations/{org}/explore/replays/{replayId}/", () => { + const result = parseSentryUrl( + "https://sentry.io/organizations/my-org/explore/replays/346789a703f6454384f1de473b8b9fcc/" + ); + expect(result).toEqual({ + baseUrl: "https://sentry.io", + org: "my-org", + replayId: "346789a703f6454384f1de473b8b9fcc", + }); + }); + + test("legacy /organizations/{org}/replays/{replayId}/", () => { + const result = parseSentryUrl( + "https://sentry.io/organizations/my-org/replays/346789a703f6454384f1de473b8b9fcc/" + ); + expect(result).toEqual({ + baseUrl: "https://sentry.io", + org: "my-org", + replayId: "346789a703f6454384f1de473b8b9fcc", + }); + }); + + test("self-hosted replay URL", () => { + const result = parseSentryUrl( + "https://sentry.example.com/organizations/acme-corp/explore/replays/346789a703f6454384f1de473b8b9fcc/" + ); + expect(result).toEqual({ + baseUrl: "https://sentry.example.com", + org: "acme-corp", + replayId: "346789a703f6454384f1de473b8b9fcc", + }); + }); + }); + describe("dashboard URLs", () => { test("/organizations/{org}/dashboard/{id}/", () => { const result = parseSentryUrl( @@ -279,6 +314,28 @@ describe("parseSentryUrl", () => { }); }); + test("replay URL extracts org from subdomain", () => { + const result = parseSentryUrl( + "https://my-org.sentry.io/explore/replays/346789a703f6454384f1de473b8b9fcc/" + ); + expect(result).toEqual({ + baseUrl: "https://my-org.sentry.io", + org: "my-org", + replayId: "346789a703f6454384f1de473b8b9fcc", + }); + }); + + test("legacy replay URL extracts org from subdomain", () => { + const result = parseSentryUrl( + "https://my-org.sentry.io/replays/346789a703f6454384f1de473b8b9fcc/" + ); + expect(result).toEqual({ + baseUrl: "https://my-org.sentry.io", + org: "my-org", + replayId: "346789a703f6454384f1de473b8b9fcc", + }); + }); + test("dashboard URL extracts org from subdomain", () => { const result = parseSentryUrl( "https://sentry-sdks.sentry.io/dashboard/4326879/" From 5a869b456061c97830b1470fddbeed6e8b82379d Mon Sep 17 00:00:00 2001 From: David Cramer Date: Fri, 1 May 2026 13:55:53 -0700 Subject: [PATCH 04/26] ref(replay): Simplify replay schema typing Collapse the replay runtime and output schemas onto shared shape builders so the replay type layer stays readable and consistent as the command surface grows. Add a typed replay event context and remove the ad hoc replay context cast so replay linkage stays explicit in the shared event model. Co-Authored-By: OpenAI Codex --- src/lib/replay-id.ts | 9 +- src/types/index.ts | 1 + src/types/replay.ts | 257 ++++++++++++++++++------------------------- src/types/sentry.ts | 7 ++ 4 files changed, 116 insertions(+), 158 deletions(-) diff --git a/src/lib/replay-id.ts b/src/lib/replay-id.ts index dabf0900d..934bcb6e7 100644 --- a/src/lib/replay-id.ts +++ b/src/lib/replay-id.ts @@ -23,12 +23,9 @@ function getReplayIdFromReplayContext( event: Pick ): string | undefined { const replayContext = event.contexts?.replay; - if (!replayContext || typeof replayContext !== "object") { - return; - } - - const replayId = (replayContext as { replay_id?: unknown }).replay_id; - return typeof replayId === "string" ? replayId : undefined; + return typeof replayContext?.replay_id === "string" + ? replayContext.replay_id + : undefined; } /** diff --git a/src/types/index.ts b/src/types/index.ts index 1a74d6a71..da1f3c36d 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -111,6 +111,7 @@ export type { ProductTrial, ProjectKey, Region, + ReplayContext, RepositoryProvider, RequestEntry, SentryDeploy, diff --git a/src/types/replay.ts b/src/types/replay.ts index a11fef1d6..f8f5dd150 100644 --- a/src/types/replay.ts +++ b/src/types/replay.ts @@ -1,5 +1,7 @@ import { z } from "zod"; +export type ReplayTags = Record; + /** * User geo metadata attached to a replay. */ @@ -89,7 +91,7 @@ export const ReplayOtaUpdatesSchema = z export const ReplayTagsSchema = z .record(z.array(z.string())) .catch({}) - .describe("Replay tags"); + .describe("Replay tags") as z.ZodType; /** * Known root fields that the replay list endpoint accepts in repeated `field=` @@ -131,91 +133,103 @@ export const REPLAY_LIST_FIELDS = [ "warning_ids", ] as const; +function replayNullableNumber(description: string) { + return z.number().nullable().optional().describe(description); +} + +function replayNullableString(description: string) { + return z.string().nullable().optional().describe(description); +} + +function replayNullableBoolean(description: string) { + return z.boolean().nullable().optional().describe(description); +} + +function replayNullishObject( + schema: T, + description: string +) { + return schema.nullish().describe(description); +} + +function replayStringArray() { + return z.array(z.string()); +} + +function replayStringArrayWithFallback() { + return replayStringArray().catch([]); +} + +function buildReplayListItemShape< + TErrorIds extends z.ZodTypeAny, + TInfoIds extends z.ZodTypeAny, + TProjectId extends z.ZodTypeAny, + TTags extends z.ZodTypeAny, + TTraceIds extends z.ZodTypeAny, + TUrls extends z.ZodTypeAny, + TWarningIds extends z.ZodTypeAny, +>(fields: { + errorIds: TErrorIds; + infoIds: TInfoIds; + projectId: TProjectId; + tags: TTags; + traceIds: TTraceIds; + urls: TUrls; + warningIds: TWarningIds; +}) { + return { + activity: replayNullableNumber("Replay activity score"), + browser: replayNullishObject(ReplayBrowserSchema, "Browser metadata"), + count_dead_clicks: replayNullableNumber("Dead click count"), + count_errors: replayNullableNumber("Associated error count"), + count_infos: replayNullableNumber("Info event count"), + count_rage_clicks: replayNullableNumber("Rage click count"), + count_segments: replayNullableNumber("Recording segment count"), + count_urls: replayNullableNumber("Visited URL count"), + count_warnings: replayNullableNumber("Warning event count"), + device: replayNullishObject(ReplayDeviceSchema, "Device metadata"), + dist: replayNullableString("Distribution"), + duration: replayNullableNumber("Replay duration in seconds"), + environment: replayNullableString("Environment"), + error_ids: fields.errorIds.describe("Linked error IDs"), + finished_at: replayNullableString("Replay finish timestamp"), + has_viewed: replayNullableBoolean( + "Whether the current user has viewed the replay" + ), + id: z.string().describe("Replay ID"), + info_ids: fields.infoIds.describe("Linked info event IDs"), + is_archived: replayNullableBoolean("Archived flag"), + os: replayNullishObject(ReplayOsSchema, "Operating system metadata"), + platform: replayNullableString("Platform"), + project_id: fields.projectId.describe("Numeric project ID"), + releases: replayStringArray().optional().describe("Associated releases"), + sdk: replayNullishObject(ReplaySdkSchema, "SDK metadata"), + started_at: replayNullableString("Replay start timestamp"), + tags: fields.tags.describe("Replay tags"), + trace_ids: fields.traceIds.describe("Linked trace IDs"), + urls: fields.urls.describe("Visited URLs"), + user: replayNullishObject(ReplayUserSchema, "User metadata"), + warning_ids: fields.warningIds.describe("Linked warning event IDs"), + }; +} + /** * A single replay row returned by the organization replay index endpoint. * * Duration is in seconds, matching the backend replay interchange format. */ export const ReplayListItemSchema = z - .object({ - activity: z - .number() - .nullable() - .optional() - .describe("Replay activity score"), - browser: ReplayBrowserSchema.nullish().describe("Browser metadata"), - count_dead_clicks: z - .number() - .nullable() - .optional() - .describe("Dead click count"), - count_errors: z - .number() - .nullable() - .optional() - .describe("Associated error count"), - count_infos: z.number().nullable().optional().describe("Info event count"), - count_rage_clicks: z - .number() - .nullable() - .optional() - .describe("Rage click count"), - count_segments: z - .number() - .nullable() - .optional() - .describe("Recording segment count"), - count_urls: z.number().nullable().optional().describe("Visited URL count"), - count_warnings: z - .number() - .nullable() - .optional() - .describe("Warning event count"), - device: ReplayDeviceSchema.nullish().describe("Device metadata"), - dist: z.string().nullable().optional().describe("Distribution"), - duration: z - .number() - .nullable() - .optional() - .describe("Replay duration in seconds"), - environment: z.string().nullable().optional().describe("Environment"), - error_ids: z.array(z.string()).catch([]).describe("Linked error IDs"), - finished_at: z - .string() - .nullable() - .optional() - .describe("Replay finish timestamp"), - has_viewed: z - .boolean() - .nullable() - .optional() - .describe("Whether the current user has viewed the replay"), - id: z.string().describe("Replay ID"), - info_ids: z.array(z.string()).catch([]).describe("Linked info event IDs"), - is_archived: z.boolean().nullable().optional().describe("Archived flag"), - os: ReplayOsSchema.nullish().describe("Operating system metadata"), - platform: z.string().nullable().optional().describe("Platform"), - project_id: z - .union([z.string(), z.number()]) - .nullable() - .optional() - .describe("Numeric project ID"), - releases: z.array(z.string()).optional().describe("Associated releases"), - sdk: ReplaySdkSchema.nullish().describe("SDK metadata"), - started_at: z - .string() - .nullable() - .optional() - .describe("Replay start timestamp"), - tags: ReplayTagsSchema.describe("Replay tags"), - trace_ids: z.array(z.string()).catch([]).describe("Linked trace IDs"), - urls: z.array(z.string()).catch([]).describe("Visited URLs"), - user: ReplayUserSchema.nullish().describe("User metadata"), - warning_ids: z - .array(z.string()) - .catch([]) - .describe("Linked warning event IDs"), - }) + .object( + buildReplayListItemShape({ + errorIds: replayStringArrayWithFallback(), + infoIds: replayStringArrayWithFallback(), + projectId: z.union([z.string(), z.number()]).nullable().optional(), + tags: ReplayTagsSchema, + traceIds: replayStringArrayWithFallback(), + urls: replayStringArrayWithFallback(), + warningIds: replayStringArrayWithFallback(), + }) + ) .passthrough() .describe("Replay list row"); @@ -259,78 +273,17 @@ export const ReplayDetailsResponseSchema = z * legacy/nullish payload variants from archived replay rows. */ export const ReplayListItemOutputSchema = z - .object({ - activity: z - .number() - .nullable() - .optional() - .describe("Replay activity score"), - browser: ReplayBrowserSchema.nullish().describe("Browser metadata"), - count_dead_clicks: z - .number() - .nullable() - .optional() - .describe("Dead click count"), - count_errors: z - .number() - .nullable() - .optional() - .describe("Associated error count"), - count_infos: z.number().nullable().optional().describe("Info event count"), - count_rage_clicks: z - .number() - .nullable() - .optional() - .describe("Rage click count"), - count_segments: z - .number() - .nullable() - .optional() - .describe("Recording segment count"), - count_urls: z.number().nullable().optional().describe("Visited URL count"), - count_warnings: z - .number() - .nullable() - .optional() - .describe("Warning event count"), - device: ReplayDeviceSchema.nullish().describe("Device metadata"), - dist: z.string().nullable().optional().describe("Distribution"), - duration: z - .number() - .nullable() - .optional() - .describe("Replay duration in seconds"), - environment: z.string().nullable().optional().describe("Environment"), - error_ids: z.array(z.string()).describe("Linked error IDs"), - finished_at: z - .string() - .nullable() - .optional() - .describe("Replay finish timestamp"), - has_viewed: z - .boolean() - .nullable() - .optional() - .describe("Whether the current user has viewed the replay"), - id: z.string().describe("Replay ID"), - info_ids: z.array(z.string()).describe("Linked info event IDs"), - is_archived: z.boolean().nullable().optional().describe("Archived flag"), - os: ReplayOsSchema.nullish().describe("Operating system metadata"), - platform: z.string().nullable().optional().describe("Platform"), - project_id: z.string().nullable().optional().describe("Numeric project ID"), - releases: z.array(z.string()).optional().describe("Associated releases"), - sdk: ReplaySdkSchema.nullish().describe("SDK metadata"), - started_at: z - .string() - .nullable() - .optional() - .describe("Replay start timestamp"), - tags: z.record(z.array(z.string())).describe("Replay tags"), - trace_ids: z.array(z.string()).describe("Linked trace IDs"), - urls: z.array(z.string()).describe("Visited URLs"), - user: ReplayUserSchema.nullish().describe("User metadata"), - warning_ids: z.array(z.string()).describe("Linked warning event IDs"), - }) + .object( + buildReplayListItemShape({ + errorIds: replayStringArray(), + infoIds: replayStringArray(), + projectId: z.string().nullable().optional(), + tags: z.record(z.array(z.string())), + traceIds: replayStringArray(), + urls: replayStringArray(), + warningIds: replayStringArray(), + }) + ) .describe("Replay list row"); /** Documentation-oriented replay detail schema used for command metadata. */ diff --git a/src/types/sentry.ts b/src/types/sentry.ts index 55dc4b5b3..f134ad6f3 100644 --- a/src/types/sentry.ts +++ b/src/types/sentry.ts @@ -256,6 +256,7 @@ export type SentryEvent = Omit< browser?: BrowserContext; os?: OsContext; device?: DeviceContext; + replay?: ReplayContext; [key: string]: unknown; } | null; /** Date the event was created (not in OpenAPI spec) */ @@ -468,6 +469,12 @@ export type DeviceContext = { [key: string]: unknown; }; +/** Replay context from event.contexts.replay */ +export type ReplayContext = { + replay_id?: string; + [key: string]: unknown; +}; + export const ISSUE_PRIORITIES = ["high", "medium", "low"] as const; export type IssuePriority = (typeof ISSUE_PRIORITIES)[number]; From 6fbefb59767a66b919e637b1064e000ae5fc78f7 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Fri, 1 May 2026 14:32:55 -0700 Subject: [PATCH 05/26] feat(replay): Expand replay querying and inspection Add replay-backed explore queries and enrich replay view with recording activity, related issues, and related traces. This gives replay data a fuller command-line workflow instead of stopping at list metadata or web links. Tighten replay sort and field handling to the backend contract, and validate explicit project scoping when users target a replay through org/project syntax. Co-Authored-By: OpenAI Codex --- .../skills/sentry-cli/references/explore.md | 3 +- .../skills/sentry-cli/references/replay.md | 11 +- src/commands/explore.ts | 175 ++++++- src/commands/replay/list.ts | 87 ++-- src/commands/replay/view.ts | 493 +++++++++++++++++- src/lib/api-client.ts | 5 + src/lib/api/replays.ts | 89 +++- src/lib/api/traces.ts | 27 + src/lib/replay-search.ts | 189 +++++++ src/types/index.ts | 11 + src/types/replay.ts | 76 ++- src/types/sentry.ts | 26 + test/commands/explore.test.ts | 112 +++- test/commands/replay/list.test.ts | 34 ++ test/commands/replay/view.test.ts | 91 +++- test/lib/api/replays.test.ts | 69 +++ 16 files changed, 1412 insertions(+), 86 deletions(-) create mode 100644 src/lib/replay-search.ts diff --git a/plugins/sentry-cli/skills/sentry-cli/references/explore.md b/plugins/sentry-cli/skills/sentry-cli/references/explore.md index bc2a29e49..33a521ad2 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/explore.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/explore.md @@ -17,9 +17,10 @@ Query aggregate event data (Explore) **Flags:** - `-F, --field ... - API field or aggregate (repeatable). E.g., title, "count()", "p50(transaction.duration)"` -- `-d, --dataset - Dataset to query (errors, spans, metrics, logs) - (default: "errors")` +- `-d, --dataset - Dataset to query (errors, spans, metrics, logs, replays) - (default: "errors")` - `-q, --query - Search query (Sentry search syntax)` - `-s, --sort - Sort field (prefix with - for desc, e.g., "-count()")` +- `-e, --environment ... - Replay environment filter for --dataset replays (repeatable, comma-separated)` - `-n, --limit - Number of rows (1-1000) - (default: "25")` - `-t, --period - Time range: "7d", "2026-04-01..2026-05-01", ">=2026-04-01" - (default: "24h")` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/replay.md b/plugins/sentry-cli/skills/sentry-cli/references/replay.md index 5cb4170c9..96455c81f 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/replay.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/replay.md @@ -18,7 +18,8 @@ List recent Session Replays **Flags:** - `-n, --limit - Number of replays (1-1000) - (default: "25")` - `-q, --query - Search query (Sentry replay search syntax)` -- `-s, --sort - Sort by: date, oldest, duration, errors, segments, activity - (default: "date")` +- `-e, --environment ... - Filter by environment (repeatable, comma-separated)` +- `-s, --sort - Sort by: date, oldest, duration, errors, activity, or a raw replay sort field - (default: "date")` - `-t, --period - Time range: "7d", "2026-04-01..2026-05-01", ">=2026-04-01" - (default: "7d")` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` - `-c, --cursor - Navigate pages: "next", "prev", "first" (or raw cursor string)` @@ -47,6 +48,7 @@ List recent Session Replays | `info_ids` | array | Linked info event IDs | | `is_archived` | boolean \| null | Archived flag | | `os` | object \| null | Operating system metadata | +| `ota_updates` | object \| null | OTA update metadata | | `platform` | string \| null | Platform | | `project_id` | string \| null | Numeric project ID | | `releases` | array | Associated releases | @@ -90,7 +92,7 @@ View a Session Replay | Field | Type | Description | |-------|------|-------------| -| `activity` | number \| null | Replay activity score | +| `activity` | array | Summarized replay activity | | `browser` | object \| null | Browser metadata | | `count_dead_clicks` | number \| null | Dead click count | | `count_errors` | number \| null | Associated error count | @@ -110,6 +112,7 @@ View a Session Replay | `info_ids` | array | Linked info event IDs | | `is_archived` | boolean \| null | Archived flag | | `os` | object \| null | Operating system metadata | +| `ota_updates` | object \| null | OTA update metadata | | `platform` | string \| null | Platform | | `project_id` | string \| null | Numeric project ID | | `releases` | array | Associated releases | @@ -121,8 +124,10 @@ View a Session Replay | `user` | object \| null | User metadata | | `warning_ids` | array | Linked warning event IDs | | `clicks` | array | Replay click summaries | -| `ota_updates` | object \| null | OTA update metadata | | `replay_type` | string \| null | Replay type | +| `org` | string | Organization slug | +| `relatedIssues` | array | Replay-related issues | +| `relatedTraces` | array | Replay-related traces | **Examples:** diff --git a/src/commands/explore.ts b/src/commands/explore.ts index 608fe8095..fd9daa39d 100644 --- a/src/commands/explore.ts +++ b/src/commands/explore.ts @@ -7,7 +7,12 @@ */ import type { SentryContext } from "../context.js"; -import { queryEvents } from "../lib/api-client.js"; +import { + isReplaySortValue, + listReplays, + queryEvents, + type ReplaySortValue, +} from "../lib/api-client.js"; import { buildProjectQuery, validateLimit } from "../lib/arg-parsing.js"; import { advancePaginationState, @@ -30,6 +35,13 @@ import { } from "../lib/list-command.js"; import { logger } from "../lib/logger.js"; import { withProgress } from "../lib/polling.js"; +import { + DEFAULT_REPLAY_EXPLORE_FIELDS, + getReplayFieldValue, + getReplayRequestFields, + isSupportedReplayField, + listSupportedReplayFields, +} from "../lib/replay-search.js"; import { resolveOrgOptionalProjectFromArg } from "../lib/resolve-target.js"; import { sanitizeQuery } from "../lib/search-query.js"; import { @@ -52,6 +64,7 @@ const DEFAULT_FIELDS = ["title", "count()"]; /** Default dataset */ const DEFAULT_DATASET = "errors"; +const DEFAULT_REPLAY_SORT = "-started_at"; /** Default time period */ const DEFAULT_PERIOD = "24h"; @@ -73,6 +86,8 @@ const DATASET_ALIASES: Record = { metrics: "metricsEnhanced", logs: "logs", log: "logs", + replays: "replays", + replay: "replays", // Deprecated but still functional — hidden from help transactions: "transactions", transaction: "transactions", @@ -86,7 +101,13 @@ const DATASET_ALIASES: Record = { * * Set preserves insertion order for the join-based help/error rendering. */ -const VALID_DATASETS = new Set(["errors", "spans", "metrics", "logs"]); +const VALID_DATASETS = new Set([ + "errors", + "spans", + "metrics", + "logs", + "replays", +]); /** * Reverse map from API-level dataset name → canonical user-facing name. @@ -103,6 +124,7 @@ const API_TO_USER_DATASET = new Map( type ExploreFlags = { readonly field?: string[]; readonly dataset: string; + readonly environment?: readonly string[]; readonly query?: string; readonly sort?: string; readonly period: TimeRange; @@ -159,6 +181,54 @@ function parseLimit(value: string): number { return validateLimit(value, 1, LIST_MAX_LIMIT); } +function parseEnvironmentFilter( + values: readonly string[] | undefined +): string[] | undefined { + const parsed = values + ? [...values] + .flatMap((value) => value.split(",")) + .map((value) => value.trim()) + .filter(Boolean) + : []; + + return parsed.length > 0 ? parsed : undefined; +} + +function inferReplayFieldType(field: string): string { + if (field === "duration") { + return "duration"; + } + if ( + field === "activity" || + field === "count_screens" || + field.startsWith("count_") + ) { + return "integer"; + } + return "string"; +} + +function buildReplayExploreResponse( + fields: string[], + replays: Awaited>["data"] +): { data: Record[]; meta: NonNullable } { + return { + data: replays.map((replay) => + Object.fromEntries( + fields.map((field) => [field, getReplayFieldValue(replay, field)]) + ) + ), + meta: { + fields: Object.fromEntries( + fields.map((field) => [field, inferReplayFieldType(field)]) + ), + units: Object.fromEntries( + fields.map((field) => [field, field === "duration" ? "s" : null]) + ), + }, + }; +} + // --------------------------------------------------------------------------- // Output formatters // --------------------------------------------------------------------------- @@ -253,7 +323,7 @@ function appendFlagHints( base: string, flags: Pick< ExploreFlags, - "dataset" | "sort" | "query" | "period" | "field" | "limit" + "dataset" | "environment" | "sort" | "query" | "period" | "field" | "limit" > ): string { const parts: string[] = []; @@ -276,6 +346,11 @@ function appendFlagHints( if (flags.limit !== DEFAULT_LIMIT) { parts.push(`--limit ${flags.limit}`); } + if (flags.environment && flags.environment.length > 0) { + for (const environment of flags.environment) { + parts.push(`-e "${environment}"`); + } + } appendPeriodHint(parts, flags.period, DEFAULT_PERIOD); return parts.length > 0 ? `${base} ${parts.join(" ")}` : base; } @@ -288,11 +363,23 @@ function findFirstAggregate(fieldList: string[]): string | undefined { return fieldList.find((f) => f.includes("(") && f.includes(")")); } +/** Validate and normalize replay sort values. */ +function resolveReplaySort(explicitSort?: string): ReplaySortValue { + const sort = explicitSort ?? DEFAULT_REPLAY_SORT; + if (!isReplaySortValue(sort)) { + throw new ValidationError( + `Invalid replay sort "${sort}". Use a replay sort like ${DEFAULT_REPLAY_SORT} or -count_errors.`, + "sort" + ); + } + return sort; +} + /** - * Determine the effective sort value, accounting for dataset restrictions. + * Determine the effective sort value for non-replay explore datasets. * Sort is only supported on the `spans` dataset. */ -function resolveSort( +function resolveExploreSort( fieldList: string[], dataset: string, explicitSort?: string @@ -340,7 +427,8 @@ export const exploreCommand = buildListCommand("explore", { " errors Error events (default)\n" + " spans Span data\n" + " metrics Custom metrics\n" + - " logs Log entries\n\n" + + " logs Log entries\n" + + " replays Session replay search\n\n" + "Targets:\n" + " / Filter by project (auto-adds project: to query)\n" + " / All projects in org\n" + @@ -351,6 +439,7 @@ export const exploreCommand = buildListCommand("explore", { ' sentry explore my-org/ -F title -F "count()" -F "count_unique(user)" --period 1h\n' + ' sentry explore my-org/cli -F span.op -F "p50(span.duration)" ' + "--dataset spans\n" + + " sentry explore my-org/cli --dataset replays -F id -F user.email -F count_errors\n" + ' sentry explore -F span.op -F "count()" --dataset spans --period 1h\n' + " sentry explore --json", }, @@ -398,6 +487,14 @@ export const exploreCommand = buildListCommand("explore", { brief: 'Sort field (prefix with - for desc, e.g., "-count()")', optional: true, }, + environment: { + kind: "parsed", + parse: String, + brief: + "Replay environment filter for --dataset replays (repeatable, comma-separated)", + variadic: true, + optional: true, + }, limit: { kind: "parsed", parse: parseLimit, @@ -413,6 +510,7 @@ export const exploreCommand = buildListCommand("explore", { }, aliases: { ...PERIOD_ALIASES, + e: "environment", F: "field", d: "dataset", q: "query", @@ -428,15 +526,46 @@ export const exploreCommand = buildListCommand("explore", { "explore" ); - const fieldList = - flags.field && flags.field.length > 0 ? flags.field : DEFAULT_FIELDS; const dataset = flags.dataset; + let fieldList = DEFAULT_FIELDS; + if (dataset === "replays") { + fieldList = [...DEFAULT_REPLAY_EXPLORE_FIELDS]; + } + if (flags.field && flags.field.length > 0) { + fieldList = flags.field; + } const timeRange = flags.period; - const effectiveSort = resolveSort(fieldList, dataset, flags.sort); + const environment = parseEnvironmentFilter(flags.environment); + const replaySort = + dataset === "replays" ? resolveReplaySort(flags.sort) : undefined; + const effectiveSort = + replaySort ?? resolveExploreSort(fieldList, dataset, flags.sort); + + if (dataset !== "replays" && environment) { + throw new ValidationError( + "--environment is only supported with --dataset replays. Use environment:... inside --query for other datasets.", + "environment" + ); + } + + if (dataset === "replays") { + const unsupportedField = fieldList.find( + (field) => !isSupportedReplayField(field) + ); + if (unsupportedField) { + throw new ValidationError( + `Unsupported replay field "${unsupportedField}". Supported fields include: ${listSupportedReplayFields().slice(0, 12).join(", ")}...`, + "field" + ); + } + } // When a project is in the target, prepend `project:` to the query // so the API filters server-side. Mirrors `trace logs` / `log list` behavior. - const apiQuery = buildProjectQuery(flags.query, project); + const apiQuery = + dataset === "replays" + ? flags.query + : buildProjectQuery(flags.query, project); // Pagination context includes project so different scopes don't share state const contextKey = buildPaginationContextKey( @@ -444,6 +573,7 @@ export const exploreCommand = buildListCommand("explore", { project ? `${org}/${project}` : org, { dataset, + env: environment?.join(","), fields: fieldList.join(","), q: flags.query, sort: effectiveSort, @@ -461,8 +591,26 @@ export const exploreCommand = buildListCommand("explore", { message: `Querying ${dataset} in ${project ? `${org}/${project}` : org}...`, json: flags.json, }, - () => - queryEvents(org, { + async () => { + if (dataset === "replays") { + const replayResponse = await listReplays(org, { + cursor, + environment, + fields: getReplayRequestFields(fieldList), + limit: flags.limit, + projectSlugs: project ? [project] : undefined, + query: flags.query, + sort: replaySort, + ...timeRangeToApiParams(timeRange), + }); + + return { + data: buildReplayExploreResponse(fieldList, replayResponse.data), + nextCursor: replayResponse.nextCursor, + }; + } + + return queryEvents(org, { fields: fieldList, dataset, query: apiQuery, @@ -470,7 +618,8 @@ export const exploreCommand = buildListCommand("explore", { limit: flags.limit, cursor, ...timeRangeToApiParams(timeRange), - }) + }); + } ); advancePaginationState(PAGINATION_KEY, contextKey, direction, nextCursor); diff --git a/src/commands/replay/list.ts b/src/commands/replay/list.ts index c7b8f485a..f3753fa98 100644 --- a/src/commands/replay/list.ts +++ b/src/commands/replay/list.ts @@ -5,7 +5,11 @@ */ import type { SentryContext } from "../../context.js"; -import { listReplays, type ReplaySortValue } from "../../lib/api-client.js"; +import { + isReplaySortValue, + listReplays, + type ReplaySortValue, +} from "../../lib/api-client.js"; import { validateLimit } from "../../lib/arg-parsing.js"; import { advancePaginationState, @@ -34,6 +38,7 @@ import { targetPatternExplanation, } from "../../lib/list-command.js"; import { withProgress } from "../../lib/polling.js"; +import { getReplayUserLabel } from "../../lib/replay-search.js"; import { resolveOrgOptionalProjectFromArg } from "../../lib/resolve-target.js"; import { sanitizeQuery } from "../../lib/search-query.js"; import { @@ -48,6 +53,7 @@ import { } from "../../types/index.js"; type ListFlags = { + readonly environment?: readonly string[]; readonly limit: number; readonly query?: string; readonly sort: ReplaySortValue; @@ -67,20 +73,13 @@ type ReplayListResult = { project?: string; }; -type ReplaySortKey = - | "date" - | "oldest" - | "duration" - | "errors" - | "segments" - | "activity"; +type ReplaySortKey = "date" | "oldest" | "duration" | "errors" | "activity"; const SORT_MAP: Record = { date: "-started_at", oldest: "started_at", duration: "-duration", errors: "-count_errors", - segments: "-count_segments", activity: "-activity", }; @@ -93,18 +92,37 @@ function parseLimit(value: string): number { return validateLimit(value, LIST_MIN_LIMIT, LIST_MAX_LIMIT); } +function parseEnvironmentFilter( + values: readonly string[] | undefined +): string[] | undefined { + const parsed = values + ? [...values] + .flatMap((value) => value.split(",")) + .map((value) => value.trim()) + .filter(Boolean) + : []; + + return parsed.length > 0 ? parsed : undefined; +} + /** * Parse user-facing replay sort values into API sort expressions. */ export function parseSort(value: string): ReplaySortValue { - const normalized = value.toLowerCase() as ReplaySortKey; + const trimmed = value.trim(); + const normalized = trimmed.toLowerCase() as ReplaySortKey; const mapped = SORT_MAP[normalized]; - if (!mapped) { - throw new Error( - `Invalid sort value. Must be one of: ${Object.keys(SORT_MAP).join(", ")}` - ); + if (mapped) { + return mapped; } - return mapped; + + if (isReplaySortValue(trimmed)) { + return trimmed; + } + + throw new Error( + `Invalid sort value. Must be one of: ${Object.keys(SORT_MAP).join(", ")} or a replay sort like -count_rage_clicks` + ); } function formatCount(value: number | null | undefined): string { @@ -138,18 +156,7 @@ function formatReplayDuration(seconds: number | null | undefined): string { } function replayUserLabel(replay: ReplayListItem): string { - const user = replay.user; - if (!user) { - return "—"; - } - return ( - user.display_name ?? - user.username ?? - user.email ?? - user.id ?? - user.ip ?? - "—" - ); + return getReplayUserLabel(replay) ?? "—"; } const REPLAY_COLUMNS: Column[] = [ @@ -203,11 +210,16 @@ function formatScope(org: string, project?: string): string { function appendReplayFlags( base: string, - flags: Pick + flags: Pick ): string { const parts: string[] = []; appendQueryHint(parts, flags.query); appendSortHint(parts, flags.sort, DEFAULT_SORT); + if (flags.environment && flags.environment.length > 0) { + for (const environment of flags.environment) { + parts.push(`-e "${environment}"`); + } + } appendPeriodHint(parts, flags.period, DEFAULT_PERIOD); return parts.length > 0 ? `${base} ${parts.join(" ")}` : base; } @@ -215,7 +227,7 @@ function appendReplayFlags( function nextPageHint( org: string, project: string | undefined, - flags: Pick + flags: Pick ): string { return appendReplayFlags( `sentry replay list ${formatScope(org, project)} -c next`, @@ -226,7 +238,7 @@ function nextPageHint( function prevPageHint( org: string, project: string | undefined, - flags: Pick + flags: Pick ): string { return appendReplayFlags( `sentry replay list ${formatScope(org, project)} -c prev`, @@ -285,6 +297,7 @@ export const listCommand = buildListCommand("replay", { " sentry replay list sentry/cli --limit 50\n" + " sentry replay list sentry/cli --sort duration\n" + ' sentry replay list sentry/cli -q "user.email:foo@example.com"\n' + + " sentry replay list sentry/cli -e production -e canary\n" + " sentry replay list sentry/cli --period 24h\n\n" + "Alias: `sentry replays` → `sentry replay list`", }, @@ -318,16 +331,25 @@ export const listCommand = buildListCommand("replay", { brief: "Search query (Sentry replay search syntax)", optional: true, }, + environment: { + kind: "parsed", + parse: String, + brief: "Filter by environment (repeatable, comma-separated)", + variadic: true, + optional: true, + }, sort: { kind: "parsed", parse: parseSort, - brief: "Sort by: date, oldest, duration, errors, segments, activity", + brief: + "Sort by: date, oldest, duration, errors, activity, or a raw replay sort field", default: "date", }, period: LIST_PERIOD_FLAG, }, aliases: { ...PERIOD_ALIASES, + e: "environment", n: "limit", q: "query", s: "sort", @@ -336,6 +358,7 @@ export const listCommand = buildListCommand("replay", { async *func(this: SentryContext, flags: ListFlags, target?: string) { const { cwd } = this; const timeRange = flags.period; + const environment = parseEnvironmentFilter(flags.environment); const { query } = flags; const resolved = await resolveOrgOptionalProjectFromArg( @@ -348,6 +371,7 @@ export const listCommand = buildListCommand("replay", { "replay", formatScope(resolved.org, resolved.project), { + env: environment?.join(","), sort: flags.sort, q: query, period: serializeTimeRange(timeRange), @@ -366,6 +390,7 @@ export const listCommand = buildListCommand("replay", { }, () => listReplays(resolved.org, { + environment, limit: flags.limit, query, projectSlugs: resolved.project ? [resolved.project] : undefined, diff --git a/src/commands/replay/view.ts b/src/commands/replay/view.ts index 1e982cb57..a7a105b6f 100644 --- a/src/commands/replay/view.ts +++ b/src/commands/replay/view.ts @@ -5,7 +5,13 @@ */ import type { SentryContext } from "../../context.js"; -import { getReplay } from "../../lib/api-client.js"; +import { + getProject, + getReplay, + getReplayRecordingSegments, + getTraceMeta, + listIssuesPaginated, +} from "../../lib/api-client.js"; import { detectSwappedViewArgs, parseSlashSeparatedArg, @@ -28,14 +34,21 @@ import { FRESH_FLAG, } from "../../lib/list-command.js"; import { normalizeReplayId } from "../../lib/replay-id.js"; +import { getReplayUserLabel } from "../../lib/replay-search.js"; import { resolveOrgOptionalProjectFromArg } from "../../lib/resolve-target.js"; import { applySentryUrlContext, parseSentryUrl, } from "../../lib/sentry-url-parser.js"; import { buildReplayUrl } from "../../lib/sentry-urls.js"; -import type { ReplayDetails } from "../../types/index.js"; -import { ReplayDetailsOutputSchema } from "../../types/index.js"; +import type { + ReplayActivityEvent, + ReplayDetails, + ReplayRecordingSegments, + ReplayRelatedIssue, + ReplayRelatedTrace, +} from "../../types/index.js"; +import { ReplayViewOutputSchema } from "../../types/index.js"; type ViewFlags = { readonly json: boolean; @@ -50,10 +63,21 @@ type ParsedPositionalArgs = { warning?: string; }; +type ReplayViewData = { + org: string; + replay: ReplayDetails; + activity: ReplayActivityEvent[]; + relatedIssues: ReplayRelatedIssue[]; + relatedTraces: ReplayRelatedTrace[]; +}; + type MarkdownRow = [string, string]; const USAGE_HINT = "sentry replay view [//] | "; +const MAX_ACTIVITY_EVENTS = 6; +const MAX_RELATED_ERRORS = 3; +const MAX_RELATED_TRACES = 2; function pluralize(value: number, singular: string): string { return `${value} ${singular}${value === 1 ? "" : "s"}`; } @@ -164,21 +188,308 @@ export function parsePositionalArgs(args: string[]): ParsedPositionalArgs { return { replayId: second, targetArg: first }; } -function replayUserLabel(replay: ReplayDetails): string | undefined { - const user = replay.user; - if (!user) { +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function formatRelativeOffset(milliseconds: number): string { + const seconds = Math.max(0, Math.round(milliseconds / 1000)); + if (seconds < 60) { + return `${seconds}s`; + } + + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + if (minutes < 60) { + return remainingSeconds > 0 + ? `${minutes}m ${remainingSeconds}s` + : `${minutes}m`; + } + + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`; +} + +function getEventTimestampMillis(value: unknown): number | null { + if (typeof value === "number") { + return value; + } + if (typeof value === "string") { + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? null : parsed; + } + return null; +} + +function firstString(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null; +} + +function compactDetails(values: Array): string[] { + return values.filter((value): value is string => value !== null); +} + +function summarizePerformanceSpan( + payload: Record | null +): Omit | null { + const op = firstString(payload?.op); + const description = firstString(payload?.description); + const durationMs = + isRecord(payload?.data) && typeof payload.data.duration === "number" + ? payload.data.duration + : null; + + if (!(description || op)) { + return null; + } + + return { + label: op ?? "performanceSpan", + details: compactDetails([ + description ? `description=${description}` : null, + durationMs !== null ? `duration_ms=${durationMs}` : null, + ]), + }; +} + +function summarizeClickLikeEvent( + label: string, + payload: Record | null, + includeLabel = false +): Omit { + const selector = firstString(payload?.selector); + const clickLabel = firstString(payload?.label); + + return { + label, + details: compactDetails([ + selector ? `selector=${selector}` : null, + includeLabel && clickLabel ? `label=${clickLabel}` : null, + ]), + }; +} + +function summarizeBreadcrumb( + payload: Record | null +): Omit | null { + const category = firstString(payload?.category); + const message = firstString(payload?.message); + if (!(category || message)) { + return null; + } + + return { + label: category ?? "breadcrumb", + details: compactDetails([message ? `message=${message}` : null]), + }; +} + +const TAGGED_REPLAY_EVENT_SUMMARIZERS: Record< + string, + ( + payload: Record | null + ) => Omit | null +> = { + breadcrumb: summarizeBreadcrumb, + click: (payload: Record | null) => + summarizeClickLikeEvent("click", payload, true), + deadClick: (payload: Record | null) => + summarizeClickLikeEvent("dead.click", payload), + performanceSpan: summarizePerformanceSpan, + rageClick: (payload: Record | null) => + summarizeClickLikeEvent("rage.click", payload), +}; + +function summarizeTaggedReplayEvent( + tag: string, + payload: Record | null +): Omit | null { + const summarize = TAGGED_REPLAY_EVENT_SUMMARIZERS[tag]; + return summarize ? summarize(payload) : null; +} + +function summarizeReplayEvent(event: unknown): ReplayActivityEvent | null { + if (!isRecord(event)) { + return null; + } + + const timestampMs = getEventTimestampMillis(event.timestamp); + const data = isRecord(event.data) ? event.data : null; + const tag = typeof data?.tag === "string" ? data.tag : ""; + const payload = isRecord(data?.payload) ? data.payload : null; + + if (tag) { + const replayEvent = summarizeTaggedReplayEvent(tag, payload); + if (replayEvent) { + return { timestampMs, ...replayEvent }; + } + } + + const href = firstString(data?.href); + if (href) { + return { + timestampMs, + label: "page.view", + details: [`href=${href}`], + }; + } + + return null; +} + +function extractReplayActivityEvents( + segments: ReplayRecordingSegments | null +): ReplayActivityEvent[] { + if (!segments) { + return []; + } + + const events: ReplayActivityEvent[] = []; + for (const segment of segments) { + for (const event of segment) { + const replayEvent = summarizeReplayEvent(event); + if (replayEvent) { + events.push(replayEvent); + } + if (events.length >= MAX_ACTIVITY_EVENTS) { + return events; + } + } + } + + return events; +} + +type ReplayProjectScope = { + org: string; + project?: string; + expectedProjectId?: string; + replayId: string; + replay: ReplayDetails; +}; + +async function validateReplayProjectScope( + scope: ReplayProjectScope +): Promise { + const { expectedProjectId, org, project, replay, replayId } = scope; + if (!project) { return; } - return ( - user.display_name ?? - user.username ?? - user.email ?? - user.id ?? - user.ip ?? - undefined + + const projectId = expectedProjectId ?? (await getProject(org, project)).id; + if ( + replay.project_id === null || + replay.project_id === undefined || + String(projectId) !== String(replay.project_id) + ) { + throw new ResolutionError( + `Replay '${replayId}'`, + `is not in project '${project}'`, + `sentry replay view ${org}/${project}/${replayId}`, + [ + `Open the org-scoped replay instead: sentry replay view ${org}/${replayId}`, + ] + ); + } +} + +async function fetchReplayActivity( + org: string, + replay: ReplayDetails +): Promise { + if ( + replay.is_archived || + !replay.project_id || + (replay.count_segments ?? 0) <= 0 + ) { + return []; + } + + try { + const segments = await getReplayRecordingSegments( + org, + String(replay.project_id), + replay.id + ); + return extractReplayActivityEvents(segments); + } catch { + return []; + } +} + +function fetchRelatedReplayIssues( + org: string, + replay: ReplayDetails +): Promise { + const eventIds = replay.error_ids.slice(0, MAX_RELATED_ERRORS); + + return Promise.all( + eventIds.map(async (eventId) => { + try { + const page = await listIssuesPaginated(org, "", { + query: eventId, + perPage: 1, + }); + const issue = page.data[0]; + return { + eventId, + issueId: issue?.id ?? null, + shortId: issue?.shortId ?? null, + title: issue?.title ?? null, + }; + } catch { + return { eventId, issueId: null, shortId: null, title: null }; + } + }) + ); +} + +function fetchRelatedReplayTraces( + org: string, + replay: ReplayDetails +): Promise { + const traceIds = replay.trace_ids.slice(0, MAX_RELATED_TRACES); + + return Promise.all( + traceIds.map(async (traceId) => { + try { + const meta = await getTraceMeta(org, traceId); + return { + traceId, + errorCount: meta.errors, + logCount: meta.logs, + performanceIssueCount: meta.performance_issues, + spanCount: meta.span_count, + }; + } catch { + return { + traceId, + errorCount: null, + logCount: null, + performanceIssueCount: null, + spanCount: null, + }; + } + }) ); } +async function enrichReplayView( + org: string, + replay: ReplayDetails +): Promise< + Pick +> { + const [activity, relatedIssues, relatedTraces] = await Promise.all([ + fetchReplayActivity(org, replay), + fetchRelatedReplayIssues(org, replay), + fetchRelatedReplayTraces(org, replay), + ]); + + return { activity, relatedIssues, relatedTraces }; +} + function formatList(values: string[] | undefined): string | undefined { if (!values || values.length === 0) { return; @@ -232,8 +543,12 @@ function formatReplayLocation(replay: ReplayDetails): string | undefined { return location ? escapeMarkdownCell(location) : undefined; } -function buildReplayOverviewRows(replay: ReplayDetails): MarkdownRow[] { +function buildReplayOverviewRows( + org: string, + replay: ReplayDetails +): MarkdownRow[] { const rows: MarkdownRow[] = [["Replay ID", `\`${replay.id}\``]]; + pushMarkdownRow(rows, "Link", buildReplayUrl(org, replay.id)); pushMarkdownRow( rows, @@ -296,7 +611,7 @@ function buildReplayOverviewRows(replay: ReplayDetails): MarkdownRow[] { function buildReplayUserRows(replay: ReplayDetails): MarkdownRow[] { const rows: MarkdownRow[] = []; - const userLabel = replayUserLabel(replay); + const userLabel = getReplayUserLabel(replay); pushMarkdownRow( rows, "User", @@ -393,12 +708,107 @@ function pushTagsSection(lines: string[], replay: ReplayDetails): void { } } -function formatReplayDetails(replay: ReplayDetails): string { +function pushActivitySection( + lines: string[], + replay: ReplayDetails, + activity: ReplayActivityEvent[] +): void { + lines.push(""); + lines.push("### Activity"); + lines.push(""); + + if (replay.is_archived) { + lines.push("Recording is archived and not available for playback."); + return; + } + + if (activity.length === 0) { + lines.push("No activity events recorded."); + return; + } + + const startTime = activity[0]?.timestampMs ?? null; + for (const event of activity) { + const prefix = + event.timestampMs !== null && startTime !== null + ? `${formatRelativeOffset(event.timestampMs - startTime)} · ` + : ""; + const details = + event.details.length > 0 + ? ` · ${event.details.map((detail) => escapeMarkdownInline(detail)).join(" · ")}` + : ""; + lines.push(`- ${prefix}\`${escapeMarkdownInline(event.label)}\`${details}`); + } +} + +function formatRelatedIssueLine( + org: string, + issue: ReplayRelatedIssue +): string { + if (!(issue.shortId && issue.title)) { + return `- Event \`${issue.eventId}\``; + } + + return `- \`${issue.shortId}\`: ${escapeMarkdownInline(issue.title)} (view: \`sentry issue view ${org}/${issue.shortId}\`)`; +} + +function buildRelatedTraceStats(trace: ReplayRelatedTrace): string[] { + return [ + trace.spanCount !== null && trace.spanCount !== undefined + ? `${trace.spanCount} spans` + : null, + trace.errorCount !== null && trace.errorCount !== undefined + ? `${trace.errorCount} errors` + : null, + trace.logCount !== null && trace.logCount !== undefined + ? `${trace.logCount} logs` + : null, + trace.performanceIssueCount !== null && + trace.performanceIssueCount !== undefined + ? `${trace.performanceIssueCount} perf issues` + : null, + ].filter((value): value is string => value !== null); +} + +function formatRelatedTraceLine( + org: string, + trace: ReplayRelatedTrace +): string { + const stats = buildRelatedTraceStats(trace); + const suffix = stats.length > 0 ? ` (${stats.join(", ")})` : ""; + return `- Trace \`${trace.traceId}\`${suffix} (view: \`sentry trace view ${org}/${trace.traceId}\`)`; +} + +function pushRelatedSection( + lines: string[], + org: string, + relatedIssues: ReplayRelatedIssue[], + relatedTraces: ReplayRelatedTrace[] +): void { + if (relatedIssues.length === 0 && relatedTraces.length === 0) { + return; + } + + lines.push(""); + lines.push("### Related"); + lines.push(""); + + for (const issue of relatedIssues) { + lines.push(formatRelatedIssueLine(org, issue)); + } + + for (const trace of relatedTraces) { + lines.push(formatRelatedTraceLine(org, trace)); + } +} + +function formatReplayDetails(data: ReplayViewData): string { + const { activity, org, relatedIssues, relatedTraces, replay } = data; const lines: string[] = []; lines.push(`## Replay \`${replay.id.slice(0, 8)}\``); lines.push(""); - lines.push(mdKvTable(buildReplayOverviewRows(replay))); + lines.push(mdKvTable(buildReplayOverviewRows(org, replay))); pushKvSection(lines, buildReplayUserRows(replay), "User"); pushKvSection(lines, buildReplayClientRows(replay), "Client"); @@ -407,16 +817,24 @@ function formatReplayDetails(replay: ReplayDetails): string { pushListSection(lines, "URLs", replay.urls); pushListSection(lines, "Trace IDs", replay.trace_ids); pushListSection(lines, "Error IDs", replay.error_ids); + pushActivitySection(lines, replay, activity); + pushRelatedSection(lines, org, relatedIssues, relatedTraces); pushTagsSection(lines, replay); return renderMarkdown(lines.join("\n")); } -function replayHint(org: string, replay: ReplayDetails): string | undefined { - const traceId = replay.trace_ids?.[0]; +function replayHint(data: ReplayViewData): string | undefined { + const traceId = data.replay.trace_ids?.[0]; if (traceId) { - return `Related trace: sentry trace view ${org}/${traceId}`; + return `Related trace: sentry trace view ${data.org}/${traceId}`; + } + + const issue = data.relatedIssues[0]; + if (issue?.shortId) { + return `Related issue: sentry issue view ${data.org}/${issue.shortId}`; } + return; } @@ -439,9 +857,19 @@ export const viewCommand = buildCommand({ }, output: { human: formatReplayDetails, - jsonTransform: (replay: ReplayDetails, fields?: string[]) => - fields && fields.length > 0 ? filterFields(replay, fields) : replay, - schema: ReplayDetailsOutputSchema, + jsonTransform: (data: ReplayViewData, fields?: string[]) => { + const result: Record = { + ...data.replay, + org: data.org, + activity: data.activity, + relatedIssues: data.relatedIssues, + relatedTraces: data.relatedTraces, + }; + return fields && fields.length > 0 + ? filterFields(result, fields) + : result; + }, + schema: ReplayViewOutputSchema, }, parameters: { positional: { @@ -501,7 +929,22 @@ export const viewCommand = buildCommand({ throw error; } - yield new CommandOutput(replay); - return { hint: replayHint(resolved.org, replay) }; + await validateReplayProjectScope({ + org: resolved.org, + project: resolved.project, + expectedProjectId: resolved.projectData?.id, + replayId, + replay, + }); + + const enrichment = await enrichReplayView(resolved.org, replay); + const data: ReplayViewData = { + org: resolved.org, + replay, + ...enrichment, + }; + + yield new CommandOutput(data); + return { hint: replayHint(data) }; }, }); diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 233910a5d..6b4d1e78f 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -122,9 +122,13 @@ export { } from "./api/releases.js"; export { getReplay, + getReplayRecordingSegments, + isReplaySortValue, type ListReplaysOptions, listReplayIdsForIssue, listReplays, + REPLAY_SORT_FIELDS, + type ReplaySortField, type ReplaySortValue, } from "./api/replays.js"; export { @@ -155,6 +159,7 @@ export { fetchMultiSpanDetails, getDetailedTrace, getSpanDetails, + getTraceMeta, listSpans, listTransactions, normalizeTraceSpan, diff --git a/src/lib/api/replays.ts b/src/lib/api/replays.ts index 26387be5d..4b5439ff7 100644 --- a/src/lib/api/replays.ts +++ b/src/lib/api/replays.ts @@ -14,6 +14,8 @@ import { type ReplayListItem, type ReplayListResponse, ReplayListResponseSchema, + type ReplayRecordingSegments, + ReplayRecordingSegmentsSchema, } from "../../types/index.js"; import { resolveOrgRegion } from "../region.js"; @@ -26,14 +28,51 @@ import { parseLinkHeader, } from "./infrastructure.js"; -/** Sort values supported by the CLI replay list command. */ -export type ReplaySortValue = - | "-started_at" - | "started_at" - | "-duration" - | "-count_errors" - | "-count_segments" - | "-activity"; +/** Replay sort field names supported by the backend replay index endpoint. */ +export const REPLAY_SORT_FIELDS = [ + "activity", + "browser", + "browser.name", + "browser.version", + "count_dead_clicks", + "count_errors", + "count_infos", + "count_rage_clicks", + "count_screens", + "count_urls", + "count_warnings", + "device.brand", + "device.family", + "device.model", + "device.name", + "dist", + "duration", + "finished_at", + "os", + "os.name", + "os.version", + "platform", + "project_id", + "sdk.name", + "started_at", + "user.email", + "user.id", + "user.username", +] as const; + +/** Sort values supported by the backend replay index endpoint. */ +export type ReplaySortField = (typeof REPLAY_SORT_FIELDS)[number]; +export type ReplaySortValue = ReplaySortField | `-${ReplaySortField}`; + +const REPLAY_SORT_VALUES = new Set([ + ...REPLAY_SORT_FIELDS, + ...REPLAY_SORT_FIELDS.map((field) => `-${field}`), +]); + +/** Return whether a sort string is supported by the replay index endpoint. */ +export function isReplaySortValue(value: string): value is ReplaySortValue { + return REPLAY_SORT_VALUES.has(value); +} /** Options for {@link listReplays}. */ export type ListReplaysOptions = { @@ -41,6 +80,10 @@ export type ListReplaysOptions = { limit?: number; /** Structured replay query using Sentry search syntax. */ query?: string; + /** Optional environment filter(s). */ + environment?: string[]; + /** Response fields to request from the replay endpoint. */ + fields?: string[]; /** Project slugs to filter by. */ projectSlugs?: string[]; /** Sort expression for the replay index endpoint. */ @@ -93,7 +136,11 @@ async function fetchReplayPage( { params: { cursor, - field: [...REPLAY_LIST_FIELDS], + environment: options.environment, + field: + options.fields && options.fields.length > 0 + ? options.fields + : [...REPLAY_LIST_FIELDS], per_page: perPage, projectSlug: options.projectSlugs, query: options.query, @@ -155,6 +202,30 @@ export async function getReplay( return normalizeReplayProjectId(data.data); } +/** + * Fetch replay recording segments for a single replay. + * + * Uses the project-scoped replay endpoint because recording segments are + * partitioned by project. `download=true` matches the frontend contract and + * returns the parsed segment payload directly. + */ +export async function getReplayRecordingSegments( + orgSlug: string, + projectSlugOrId: string, + replayId: string +): Promise { + const regionUrl = await resolveOrgRegion(orgSlug); + const { data } = await apiRequestToRegion( + regionUrl, + `/projects/${orgSlug}/${projectSlugOrId}/replays/${replayId}/recording-segments/`, + { + params: { download: true }, + schema: ReplayRecordingSegmentsSchema, + } + ); + return data; +} + /** * List replay IDs related to a single issue. */ diff --git a/src/lib/api/traces.ts b/src/lib/api/traces.ts index 3b535f5e3..d50f3dc77 100644 --- a/src/lib/api/traces.ts +++ b/src/lib/api/traces.ts @@ -10,6 +10,8 @@ import { type SpanListItem, type SpansResponse, SpansResponseSchema, + type TraceMeta, + TraceMetaSchema, type TraceSpan, type TransactionListItem, type TransactionsResponse, @@ -169,6 +171,31 @@ export async function getSpanDetails( return data; } +/** + * Fetch high-level metadata for a trace. + * + * Uses the org-scoped trace-meta endpoint to retrieve counts for spans, errors, + * logs, and performance issues. This is useful for lightweight trace + * references without fetching the full trace tree. + */ +export async function getTraceMeta( + orgSlug: string, + traceId: string, + statsPeriod = "14d" +): Promise { + const regionUrl = await resolveOrgRegion(orgSlug); + + const { data } = await apiRequestToRegion( + regionUrl, + `/organizations/${orgSlug}/trace-meta/${traceId}/`, + { + params: { statsPeriod }, + schema: TraceMetaSchema, + } + ); + return data; +} + // --------------------------------------------------------------------------- // Shared span detail helpers // --------------------------------------------------------------------------- diff --git a/src/lib/replay-search.ts b/src/lib/replay-search.ts new file mode 100644 index 000000000..18e085e7d --- /dev/null +++ b/src/lib/replay-search.ts @@ -0,0 +1,189 @@ +import type { ReplayDetails, ReplayListItem } from "../types/index.js"; + +type ReplayLike = ReplayListItem | ReplayDetails; +type ReplayFieldResolver = (replay: ReplayLike) => unknown; + +const REPLAY_FIELD_ALIASES = { + count_screens: "count_urls", + error_id: "error_ids", + info_id: "info_ids", + release: "releases", + screen: "urls", + screens: "urls", + seen_by_me: "has_viewed", + trace: "trace_ids", + trace_id: "trace_ids", + url: "urls", + "user.ip_address": "user.ip", + viewed_by_me: "has_viewed", + warning_id: "warning_ids", +} as const satisfies Record; + +/** Default field set for replay rows shown in `sentry explore --dataset replays`. */ +export const DEFAULT_REPLAY_EXPLORE_FIELDS = [ + "id", + "started_at", + "duration", + "count_errors", + "count_rage_clicks", + "count_dead_clicks", + "url", + "user.email", +] as const; + +function firstValue(values: T[] | undefined): T | undefined { + return values && values.length > 0 ? values[0] : undefined; +} + +/** Return the best available human label for the replay user. */ +export function getReplayUserLabel(replay: ReplayLike): string | undefined { + const user = replay.user; + if (!user) { + return; + } + + return ( + user.display_name ?? + user.username ?? + user.email ?? + user.id ?? + user.ip ?? + undefined + ); +} + +const REPLAY_FIELD_RESOLVERS: Record = { + activity: (replay) => replay.activity, + browser: (replay) => replay.browser?.name, + "browser.name": (replay) => replay.browser?.name, + "browser.version": (replay) => replay.browser?.version, + count_dead_clicks: (replay) => replay.count_dead_clicks, + count_errors: (replay) => replay.count_errors, + count_infos: (replay) => replay.count_infos, + count_rage_clicks: (replay) => replay.count_rage_clicks, + count_screens: (replay) => replay.count_urls, + count_segments: (replay) => replay.count_segments, + count_traces: (replay) => replay.trace_ids?.length, + count_urls: (replay) => replay.count_urls, + count_warnings: (replay) => replay.count_warnings, + device: (replay) => replay.device?.name, + "device.brand": (replay) => replay.device?.brand, + "device.family": (replay) => replay.device?.family, + "device.model": (replay) => replay.device?.model, + "device.model_id": (replay) => replay.device?.model_id, + "device.name": (replay) => replay.device?.name, + dist: (replay) => replay.dist, + duration: (replay) => replay.duration, + environment: (replay) => replay.environment, + error_id: (replay) => firstValue(replay.error_ids), + error_ids: (replay) => replay.error_ids, + finished_at: (replay) => replay.finished_at, + has_viewed: (replay) => replay.has_viewed, + id: (replay) => replay.id, + info_id: (replay) => firstValue(replay.info_ids), + info_ids: (replay) => replay.info_ids, + is_archived: (replay) => replay.is_archived, + os: (replay) => replay.os?.name, + "os.name": (replay) => replay.os?.name, + "os.version": (replay) => replay.os?.version, + platform: (replay) => replay.platform, + project_id: (replay) => replay.project_id, + release: (replay) => firstValue(replay.releases), + releases: (replay) => replay.releases, + replay_type: (replay) => replay.replay_type, + screen: (replay) => firstValue(replay.urls), + screens: (replay) => replay.urls, + sdk: (replay) => replay.sdk?.name, + "sdk.name": (replay) => replay.sdk?.name, + "sdk.version": (replay) => replay.sdk?.version, + seen_by_me: (replay) => replay.has_viewed, + started_at: (replay) => replay.started_at, + trace: (replay) => firstValue(replay.trace_ids), + trace_id: (replay) => firstValue(replay.trace_ids), + trace_ids: (replay) => replay.trace_ids, + url: (replay) => firstValue(replay.urls), + urls: (replay) => replay.urls, + user: (replay) => getReplayUserLabel(replay), + "user.email": (replay) => replay.user?.email, + "user.geo.city": (replay) => replay.user?.geo?.city, + "user.geo.country_code": (replay) => replay.user?.geo?.country_code, + "user.geo.region": (replay) => replay.user?.geo?.region, + "user.geo.subdivision": (replay) => replay.user?.geo?.subdivision, + "user.id": (replay) => replay.user?.id, + "user.ip": (replay) => replay.user?.ip, + "user.ip_address": (replay) => replay.user?.ip, + "user.username": (replay) => replay.user?.username, + viewed_by_me: (replay) => replay.has_viewed, + warning_id: (replay) => firstValue(replay.warning_ids), + warning_ids: (replay) => replay.warning_ids, +}; + +function replayRequestRoot(field: string): string { + const normalized: string = + REPLAY_FIELD_ALIASES[field as keyof typeof REPLAY_FIELD_ALIASES] ?? field; + + switch (normalized) { + case "browser.name": + case "browser.version": + return "browser"; + case "device.brand": + case "device.family": + case "device.model": + case "device.model_id": + case "device.name": + return "device"; + case "os.name": + case "os.version": + return "os"; + case "sdk.name": + case "sdk.version": + return "sdk"; + case "user.email": + case "user.geo.city": + case "user.geo.country_code": + case "user.geo.region": + case "user.geo.subdivision": + case "user.id": + case "user.ip": + case "user.username": + return "user"; + default: + return normalized; + } +} + +/** Return whether the CLI can render a replay field in replay search outputs. */ +export function isSupportedReplayField(field: string): boolean { + return field in REPLAY_FIELD_RESOLVERS; +} + +/** List the replay fields the CLI can render in replay search outputs. */ +export function listSupportedReplayFields(): string[] { + return Object.keys(REPLAY_FIELD_RESOLVERS).sort(); +} + +/** + * Map requested replay output fields to the top-level replay API fields required + * to materialize them. + */ +export function getReplayRequestFields(fields: string[]): string[] { + const roots = new Set(["id"]); + + for (const field of fields) { + roots.add(replayRequestRoot(field)); + } + + return [...roots]; +} + +/** Extract a replay field value for CLI search/display output. */ +export function getReplayFieldValue( + replay: ReplayLike, + field: string +): unknown { + const resolver = REPLAY_FIELD_RESOLVERS[field]; + if (!resolver) { + throw new Error(`Unsupported replay field: ${field}`); + } + return resolver(replay); +} diff --git a/src/types/index.ts b/src/types/index.ts index da1f3c36d..ed8af9394 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -47,6 +47,7 @@ export { } from "./oauth.js"; // Replay types and schemas export type { + ReplayActivityEvent, ReplayBrowser, ReplayDetails, ReplayDetailsResponse, @@ -57,11 +58,15 @@ export type { ReplayListResponse, ReplayOs, ReplayOtaUpdates, + ReplayRecordingSegments, + ReplayRelatedIssue, + ReplayRelatedTrace, ReplaySdk, ReplayUser, } from "./replay.js"; export { REPLAY_LIST_FIELDS, + ReplayActivityEventSchema, ReplayBrowserSchema, ReplayDetailsOutputSchema, ReplayDetailsResponseSchema, @@ -74,8 +79,12 @@ export { ReplayListResponseSchema, ReplayOsSchema, ReplayOtaUpdatesSchema, + ReplayRecordingSegmentsSchema, + ReplayRelatedIssueSchema, + ReplayRelatedTraceSchema, ReplaySdkSchema, ReplayUserSchema, + ReplayViewOutputSchema, } from "./replay.js"; export type { AutofixResponse, @@ -131,6 +140,7 @@ export type { TraceContext, TraceLog, TraceLogsResponse, + TraceMeta, TraceSpan, TransactionListItem, TransactionsResponse, @@ -156,6 +166,7 @@ export { SpansResponseSchema, TraceLogSchema, TraceLogsResponseSchema, + TraceMetaSchema, TransactionListItemSchema, TransactionsResponseSchema, UserRegionsResponseSchema, diff --git a/src/types/replay.ts b/src/types/replay.ts index f8f5dd150..4cc45d441 100644 --- a/src/types/replay.ts +++ b/src/types/replay.ts @@ -163,6 +163,7 @@ function replayStringArrayWithFallback() { function buildReplayListItemShape< TErrorIds extends z.ZodTypeAny, TInfoIds extends z.ZodTypeAny, + TOtaUpdates extends z.ZodTypeAny, TProjectId extends z.ZodTypeAny, TTags extends z.ZodTypeAny, TTraceIds extends z.ZodTypeAny, @@ -171,6 +172,7 @@ function buildReplayListItemShape< >(fields: { errorIds: TErrorIds; infoIds: TInfoIds; + otaUpdates: TOtaUpdates; projectId: TProjectId; tags: TTags; traceIds: TTraceIds; @@ -200,6 +202,7 @@ function buildReplayListItemShape< info_ids: fields.infoIds.describe("Linked info event IDs"), is_archived: replayNullableBoolean("Archived flag"), os: replayNullishObject(ReplayOsSchema, "Operating system metadata"), + ota_updates: fields.otaUpdates.describe("OTA update metadata"), platform: replayNullableString("Platform"), project_id: fields.projectId.describe("Numeric project ID"), releases: replayStringArray().optional().describe("Associated releases"), @@ -223,6 +226,10 @@ export const ReplayListItemSchema = z buildReplayListItemShape({ errorIds: replayStringArrayWithFallback(), infoIds: replayStringArrayWithFallback(), + otaUpdates: replayNullishObject( + ReplayOtaUpdatesSchema, + "OTA update metadata" + ), projectId: z.union([z.string(), z.number()]).nullable().optional(), tags: ReplayTagsSchema, traceIds: replayStringArrayWithFallback(), @@ -248,10 +255,14 @@ export const ReplayDetailsSchema = ReplayListItemSchema.extend({ .array(ReplayClickSchema) .optional() .describe("Replay click summaries"), - ota_updates: ReplayOtaUpdatesSchema.nullish().describe("OTA update metadata"), replay_type: z.string().nullable().optional().describe("Replay type"), }).describe("Replay details"); +/** Replay recording segments downloaded from the project replay endpoint. */ +export const ReplayRecordingSegmentsSchema = z + .array(z.array(z.unknown())) + .describe("Replay recording segments"); + /** Envelope returned by the replay index endpoint. */ export const ReplayListResponseSchema = z .object({ @@ -277,6 +288,7 @@ export const ReplayListItemOutputSchema = z buildReplayListItemShape({ errorIds: replayStringArray(), infoIds: replayStringArray(), + otaUpdates: ReplayOtaUpdatesSchema.nullish(), projectId: z.string().nullable().optional(), tags: z.record(z.array(z.string())), traceIds: replayStringArray(), @@ -292,10 +304,64 @@ export const ReplayDetailsOutputSchema = ReplayListItemOutputSchema.extend({ .array(ReplayClickSchema) .optional() .describe("Replay click summaries"), - ota_updates: ReplayOtaUpdatesSchema.nullish().describe("OTA update metadata"), replay_type: z.string().nullable().optional().describe("Replay type"), }).describe("Replay details"); +/** A summarized replay activity event extracted from recording segments. */ +export const ReplayActivityEventSchema = z + .object({ + timestampMs: z + .number() + .nullable() + .describe("Milliseconds since UNIX epoch for the activity event"), + label: z.string().describe("Activity label"), + details: z.array(z.string()).describe("Supplemental activity details"), + }) + .describe("Summarized replay activity event"); + +/** Related issue metadata extracted from replay-linked event IDs. */ +export const ReplayRelatedIssueSchema = z + .object({ + eventId: z.string().describe("Replay-linked event ID"), + issueId: z.string().nullable().optional().describe("Resolved issue ID"), + shortId: z + .string() + .nullable() + .optional() + .describe("Resolved issue short ID"), + title: z.string().nullable().optional().describe("Resolved issue title"), + }) + .describe("Replay-related issue"); + +/** Related trace metadata extracted from replay trace IDs. */ +export const ReplayRelatedTraceSchema = z + .object({ + traceId: z.string().describe("Replay-linked trace ID"), + errorCount: z.number().nullable().optional().describe("Trace error count"), + logCount: z.number().nullable().optional().describe("Trace log count"), + performanceIssueCount: z + .number() + .nullable() + .optional() + .describe("Trace performance issue count"), + spanCount: z.number().nullable().optional().describe("Trace span count"), + }) + .describe("Replay-related trace"); + +/** Replay view output with related context and summarized activity. */ +export const ReplayViewOutputSchema = ReplayDetailsOutputSchema.extend({ + org: z.string().describe("Organization slug"), + activity: z + .array(ReplayActivityEventSchema) + .describe("Summarized replay activity"), + relatedIssues: z + .array(ReplayRelatedIssueSchema) + .describe("Replay-related issues"), + relatedTraces: z + .array(ReplayRelatedTraceSchema) + .describe("Replay-related traces"), +}).describe("Replay view output"); + /** Replay IDs keyed by resource identifier (issue ID, trace ID, replay ID). */ export const ReplayIdsByResourceSchema = z .record(z.string(), z.array(z.string())) @@ -310,6 +376,12 @@ export type ReplayDevice = z.infer; export type ReplayOtaUpdates = z.infer; export type ReplayListItem = z.infer; export type ReplayDetails = z.infer; +export type ReplayRecordingSegments = z.infer< + typeof ReplayRecordingSegmentsSchema +>; export type ReplayListResponse = z.infer; export type ReplayDetailsResponse = z.infer; export type ReplayIdsByResource = z.infer; +export type ReplayActivityEvent = z.infer; +export type ReplayRelatedIssue = z.infer; +export type ReplayRelatedTrace = z.infer; diff --git a/src/types/sentry.ts b/src/types/sentry.ts index f134ad6f3..3a173ce5b 100644 --- a/src/types/sentry.ts +++ b/src/types/sentry.ts @@ -475,6 +475,32 @@ export type ReplayContext = { [key: string]: unknown; }; +/** High-level metadata returned by the organization trace-meta endpoint. */ +export const TraceMetaSchema = z + .object({ + logs: z.number().describe("Log entry count"), + errors: z.number().describe("Error count"), + performance_issues: z.number().describe("Performance issue count"), + span_count: z.number().describe("Span count"), + transaction_child_count_map: z + .array( + z.object({ + "transaction.event_id": z + .string() + .nullable() + .describe("Transaction event ID"), + "count()": z.number().describe("Transaction child count"), + }) + ) + .describe("Per-transaction child counts"), + span_count_map: z + .record(z.string(), z.number()) + .describe("Span counts grouped by operation"), + }) + .describe("Trace metadata"); + +export type TraceMeta = z.infer; + export const ISSUE_PRIORITIES = ["high", "medium", "low"] as const; export type IssuePriority = (typeof ISSUE_PRIORITIES)[number]; diff --git a/test/commands/explore.test.ts b/test/commands/explore.test.ts index e31f4872e..6f63fa753 100644 --- a/test/commands/explore.test.ts +++ b/test/commands/explore.test.ts @@ -19,7 +19,7 @@ import { exploreCommand } from "../../src/commands/explore.js"; import * as apiClient from "../../src/lib/api-client.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as paginationDb from "../../src/lib/db/pagination.js"; -import { ContextError } from "../../src/lib/errors.js"; +import { ContextError, ValidationError } from "../../src/lib/errors.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as resolveTarget from "../../src/lib/resolve-target.js"; import { parsePeriod } from "../../src/lib/time-range.js"; @@ -71,7 +71,26 @@ const MOCK_EVENTS_RESPONSE = { }, }; +const MOCK_REPLAYS_RESPONSE = [ + { + id: "346789a703f6454384f1de473b8b9fcc", + count_dead_clicks: 1, + count_errors: 2, + count_rage_clicks: 3, + duration: 125, + error_ids: ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"], + info_ids: [], + started_at: "2025-01-30T14:32:15+00:00", + tags: {}, + trace_ids: ["bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + urls: ["/checkout"], + user: { email: "user@example.com" }, + warning_ids: [], + }, +]; + let queryEventsSpy: ReturnType; +let listReplaysSpy: ReturnType; let resolveTargetSpy: ReturnType; let resolveCursorSpy: ReturnType; let advancePaginationStateSpy: ReturnType; @@ -85,6 +104,11 @@ beforeEach(async () => { data: MOCK_EVENTS_RESPONSE, nextCursor: undefined, }); + listReplaysSpy = spyOn(apiClient, "listReplays"); + listReplaysSpy.mockResolvedValue({ + data: MOCK_REPLAYS_RESPONSE, + nextCursor: undefined, + }); // Default: resolveOrgOptionalProjectFromArg returns org-only (auto-detect) resolveTargetSpy = spyOn(resolveTarget, "resolveOrgOptionalProjectFromArg"); @@ -105,6 +129,7 @@ beforeEach(async () => { afterEach(() => { queryEventsSpy.mockRestore(); + listReplaysSpy.mockRestore(); resolveTargetSpy.mockRestore(); resolveCursorSpy.mockRestore(); advancePaginationStateSpy.mockRestore(); @@ -298,6 +323,35 @@ describe("sentry explore", () => { expect.objectContaining({ limit: 100 }) ); }); + + test("routes replay dataset queries through the replay index API", async () => { + resolveTargetSpy.mockResolvedValue({ org: "test-org", project: "cli" }); + const { context } = createContext(); + + await func.call( + context, + { + ...DEFAULT_FLAGS, + dataset: "replays", + environment: ["production,canary"], + field: ["id", "user.email", "count_errors", "url"], + sort: "-count_errors", + }, + "test-org/cli" + ); + + expect(listReplaysSpy).toHaveBeenCalledWith("test-org", { + cursor: undefined, + environment: ["production", "canary"], + fields: ["id", "user", "count_errors", "urls"], + limit: 25, + projectSlugs: ["cli"], + query: undefined, + sort: "-count_errors", + statsPeriod: "24h", + }); + expect(queryEventsSpy).not.toHaveBeenCalled(); + }); }); describe("sort handling", () => { @@ -346,6 +400,22 @@ describe("sentry explore", () => { expect.objectContaining({ sort: undefined }) ); }); + + test("defaults replay dataset sort to -started_at", async () => { + resolveTargetSpy.mockResolvedValue({ org: "test-org" }); + const { context } = createContext(); + + await func.call( + context, + { ...DEFAULT_FLAGS, dataset: "replays" }, + "test-org/" + ); + + expect(listReplaysSpy).toHaveBeenCalledWith( + "test-org", + expect.objectContaining({ sort: "-started_at" }) + ); + }); }); describe("output", () => { @@ -426,6 +496,31 @@ describe("sentry explore", () => { expect(parsed.meta).toBeDefined(); }); + test("renders replay dataset JSON output with flattened replay rows", async () => { + resolveTargetSpy.mockResolvedValue({ org: "test-org" }); + const { context, getStdout } = createContext(); + + await func.call( + context, + { + ...DEFAULT_FLAGS, + dataset: "replays", + field: ["id", "user.email", "count_errors", "url"], + json: true, + }, + "test-org/" + ); + + const parsed = JSON.parse(getStdout()); + expect(parsed.dataset).toBe("replays"); + expect(parsed.data[0]).toEqual({ + id: "346789a703f6454384f1de473b8b9fcc", + "user.email": "user@example.com", + count_errors: 2, + url: "/checkout", + }); + }); + test("shows empty message when no results", async () => { resolveTargetSpy.mockResolvedValue({ org: "test-org" }); queryEventsSpy.mockResolvedValue({ @@ -474,4 +569,19 @@ describe("sentry explore", () => { ); }); }); + + describe("validation", () => { + test("rejects --environment on non-replay datasets", async () => { + resolveTargetSpy.mockResolvedValue({ org: "test-org" }); + const { context } = createContext(); + + await expect( + func.call( + context, + { ...DEFAULT_FLAGS, environment: ["production"] }, + "test-org/" + ) + ).rejects.toThrow(ValidationError); + }); + }); }); diff --git a/test/commands/replay/list.test.ts b/test/commands/replay/list.test.ts index bcd999955..d1d3a06d0 100644 --- a/test/commands/replay/list.test.ts +++ b/test/commands/replay/list.test.ts @@ -26,6 +26,7 @@ describe("parseSort", () => { expect(parseSort("date")).toBe("-started_at"); expect(parseSort("duration")).toBe("-duration"); expect(parseSort("errors")).toBe("-count_errors"); + expect(parseSort("-count_rage_clicks")).toBe("-count_rage_clicks"); }); test("throws for invalid sort value", () => { @@ -110,6 +111,7 @@ describe("listCommand.func", () => { ); expect(listReplaysSpy).toHaveBeenCalledWith("test-org", { + environment: undefined, limit: 25, projectSlugs: ["cli"], query: undefined, @@ -126,6 +128,38 @@ describe("listCommand.func", () => { expect(parsed.data[0].id).toBe(sampleReplays[0]?.id); }); + test("passes replay environment filters through to the API", async () => { + resolveTargetSpy.mockResolvedValue({ org: "test-org", project: "cli" }); + listReplaysSpy.mockResolvedValue({ + data: sampleReplays, + nextCursor: undefined, + }); + + const { context } = createMockContext(); + const func = await listCommand.loader(); + await func.call( + context, + { + environment: ["production,canary", "staging"], + limit: 25, + json: true, + period: parsePeriod("7d"), + sort: "-started_at", + }, + "test-org/cli" + ); + + expect(listReplaysSpy).toHaveBeenCalledWith("test-org", { + environment: ["production", "canary", "staging"], + limit: 25, + projectSlugs: ["cli"], + query: undefined, + sort: "-started_at", + cursor: undefined, + statsPeriod: "7d", + }); + }); + test("renders human output with a replay hint", async () => { resolveTargetSpy.mockResolvedValue({ org: "test-org", project: "cli" }); listReplaysSpy.mockResolvedValue({ data: sampleReplays }); diff --git a/test/commands/replay/view.test.ts b/test/commands/replay/view.test.ts index a37c4772e..2ba1a51e7 100644 --- a/test/commands/replay/view.test.ts +++ b/test/commands/replay/view.test.ts @@ -97,7 +97,11 @@ describe("parsePositionalArgs", () => { }); describe("viewCommand.func", () => { + let getProjectSpy: ReturnType; let getReplaySpy: ReturnType; + let getReplayRecordingSegmentsSpy: ReturnType; + let getTraceMetaSpy: ReturnType; + let listIssuesPaginatedSpy: ReturnType; let resolveTargetSpy: ReturnType; let openInBrowserSpy: ReturnType; @@ -114,20 +118,64 @@ describe("viewCommand.func", () => { } beforeEach(() => { + getProjectSpy = spyOn(apiClient, "getProject").mockResolvedValue({ + id: "42", + slug: "cli", + name: "CLI", + }); getReplaySpy = spyOn(apiClient, "getReplay"); + getReplayRecordingSegmentsSpy = spyOn( + apiClient, + "getReplayRecordingSegments" + ).mockResolvedValue([ + [ + { + timestamp: 1_735_500_000_000, + data: { href: "/checkout" }, + }, + ], + ]); + getTraceMetaSpy = spyOn(apiClient, "getTraceMeta").mockResolvedValue({ + errors: 2, + logs: 4, + performance_issues: 1, + span_count: 8, + span_count_map: {}, + transaction_child_count_map: [], + }); + listIssuesPaginatedSpy = spyOn( + apiClient, + "listIssuesPaginated" + ).mockResolvedValue({ + data: [ + { + id: "100", + shortId: "CLI-123", + title: "Checkout error", + }, + ], + }); resolveTargetSpy = spyOn(resolveTarget, "resolveOrgOptionalProjectFromArg"); openInBrowserSpy = spyOn(browser, "openInBrowser").mockResolvedValue(); }); afterEach(() => { + getProjectSpy.mockRestore(); getReplaySpy.mockRestore(); + getReplayRecordingSegmentsSpy.mockRestore(); + getTraceMetaSpy.mockRestore(); + listIssuesPaginatedSpy.mockRestore(); resolveTargetSpy.mockRestore(); openInBrowserSpy.mockRestore(); }); test("renders JSON output", async () => { resolveTargetSpy.mockResolvedValue({ org: "test-org", project: "cli" }); - getReplaySpy.mockResolvedValue(sampleReplay()); + getReplaySpy.mockResolvedValue( + sampleReplay({ + error_ids: ["bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + }) + ); const { context, stdoutWrite } = createMockContext(); const func = await viewCommand.loader(); @@ -140,6 +188,10 @@ describe("viewCommand.func", () => { const output = stdoutWrite.mock.calls.map((call) => call[0]).join(""); const parsed = JSON.parse(output); expect(parsed.id).toBe(REPLAY_ID); + expect(parsed.org).toBe("test-org"); + expect(parsed.activity[0]?.label).toBe("page.view"); + expect(parsed.relatedIssues[0]?.shortId).toBe("CLI-123"); + expect(parsed.relatedTraces[0]?.spanCount).toBe(8); expect(parsed.trace_ids[0]).toBe("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); }); @@ -190,4 +242,41 @@ describe("viewCommand.func", () => { func.call(context, { json: false, web: false, fresh: false }, REPLAY_ID) ).rejects.toThrow(ResolutionError); }); + + test("rejects replays outside the explicit project scope", async () => { + resolveTargetSpy.mockResolvedValue({ org: "test-org", project: "cli" }); + getReplaySpy.mockResolvedValue(sampleReplay({ project_id: "999" })); + + const { context } = createMockContext(); + const func = await viewCommand.loader(); + + await expect( + func.call(context, { json: false, web: false, fresh: false }, REPLAY_ID) + ).rejects.toThrow(ResolutionError); + }); + + test("renders activity and related sections in human output", async () => { + resolveTargetSpy.mockResolvedValue({ org: "test-org", project: "cli" }); + getReplaySpy.mockResolvedValue( + sampleReplay({ + error_ids: ["bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + urls: ["/checkout"], + }) + ); + + const { context, stdoutWrite } = createMockContext(); + const func = await viewCommand.loader(); + await func.call( + context, + { json: false, web: false, fresh: false }, + REPLAY_ID + ); + + const output = stdoutWrite.mock.calls.map((call) => call[0]).join(""); + expect(output).toContain("Activity"); + expect(output).toContain("page.view"); + expect(output).toContain("Related"); + expect(output).toContain("CLI-123"); + expect(output).toContain("sentry trace view test-org/"); + }); }); diff --git a/test/lib/api/replays.test.ts b/test/lib/api/replays.test.ts index f8f498e13..227ef7257 100644 --- a/test/lib/api/replays.test.ts +++ b/test/lib/api/replays.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { getReplay, + getReplayRecordingSegments, listReplayIdsForIssue, listReplays, } from "../../../src/lib/api/replays.js"; @@ -77,6 +78,34 @@ describe("listReplays", () => { expect(result.nextCursor).toBe("0:25:0"); }); + test("passes replay environment filters and custom field selection", async () => { + let capturedUrl = ""; + + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + capturedUrl = req.url; + return new Response(JSON.stringify({ data: [replayRow()] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + await listReplays("test-org", { + environment: ["production", "canary"], + fields: ["id", "user", "urls"], + limit: 10, + sort: "-count_rage_clicks", + }); + + const url = new URL(capturedUrl); + expect(url.searchParams.getAll("environment")).toEqual([ + "production", + "canary", + ]); + expect(url.searchParams.getAll("field")).toEqual(["id", "user", "urls"]); + expect(url.searchParams.get("sort")).toBe("-count_rage_clicks"); + }); + test("auto-paginates when limit exceeds the API cap", async () => { const capturedUrls: string[] = []; let callIndex = 0; @@ -193,6 +222,46 @@ describe("getReplay", () => { }); }); +describe("getReplayRecordingSegments", () => { + let originalFetch: typeof globalThis.fetch; + + beforeEach(async () => { + originalFetch = globalThis.fetch; + await setAuthToken("test-token"); + setOrgRegion("test-org", DEFAULT_SENTRY_URL); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + test("calls the project replay recording-segments endpoint", async () => { + let capturedUrl = ""; + + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + capturedUrl = req.url; + return new Response(JSON.stringify([[{ timestamp: 1 }]]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + const segments = await getReplayRecordingSegments( + "test-org", + "42", + REPLAY_ID + ); + + const url = new URL(capturedUrl); + expect(url.pathname).toContain( + `/api/0/projects/test-org/42/replays/${REPLAY_ID}/recording-segments/` + ); + expect(url.searchParams.get("download")).toBe("true"); + expect(segments).toEqual([[{ timestamp: 1 }]]); + }); +}); + describe("listReplayIdsForIssue", () => { let originalFetch: typeof globalThis.fetch; From 0a182b272ff1583c14efc4a4ee4b5ce1d4363727 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Fri, 1 May 2026 14:42:37 -0700 Subject: [PATCH 06/26] fix(replay): Normalize replay field fallbacks Make replay field projection request the right backend fields and harden archived replay normalization for nullable releases payloads. Clean up the replay list table headers while extending replay-explore coverage so these regressions stay caught locally. Co-Authored-By: OpenAI Codex --- src/commands/replay/list.ts | 4 ++-- src/lib/replay-search.ts | 2 ++ src/types/replay.ts | 6 +++++- test/commands/explore.test.ts | 22 ++++++++++++++++++++++ test/lib/api/replays.test.ts | 2 ++ 5 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/commands/replay/list.ts b/src/commands/replay/list.ts index f3753fa98..0e23b2a74 100644 --- a/src/commands/replay/list.ts +++ b/src/commands/replay/list.ts @@ -177,13 +177,13 @@ const REPLAY_COLUMNS: Column[] = [ minWidth: 10, }, { - header: "ERRORS:", + header: "ERRORS", value: (replay) => formatCount(replay.count_errors), align: "right", minWidth: 6, }, { - header: "SEGMENTS:", + header: "SEGMENTS", value: (replay) => formatCount(replay.count_segments), align: "right", minWidth: 8, diff --git a/src/lib/replay-search.ts b/src/lib/replay-search.ts index 18e085e7d..feed0cebf 100644 --- a/src/lib/replay-search.ts +++ b/src/lib/replay-search.ts @@ -138,6 +138,8 @@ function replayRequestRoot(field: string): string { case "sdk.name": case "sdk.version": return "sdk"; + case "count_traces": + return "trace_ids"; case "user.email": case "user.geo.city": case "user.geo.country_code": diff --git a/src/types/replay.ts b/src/types/replay.ts index 4cc45d441..99e617405 100644 --- a/src/types/replay.ts +++ b/src/types/replay.ts @@ -165,6 +165,7 @@ function buildReplayListItemShape< TInfoIds extends z.ZodTypeAny, TOtaUpdates extends z.ZodTypeAny, TProjectId extends z.ZodTypeAny, + TReleases extends z.ZodTypeAny, TTags extends z.ZodTypeAny, TTraceIds extends z.ZodTypeAny, TUrls extends z.ZodTypeAny, @@ -174,6 +175,7 @@ function buildReplayListItemShape< infoIds: TInfoIds; otaUpdates: TOtaUpdates; projectId: TProjectId; + releases: TReleases; tags: TTags; traceIds: TTraceIds; urls: TUrls; @@ -205,7 +207,7 @@ function buildReplayListItemShape< ota_updates: fields.otaUpdates.describe("OTA update metadata"), platform: replayNullableString("Platform"), project_id: fields.projectId.describe("Numeric project ID"), - releases: replayStringArray().optional().describe("Associated releases"), + releases: fields.releases.describe("Associated releases"), sdk: replayNullishObject(ReplaySdkSchema, "SDK metadata"), started_at: replayNullableString("Replay start timestamp"), tags: fields.tags.describe("Replay tags"), @@ -231,6 +233,7 @@ export const ReplayListItemSchema = z "OTA update metadata" ), projectId: z.union([z.string(), z.number()]).nullable().optional(), + releases: replayStringArrayWithFallback(), tags: ReplayTagsSchema, traceIds: replayStringArrayWithFallback(), urls: replayStringArrayWithFallback(), @@ -290,6 +293,7 @@ export const ReplayListItemOutputSchema = z infoIds: replayStringArray(), otaUpdates: ReplayOtaUpdatesSchema.nullish(), projectId: z.string().nullable().optional(), + releases: replayStringArray(), tags: z.record(z.array(z.string())), traceIds: replayStringArray(), urls: replayStringArray(), diff --git a/test/commands/explore.test.ts b/test/commands/explore.test.ts index 6f63fa753..6d295b91e 100644 --- a/test/commands/explore.test.ts +++ b/test/commands/explore.test.ts @@ -352,6 +352,28 @@ describe("sentry explore", () => { }); expect(queryEventsSpy).not.toHaveBeenCalled(); }); + + test("requests trace_ids when replay fields derive count_traces", async () => { + resolveTargetSpy.mockResolvedValue({ org: "test-org" }); + const { context } = createContext(); + + await func.call( + context, + { + ...DEFAULT_FLAGS, + dataset: "replays", + field: ["id", "count_traces"], + }, + "test-org/" + ); + + expect(listReplaysSpy).toHaveBeenCalledWith( + "test-org", + expect.objectContaining({ + fields: ["id", "trace_ids"], + }) + ); + }); }); describe("sort handling", () => { diff --git a/test/lib/api/replays.test.ts b/test/lib/api/replays.test.ts index 227ef7257..b7f98339f 100644 --- a/test/lib/api/replays.test.ts +++ b/test/lib/api/replays.test.ts @@ -197,6 +197,7 @@ describe("getReplay", () => { error_ids: undefined, info_ids: undefined, project_id: 42, + releases: null, tags: [], trace_ids: undefined, urls: null, @@ -214,6 +215,7 @@ describe("getReplay", () => { expect(replay.project_id).toBe("42"); expect(replay.tags).toEqual({}); + expect(replay.releases).toEqual([]); expect(replay.urls).toEqual([]); expect(replay.error_ids).toEqual([]); expect(replay.info_ids).toEqual([]); From 663d594f9ce5aff6d8060723a427aed039d56d93 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Fri, 1 May 2026 14:51:12 -0700 Subject: [PATCH 07/26] ref(replay): Share replay environment parsing Extract the replay environment filter parsing into a shared replay helper so replay list and replay explore stay in sync without duplicated flag logic. Keep the cleanup replay-scoped by reusing the existing replay utility module instead of adding a broader command helper layer. Co-Authored-By: OpenAI Codex --- src/commands/explore.ts | 16 ++-------------- src/commands/replay/list.ts | 20 +++++--------------- src/lib/replay-search.ts | 14 ++++++++++++++ 3 files changed, 21 insertions(+), 29 deletions(-) diff --git a/src/commands/explore.ts b/src/commands/explore.ts index fd9daa39d..1c6ddeba3 100644 --- a/src/commands/explore.ts +++ b/src/commands/explore.ts @@ -41,6 +41,7 @@ import { getReplayRequestFields, isSupportedReplayField, listSupportedReplayFields, + parseReplayEnvironmentFilter, } from "../lib/replay-search.js"; import { resolveOrgOptionalProjectFromArg } from "../lib/resolve-target.js"; import { sanitizeQuery } from "../lib/search-query.js"; @@ -181,19 +182,6 @@ function parseLimit(value: string): number { return validateLimit(value, 1, LIST_MAX_LIMIT); } -function parseEnvironmentFilter( - values: readonly string[] | undefined -): string[] | undefined { - const parsed = values - ? [...values] - .flatMap((value) => value.split(",")) - .map((value) => value.trim()) - .filter(Boolean) - : []; - - return parsed.length > 0 ? parsed : undefined; -} - function inferReplayFieldType(field: string): string { if (field === "duration") { return "duration"; @@ -535,7 +523,7 @@ export const exploreCommand = buildListCommand("explore", { fieldList = flags.field; } const timeRange = flags.period; - const environment = parseEnvironmentFilter(flags.environment); + const environment = parseReplayEnvironmentFilter(flags.environment); const replaySort = dataset === "replays" ? resolveReplaySort(flags.sort) : undefined; const effectiveSort = diff --git a/src/commands/replay/list.ts b/src/commands/replay/list.ts index 0e23b2a74..c716c35d9 100644 --- a/src/commands/replay/list.ts +++ b/src/commands/replay/list.ts @@ -38,7 +38,10 @@ import { targetPatternExplanation, } from "../../lib/list-command.js"; import { withProgress } from "../../lib/polling.js"; -import { getReplayUserLabel } from "../../lib/replay-search.js"; +import { + getReplayUserLabel, + parseReplayEnvironmentFilter, +} from "../../lib/replay-search.js"; import { resolveOrgOptionalProjectFromArg } from "../../lib/resolve-target.js"; import { sanitizeQuery } from "../../lib/search-query.js"; import { @@ -92,19 +95,6 @@ function parseLimit(value: string): number { return validateLimit(value, LIST_MIN_LIMIT, LIST_MAX_LIMIT); } -function parseEnvironmentFilter( - values: readonly string[] | undefined -): string[] | undefined { - const parsed = values - ? [...values] - .flatMap((value) => value.split(",")) - .map((value) => value.trim()) - .filter(Boolean) - : []; - - return parsed.length > 0 ? parsed : undefined; -} - /** * Parse user-facing replay sort values into API sort expressions. */ @@ -358,7 +348,7 @@ export const listCommand = buildListCommand("replay", { async *func(this: SentryContext, flags: ListFlags, target?: string) { const { cwd } = this; const timeRange = flags.period; - const environment = parseEnvironmentFilter(flags.environment); + const environment = parseReplayEnvironmentFilter(flags.environment); const { query } = flags; const resolved = await resolveOrgOptionalProjectFromArg( diff --git a/src/lib/replay-search.ts b/src/lib/replay-search.ts index feed0cebf..ef0b4216e 100644 --- a/src/lib/replay-search.ts +++ b/src/lib/replay-search.ts @@ -31,6 +31,20 @@ export const DEFAULT_REPLAY_EXPLORE_FIELDS = [ "user.email", ] as const; +/** Parse repeatable and comma-separated replay environment filters. */ +export function parseReplayEnvironmentFilter( + values: readonly string[] | undefined +): string[] | undefined { + const parsed = values + ? [...values] + .flatMap((value) => value.split(",")) + .map((value) => value.trim()) + .filter(Boolean) + : []; + + return parsed.length > 0 ? parsed : undefined; +} + function firstValue(values: T[] | undefined): T | undefined { return values && values.length > 0 ? values[0] : undefined; } From 2b1f63bda3b2379e13029b940b1f11a832b0f33d Mon Sep 17 00:00:00 2001 From: David Cramer Date: Fri, 1 May 2026 15:02:13 -0700 Subject: [PATCH 08/26] fix(replay): Address replay review feedback Handle archived replay views that no longer carry project IDs so explicit project scope does not reject valid archived replays. Extract the shared replay duration bucketing into a replay utility to keep list and view formatting aligned. Also preserve undefined event hints when replay-related hint parts are absent so replay-adjacent event output does not emit an empty hint line. Co-Authored-By: OpenAI Codex --- src/commands/event/view.ts | 17 ++++---- src/commands/replay/list.ts | 29 +------------ src/commands/replay/view.ts | 56 ++++++++----------------- src/lib/replay-duration.ts | 70 +++++++++++++++++++++++++++++++ test/commands/event/view.test.ts | 21 ++++++++++ test/commands/replay/view.test.ts | 24 +++++++++++ test/lib/replay-duration.test.ts | 38 +++++++++++++++++ 7 files changed, 182 insertions(+), 73 deletions(-) create mode 100644 src/lib/replay-duration.ts create mode 100644 test/lib/replay-duration.test.ts diff --git a/src/commands/event/view.ts b/src/commands/event/view.ts index ac56672c1..1be667306 100644 --- a/src/commands/event/view.ts +++ b/src/commands/event/view.ts @@ -159,6 +159,11 @@ function replayHint(org: string, event: SentryEvent): string | undefined { ? `Related replay: sentry replay view ${org}/${replayId}` : undefined; } + +function joinHintParts(parts: Array): string | undefined { + const hints = parts.filter((part): part is string => Boolean(part)); + return hints.length > 0 ? hints.join(" | ") : undefined; +} /** Usage hint for ContextError messages */ const USAGE_HINT = "sentry event view / "; @@ -999,12 +1004,10 @@ export const viewCommand = buildCommand({ requestedCount: 1, }); return { - hint: [ + hint: joinHintParts([ issueShortcut.hint, replayHint(issueShortcut.org, issueShortcut.data.event), - ] - .filter(Boolean) - .join(" | "), + ]), }; } @@ -1061,14 +1064,12 @@ export const viewCommand = buildCommand({ requestedCount: allEventIds.length, }); return { - hint: [ + hint: joinHintParts([ target.detectedFrom ? `Detected from ${target.detectedFrom}` : undefined, replayHint(target.org, event), - ] - .filter(Boolean) - .join(" | "), + ]), }; }, }); diff --git a/src/commands/replay/list.ts b/src/commands/replay/list.ts index c716c35d9..90250427a 100644 --- a/src/commands/replay/list.ts +++ b/src/commands/replay/list.ts @@ -38,6 +38,7 @@ import { targetPatternExplanation, } from "../../lib/list-command.js"; import { withProgress } from "../../lib/polling.js"; +import { formatReplayDurationCompact } from "../../lib/replay-duration.js"; import { getReplayUserLabel, parseReplayEnvironmentFilter, @@ -119,32 +120,6 @@ function formatCount(value: number | null | undefined): string { return value === null || value === undefined ? "0" : String(value); } -function formatReplayDuration(seconds: number | null | undefined): string { - if (seconds === null || seconds === undefined) { - return "—"; - } - const rounded = Math.max(0, Math.round(seconds)); - if (rounded < 60) { - return `${rounded}s`; - } - if (rounded < 3600) { - const minutes = Math.floor(rounded / 60); - const remaining = rounded % 60; - return remaining > 0 ? `${minutes}m ${remaining}s` : `${minutes}m`; - } - if (rounded < 86_400) { - const hours = Math.floor(rounded / 3600); - const remainingMinutes = Math.floor((rounded % 3600) / 60); - return remainingMinutes > 0 - ? `${hours}h ${remainingMinutes}m` - : `${hours}h`; - } - - const days = Math.floor(rounded / 86_400); - const remainingHours = Math.floor((rounded % 86_400) / 3600); - return remainingHours > 0 ? `${days}d ${remainingHours}h` : `${days}d`; -} - function replayUserLabel(replay: ReplayListItem): string { return getReplayUserLabel(replay) ?? "—"; } @@ -163,7 +138,7 @@ const REPLAY_COLUMNS: Column[] = [ }, { header: "DURATION", - value: (replay) => formatReplayDuration(replay.duration), + value: (replay) => formatReplayDurationCompact(replay.duration), minWidth: 10, }, { diff --git a/src/commands/replay/view.ts b/src/commands/replay/view.ts index a7a105b6f..3415cee0a 100644 --- a/src/commands/replay/view.ts +++ b/src/commands/replay/view.ts @@ -33,6 +33,7 @@ import { FRESH_ALIASES, FRESH_FLAG, } from "../../lib/list-command.js"; +import { formatReplayDurationVerbose } from "../../lib/replay-duration.js"; import { normalizeReplayId } from "../../lib/replay-id.js"; import { getReplayUserLabel } from "../../lib/replay-search.js"; import { resolveOrgOptionalProjectFromArg } from "../../lib/resolve-target.js"; @@ -78,38 +79,6 @@ const USAGE_HINT = const MAX_ACTIVITY_EVENTS = 6; const MAX_RELATED_ERRORS = 3; const MAX_RELATED_TRACES = 2; -function pluralize(value: number, singular: string): string { - return `${value} ${singular}${value === 1 ? "" : "s"}`; -} - -function formatReplayDuration(seconds: number): string { - const rounded = Math.max(0, Math.round(seconds)); - if (rounded < 60) { - return pluralize(rounded, "second"); - } - - const minutes = Math.floor(rounded / 60); - const remainingSeconds = rounded % 60; - if (minutes < 60) { - return remainingSeconds > 0 - ? `${pluralize(minutes, "minute")} and ${pluralize(remainingSeconds, "second")}` - : pluralize(minutes, "minute"); - } - - const hours = Math.floor(minutes / 60); - const remainingMinutes = minutes % 60; - if (hours < 24) { - return remainingMinutes > 0 - ? `${pluralize(hours, "hour")} and ${pluralize(remainingMinutes, "minute")}` - : pluralize(hours, "hour"); - } - - const days = Math.floor(hours / 24); - const remainingHours = hours % 24; - return remainingHours > 0 - ? `${pluralize(days, "day")} and ${pluralize(remainingHours, "hour")}` - : pluralize(days, "day"); -} function parseSingleArg(arg: string): ParsedPositionalArgs { const trimmed = arg.trim(); @@ -377,12 +346,23 @@ async function validateReplayProjectScope( return; } + if (replay.project_id === null || replay.project_id === undefined) { + if (replay.is_archived) { + return; + } + + throw new ResolutionError( + `Replay '${replayId}'`, + "has no project association", + `sentry replay view ${org}/${project}/${replayId}`, + [ + `Open the org-scoped replay instead: sentry replay view ${org}/${replayId}`, + ] + ); + } + const projectId = expectedProjectId ?? (await getProject(org, project)).id; - if ( - replay.project_id === null || - replay.project_id === undefined || - String(projectId) !== String(replay.project_id) - ) { + if (String(projectId) !== String(replay.project_id)) { throw new ResolutionError( `Replay '${replayId}'`, `is not in project '${project}'`, @@ -566,7 +546,7 @@ function buildReplayOverviewRows( rows, "Duration", replay.duration !== null && replay.duration !== undefined - ? formatReplayDuration(replay.duration) + ? formatReplayDurationVerbose(replay.duration) : undefined ); pushMarkdownRow( diff --git a/src/lib/replay-duration.ts b/src/lib/replay-duration.ts new file mode 100644 index 000000000..16ccfa317 --- /dev/null +++ b/src/lib/replay-duration.ts @@ -0,0 +1,70 @@ +function splitReplayDuration(totalSeconds: number): { + days: number; + hours: number; + minutes: number; + seconds: number; +} { + const rounded = Math.max(0, Math.round(totalSeconds)); + return { + days: Math.floor(rounded / 86_400), + hours: Math.floor((rounded % 86_400) / 3600), + minutes: Math.floor((rounded % 3600) / 60), + seconds: rounded % 60, + }; +} + +function pluralize(value: number, singular: string): string { + return `${value} ${singular}${value === 1 ? "" : "s"}`; +} + +/** + * Format a replay duration for compact table output. + */ +export function formatReplayDurationCompact( + seconds: number | null | undefined +): string { + if (seconds === null || seconds === undefined) { + return "—"; + } + + const parts = splitReplayDuration(seconds); + if (parts.days > 0) { + return parts.hours > 0 + ? `${parts.days}d ${parts.hours}h` + : `${parts.days}d`; + } + if (parts.hours > 0) { + return parts.minutes > 0 + ? `${parts.hours}h ${parts.minutes}m` + : `${parts.hours}h`; + } + if (parts.minutes > 0) { + return parts.seconds > 0 + ? `${parts.minutes}m ${parts.seconds}s` + : `${parts.minutes}m`; + } + return `${parts.seconds}s`; +} + +/** + * Format a replay duration for verbose detail output. + */ +export function formatReplayDurationVerbose(seconds: number): string { + const parts = splitReplayDuration(seconds); + if (parts.days > 0) { + return parts.hours > 0 + ? `${pluralize(parts.days, "day")} and ${pluralize(parts.hours, "hour")}` + : pluralize(parts.days, "day"); + } + if (parts.hours > 0) { + return parts.minutes > 0 + ? `${pluralize(parts.hours, "hour")} and ${pluralize(parts.minutes, "minute")}` + : pluralize(parts.hours, "hour"); + } + if (parts.minutes > 0) { + return parts.seconds > 0 + ? `${pluralize(parts.minutes, "minute")} and ${pluralize(parts.seconds, "second")}` + : pluralize(parts.minutes, "minute"); + } + return pluralize(parts.seconds, "second"); +} diff --git a/test/commands/event/view.test.ts b/test/commands/event/view.test.ts index 3b4bf9148..dcee090cf 100644 --- a/test/commands/event/view.test.ts +++ b/test/commands/event/view.test.ts @@ -928,6 +928,27 @@ describe("viewCommand.func", () => { expect(getEventSpy).toHaveBeenCalled(); }); + test("returns undefined hint when there is no detection or replay hint", async () => { + getEventSpy.mockResolvedValue(sampleEvent); + getSpanTreeLinesSpy.mockResolvedValue({ + lines: [], + spans: null, + traceId: null, + success: false, + }); + + const { context } = createMockContext(); + const func = await viewCommand.loader(); + const result = await func.call( + context, + { json: true, web: false, spans: 0 }, + "test-org/test-proj", + VALID_EVENT_ID + ); + + expect(result?.hint).toBeUndefined(); + }); + test("auto-redirects issue short ID in two-arg form via issueShortId path", async () => { // "CAM-82X" as first arg matches looksLikeIssueShortId → sets issueShortId, // NOT targetArg. The resolveIssueShortcut path fetches the latest event. diff --git a/test/commands/replay/view.test.ts b/test/commands/replay/view.test.ts index 2ba1a51e7..9c1631e41 100644 --- a/test/commands/replay/view.test.ts +++ b/test/commands/replay/view.test.ts @@ -255,6 +255,30 @@ describe("viewCommand.func", () => { ).rejects.toThrow(ResolutionError); }); + test("allows archived replays with no project ID in explicit project scope", async () => { + resolveTargetSpy.mockResolvedValue({ org: "test-org", project: "cli" }); + getReplaySpy.mockResolvedValue( + sampleReplay({ + count_segments: 0, + is_archived: true, + project_id: null, + }) + ); + + const { context, stdoutWrite } = createMockContext(); + const func = await viewCommand.loader(); + await func.call( + context, + { json: true, web: false, fresh: false }, + REPLAY_ID + ); + + expect(getProjectSpy).not.toHaveBeenCalled(); + const output = stdoutWrite.mock.calls.map((call) => call[0]).join(""); + const parsed = JSON.parse(output); + expect(parsed.is_archived).toBe(true); + }); + test("renders activity and related sections in human output", async () => { resolveTargetSpy.mockResolvedValue({ org: "test-org", project: "cli" }); getReplaySpy.mockResolvedValue( diff --git a/test/lib/replay-duration.test.ts b/test/lib/replay-duration.test.ts new file mode 100644 index 000000000..a29b78587 --- /dev/null +++ b/test/lib/replay-duration.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from "bun:test"; + +import { + formatReplayDurationCompact, + formatReplayDurationVerbose, +} from "../../src/lib/replay-duration.js"; + +describe("formatReplayDurationCompact", () => { + test("formats short durations", () => { + expect(formatReplayDurationCompact(59)).toBe("59s"); + expect(formatReplayDurationCompact(60)).toBe("1m"); + expect(formatReplayDurationCompact(125)).toBe("2m 5s"); + }); + + test("formats long durations", () => { + expect(formatReplayDurationCompact(3600)).toBe("1h"); + expect(formatReplayDurationCompact(3665)).toBe("1h 1m"); + expect(formatReplayDurationCompact(90_061)).toBe("1d 1h"); + }); + + test("handles missing durations", () => { + expect(formatReplayDurationCompact(null)).toBe("—"); + expect(formatReplayDurationCompact(undefined)).toBe("—"); + }); +}); + +describe("formatReplayDurationVerbose", () => { + test("formats short durations", () => { + expect(formatReplayDurationVerbose(1)).toBe("1 second"); + expect(formatReplayDurationVerbose(125)).toBe("2 minutes and 5 seconds"); + }); + + test("formats long durations", () => { + expect(formatReplayDurationVerbose(3600)).toBe("1 hour"); + expect(formatReplayDurationVerbose(3665)).toBe("1 hour and 1 minute"); + expect(formatReplayDurationVerbose(90_061)).toBe("1 day and 1 hour"); + }); +}); From 1f7cb8b798d428c7c37dec1959cdb3469fb7e038 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Fri, 1 May 2026 15:12:23 -0700 Subject: [PATCH 09/26] fix(replay): Tighten replay follow-up behavior Compare replay explore fields against the replay default field set when building pagination hints so explicit default replay fields do not produce redundant -F flags. Also resolve replay-related issues with an event.id search query instead of a bare event ID so related issue pivots use a structured issue search. Co-Authored-By: OpenAI Codex --- src/commands/explore.ts | 14 +++++++++----- src/commands/replay/view.ts | 2 +- test/commands/explore.test.ts | 27 +++++++++++++++++++++++++++ test/commands/replay/view.test.ts | 8 ++++++++ 4 files changed, 45 insertions(+), 6 deletions(-) diff --git a/src/commands/explore.ts b/src/commands/explore.ts index 1c6ddeba3..d41a008a6 100644 --- a/src/commands/explore.ts +++ b/src/commands/explore.ts @@ -306,6 +306,10 @@ function jsonTransformExplore(data: ExploreData, fields?: string[]): unknown { /** Default limit value matching the flag default for hint comparison */ const DEFAULT_LIMIT = 25; +function defaultFieldsForDataset(dataset: string): readonly string[] { + return dataset === "replays" ? DEFAULT_REPLAY_EXPLORE_FIELDS : DEFAULT_FIELDS; +} + /** Append active non-default flags to a base command string */ function appendFlagHints( base: string, @@ -326,7 +330,10 @@ function appendFlagHints( // Include --field flags when non-default const fieldList = flags.field ?? []; const currentFieldStr = fieldList.join(","); - if (currentFieldStr !== DEFAULT_FIELDS.join(",") && fieldList.length > 0) { + if ( + currentFieldStr !== defaultFieldsForDataset(flags.dataset).join(",") && + fieldList.length > 0 + ) { for (const f of fieldList) { parts.push(`-F "${f}"`); } @@ -515,10 +522,7 @@ export const exploreCommand = buildListCommand("explore", { ); const dataset = flags.dataset; - let fieldList = DEFAULT_FIELDS; - if (dataset === "replays") { - fieldList = [...DEFAULT_REPLAY_EXPLORE_FIELDS]; - } + let fieldList = [...defaultFieldsForDataset(dataset)]; if (flags.field && flags.field.length > 0) { fieldList = flags.field; } diff --git a/src/commands/replay/view.ts b/src/commands/replay/view.ts index 3415cee0a..77c8fd627 100644 --- a/src/commands/replay/view.ts +++ b/src/commands/replay/view.ts @@ -408,7 +408,7 @@ function fetchRelatedReplayIssues( eventIds.map(async (eventId) => { try { const page = await listIssuesPaginated(org, "", { - query: eventId, + query: `event.id:${eventId}`, perPage: 1, }); const issue = page.data[0]; diff --git a/test/commands/explore.test.ts b/test/commands/explore.test.ts index 6d295b91e..4b9c3ad10 100644 --- a/test/commands/explore.test.ts +++ b/test/commands/explore.test.ts @@ -20,6 +20,7 @@ import * as apiClient from "../../src/lib/api-client.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as paginationDb from "../../src/lib/db/pagination.js"; import { ContextError, ValidationError } from "../../src/lib/errors.js"; +import { DEFAULT_REPLAY_EXPLORE_FIELDS } from "../../src/lib/replay-search.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as resolveTarget from "../../src/lib/resolve-target.js"; import { parsePeriod } from "../../src/lib/time-range.js"; @@ -590,6 +591,32 @@ describe("sentry explore", () => { "cursor123" ); }); + + test("omits replay default fields from pagination hints", async () => { + resolveTargetSpy.mockResolvedValue({ org: "test-org" }); + listReplaysSpy.mockResolvedValue({ + data: MOCK_REPLAYS_RESPONSE, + nextCursor: "cursor123", + }); + const { context, getStdout } = createContext(); + + await func.call( + context, + { + ...DEFAULT_FLAGS, + dataset: "replays", + field: [...DEFAULT_REPLAY_EXPLORE_FIELDS], + }, + "test-org/" + ); + + const output = getStdout(); + expect(output).toContain( + "sentry explore test-org/ -c next --dataset replays" + ); + expect(output).not.toContain('-F "id"'); + expect(output).not.toContain('-F "started_at"'); + }); }); describe("validation", () => { diff --git a/test/commands/replay/view.test.ts b/test/commands/replay/view.test.ts index 9c1631e41..246119f4f 100644 --- a/test/commands/replay/view.test.ts +++ b/test/commands/replay/view.test.ts @@ -193,6 +193,14 @@ describe("viewCommand.func", () => { expect(parsed.relatedIssues[0]?.shortId).toBe("CLI-123"); expect(parsed.relatedTraces[0]?.spanCount).toBe(8); expect(parsed.trace_ids[0]).toBe("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + expect(listIssuesPaginatedSpy).toHaveBeenCalledWith( + "test-org", + "", + expect.objectContaining({ + perPage: 1, + query: "event.id:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + }) + ); }); test("opens the replay in the browser with --web", async () => { From 68bd9ed875f974115bf709a54054d87a84237012 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Fri, 1 May 2026 15:25:15 -0700 Subject: [PATCH 10/26] fix(replay): Clean up replay review feedback Tighten small replay follow-up details from the latest Bugbot pass by separating the replay hint helper from the usage JSDoc, sorting replay tags explicitly by key, and dropping a redundant replay count_screens type check in explore. Co-Authored-By: OpenAI Codex --- src/commands/event/view.ts | 1 + src/commands/explore.ts | 6 +----- src/commands/replay/view.ts | 4 +++- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/commands/event/view.ts b/src/commands/event/view.ts index 1be667306..b5a290d20 100644 --- a/src/commands/event/view.ts +++ b/src/commands/event/view.ts @@ -164,6 +164,7 @@ function joinHintParts(parts: Array): string | undefined { const hints = parts.filter((part): part is string => Boolean(part)); return hints.length > 0 ? hints.join(" | ") : undefined; } + /** Usage hint for ContextError messages */ const USAGE_HINT = "sentry event view / "; diff --git a/src/commands/explore.ts b/src/commands/explore.ts index d41a008a6..d82cf55fe 100644 --- a/src/commands/explore.ts +++ b/src/commands/explore.ts @@ -186,11 +186,7 @@ function inferReplayFieldType(field: string): string { if (field === "duration") { return "duration"; } - if ( - field === "activity" || - field === "count_screens" || - field.startsWith("count_") - ) { + if (field === "activity" || field.startsWith("count_")) { return "integer"; } return "string"; diff --git a/src/commands/replay/view.ts b/src/commands/replay/view.ts index 77c8fd627..edce077cb 100644 --- a/src/commands/replay/view.ts +++ b/src/commands/replay/view.ts @@ -681,7 +681,9 @@ function pushTagsSection(lines: string[], replay: ReplayDetails): void { lines.push(""); lines.push("### Tags"); lines.push(""); - for (const [key, values] of Object.entries(replay.tags).sort()) { + for (const [key, values] of Object.entries(replay.tags).sort(([a], [b]) => + a.localeCompare(b) + )) { lines.push( `- \`${escapeMarkdownInline(key)}\`: ${values.map((value) => `\`${escapeMarkdownInline(value)}\``).join(", ")}` ); From eb5bf8aef910ecf4cadf715165c212fb65ccd58a Mon Sep 17 00:00:00 2001 From: David Cramer Date: Fri, 1 May 2026 15:29:42 -0700 Subject: [PATCH 11/26] fix(replay): Correct replay field and activity timing Normalize replay request fields through an explicit helper so replay explore keeps requesting canonical API fields for aliases like url. Anchor replay activity offsets to replay.started_at so the rendered timeline matches the actual session start instead of the first extracted event. Co-Authored-By: OpenAI Codex --- src/commands/replay/view.ts | 5 ++++- src/lib/replay-search.ts | 9 +++++++-- test/commands/replay/view.test.ts | 28 ++++++++++++++++++++++++++++ test/lib/replay-search.test.ts | 12 ++++++++++++ 4 files changed, 51 insertions(+), 3 deletions(-) create mode 100644 test/lib/replay-search.test.ts diff --git a/src/commands/replay/view.ts b/src/commands/replay/view.ts index edce077cb..d01b151be 100644 --- a/src/commands/replay/view.ts +++ b/src/commands/replay/view.ts @@ -709,7 +709,10 @@ function pushActivitySection( return; } - const startTime = activity[0]?.timestampMs ?? null; + const startTime = + getEventTimestampMillis(replay.started_at) ?? + activity[0]?.timestampMs ?? + null; for (const event of activity) { const prefix = event.timestampMs !== null && startTime !== null diff --git a/src/lib/replay-search.ts b/src/lib/replay-search.ts index ef0b4216e..f270910fc 100644 --- a/src/lib/replay-search.ts +++ b/src/lib/replay-search.ts @@ -19,6 +19,12 @@ const REPLAY_FIELD_ALIASES = { warning_id: "warning_ids", } as const satisfies Record; +function normalizeReplayField(field: string): string { + return Object.hasOwn(REPLAY_FIELD_ALIASES, field) + ? REPLAY_FIELD_ALIASES[field as keyof typeof REPLAY_FIELD_ALIASES] + : field; +} + /** Default field set for replay rows shown in `sentry explore --dataset replays`. */ export const DEFAULT_REPLAY_EXPLORE_FIELDS = [ "id", @@ -133,8 +139,7 @@ const REPLAY_FIELD_RESOLVERS: Record = { }; function replayRequestRoot(field: string): string { - const normalized: string = - REPLAY_FIELD_ALIASES[field as keyof typeof REPLAY_FIELD_ALIASES] ?? field; + const normalized = normalizeReplayField(field); switch (normalized) { case "browser.name": diff --git a/test/commands/replay/view.test.ts b/test/commands/replay/view.test.ts index 246119f4f..5883bc754 100644 --- a/test/commands/replay/view.test.ts +++ b/test/commands/replay/view.test.ts @@ -311,4 +311,32 @@ describe("viewCommand.func", () => { expect(output).toContain("CLI-123"); expect(output).toContain("sentry trace view test-org/"); }); + + test("anchors activity offsets to the replay start time", async () => { + resolveTargetSpy.mockResolvedValue({ org: "test-org", project: "cli" }); + getReplaySpy.mockResolvedValue( + sampleReplay({ + started_at: "2025-01-01T00:00:00.000Z", + }) + ); + getReplayRecordingSegmentsSpy.mockResolvedValue([ + [ + { + timestamp: Date.parse("2025-01-01T00:00:05.000Z"), + data: { href: "/checkout" }, + }, + ], + ]); + + const { context, stdoutWrite } = createMockContext(); + const func = await viewCommand.loader(); + await func.call( + context, + { json: false, web: false, fresh: false }, + REPLAY_ID + ); + + const output = stdoutWrite.mock.calls.map((call) => call[0]).join(""); + expect(output).toContain("5s"); + }); }); diff --git a/test/lib/replay-search.test.ts b/test/lib/replay-search.test.ts new file mode 100644 index 000000000..220bd9d53 --- /dev/null +++ b/test/lib/replay-search.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, test } from "bun:test"; +import { getReplayRequestFields } from "../../src/lib/replay-search.js"; + +describe("getReplayRequestFields", () => { + test("normalizes replay field aliases for API requests", () => { + expect(getReplayRequestFields(["url", "trace_id"])).toEqual([ + "id", + "urls", + "trace_ids", + ]); + }); +}); From 90a6a1fef6c089a7394e0c69ead8bfa0a8b91b7c Mon Sep 17 00:00:00 2001 From: David Cramer Date: Fri, 1 May 2026 15:34:56 -0700 Subject: [PATCH 12/26] fix(replay): Tighten replay explore field handling Stop advertising replay detail-only fields through replay explore and separate replay sort handling from event query sorting. This keeps replay field validation aligned with the list API contract and makes replay pagination context use the same validated sort that listReplays receives. Co-Authored-By: OpenAI Codex --- src/commands/explore.ts | 11 +++++++---- src/lib/replay-search.ts | 1 - test/commands/explore.test.ts | 13 +++++++++++++ test/lib/replay-search.test.ts | 11 ++++++++++- 4 files changed, 30 insertions(+), 6 deletions(-) diff --git a/src/commands/explore.ts b/src/commands/explore.ts index d82cf55fe..4fbd3a2df 100644 --- a/src/commands/explore.ts +++ b/src/commands/explore.ts @@ -526,8 +526,11 @@ export const exploreCommand = buildListCommand("explore", { const environment = parseReplayEnvironmentFilter(flags.environment); const replaySort = dataset === "replays" ? resolveReplaySort(flags.sort) : undefined; - const effectiveSort = - replaySort ?? resolveExploreSort(fieldList, dataset, flags.sort); + const eventSort = + dataset === "replays" + ? undefined + : resolveExploreSort(fieldList, dataset, flags.sort); + const paginationSort = dataset === "replays" ? replaySort : eventSort; if (dataset !== "replays" && environment) { throw new ValidationError( @@ -564,7 +567,7 @@ export const exploreCommand = buildListCommand("explore", { env: environment?.join(","), fields: fieldList.join(","), q: flags.query, - sort: effectiveSort, + sort: paginationSort, period: serializeTimeRange(timeRange), } ); @@ -602,7 +605,7 @@ export const exploreCommand = buildListCommand("explore", { fields: fieldList, dataset, query: apiQuery, - sort: effectiveSort, + sort: eventSort, limit: flags.limit, cursor, ...timeRangeToApiParams(timeRange), diff --git a/src/lib/replay-search.ts b/src/lib/replay-search.ts index f270910fc..961a43253 100644 --- a/src/lib/replay-search.ts +++ b/src/lib/replay-search.ts @@ -110,7 +110,6 @@ const REPLAY_FIELD_RESOLVERS: Record = { project_id: (replay) => replay.project_id, release: (replay) => firstValue(replay.releases), releases: (replay) => replay.releases, - replay_type: (replay) => replay.replay_type, screen: (replay) => firstValue(replay.urls), screens: (replay) => replay.urls, sdk: (replay) => replay.sdk?.name, diff --git a/test/commands/explore.test.ts b/test/commands/explore.test.ts index 4b9c3ad10..567c016a5 100644 --- a/test/commands/explore.test.ts +++ b/test/commands/explore.test.ts @@ -632,5 +632,18 @@ describe("sentry explore", () => { ) ).rejects.toThrow(ValidationError); }); + + test("rejects replay detail-only fields on the replay dataset", async () => { + resolveTargetSpy.mockResolvedValue({ org: "test-org" }); + const { context } = createContext(); + + await expect( + func.call( + context, + { ...DEFAULT_FLAGS, dataset: "replays", field: ["replay_type"] }, + "test-org/" + ) + ).rejects.toThrow(ValidationError); + }); }); }); diff --git a/test/lib/replay-search.test.ts b/test/lib/replay-search.test.ts index 220bd9d53..b7c901cef 100644 --- a/test/lib/replay-search.test.ts +++ b/test/lib/replay-search.test.ts @@ -1,5 +1,8 @@ import { describe, expect, test } from "bun:test"; -import { getReplayRequestFields } from "../../src/lib/replay-search.js"; +import { + getReplayRequestFields, + isSupportedReplayField, +} from "../../src/lib/replay-search.js"; describe("getReplayRequestFields", () => { test("normalizes replay field aliases for API requests", () => { @@ -10,3 +13,9 @@ describe("getReplayRequestFields", () => { ]); }); }); + +describe("isSupportedReplayField", () => { + test("does not expose replay detail-only fields in replay explore", () => { + expect(isSupportedReplayField("replay_type")).toBe(false); + }); +}); From bcc5540b9933cac833a296a3746277a93502689a Mon Sep 17 00:00:00 2001 From: David Cramer Date: Fri, 1 May 2026 15:44:31 -0700 Subject: [PATCH 13/26] fix(replay): Separate replay aliases from convenience fields Keep replay convenience columns like url, screen, and release as first-class CLI fields instead of treating them as canonical aliases. This preserves the scalar explore output while making replay request-field normalization explicit and less confusing. Add focused coverage for convenience-field request roots and the default replay explore field set so the bugbot regression stays closed. Co-Authored-By: OpenAI Codex --- src/lib/replay-search.ts | 22 ++++++++++++++-------- test/commands/explore.test.ts | 27 +++++++++++++++++++++++++++ test/lib/replay-search.test.ts | 19 +++++++++++++++++++ 3 files changed, 60 insertions(+), 8 deletions(-) diff --git a/src/lib/replay-search.ts b/src/lib/replay-search.ts index 961a43253..bb89e7c80 100644 --- a/src/lib/replay-search.ts +++ b/src/lib/replay-search.ts @@ -5,18 +5,10 @@ type ReplayFieldResolver = (replay: ReplayLike) => unknown; const REPLAY_FIELD_ALIASES = { count_screens: "count_urls", - error_id: "error_ids", - info_id: "info_ids", - release: "releases", - screen: "urls", screens: "urls", seen_by_me: "has_viewed", - trace: "trace_ids", - trace_id: "trace_ids", - url: "urls", "user.ip_address": "user.ip", viewed_by_me: "has_viewed", - warning_id: "warning_ids", } as const satisfies Record; function normalizeReplayField(field: string): string { @@ -158,6 +150,18 @@ function replayRequestRoot(field: string): string { return "sdk"; case "count_traces": return "trace_ids"; + case "error_id": + return "error_ids"; + case "info_id": + return "info_ids"; + case "release": + return "releases"; + case "screen": + case "url": + return "urls"; + case "trace": + case "trace_id": + return "trace_ids"; case "user.email": case "user.geo.city": case "user.geo.country_code": @@ -167,6 +171,8 @@ function replayRequestRoot(field: string): string { case "user.ip": case "user.username": return "user"; + case "warning_id": + return "warning_ids"; default: return normalized; } diff --git a/test/commands/explore.test.ts b/test/commands/explore.test.ts index 567c016a5..f8e8bbfde 100644 --- a/test/commands/explore.test.ts +++ b/test/commands/explore.test.ts @@ -439,6 +439,33 @@ describe("sentry explore", () => { expect.objectContaining({ sort: "-started_at" }) ); }); + + test("requests canonical replay API fields for default replay columns", async () => { + resolveTargetSpy.mockResolvedValue({ org: "test-org" }); + const { context } = createContext(); + + await func.call( + context, + { ...DEFAULT_FLAGS, dataset: "replays" }, + "test-org/" + ); + + expect(listReplaysSpy).toHaveBeenCalledWith( + "test-org", + expect.objectContaining({ + fields: [ + "id", + "started_at", + "duration", + "count_errors", + "count_rage_clicks", + "count_dead_clicks", + "urls", + "user", + ], + }) + ); + }); }); describe("output", () => { diff --git a/test/lib/replay-search.test.ts b/test/lib/replay-search.test.ts index b7c901cef..94818471a 100644 --- a/test/lib/replay-search.test.ts +++ b/test/lib/replay-search.test.ts @@ -12,6 +12,25 @@ describe("getReplayRequestFields", () => { "trace_ids", ]); }); + + test("requests backing array fields for convenience replay columns", () => { + expect( + getReplayRequestFields([ + "error_id", + "info_id", + "release", + "screen", + "warning_id", + ]) + ).toEqual([ + "id", + "error_ids", + "info_ids", + "releases", + "urls", + "warning_ids", + ]); + }); }); describe("isSupportedReplayField", () => { From cf62b1610c46a473160d80cf807a4c57de8db0d4 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Fri, 1 May 2026 15:59:28 -0700 Subject: [PATCH 14/26] fix(replay): Validate replay IDs in parsed URLs Reject replay-shaped URLs that do not contain a 32-character hex replay ID instead of falling back to generic organization parsing. This keeps malformed replay links from surfacing confusing downstream validation errors. Add parser coverage for malformed replay paths on org-scoped and subdomain SaaS URLs. Co-Authored-By: OpenAI Codex --- src/lib/sentry-url-parser.ts | 35 +++++++++++++++++++++++++----- test/lib/sentry-url-parser.test.ts | 24 ++++++++++++++++++++ 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/src/lib/sentry-url-parser.ts b/src/lib/sentry-url-parser.ts index fe36771c0..4afba306b 100644 --- a/src/lib/sentry-url-parser.ts +++ b/src/lib/sentry-url-parser.ts @@ -11,6 +11,7 @@ import { DEFAULT_SENTRY_HOST } from "./constants.js"; import { getEnv } from "./env.js"; import { HostScopeError } from "./errors.js"; +import { HEX_ID_RE } from "./hex-id.js"; import { isSaaSTrustOrigin } from "./sentry-urls.js"; import { getActiveTokenHost, isHostTrusted } from "./token-host.js"; @@ -74,6 +75,9 @@ function matchOrganizationsPath( if (replayPath) { return { baseUrl, org, ...replayPath }; } + if (isReplayPathPrefix(segments, 2)) { + return null; + } // /organizations/{org}/dashboard/{id}/ if (segments[2] === "dashboard" && segments[3]) { @@ -105,6 +109,14 @@ function matchSettingsPath( return { baseUrl, org: segments[1], project: segments[3] }; } +function isReplayPathPrefix(segments: string[], startIndex: number): boolean { + return ( + (segments[startIndex] === "explore" && + segments[startIndex + 1] === "replays") || + segments[startIndex] === "replays" + ); +} + /** * Match the path portion of a SaaS subdomain-style URL against known patterns. * @@ -130,6 +142,9 @@ function matchSubdomainPath( if (replayPath) { return replayPath; } + if (isReplayPathPrefix(segments, 0)) { + return null; + } // /settings/projects/{project}/ (org-scoped subdomain settings URL) if (segments[0] === "settings" && segments[1] === "projects" && segments[2]) { return { project: segments[2] }; @@ -153,16 +168,26 @@ function matchReplayPath( segments: string[], startIndex: number ): Pick | null { + const replayId = + segments[startIndex] === "explore" && segments[startIndex + 1] === "replays" + ? segments[startIndex + 2] + : segments[startIndex] === "replays" + ? segments[startIndex + 1] + : undefined; + + if (!replayId || !HEX_ID_RE.test(replayId)) { + return null; + } + if ( segments[startIndex] === "explore" && - segments[startIndex + 1] === "replays" && - segments[startIndex + 2] + segments[startIndex + 1] === "replays" ) { - return { replayId: segments[startIndex + 2] }; + return { replayId }; } - if (segments[startIndex] === "replays" && segments[startIndex + 1]) { - return { replayId: segments[startIndex + 1] }; + if (segments[startIndex] === "replays") { + return { replayId }; } return null; diff --git a/test/lib/sentry-url-parser.test.ts b/test/lib/sentry-url-parser.test.ts index 863685f6c..1d39b808b 100644 --- a/test/lib/sentry-url-parser.test.ts +++ b/test/lib/sentry-url-parser.test.ts @@ -218,6 +218,30 @@ describe("parseSentryUrl", () => { replayId: "346789a703f6454384f1de473b8b9fcc", }); }); + + test("rejects non-hex replay ID on explore path", () => { + expect( + parseSentryUrl( + "https://sentry.io/organizations/my-org/explore/replays/some-random-page/" + ) + ).toBeNull(); + }); + + test("rejects non-hex replay ID on legacy path", () => { + expect( + parseSentryUrl( + "https://sentry.io/organizations/my-org/replays/some-random-page/" + ) + ).toBeNull(); + }); + + test("rejects non-hex replay ID on subdomain explore path", () => { + expect( + parseSentryUrl( + "https://my-org.sentry.io/explore/replays/some-random-page/" + ) + ).toBeNull(); + }); }); describe("dashboard URLs", () => { From 4d26542ad9d4ede8d33b9c2e69a442bfaffabe77 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Fri, 1 May 2026 16:03:17 -0700 Subject: [PATCH 15/26] fix(replay): Tighten replay URL and hint defaults Reject malformed replay-shaped URLs during Sentry URL parsing instead of falling back to generic organization matches. Also treat -started_at as the replay dataset's default sort when building explore pagination hints so default navigation output stays compact. Keep the parser under the repo's complexity limit by extracting the subdomain tail-path matcher, and add focused replay URL and explore hint coverage. Co-Authored-By: OpenAI Codex --- src/commands/explore.ts | 4 +++- src/lib/sentry-url-parser.ts | 38 +++++++++++++++++++---------------- test/commands/explore.test.ts | 1 + 3 files changed, 25 insertions(+), 18 deletions(-) diff --git a/src/commands/explore.ts b/src/commands/explore.ts index 4fbd3a2df..199bbc1b2 100644 --- a/src/commands/explore.ts +++ b/src/commands/explore.ts @@ -315,13 +315,15 @@ function appendFlagHints( > ): string { const parts: string[] = []; + const defaultSort = + flags.dataset === "replays" ? DEFAULT_REPLAY_SORT : undefined; if (flags.dataset !== DEFAULT_DATASET) { // Emit user-facing name, not API-level name (e.g. "metrics" not "metricsEnhanced") const displayDataset = API_TO_USER_DATASET.get(flags.dataset) ?? flags.dataset; parts.push(`--dataset ${displayDataset}`); } - appendSortHint(parts, flags.sort); + appendSortHint(parts, flags.sort, defaultSort); appendQueryHint(parts, flags.query); // Include --field flags when non-default const fieldList = flags.field ?? []; diff --git a/src/lib/sentry-url-parser.ts b/src/lib/sentry-url-parser.ts index 4afba306b..4c5923233 100644 --- a/src/lib/sentry-url-parser.ts +++ b/src/lib/sentry-url-parser.ts @@ -72,11 +72,8 @@ function matchOrganizationsPath( } const replayPath = matchReplayPath(segments, 2); - if (replayPath) { - return { baseUrl, org, ...replayPath }; - } - if (isReplayPathPrefix(segments, 2)) { - return null; + if (replayPath || isReplayPathPrefix(segments, 2)) { + return replayPath ? { baseUrl, org, ...replayPath } : null; } // /organizations/{org}/dashboard/{id}/ @@ -139,12 +136,15 @@ function matchSubdomainPath( } const replayPath = matchReplayPath(segments, 0); - if (replayPath) { + if (replayPath || isReplayPathPrefix(segments, 0)) { return replayPath; } - if (isReplayPathPrefix(segments, 0)) { - return null; - } + return matchSubdomainTailPath(segments); +} + +function matchSubdomainTailPath( + segments: string[] +): Omit | null { // /settings/projects/{project}/ (org-scoped subdomain settings URL) if (segments[0] === "settings" && segments[1] === "projects" && segments[2]) { return { project: segments[2] }; @@ -168,14 +168,18 @@ function matchReplayPath( segments: string[], startIndex: number ): Pick | null { - const replayId = - segments[startIndex] === "explore" && segments[startIndex + 1] === "replays" - ? segments[startIndex + 2] - : segments[startIndex] === "replays" - ? segments[startIndex + 1] - : undefined; - - if (!replayId || !HEX_ID_RE.test(replayId)) { + let replayId: string | undefined; + + if ( + segments[startIndex] === "explore" && + segments[startIndex + 1] === "replays" + ) { + replayId = segments[startIndex + 2]; + } else if (segments[startIndex] === "replays") { + replayId = segments[startIndex + 1]; + } + + if (!(replayId && HEX_ID_RE.test(replayId))) { return null; } diff --git a/test/commands/explore.test.ts b/test/commands/explore.test.ts index f8e8bbfde..9be11a7c3 100644 --- a/test/commands/explore.test.ts +++ b/test/commands/explore.test.ts @@ -643,6 +643,7 @@ describe("sentry explore", () => { ); expect(output).not.toContain('-F "id"'); expect(output).not.toContain('-F "started_at"'); + expect(output).not.toContain('--sort "-started_at"'); }); }); From 39230dbe44560dfd09fed26491ca60e2ac8d7912 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Fri, 1 May 2026 16:10:44 -0700 Subject: [PATCH 16/26] fix(replay): Preserve org context for replay listing URLs Let replay listing pages fall back to org-scoped URL parsing while still rejecting malformed replay detail URLs. This keeps replay URL lookup strict for view commands without breaking org extraction from replay listing links on both /organizations and subdomain URL shapes. Use a status-based replay path matcher so the parser can distinguish listing, detail, invalid, and absent replay paths without duplicating replay prefix checks. Co-Authored-By: OpenAI Codex --- src/lib/sentry-url-parser.ts | 48 ++++++++++++++---------------- test/lib/sentry-url-parser.test.ts | 28 +++++++++++++++++ 2 files changed, 51 insertions(+), 25 deletions(-) diff --git a/src/lib/sentry-url-parser.ts b/src/lib/sentry-url-parser.ts index 4c5923233..9a2a541e9 100644 --- a/src/lib/sentry-url-parser.ts +++ b/src/lib/sentry-url-parser.ts @@ -72,8 +72,11 @@ function matchOrganizationsPath( } const replayPath = matchReplayPath(segments, 2); - if (replayPath || isReplayPathPrefix(segments, 2)) { - return replayPath ? { baseUrl, org, ...replayPath } : null; + if (replayPath.status === "detail") { + return { baseUrl, org, replayId: replayPath.replayId }; + } + if (replayPath.status === "invalid") { + return null; } // /organizations/{org}/dashboard/{id}/ @@ -106,14 +109,6 @@ function matchSettingsPath( return { baseUrl, org: segments[1], project: segments[3] }; } -function isReplayPathPrefix(segments: string[], startIndex: number): boolean { - return ( - (segments[startIndex] === "explore" && - segments[startIndex + 1] === "replays") || - segments[startIndex] === "replays" - ); -} - /** * Match the path portion of a SaaS subdomain-style URL against known patterns. * @@ -136,8 +131,14 @@ function matchSubdomainPath( } const replayPath = matchReplayPath(segments, 0); - if (replayPath || isReplayPathPrefix(segments, 0)) { - return replayPath; + if (replayPath.status === "detail") { + return { replayId: replayPath.replayId }; + } + if (replayPath.status === "invalid") { + return null; + } + if (replayPath.status === "list") { + return {}; } return matchSubdomainTailPath(segments); } @@ -167,7 +168,9 @@ function matchSubdomainTailPath( function matchReplayPath( segments: string[], startIndex: number -): Pick | null { +): + | { status: "absent" | "list" | "invalid" } + | { status: "detail"; replayId: string } { let replayId: string | undefined; if ( @@ -177,24 +180,19 @@ function matchReplayPath( replayId = segments[startIndex + 2]; } else if (segments[startIndex] === "replays") { replayId = segments[startIndex + 1]; + } else { + return { status: "absent" }; } - if (!(replayId && HEX_ID_RE.test(replayId))) { - return null; + if (!replayId) { + return { status: "list" }; } - if ( - segments[startIndex] === "explore" && - segments[startIndex + 1] === "replays" - ) { - return { replayId }; - } - - if (segments[startIndex] === "replays") { - return { replayId }; + if (!HEX_ID_RE.test(replayId)) { + return { status: "invalid" }; } - return null; + return { status: "detail", replayId }; } /** diff --git a/test/lib/sentry-url-parser.test.ts b/test/lib/sentry-url-parser.test.ts index 1d39b808b..9417cad70 100644 --- a/test/lib/sentry-url-parser.test.ts +++ b/test/lib/sentry-url-parser.test.ts @@ -219,6 +219,26 @@ describe("parseSentryUrl", () => { }); }); + test("falls back to org for replay listing URL", () => { + const result = parseSentryUrl( + "https://sentry.io/organizations/my-org/explore/replays/" + ); + expect(result).toEqual({ + baseUrl: "https://sentry.io", + org: "my-org", + }); + }); + + test("falls back to org for legacy replay listing URL", () => { + const result = parseSentryUrl( + "https://sentry.io/organizations/my-org/replays/" + ); + expect(result).toEqual({ + baseUrl: "https://sentry.io", + org: "my-org", + }); + }); + test("rejects non-hex replay ID on explore path", () => { expect( parseSentryUrl( @@ -242,6 +262,14 @@ describe("parseSentryUrl", () => { ) ).toBeNull(); }); + + test("falls back to org for subdomain replay listing URL", () => { + const result = parseSentryUrl("https://my-org.sentry.io/explore/replays/"); + expect(result).toEqual({ + baseUrl: "https://my-org.sentry.io", + org: "my-org", + }); + }); }); describe("dashboard URLs", () => { From 8d1e0276bd630b90633b879efba9bfa9f8b56d03 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Fri, 1 May 2026 16:13:37 -0700 Subject: [PATCH 17/26] fix(replay): Reuse the shared replay list period default Derive replay list hint rendering from LIST_PERIOD_FLAG.default so pagination commands cannot drift from the actual flag default. This keeps replay list behavior aligned with the shared list-command contract even if the default period changes later. Add focused coverage for next-page hints so the shared default period stays omitted unless the user explicitly overrides it. Co-Authored-By: OpenAI Codex --- src/commands/replay/list.ts | 2 +- test/commands/replay/list.test.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/commands/replay/list.ts b/src/commands/replay/list.ts index 90250427a..527012983 100644 --- a/src/commands/replay/list.ts +++ b/src/commands/replay/list.ts @@ -87,7 +87,7 @@ const SORT_MAP: Record = { activity: "-activity", }; -const DEFAULT_PERIOD = "7d"; +const DEFAULT_PERIOD = LIST_PERIOD_FLAG.default; const DEFAULT_SORT: ReplaySortValue = SORT_MAP.date; const PAGINATION_KEY = "replay-list"; const COMMAND_NAME = "replay list"; diff --git a/test/commands/replay/list.test.ts b/test/commands/replay/list.test.ts index d1d3a06d0..9c1764415 100644 --- a/test/commands/replay/list.test.ts +++ b/test/commands/replay/list.test.ts @@ -16,6 +16,7 @@ import { listCommand, parseSort } from "../../../src/commands/replay/list.js"; import * as apiClient from "../../../src/lib/api-client.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as paginationDb from "../../../src/lib/db/pagination.js"; +import { LIST_PERIOD_FLAG } from "../../../src/lib/list-command.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as resolveTarget from "../../../src/lib/resolve-target.js"; import { parsePeriod } from "../../../src/lib/time-range.js"; @@ -183,4 +184,29 @@ describe("listCommand.func", () => { expect(output).toContain("Showing 1 replay."); expect(output).toContain("sentry replay view test-org/"); }); + + test("omits --period in next-page hints for the shared default period", async () => { + resolveTargetSpy.mockResolvedValue({ org: "test-org", project: "cli" }); + listReplaysSpy.mockResolvedValue({ + data: sampleReplays, + nextCursor: "0:25:0", + }); + + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call( + context, + { + limit: 25, + json: false, + period: parsePeriod(LIST_PERIOD_FLAG.default), + sort: "-started_at", + }, + "test-org/cli" + ); + + const output = stdoutWrite.mock.calls.map((call) => call[0]).join(""); + expect(output).toContain("sentry replay list test-org/cli -c next"); + expect(output).not.toContain("--period"); + }); }); From e771a23329115a593e00018199c7b2071de0432d Mon Sep 17 00:00:00 2001 From: David Cramer Date: Fri, 1 May 2026 16:18:22 -0700 Subject: [PATCH 18/26] fix(replay): Format replay URL parser test coverage Wrap the new replay listing URL expectation the way Ultracite expects so the replay follow-up commits stay green in CI. Co-Authored-By: OpenAI Codex --- test/lib/sentry-url-parser.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/lib/sentry-url-parser.test.ts b/test/lib/sentry-url-parser.test.ts index 9417cad70..5125f18a6 100644 --- a/test/lib/sentry-url-parser.test.ts +++ b/test/lib/sentry-url-parser.test.ts @@ -264,7 +264,9 @@ describe("parseSentryUrl", () => { }); test("falls back to org for subdomain replay listing URL", () => { - const result = parseSentryUrl("https://my-org.sentry.io/explore/replays/"); + const result = parseSentryUrl( + "https://my-org.sentry.io/explore/replays/" + ); expect(result).toEqual({ baseUrl: "https://my-org.sentry.io", org: "my-org", From 6c7e2bee44b1058a0ba632df8fbb615fbc91a0f1 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Fri, 1 May 2026 16:24:21 -0700 Subject: [PATCH 19/26] fix(replay): Normalize replay query and ID handling Normalize org/replay shorthand IDs before replay view uses them so dashed replay IDs keep working through the CLI shorthand forms. Make replay-backed explore queries and replay list requests pass the same explicit filters and field sets the command layer already knows about. That keeps replay target scoping consistent across list and explore and makes the replay API usage less implicit. Co-Authored-By: OpenAI Codex --- src/commands/explore.ts | 7 ++----- src/commands/replay/list.ts | 2 ++ src/commands/replay/view.ts | 6 ++++-- test/commands/explore.test.ts | 25 ++++++++++++++++++++++++- test/commands/replay/list.test.ts | 7 ++++++- test/commands/replay/view.test.ts | 8 ++++++++ 6 files changed, 46 insertions(+), 9 deletions(-) diff --git a/src/commands/explore.ts b/src/commands/explore.ts index 199bbc1b2..1cec2b9b2 100644 --- a/src/commands/explore.ts +++ b/src/commands/explore.ts @@ -555,10 +555,7 @@ export const exploreCommand = buildListCommand("explore", { // When a project is in the target, prepend `project:` to the query // so the API filters server-side. Mirrors `trace logs` / `log list` behavior. - const apiQuery = - dataset === "replays" - ? flags.query - : buildProjectQuery(flags.query, project); + const apiQuery = buildProjectQuery(flags.query, project); // Pagination context includes project so different scopes don't share state const contextKey = buildPaginationContextKey( @@ -592,7 +589,7 @@ export const exploreCommand = buildListCommand("explore", { fields: getReplayRequestFields(fieldList), limit: flags.limit, projectSlugs: project ? [project] : undefined, - query: flags.query, + query: apiQuery, sort: replaySort, ...timeRangeToApiParams(timeRange), }); diff --git a/src/commands/replay/list.ts b/src/commands/replay/list.ts index 527012983..582c75ebd 100644 --- a/src/commands/replay/list.ts +++ b/src/commands/replay/list.ts @@ -52,6 +52,7 @@ import { timeRangeToApiParams, } from "../../lib/time-range.js"; import { + REPLAY_LIST_FIELDS, type ReplayListItem, ReplayListItemOutputSchema, } from "../../types/index.js"; @@ -356,6 +357,7 @@ export const listCommand = buildListCommand("replay", { () => listReplays(resolved.org, { environment, + fields: [...REPLAY_LIST_FIELDS], limit: flags.limit, query, projectSlugs: resolved.project ? [resolved.project] : undefined, diff --git a/src/commands/replay/view.ts b/src/commands/replay/view.ts index d01b151be..be41de8c1 100644 --- a/src/commands/replay/view.ts +++ b/src/commands/replay/view.ts @@ -90,10 +90,12 @@ function parseSingleArg(arg: string): ParsedPositionalArgs { if (slashIdx !== -1 && trimmed.indexOf("/", slashIdx + 1) === -1) { const org = trimmed.slice(0, slashIdx); const replaySegment = trimmed.slice(slashIdx + 1); - if (!(replaySegment && normalizeReplayId(replaySegment))) { + const normalizedReplayId = + replaySegment && normalizeReplayId(replaySegment); + if (!normalizedReplayId) { throw new ContextError("Replay ID", USAGE_HINT, []); } - return { replayId: replaySegment, targetArg: `${org}/` }; + return { replayId: normalizedReplayId, targetArg: `${org}/` }; } const { id: replayId, targetArg } = parseSlashSeparatedArg( diff --git a/test/commands/explore.test.ts b/test/commands/explore.test.ts index 9be11a7c3..14b0c8422 100644 --- a/test/commands/explore.test.ts +++ b/test/commands/explore.test.ts @@ -347,13 +347,36 @@ describe("sentry explore", () => { fields: ["id", "user", "count_errors", "urls"], limit: 25, projectSlugs: ["cli"], - query: undefined, + query: "project:cli", sort: "-count_errors", statsPeriod: "24h", }); expect(queryEventsSpy).not.toHaveBeenCalled(); }); + test("merges replay query text with the target project filter", async () => { + resolveTargetSpy.mockResolvedValue({ org: "test-org", project: "cli" }); + const { context } = createContext(); + + await func.call( + context, + { + ...DEFAULT_FLAGS, + dataset: "replays", + query: "count_errors:>0", + }, + "test-org/cli" + ); + + expect(listReplaysSpy).toHaveBeenCalledWith( + "test-org", + expect.objectContaining({ + projectSlugs: ["cli"], + query: "project:cli count_errors:>0", + }) + ); + }); + test("requests trace_ids when replay fields derive count_traces", async () => { resolveTargetSpy.mockResolvedValue({ org: "test-org" }); const { context } = createContext(); diff --git a/test/commands/replay/list.test.ts b/test/commands/replay/list.test.ts index 9c1764415..887d98598 100644 --- a/test/commands/replay/list.test.ts +++ b/test/commands/replay/list.test.ts @@ -20,7 +20,10 @@ import { LIST_PERIOD_FLAG } from "../../../src/lib/list-command.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as resolveTarget from "../../../src/lib/resolve-target.js"; import { parsePeriod } from "../../../src/lib/time-range.js"; -import type { ReplayListItem } from "../../../src/types/index.js"; +import { + REPLAY_LIST_FIELDS, + type ReplayListItem, +} from "../../../src/types/index.js"; describe("parseSort", () => { test("accepts supported sort values", () => { @@ -113,6 +116,7 @@ describe("listCommand.func", () => { expect(listReplaysSpy).toHaveBeenCalledWith("test-org", { environment: undefined, + fields: [...REPLAY_LIST_FIELDS], limit: 25, projectSlugs: ["cli"], query: undefined, @@ -152,6 +156,7 @@ describe("listCommand.func", () => { expect(listReplaysSpy).toHaveBeenCalledWith("test-org", { environment: ["production", "canary", "staging"], + fields: [...REPLAY_LIST_FIELDS], limit: 25, projectSlugs: ["cli"], query: undefined, diff --git a/test/commands/replay/view.test.ts b/test/commands/replay/view.test.ts index 5883bc754..e0b8fd01a 100644 --- a/test/commands/replay/view.test.ts +++ b/test/commands/replay/view.test.ts @@ -62,6 +62,14 @@ describe("parsePositionalArgs", () => { expect(result.targetArg).toBe("test-org/"); }); + test("normalizes dashed org/replay-id shorthand", () => { + const result = parsePositionalArgs([ + "test-org/346789a7-03f6-4543-84f1-de473b8b9fcc", + ]); + expect(result.replayId).toBe(REPLAY_ID); + expect(result.targetArg).toBe("test-org/"); + }); + test("parses org/project/replay-id form", () => { const result = parsePositionalArgs([`test-org/cli/${REPLAY_ID}`]); expect(result.replayId).toBe(REPLAY_ID); From 3b363113f27dfcb1d4413a9082c5032cc3ecacd6 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Fri, 1 May 2026 16:28:03 -0700 Subject: [PATCH 20/26] fix(replay): Normalize replay URLs and list fields Normalize replay IDs as soon as they are parsed from replay URLs so all replay entry paths use the same canonical lowercase ID. Keep the replay list field contract aligned with the schema by requesting ota_updates in the default replay list field set. Co-Authored-By: OpenAI Codex --- src/lib/sentry-url-parser.ts | 7 ++++--- src/types/replay.ts | 1 + test/lib/api/replays.test.ts | 1 + test/lib/sentry-url-parser.test.ts | 11 +++++++++++ 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/lib/sentry-url-parser.ts b/src/lib/sentry-url-parser.ts index 9a2a541e9..cb5ed62de 100644 --- a/src/lib/sentry-url-parser.ts +++ b/src/lib/sentry-url-parser.ts @@ -11,7 +11,7 @@ import { DEFAULT_SENTRY_HOST } from "./constants.js"; import { getEnv } from "./env.js"; import { HostScopeError } from "./errors.js"; -import { HEX_ID_RE } from "./hex-id.js"; +import { normalizeReplayId } from "./replay-id.js"; import { isSaaSTrustOrigin } from "./sentry-urls.js"; import { getActiveTokenHost, isHostTrusted } from "./token-host.js"; @@ -188,11 +188,12 @@ function matchReplayPath( return { status: "list" }; } - if (!HEX_ID_RE.test(replayId)) { + const normalizedReplayId = normalizeReplayId(replayId); + if (!normalizedReplayId) { return { status: "invalid" }; } - return { status: "detail", replayId }; + return { status: "detail", replayId: normalizedReplayId }; } /** diff --git a/src/types/replay.ts b/src/types/replay.ts index 99e617405..82a26984e 100644 --- a/src/types/replay.ts +++ b/src/types/replay.ts @@ -121,6 +121,7 @@ export const REPLAY_LIST_FIELDS = [ "info_ids", "is_archived", "os", + "ota_updates", "platform", "project_id", "releases", diff --git a/test/lib/api/replays.test.ts b/test/lib/api/replays.test.ts index b7f98339f..0b2f0f18f 100644 --- a/test/lib/api/replays.test.ts +++ b/test/lib/api/replays.test.ts @@ -73,6 +73,7 @@ describe("listReplays", () => { expect(url.searchParams.get("statsPeriod")).toBe("24h"); expect(url.searchParams.get("per_page")).toBe("25"); expect(url.searchParams.getAll("field")).toContain("id"); + expect(url.searchParams.getAll("field")).toContain("ota_updates"); expect(url.searchParams.getAll("field")).toContain("user"); expect(result.data).toHaveLength(1); expect(result.nextCursor).toBe("0:25:0"); diff --git a/test/lib/sentry-url-parser.test.ts b/test/lib/sentry-url-parser.test.ts index 5125f18a6..b835ce6c5 100644 --- a/test/lib/sentry-url-parser.test.ts +++ b/test/lib/sentry-url-parser.test.ts @@ -219,6 +219,17 @@ describe("parseSentryUrl", () => { }); }); + test("normalizes uppercase replay IDs in replay URLs", () => { + const result = parseSentryUrl( + "https://sentry.io/organizations/my-org/explore/replays/346789A703F6454384F1DE473B8B9FCC/" + ); + expect(result).toEqual({ + baseUrl: "https://sentry.io", + org: "my-org", + replayId: "346789a703f6454384f1de473b8b9fcc", + }); + }); + test("falls back to org for replay listing URL", () => { const result = parseSentryUrl( "https://sentry.io/organizations/my-org/explore/replays/" From 9ce3297a0f649682f130903eabf6fbe21135d7a2 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Fri, 1 May 2026 16:42:14 -0700 Subject: [PATCH 21/26] fix(replay): Reject extra replay view args Make replay view fail fast when users pass more than the supported target and replay ID positional arguments instead of silently dropping trailing args. Normalize swapped replay IDs before returning them from positional parsing so every replay view entry path stays consistent. Co-Authored-By: OpenAI Codex --- src/commands/replay/view.ts | 16 ++++++++++++++-- test/commands/replay/view.test.ts | 7 +++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/commands/replay/view.ts b/src/commands/replay/view.ts index be41de8c1..b8467613e 100644 --- a/src/commands/replay/view.ts +++ b/src/commands/replay/view.ts @@ -18,7 +18,12 @@ import { } from "../../lib/arg-parsing.js"; import { openInBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; -import { ApiError, ContextError, ResolutionError } from "../../lib/errors.js"; +import { + ApiError, + ContextError, + ResolutionError, + ValidationError, +} from "../../lib/errors.js"; import { escapeMarkdownCell, escapeMarkdownInline, @@ -120,6 +125,12 @@ export function parsePositionalArgs(args: string[]): ParsedPositionalArgs { if (args.length === 0) { throw new ContextError("Replay ID", USAGE_HINT, []); } + if (args.length > 2) { + throw new ValidationError( + `Too many positional arguments (got ${args.length}, expected at most 2).\n\nUsage: ${USAGE_HINT}`, + "positional" + ); + } const first = args[0]; if (!first) { @@ -149,8 +160,9 @@ export function parsePositionalArgs(args: string[]): ParsedPositionalArgs { const warning = args.length === 2 ? detectSwappedViewArgs(first, second) : null; if (warning) { + const normalizedReplayId = normalizeReplayId(first) ?? first; return { - replayId: first, + replayId: normalizedReplayId, targetArg: second, warning, }; diff --git a/test/commands/replay/view.test.ts b/test/commands/replay/view.test.ts index e0b8fd01a..f94bc63ea 100644 --- a/test/commands/replay/view.test.ts +++ b/test/commands/replay/view.test.ts @@ -23,6 +23,7 @@ import { ApiError, ContextError, ResolutionError, + ValidationError, } from "../../../src/lib/errors.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as resolveTarget from "../../../src/lib/resolve-target.js"; @@ -102,6 +103,12 @@ describe("parsePositionalArgs", () => { test("throws ContextError for org/project with no replay ID", () => { expect(() => parsePositionalArgs(["test-org/cli"])).toThrow(ContextError); }); + + test("throws ValidationError for extra positional args", () => { + expect(() => + parsePositionalArgs(["test-org/cli", REPLAY_ID, "extra-arg"]) + ).toThrow(ValidationError); + }); }); describe("viewCommand.func", () => { From 71606e787aaa8609a1bb1469139532aee3f37b15 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Sat, 2 May 2026 01:04:21 +0000 Subject: [PATCH 22/26] ref(replay): consolidate replay-duration and replay-id into shared utilities - Delete src/lib/replay-duration.ts: move formatDurationCompact, formatDurationVerbose, and formatDurationCompactMs to src/lib/formatters/time-utils.ts where other duration helpers live. - Delete src/lib/replay-id.ts: add tryNormalizeHexId to hex-id.ts (non-throwing variant), move getReplayIdFromEvent and collectReplayIds to replay-search.ts (replay domain logic). - Replace local formatRelativeOffset in replay/view.ts with the shared formatDurationCompactMs from time-utils.ts. - Fix event/view.ts replayHint reference to undefined 'event' variable introduced during merge conflict resolution (use fetchedEvents[0]). --- .../skills/sentry-cli/references/replay.md | 2 +- src/commands/event/view.ts | 4 +- src/commands/issue/view.ts | 5 +- src/commands/replay/list.ts | 4 +- src/commands/replay/view.ts | 35 ++---- src/lib/formatters/human.ts | 2 +- src/lib/formatters/time-utils.ts | 117 ++++++++++++++++++ src/lib/hex-id.ts | 21 ++++ src/lib/replay-duration.ts | 70 ----------- src/lib/replay-id.ts | 67 ---------- src/lib/replay-search.ts | 67 +++++++++- src/lib/sentry-url-parser.ts | 4 +- test/lib/replay-duration.test.ts | 51 +++++--- 13 files changed, 258 insertions(+), 191 deletions(-) delete mode 100644 src/lib/replay-duration.ts delete mode 100644 src/lib/replay-id.ts diff --git a/plugins/sentry-cli/skills/sentry-cli/references/replay.md b/plugins/sentry-cli/skills/sentry-cli/references/replay.md index 96455c81f..32154321d 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/replay.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/replay.md @@ -1,6 +1,6 @@ --- name: sentry-cli-replay -version: 0.31.0-dev.0 +version: 0.32.0-dev.0 description: Search and inspect Session Replays requires: bins: ["sentry"] diff --git a/src/commands/event/view.ts b/src/commands/event/view.ts index b5a290d20..9deef30f7 100644 --- a/src/commands/event/view.ts +++ b/src/commands/event/view.ts @@ -49,7 +49,7 @@ import { } from "../../lib/list-command.js"; import { logger } from "../../lib/logger.js"; import { resolveEffectiveOrg } from "../../lib/region.js"; -import { getReplayIdFromEvent } from "../../lib/replay-id.js"; +import { getReplayIdFromEvent } from "../../lib/replay-search.js"; import { resolveOrg, resolveOrgAndProject, @@ -1069,7 +1069,7 @@ export const viewCommand = buildCommand({ target.detectedFrom ? `Detected from ${target.detectedFrom}` : undefined, - replayHint(target.org, event), + fetchedEvents[0] ? replayHint(target.org, fetchedEvents[0]) : undefined, ]), }; }, diff --git a/src/commands/issue/view.ts b/src/commands/issue/view.ts index 5e25bc1ad..9cdd89a06 100644 --- a/src/commands/issue/view.ts +++ b/src/commands/issue/view.ts @@ -23,7 +23,10 @@ import { FRESH_ALIASES, FRESH_FLAG, } from "../../lib/list-command.js"; -import { collectReplayIds, getReplayIdFromEvent } from "../../lib/replay-id.js"; +import { + collectReplayIds, + getReplayIdFromEvent, +} from "../../lib/replay-search.js"; import { getSpanTreeLines } from "../../lib/span-tree.js"; import type { SentryEvent, SentryIssue } from "../../types/index.js"; import { issueIdPositional, resolveIssue } from "./utils.js"; diff --git a/src/commands/replay/list.ts b/src/commands/replay/list.ts index 582c75ebd..ebc10a7fa 100644 --- a/src/commands/replay/list.ts +++ b/src/commands/replay/list.ts @@ -25,6 +25,7 @@ import { import { filterFields } from "../../lib/formatters/json.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import type { Column } from "../../lib/formatters/table.js"; +import { formatDurationCompact } from "../../lib/formatters/time-utils.js"; import { appendQueryHint, appendSortHint, @@ -38,7 +39,6 @@ import { targetPatternExplanation, } from "../../lib/list-command.js"; import { withProgress } from "../../lib/polling.js"; -import { formatReplayDurationCompact } from "../../lib/replay-duration.js"; import { getReplayUserLabel, parseReplayEnvironmentFilter, @@ -139,7 +139,7 @@ const REPLAY_COLUMNS: Column[] = [ }, { header: "DURATION", - value: (replay) => formatReplayDurationCompact(replay.duration), + value: (replay) => formatDurationCompact(replay.duration), minWidth: 10, }, { diff --git a/src/commands/replay/view.ts b/src/commands/replay/view.ts index b8467613e..eeea2429e 100644 --- a/src/commands/replay/view.ts +++ b/src/commands/replay/view.ts @@ -32,14 +32,16 @@ import { } from "../../lib/formatters/index.js"; import { filterFields } from "../../lib/formatters/json.js"; import { CommandOutput } from "../../lib/formatters/output.js"; -import { validateHexId } from "../../lib/hex-id.js"; +import { + formatDurationCompactMs, + formatDurationVerbose, +} from "../../lib/formatters/time-utils.js"; +import { tryNormalizeHexId, validateHexId } from "../../lib/hex-id.js"; import { applyFreshFlag, FRESH_ALIASES, FRESH_FLAG, } from "../../lib/list-command.js"; -import { formatReplayDurationVerbose } from "../../lib/replay-duration.js"; -import { normalizeReplayId } from "../../lib/replay-id.js"; import { getReplayUserLabel } from "../../lib/replay-search.js"; import { resolveOrgOptionalProjectFromArg } from "../../lib/resolve-target.js"; import { @@ -96,7 +98,7 @@ function parseSingleArg(arg: string): ParsedPositionalArgs { const org = trimmed.slice(0, slashIdx); const replaySegment = trimmed.slice(slashIdx + 1); const normalizedReplayId = - replaySegment && normalizeReplayId(replaySegment); + replaySegment && tryNormalizeHexId(replaySegment); if (!normalizedReplayId) { throw new ContextError("Replay ID", USAGE_HINT, []); } @@ -160,7 +162,7 @@ export function parsePositionalArgs(args: string[]): ParsedPositionalArgs { const warning = args.length === 2 ? detectSwappedViewArgs(first, second) : null; if (warning) { - const normalizedReplayId = normalizeReplayId(first) ?? first; + const normalizedReplayId = tryNormalizeHexId(first) ?? first; return { replayId: normalizedReplayId, targetArg: second, @@ -175,25 +177,6 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } -function formatRelativeOffset(milliseconds: number): string { - const seconds = Math.max(0, Math.round(milliseconds / 1000)); - if (seconds < 60) { - return `${seconds}s`; - } - - const minutes = Math.floor(seconds / 60); - const remainingSeconds = seconds % 60; - if (minutes < 60) { - return remainingSeconds > 0 - ? `${minutes}m ${remainingSeconds}s` - : `${minutes}m`; - } - - const hours = Math.floor(minutes / 60); - const remainingMinutes = minutes % 60; - return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`; -} - function getEventTimestampMillis(value: unknown): number | null { if (typeof value === "number") { return value; @@ -560,7 +543,7 @@ function buildReplayOverviewRows( rows, "Duration", replay.duration !== null && replay.duration !== undefined - ? formatReplayDurationVerbose(replay.duration) + ? formatDurationVerbose(replay.duration) : undefined ); pushMarkdownRow( @@ -730,7 +713,7 @@ function pushActivitySection( for (const event of activity) { const prefix = event.timestampMs !== null && startTime !== null - ? `${formatRelativeOffset(event.timestampMs - startTime)} · ` + ? `${formatDurationCompactMs(event.timestampMs - startTime)} · ` : ""; const details = event.details.length > 0 diff --git a/src/lib/formatters/human.ts b/src/lib/formatters/human.ts index 66d76fb50..64d0a88b9 100644 --- a/src/lib/formatters/human.ts +++ b/src/lib/formatters/human.ts @@ -29,7 +29,7 @@ import type { Writer, } from "../../types/index.js"; import { resolveOrgDisplayName } from "../api-client.js"; -import { getReplayIdFromEvent } from "../replay-id.js"; +import { getReplayIdFromEvent } from "../replay-search.js"; import { withSerializeSpan } from "../telemetry.js"; import { type FixabilityTier, muted } from "./colors.js"; import { diff --git a/src/lib/formatters/time-utils.ts b/src/lib/formatters/time-utils.ts index 0d68b2595..3202d8d1a 100644 --- a/src/lib/formatters/time-utils.ts +++ b/src/lib/formatters/time-utils.ts @@ -3,6 +3,9 @@ * * Extracted to break the circular import between `human.ts` and `trace.ts`: * both modules need these utilities but neither should depend on the other. + * + * Also provides generic compact/verbose duration formatters (seconds-based) + * used by replay commands and any future duration display. */ import type { TraceSpan } from "../../types/index.js"; @@ -48,6 +51,120 @@ export function formatRelativeTime(dateString: string | undefined): string { return text; } +// --------------------------------------------------------------------------- +// Generic duration formatting (seconds-based) +// --------------------------------------------------------------------------- + +/** + * Split a duration in seconds into days, hours, minutes, and seconds. + * Rounds to the nearest second and clamps to non-negative. + */ +function splitDuration(totalSeconds: number): { + days: number; + hours: number; + minutes: number; + seconds: number; +} { + const rounded = Math.max(0, Math.round(totalSeconds)); + return { + days: Math.floor(rounded / 86_400), + hours: Math.floor((rounded % 86_400) / 3600), + minutes: Math.floor((rounded % 3600) / 60), + seconds: rounded % 60, + }; +} + +/** + * Pluralize a value with its singular unit name. + * + * @example pluralize(1, "minute") → "1 minute" + * @example pluralize(3, "hour") → "3 hours" + */ +function pluralize(value: number, singular: string): string { + return `${value} ${singular}${value === 1 ? "" : "s"}`; +} + +/** + * Format a duration (in seconds) as a compact string for table/list output. + * + * Shows at most two adjacent units: `2m 5s`, `1h 1m`, `1d 1h`. + * Returns `"—"` when the input is null or undefined. + * + * @param seconds - Duration in seconds, or null/undefined + * @returns Compact duration string (e.g., `"2m 5s"`, `"1d"`, `"—"`) + */ +export function formatDurationCompact( + seconds: number | null | undefined +): string { + if (seconds === null || seconds === undefined) { + return "—"; + } + + const parts = splitDuration(seconds); + if (parts.days > 0) { + return parts.hours > 0 + ? `${parts.days}d ${parts.hours}h` + : `${parts.days}d`; + } + if (parts.hours > 0) { + return parts.minutes > 0 + ? `${parts.hours}h ${parts.minutes}m` + : `${parts.hours}h`; + } + if (parts.minutes > 0) { + return parts.seconds > 0 + ? `${parts.minutes}m ${parts.seconds}s` + : `${parts.minutes}m`; + } + return `${parts.seconds}s`; +} + +/** + * Format a duration (in milliseconds) as a compact string. + * + * Converts ms → seconds and delegates to {@link formatDurationCompact}. + * Useful for activity offsets and other ms-based durations. + * + * @param milliseconds - Duration in milliseconds + * @returns Compact duration string (e.g., `"2m 5s"`, `"1h"`) + */ +export function formatDurationCompactMs(milliseconds: number): string { + return formatDurationCompact(milliseconds / 1000); +} + +/** + * Format a duration (in seconds) as a verbose human-readable string. + * + * Uses full unit names with "and" joining the two most significant units: + * `"2 minutes and 5 seconds"`, `"1 hour and 1 minute"`, `"1 day"`. + * + * @param seconds - Duration in seconds + * @returns Verbose duration string + */ +export function formatDurationVerbose(seconds: number): string { + const parts = splitDuration(seconds); + if (parts.days > 0) { + return parts.hours > 0 + ? `${pluralize(parts.days, "day")} and ${pluralize(parts.hours, "hour")}` + : pluralize(parts.days, "day"); + } + if (parts.hours > 0) { + return parts.minutes > 0 + ? `${pluralize(parts.hours, "hour")} and ${pluralize(parts.minutes, "minute")}` + : pluralize(parts.hours, "hour"); + } + if (parts.minutes > 0) { + return parts.seconds > 0 + ? `${pluralize(parts.minutes, "minute")} and ${pluralize(parts.seconds, "second")}` + : pluralize(parts.minutes, "minute"); + } + return pluralize(parts.seconds, "second"); +} + +// --------------------------------------------------------------------------- +// Span duration +// --------------------------------------------------------------------------- + /** * Compute the duration of a span in milliseconds. * Prefers the API-provided `duration` field, falls back to timestamp arithmetic. diff --git a/src/lib/hex-id.ts b/src/lib/hex-id.ts index 1d7183b89..f720dca6b 100644 --- a/src/lib/hex-id.ts +++ b/src/lib/hex-id.ts @@ -97,6 +97,27 @@ export function normalizeHexId(value: string): string { return trimmed; } +/** + * Non-throwing variant of {@link normalizeHexId} that returns `undefined` + * for falsy input or input that doesn't normalize to a valid 32-char hex ID. + * + * Useful when the caller wants to silently discard invalid IDs (e.g., + * collecting replay IDs from event tags where bad values are expected). + * + * @param value - Raw string, or null/undefined + * @returns Normalized 32-char lowercase hex ID, or undefined + */ +export function tryNormalizeHexId( + value: string | null | undefined +): string | undefined { + if (!value) { + return; + } + + const normalized = normalizeHexId(value.trim()); + return HEX_ID_RE.test(normalized) ? normalized : undefined; +} + /** * Validate that a string is a 32-character hexadecimal ID. * Trims whitespace and normalizes to lowercase before validation. diff --git a/src/lib/replay-duration.ts b/src/lib/replay-duration.ts deleted file mode 100644 index 16ccfa317..000000000 --- a/src/lib/replay-duration.ts +++ /dev/null @@ -1,70 +0,0 @@ -function splitReplayDuration(totalSeconds: number): { - days: number; - hours: number; - minutes: number; - seconds: number; -} { - const rounded = Math.max(0, Math.round(totalSeconds)); - return { - days: Math.floor(rounded / 86_400), - hours: Math.floor((rounded % 86_400) / 3600), - minutes: Math.floor((rounded % 3600) / 60), - seconds: rounded % 60, - }; -} - -function pluralize(value: number, singular: string): string { - return `${value} ${singular}${value === 1 ? "" : "s"}`; -} - -/** - * Format a replay duration for compact table output. - */ -export function formatReplayDurationCompact( - seconds: number | null | undefined -): string { - if (seconds === null || seconds === undefined) { - return "—"; - } - - const parts = splitReplayDuration(seconds); - if (parts.days > 0) { - return parts.hours > 0 - ? `${parts.days}d ${parts.hours}h` - : `${parts.days}d`; - } - if (parts.hours > 0) { - return parts.minutes > 0 - ? `${parts.hours}h ${parts.minutes}m` - : `${parts.hours}h`; - } - if (parts.minutes > 0) { - return parts.seconds > 0 - ? `${parts.minutes}m ${parts.seconds}s` - : `${parts.minutes}m`; - } - return `${parts.seconds}s`; -} - -/** - * Format a replay duration for verbose detail output. - */ -export function formatReplayDurationVerbose(seconds: number): string { - const parts = splitReplayDuration(seconds); - if (parts.days > 0) { - return parts.hours > 0 - ? `${pluralize(parts.days, "day")} and ${pluralize(parts.hours, "hour")}` - : pluralize(parts.days, "day"); - } - if (parts.hours > 0) { - return parts.minutes > 0 - ? `${pluralize(parts.hours, "hour")} and ${pluralize(parts.minutes, "minute")}` - : pluralize(parts.hours, "hour"); - } - if (parts.minutes > 0) { - return parts.seconds > 0 - ? `${pluralize(parts.minutes, "minute")} and ${pluralize(parts.seconds, "second")}` - : pluralize(parts.minutes, "minute"); - } - return pluralize(parts.seconds, "second"); -} diff --git a/src/lib/replay-id.ts b/src/lib/replay-id.ts deleted file mode 100644 index 934bcb6e7..000000000 --- a/src/lib/replay-id.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { SentryEvent } from "../types/index.js"; -import { normalizeHexId } from "./hex-id.js"; - -const REPLAY_ID_RE = /^[0-9a-f]{32}$/; - -/** - * Normalize a replay ID to the canonical 32-character lowercase hex form. - * - * Accepts both bare hex IDs and UUID-style IDs with dashes. - */ -export function normalizeReplayId( - value: string | null | undefined -): string | undefined { - if (!value) { - return; - } - - const normalized = normalizeHexId(value.trim()); - return REPLAY_ID_RE.test(normalized) ? normalized : undefined; -} - -function getReplayIdFromReplayContext( - event: Pick -): string | undefined { - const replayContext = event.contexts?.replay; - return typeof replayContext?.replay_id === "string" - ? replayContext.replay_id - : undefined; -} - -/** - * Extract the best replay ID from an event's known replay linkage fields. - */ -export function getReplayIdFromEvent( - event: Pick -): string | undefined { - const tagReplayId = event.tags?.find( - (tag) => tag.key === "replayId" || tag.key === "replay.id" - )?.value; - - return collectReplayIds([ - tagReplayId, - getReplayIdFromReplayContext(event), - ])[0]; -} - -/** - * Normalize and deduplicate replay IDs while preserving first-seen order. - */ -export function collectReplayIds( - values: Iterable -): string[] { - const seen = new Set(); - const replayIds: string[] = []; - - for (const value of values) { - const replayId = normalizeReplayId(value); - if (!replayId || seen.has(replayId)) { - continue; - } - - seen.add(replayId); - replayIds.push(replayId); - } - - return replayIds; -} diff --git a/src/lib/replay-search.ts b/src/lib/replay-search.ts index bb89e7c80..7816b23f1 100644 --- a/src/lib/replay-search.ts +++ b/src/lib/replay-search.ts @@ -1,4 +1,9 @@ -import type { ReplayDetails, ReplayListItem } from "../types/index.js"; +import type { + ReplayDetails, + ReplayListItem, + SentryEvent, +} from "../types/index.js"; +import { tryNormalizeHexId } from "./hex-id.js"; type ReplayLike = ReplayListItem | ReplayDetails; type ReplayFieldResolver = (replay: ReplayLike) => unknown; @@ -213,3 +218,63 @@ export function getReplayFieldValue( } return resolver(replay); } + +// --------------------------------------------------------------------------- +// Replay ID extraction from events +// --------------------------------------------------------------------------- + +/** + * Extract the replay ID from the event's `contexts.replay` object. + */ +function getReplayIdFromReplayContext( + event: Pick +): string | undefined { + const replayContext = event.contexts?.replay; + return typeof replayContext?.replay_id === "string" + ? replayContext.replay_id + : undefined; +} + +/** + * Extract the best replay ID from an event's known replay linkage fields. + * + * Checks both event tags (`replayId`, `replay.id`) and the replay context. + * Returns the first valid, normalized replay ID found. + */ +export function getReplayIdFromEvent( + event: Pick +): string | undefined { + const tagReplayId = event.tags?.find( + (tag) => tag.key === "replayId" || tag.key === "replay.id" + )?.value; + + return collectReplayIds([ + tagReplayId, + getReplayIdFromReplayContext(event), + ])[0]; +} + +/** + * Normalize and deduplicate replay IDs while preserving first-seen order. + * + * Each value is passed through {@link tryNormalizeHexId} — invalid or + * duplicate IDs are silently dropped. + */ +export function collectReplayIds( + values: Iterable +): string[] { + const seen = new Set(); + const replayIds: string[] = []; + + for (const value of values) { + const replayId = tryNormalizeHexId(value); + if (!replayId || seen.has(replayId)) { + continue; + } + + seen.add(replayId); + replayIds.push(replayId); + } + + return replayIds; +} diff --git a/src/lib/sentry-url-parser.ts b/src/lib/sentry-url-parser.ts index cb5ed62de..e6dc259c8 100644 --- a/src/lib/sentry-url-parser.ts +++ b/src/lib/sentry-url-parser.ts @@ -11,7 +11,7 @@ import { DEFAULT_SENTRY_HOST } from "./constants.js"; import { getEnv } from "./env.js"; import { HostScopeError } from "./errors.js"; -import { normalizeReplayId } from "./replay-id.js"; +import { tryNormalizeHexId } from "./hex-id.js"; import { isSaaSTrustOrigin } from "./sentry-urls.js"; import { getActiveTokenHost, isHostTrusted } from "./token-host.js"; @@ -188,7 +188,7 @@ function matchReplayPath( return { status: "list" }; } - const normalizedReplayId = normalizeReplayId(replayId); + const normalizedReplayId = tryNormalizeHexId(replayId); if (!normalizedReplayId) { return { status: "invalid" }; } diff --git a/test/lib/replay-duration.test.ts b/test/lib/replay-duration.test.ts index a29b78587..cf1087f4f 100644 --- a/test/lib/replay-duration.test.ts +++ b/test/lib/replay-duration.test.ts @@ -1,38 +1,53 @@ import { describe, expect, test } from "bun:test"; import { - formatReplayDurationCompact, - formatReplayDurationVerbose, -} from "../../src/lib/replay-duration.js"; + formatDurationCompact, + formatDurationCompactMs, + formatDurationVerbose, +} from "../../src/lib/formatters/time-utils.js"; -describe("formatReplayDurationCompact", () => { +describe("formatDurationCompact", () => { test("formats short durations", () => { - expect(formatReplayDurationCompact(59)).toBe("59s"); - expect(formatReplayDurationCompact(60)).toBe("1m"); - expect(formatReplayDurationCompact(125)).toBe("2m 5s"); + expect(formatDurationCompact(59)).toBe("59s"); + expect(formatDurationCompact(60)).toBe("1m"); + expect(formatDurationCompact(125)).toBe("2m 5s"); }); test("formats long durations", () => { - expect(formatReplayDurationCompact(3600)).toBe("1h"); - expect(formatReplayDurationCompact(3665)).toBe("1h 1m"); - expect(formatReplayDurationCompact(90_061)).toBe("1d 1h"); + expect(formatDurationCompact(3600)).toBe("1h"); + expect(formatDurationCompact(3665)).toBe("1h 1m"); + expect(formatDurationCompact(90_061)).toBe("1d 1h"); }); test("handles missing durations", () => { - expect(formatReplayDurationCompact(null)).toBe("—"); - expect(formatReplayDurationCompact(undefined)).toBe("—"); + expect(formatDurationCompact(null)).toBe("—"); + expect(formatDurationCompact(undefined)).toBe("—"); }); }); -describe("formatReplayDurationVerbose", () => { +describe("formatDurationCompactMs", () => { + test("converts ms to seconds and formats compactly", () => { + expect(formatDurationCompactMs(5000)).toBe("5s"); + expect(formatDurationCompactMs(125_000)).toBe("2m 5s"); + expect(formatDurationCompactMs(3_665_000)).toBe("1h 1m"); + }); + + test("handles sub-second durations", () => { + expect(formatDurationCompactMs(0)).toBe("0s"); + expect(formatDurationCompactMs(499)).toBe("0s"); + expect(formatDurationCompactMs(500)).toBe("1s"); + }); +}); + +describe("formatDurationVerbose", () => { test("formats short durations", () => { - expect(formatReplayDurationVerbose(1)).toBe("1 second"); - expect(formatReplayDurationVerbose(125)).toBe("2 minutes and 5 seconds"); + expect(formatDurationVerbose(1)).toBe("1 second"); + expect(formatDurationVerbose(125)).toBe("2 minutes and 5 seconds"); }); test("formats long durations", () => { - expect(formatReplayDurationVerbose(3600)).toBe("1 hour"); - expect(formatReplayDurationVerbose(3665)).toBe("1 hour and 1 minute"); - expect(formatReplayDurationVerbose(90_061)).toBe("1 day and 1 hour"); + expect(formatDurationVerbose(3600)).toBe("1 hour"); + expect(formatDurationVerbose(3665)).toBe("1 hour and 1 minute"); + expect(formatDurationVerbose(90_061)).toBe("1 day and 1 hour"); }); }); From d598cb599eb0f57286bbfc4efb3f2ead7d106136 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Sat, 2 May 2026 01:14:39 +0000 Subject: [PATCH 23/26] ref(replay): extract formatting helpers and address review findings - Extract replay formatting logic from replay/view.ts (950 lines) into a dedicated src/lib/formatters/replay.ts module, bringing view.ts down to ~425 lines focused on argument parsing and API orchestration. - Replace this.stderr.write() with structured logger (consola) per repo conventions. Add diagnostic logging to silent catch blocks. - Document normalizeReplayProjectId coercion and empty-string project slug in org-wide issue search. - Add explanatory comment for parseSingleArg's org/hex-id special case. --- src/commands/replay/view.ts | 559 ++--------------------------------- src/lib/api/replays.ts | 6 + src/lib/formatters/replay.ts | 547 ++++++++++++++++++++++++++++++++++ 3 files changed, 579 insertions(+), 533 deletions(-) create mode 100644 src/lib/formatters/replay.ts diff --git a/src/commands/replay/view.ts b/src/commands/replay/view.ts index eeea2429e..90db3b037 100644 --- a/src/commands/replay/view.ts +++ b/src/commands/replay/view.ts @@ -24,25 +24,21 @@ import { ResolutionError, ValidationError, } from "../../lib/errors.js"; -import { - escapeMarkdownCell, - escapeMarkdownInline, - mdKvTable, - renderMarkdown, -} from "../../lib/formatters/index.js"; import { filterFields } from "../../lib/formatters/json.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { - formatDurationCompactMs, - formatDurationVerbose, -} from "../../lib/formatters/time-utils.js"; + extractReplayActivityEvents, + formatReplayDetails, + type ReplayViewData, + replayHint, +} from "../../lib/formatters/replay.js"; import { tryNormalizeHexId, validateHexId } from "../../lib/hex-id.js"; import { applyFreshFlag, FRESH_ALIASES, FRESH_FLAG, } from "../../lib/list-command.js"; -import { getReplayUserLabel } from "../../lib/replay-search.js"; +import { logger } from "../../lib/logger.js"; import { resolveOrgOptionalProjectFromArg } from "../../lib/resolve-target.js"; import { applySentryUrlContext, @@ -52,7 +48,6 @@ import { buildReplayUrl } from "../../lib/sentry-urls.js"; import type { ReplayActivityEvent, ReplayDetails, - ReplayRecordingSegments, ReplayRelatedIssue, ReplayRelatedTrace, } from "../../types/index.js"; @@ -71,28 +66,24 @@ type ParsedPositionalArgs = { warning?: string; }; -type ReplayViewData = { - org: string; - replay: ReplayDetails; - activity: ReplayActivityEvent[]; - relatedIssues: ReplayRelatedIssue[]; - relatedTraces: ReplayRelatedTrace[]; -}; - -type MarkdownRow = [string, string]; - const USAGE_HINT = "sentry replay view [//] | "; const MAX_ACTIVITY_EVENTS = 6; const MAX_RELATED_ERRORS = 3; const MAX_RELATED_TRACES = 2; +const log = logger.withTag("replay.view"); + function parseSingleArg(arg: string): ParsedPositionalArgs { const trimmed = arg.trim(); if (!trimmed) { throw new ContextError("Replay ID", USAGE_HINT, []); } + // Handle / shorthand — must check before parseSlashSeparatedArg + // because replay IDs are 32-char hex strings that look valid to the generic + // slash parser's ID extraction, but with only one slash the "project" segment + // would be wrongly treated as the ID. const slashIdx = trimmed.indexOf("/"); if (slashIdx !== -1 && trimmed.indexOf("/", slashIdx + 1) === -1) { const org = trimmed.slice(0, slashIdx); @@ -173,160 +164,6 @@ export function parsePositionalArgs(args: string[]): ParsedPositionalArgs { return { replayId: second, targetArg: first }; } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -function getEventTimestampMillis(value: unknown): number | null { - if (typeof value === "number") { - return value; - } - if (typeof value === "string") { - const parsed = Date.parse(value); - return Number.isNaN(parsed) ? null : parsed; - } - return null; -} - -function firstString(value: unknown): string | null { - return typeof value === "string" && value.length > 0 ? value : null; -} - -function compactDetails(values: Array): string[] { - return values.filter((value): value is string => value !== null); -} - -function summarizePerformanceSpan( - payload: Record | null -): Omit | null { - const op = firstString(payload?.op); - const description = firstString(payload?.description); - const durationMs = - isRecord(payload?.data) && typeof payload.data.duration === "number" - ? payload.data.duration - : null; - - if (!(description || op)) { - return null; - } - - return { - label: op ?? "performanceSpan", - details: compactDetails([ - description ? `description=${description}` : null, - durationMs !== null ? `duration_ms=${durationMs}` : null, - ]), - }; -} - -function summarizeClickLikeEvent( - label: string, - payload: Record | null, - includeLabel = false -): Omit { - const selector = firstString(payload?.selector); - const clickLabel = firstString(payload?.label); - - return { - label, - details: compactDetails([ - selector ? `selector=${selector}` : null, - includeLabel && clickLabel ? `label=${clickLabel}` : null, - ]), - }; -} - -function summarizeBreadcrumb( - payload: Record | null -): Omit | null { - const category = firstString(payload?.category); - const message = firstString(payload?.message); - if (!(category || message)) { - return null; - } - - return { - label: category ?? "breadcrumb", - details: compactDetails([message ? `message=${message}` : null]), - }; -} - -const TAGGED_REPLAY_EVENT_SUMMARIZERS: Record< - string, - ( - payload: Record | null - ) => Omit | null -> = { - breadcrumb: summarizeBreadcrumb, - click: (payload: Record | null) => - summarizeClickLikeEvent("click", payload, true), - deadClick: (payload: Record | null) => - summarizeClickLikeEvent("dead.click", payload), - performanceSpan: summarizePerformanceSpan, - rageClick: (payload: Record | null) => - summarizeClickLikeEvent("rage.click", payload), -}; - -function summarizeTaggedReplayEvent( - tag: string, - payload: Record | null -): Omit | null { - const summarize = TAGGED_REPLAY_EVENT_SUMMARIZERS[tag]; - return summarize ? summarize(payload) : null; -} - -function summarizeReplayEvent(event: unknown): ReplayActivityEvent | null { - if (!isRecord(event)) { - return null; - } - - const timestampMs = getEventTimestampMillis(event.timestamp); - const data = isRecord(event.data) ? event.data : null; - const tag = typeof data?.tag === "string" ? data.tag : ""; - const payload = isRecord(data?.payload) ? data.payload : null; - - if (tag) { - const replayEvent = summarizeTaggedReplayEvent(tag, payload); - if (replayEvent) { - return { timestampMs, ...replayEvent }; - } - } - - const href = firstString(data?.href); - if (href) { - return { - timestampMs, - label: "page.view", - details: [`href=${href}`], - }; - } - - return null; -} - -function extractReplayActivityEvents( - segments: ReplayRecordingSegments | null -): ReplayActivityEvent[] { - if (!segments) { - return []; - } - - const events: ReplayActivityEvent[] = []; - for (const segment of segments) { - for (const event of segment) { - const replayEvent = summarizeReplayEvent(event); - if (replayEvent) { - events.push(replayEvent); - } - if (events.length >= MAX_ACTIVITY_EVENTS) { - return events; - } - } - } - - return events; -} - type ReplayProjectScope = { org: string; project?: string; @@ -389,12 +226,19 @@ async function fetchReplayActivity( String(replay.project_id), replay.id ); - return extractReplayActivityEvents(segments); - } catch { + return extractReplayActivityEvents(segments, MAX_ACTIVITY_EVENTS); + } catch (error) { + log.debug("Failed to fetch replay recording segments", error); return []; } } +/** + * Fetch related issue metadata for replay-linked error event IDs. + * + * Uses org-wide issue search (`project = ""`) because replays may span + * multiple projects within the same organization. + */ function fetchRelatedReplayIssues( org: string, replay: ReplayDetails @@ -415,7 +259,8 @@ function fetchRelatedReplayIssues( shortId: issue?.shortId ?? null, title: issue?.title ?? null, }; - } catch { + } catch (error) { + log.debug(`Failed to resolve issue for event ${eventId}`, error); return { eventId, issueId: null, shortId: null, title: null }; } }) @@ -439,7 +284,8 @@ function fetchRelatedReplayTraces( performanceIssueCount: meta.performance_issues, spanCount: meta.span_count, }; - } catch { + } catch (error) { + log.debug(`Failed to fetch trace meta for ${traceId}`, error); return { traceId, errorCount: null, @@ -467,359 +313,6 @@ async function enrichReplayView( return { activity, relatedIssues, relatedTraces }; } -function formatList(values: string[] | undefined): string | undefined { - if (!values || values.length === 0) { - return; - } - return values.map((value) => `- \`${value}\``).join("\n"); -} - -function pushMarkdownRow( - rows: MarkdownRow[], - label: string, - value: string | undefined -): void { - if (!value) { - return; - } - rows.push([label, value]); -} - -function formatYesNo(value: boolean | null | undefined): string | undefined { - if (value === null || value === undefined) { - return; - } - return value ? "Yes" : "No"; -} - -function formatNullableCount( - value: number | null | undefined -): string | undefined { - if (value === null || value === undefined) { - return; - } - return String(value); -} - -function formatJoinedMarkdown( - values: Array -): string | undefined { - const joined = values.filter(Boolean).join(" "); - return joined ? escapeMarkdownCell(joined) : undefined; -} - -function formatReplayLocation(replay: ReplayDetails): string | undefined { - const geo = replay.user?.geo; - if (!geo) { - return; - } - - const location = [geo.city, geo.region, geo.country_code] - .filter(Boolean) - .join(", "); - return location ? escapeMarkdownCell(location) : undefined; -} - -function buildReplayOverviewRows( - org: string, - replay: ReplayDetails -): MarkdownRow[] { - const rows: MarkdownRow[] = [["Replay ID", `\`${replay.id}\``]]; - pushMarkdownRow(rows, "Link", buildReplayUrl(org, replay.id)); - - pushMarkdownRow( - rows, - "Started", - replay.started_at ? new Date(replay.started_at).toLocaleString() : undefined - ); - pushMarkdownRow( - rows, - "Finished", - replay.finished_at - ? new Date(replay.finished_at).toLocaleString() - : undefined - ); - pushMarkdownRow( - rows, - "Duration", - replay.duration !== null && replay.duration !== undefined - ? formatDurationVerbose(replay.duration) - : undefined - ); - pushMarkdownRow( - rows, - "Environment", - replay.environment ? escapeMarkdownCell(replay.environment) : undefined - ); - pushMarkdownRow( - rows, - "Platform", - replay.platform ? escapeMarkdownCell(replay.platform) : undefined - ); - pushMarkdownRow( - rows, - "Project ID", - replay.project_id !== null && replay.project_id !== undefined - ? String(replay.project_id) - : undefined - ); - pushMarkdownRow( - rows, - "Replay Type", - replay.replay_type ? escapeMarkdownCell(replay.replay_type) : undefined - ); - pushMarkdownRow(rows, "Archived", formatYesNo(replay.is_archived)); - pushMarkdownRow(rows, "Viewed", formatYesNo(replay.has_viewed)); - pushMarkdownRow(rows, "Errors", formatNullableCount(replay.count_errors)); - pushMarkdownRow(rows, "Segments", formatNullableCount(replay.count_segments)); - pushMarkdownRow( - rows, - "Rage Clicks", - formatNullableCount(replay.count_rage_clicks) - ); - pushMarkdownRow( - rows, - "Dead Clicks", - formatNullableCount(replay.count_dead_clicks) - ); - - return rows; -} - -function buildReplayUserRows(replay: ReplayDetails): MarkdownRow[] { - const rows: MarkdownRow[] = []; - const userLabel = getReplayUserLabel(replay); - pushMarkdownRow( - rows, - "User", - userLabel ? escapeMarkdownCell(userLabel) : undefined - ); - pushMarkdownRow( - rows, - "Email", - replay.user?.email ? escapeMarkdownCell(replay.user.email) : undefined - ); - pushMarkdownRow( - rows, - "IP", - replay.user?.ip ? escapeMarkdownCell(replay.user.ip) : undefined - ); - pushMarkdownRow(rows, "Location", formatReplayLocation(replay)); - return rows; -} - -function buildReplayClientRows(replay: ReplayDetails): MarkdownRow[] { - const rows: MarkdownRow[] = []; - pushMarkdownRow( - rows, - "Browser", - formatJoinedMarkdown([replay.browser?.name, replay.browser?.version]) - ); - pushMarkdownRow( - rows, - "OS", - formatJoinedMarkdown([replay.os?.name, replay.os?.version]) - ); - pushMarkdownRow( - rows, - "Device", - formatJoinedMarkdown([ - replay.device?.brand, - replay.device?.family, - replay.device?.name, - replay.device?.model_id, - ]) - ); - pushMarkdownRow( - rows, - "SDK", - formatJoinedMarkdown([replay.sdk?.name, replay.sdk?.version]) - ); - pushMarkdownRow( - rows, - "Dist", - replay.dist ? escapeMarkdownCell(replay.dist) : undefined - ); - return rows; -} - -function pushKvSection( - lines: string[], - rows: MarkdownRow[], - title?: string -): void { - if (rows.length === 0) { - return; - } - lines.push(""); - lines.push(mdKvTable(rows, title)); -} - -function pushListSection( - lines: string[], - title: string, - values: string[] | undefined -): void { - const content = formatList(values); - if (!content) { - return; - } - lines.push(""); - lines.push(`### ${title}`); - lines.push(""); - lines.push(content); -} - -function pushTagsSection(lines: string[], replay: ReplayDetails): void { - if (Object.keys(replay.tags).length === 0) { - return; - } - - lines.push(""); - lines.push("### Tags"); - lines.push(""); - for (const [key, values] of Object.entries(replay.tags).sort(([a], [b]) => - a.localeCompare(b) - )) { - lines.push( - `- \`${escapeMarkdownInline(key)}\`: ${values.map((value) => `\`${escapeMarkdownInline(value)}\``).join(", ")}` - ); - } -} - -function pushActivitySection( - lines: string[], - replay: ReplayDetails, - activity: ReplayActivityEvent[] -): void { - lines.push(""); - lines.push("### Activity"); - lines.push(""); - - if (replay.is_archived) { - lines.push("Recording is archived and not available for playback."); - return; - } - - if (activity.length === 0) { - lines.push("No activity events recorded."); - return; - } - - const startTime = - getEventTimestampMillis(replay.started_at) ?? - activity[0]?.timestampMs ?? - null; - for (const event of activity) { - const prefix = - event.timestampMs !== null && startTime !== null - ? `${formatDurationCompactMs(event.timestampMs - startTime)} · ` - : ""; - const details = - event.details.length > 0 - ? ` · ${event.details.map((detail) => escapeMarkdownInline(detail)).join(" · ")}` - : ""; - lines.push(`- ${prefix}\`${escapeMarkdownInline(event.label)}\`${details}`); - } -} - -function formatRelatedIssueLine( - org: string, - issue: ReplayRelatedIssue -): string { - if (!(issue.shortId && issue.title)) { - return `- Event \`${issue.eventId}\``; - } - - return `- \`${issue.shortId}\`: ${escapeMarkdownInline(issue.title)} (view: \`sentry issue view ${org}/${issue.shortId}\`)`; -} - -function buildRelatedTraceStats(trace: ReplayRelatedTrace): string[] { - return [ - trace.spanCount !== null && trace.spanCount !== undefined - ? `${trace.spanCount} spans` - : null, - trace.errorCount !== null && trace.errorCount !== undefined - ? `${trace.errorCount} errors` - : null, - trace.logCount !== null && trace.logCount !== undefined - ? `${trace.logCount} logs` - : null, - trace.performanceIssueCount !== null && - trace.performanceIssueCount !== undefined - ? `${trace.performanceIssueCount} perf issues` - : null, - ].filter((value): value is string => value !== null); -} - -function formatRelatedTraceLine( - org: string, - trace: ReplayRelatedTrace -): string { - const stats = buildRelatedTraceStats(trace); - const suffix = stats.length > 0 ? ` (${stats.join(", ")})` : ""; - return `- Trace \`${trace.traceId}\`${suffix} (view: \`sentry trace view ${org}/${trace.traceId}\`)`; -} - -function pushRelatedSection( - lines: string[], - org: string, - relatedIssues: ReplayRelatedIssue[], - relatedTraces: ReplayRelatedTrace[] -): void { - if (relatedIssues.length === 0 && relatedTraces.length === 0) { - return; - } - - lines.push(""); - lines.push("### Related"); - lines.push(""); - - for (const issue of relatedIssues) { - lines.push(formatRelatedIssueLine(org, issue)); - } - - for (const trace of relatedTraces) { - lines.push(formatRelatedTraceLine(org, trace)); - } -} - -function formatReplayDetails(data: ReplayViewData): string { - const { activity, org, relatedIssues, relatedTraces, replay } = data; - const lines: string[] = []; - - lines.push(`## Replay \`${replay.id.slice(0, 8)}\``); - lines.push(""); - lines.push(mdKvTable(buildReplayOverviewRows(org, replay))); - - pushKvSection(lines, buildReplayUserRows(replay), "User"); - pushKvSection(lines, buildReplayClientRows(replay), "Client"); - - pushListSection(lines, "Releases", replay.releases); - pushListSection(lines, "URLs", replay.urls); - pushListSection(lines, "Trace IDs", replay.trace_ids); - pushListSection(lines, "Error IDs", replay.error_ids); - pushActivitySection(lines, replay, activity); - pushRelatedSection(lines, org, relatedIssues, relatedTraces); - pushTagsSection(lines, replay); - - return renderMarkdown(lines.join("\n")); -} - -function replayHint(data: ReplayViewData): string | undefined { - const traceId = data.replay.trace_ids?.[0]; - if (traceId) { - return `Related trace: sentry trace view ${data.org}/${traceId}`; - } - - const issue = data.relatedIssues[0]; - if (issue?.shortId) { - return `Related issue: sentry issue view ${data.org}/${issue.shortId}`; - } - - return; -} - export const viewCommand = buildCommand({ docs: { brief: "View a Session Replay", @@ -878,7 +371,7 @@ export const viewCommand = buildCommand({ const parsedArgs = parsePositionalArgs(args); if (parsedArgs.warning) { - this.stderr.write(`${parsedArgs.warning}\n`); + log.warn(parsedArgs.warning); } const replayId = validateHexId(parsedArgs.replayId, "replay ID"); diff --git a/src/lib/api/replays.ts b/src/lib/api/replays.ts index 4b5439ff7..fbf5dfcdb 100644 --- a/src/lib/api/replays.ts +++ b/src/lib/api/replays.ts @@ -104,6 +104,12 @@ type FetchReplayPageOptions = { cursor?: string; }; +/** + * Coerce numeric project_id to string for consistent downstream handling. + * + * The replay API returns project_id as a number but the CLI and output + * schemas normalize it to a string for uniform comparisons and display. + */ function normalizeReplayProjectId< T extends { project_id?: string | number | null }, >(replay: T): T { diff --git a/src/lib/formatters/replay.ts b/src/lib/formatters/replay.ts new file mode 100644 index 000000000..6785b126e --- /dev/null +++ b/src/lib/formatters/replay.ts @@ -0,0 +1,547 @@ +/** + * Replay formatting helpers + * + * Human-readable formatting for Session Replay data in the CLI. + */ + +import type { + ReplayActivityEvent, + ReplayDetails, + ReplayRecordingSegments, + ReplayRelatedIssue, + ReplayRelatedTrace, +} from "../../types/index.js"; +import { getReplayUserLabel } from "../replay-search.js"; +import { buildReplayUrl } from "../sentry-urls.js"; +import { + escapeMarkdownCell, + escapeMarkdownInline, + mdKvTable, + renderMarkdown, +} from "./markdown.js"; +import { + formatDurationCompactMs, + formatDurationVerbose, +} from "./time-utils.js"; + +/** Data bag assembled by replay view before rendering. */ +export type ReplayViewData = { + org: string; + replay: ReplayDetails; + activity: ReplayActivityEvent[]; + relatedIssues: ReplayRelatedIssue[]; + relatedTraces: ReplayRelatedTrace[]; +}; + +type MarkdownRow = [string, string]; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function getEventTimestampMillis(value: unknown): number | null { + if (typeof value === "number") { + return value; + } + if (typeof value === "string") { + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? null : parsed; + } + return null; +} + +function firstString(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null; +} + +function compactDetails(values: Array): string[] { + return values.filter((value): value is string => value !== null); +} + +function summarizePerformanceSpan( + payload: Record | null +): Omit | null { + const op = firstString(payload?.op); + const description = firstString(payload?.description); + const durationMs = + isRecord(payload?.data) && typeof payload.data.duration === "number" + ? payload.data.duration + : null; + + if (!(description || op)) { + return null; + } + + return { + label: op ?? "performanceSpan", + details: compactDetails([ + description ? `description=${description}` : null, + durationMs !== null ? `duration_ms=${durationMs}` : null, + ]), + }; +} + +function summarizeClickLikeEvent( + label: string, + payload: Record | null, + includeLabel = false +): Omit { + const selector = firstString(payload?.selector); + const clickLabel = firstString(payload?.label); + + return { + label, + details: compactDetails([ + selector ? `selector=${selector}` : null, + includeLabel && clickLabel ? `label=${clickLabel}` : null, + ]), + }; +} + +function summarizeBreadcrumb( + payload: Record | null +): Omit | null { + const category = firstString(payload?.category); + const message = firstString(payload?.message); + if (!(category || message)) { + return null; + } + + return { + label: category ?? "breadcrumb", + details: compactDetails([message ? `message=${message}` : null]), + }; +} + +const TAGGED_REPLAY_EVENT_SUMMARIZERS: Record< + string, + ( + payload: Record | null + ) => Omit | null +> = { + breadcrumb: summarizeBreadcrumb, + click: (payload: Record | null) => + summarizeClickLikeEvent("click", payload, true), + deadClick: (payload: Record | null) => + summarizeClickLikeEvent("dead.click", payload), + performanceSpan: summarizePerformanceSpan, + rageClick: (payload: Record | null) => + summarizeClickLikeEvent("rage.click", payload), +}; + +function summarizeTaggedReplayEvent( + tag: string, + payload: Record | null +): Omit | null { + const summarize = TAGGED_REPLAY_EVENT_SUMMARIZERS[tag]; + return summarize ? summarize(payload) : null; +} + +function summarizeReplayEvent(event: unknown): ReplayActivityEvent | null { + if (!isRecord(event)) { + return null; + } + + const timestampMs = getEventTimestampMillis(event.timestamp); + const data = isRecord(event.data) ? event.data : null; + const tag = typeof data?.tag === "string" ? data.tag : ""; + const payload = isRecord(data?.payload) ? data.payload : null; + + if (tag) { + const replayEvent = summarizeTaggedReplayEvent(tag, payload); + if (replayEvent) { + return { timestampMs, ...replayEvent }; + } + } + + const href = firstString(data?.href); + if (href) { + return { + timestampMs, + label: "page.view", + details: [`href=${href}`], + }; + } + + return null; +} + +/** Extract a capped list of activity events from replay recording segments. */ +export function extractReplayActivityEvents( + segments: ReplayRecordingSegments | null, + maxEvents: number +): ReplayActivityEvent[] { + if (!segments) { + return []; + } + + const events: ReplayActivityEvent[] = []; + for (const segment of segments) { + for (const event of segment) { + const replayEvent = summarizeReplayEvent(event); + if (replayEvent) { + events.push(replayEvent); + } + if (events.length >= maxEvents) { + return events; + } + } + } + + return events; +} + +function formatList(values: string[] | undefined): string | undefined { + if (!values || values.length === 0) { + return; + } + return values.map((value) => `- \`${value}\``).join("\n"); +} + +function pushMarkdownRow( + rows: MarkdownRow[], + label: string, + value: string | undefined +): void { + if (!value) { + return; + } + rows.push([label, value]); +} + +function formatYesNo(value: boolean | null | undefined): string | undefined { + if (value === null || value === undefined) { + return; + } + return value ? "Yes" : "No"; +} + +function formatNullableCount( + value: number | null | undefined +): string | undefined { + if (value === null || value === undefined) { + return; + } + return String(value); +} + +function formatJoinedMarkdown( + values: Array +): string | undefined { + const joined = values.filter(Boolean).join(" "); + return joined ? escapeMarkdownCell(joined) : undefined; +} + +function formatReplayLocation(replay: ReplayDetails): string | undefined { + const geo = replay.user?.geo; + if (!geo) { + return; + } + + const location = [geo.city, geo.region, geo.country_code] + .filter(Boolean) + .join(", "); + return location ? escapeMarkdownCell(location) : undefined; +} + +function buildReplayOverviewRows( + org: string, + replay: ReplayDetails +): MarkdownRow[] { + const rows: MarkdownRow[] = [["Replay ID", `\`${replay.id}\``]]; + pushMarkdownRow(rows, "Link", buildReplayUrl(org, replay.id)); + + pushMarkdownRow( + rows, + "Started", + replay.started_at ? new Date(replay.started_at).toLocaleString() : undefined + ); + pushMarkdownRow( + rows, + "Finished", + replay.finished_at + ? new Date(replay.finished_at).toLocaleString() + : undefined + ); + pushMarkdownRow( + rows, + "Duration", + replay.duration !== null && replay.duration !== undefined + ? formatDurationVerbose(replay.duration) + : undefined + ); + pushMarkdownRow( + rows, + "Environment", + replay.environment ? escapeMarkdownCell(replay.environment) : undefined + ); + pushMarkdownRow( + rows, + "Platform", + replay.platform ? escapeMarkdownCell(replay.platform) : undefined + ); + pushMarkdownRow( + rows, + "Project ID", + replay.project_id !== null && replay.project_id !== undefined + ? String(replay.project_id) + : undefined + ); + pushMarkdownRow( + rows, + "Replay Type", + replay.replay_type ? escapeMarkdownCell(replay.replay_type) : undefined + ); + pushMarkdownRow(rows, "Archived", formatYesNo(replay.is_archived)); + pushMarkdownRow(rows, "Viewed", formatYesNo(replay.has_viewed)); + pushMarkdownRow(rows, "Errors", formatNullableCount(replay.count_errors)); + pushMarkdownRow(rows, "Segments", formatNullableCount(replay.count_segments)); + pushMarkdownRow( + rows, + "Rage Clicks", + formatNullableCount(replay.count_rage_clicks) + ); + pushMarkdownRow( + rows, + "Dead Clicks", + formatNullableCount(replay.count_dead_clicks) + ); + + return rows; +} + +function buildReplayUserRows(replay: ReplayDetails): MarkdownRow[] { + const rows: MarkdownRow[] = []; + const userLabel = getReplayUserLabel(replay); + pushMarkdownRow( + rows, + "User", + userLabel ? escapeMarkdownCell(userLabel) : undefined + ); + pushMarkdownRow( + rows, + "Email", + replay.user?.email ? escapeMarkdownCell(replay.user.email) : undefined + ); + pushMarkdownRow( + rows, + "IP", + replay.user?.ip ? escapeMarkdownCell(replay.user.ip) : undefined + ); + pushMarkdownRow(rows, "Location", formatReplayLocation(replay)); + return rows; +} + +function buildReplayClientRows(replay: ReplayDetails): MarkdownRow[] { + const rows: MarkdownRow[] = []; + pushMarkdownRow( + rows, + "Browser", + formatJoinedMarkdown([replay.browser?.name, replay.browser?.version]) + ); + pushMarkdownRow( + rows, + "OS", + formatJoinedMarkdown([replay.os?.name, replay.os?.version]) + ); + pushMarkdownRow( + rows, + "Device", + formatJoinedMarkdown([ + replay.device?.brand, + replay.device?.family, + replay.device?.name, + replay.device?.model_id, + ]) + ); + pushMarkdownRow( + rows, + "SDK", + formatJoinedMarkdown([replay.sdk?.name, replay.sdk?.version]) + ); + pushMarkdownRow( + rows, + "Dist", + replay.dist ? escapeMarkdownCell(replay.dist) : undefined + ); + return rows; +} + +function pushKvSection( + lines: string[], + rows: MarkdownRow[], + title?: string +): void { + if (rows.length === 0) { + return; + } + lines.push(""); + lines.push(mdKvTable(rows, title)); +} + +function pushListSection( + lines: string[], + title: string, + values: string[] | undefined +): void { + const content = formatList(values); + if (!content) { + return; + } + lines.push(""); + lines.push(`### ${title}`); + lines.push(""); + lines.push(content); +} + +function pushTagsSection(lines: string[], replay: ReplayDetails): void { + if (Object.keys(replay.tags).length === 0) { + return; + } + + lines.push(""); + lines.push("### Tags"); + lines.push(""); + for (const [key, values] of Object.entries(replay.tags).sort(([a], [b]) => + a.localeCompare(b) + )) { + lines.push( + `- \`${escapeMarkdownInline(key)}\`: ${values.map((value) => `\`${escapeMarkdownInline(value)}\``).join(", ")}` + ); + } +} + +function pushActivitySection( + lines: string[], + replay: ReplayDetails, + activity: ReplayActivityEvent[] +): void { + lines.push(""); + lines.push("### Activity"); + lines.push(""); + + if (replay.is_archived) { + lines.push("Recording is archived and not available for playback."); + return; + } + + if (activity.length === 0) { + lines.push("No activity events recorded."); + return; + } + + const startTime = + getEventTimestampMillis(replay.started_at) ?? + activity[0]?.timestampMs ?? + null; + for (const event of activity) { + const prefix = + event.timestampMs !== null && startTime !== null + ? `${formatDurationCompactMs(event.timestampMs - startTime)} · ` + : ""; + const details = + event.details.length > 0 + ? ` · ${event.details.map((detail) => escapeMarkdownInline(detail)).join(" · ")}` + : ""; + lines.push(`- ${prefix}\`${escapeMarkdownInline(event.label)}\`${details}`); + } +} + +function formatRelatedIssueLine( + org: string, + issue: ReplayRelatedIssue +): string { + if (!(issue.shortId && issue.title)) { + return `- Event \`${issue.eventId}\``; + } + + return `- \`${issue.shortId}\`: ${escapeMarkdownInline(issue.title)} (view: \`sentry issue view ${org}/${issue.shortId}\`)`; +} + +function buildRelatedTraceStats(trace: ReplayRelatedTrace): string[] { + return [ + trace.spanCount !== null && trace.spanCount !== undefined + ? `${trace.spanCount} spans` + : null, + trace.errorCount !== null && trace.errorCount !== undefined + ? `${trace.errorCount} errors` + : null, + trace.logCount !== null && trace.logCount !== undefined + ? `${trace.logCount} logs` + : null, + trace.performanceIssueCount !== null && + trace.performanceIssueCount !== undefined + ? `${trace.performanceIssueCount} perf issues` + : null, + ].filter((value): value is string => value !== null); +} + +function formatRelatedTraceLine( + org: string, + trace: ReplayRelatedTrace +): string { + const stats = buildRelatedTraceStats(trace); + const suffix = stats.length > 0 ? ` (${stats.join(", ")})` : ""; + return `- Trace \`${trace.traceId}\`${suffix} (view: \`sentry trace view ${org}/${trace.traceId}\`)`; +} + +function pushRelatedSection( + lines: string[], + org: string, + relatedIssues: ReplayRelatedIssue[], + relatedTraces: ReplayRelatedTrace[] +): void { + if (relatedIssues.length === 0 && relatedTraces.length === 0) { + return; + } + + lines.push(""); + lines.push("### Related"); + lines.push(""); + + for (const issue of relatedIssues) { + lines.push(formatRelatedIssueLine(org, issue)); + } + + for (const trace of relatedTraces) { + lines.push(formatRelatedTraceLine(org, trace)); + } +} + +/** Format replay details for human-readable output. */ +export function formatReplayDetails(data: ReplayViewData): string { + const { activity, org, relatedIssues, relatedTraces, replay } = data; + const lines: string[] = []; + + lines.push(`## Replay \`${replay.id.slice(0, 8)}\``); + lines.push(""); + lines.push(mdKvTable(buildReplayOverviewRows(org, replay))); + + pushKvSection(lines, buildReplayUserRows(replay), "User"); + pushKvSection(lines, buildReplayClientRows(replay), "Client"); + + pushListSection(lines, "Releases", replay.releases); + pushListSection(lines, "URLs", replay.urls); + pushListSection(lines, "Trace IDs", replay.trace_ids); + pushListSection(lines, "Error IDs", replay.error_ids); + pushActivitySection(lines, replay, activity); + pushRelatedSection(lines, org, relatedIssues, relatedTraces); + pushTagsSection(lines, replay); + + return renderMarkdown(lines.join("\n")); +} + +/** Build a contextual hint pointing the user to a related trace or issue. */ +export function replayHint(data: ReplayViewData): string | undefined { + const traceId = data.replay.trace_ids?.[0]; + if (traceId) { + return `Related trace: sentry trace view ${data.org}/${traceId}`; + } + + const issue = data.relatedIssues[0]; + if (issue?.shortId) { + return `Related issue: sentry issue view ${data.org}/${issue.shortId}`; + } + + return; +} From dea9eb4f53fda2bf54ccd68a66155d81f7469564 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Sat, 2 May 2026 01:26:40 +0000 Subject: [PATCH 24/26] fix(replay): address review bot findings - Fix explore replay query double-filtering: skip buildProjectQuery for replays dataset since the replay API uses projectSlugs param. - Merge consecutive JSDoc blocks in event/view.ts (tools only pick up the last block when two JSDoc comments are adjacent). --- src/commands/event/view.ts | 9 ++++++--- src/commands/explore.ts | 7 ++++++- test/commands/explore.test.ts | 6 +++--- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/commands/event/view.ts b/src/commands/event/view.ts index 9deef30f7..51c0b123d 100644 --- a/src/commands/event/view.ts +++ b/src/commands/event/view.ts @@ -434,8 +434,9 @@ type ResolveTargetOptions = { * * Handles all target types (explicit, search, org-all, auto-detect) * including cross-project fallback via the eventids endpoint. + * + * @internal Exported for testing */ -/** @internal Exported for testing */ export async function resolveEventTarget( options: ResolveTargetOptions ): Promise { @@ -487,8 +488,9 @@ export async function resolveEventTarget( * Throws a ContextError if the event is not found in the given org, with a * message that names the org so the error is not misleading. * Propagates auth/network errors from resolveEventInOrg. + * + * @internal Exported for testing */ -/** @internal Exported for testing */ export async function resolveOrgAllTarget( org: string, eventId: string, @@ -514,8 +516,9 @@ export async function resolveOrgAllTarget( /** * Resolve target via auto-detect cascade, falling back to cross-project * event search across all accessible orgs. + * + * @internal Exported for testing */ -/** @internal Exported for testing */ export async function resolveAutoDetectTarget( eventId: string, cwd: string diff --git a/src/commands/explore.ts b/src/commands/explore.ts index 1cec2b9b2..a287ce5aa 100644 --- a/src/commands/explore.ts +++ b/src/commands/explore.ts @@ -555,7 +555,12 @@ export const exploreCommand = buildListCommand("explore", { // When a project is in the target, prepend `project:` to the query // so the API filters server-side. Mirrors `trace logs` / `log list` behavior. - const apiQuery = buildProjectQuery(flags.query, project); + // Replays use the `projectSlugs` API param for project filtering instead of + // the `project:` query prefix — skip `buildProjectQuery` for that dataset. + const apiQuery = + dataset === "replays" + ? flags.query + : buildProjectQuery(flags.query, project); // Pagination context includes project so different scopes don't share state const contextKey = buildPaginationContextKey( diff --git a/test/commands/explore.test.ts b/test/commands/explore.test.ts index 14b0c8422..ff3922486 100644 --- a/test/commands/explore.test.ts +++ b/test/commands/explore.test.ts @@ -347,14 +347,14 @@ describe("sentry explore", () => { fields: ["id", "user", "count_errors", "urls"], limit: 25, projectSlugs: ["cli"], - query: "project:cli", + query: undefined, sort: "-count_errors", statsPeriod: "24h", }); expect(queryEventsSpy).not.toHaveBeenCalled(); }); - test("merges replay query text with the target project filter", async () => { + test("passes replay query text without project: prefix (uses projectSlugs instead)", async () => { resolveTargetSpy.mockResolvedValue({ org: "test-org", project: "cli" }); const { context } = createContext(); @@ -372,7 +372,7 @@ describe("sentry explore", () => { "test-org", expect.objectContaining({ projectSlugs: ["cli"], - query: "project:cli count_errors:>0", + query: "count_errors:>0", }) ); }); From d505e544ede2905f3054359031ebc1edda27b424 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Sat, 2 May 2026 01:37:15 +0000 Subject: [PATCH 25/26] ref(explore): centralize replay dataset branching into resolveDatasetConfig MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace 7 scattered `dataset === "replays"` branches in the explore command's func body with a single resolveDatasetConfig() function that returns a DatasetConfig with sort, query, and fetch properties. The func body now reads linearly: resolve target → build fields → resolve dataset config → paginate → fetch → yield output. All replay- specific logic (field validation, sort resolution, query building, API dispatch) is centralized in one place. --- src/commands/explore.ts | 220 +++++++++++++++++++++++----------------- 1 file changed, 126 insertions(+), 94 deletions(-) diff --git a/src/commands/explore.ts b/src/commands/explore.ts index a287ce5aa..f821ffca9 100644 --- a/src/commands/explore.ts +++ b/src/commands/explore.ts @@ -11,7 +11,6 @@ import { isReplaySortValue, listReplays, queryEvents, - type ReplaySortValue, } from "../lib/api-client.js"; import { buildProjectQuery, validateLimit } from "../lib/arg-parsing.js"; import { @@ -356,40 +355,129 @@ function findFirstAggregate(fieldList: string[]): string | undefined { return fieldList.find((f) => f.includes("(") && f.includes(")")); } -/** Validate and normalize replay sort values. */ -function resolveReplaySort(explicitSort?: string): ReplaySortValue { - const sort = explicitSort ?? DEFAULT_REPLAY_SORT; - if (!isReplaySortValue(sort)) { - throw new ValidationError( - `Invalid replay sort "${sort}". Use a replay sort like ${DEFAULT_REPLAY_SORT} or -count_errors.`, - "sort" - ); - } - return sort; -} +// --------------------------------------------------------------------------- +// Dataset configuration +// --------------------------------------------------------------------------- /** - * Determine the effective sort value for non-replay explore datasets. - * Sort is only supported on the `spans` dataset. + * Dataset-specific configuration resolved before the main query loop. + * + * Centralizes all replay vs. non-replay branching so the main `func` body + * reads linearly without `dataset === "replays"` checks. */ -function resolveExploreSort( - fieldList: string[], - dataset: string, - explicitSort?: string -): string | undefined { - const firstAgg = findFirstAggregate(fieldList); - const sort = explicitSort ?? (firstAgg ? `-${firstAgg}` : undefined); +type DatasetConfig = { + /** The effective sort value for pagination context and API calls. */ + sort: string | undefined; + /** The API query string (with or without `project:` prefix). */ + query: string | undefined; + /** Execute the dataset-specific API query. */ + fetch: (params: { + cursor: string | undefined; + limit: number; + timeRange: TimeRange; + }) => Promise<{ + data: { data: Record[]; meta?: ExploreData["meta"] }; + nextCursor?: string; + }>; +}; - if (dataset === "spans") { - return sort; +/** + * Resolve dataset-specific configuration: sort, query, validation, and fetch. + * + * For the `replays` dataset this validates fields, resolves replay-specific + * sort, and returns a fetch function that calls `listReplays`. For all other + * datasets it validates environment usage, resolves explore sort (spans-only), + * prepends `project:` to the query, and returns a `queryEvents` fetch. + */ +function resolveDatasetConfig(params: { + dataset: string; + fieldList: string[]; + flags: ExploreFlags; + org: string; + project: string | undefined; + environment: string[] | undefined; +}): DatasetConfig { + const { dataset, fieldList, flags, org, project, environment } = params; + + if (dataset === "replays") { + const unsupportedField = fieldList.find( + (field) => !isSupportedReplayField(field) + ); + if (unsupportedField) { + throw new ValidationError( + `Unsupported replay field "${unsupportedField}". Supported fields include: ${listSupportedReplayFields().slice(0, 12).join(", ")}...`, + "field" + ); + } + + const sort = flags.sort ?? DEFAULT_REPLAY_SORT; + if (!isReplaySortValue(sort)) { + throw new ValidationError( + `Invalid replay sort "${sort}". Use a replay sort like ${DEFAULT_REPLAY_SORT} or -count_errors.`, + "sort" + ); + } + + return { + sort, + query: flags.query, + fetch: async ({ cursor, limit, timeRange }) => { + const replayResponse = await listReplays(org, { + cursor, + environment, + fields: getReplayRequestFields(fieldList), + limit, + projectSlugs: project ? [project] : undefined, + query: flags.query, + sort, + ...timeRangeToApiParams(timeRange), + }); + return { + data: buildReplayExploreResponse(fieldList, replayResponse.data), + nextCursor: replayResponse.nextCursor, + }; + }, + }; } - // Warn only when user explicitly passed --sort on a non-spans dataset - if (sort && explicitSort) { - log.warn( - `--sort is only supported on the spans dataset. Ignoring sort for ${dataset}.` + + // Non-replay datasets + if (environment) { + throw new ValidationError( + "--environment is only supported with --dataset replays. Use environment:... inside --query for other datasets.", + "environment" ); } - return; + + const firstAgg = findFirstAggregate(fieldList); + const rawSort = flags.sort ?? (firstAgg ? `-${firstAgg}` : undefined); + let sort: string | undefined; + if (dataset === "spans") { + sort = rawSort; + } else { + // Warn only when user explicitly passed --sort on a non-spans dataset + if (rawSort && flags.sort) { + log.warn( + `--sort is only supported on the spans dataset. Ignoring sort for ${dataset}.` + ); + } + sort = undefined; + } + + const query = buildProjectQuery(flags.query, project); + return { + sort, + query, + fetch: async ({ cursor, limit, timeRange }) => + queryEvents(org, { + fields: fieldList, + dataset, + query, + sort, + limit, + cursor, + ...timeRangeToApiParams(timeRange), + }), + }; } /** Build the result hint string from pagination state and row count */ @@ -526,43 +614,16 @@ export const exploreCommand = buildListCommand("explore", { } const timeRange = flags.period; const environment = parseReplayEnvironmentFilter(flags.environment); - const replaySort = - dataset === "replays" ? resolveReplaySort(flags.sort) : undefined; - const eventSort = - dataset === "replays" - ? undefined - : resolveExploreSort(fieldList, dataset, flags.sort); - const paginationSort = dataset === "replays" ? replaySort : eventSort; - - if (dataset !== "replays" && environment) { - throw new ValidationError( - "--environment is only supported with --dataset replays. Use environment:... inside --query for other datasets.", - "environment" - ); - } - if (dataset === "replays") { - const unsupportedField = fieldList.find( - (field) => !isSupportedReplayField(field) - ); - if (unsupportedField) { - throw new ValidationError( - `Unsupported replay field "${unsupportedField}". Supported fields include: ${listSupportedReplayFields().slice(0, 12).join(", ")}...`, - "field" - ); - } - } - - // When a project is in the target, prepend `project:` to the query - // so the API filters server-side. Mirrors `trace logs` / `log list` behavior. - // Replays use the `projectSlugs` API param for project filtering instead of - // the `project:` query prefix — skip `buildProjectQuery` for that dataset. - const apiQuery = - dataset === "replays" - ? flags.query - : buildProjectQuery(flags.query, project); + const config = resolveDatasetConfig({ + dataset, + fieldList, + flags, + org, + project, + environment, + }); - // Pagination context includes project so different scopes don't share state const contextKey = buildPaginationContextKey( "explore", project ? `${org}/${project}` : org, @@ -571,7 +632,7 @@ export const exploreCommand = buildListCommand("explore", { env: environment?.join(","), fields: fieldList.join(","), q: flags.query, - sort: paginationSort, + sort: config.sort, period: serializeTimeRange(timeRange), } ); @@ -586,42 +647,13 @@ export const exploreCommand = buildListCommand("explore", { message: `Querying ${dataset} in ${project ? `${org}/${project}` : org}...`, json: flags.json, }, - async () => { - if (dataset === "replays") { - const replayResponse = await listReplays(org, { - cursor, - environment, - fields: getReplayRequestFields(fieldList), - limit: flags.limit, - projectSlugs: project ? [project] : undefined, - query: apiQuery, - sort: replaySort, - ...timeRangeToApiParams(timeRange), - }); - - return { - data: buildReplayExploreResponse(fieldList, replayResponse.data), - nextCursor: replayResponse.nextCursor, - }; - } - - return queryEvents(org, { - fields: fieldList, - dataset, - query: apiQuery, - sort: eventSort, - limit: flags.limit, - cursor, - ...timeRangeToApiParams(timeRange), - }); - } + () => config.fetch({ cursor, limit: flags.limit, timeRange }) ); advancePaginationState(PAGINATION_KEY, contextKey, direction, nextCursor); const hasPrev = hasPreviousPage(PAGINATION_KEY, contextKey); const hasMore = !!nextCursor; - // Pagination hints preserve the original target shape (org/ vs org/project) const baseTarget = project ? `${org}/${project}` : `${org}/`; const nav = paginationHint({ hasPrev, From 60f66a40c366603967dc5d36972c66a6e535b1fc Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Sat, 2 May 2026 05:52:08 +0000 Subject: [PATCH 26/26] docs: add missing JSDoc to replay search, explore, and view internals --- src/commands/explore.ts | 1 + src/commands/replay/view.ts | 8 ++++++++ src/lib/replay-search.ts | 9 +++++++++ 3 files changed, 18 insertions(+) diff --git a/src/commands/explore.ts b/src/commands/explore.ts index f821ffca9..61b6b411e 100644 --- a/src/commands/explore.ts +++ b/src/commands/explore.ts @@ -181,6 +181,7 @@ function parseLimit(value: string): number { return validateLimit(value, 1, LIST_MAX_LIMIT); } +/** Infer a Discover-style column type for a replay field (used for table alignment). */ function inferReplayFieldType(field: string): string { if (field === "duration") { return "duration"; diff --git a/src/commands/replay/view.ts b/src/commands/replay/view.ts index 90db3b037..d2f37ae27 100644 --- a/src/commands/replay/view.ts +++ b/src/commands/replay/view.ts @@ -74,6 +74,14 @@ const MAX_RELATED_TRACES = 2; const log = logger.withTag("replay.view"); +/** + * Parse a single positional argument as a replay target. + * + * Handles bare replay IDs, `/`, `//`, + * and Sentry replay URLs. The single-slash case (`org/id`) needs special + * handling because 32-char hex replay IDs look valid to the generic + * `parseSlashSeparatedArg` which would misinterpret the org as a project. + */ function parseSingleArg(arg: string): ParsedPositionalArgs { const trimmed = arg.trim(); if (!trimmed) { diff --git a/src/lib/replay-search.ts b/src/lib/replay-search.ts index 7816b23f1..53b01305c 100644 --- a/src/lib/replay-search.ts +++ b/src/lib/replay-search.ts @@ -1,3 +1,10 @@ +/** + * Replay Search + * + * Field resolution, normalization, and replay ID extraction utilities + * shared by replay commands and explore --dataset replays. + */ + import type { ReplayDetails, ReplayListItem, @@ -8,6 +15,7 @@ import { tryNormalizeHexId } from "./hex-id.js"; type ReplayLike = ReplayListItem | ReplayDetails; type ReplayFieldResolver = (replay: ReplayLike) => unknown; +/** Maps user-facing field aliases to canonical replay API field names. */ const REPLAY_FIELD_ALIASES = { count_screens: "count_urls", screens: "urls", @@ -16,6 +24,7 @@ const REPLAY_FIELD_ALIASES = { viewed_by_me: "has_viewed", } as const satisfies Record; +/** Resolve a field alias to its canonical API name, or pass through as-is. */ function normalizeReplayField(field: string): string { return Object.hasOwn(REPLAY_FIELD_ALIASES, field) ? REPLAY_FIELD_ALIASES[field as keyof typeof REPLAY_FIELD_ALIASES]