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/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/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 8cc3670ac..d89f44bef 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/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 new file mode 100644 index 000000000..32154321d --- /dev/null +++ b/plugins/sentry-cli/skills/sentry-cli/references/replay.md @@ -0,0 +1,148 @@ +--- +name: sentry-cli-replay +version: 0.32.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)` +- `-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)` + +**JSON Fields** (use `--json --fields` to select specific fields): + +| Field | Type | Description | +|-------|------|-------------| +| `activity` | number \| null | Replay activity score | +| `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 | +| `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 \| null | 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 \| 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 | +| `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 \| null | 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 + +**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` | array | Summarized replay activity | +| `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 | +| `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 \| null | 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 \| 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 | +| `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 \| null | User metadata | +| `warning_ids` | array | Linked warning event IDs | +| `clicks` | array | Replay click summaries | +| `replay_type` | string \| null | Replay type | +| `org` | string | Organization slug | +| `relatedIssues` | array | Replay-related issues | +| `relatedTraces` | array | Replay-related traces | + +**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 52174001e..ea537a416 100644 --- a/src/app.ts +++ b/src/app.ts @@ -26,6 +26,8 @@ import { projectRoute } from "./commands/project/index.js"; import { listCommand as projectListCommand } from "./commands/project/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"; @@ -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..51c0b123d 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-search.js"; import { resolveOrg, resolveOrgAndProject, @@ -148,6 +149,22 @@ 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 = getReplayIdFromEvent(event); + return replayId + ? `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 / "; @@ -417,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 { @@ -470,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, @@ -497,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 @@ -987,7 +1007,12 @@ export const viewCommand = buildCommand({ events: [issueShortcut.data], requestedCount: 1, }); - return { hint: issueShortcut.hint }; + return { + hint: joinHintParts([ + issueShortcut.hint, + replayHint(issueShortcut.org, issueShortcut.data.event), + ]), + }; } // Validate + attempt recovery. `skipValidation` is true when the ID is @@ -1043,9 +1068,12 @@ export const viewCommand = buildCommand({ requestedCount: allEventIds.length, }); return { - hint: target.detectedFrom - ? `Detected from ${target.detectedFrom}` - : undefined, + hint: joinHintParts([ + target.detectedFrom + ? `Detected from ${target.detectedFrom}` + : undefined, + fetchedEvents[0] ? replayHint(target.org, fetchedEvents[0]) : undefined, + ]), }; }, }); diff --git a/src/commands/explore.ts b/src/commands/explore.ts index 608fe8095..61b6b411e 100644 --- a/src/commands/explore.ts +++ b/src/commands/explore.ts @@ -7,7 +7,11 @@ */ import type { SentryContext } from "../context.js"; -import { queryEvents } from "../lib/api-client.js"; +import { + isReplaySortValue, + listReplays, + queryEvents, +} from "../lib/api-client.js"; import { buildProjectQuery, validateLimit } from "../lib/arg-parsing.js"; import { advancePaginationState, @@ -30,6 +34,14 @@ 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, + parseReplayEnvironmentFilter, +} 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,38 @@ 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"; + } + if (field === "activity" || 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 // --------------------------------------------------------------------------- @@ -248,27 +302,36 @@ 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, flags: Pick< ExploreFlags, - "dataset" | "sort" | "query" | "period" | "field" | "limit" + "dataset" | "environment" | "sort" | "query" | "period" | "field" | "limit" > ): 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 ?? []; 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}"`); } @@ -276,6 +339,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,28 +356,129 @@ function findFirstAggregate(fieldList: string[]): string | undefined { return fieldList.find((f) => f.includes("(") && f.includes(")")); } +// --------------------------------------------------------------------------- +// Dataset configuration +// --------------------------------------------------------------------------- + /** - * Determine the effective sort value, accounting for dataset restrictions. - * 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 resolveSort( - 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 */ @@ -340,7 +509,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 +521,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 +569,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 +592,7 @@ export const exploreCommand = buildListCommand("explore", { }, aliases: { ...PERIOD_ALIASES, + e: "environment", F: "field", d: "dataset", q: "query", @@ -428,25 +608,32 @@ export const exploreCommand = buildListCommand("explore", { "explore" ); - const fieldList = - flags.field && flags.field.length > 0 ? flags.field : DEFAULT_FIELDS; const dataset = flags.dataset; + let fieldList = [...defaultFieldsForDataset(dataset)]; + if (flags.field && flags.field.length > 0) { + fieldList = flags.field; + } const timeRange = flags.period; - const effectiveSort = resolveSort(fieldList, dataset, flags.sort); + const environment = parseReplayEnvironmentFilter(flags.environment); - // 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 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, { dataset, + env: environment?.join(","), fields: fieldList.join(","), q: flags.query, - sort: effectiveSort, + sort: config.sort, period: serializeTimeRange(timeRange), } ); @@ -461,23 +648,13 @@ export const exploreCommand = buildListCommand("explore", { message: `Querying ${dataset} in ${project ? `${org}/${project}` : org}...`, json: flags.json, }, - () => - queryEvents(org, { - fields: fieldList, - dataset, - query: apiQuery, - sort: effectiveSort, - 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, diff --git a/src/commands/issue/view.ts b/src/commands/issue/view.ts index 28af5a22a..9cdd89a06 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,10 @@ import { FRESH_ALIASES, FRESH_FLAG, } from "../../lib/list-command.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"; @@ -53,15 +58,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 +121,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 +133,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 +152,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 +164,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 +230,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 +267,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/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..ebc10a7fa --- /dev/null +++ b/src/commands/replay/list.ts @@ -0,0 +1,405 @@ +/** + * sentry replay list + * + * List Session Replays from Sentry. + */ + +import type { SentryContext } from "../../context.js"; +import { + isReplaySortValue, + 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 type { Column } from "../../lib/formatters/table.js"; +import { formatDurationCompact } from "../../lib/formatters/time-utils.js"; +import { + appendQueryHint, + appendSortHint, + buildListCommand, + LIST_DEFAULT_LIMIT, + LIST_MAX_LIMIT, + LIST_MIN_LIMIT, + LIST_PERIOD_FLAG, + PERIOD_ALIASES, + paginationHint, + targetPatternExplanation, +} from "../../lib/list-command.js"; +import { withProgress } from "../../lib/polling.js"; +import { + getReplayUserLabel, + parseReplayEnvironmentFilter, +} from "../../lib/replay-search.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 { + REPLAY_LIST_FIELDS, + type ReplayListItem, + ReplayListItemOutputSchema, +} from "../../types/index.js"; + +type ListFlags = { + readonly environment?: readonly string[]; + 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" | "activity"; + +const SORT_MAP: Record = { + date: "-started_at", + oldest: "started_at", + duration: "-duration", + errors: "-count_errors", + activity: "-activity", +}; + +const DEFAULT_PERIOD = LIST_PERIOD_FLAG.default; +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 trimmed = value.trim(); + const normalized = trimmed.toLowerCase() as ReplaySortKey; + const mapped = SORT_MAP[normalized]; + if (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 { + return value === null || value === undefined ? "0" : String(value); +} + +function replayUserLabel(replay: ReplayListItem): string { + return getReplayUserLabel(replay) ?? "—"; +} + +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) => formatDurationCompact(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 !== null && replay.project_id !== undefined + ? String(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); + 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; +} + +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 -e production -e canary\n" + + " sentry replay list sentry/cli --period 24h\n\n" + + "Alias: `sentry replays` → `sentry replay list`", + }, + output: { + human: formatReplayListHuman, + jsonTransform: jsonTransformReplayList, + schema: ReplayListItemOutputSchema, + }, + 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, + }, + 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, activity, or a raw replay sort field", + default: "date", + }, + period: LIST_PERIOD_FLAG, + }, + aliases: { + ...PERIOD_ALIASES, + e: "environment", + n: "limit", + q: "query", + s: "sort", + }, + }, + async *func(this: SentryContext, flags: ListFlags, target?: string) { + const { cwd } = this; + const timeRange = flags.period; + const environment = parseReplayEnvironmentFilter(flags.environment); + const { query } = flags; + + const resolved = await resolveOrgOptionalProjectFromArg( + target, + cwd, + COMMAND_NAME + ); + + const contextKey = buildPaginationContextKey( + "replay", + formatScope(resolved.org, resolved.project), + { + env: environment?.join(","), + 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, { + environment, + fields: [...REPLAY_LIST_FIELDS], + 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..d2f37ae27 --- /dev/null +++ b/src/commands/replay/view.ts @@ -0,0 +1,433 @@ +/** + * sentry replay view + * + * View detailed information about a Session Replay. + */ + +import type { SentryContext } from "../../context.js"; +import { + getProject, + getReplay, + getReplayRecordingSegments, + getTraceMeta, + listIssuesPaginated, +} 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, + ValidationError, +} from "../../lib/errors.js"; +import { filterFields } from "../../lib/formatters/json.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; +import { + 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 { logger } from "../../lib/logger.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 { + ReplayActivityEvent, + ReplayDetails, + ReplayRelatedIssue, + ReplayRelatedTrace, +} from "../../types/index.js"; +import { ReplayViewOutputSchema } 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 MAX_ACTIVITY_EVENTS = 6; +const MAX_RELATED_ERRORS = 3; +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) { + 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); + const replaySegment = trimmed.slice(slashIdx + 1); + const normalizedReplayId = + replaySegment && tryNormalizeHexId(replaySegment); + if (!normalizedReplayId) { + throw new ContextError("Replay ID", USAGE_HINT, []); + } + return { replayId: normalizedReplayId, 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, []); + } + 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) { + 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); + } + + const second = args[1]; + if (!second) { + throw new ContextError("Replay ID", USAGE_HINT, []); + } + + const warning = + args.length === 2 ? detectSwappedViewArgs(first, second) : null; + if (warning) { + const normalizedReplayId = tryNormalizeHexId(first) ?? first; + return { + replayId: normalizedReplayId, + targetArg: second, + warning, + }; + } + + return { replayId: second, targetArg: first }; +} + +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; + } + + 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 (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, 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 +): 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: `event.id:${eventId}`, + perPage: 1, + }); + const issue = page.data[0]; + return { + eventId, + issueId: issue?.id ?? null, + shortId: issue?.shortId ?? null, + title: issue?.title ?? null, + }; + } catch (error) { + log.debug(`Failed to resolve issue for event ${eventId}`, error); + 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 (error) { + log.debug(`Failed to fetch trace meta for ${traceId}`, error); + 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 }; +} + +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" + + " - 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: (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: { + kind: "array", + parameter: { + placeholder: "replay-id-or-url", + brief: "[/] or ", + 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) { + log.warn(parsedArgs.warning); + } + + 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; + } + + 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 de0688981..6b4d1e78f 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 @@ -119,6 +120,17 @@ export { setCommitsWithRefs, updateRelease, } from "./api/releases.js"; +export { + getReplay, + getReplayRecordingSegments, + isReplaySortValue, + type ListReplaysOptions, + listReplayIdsForIssue, + listReplays, + REPLAY_SORT_FIELDS, + type ReplaySortField, + type ReplaySortValue, +} from "./api/replays.js"; export { listAllRepositories, listRepositories, @@ -147,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 new file mode 100644 index 000000000..fbf5dfcdb --- /dev/null +++ b/src/lib/api/replays.ts @@ -0,0 +1,260 @@ +/** + * Replay API functions + * + * 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, + type ReplayRecordingSegments, + ReplayRecordingSegmentsSchema, +} from "../../types/index.js"; + +import { resolveOrgRegion } from "../region.js"; + +import { + API_MAX_PER_PAGE, + apiRequestToRegion, + autoPaginate, + type PaginatedResponse, + parseLinkHeader, +} from "./infrastructure.js"; + +/** 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 = { + /** Limit total rows returned across auto-paginated pages. */ + 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. */ + 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; +}; + +type FetchReplayPageOptions = { + options: ListReplaysOptions; + perPage: number; + 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 { + 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. + */ +async function fetchReplayPage( + regionUrl: string, + orgSlug: string, + page: FetchReplayPageOptions +): Promise> { + const { cursor, options, perPage } = page; + const { data, headers } = await apiRequestToRegion( + regionUrl, + `/organizations/${orgSlug}/replays/`, + { + params: { + cursor, + environment: options.environment, + field: + options.fields && options.fields.length > 0 + ? options.fields + : [...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 as z.ZodType, + } + ); + + const { nextCursor } = parseLinkHeader(headers.get("link") ?? null); + return { + data: data.data.map(normalizeReplayProjectId), + 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 as z.ZodType, + } + ); + 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. + */ +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/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/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/lib/formatters/human.ts b/src/lib/formatters/human.ts index bdae0a41e..64d0a88b9 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-search.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]}/replays/${replayTag.value}/`); + lines.push(`**Link:** ${match[1]}/explore/replays/${replayId}/`); } } 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; +} 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-search.ts b/src/lib/replay-search.ts new file mode 100644 index 000000000..53b01305c --- /dev/null +++ b/src/lib/replay-search.ts @@ -0,0 +1,289 @@ +/** + * Replay Search + * + * Field resolution, normalization, and replay ID extraction utilities + * shared by replay commands and explore --dataset replays. + */ + +import type { + ReplayDetails, + ReplayListItem, + SentryEvent, +} from "../types/index.js"; +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", + seen_by_me: "has_viewed", + "user.ip_address": "user.ip", + 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] + : field; +} + +/** 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; + +/** 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; +} + +/** 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, + 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 = normalizeReplayField(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 "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": + case "user.geo.region": + case "user.geo.subdivision": + case "user.id": + case "user.ip": + case "user.username": + return "user"; + case "warning_id": + return "warning_ids"; + 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); +} + +// --------------------------------------------------------------------------- +// 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 93d554163..e6dc259c8 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 @@ -11,6 +11,7 @@ import { DEFAULT_SENTRY_HOST } from "./constants.js"; import { getEnv } from "./env.js"; import { HostScopeError } from "./errors.js"; +import { tryNormalizeHexId } from "./hex-id.js"; import { isSaaSTrustOrigin } from "./sentry-urls.js"; import { getActiveTokenHost, isHostTrusted } from "./token-host.js"; @@ -35,6 +36,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 +71,14 @@ function matchOrganizationsPath( return { baseUrl, org, traceId: segments[3] }; } + const replayPath = matchReplayPath(segments, 2); + if (replayPath.status === "detail") { + return { baseUrl, org, replayId: replayPath.replayId }; + } + if (replayPath.status === "invalid") { + return null; + } + // /organizations/{org}/dashboard/{id}/ if (segments[2] === "dashboard" && segments[3]) { return { baseUrl, org, dashboardId: segments[3] }; @@ -118,6 +129,23 @@ function matchSubdomainPath( if (segments[0] === "traces" && segments[1]) { return { traceId: segments[1] }; } + + const replayPath = matchReplayPath(segments, 0); + if (replayPath.status === "detail") { + return { replayId: replayPath.replayId }; + } + if (replayPath.status === "invalid") { + return null; + } + if (replayPath.status === "list") { + return {}; + } + 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] }; @@ -137,6 +165,37 @@ function matchSubdomainPath( return null; } +function matchReplayPath( + segments: string[], + startIndex: number +): + | { status: "absent" | "list" | "invalid" } + | { status: "detail"; replayId: string } { + 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]; + } else { + return { status: "absent" }; + } + + if (!replayId) { + return { status: "list" }; + } + + const normalizedReplayId = tryNormalizeHexId(replayId); + if (!normalizedReplayId) { + return { status: "invalid" }; + } + + return { status: "detail", replayId: normalizedReplayId }; +} + /** * Try to extract org from a SaaS subdomain-style URL. * @@ -198,6 +257,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 +266,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/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..ed8af9394 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -45,6 +45,47 @@ export { TokenErrorResponseSchema, TokenResponseSchema, } from "./oauth.js"; +// Replay types and schemas +export type { + ReplayActivityEvent, + ReplayBrowser, + ReplayDetails, + ReplayDetailsResponse, + ReplayDevice, + ReplayGeo, + ReplayIdsByResource, + ReplayListItem, + ReplayListResponse, + ReplayOs, + ReplayOtaUpdates, + ReplayRecordingSegments, + ReplayRelatedIssue, + ReplayRelatedTrace, + ReplaySdk, + ReplayUser, +} from "./replay.js"; +export { + REPLAY_LIST_FIELDS, + ReplayActivityEventSchema, + ReplayBrowserSchema, + ReplayDetailsOutputSchema, + ReplayDetailsResponseSchema, + ReplayDetailsSchema, + ReplayDeviceSchema, + ReplayGeoSchema, + ReplayIdsByResourceSchema, + ReplayListItemOutputSchema, + ReplayListItemSchema, + ReplayListResponseSchema, + ReplayOsSchema, + ReplayOtaUpdatesSchema, + ReplayRecordingSegmentsSchema, + ReplayRelatedIssueSchema, + ReplayRelatedTraceSchema, + ReplaySdkSchema, + ReplayUserSchema, + ReplayViewOutputSchema, +} from "./replay.js"; export type { AutofixResponse, AutofixState, @@ -79,6 +120,7 @@ export type { ProductTrial, ProjectKey, Region, + ReplayContext, RepositoryProvider, RequestEntry, SentryDeploy, @@ -98,6 +140,7 @@ export type { TraceContext, TraceLog, TraceLogsResponse, + TraceMeta, TraceSpan, TransactionListItem, TransactionsResponse, @@ -123,6 +166,7 @@ export { SpansResponseSchema, TraceLogSchema, TraceLogsResponseSchema, + TraceMetaSchema, TransactionListItemSchema, TransactionsResponseSchema, UserRegionsResponseSchema, diff --git a/src/types/replay.ts b/src/types/replay.ts new file mode 100644 index 000000000..82a26984e --- /dev/null +++ b/src/types/replay.ts @@ -0,0 +1,392 @@ +import { z } from "zod"; + +export type ReplayTags = Record; + +/** + * 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.nullish().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 falls back to an empty tag object for those placeholders. + */ +export const ReplayTagsSchema = z + .record(z.array(z.string())) + .catch({}) + .describe("Replay tags") as z.ZodType; + +/** + * 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", + "ota_updates", + "platform", + "project_id", + "releases", + "sdk", + "started_at", + "tags", + "trace_ids", + "urls", + "user", + "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, + TOtaUpdates extends z.ZodTypeAny, + TProjectId extends z.ZodTypeAny, + TReleases extends z.ZodTypeAny, + TTags extends z.ZodTypeAny, + TTraceIds extends z.ZodTypeAny, + TUrls extends z.ZodTypeAny, + TWarningIds extends z.ZodTypeAny, +>(fields: { + errorIds: TErrorIds; + infoIds: TInfoIds; + otaUpdates: TOtaUpdates; + projectId: TProjectId; + releases: TReleases; + 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"), + ota_updates: fields.otaUpdates.describe("OTA update metadata"), + platform: replayNullableString("Platform"), + project_id: fields.projectId.describe("Numeric project ID"), + releases: fields.releases.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( + buildReplayListItemShape({ + errorIds: replayStringArrayWithFallback(), + infoIds: replayStringArrayWithFallback(), + otaUpdates: replayNullishObject( + ReplayOtaUpdatesSchema, + "OTA update metadata" + ), + projectId: z.union([z.string(), z.number()]).nullable().optional(), + releases: replayStringArrayWithFallback(), + tags: ReplayTagsSchema, + traceIds: replayStringArrayWithFallback(), + urls: replayStringArrayWithFallback(), + warningIds: replayStringArrayWithFallback(), + }) + ) + .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"), + 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({ + data: z.array(ReplayListItemSchema), + }) + .passthrough(); + +/** Envelope returned by the replay detail endpoint. */ +export const ReplayDetailsResponseSchema = z + .object({ + data: ReplayDetailsSchema, + }) + .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( + buildReplayListItemShape({ + errorIds: replayStringArray(), + infoIds: replayStringArray(), + otaUpdates: ReplayOtaUpdatesSchema.nullish(), + projectId: z.string().nullable().optional(), + releases: replayStringArray(), + 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. */ +export const ReplayDetailsOutputSchema = ReplayListItemOutputSchema.extend({ + clicks: z + .array(ReplayClickSchema) + .optional() + .describe("Replay click summaries"), + 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())) + .describe("Replay IDs grouped by resource identifier"); + +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 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 55dc4b5b3..3a173ce5b 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,38 @@ export type DeviceContext = { [key: string]: unknown; }; +/** Replay context from event.contexts.replay */ +export type ReplayContext = { + replay_id?: string; + [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/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/explore.test.ts b/test/commands/explore.test.ts index e31f4872e..ff3922486 100644 --- a/test/commands/explore.test.ts +++ b/test/commands/explore.test.ts @@ -19,7 +19,8 @@ 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"; +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"; @@ -71,7 +72,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 +105,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 +130,7 @@ beforeEach(async () => { afterEach(() => { queryEventsSpy.mockRestore(); + listReplaysSpy.mockRestore(); resolveTargetSpy.mockRestore(); resolveCursorSpy.mockRestore(); advancePaginationStateSpy.mockRestore(); @@ -298,6 +324,80 @@ 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(); + }); + + test("passes replay query text without project: prefix (uses projectSlugs instead)", 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: "count_errors:>0", + }) + ); + }); + + 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", () => { @@ -346,6 +446,49 @@ 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" }) + ); + }); + + 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", () => { @@ -426,6 +569,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({ @@ -473,5 +641,60 @@ 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"'); + expect(output).not.toContain('--sort "-started_at"'); + }); + }); + + 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); + }); + + 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/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 new file mode 100644 index 000000000..887d98598 --- /dev/null +++ b/test/commands/replay/list.test.ts @@ -0,0 +1,217 @@ +/** + * 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"; +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 { + REPLAY_LIST_FIELDS, + 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"); + expect(parseSort("-count_rage_clicks")).toBe("-count_rage_clicks"); + }); + + 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, + 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: [], + }, + ]; + + 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", { + environment: undefined, + fields: [...REPLAY_LIST_FIELDS], + 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("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"], + fields: [...REPLAY_LIST_FIELDS], + 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 }); + + 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/"); + }); + + 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"); + }); +}); diff --git a/test/commands/replay/view.test.ts b/test/commands/replay/view.test.ts new file mode 100644 index 000000000..f94bc63ea --- /dev/null +++ b/test/commands/replay/view.test.ts @@ -0,0 +1,357 @@ +/** + * 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, + 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 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, + 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, + }; +} + +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("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); + 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); + 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); + }); + + test("throws ValidationError for extra positional args", () => { + expect(() => + parsePositionalArgs(["test-org/cli", REPLAY_ID, "extra-arg"]) + ).toThrow(ValidationError); + }); +}); + +describe("viewCommand.func", () => { + let getProjectSpy: ReturnType; + let getReplaySpy: ReturnType; + let getReplayRecordingSegmentsSpy: ReturnType; + let getTraceMetaSpy: ReturnType; + let listIssuesPaginatedSpy: 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(() => { + 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({ + error_ids: ["bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + }) + ); + + 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.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"); + expect(listIssuesPaginatedSpy).toHaveBeenCalledWith( + "test-org", + "", + expect.objectContaining({ + perPage: 1, + query: "event.id:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + }) + ); + }); + + 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("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( + 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); + }); + + 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("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( + 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/"); + }); + + 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/api/replays.test.ts b/test/lib/api/replays.test.ts new file mode 100644 index 000000000..0b2f0f18f --- /dev/null +++ b/test/lib/api/replays.test.ts @@ -0,0 +1,317 @@ +/** + * Tests for the replay API helpers. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { + getReplay, + getReplayRecordingSegments, + 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"; +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("ota_updates"); + expect(url.searchParams.getAll("field")).toContain("user"); + expect(result.data).toHaveLength(1); + 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; + + 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); + }); + + 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, + releases: null, + 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.releases).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("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; + + 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/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..07a25e604 100644 --- a/test/lib/formatters/human.details.test.ts +++ b/test/lib/formatters/human.details.test.ts @@ -792,15 +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("https://acme.sentry.io/replays/replay-uuid-123/"); + expect(result).toContain("346789a703f6454384f1de473b8b9fcc"); + expect(result).toContain( + "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/replay-duration.test.ts b/test/lib/replay-duration.test.ts new file mode 100644 index 000000000..cf1087f4f --- /dev/null +++ b/test/lib/replay-duration.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "bun:test"; + +import { + formatDurationCompact, + formatDurationCompactMs, + formatDurationVerbose, +} from "../../src/lib/formatters/time-utils.js"; + +describe("formatDurationCompact", () => { + test("formats short durations", () => { + expect(formatDurationCompact(59)).toBe("59s"); + expect(formatDurationCompact(60)).toBe("1m"); + expect(formatDurationCompact(125)).toBe("2m 5s"); + }); + + test("formats long durations", () => { + expect(formatDurationCompact(3600)).toBe("1h"); + expect(formatDurationCompact(3665)).toBe("1h 1m"); + expect(formatDurationCompact(90_061)).toBe("1d 1h"); + }); + + test("handles missing durations", () => { + expect(formatDurationCompact(null)).toBe("—"); + expect(formatDurationCompact(undefined)).toBe("—"); + }); +}); + +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(formatDurationVerbose(1)).toBe("1 second"); + expect(formatDurationVerbose(125)).toBe("2 minutes and 5 seconds"); + }); + + test("formats long durations", () => { + 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"); + }); +}); diff --git a/test/lib/replay-search.test.ts b/test/lib/replay-search.test.ts new file mode 100644 index 000000000..94818471a --- /dev/null +++ b/test/lib/replay-search.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test"; +import { + getReplayRequestFields, + isSupportedReplayField, +} 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", + ]); + }); + + 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", () => { + test("does not expose replay detail-only fields in replay explore", () => { + expect(isSupportedReplayField("replay_type")).toBe(false); + }); +}); 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..b835ce6c5 100644 --- a/test/lib/sentry-url-parser.test.ts +++ b/test/lib/sentry-url-parser.test.ts @@ -185,6 +185,106 @@ 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", + }); + }); + + 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/" + ); + 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( + "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(); + }); + + 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", () => { test("/organizations/{org}/dashboard/{id}/", () => { const result = parseSentryUrl( @@ -279,6 +379,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/"