diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 1dfa1a5b1..1d016f247 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -26,7 +26,7 @@ Get the client ID from your Sentry OAuth application settings. ## Running Locally ```bash -bun run --env-file=.env.local src/bin.ts auth login +bun run --env-file=.env.local cli auth login ``` ## Testing the Device Flow @@ -34,7 +34,7 @@ bun run --env-file=.env.local src/bin.ts auth login 1. Run the CLI login command: ```bash -bun run --env-file=.env.local src/bin.ts auth login +bun run --env-file=.env.local cli auth login ``` 2. You'll see output like: diff --git a/README.md b/README.md index 80cb3e7c0..4bb25d2f5 100644 --- a/README.md +++ b/README.md @@ -133,10 +133,10 @@ bun install ```bash # Run CLI in development mode -bun run dev --help +bun run cli --help # With environment variables -bun run --env-file=.env.local src/bin.ts --help +bun run --env-file=.env.local cli --help ``` ### Scripts diff --git a/docs/src/content/docs/contributing.md b/docs/src/content/docs/contributing.md index 042ac62d5..05920341d 100644 --- a/docs/src/content/docs/contributing.md +++ b/docs/src/content/docs/contributing.md @@ -25,7 +25,7 @@ cd cli bun install # Run CLI in development mode -bun run --env-file=.env.local src/bin.ts --help +bun run --env-file=.env.local cli --help # Run tests bun test @@ -53,14 +53,14 @@ cli/ │ ├── commands/ # CLI commands │ │ ├── auth/ # login, logout, refresh, status, token, whoami │ │ ├── cli/ # defaults, feedback, fix, setup, upgrade -│ │ ├── dashboard/ # list, view, create, add, edit, delete +│ │ ├── dashboard/ # list, view, create, widget add, widget edit, widget delete │ │ ├── event/ # view, list │ │ ├── issue/ # list, events, explain, plan, view, resolve, unresolve, archive, merge │ │ ├── log/ # list, view │ │ ├── org/ # list, view │ │ ├── project/ # create, delete, list, view │ │ ├── release/ # list, view, create, finalize, delete, deploy, deploys, set-commits, propose-version -│ │ ├── replay/ # list, view +│ │ ├── replay/ # event list, list, summarize, 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 index b57df2515..00aa977be 100644 --- a/docs/src/fragments/commands/replay.md +++ b/docs/src/fragments/commands/replay.md @@ -8,11 +8,15 @@ sentry replay list my-org/frontend # Search across all projects in an org -sentry replay list my-org/ --query "environment:production" +sentry replay list my-org/ --search "environment:production" # Change the time window and sort sentry replay list my-org/frontend --period 24h --sort errors +# Find recent sessions with replay search syntax +sentry replay list my-org/frontend \ + --search "url:*signup* count_errors:>0" --json + # Paginate through results sentry replay list my-org/frontend -c next sentry replay list my-org/frontend -c prev @@ -36,3 +40,29 @@ sentry replay view my-org/frontend/346789a703f6454384f1de473b8b9fcc # Open a replay in the browser sentry replay view my-org/346789a703f6454384f1de473b8b9fcc --web ``` + +### Summarize behavior + +```bash +# Summarize route flow, event counts, timings, and friction signals +sentry replay summarize my-org/346789a703f6454384f1de473b8b9fcc --json + +# Focus the summary on a particular route path +sentry replay summarize my-org/346789a703f6454384f1de473b8b9fcc \ + --path /signup --json +``` + +### Inspect replay events + +```bash +# List normalized replay events for agent-readable inspection +sentry replay events my-org/346789a703f6454384f1de473b8b9fcc --json + +# Focus on user actions and failures on a page +sentry replay events my-org/346789a703f6454384f1de473b8b9fcc \ + /signup --kind click,network,console,error --json + +# Pull an evidence window around a timestamp +sentry replay events my-org/346789a703f6454384f1de473b8b9fcc \ + --around 01:23 --json +``` diff --git a/package.json b/package.json index e7872c8b1..acbc28168 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "@sentry/core@10.50.0": "patches/@sentry%2Fcore@10.50.0.patch" }, "scripts": { + "cli": "bun run src/bin.ts", "dev": "bun run generate:schema && bun run generate:docs && bun run generate:sdk && bun run src/bin.ts", "build": "bun run generate:schema && bun run generate:docs && bun run generate:sdk && bun run script/build.ts --single", "build:all": "bun run generate:schema && bun run generate:docs && bun run generate:sdk && bun run script/build.ts", diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index d89f44bef..6b1377b37 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -367,7 +367,9 @@ Manage Sentry dashboards Search and inspect Session Replays +- `sentry replay event list ` — List normalized events from a Session Replay - `sentry replay list ` — List recent Session Replays +- `sentry replay summarize ` — Summarize Session Replay behavior - `sentry replay view ` — View a Session Replay → Full flags and examples: `references/replay.md` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/replay.md b/plugins/sentry-cli/skills/sentry-cli/references/replay.md index 32154321d..0705e83b7 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/replay.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/replay.md @@ -11,15 +11,51 @@ requires: Search and inspect Session Replays +### `sentry replay event list ` + +List normalized events from a Session Replay + +**Flags:** +- `-k, --kind ... - Event kind filter (navigation, click, tap, input, focus, blur, scroll, viewport, mutation, dom-snapshot, breadcrumb, network, console, error, span, web-vital, memory, video, mobile, unknown)` +- `--path - Filter events by parsed URL pathname` +- `-q, --search - Filter events by text in labels, messages, URLs, selectors, or data` +- `--around - Show an evidence window around this replay offset` +- `-n, --limit - Number of events (1-1000) - (default: "200")` +- `--raw - Include raw source frame payloads in JSON output` +- `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` + +**JSON Fields** (use `--json --fields` to select specific fields): + +| Field | Type | Description | +|-------|------|-------------| +| `replayId` | string | Replay ID | +| `segmentIndex` | number | Zero-based recording segment index | +| `frameIndex` | number | Zero-based frame index within segment | +| `offsetMs` | number \| null | Milliseconds from replay start to the event | +| `timestamp` | string \| null | Event timestamp as ISO 8601 when available | +| `kind` | string | Normalized event kind | +| `category` | string | Broad event category | +| `label` | string \| null | Short event label | +| `message` | string \| null | Message or summary | +| `url` | string \| null | Current or target URL | +| `urlPath` | string \| null | Parsed URL pathname when available | +| `urlQuery` | string \| null | Parsed URL query string when available | +| `selector` | string \| null | CSS selector or target selector when available | +| `nodeId` | unknown \| null | rrweb node ID when available | +| `rawType` | string \| null | Source frame type | +| `rawSource` | string \| null | Source frame subtype | +| `data` | unknown | Kind-specific normalized fields | +| `raw` | unknown | Raw source frame, only present when requested | + ### `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)` +- `-q, --search - 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")` +- `-s, --sort - Sort by: date, oldest, duration, errors, warnings, rage, dead, 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)` @@ -67,11 +103,15 @@ List recent Session Replays sentry replay list my-org/frontend # Search across all projects in an org -sentry replay list my-org/ --query "environment:production" +sentry replay list my-org/ --search "environment:production" # Change the time window and sort sentry replay list my-org/frontend --period 24h --sort errors +# Find recent sessions with replay search syntax +sentry replay list my-org/frontend \ + --search "url:*signup* count_errors:>0" --json + # Paginate through results sentry replay list my-org/frontend -c next sentry replay list my-org/frontend -c prev @@ -80,6 +120,50 @@ sentry replay list my-org/frontend -c prev sentry replay list my-org/frontend --json ``` +### `sentry replay summarize ` + +Summarize Session Replay behavior + +**Flags:** +- `--path - Focus summary on events from this URL pathname` +- `--limit-signals - Maximum friction signals to include (0-50) - (default: "10")` +- `--limit-events - Maximum notable events to include (0-50) - (default: "12")` +- `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` + +**JSON Fields** (use `--json --fields` to select specific fields): + +| Field | Type | Description | +|-------|------|-------------| +| `replayId` | string | Replay ID | +| `org` | string | Organization slug | +| `project` | string \| null | Project slug | +| `platform` | string \| null | Replay platform | +| `sdkName` | string \| null | Replay SDK name | +| `sdkVersion` | string \| null | Replay SDK version | +| `replayType` | string \| null | Replay type | +| `startedAt` | string \| null | Replay start time | +| `durationSeconds` | number \| null | Replay duration in seconds | +| `entryUrl` | string \| null | First replay URL | +| `exitUrl` | string \| null | Last replay URL | +| `focusPath` | string \| null | Optional route path used to focus the summary | +| `counts` | object | Normalized event counts | +| `recording` | object | Downloaded recording and parser stats | +| `timings` | object | Key timing observations | +| `routes` | array | Route timeline | +| `signals` | array | Detected non-error and error friction signals | +| `notableEvents` | array | Representative events useful for agent narrative | + +**Examples:** + +```bash +# Summarize route flow, event counts, timings, and friction signals +sentry replay summarize my-org/346789a703f6454384f1de473b8b9fcc --json + +# Focus the summary on a particular route path +sentry replay summarize my-org/346789a703f6454384f1de473b8b9fcc \ + --path /signup --json +``` + ### `sentry replay view ` View a Session Replay diff --git a/script/generate-docs-sections.ts b/script/generate-docs-sections.ts index ebeebed08..16bd6a22b 100644 --- a/script/generate-docs-sections.ts +++ b/script/generate-docs-sections.ts @@ -127,13 +127,20 @@ function isStandaloneCommand(route: RouteInfo): boolean { /** * Get subcommand names for a route group (e.g., "list, view, create"). - * Extracts the last path segment from each command's path. + * Preserves nested subcommands as "parent child" so route groups do not + * collapse multiple commands to the same final segment. */ function getSubcommandNames(route: RouteInfo): string[] { - return route.commands.map((cmd) => { - const parts = cmd.path.split(" "); - return parts.at(-1) ?? route.name; - }); + const prefix = `sentry ${route.name} `; + return Array.from( + new Set( + route.commands.map((cmd) => + cmd.path.startsWith(prefix) + ? cmd.path.slice(prefix.length) + : (cmd.path.split(" ").at(-1) ?? route.name) + ) + ) + ); } /** diff --git a/src/commands/replay/event/index.ts b/src/commands/replay/event/index.ts new file mode 100644 index 000000000..05dbfd8e9 --- /dev/null +++ b/src/commands/replay/event/index.ts @@ -0,0 +1,24 @@ +/** + * sentry replay event + * + * Inspect normalized events from Session Replay recordings. + */ + +import { buildRouteMap } from "../../../lib/route-map.js"; +import { listCommand } from "./list.js"; + +export const eventRoute = buildRouteMap({ + routes: { + list: listCommand, + }, + defaultCommand: "list", + docs: { + brief: "Inspect normalized replay events", + fullDescription: + "Inspect normalized events extracted from Session Replay recordings.\n\n" + + "Commands:\n" + + " list List normalized replay events\n\n" + + "Alias: `sentry replay events` → `sentry replay event list`", + hideRoute: {}, + }, +}); diff --git a/src/commands/replay/event/list.ts b/src/commands/replay/event/list.ts new file mode 100644 index 000000000..a57de6c30 --- /dev/null +++ b/src/commands/replay/event/list.ts @@ -0,0 +1,347 @@ +/** + * sentry replay event list + * + * List normalized events extracted from a Session Replay recording. + */ + +import type { SentryContext } from "../../../context.js"; +import { validateLimit } from "../../../lib/arg-parsing.js"; +import { buildCommand } from "../../../lib/command.js"; +import { ValidationError } from "../../../lib/errors.js"; +import { + escapeMarkdownCell, + formatTable, +} from "../../../lib/formatters/index.js"; +import { filterFields } from "../../../lib/formatters/json.js"; +import { + CommandOutput, + formatFooter, + type HumanRenderer, +} from "../../../lib/formatters/output.js"; +import type { Column } from "../../../lib/formatters/table.js"; +import { formatDurationCompactMs } from "../../../lib/formatters/time-utils.js"; +import { validateHexId } from "../../../lib/hex-id.js"; +import { + applyFreshFlag, + FRESH_ALIASES, + FRESH_FLAG, + LIST_MAX_LIMIT, + LIST_MIN_LIMIT, +} from "../../../lib/list-command.js"; +import { withProgress } from "../../../lib/polling.js"; +import { + extractNormalizedReplayEvents, + filterNormalizedReplayEvents, + parseReplayOffset, +} from "../../../lib/replay-events.js"; +import { resolveOrgOptionalProjectFromArg } from "../../../lib/resolve-target.js"; +import { + REPLAY_EVENT_KINDS, + type ReplayEvent, + type ReplayEventKind, + ReplayEventSchema, +} from "../../../types/index.js"; +import { + fetchReplayDetailsForCommand, + fetchReplaySegmentsForCommand, + validateReplayProjectScope, +} from "../shared.js"; +import { parseReplayTargetArgs } from "../target.js"; + +type EventListFlags = { + readonly around?: number; + readonly fields?: string[]; + readonly fresh: boolean; + readonly json: boolean; + readonly kind?: readonly string[]; + readonly limit: number; + readonly path?: string; + readonly raw: boolean; + readonly search?: string; +}; + +const COMMAND_NAME = "replay event list"; +const USAGE_HINT = + "sentry replay event list [//] [path] | [path]"; +const DEFAULT_LIMIT = 200; +const DEFAULT_BEFORE_MS = 10_000; +const DEFAULT_AFTER_MS = 30_000; + +const REPLAY_EVENT_KIND_SET = new Set(REPLAY_EVENT_KINDS); + +function parseLimit(value: string): number { + return validateLimit(value, LIST_MIN_LIMIT, LIST_MAX_LIMIT); +} + +function parseOffsetFlag(value: string): number { + return parseReplayOffset(value); +} + +function parseEventKinds( + values: readonly string[] | undefined +): ReplayEventKind[] { + const kinds = values + ? [...values] + .flatMap((value) => value.split(",")) + .map((value) => value.trim()) + .filter(Boolean) + : []; + + for (const kind of kinds) { + if (!REPLAY_EVENT_KIND_SET.has(kind)) { + throw new ValidationError( + `Invalid replay event kind "${kind}". Must be one of: ${REPLAY_EVENT_KINDS.join(", ")}`, + "kind" + ); + } + } + + return kinds as ReplayEventKind[]; +} + +function resolveWindow(flags: EventListFlags): { + fromMs?: number; + toMs?: number; +} { + if (flags.around === undefined) { + return {}; + } + + return { + fromMs: Math.max(0, flags.around - DEFAULT_BEFORE_MS), + toMs: flags.around + DEFAULT_AFTER_MS, + }; +} + +function splitTargetAndPathArgs( + args: string[], + flagPath: string | undefined +): { targetArgs: string[]; path?: string } { + const lastArg = args.at(-1); + if (args.length > 1 && lastArg?.startsWith("/")) { + if (flagPath) { + throw new ValidationError( + "Path provided both positionally and with --path", + "path" + ); + } + return { targetArgs: args.slice(0, -1), path: lastArg }; + } + + return { targetArgs: args, path: flagPath }; +} + +function eventLabel(event: ReplayEvent): string { + return event.label ?? event.message ?? event.selector ?? "—"; +} + +function formatOffset(event: ReplayEvent): string { + return event.offsetMs === null + ? "—" + : formatDurationCompactMs(event.offsetMs); +} + +const EVENT_COLUMNS: Column[] = [ + { + header: "OFFSET", + value: formatOffset, + minWidth: 8, + shrinkable: false, + }, + { + header: "KIND", + value: (event) => event.kind, + minWidth: 10, + }, + { + header: "LABEL", + value: (event) => escapeMarkdownCell(eventLabel(event)), + minWidth: 18, + truncate: true, + }, + { + header: "URL", + value: (event) => escapeMarkdownCell(event.url ?? "—"), + minWidth: 20, + truncate: true, + }, + { + header: "POINTER", + value: (event) => `${event.segmentIndex}:${event.frameIndex}`, + minWidth: 9, + shrinkable: false, + }, +]; + +function createEventListHumanRenderer(): HumanRenderer { + const events: ReplayEvent[] = []; + return { + render(event) { + events.push(event); + return ""; + }, + finalize(hint) { + if (events.length === 0) { + return `No replay events matched the filters.${hint ? formatFooter(hint) : "\n"}`; + } + + const replayId = events[0]?.replayId; + const title = replayId + ? `Replay events for ${replayId.slice(0, 8)}:` + : "Replay events:"; + const output = `${title}\n\n${formatTable(events, EVENT_COLUMNS, { truncate: true })}`; + return hint ? `${output}${formatFooter(hint)}` : `${output}\n`; + }, + }; +} + +function jsonTransformReplayEvent( + event: ReplayEvent, + fields?: string[] +): unknown { + return fields && fields.length > 0 ? filterFields(event, fields) : event; +} + +export const listCommand = buildCommand({ + docs: { + brief: "List normalized events from a Session Replay", + fullDescription: + "List normalized events extracted from Session Replay recording segments.\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" + + "Add a trailing /path argument to focus the timeline on one route.\n\n" + + "Examples:\n" + + " sentry replay events sentry/346789a703f6454384f1de473b8b9fcc --json\n" + + " sentry replay events sentry/cli/346789a703f6454384f1de473b8b9fcc --kind click,network,error --json\n" + + ' sentry replay events sentry/346789a703f6454384f1de473b8b9fcc /signup -q "button[type=submit]" --json\n' + + " sentry replay events sentry/346789a703f6454384f1de473b8b9fcc --around 01:23 --json", + }, + output: { + human: createEventListHumanRenderer, + jsonTransform: jsonTransformReplayEvent, + jsonLines: true, + schema: ReplayEventSchema, + }, + parameters: { + positional: { + kind: "array", + parameter: { + placeholder: "replay-target", + brief: "[/] [path] or [path]", + parse: String, + }, + }, + flags: { + kind: { + kind: "parsed", + parse: String, + brief: `Event kind filter (${REPLAY_EVENT_KINDS.join(", ")})`, + variadic: true, + optional: true, + }, + path: { + kind: "parsed", + parse: String, + brief: "Filter events by parsed URL pathname", + optional: true, + }, + search: { + kind: "parsed", + parse: String, + brief: + "Filter events by text in labels, messages, URLs, selectors, or data", + optional: true, + }, + around: { + kind: "parsed", + parse: parseOffsetFlag, + brief: "Show an evidence window around this replay offset", + optional: true, + }, + limit: { + kind: "parsed", + parse: parseLimit, + brief: `Number of events (${LIST_MIN_LIMIT}-${LIST_MAX_LIMIT})`, + default: String(DEFAULT_LIMIT), + }, + raw: { + kind: "boolean", + brief: "Include raw source frame payloads in JSON output", + default: false, + }, + fresh: FRESH_FLAG, + }, + aliases: { + ...FRESH_ALIASES, + k: "kind", + n: "limit", + q: "search", + }, + }, + async *func(this: SentryContext, flags: EventListFlags, ...args: string[]) { + applyFreshFlag(flags); + const kinds = parseEventKinds(flags.kind); + const window = resolveWindow(flags); + const { path, targetArgs } = splitTargetAndPathArgs(args, flags.path); + + const parsedArgs = parseReplayTargetArgs(targetArgs, USAGE_HINT); + const replayId = validateHexId(parsedArgs.replayId, "replay ID"); + const resolved = await resolveOrgOptionalProjectFromArg( + parsedArgs.targetArg, + this.cwd, + COMMAND_NAME + ); + + const replay = await withProgress( + { message: "Fetching replay metadata...", json: flags.json }, + () => + fetchReplayDetailsForCommand( + resolved.org, + replayId, + "sentry replay event list" + ) + ); + + validateReplayProjectScope({ + replay, + projectId: resolved.projectData?.id, + replayId, + org: resolved.org, + project: resolved.project, + command: "sentry replay event list", + }); + + const segments = await fetchReplaySegmentsForCommand({ + org: resolved.org, + replay, + replayId, + project: resolved.project, + json: flags.json, + }); + + const allEvents = extractNormalizedReplayEvents(replay, segments, { + includeRaw: flags.raw, + }); + const filtered = filterNormalizedReplayEvents(allEvents, { + kinds, + path, + contains: flags.search, + ...window, + }); + const events = filtered.slice(0, flags.limit); + const truncated = filtered.length > events.length; + + for (const event of events) { + yield new CommandOutput(event); + } + + const countText = `Showing ${events.length} of ${filtered.length} replay event${filtered.length === 1 ? "" : "s"}.`; + const truncationHint = truncated + ? ` Increase --limit or narrow filters to inspect the remaining ${filtered.length - events.length}.` + : ""; + return { hint: `${countText}${truncationHint}` }; + }, +}); diff --git a/src/commands/replay/index.ts b/src/commands/replay/index.ts index a5c658703..9748c92d9 100644 --- a/src/commands/replay/index.ts +++ b/src/commands/replay/index.ts @@ -5,21 +5,28 @@ */ import { buildRouteMap } from "../../lib/route-map.js"; +import { eventRoute } from "./event/index.js"; import { listCommand } from "./list.js"; +import { summarizeCommand } from "./summarize.js"; import { viewCommand } from "./view.js"; export const replayRoute = buildRouteMap({ routes: { + event: eventRoute, list: listCommand, + summarize: summarizeCommand, view: viewCommand, }, + aliases: { events: "event" }, 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" + + " list Search replay sessions in an org or project\n" + + " event Expand one replay into a normalized event timeline (alias: events)\n" + + " summarize Summarize replay behavior and friction signals\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 index ebc10a7fa..f0642f1c4 100644 --- a/src/commands/replay/list.ts +++ b/src/commands/replay/list.ts @@ -60,7 +60,7 @@ import { type ListFlags = { readonly environment?: readonly string[]; readonly limit: number; - readonly query?: string; + readonly search?: string; readonly sort: ReplaySortValue; readonly period: TimeRange; readonly json: boolean; @@ -78,14 +78,25 @@ type ReplayListResult = { project?: string; }; -type ReplaySortKey = "date" | "oldest" | "duration" | "errors" | "activity"; +type ReplaySortKey = + | "activity" + | "date" + | "dead" + | "duration" + | "errors" + | "oldest" + | "rage" + | "warnings"; const SORT_MAP: Record = { + activity: "-activity", date: "-started_at", - oldest: "started_at", + dead: "-count_dead_clicks", duration: "-duration", errors: "-count_errors", - activity: "-activity", + oldest: "started_at", + rage: "-count_rage_clicks", + warnings: "-count_warnings", }; const DEFAULT_PERIOD = LIST_PERIOD_FLAG.default; @@ -176,10 +187,10 @@ function formatScope(org: string, project?: string): string { function appendReplayFlags( base: string, - flags: Pick + flags: Pick ): string { const parts: string[] = []; - appendQueryHint(parts, flags.query); + appendQueryHint(parts, flags.search); appendSortHint(parts, flags.sort, DEFAULT_SORT); if (flags.environment && flags.environment.length > 0) { for (const environment of flags.environment) { @@ -193,7 +204,7 @@ function appendReplayFlags( function nextPageHint( org: string, project: string | undefined, - flags: Pick + flags: Pick ): string { return appendReplayFlags( `sentry replay list ${formatScope(org, project)} -c next`, @@ -204,7 +215,7 @@ function nextPageHint( function prevPageHint( org: string, project: string | undefined, - flags: Pick + flags: Pick ): string { return appendReplayFlags( `sentry replay list ${formatScope(org, project)} -c prev`, @@ -291,7 +302,7 @@ export const listCommand = buildListCommand("replay", { brief: `Number of replays (${LIST_MIN_LIMIT}-${LIST_MAX_LIMIT})`, default: String(LIST_DEFAULT_LIMIT), }, - query: { + search: { kind: "parsed", parse: sanitizeQuery, brief: "Search query (Sentry replay search syntax)", @@ -308,7 +319,7 @@ export const listCommand = buildListCommand("replay", { kind: "parsed", parse: parseSort, brief: - "Sort by: date, oldest, duration, errors, activity, or a raw replay sort field", + "Sort by: date, oldest, duration, errors, warnings, rage, dead, activity, or a raw replay sort field", default: "date", }, period: LIST_PERIOD_FLAG, @@ -317,7 +328,7 @@ export const listCommand = buildListCommand("replay", { ...PERIOD_ALIASES, e: "environment", n: "limit", - q: "query", + q: "search", s: "sort", }, }, @@ -325,7 +336,7 @@ export const listCommand = buildListCommand("replay", { const { cwd } = this; const timeRange = flags.period; const environment = parseReplayEnvironmentFilter(flags.environment); - const { query } = flags; + const { search } = flags; const resolved = await resolveOrgOptionalProjectFromArg( target, @@ -339,7 +350,7 @@ export const listCommand = buildListCommand("replay", { { env: environment?.join(","), sort: flags.sort, - q: query, + q: search, period: serializeTimeRange(timeRange), } ); @@ -359,7 +370,7 @@ export const listCommand = buildListCommand("replay", { environment, fields: [...REPLAY_LIST_FIELDS], limit: flags.limit, - query, + query: search, projectSlugs: resolved.project ? [resolved.project] : undefined, sort: flags.sort, cursor, diff --git a/src/commands/replay/shared.ts b/src/commands/replay/shared.ts new file mode 100644 index 000000000..af285dda3 --- /dev/null +++ b/src/commands/replay/shared.ts @@ -0,0 +1,110 @@ +/** + * Shared helpers for replay commands. + */ + +import { getReplay, getReplayRecordingSegments } from "../../lib/api-client.js"; +import { ApiError, ResolutionError } from "../../lib/errors.js"; +import { withProgress } from "../../lib/polling.js"; +import type { + ReplayDetails, + ReplayRecordingSegments, +} from "../../types/index.js"; + +type ReplayProjectScopeValidation = { + replay: ReplayDetails; + projectId?: string; + replayId: string; + org: string; + project?: string; + command: string; +}; + +type ReplaySegmentsOptions = { + org: string; + replay: ReplayDetails; + replayId: string; + project?: string; + json: boolean; +}; + +export async function fetchReplayDetailsForCommand( + org: string, + replayId: string, + command: string +): Promise { + try { + return await getReplay(org, replayId); + } catch (error) { + if (error instanceof ApiError && error.status === 404) { + throw new ResolutionError( + `Replay '${replayId}'`, + "not found", + `${command} ${org}/${replayId}`, + [ + "Check that you are querying the right organization", + "The replay may be past your retention window", + ] + ); + } + throw error; + } +} + +export function validateReplayProjectScope({ + replay, + projectId, + replayId, + org, + project, + command, +}: ReplayProjectScopeValidation): void { + if (project === undefined || projectId === undefined) { + return; + } + + const replayProjectId = replay.project_id; + if (replayProjectId === null || replayProjectId === undefined) { + return; + } + + if (String(projectId) !== String(replayProjectId)) { + throw new ResolutionError( + `Replay '${replayId}'`, + `is not in project '${project}'`, + `${command} ${org}/${project}/${replayId}`, + [`Open the org-scoped replay instead: ${command} ${org}/${replayId}`] + ); + } +} + +export async function fetchReplaySegmentsForCommand({ + org, + replay, + replayId, + project, + json, +}: ReplaySegmentsOptions): Promise { + const projectSlugOrId = + replay.project_id !== null && replay.project_id !== undefined + ? String(replay.project_id) + : project; + + if ( + !projectSlugOrId || + replay.is_archived || + (replay.count_segments ?? 0) <= 0 + ) { + return []; + } + + return await withProgress( + { + message: `Fetching replay recording segments (${replay.count_segments})...`, + json, + }, + () => + getReplayRecordingSegments(org, projectSlugOrId, replayId, { + expectedSegments: replay.count_segments, + }) + ); +} diff --git a/src/commands/replay/summarize.ts b/src/commands/replay/summarize.ts new file mode 100644 index 000000000..a33374209 --- /dev/null +++ b/src/commands/replay/summarize.ts @@ -0,0 +1,345 @@ +/** + * sentry replay summarize + * + * Summarize Session Replay behavior and deterministic friction signals. + */ + +import type { SentryContext } from "../../context.js"; +import { validateLimit } from "../../lib/arg-parsing.js"; +import { buildCommand } from "../../lib/command.js"; +import { escapeMarkdownCell, 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 { formatDurationCompactMs } from "../../lib/formatters/time-utils.js"; +import { validateHexId } from "../../lib/hex-id.js"; +import { + applyFreshFlag, + FRESH_ALIASES, + FRESH_FLAG, +} from "../../lib/list-command.js"; +import { withProgress } from "../../lib/polling.js"; +import { extractNormalizedReplayEvents } from "../../lib/replay-events.js"; +import { summarizeReplay } from "../../lib/replay-summary.js"; +import { resolveOrgOptionalProjectFromArg } from "../../lib/resolve-target.js"; +import { + type ReplayFrictionSignal, + type ReplayRouteSummary, + type ReplaySummaryOutput, + ReplaySummaryOutputSchema, +} from "../../types/index.js"; +import { + fetchReplayDetailsForCommand, + fetchReplaySegmentsForCommand, + validateReplayProjectScope, +} from "./shared.js"; +import { parseReplayTargetArgs } from "./target.js"; + +type SummaryFlags = { + readonly fields?: string[]; + readonly fresh: boolean; + readonly json: boolean; + readonly "limit-events": number; + readonly "limit-signals": number; + readonly path?: string; +}; + +const COMMAND_NAME = "replay summarize"; +const USAGE_HINT = + "sentry replay summarize [//] | "; +const DEFAULT_SIGNAL_LIMIT = 10; +const DEFAULT_EVENT_LIMIT = 12; + +function parseSignalLimit(value: string): number { + return validateLimit(value, 0, 50); +} + +function parseEventLimit(value: string): number { + return validateLimit(value, 0, 50); +} + +function formatOffset(offsetMs: number | null | undefined): string { + return offsetMs === null || offsetMs === undefined + ? "-" + : formatDurationCompactMs(offsetMs); +} + +function formatDurationSeconds(seconds: number | null | undefined): string { + return seconds === null || seconds === undefined ? "-" : `${seconds}s`; +} + +const SIGNAL_COLUMNS: Column[] = [ + { + header: "OFFSET", + value: (signal) => formatOffset(signal.offsetMs), + minWidth: 8, + shrinkable: false, + }, + { + header: "SEVERITY", + value: (signal) => signal.severity, + minWidth: 8, + }, + { + header: "SIGNAL", + value: (signal) => signal.kind, + minWidth: 14, + }, + { + header: "MESSAGE", + value: (signal) => escapeMarkdownCell(signal.message), + minWidth: 28, + truncate: true, + }, +]; + +const ROUTE_COLUMNS: Column[] = [ + { + header: "ENTER", + value: (route) => formatOffset(route.enteredAtOffsetMs), + minWidth: 8, + shrinkable: false, + }, + { + header: "DURATION", + value: (route) => formatOffset(route.durationMs), + minWidth: 8, + shrinkable: false, + }, + { + header: "EVENTS", + value: (route) => String(route.eventCount), + align: "right", + minWidth: 6, + }, + { + header: "INTERACTIONS", + value: (route) => formatRouteInteractions(route), + minWidth: 12, + truncate: true, + }, + { + header: "PATH", + value: (route) => escapeMarkdownCell(route.path), + minWidth: 24, + truncate: true, + }, + { + header: "NEXT", + value: (route) => escapeMarkdownCell(route.nextPath ?? "-"), + minWidth: 16, + truncate: true, + }, +]; + +function formatCount(count: number, singular: string, plural = `${singular}s`) { + return `${count} ${count === 1 ? singular : plural}`; +} + +function formatNonZeroCount( + count: number, + singular: string, + plural = `${singular}s` +): string | undefined { + return count > 0 ? formatCount(count, singular, plural) : undefined; +} + +function formatRouteInteractions(route: ReplayRouteSummary): string { + const parts = [ + formatNonZeroCount(route.counts.clicks, "click"), + formatNonZeroCount(route.counts.taps, "tap"), + formatNonZeroCount(route.counts.inputs, "input"), + formatNonZeroCount(route.counts.scrolls, "scroll"), + formatNonZeroCount(route.counts.focuses, "focus", "focuses"), + formatNonZeroCount(route.counts.blurs, "blur"), + ].filter((part): part is string => Boolean(part)); + return parts.length > 0 ? parts.join(", ") : "-"; +} + +function formatRecordingStats(summary: ReplaySummaryOutput): string { + return [ + summary.recording.segmentCount !== null + ? formatCount(summary.recording.segmentCount, "segment") + : undefined, + summary.recording.frameCount !== null + ? formatCount(summary.recording.frameCount, "raw frame") + : undefined, + formatCount(summary.recording.normalizedEventCount, "normalized event"), + summary.recording.focusedEventCount !== null + ? formatCount(summary.recording.focusedEventCount, "focused event") + : undefined, + ] + .filter((part): part is string => Boolean(part)) + .join(", "); +} + +function formatEventCounts(summary: ReplaySummaryOutput): string { + return [ + formatCount(summary.counts.total, "event"), + formatCount(summary.counts.clicks, "click"), + formatCount(summary.counts.taps, "tap"), + formatCount(summary.counts.inputs, "input"), + formatCount(summary.counts.scrolls, "scroll"), + formatCount(summary.counts.focuses, "focus", "focuses"), + formatCount(summary.counts.blurs, "blur"), + formatCount(summary.counts.network, "network event"), + formatCount(summary.counts.errors, "error"), + ].join(", "); +} + +function jsonTransformSummary( + summary: ReplaySummaryOutput, + fields?: string[] +): unknown { + return fields && fields.length > 0 ? filterFields(summary, fields) : summary; +} + +function formatSummaryHuman(summary: ReplaySummaryOutput): string { + const lines = [ + `Replay summary for ${summary.org}/${summary.replayId.slice(0, 8)}`, + "", + `Platform: ${summary.platform ?? "-"}`, + `SDK: ${[summary.sdkName, summary.sdkVersion].filter(Boolean).join(" ") || "-"}`, + `Replay type: ${summary.replayType ?? "-"}`, + `Entry: ${summary.entryUrl ?? "-"}`, + `Exit: ${summary.exitUrl ?? "-"}`, + `Duration: ${formatDurationSeconds(summary.durationSeconds)}`, + `Recording: ${formatRecordingStats(summary)}`, + `Events: ${formatEventCounts(summary)}`, + ]; + + if (summary.focusPath) { + lines.push(`Focus path: ${summary.focusPath}`); + } + + if (summary.signals.length > 0) { + lines.push( + "", + "Signals:", + "", + formatTable(summary.signals, SIGNAL_COLUMNS) + ); + } else { + lines.push("", "Signals: none detected"); + } + + if (summary.routes.length > 0) { + lines.push("", "Routes:", "", formatTable(summary.routes, ROUTE_COLUMNS)); + } + + return lines.join("\n"); +} + +export const summarizeCommand = buildCommand({ + docs: { + brief: "Summarize Session Replay behavior", + fullDescription: + "Summarize a Session Replay into route flow, event counts, timing facts, and deterministic friction signals.\n\n" + + "This command does not use AI. It returns factual evidence that an agent can use for analysis.\n\n" + + "Recording parsing is best-effort. Summary metadata includes platform, SDK, replay type, and raw recording counts so agents can tell when a replay fetched successfully but produced sparse normalized events.\n\n" + + "Examples:\n" + + " sentry replay summarize sentry/346789a703f6454384f1de473b8b9fcc --json\n" + + " sentry replay summarize sentry/346789a703f6454384f1de473b8b9fcc --path /signup --json\n" + + " sentry replay summarize sentry/cli/346789a703f6454384f1de473b8b9fcc --limit-signals 20 --json", + }, + output: { + human: formatSummaryHuman, + jsonTransform: jsonTransformSummary, + schema: ReplaySummaryOutputSchema, + }, + parameters: { + positional: { + kind: "array", + parameter: { + placeholder: "replay-id-or-url", + brief: "[/] or ", + parse: String, + }, + }, + flags: { + path: { + kind: "parsed", + parse: String, + brief: "Focus summary on events from this URL pathname", + optional: true, + }, + "limit-signals": { + kind: "parsed", + parse: parseSignalLimit, + brief: "Maximum friction signals to include (0-50)", + default: String(DEFAULT_SIGNAL_LIMIT), + }, + "limit-events": { + kind: "parsed", + parse: parseEventLimit, + brief: "Maximum notable events to include (0-50)", + default: String(DEFAULT_EVENT_LIMIT), + }, + fresh: FRESH_FLAG, + }, + aliases: { + ...FRESH_ALIASES, + }, + }, + async *func(this: SentryContext, flags: SummaryFlags, ...args: string[]) { + applyFreshFlag(flags); + + const parsedArgs = parseReplayTargetArgs(args, USAGE_HINT); + const replayId = validateHexId(parsedArgs.replayId, "replay ID"); + const resolved = await resolveOrgOptionalProjectFromArg( + parsedArgs.targetArg, + this.cwd, + COMMAND_NAME + ); + + const replay = await withProgress( + { message: "Fetching replay metadata...", json: flags.json }, + () => + fetchReplayDetailsForCommand( + resolved.org, + replayId, + "sentry replay summarize" + ) + ); + + validateReplayProjectScope({ + replay, + projectId: resolved.projectData?.id, + replayId, + org: resolved.org, + project: resolved.project, + command: "sentry replay summarize", + }); + + const segments = await fetchReplaySegmentsForCommand({ + org: resolved.org, + replay, + replayId, + project: resolved.project, + json: flags.json, + }); + + const events = extractNormalizedReplayEvents(replay, segments); + const recordingFrameCount = segments.reduce( + (count, segment) => count + segment.length, + 0 + ); + const summary = summarizeReplay(replay, events, { + org: resolved.org, + project: resolved.project, + focusPath: flags.path, + maxSignals: flags["limit-signals"], + maxNotableEvents: flags["limit-events"], + recordingFrameCount, + recordingSegmentCount: segments.length, + }); + + yield new CommandOutput(summary); + return { + hint: + summary.signals.length > 0 + ? `Detected ${summary.signals.length} friction signal${summary.signals.length === 1 ? "" : "s"}. Cite replay ID and offset when reporting findings.` + : "No deterministic friction signals detected. Use route flow and notable events for behavior context.", + }; + }, +}); diff --git a/src/commands/replay/target.ts b/src/commands/replay/target.ts new file mode 100644 index 000000000..32f73f8f6 --- /dev/null +++ b/src/commands/replay/target.ts @@ -0,0 +1,118 @@ +/** + * Shared replay target parsing helpers. + * + * Keeps `replay view` and replay subcommands aligned on accepted target forms: + * bare replay IDs, `/`, `//`, + * ` `, and Sentry replay URLs. + */ + +import { + detectSwappedViewArgs, + parseSlashSeparatedArg, +} from "../../lib/arg-parsing.js"; +import { ContextError, ValidationError } from "../../lib/errors.js"; +import { tryNormalizeHexId } from "../../lib/hex-id.js"; +import { + applySentryUrlContext, + parseSentryUrl, +} from "../../lib/sentry-url-parser.js"; + +export type ParsedReplayTargetArgs = { + replayId: string; + targetArg: string | undefined; + warning?: string; +}; + +export const REPLAY_TARGET_USAGE = + "sentry replay [//] | "; + +/** + * Parse a single positional argument as a replay target. + * + * The single-slash case (`org/id`) needs special handling because 32-char hex + * replay IDs look valid to the generic slash parser's ID extraction. + */ +function parseSingleReplayTargetArg( + arg: string, + usageHint: string +): ParsedReplayTargetArgs { + const trimmed = arg.trim(); + if (!trimmed) { + throw new ContextError("Replay ID", usageHint, []); + } + + 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", usageHint, []); + } + return { replayId: normalizedReplayId, targetArg: `${org}/` }; + } + + const { id: replayId, targetArg } = parseSlashSeparatedArg( + trimmed, + "Replay ID", + usageHint + ); + return { replayId, targetArg }; +} + +/** + * Parse replay command positional arguments. + */ +export function parseReplayTargetArgs( + args: string[], + usageHint = REPLAY_TARGET_USAGE +): ParsedReplayTargetArgs { + if (args.length === 0) { + throw new ContextError("Replay ID", usageHint, []); + } + if (args.length > 2) { + throw new ValidationError( + `Too many positional arguments (got ${args.length}, expected at most 2).\n\nUsage: ${usageHint}`, + "positional" + ); + } + + const first = args[0]; + if (!first) { + throw new ContextError("Replay ID", usageHint, []); + } + + 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", usageHint, [ + "Pass a replay URL: https://sentry.io/organizations/{org}/explore/replays/{replayId}/", + ]); + } + + if (args.length === 1) { + return parseSingleReplayTargetArg(first, usageHint); + } + + const second = args[1]; + if (!second) { + throw new ContextError("Replay ID", usageHint, []); + } + + 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 }; +} diff --git a/src/commands/replay/view.ts b/src/commands/replay/view.ts index d2f37ae27..b0291a5e2 100644 --- a/src/commands/replay/view.ts +++ b/src/commands/replay/view.ts @@ -12,18 +12,9 @@ import { 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 { ApiError, ResolutionError } from "../../lib/errors.js"; import { filterFields } from "../../lib/formatters/json.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { @@ -32,7 +23,7 @@ import { type ReplayViewData, replayHint, } from "../../lib/formatters/replay.js"; -import { tryNormalizeHexId, validateHexId } from "../../lib/hex-id.js"; +import { validateHexId } from "../../lib/hex-id.js"; import { applyFreshFlag, FRESH_ALIASES, @@ -40,10 +31,6 @@ import { } 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, @@ -52,6 +39,7 @@ import type { ReplayRelatedTrace, } from "../../types/index.js"; import { ReplayViewOutputSchema } from "../../types/index.js"; +import { parseReplayTargetArgs } from "./target.js"; type ViewFlags = { readonly json: boolean; @@ -60,12 +48,6 @@ type ViewFlags = { readonly fields?: string[]; }; -type ParsedPositionalArgs = { - replayId: string; - targetArg: string | undefined; - warning?: string; -}; - const USAGE_HINT = "sentry replay view [//] | "; const MAX_ACTIVITY_EVENTS = 6; @@ -74,44 +56,6 @@ 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. * @@ -122,55 +66,8 @@ function parseSingleArg(arg: string): ParsedPositionalArgs { * - ` ` * - `` */ -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 }; -} +export const parsePositionalArgs = (args: string[]) => + parseReplayTargetArgs(args, USAGE_HINT); type ReplayProjectScope = { org: string; @@ -232,7 +129,8 @@ async function fetchReplayActivity( const segments = await getReplayRecordingSegments( org, String(replay.project_id), - replay.id + replay.id, + { expectedSegments: replay.count_segments } ); return extractReplayActivityEvents(segments, MAX_ACTIVITY_EVENTS); } catch (error) { diff --git a/src/lib/api/replays.ts b/src/lib/api/replays.ts index fbf5dfcdb..a1a722b29 100644 --- a/src/lib/api/replays.ts +++ b/src/lib/api/replays.ts @@ -24,6 +24,7 @@ import { API_MAX_PER_PAGE, apiRequestToRegion, autoPaginate, + MAX_PAGINATION_PAGES, type PaginatedResponse, parseLinkHeader, } from "./infrastructure.js"; @@ -104,6 +105,29 @@ type FetchReplayPageOptions = { cursor?: string; }; +type FetchReplayRecordingSegmentsPageOptions = { + regionUrl: string; + orgSlug: string; + projectSlugOrId: string; + replayId: string; + cursor?: string; +}; + +/** Options for {@link getReplayRecordingSegments}. */ +export type GetReplayRecordingSegmentsOptions = { + /** + * Soft stop hint: total segment count from replay metadata. + * + * Pagination stops as soon as this many segments have been fetched, even if + * the API advertises another cursor. Because metadata can be slightly stale, + * the result is NOT trimmed to this value — callers may receive more segments + * than expected if the final page overshoots. + * + * Omit (or pass null/undefined) to fetch all pages up to MAX_PAGINATION_PAGES. + */ + expectedSegments?: number | null; +}; + /** * Coerce numeric project_id to string for consistent downstream handling. * @@ -214,22 +238,62 @@ export async function getReplay( * 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. + * + * Uses a manual pagination loop rather than {@link autoPaginate} because + * `autoPaginate` trims results to `limit`, but `expectedSegments` is a soft + * hint — trimming could silently drop real segments if metadata is stale. */ export async function getReplayRecordingSegments( orgSlug: string, projectSlugOrId: string, - replayId: string + replayId: string, + options: GetReplayRecordingSegmentsOptions = {} ): Promise { const regionUrl = await resolveOrgRegion(orgSlug); - const { data } = await apiRequestToRegion( + const expectedSegments = options.expectedSegments ?? Number.POSITIVE_INFINITY; + const segments: ReplayRecordingSegments = []; + let cursor: string | undefined; + + for (let page = 0; page < MAX_PAGINATION_PAGES; page += 1) { + const { data, nextCursor } = await fetchReplayRecordingSegmentsPage({ + regionUrl, + orgSlug, + projectSlugOrId, + replayId, + cursor, + }); + + segments.push(...data); + + if (segments.length >= expectedSegments || !nextCursor) { + return segments; + } + + cursor = nextCursor; + } + + return segments; +} + +async function fetchReplayRecordingSegmentsPage( + options: FetchReplayRecordingSegmentsPageOptions +): Promise> { + const { cursor, orgSlug, projectSlugOrId, regionUrl, replayId } = options; + const { data, headers } = await apiRequestToRegion( regionUrl, `/projects/${orgSlug}/${projectSlugOrId}/replays/${replayId}/recording-segments/`, { - params: { download: true }, + params: { + cursor, + download: true, + per_page: API_MAX_PER_PAGE, + }, schema: ReplayRecordingSegmentsSchema, } ); - return data; + + const { nextCursor } = parseLinkHeader(headers.get("link") ?? null); + return { data, nextCursor }; } /** diff --git a/src/lib/command.ts b/src/lib/command.ts index 97bfbc784..eb46353ea 100644 --- a/src/lib/command.ts +++ b/src/lib/command.ts @@ -556,6 +556,7 @@ export function buildCommand< renderCommandOutput(stdout, value.data, outputConfig, renderer, { json: Boolean(flags.json), fields: flags.fields as string[] | undefined, + jsonCompact: outputConfig.jsonLines, clearPrefix: pendingClear ? "\x1b[H\x1b[J" : undefined, }); pendingClear = false; diff --git a/src/lib/formatters/output.ts b/src/lib/formatters/output.ts index 8702810de..205f71ac5 100644 --- a/src/lib/formatters/output.ts +++ b/src/lib/formatters/output.ts @@ -145,6 +145,14 @@ export type OutputConfig = { * - `generate-skill.ts`: SKILL.md field tables for AI agents */ schema?: ZodType; + /** + * Emit compact one-line JSON for each yielded value. + * + * Use this for commands that intentionally yield a stream of records in + * `--json` mode so the output is newline-delimited JSON without a separate + * command-specific flag. + */ + jsonLines?: boolean; }; /** @@ -212,6 +220,8 @@ type RenderContext = { json: boolean; /** Pre-parsed `--fields` value */ fields?: string[]; + /** Emit compact JSON instead of pretty JSON, useful for JSONL streams. */ + jsonCompact?: boolean; /** ANSI prefix to prepend to the output (e.g., clear-screen escape) */ clearPrefix?: string; }; @@ -257,12 +267,17 @@ function applyJsonExclude( * is handed off directly without serialization. Otherwise it is * JSON-stringified and written as a single line. */ -function emitJsonObject(stdout: Writer, obj: unknown): void { +function emitJsonObject( + stdout: Writer, + obj: unknown, + options: { compact?: boolean } = {} +): void { if (stdout.captureObject) { stdout.captureObject(obj); return; } - stdout.write(`${formatJson(obj)}\n`); + const json = options.compact ? JSON.stringify(obj) : formatJson(obj); + stdout.write(`${json}\n`); } /** @@ -297,7 +312,7 @@ export function renderCommandOutput( if (transformed === undefined) { return; } - emitJsonObject(stdout, transformed); + emitJsonObject(stdout, transformed, { compact: ctx.jsonCompact }); return; } @@ -306,7 +321,7 @@ export function renderCommandOutput( ctx.fields && ctx.fields.length > 0 ? filterFields(excluded, ctx.fields) : excluded; - emitJsonObject(stdout, final); + emitJsonObject(stdout, final, { compact: ctx.jsonCompact }); return; } diff --git a/src/lib/replay-events.ts b/src/lib/replay-events.ts new file mode 100644 index 000000000..7a3df1520 --- /dev/null +++ b/src/lib/replay-events.ts @@ -0,0 +1,702 @@ +/** + * Normalized Session Replay event extraction. + * + * Converts rrweb frames and Sentry custom replay frames into stable, + * agent-readable rows. The normalized shape intentionally preserves evidence + * pointers (segment/frame/offset) while avoiding raw payloads unless callers + * explicitly request them. + */ + +import type { + ReplayDetails, + ReplayEvent, + ReplayEventKind, + ReplayRecordingSegments, +} from "../types/index.js"; +import { ValidationError } from "./errors.js"; +import { getReplayUrlParts, replayUrlPathMatches } from "./replay-search.js"; +import { parseRelativeParts, UNIT_SECONDS } from "./time-range.js"; + +type RecordValue = Record; + +type EventContext = { + replayStartMs: number | null; + replayId: string; + includeRaw: boolean; + currentUrl?: string; +}; + +type FrameLocation = { + ctx: EventContext; + frame: RecordValue; + segmentIndex: number; + frameIndex: number; +}; + +export type ReplayEventFilters = { + kinds?: readonly ReplayEventKind[]; + url?: string; + path?: string; + contains?: string; + selector?: string; + fromMs?: number; + toMs?: number; +}; + +const RRWEB_EVENT_TYPES: Record = { + 0: "DomContentLoaded", + 1: "Load", + 2: "FullSnapshot", + 3: "IncrementalSnapshot", + 4: "Meta", + 5: "Custom", + 6: "Plugin", +}; + +const RRWEB_INCREMENTAL_SOURCES: Record = { + 0: "Mutation", + 1: "MouseMove", + 2: "MouseInteraction", + 3: "Scroll", + 4: "ViewportResize", + 5: "Input", + 6: "TouchMove", + 7: "MediaInteraction", + 8: "StyleSheetRule", + 9: "CanvasMutation", + 10: "Font", + 11: "Log", + 12: "Drag", + 13: "StyleDeclaration", + 14: "Selection", +}; + +const RRWEB_MOUSE_INTERACTIONS: Record = { + 0: "MouseUp", + 1: "MouseDown", + 2: "Click", + 3: "ContextMenu", + 4: "DblClick", + 5: "Focus", + 6: "Blur", + 7: "TouchStart", + 8: "TouchMove", + 9: "TouchEnd", +}; + +const CLICK_LIKE_CUSTOM_TAGS = new Set(["click", "deadClick", "rageClick"]); +const MASKED_INPUT_RE = /^\*+$/; +const SECONDS_OFFSET_RE = /^\d+(\.\d+)?$/; + +function isRecord(value: unknown): value is RecordValue { + return typeof value === "object" && value !== null; +} + +function firstString(...values: unknown[]): string | undefined { + return values.find( + (value): value is string => typeof value === "string" && value.length > 0 + ); +} + +function firstNumber(...values: unknown[]): number | undefined { + return values.find( + (value): value is number => + typeof value === "number" && Number.isFinite(value) + ); +} + +function timestampToMillis(value: unknown): number | null { + if (typeof value === "string") { + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? null : parsed; + } + + if (typeof value !== "number" || !Number.isFinite(value)) { + return null; + } + + // rrweb timestamps are epoch milliseconds. Some Sentry payloads use epoch + // seconds, so normalize realistic second values as a fallback. + if (value > 1_000_000_000 && value < 10_000_000_000) { + return Math.round(value * 1000); + } + + return Math.round(value); +} + +function eventTimeFields( + frame: RecordValue, + replayStartMs: number | null +): Pick { + const timestampMs = timestampToMillis(frame.timestamp); + return { + offsetMs: + timestampMs !== null && replayStartMs !== null + ? Math.max(0, timestampMs - replayStartMs) + : null, + timestamp: + timestampMs !== null ? new Date(timestampMs).toISOString() : null, + }; +} + +function buildBaseEvent(params: { + ctx: EventContext; + frame: RecordValue; + segmentIndex: number; + frameIndex: number; + kind: ReplayEventKind; + category: string; + label?: string; + message?: string; + url?: string; + selector?: string; + nodeId?: string | number; + rawType?: string; + rawSource?: string; + data?: RecordValue; +}): ReplayEvent { + const { ctx, frame, segmentIndex, frameIndex, ...event } = params; + const url = event.url ?? ctx.currentUrl ?? null; + const urlParts = getReplayUrlParts(url); + return { + replayId: ctx.replayId, + segmentIndex, + frameIndex, + ...eventTimeFields(frame, ctx.replayStartMs), + kind: event.kind, + category: event.category, + label: event.label ?? null, + message: event.message ?? null, + url, + urlPath: urlParts?.path ?? null, + urlQuery: urlParts?.query ?? null, + selector: event.selector ?? null, + nodeId: event.nodeId ?? null, + rawType: event.rawType ?? null, + rawSource: event.rawSource ?? null, + ...(event.data ? { data: event.data } : {}), + ...(ctx.includeRaw ? { raw: frame } : {}), + }; +} + +function summarizeMutationData(data: RecordValue): RecordValue { + return { + adds: Array.isArray(data.adds) ? data.adds.length : undefined, + removes: Array.isArray(data.removes) ? data.removes.length : undefined, + texts: Array.isArray(data.texts) ? data.texts.length : undefined, + attributes: Array.isArray(data.attributes) + ? data.attributes.length + : undefined, + }; +} + +function normalizeMouseInteraction( + location: FrameLocation, + data: RecordValue +): ReplayEvent | null { + const interactionType = firstNumber(data.type); + const interactionName = + interactionType !== undefined + ? (RRWEB_MOUSE_INTERACTIONS[interactionType] ?? String(interactionType)) + : undefined; + + let kind: ReplayEventKind | null = null; + if (interactionName === "Click" || interactionName === "DblClick") { + kind = "click"; + } else if ( + interactionName === "TouchStart" || + interactionName === "TouchEnd" + ) { + kind = "tap"; + } else if (interactionName === "Focus") { + kind = "focus"; + } else if (interactionName === "Blur") { + kind = "blur"; + } + + if (!kind) { + return null; + } + + const selector = firstString(data.selector); + const nodeId = firstNumber(data.id); + return buildBaseEvent({ + ...location, + kind, + category: "interaction", + label: selector ?? interactionName, + selector, + nodeId, + rawType: "IncrementalSnapshot", + rawSource: interactionName ?? "MouseInteraction", + data: { + x: firstNumber(data.x), + y: firstNumber(data.y), + interaction: interactionName, + }, + }); +} + +function normalizeIncrementalFrame( + ctx: EventContext, + frame: RecordValue, + segmentIndex: number, + frameIndex: number +): ReplayEvent | null { + const location = { ctx, frame, segmentIndex, frameIndex }; + const data = isRecord(frame.data) ? frame.data : {}; + const source = firstNumber(data.source); + const sourceName = + source !== undefined + ? (RRWEB_INCREMENTAL_SOURCES[source] ?? String(source)) + : undefined; + + switch (sourceName) { + case "Mutation": + return buildBaseEvent({ + ...location, + kind: "mutation", + category: "dom", + label: "mutation", + rawType: "IncrementalSnapshot", + rawSource: sourceName, + data: summarizeMutationData(data), + }); + case "MouseInteraction": + return normalizeMouseInteraction(location, data); + case "Scroll": + return buildBaseEvent({ + ...location, + kind: "scroll", + category: "interaction", + nodeId: firstNumber(data.id), + rawType: "IncrementalSnapshot", + rawSource: sourceName, + data: { x: firstNumber(data.x), y: firstNumber(data.y) }, + }); + case "ViewportResize": + return buildBaseEvent({ + ...location, + kind: "viewport", + category: "viewport", + label: "resize", + rawType: "IncrementalSnapshot", + rawSource: sourceName, + data: { + width: firstNumber(data.width), + height: firstNumber(data.height), + }, + }); + case "Input": + return buildBaseEvent({ + ...location, + kind: "input", + category: "input", + nodeId: firstNumber(data.id), + rawType: "IncrementalSnapshot", + rawSource: sourceName, + data: { + textLength: + typeof data.text === "string" ? data.text.length : undefined, + isChecked: + typeof data.isChecked === "boolean" ? data.isChecked : undefined, + masked: + typeof data.text === "string" && MASKED_INPUT_RE.test(data.text), + }, + }); + case "Log": { + const level = firstString(data.level); + const normalizedLevel = level?.toLowerCase(); + const message = Array.isArray(data.payload) + ? data.payload.map(String).join(" ") + : firstString(data.payload, data.message); + return buildBaseEvent({ + ...location, + kind: normalizedLevel === "error" ? "error" : "console", + category: "console", + label: level ?? "console", + message, + rawType: "IncrementalSnapshot", + rawSource: sourceName, + data: { level }, + }); + } + default: + return null; + } +} + +function breadcrumbKind(payload: RecordValue): ReplayEventKind { + const category = firstString(payload.category)?.toLowerCase() ?? ""; + const type = firstString(payload.type)?.toLowerCase() ?? ""; + const level = firstString(payload.level)?.toLowerCase() ?? ""; + + if ( + category.includes("fetch") || + category.includes("xhr") || + category.includes("http") || + type === "http" + ) { + return "network"; + } + if (category.includes("console")) { + return level === "error" ? "error" : "console"; + } + if (category.includes("exception") || category.includes("error")) { + return "error"; + } + if (category.includes("navigation")) { + return "navigation"; + } + return "breadcrumb"; +} + +function normalizeBreadcrumbCustomFrame( + location: FrameLocation, + payload: RecordValue +): ReplayEvent { + const { ctx } = location; + const nestedData = isRecord(payload.data) ? payload.data : {}; + const kind = breadcrumbKind(payload); + const url = firstString(payload.url, nestedData.url, nestedData.to); + if (kind === "navigation" && url) { + ctx.currentUrl = url; + } + + return buildBaseEvent({ + ...location, + kind, + category: kind === "breadcrumb" ? "breadcrumb" : kind, + label: firstString(payload.category, payload.type) ?? kind, + message: firstString(payload.message), + url, + rawType: "Custom", + rawSource: "breadcrumb", + data: { + level: firstString(payload.level), + statusCode: firstNumber(nestedData.status_code, nestedData.status), + method: firstString(nestedData.method), + }, + }); +} + +function normalizeClickCustomFrame( + location: FrameLocation, + tag: string, + payload: RecordValue +): ReplayEvent { + const selector = firstString(payload.selector); + const label = firstString(payload.label) ?? tag; + return buildBaseEvent({ + ...location, + kind: "click", + category: "interaction", + label, + selector, + rawType: "Custom", + rawSource: tag, + data: { + interaction: tag, + isDeadClick: tag === "deadClick", + isRageClick: tag === "rageClick", + }, + }); +} + +function normalizePerformanceSpanCustomFrame( + location: FrameLocation, + payload: RecordValue +): ReplayEvent { + const nestedData = isRecord(payload.data) ? payload.data : {}; + const op = firstString(payload.op); + const description = firstString(payload.description); + return buildBaseEvent({ + ...location, + kind: "span", + category: "performance", + label: op ?? "performanceSpan", + message: description, + rawType: "Custom", + rawSource: "performanceSpan", + data: { + op, + description, + durationMs: firstNumber(nestedData.duration, payload.duration), + }, + }); +} + +function normalizeCustomFrame( + ctx: EventContext, + frame: RecordValue, + segmentIndex: number, + frameIndex: number +): ReplayEvent | null { + const location = { ctx, frame, segmentIndex, frameIndex }; + const data = isRecord(frame.data) ? frame.data : {}; + const tag = firstString(data.tag); + const payload = isRecord(data.payload) ? data.payload : {}; + + if (!tag) { + const href = firstString(data.href); + if (!href) { + return null; + } + ctx.currentUrl = href; + return buildBaseEvent({ + ...location, + kind: "navigation", + category: "navigation", + label: "page.view", + url: href, + rawType: "Custom", + rawSource: "href", + }); + } + + if (tag === "breadcrumb") { + return normalizeBreadcrumbCustomFrame(location, payload); + } + + if (CLICK_LIKE_CUSTOM_TAGS.has(tag)) { + return normalizeClickCustomFrame(location, tag, payload); + } + + if (tag === "performanceSpan") { + return normalizePerformanceSpanCustomFrame(location, payload); + } + + const kindByTag: Record = { + memory: "memory", + mobile: "mobile", + navigation: "navigation", + video: "video", + webVital: "web-vital", + }; + const kind = kindByTag[tag] ?? "unknown"; + return buildBaseEvent({ + ...location, + kind, + category: kind === "unknown" ? "custom" : kind, + label: tag, + message: firstString(payload.message, payload.description), + rawType: "Custom", + rawSource: tag, + data: payload, + }); +} + +function normalizeFrame( + ctx: EventContext, + frame: unknown, + segmentIndex: number, + frameIndex: number +): ReplayEvent | null { + if (!isRecord(frame)) { + return null; + } + + const type = firstNumber(frame.type); + const typeName = + type !== undefined ? (RRWEB_EVENT_TYPES[type] ?? String(type)) : undefined; + + if (typeName === "FullSnapshot") { + return buildBaseEvent({ + ctx, + frame, + segmentIndex, + frameIndex, + kind: "dom-snapshot", + category: "dom", + label: "full-snapshot", + rawType: typeName, + }); + } + + if (typeName === "Meta") { + const data = isRecord(frame.data) ? frame.data : {}; + const href = firstString(data.href); + if (!href) { + return null; + } + ctx.currentUrl = href; + return buildBaseEvent({ + ctx, + frame, + segmentIndex, + frameIndex, + kind: "navigation", + category: "navigation", + label: "page.view", + url: href, + rawType: typeName, + }); + } + + if (typeName === "IncrementalSnapshot") { + return normalizeIncrementalFrame(ctx, frame, segmentIndex, frameIndex); + } + + if (typeName === "Custom" || isRecord(frame.data)) { + return normalizeCustomFrame(ctx, frame, segmentIndex, frameIndex); + } + + return null; +} + +export function extractNormalizedReplayEvents( + replay: ReplayDetails, + segments: ReplayRecordingSegments, + options: { includeRaw?: boolean } = {} +): ReplayEvent[] { + const replayStartMs = timestampToMillis(replay.started_at); + const ctx: EventContext = { + replayId: replay.id, + replayStartMs, + includeRaw: options.includeRaw ?? false, + }; + + const events: ReplayEvent[] = []; + for (const [segmentIndex, segment] of segments.entries()) { + for (const [frameIndex, frame] of segment.entries()) { + const normalized = normalizeFrame(ctx, frame, segmentIndex, frameIndex); + if (normalized) { + events.push(normalized); + } + } + } + + return events.sort((a, b) => { + if (a.offsetMs === null && b.offsetMs === null) { + return a.segmentIndex - b.segmentIndex || a.frameIndex - b.frameIndex; + } + if (a.offsetMs === null) { + return 1; + } + if (b.offsetMs === null) { + return -1; + } + return a.offsetMs - b.offsetMs; + }); +} + +function textMatches(event: ReplayEvent, needle: string): boolean { + const normalizedNeedle = needle.toLowerCase(); + const haystack = [ + event.kind, + event.category, + event.label, + event.message, + event.url, + event.selector, + event.rawType, + event.rawSource, + event.data ? JSON.stringify(event.data) : undefined, + ] + .filter((value): value is string => typeof value === "string") + .join("\n") + .toLowerCase(); + return haystack.includes(normalizedNeedle); +} + +function eventMatchesTextFilters( + event: ReplayEvent, + filters: ReplayEventFilters, + contains: string | undefined +): boolean { + if (filters.url && !(event.url ?? "").includes(filters.url)) { + return false; + } + if (filters.path && !replayUrlPathMatches(event.url, filters.path)) { + return false; + } + if (filters.selector && !(event.selector ?? "").includes(filters.selector)) { + return false; + } + if (contains && !textMatches(event, contains)) { + return false; + } + return true; +} + +function eventMatchesOffsetWindow( + event: ReplayEvent, + filters: ReplayEventFilters +): boolean { + if ( + filters.fromMs !== undefined && + (event.offsetMs === null || event.offsetMs < filters.fromMs) + ) { + return false; + } + if ( + filters.toMs !== undefined && + (event.offsetMs === null || event.offsetMs > filters.toMs) + ) { + return false; + } + return true; +} + +export function filterNormalizedReplayEvents( + events: ReplayEvent[], + filters: ReplayEventFilters +): ReplayEvent[] { + const kindSet = + filters.kinds && filters.kinds.length > 0 + ? new Set(filters.kinds) + : undefined; + const contains = filters.contains?.toLowerCase(); + + return events.filter((event) => { + if (kindSet && !kindSet.has(event.kind)) { + return false; + } + return ( + eventMatchesTextFilters(event, filters, contains) && + eventMatchesOffsetWindow(event, filters) + ); + }); +} + +/** Parse replay offsets such as `01:23`, `1:02:03`, `90s`, `2m`, or `83000ms`. */ +export function parseReplayOffset(value: string): number { + const trimmed = value.trim(); + if (!trimmed) { + throw new ValidationError("Offset cannot be empty", "offset"); + } + + if (trimmed.endsWith("ms")) { + const ms = Number(trimmed.slice(0, -2)); + if (Number.isFinite(ms) && ms >= 0) { + return Math.round(ms); + } + } + + const relative = parseRelativeParts(trimmed); + if (relative) { + return relative.value * (UNIT_SECONDS[relative.unit] ?? 0) * 1000; + } + + if (SECONDS_OFFSET_RE.test(trimmed)) { + return Math.round(Number(trimmed) * 1000); + } + + const parts = trimmed.split(":").map(Number); + if ( + parts.length < 2 || + parts.length > 3 || + parts.some((part) => !Number.isFinite(part) || part < 0) + ) { + throw new ValidationError( + `Invalid replay offset '${value}'. Use seconds, 90s, 01:23, or 1:02:03.`, + "offset" + ); + } + + const [hours, minutes, seconds] = + parts.length === 3 ? parts : [0, parts[0], parts[1]]; + return Math.round( + ((hours ?? 0) * 3600 + (minutes ?? 0) * 60 + (seconds ?? 0)) * 1000 + ); +} diff --git a/src/lib/replay-search.ts b/src/lib/replay-search.ts index 53b01305c..3947b96f0 100644 --- a/src/lib/replay-search.ts +++ b/src/lib/replay-search.ts @@ -11,10 +11,14 @@ import type { SentryEvent, } from "../types/index.js"; import { tryNormalizeHexId } from "./hex-id.js"; +import { logger } from "./logger.js"; type ReplayLike = ReplayListItem | ReplayDetails; type ReplayFieldResolver = (replay: ReplayLike) => unknown; +const REPLAY_URL_PARSE_BASE = "https://replay.local"; +const log = logger.withTag("replay-search"); + /** Maps user-facing field aliases to canonical replay API field names. */ const REPLAY_FIELD_ALIASES = { count_screens: "count_urls", @@ -78,6 +82,83 @@ export function getReplayUserLabel(replay: ReplayLike): string | undefined { ); } +export type ReplayUrlParts = { + path: string; + query: string; +}; + +/** Parse a replay URL or relative URL into stable path/query parts. */ +export function getReplayUrlParts( + value: string | null | undefined +): ReplayUrlParts | undefined { + if (!value) { + return; + } + + try { + const parsed = new URL(value, REPLAY_URL_PARSE_BASE); + return { path: parsed.pathname, query: parsed.search }; + } catch (error) { + log.debug("Failed to parse replay URL", { value, error }); + return; + } +} + +function normalizePathFilter(path: string): string { + const trimmed = path.trim(); + if (!trimmed) { + return "/"; + } + + const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`; + return withSlash.length > 1 && withSlash.endsWith("/") + ? withSlash.slice(0, -1) + : withSlash; +} + +/** Match a route path exactly or by child path, avoiding raw query matches. */ +export function replayUrlPathMatches( + url: string | null | undefined, + path: string +): boolean { + const parts = getReplayUrlParts(url); + if (!parts) { + return false; + } + + const normalizedFilter = normalizePathFilter(path); + const normalizedPath = normalizePathFilter(parts.path); + if (normalizedFilter === "/") { + return normalizedPath.startsWith("/"); + } + return ( + normalizedPath === normalizedFilter || + normalizedPath.startsWith(`${normalizedFilter}/`) + ); +} + +export type ReplayPathMatchMode = "any" | "entry" | "exit"; + +/** Match replay URL arrays by route path in any, first, or last position. */ +export function replayMatchesPath( + replay: Pick, + path: string, + mode: ReplayPathMatchMode = "any" +): boolean { + const urls = replay.urls ?? []; + if (urls.length === 0) { + return false; + } + + if (mode === "entry") { + return replayUrlPathMatches(urls[0], path); + } + if (mode === "exit") { + return replayUrlPathMatches(urls.at(-1), path); + } + return urls.some((url) => replayUrlPathMatches(url, path)); +} + const REPLAY_FIELD_RESOLVERS: Record = { activity: (replay) => replay.activity, browser: (replay) => replay.browser?.name, diff --git a/src/lib/replay-summary.ts b/src/lib/replay-summary.ts new file mode 100644 index 000000000..be60caf29 --- /dev/null +++ b/src/lib/replay-summary.ts @@ -0,0 +1,787 @@ +/** + * Deterministic Session Replay behavior summaries. + * + * The summary intentionally stays factual: counts, routes, timings, and + * heuristic friction signals with nearby evidence. This gives agents useful + * material for analysis without pretending the CLI performed subjective RCA. + */ + +import type { + ReplayDetails, + ReplayEvent, + ReplayEventCounts, + ReplayFrictionSignal, + ReplayRouteSummary, + ReplaySummaryOutput, + ReplayTimingSummary, +} from "../types/index.js"; +import { replayUrlPathMatches } from "./replay-search.js"; + +type SummaryOptions = { + org: string; + project?: string; + focusPath?: string; + maxSignals?: number; + maxNotableEvents?: number; + recordingFrameCount?: number | null; + recordingSegmentCount?: number | null; +}; + +type ClickPoint = { + event: ReplayEvent; + x: number; + y: number; +}; + +const DEFAULT_MAX_SIGNALS = 10; +const DEFAULT_MAX_NOTABLE_EVENTS = 12; +const REPEATED_CLICK_WINDOW_MS = 3000; +const REPEATED_CLICK_DISTANCE_PX = 32; +const LONG_WAIT_AFTER_CLICK_MS = 10_000; +const QUICK_BOUNCE_SECONDS = 10; +const SLOW_NAVIGATION_MS = 3000; +const SLOW_RESOURCE_MS = 3000; +const ROUTE_CHURN_WINDOW_MS = 15_000; +const ROUTE_CHURN_COUNT = 3; + +const NOTABLE_EVENT_KINDS = new Set([ + "navigation", + "click", + "tap", + "input", + "network", + "console", + "error", +]); + +function numberFromData(event: ReplayEvent, key: string): number | undefined { + const value = event.data?.[key]; + return typeof value === "number" && Number.isFinite(value) + ? value + : undefined; +} + +function stringFromData(event: ReplayEvent, key: string): string | undefined { + const value = event.data?.[key]; + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function eventDuration(event: ReplayEvent): number | undefined { + return numberFromData(event, "durationMs"); +} + +function replayDurationMs(replay: ReplayDetails): number | null { + return typeof replay.duration === "number" && Number.isFinite(replay.duration) + ? Math.round(replay.duration * 1000) + : null; +} + +function routeKey(event: ReplayEvent): string | undefined { + return event.urlPath ?? undefined; +} + +function emptyEventCounts(): ReplayEventCounts { + return { + total: 0, + navigations: 0, + clicks: 0, + taps: 0, + inputs: 0, + focuses: 0, + blurs: 0, + scrolls: 0, + network: 0, + console: 0, + errors: 0, + spans: 0, + }; +} + +function countEvents(events: ReplayEvent[]): ReplayEventCounts { + const counts = emptyEventCounts(); + counts.total = events.length; + + for (const event of events) { + switch (event.kind) { + case "navigation": + counts.navigations += 1; + break; + case "click": + counts.clicks += 1; + break; + case "tap": + counts.taps += 1; + break; + case "input": + counts.inputs += 1; + break; + case "focus": + counts.focuses += 1; + break; + case "blur": + counts.blurs += 1; + break; + case "scroll": + counts.scrolls += 1; + break; + case "network": + counts.network += 1; + break; + case "console": + counts.console += 1; + break; + case "error": + counts.errors += 1; + break; + case "span": + counts.spans += 1; + break; + default: + break; + } + } + + return counts; +} + +function hadUserInteraction(counts: ReplayEventCounts): boolean { + return ( + counts.clicks > 0 || + counts.taps > 0 || + counts.inputs > 0 || + counts.scrolls > 0 + ); +} + +type RouteVisitDraft = { + path: string; + url: string | null; + enteredAtOffsetMs: number | null; + events: ReplayEvent[]; +}; + +function durationBetween( + startMs: number | null, + endMs: number | null +): number | null { + if (startMs === null || endMs === null || endMs < startMs) { + return null; + } + return endMs - startMs; +} + +function finalizeRouteVisit( + visit: RouteVisitDraft, + leftAtOffsetMs: number | null, + nextPath: string | null +): ReplayRouteSummary { + const eventOffsets = visit.events + .map((event) => event.offsetMs) + .filter((offset): offset is number => offset !== null); + const counts = countEvents(visit.events); + return { + path: visit.path, + url: visit.url, + enteredAtOffsetMs: visit.enteredAtOffsetMs, + leftAtOffsetMs, + durationMs: durationBetween(visit.enteredAtOffsetMs, leftAtOffsetMs), + nextPath, + firstOffsetMs: eventOffsets.length > 0 ? Math.min(...eventOffsets) : null, + lastOffsetMs: eventOffsets.length > 0 ? Math.max(...eventOffsets) : null, + eventCount: visit.events.length, + counts, + hadUserInteraction: hadUserInteraction(counts), + }; +} + +function buildRouteSummaries( + events: ReplayEvent[], + replayDuration: number | null +): ReplayRouteSummary[] { + const routes: ReplayRouteSummary[] = []; + let current: RouteVisitDraft | undefined; + + for (const event of events) { + const navigationPath = + event.kind === "navigation" ? routeKey(event) : undefined; + if (navigationPath && (!current || current.path !== navigationPath)) { + if (current) { + routes.push( + finalizeRouteVisit(current, event.offsetMs, navigationPath) + ); + } + current = { + path: navigationPath, + url: event.url ?? null, + enteredAtOffsetMs: event.offsetMs, + events: [event], + }; + continue; + } + + if (current) { + current.events.push(event); + } + } + + if (current) { + routes.push(finalizeRouteVisit(current, replayDuration, null)); + } + + return routes; +} + +function firstOffsetForSpan( + events: ReplayEvent[], + op: string, + description: string +): number | null { + const event = events.find( + (item) => + item.kind === "span" && + stringFromData(item, "op") === op && + item.message === description && + item.offsetMs !== null + ); + return event?.offsetMs ?? null; +} + +function timingSummary(events: ReplayEvent[]): ReplayTimingSummary { + const navigationSpan = events.find( + (event) => + event.kind === "span" && + stringFromData(event, "op") === "navigation.navigate" && + eventDuration(event) !== undefined + ); + + return { + firstPaintMs: firstOffsetForSpan(events, "paint", "first-paint"), + firstContentfulPaintMs: firstOffsetForSpan( + events, + "paint", + "first-contentful-paint" + ), + largestContentfulPaintMs: firstOffsetForSpan( + events, + "web-vital", + "largest-contentful-paint" + ), + navigationDurationMs: navigationSpan + ? (eventDuration(navigationSpan) ?? null) + : null, + }; +} + +function eventsAround( + events: ReplayEvent[], + offsetMs: number | null, + limit = 6 +): ReplayEvent[] { + if (offsetMs === null) { + return []; + } + + return events + .filter( + (event) => + event.offsetMs !== null && + Math.abs(event.offsetMs - offsetMs) <= LONG_WAIT_AFTER_CLICK_MS && + (NOTABLE_EVENT_KINDS.has(event.kind) || event.kind === "span") + ) + .slice(0, limit); +} + +function pushSignal( + signals: ReplayFrictionSignal[], + signal: ReplayFrictionSignal, + maxSignals: number +): void { + if (signals.length >= maxSignals) { + return; + } + signals.push(signal); +} + +function signalFromEvent(params: { + events: ReplayEvent[]; + event: ReplayEvent; + kind: ReplayFrictionSignal["kind"]; + severity: ReplayFrictionSignal["severity"]; + message: string; +}): ReplayFrictionSignal { + const { events, event, kind, message, severity } = params; + return { + kind, + severity, + offsetMs: event.offsetMs, + url: event.url ?? null, + urlPath: event.urlPath ?? null, + message, + evidence: eventsAround(events, event.offsetMs), + }; +} + +function indexedSignalContext(events: ReplayEvent[]) { + const offsetMs = events[0]?.offsetMs ?? null; + return { + offsetMs, + url: events[0]?.url ?? null, + urlPath: events[0]?.urlPath ?? null, + evidence: offsetMs === null ? [] : eventsAround(events, offsetMs), + }; +} + +function detectIndexedErrorSignal( + replay: ReplayDetails, + events: ReplayEvent[], + signals: ReplayFrictionSignal[], + maxSignals: number +): void { + if ((replay.count_errors ?? 0) <= 0 && replay.error_ids.length === 0) { + return; + } + + const errorCount = + replay.count_errors && replay.count_errors > 0 + ? replay.count_errors + : replay.error_ids.length; + pushSignal( + signals, + { + kind: "indexed_error", + severity: "high", + ...indexedSignalContext(events), + message: `Replay is linked to ${errorCount} error event(s).`, + }, + maxSignals + ); +} + +function detectIndexedWarningSignal( + replay: ReplayDetails, + events: ReplayEvent[], + signals: ReplayFrictionSignal[], + maxSignals: number +): void { + if ((replay.count_warnings ?? 0) <= 0 && replay.warning_ids.length === 0) { + return; + } + + const warningCount = + replay.count_warnings && replay.count_warnings > 0 + ? replay.count_warnings + : replay.warning_ids.length; + pushSignal( + signals, + { + kind: "indexed_warning", + severity: "medium", + ...indexedSignalContext(events), + message: `Replay is linked to ${warningCount} warning event(s).`, + }, + maxSignals + ); +} + +function detectIndexedSignals( + replay: ReplayDetails, + events: ReplayEvent[], + signals: ReplayFrictionSignal[], + maxSignals: number +): void { + detectIndexedErrorSignal(replay, events, signals, maxSignals); + detectIndexedWarningSignal(replay, events, signals, maxSignals); +} + +function clickPoints(events: ReplayEvent[]): ClickPoint[] { + return events + .filter((event) => event.kind === "click" || event.kind === "tap") + .map((event) => { + const x = numberFromData(event, "x"); + const y = numberFromData(event, "y"); + return x === undefined || y === undefined ? undefined : { event, x, y }; + }) + .filter((point): point is ClickPoint => point !== undefined); +} + +function detectExplicitClickSignals( + events: ReplayEvent[], + signals: ReplayFrictionSignal[], + maxSignals: number +): void { + for (const event of events) { + if (event.kind !== "click" && event.kind !== "tap") { + continue; + } + + if (event.data?.isRageClick === true) { + pushSignal( + signals, + signalFromEvent({ + events, + event, + kind: "rage_click", + severity: "high", + message: "Replay includes a rage click signal.", + }), + maxSignals + ); + } + if (event.data?.isDeadClick === true) { + pushSignal( + signals, + signalFromEvent({ + events, + event, + kind: "dead_click", + severity: "medium", + message: "Replay includes a dead click signal.", + }), + maxSignals + ); + } + } +} + +function detectRepeatedClickSignal( + events: ReplayEvent[], + signals: ReplayFrictionSignal[], + maxSignals: number +): void { + const points = clickPoints(events); + for (let i = 1; i < points.length; i += 1) { + const previous = points[i - 1]; + const current = points[i]; + if (!(previous && current)) { + continue; + } + if (previous.event.offsetMs === null || current.event.offsetMs === null) { + continue; + } + + const deltaMs = current.event.offsetMs - previous.event.offsetMs; + const distance = Math.hypot(current.x - previous.x, current.y - previous.y); + if ( + deltaMs <= REPEATED_CLICK_WINDOW_MS && + distance <= REPEATED_CLICK_DISTANCE_PX + ) { + pushSignal( + signals, + signalFromEvent({ + events, + event: current.event, + kind: "repeated_click", + severity: "medium", + message: + "User clicked the same area repeatedly within a few seconds.", + }), + maxSignals + ); + break; + } + } +} + +function detectLongWaitAfterClickSignal( + events: ReplayEvent[], + replay: ReplayDetails, + signals: ReplayFrictionSignal[], + maxSignals: number +): void { + const points = clickPoints(events); + const durationMs = replayDurationMs(replay); + for (const point of points) { + const offsetMs = point.event.offsetMs; + if (offsetMs === null || durationMs === null) { + continue; + } + + const next = events.find( + (event) => + event.offsetMs !== null && + event.offsetMs > offsetMs && + NOTABLE_EVENT_KINDS.has(event.kind) + ); + const nextOffset = next?.offsetMs ?? durationMs; + if (nextOffset - offsetMs >= LONG_WAIT_AFTER_CLICK_MS) { + pushSignal( + signals, + signalFromEvent({ + events, + event: point.event, + kind: "long_wait_after_click", + severity: "low", + message: + "User clicked and then had a long wait or no further notable activity.", + }), + maxSignals + ); + break; + } + } +} + +function detectClickSignals( + events: ReplayEvent[], + replay: ReplayDetails, + signals: ReplayFrictionSignal[], + maxSignals: number +): void { + detectExplicitClickSignals(events, signals, maxSignals); + detectRepeatedClickSignal(events, signals, maxSignals); + detectLongWaitAfterClickSignal(events, replay, signals, maxSignals); +} + +function detectNetworkAndConsoleSignals( + events: ReplayEvent[], + signals: ReplayFrictionSignal[], + maxSignals: number +): void { + for (const event of events) { + const statusCode = numberFromData(event, "statusCode"); + if ( + event.kind === "network" && + statusCode !== undefined && + statusCode >= 400 + ) { + pushSignal( + signals, + signalFromEvent({ + events, + event, + kind: "network_error", + severity: statusCode >= 500 ? "high" : "medium", + message: `Network breadcrumb reported HTTP ${statusCode}.`, + }), + maxSignals + ); + } + if ( + event.kind === "console" && + stringFromData(event, "level")?.toLowerCase() === "error" + ) { + pushSignal( + signals, + signalFromEvent({ + events, + event, + kind: "console_error", + severity: "medium", + message: "Console emitted an error during the replay.", + }), + maxSignals + ); + } + if (event.kind === "error") { + pushSignal( + signals, + signalFromEvent({ + events, + event, + kind: "error_event", + severity: "high", + message: event.message ?? "Replay contains an error event.", + }), + maxSignals + ); + } + } +} + +function detectPerformanceSignals( + events: ReplayEvent[], + signals: ReplayFrictionSignal[], + maxSignals: number +): void { + for (const event of events) { + if (event.kind !== "span") { + continue; + } + + const durationMs = eventDuration(event); + if (durationMs === undefined) { + continue; + } + + const op = stringFromData(event, "op") ?? event.label ?? ""; + if (op === "navigation.navigate" && durationMs >= SLOW_NAVIGATION_MS) { + pushSignal( + signals, + signalFromEvent({ + events, + event, + kind: "slow_navigation", + severity: "medium", + message: `Navigation took ${Math.round(durationMs)}ms.`, + }), + maxSignals + ); + } else if (op.startsWith("resource.") && durationMs >= SLOW_RESOURCE_MS) { + pushSignal( + signals, + signalFromEvent({ + events, + event, + kind: "slow_resource", + severity: "low", + message: `Resource load took ${Math.round(durationMs)}ms.`, + }), + maxSignals + ); + } + } +} + +function detectSessionShapeSignals( + replay: ReplayDetails, + events: ReplayEvent[], + signals: ReplayFrictionSignal[], + maxSignals: number +): void { + const counts = countEvents(events); + if ( + typeof replay.duration === "number" && + replay.duration <= QUICK_BOUNCE_SECONDS && + counts.clicks === 0 && + counts.taps === 0 && + counts.inputs === 0 && + counts.scrolls === 0 + ) { + pushSignal( + signals, + { + kind: "quick_bounce", + severity: "low", + offsetMs: events[0]?.offsetMs ?? null, + url: events[0]?.url ?? null, + urlPath: events[0]?.urlPath ?? null, + message: "Replay ended quickly without user interactions.", + evidence: events.slice(0, 5), + }, + maxSignals + ); + } + + const navigations = events.filter( + (event) => event.kind === "navigation" && event.offsetMs !== null + ); + for (const start of navigations) { + if (start.offsetMs === null) { + continue; + } + const startOffsetMs = start.offsetMs; + const nearby = navigations.filter( + (event) => + event.offsetMs !== null && + event.offsetMs >= startOffsetMs && + event.offsetMs - startOffsetMs <= ROUTE_CHURN_WINDOW_MS + ); + if (nearby.length >= ROUTE_CHURN_COUNT) { + pushSignal( + signals, + { + kind: "route_churn", + severity: "low", + offsetMs: start.offsetMs, + url: start.url ?? null, + urlPath: start.urlPath ?? null, + message: `${nearby.length} route changes occurred within ${ROUTE_CHURN_WINDOW_MS / 1000}s.`, + evidence: nearby.slice(0, 6), + }, + maxSignals + ); + break; + } + } +} + +function detectFrictionSignals( + replay: ReplayDetails, + events: ReplayEvent[], + maxSignals: number +): ReplayFrictionSignal[] { + const signals: ReplayFrictionSignal[] = []; + detectIndexedSignals(replay, events, signals, maxSignals); + detectClickSignals(events, replay, signals, maxSignals); + detectNetworkAndConsoleSignals(events, signals, maxSignals); + detectPerformanceSignals(events, signals, maxSignals); + detectSessionShapeSignals(replay, events, signals, maxSignals); + return signals.slice(0, maxSignals); +} + +function notableEvents( + events: ReplayEvent[], + maxNotableEvents: number +): ReplayEvent[] { + return events + .filter((event) => NOTABLE_EVENT_KINDS.has(event.kind)) + .slice(0, maxNotableEvents); +} + +function focusEvents(events: ReplayEvent[], focusPath?: string): ReplayEvent[] { + if (!focusPath) { + return events; + } + return events.filter((event) => replayUrlPathMatches(event.url, focusPath)); +} + +function routeMatchesFocus( + route: ReplayRouteSummary, + focusPath?: string +): boolean { + if (!focusPath) { + return true; + } + return replayUrlPathMatches(route.url ?? route.path, focusPath); +} + +function recordingSegmentCount( + replay: ReplayDetails, + options: SummaryOptions +): number | null { + if (options.recordingSegmentCount !== undefined) { + return options.recordingSegmentCount; + } + return replay.count_segments ?? null; +} + +export function summarizeReplay( + replay: ReplayDetails, + events: ReplayEvent[], + options: SummaryOptions +): ReplaySummaryOutput { + const focusedEvents = focusEvents(events, options.focusPath); + const replayDuration = replayDurationMs(replay); + const maxSignals = options.maxSignals ?? DEFAULT_MAX_SIGNALS; + const maxNotableEvents = + options.maxNotableEvents ?? DEFAULT_MAX_NOTABLE_EVENTS; + const routes = buildRouteSummaries(events, replayDuration).filter((route) => + routeMatchesFocus(route, options.focusPath) + ); + + return { + replayId: replay.id, + org: options.org, + project: options.project ?? null, + platform: replay.platform ?? null, + sdkName: replay.sdk?.name ?? null, + sdkVersion: replay.sdk?.version ?? null, + replayType: replay.replay_type ?? null, + startedAt: replay.started_at ?? null, + durationSeconds: replay.duration ?? null, + entryUrl: replay.urls[0] ?? null, + exitUrl: replay.urls.at(-1) ?? null, + focusPath: options.focusPath ?? null, + counts: countEvents(focusedEvents), + recording: { + segmentCount: recordingSegmentCount(replay, options), + frameCount: options.recordingFrameCount ?? null, + normalizedEventCount: events.length, + focusedEventCount: options.focusPath ? focusedEvents.length : null, + }, + timings: timingSummary(focusedEvents), + routes, + signals: detectFrictionSignals(replay, focusedEvents, maxSignals), + notableEvents: notableEvents(focusedEvents, maxNotableEvents), + }; +} diff --git a/src/types/index.ts b/src/types/index.ts index ed8af9394..264b2ad38 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -52,6 +52,11 @@ export type { ReplayDetails, ReplayDetailsResponse, ReplayDevice, + ReplayEvent, + ReplayEventCounts, + ReplayEventKind, + ReplayFrictionSignal, + ReplayFrictionSignalKind, ReplayGeo, ReplayIdsByResource, ReplayListItem, @@ -61,10 +66,15 @@ export type { ReplayRecordingSegments, ReplayRelatedIssue, ReplayRelatedTrace, + ReplayRouteSummary, ReplaySdk, + ReplaySummaryOutput, + ReplayTimingSummary, ReplayUser, } from "./replay.js"; export { + REPLAY_EVENT_KINDS, + REPLAY_FRICTION_SIGNAL_KINDS, REPLAY_LIST_FIELDS, ReplayActivityEventSchema, ReplayBrowserSchema, @@ -72,6 +82,9 @@ export { ReplayDetailsResponseSchema, ReplayDetailsSchema, ReplayDeviceSchema, + ReplayEventCountsSchema, + ReplayEventSchema, + ReplayFrictionSignalSchema, ReplayGeoSchema, ReplayIdsByResourceSchema, ReplayListItemOutputSchema, @@ -82,7 +95,10 @@ export { ReplayRecordingSegmentsSchema, ReplayRelatedIssueSchema, ReplayRelatedTraceSchema, + ReplayRouteSummarySchema, ReplaySdkSchema, + ReplaySummaryOutputSchema, + ReplayTimingSummarySchema, ReplayUserSchema, ReplayViewOutputSchema, } from "./replay.js"; diff --git a/src/types/replay.ts b/src/types/replay.ts index 82a26984e..f1b458251 100644 --- a/src/types/replay.ts +++ b/src/types/replay.ts @@ -121,7 +121,6 @@ export const REPLAY_LIST_FIELDS = [ "info_ids", "is_archived", "os", - "ota_updates", "platform", "project_id", "releases", @@ -324,6 +323,259 @@ export const ReplayActivityEventSchema = z }) .describe("Summarized replay activity event"); +export const REPLAY_EVENT_KINDS = [ + "navigation", + "click", + "tap", + "input", + "focus", + "blur", + "scroll", + "viewport", + "mutation", + "dom-snapshot", + "breadcrumb", + "network", + "console", + "error", + "span", + "web-vital", + "memory", + "video", + "mobile", + "unknown", +] as const; + +/** Normalized replay event extracted from rrweb or Sentry custom frames. */ +export const ReplayEventSchema = z + .object({ + replayId: z.string().describe("Replay ID"), + segmentIndex: z.number().describe("Zero-based recording segment index"), + frameIndex: z.number().describe("Zero-based frame index within segment"), + offsetMs: z + .number() + .nullable() + .describe("Milliseconds from replay start to the event"), + timestamp: z + .string() + .nullable() + .describe("Event timestamp as ISO 8601 when available"), + kind: z.enum(REPLAY_EVENT_KINDS).describe("Normalized event kind"), + category: z.string().describe("Broad event category"), + label: z.string().nullable().optional().describe("Short event label"), + message: z.string().nullable().optional().describe("Message or summary"), + url: z.string().nullable().optional().describe("Current or target URL"), + urlPath: z + .string() + .nullable() + .optional() + .describe("Parsed URL pathname when available"), + urlQuery: z + .string() + .nullable() + .optional() + .describe("Parsed URL query string when available"), + selector: z + .string() + .nullable() + .optional() + .describe("CSS selector or target selector when available"), + nodeId: z + .union([z.string(), z.number()]) + .nullable() + .optional() + .describe("rrweb node ID when available"), + rawType: z.string().nullable().optional().describe("Source frame type"), + rawSource: z + .string() + .nullable() + .optional() + .describe("Source frame subtype"), + data: z + .record(z.unknown()) + .optional() + .describe("Kind-specific normalized fields"), + raw: z + .unknown() + .optional() + .describe("Raw source frame, only present when requested"), + }) + .describe("Normalized replay event"); + +export const REPLAY_FRICTION_SIGNAL_KINDS = [ + "indexed_error", + "indexed_warning", + "rage_click", + "dead_click", + "repeated_click", + "long_wait_after_click", + "quick_bounce", + "slow_navigation", + "slow_resource", + "network_error", + "console_error", + "error_event", + "route_churn", +] as const; + +export const ReplayEventCountsSchema = z + .object({ + total: z.number().describe("Total normalized event count"), + navigations: z.number().describe("Navigation event count"), + clicks: z.number().describe("Click event count"), + taps: z.number().describe("Tap event count"), + inputs: z.number().describe("Input event count"), + focuses: z.number().describe("Focus event count"), + blurs: z.number().describe("Blur event count"), + scrolls: z.number().describe("Scroll event count"), + network: z.number().describe("Network event count"), + console: z.number().describe("Console event count"), + errors: z.number().describe("Error event count"), + spans: z.number().describe("Performance span event count"), + }) + .describe("Replay event counts"); + +export const ReplayRouteSummarySchema = z + .object({ + path: z.string().describe("Route pathname"), + url: z + .string() + .nullable() + .describe("Representative URL for this route visit"), + enteredAtOffsetMs: z + .number() + .nullable() + .describe("Offset where this route visit started"), + leftAtOffsetMs: z + .number() + .nullable() + .describe("Offset where this route visit ended"), + durationMs: z + .number() + .nullable() + .describe("Duration of this route visit when bounded"), + nextPath: z + .string() + .nullable() + .describe("Next route pathname after this visit"), + firstOffsetMs: z + .number() + .nullable() + .describe("First observed event offset for this route visit"), + lastOffsetMs: z + .number() + .nullable() + .describe("Last observed event offset for this route visit"), + eventCount: z + .number() + .describe("Number of normalized events in this route visit"), + counts: ReplayEventCountsSchema.describe( + "Normalized event counts within this route visit" + ), + hadUserInteraction: z + .boolean() + .describe("Whether this route visit had click, tap, input, or scroll"), + }) + .describe("Replay route visit summary"); + +export const ReplayTimingSummarySchema = z + .object({ + firstPaintMs: z.number().nullable().describe("First paint offset"), + firstContentfulPaintMs: z + .number() + .nullable() + .describe("First contentful paint offset"), + largestContentfulPaintMs: z + .number() + .nullable() + .describe("Largest contentful paint offset"), + navigationDurationMs: z + .number() + .nullable() + .describe("Navigation span duration when available"), + }) + .describe("Replay timing summary"); + +export const ReplayRecordingStatsSchema = z + .object({ + segmentCount: z + .number() + .nullable() + .describe("Downloaded recording segment count when available"), + frameCount: z + .number() + .nullable() + .describe("Downloaded raw recording frame count when available"), + normalizedEventCount: z + .number() + .describe("Normalized event count extracted from the recording"), + focusedEventCount: z + .number() + .nullable() + .describe("Normalized event count after the optional focus path"), + }) + .describe("Replay recording parser stats"); + +export const ReplayFrictionSignalSchema = z + .object({ + kind: z + .enum(REPLAY_FRICTION_SIGNAL_KINDS) + .describe("Detected friction signal kind"), + severity: z.enum(["low", "medium", "high"]).describe("Heuristic severity"), + offsetMs: z + .number() + .nullable() + .describe("Primary signal offset when available"), + url: z.string().nullable().optional().describe("URL at the signal"), + urlPath: z + .string() + .nullable() + .optional() + .describe("Route path at the signal"), + message: z.string().describe("Human-readable signal summary"), + evidence: z + .array(ReplayEventSchema) + .describe("Nearby normalized events supporting the signal"), + }) + .describe("Replay friction signal"); + +export const ReplaySummaryOutputSchema = z + .object({ + replayId: z.string().describe("Replay ID"), + org: z.string().describe("Organization slug"), + project: z.string().nullable().optional().describe("Project slug"), + platform: z.string().nullable().optional().describe("Replay platform"), + sdkName: z.string().nullable().optional().describe("Replay SDK name"), + sdkVersion: z.string().nullable().optional().describe("Replay SDK version"), + replayType: z.string().nullable().optional().describe("Replay type"), + startedAt: z.string().nullable().optional().describe("Replay start time"), + durationSeconds: z + .number() + .nullable() + .optional() + .describe("Replay duration in seconds"), + entryUrl: z.string().nullable().describe("First replay URL"), + exitUrl: z.string().nullable().describe("Last replay URL"), + focusPath: z + .string() + .nullable() + .optional() + .describe("Optional route path used to focus the summary"), + counts: ReplayEventCountsSchema.describe("Normalized event counts"), + recording: ReplayRecordingStatsSchema.describe( + "Downloaded recording and parser stats" + ), + timings: ReplayTimingSummarySchema.describe("Key timing observations"), + routes: z.array(ReplayRouteSummarySchema).describe("Route timeline"), + signals: z + .array(ReplayFrictionSignalSchema) + .describe("Detected non-error and error friction signals"), + notableEvents: z + .array(ReplayEventSchema) + .describe("Representative events useful for agent narrative"), + }) + .describe("Replay behavior summary"); + /** Related issue metadata extracted from replay-linked event IDs. */ export const ReplayRelatedIssueSchema = z .object({ @@ -388,5 +640,15 @@ export type ReplayListResponse = z.infer; export type ReplayDetailsResponse = z.infer; export type ReplayIdsByResource = z.infer; export type ReplayActivityEvent = z.infer; +export type ReplayEventKind = (typeof REPLAY_EVENT_KINDS)[number]; +export type ReplayEvent = z.infer; +export type ReplayFrictionSignalKind = + (typeof REPLAY_FRICTION_SIGNAL_KINDS)[number]; +export type ReplayRouteSummary = z.infer; +export type ReplayEventCounts = z.infer; +export type ReplayRecordingStats = z.infer; +export type ReplayTimingSummary = z.infer; +export type ReplayFrictionSignal = z.infer; +export type ReplaySummaryOutput = z.infer; export type ReplayRelatedIssue = z.infer; export type ReplayRelatedTrace = z.infer; diff --git a/test/commands/replay/event-list.test.ts b/test/commands/replay/event-list.test.ts new file mode 100644 index 000000000..9f48931b3 --- /dev/null +++ b/test/commands/replay/event-list.test.ts @@ -0,0 +1,222 @@ +/** + * Replay Event List Command Tests + */ + +import { + afterEach, + beforeEach, + describe, + expect, + mock, + spyOn, + test, +} from "bun:test"; +import { listCommand } from "../../../src/commands/replay/event/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 resolveTarget from "../../../src/lib/resolve-target.js"; +import type { + ReplayDetails, + ReplayRecordingSegments, +} from "../../../src/types/index.js"; + +const REPLAY_ID = "346789a703f6454384f1de473b8b9fcc"; + +function sampleReplay(overrides: Partial = {}): ReplayDetails { + return { + id: REPLAY_ID, + count_errors: 0, + count_segments: 1, + duration: 60, + error_ids: [], + info_ids: [], + project_id: "42", + started_at: "2025-01-01T00:00:00.000Z", + tags: {}, + trace_ids: [], + urls: ["/signup"], + user: null, + warning_ids: [], + ...overrides, + }; +} + +function sampleSegments(): ReplayRecordingSegments { + return [ + [ + { + type: 4, + timestamp: Date.parse("2025-01-01T00:00:01.000Z"), + data: { href: "/signup" }, + }, + { + type: 5, + timestamp: Date.parse("2025-01-01T00:00:02.000Z"), + data: { + tag: "deadClick", + payload: { selector: "button[type=submit]", label: "Sign up" }, + }, + }, + { + type: 5, + timestamp: Date.parse("2025-01-01T00:00:03.000Z"), + data: { + tag: "breadcrumb", + payload: { + category: "fetch", + message: "POST /api/signup", + data: { status_code: 500, url: "/api/signup" }, + }, + }, + }, + ], + ]; +} + +function createMockContext() { + const stdoutWrite = mock(() => true); + return { + context: { + stdout: { write: stdoutWrite }, + stderr: { write: mock(() => true) }, + cwd: "/tmp", + }, + stdoutWrite, + }; +} + +describe("replay event list", () => { + let getReplaySpy: ReturnType; + let getReplayRecordingSegmentsSpy: ReturnType; + let resolveTargetSpy: ReturnType; + + beforeEach(() => { + getReplaySpy = spyOn(apiClient, "getReplay").mockResolvedValue( + sampleReplay() + ); + getReplayRecordingSegmentsSpy = spyOn( + apiClient, + "getReplayRecordingSegments" + ).mockResolvedValue(sampleSegments()); + resolveTargetSpy = spyOn( + resolveTarget, + "resolveOrgOptionalProjectFromArg" + ).mockResolvedValue({ + org: "test-org", + project: "cli", + projectData: { id: "42", slug: "cli", name: "CLI" }, + }); + }); + + afterEach(() => { + getReplaySpy.mockRestore(); + getReplayRecordingSegmentsSpy.mockRestore(); + resolveTargetSpy.mockRestore(); + }); + + test("streams filtered JSON events with --json", async () => { + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call( + context, + { + fresh: false, + json: true, + kind: ["click,network"], + limit: 10, + raw: false, + }, + `test-org/cli/${REPLAY_ID}` + ); + + expect(getReplayRecordingSegmentsSpy).toHaveBeenCalledWith( + "test-org", + "42", + REPLAY_ID, + { expectedSegments: 1 } + ); + + const lines = stdoutWrite.mock.calls + .map((call) => call[0]) + .join("") + .trim() + .split("\n"); + expect(lines).toHaveLength(2); + const first = JSON.parse(lines[0]!); + const second = JSON.parse(lines[1]!); + expect(first.kind).toBe("click"); + expect(first.selector).toBe("button[type=submit]"); + expect(second.kind).toBe("network"); + }); + + test("uses -q search text for normalized event fields", async () => { + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call( + context, + { + fresh: false, + json: true, + limit: 10, + raw: false, + search: "button[type=submit]", + }, + `test-org/${REPLAY_ID}` + ); + + const lines = stdoutWrite.mock.calls + .map((call) => call[0]) + .join("") + .trim() + .split("\n"); + expect(lines).toHaveLength(1); + expect(JSON.parse(lines[0]!).kind).toBe("click"); + }); + + test("accepts a trailing positional path filter", async () => { + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call( + context, + { + fresh: false, + json: true, + kind: ["click,network"], + limit: 10, + raw: false, + }, + `test-org/${REPLAY_ID}`, + "/signup" + ); + + const lines = stdoutWrite.mock.calls + .map((call) => call[0]) + .join("") + .trim() + .split("\n"); + expect(lines).toHaveLength(1); + expect(JSON.parse(lines[0]!).kind).toBe("click"); + }); + + test("renders a human table from streamed events", async () => { + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call( + context, + { + fresh: false, + json: false, + kind: ["click"], + limit: 10, + raw: false, + }, + `test-org/${REPLAY_ID}` + ); + + const output = stdoutWrite.mock.calls.map((call) => call[0]).join(""); + expect(output).toContain("Replay events for 346789a7:"); + expect(output).toContain("Sign up"); + expect(output).toContain("Showing 1 of 1 replay event."); + }); +}); diff --git a/test/commands/replay/list.test.ts b/test/commands/replay/list.test.ts index 887d98598..72a6a96ad 100644 --- a/test/commands/replay/list.test.ts +++ b/test/commands/replay/list.test.ts @@ -30,6 +30,7 @@ describe("parseSort", () => { expect(parseSort("date")).toBe("-started_at"); expect(parseSort("duration")).toBe("-duration"); expect(parseSort("errors")).toBe("-count_errors"); + expect(parseSort("rage")).toBe("-count_rage_clicks"); expect(parseSort("-count_rage_clicks")).toBe("-count_rage_clicks"); }); @@ -166,6 +167,70 @@ describe("listCommand.func", () => { }); }); + test("passes one search query through Sentry search syntax", async () => { + resolveTargetSpy.mockResolvedValue({ org: "test-org", project: "cli" }); + listReplaysSpy.mockResolvedValue({ + data: sampleReplays, + nextCursor: undefined, + }); + + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call( + context, + { + limit: 25, + json: true, + period: parsePeriod("7d"), + search: "environment:production url:*signup* count_errors:>0", + sort: "-started_at", + }, + "test-org/cli" + ); + + expect(listReplaysSpy).toHaveBeenCalledWith("test-org", { + environment: undefined, + fields: [...REPLAY_LIST_FIELDS], + limit: 25, + projectSlugs: ["cli"], + query: "environment:production url:*signup* count_errors:>0", + sort: "-started_at", + cursor: undefined, + statsPeriod: "7d", + }); + + const output = stdoutWrite.mock.calls.map((call) => call[0]).join(""); + const parsed = JSON.parse(output); + expect(parsed.data).toHaveLength(1); + expect(parsed.data[0].id).toBe(sampleReplays[0]?.id); + }); + + test("passes large limits to the API layer for auto-pagination", 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, + { + limit: 250, + json: true, + period: parsePeriod("7d"), + sort: "-started_at", + }, + "test-org/cli" + ); + + expect(listReplaysSpy).toHaveBeenCalledWith( + "test-org", + expect.objectContaining({ limit: 250 }) + ); + }); + test("renders human output with a replay hint", async () => { resolveTargetSpy.mockResolvedValue({ org: "test-org", project: "cli" }); listReplaysSpy.mockResolvedValue({ data: sampleReplays }); diff --git a/test/commands/replay/summarize.test.ts b/test/commands/replay/summarize.test.ts new file mode 100644 index 000000000..ca52c69db --- /dev/null +++ b/test/commands/replay/summarize.test.ts @@ -0,0 +1,156 @@ +/** + * Replay Summarize Command Tests + */ + +import { + afterEach, + beforeEach, + describe, + expect, + mock, + spyOn, + test, +} from "bun:test"; +import { summarizeCommand } from "../../../src/commands/replay/summarize.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 resolveTarget from "../../../src/lib/resolve-target.js"; +import type { + ReplayDetails, + ReplayRecordingSegments, +} from "../../../src/types/index.js"; + +const REPLAY_ID = "346789a703f6454384f1de473b8b9fcc"; + +function sampleReplay(overrides: Partial = {}): ReplayDetails { + return { + id: REPLAY_ID, + count_errors: 0, + count_segments: 1, + duration: 12, + error_ids: [], + info_ids: [], + project_id: "42", + started_at: "2025-01-01T00:00:00.000Z", + tags: {}, + trace_ids: [], + urls: ["https://example.com/signup"], + user: null, + warning_ids: [], + ...overrides, + }; +} + +function sampleSegments(): ReplayRecordingSegments { + return [ + [ + { + type: 4, + timestamp: Date.parse("2025-01-01T00:00:01.000Z"), + data: { href: "https://example.com/signup" }, + }, + { + type: 5, + timestamp: Date.parse("2025-01-01T00:00:02.000Z"), + data: { + tag: "deadClick", + payload: { selector: "button[type=submit]", label: "Sign up" }, + }, + }, + ], + ]; +} + +function createMockContext() { + const stdoutWrite = mock(() => true); + return { + context: { + stdout: { write: stdoutWrite }, + stderr: { write: mock(() => true) }, + cwd: "/tmp", + }, + stdoutWrite, + }; +} + +describe("replay summarize", () => { + let getReplaySpy: ReturnType; + let getReplayRecordingSegmentsSpy: ReturnType; + let resolveTargetSpy: ReturnType; + + beforeEach(() => { + getReplaySpy = spyOn(apiClient, "getReplay").mockResolvedValue( + sampleReplay() + ); + getReplayRecordingSegmentsSpy = spyOn( + apiClient, + "getReplayRecordingSegments" + ).mockResolvedValue(sampleSegments()); + resolveTargetSpy = spyOn( + resolveTarget, + "resolveOrgOptionalProjectFromArg" + ).mockResolvedValue({ + org: "test-org", + project: "web", + projectData: { id: "42", slug: "web", name: "Web" }, + }); + }); + + afterEach(() => { + getReplaySpy.mockRestore(); + getReplayRecordingSegmentsSpy.mockRestore(); + resolveTargetSpy.mockRestore(); + }); + + test("renders a JSON replay behavior summary", async () => { + const { context, stdoutWrite } = createMockContext(); + const func = await summarizeCommand.loader(); + await func.call( + context, + { + fresh: false, + json: true, + "limit-events": 5, + "limit-signals": 5, + path: "/signup", + }, + `test-org/web/${REPLAY_ID}` + ); + + const output = stdoutWrite.mock.calls.map((call) => call[0]).join(""); + const parsed = JSON.parse(output); + expect(parsed.replayId).toBe(REPLAY_ID); + expect(parsed.focusPath).toBe("/signup"); + expect(parsed.counts.clicks).toBe(1); + expect(parsed.recording).toMatchObject({ + segmentCount: 1, + frameCount: 2, + normalizedEventCount: 2, + focusedEventCount: 2, + }); + expect(parsed.routes[0].path).toBe("/signup"); + expect(parsed.signals[0].kind).toBe("dead_click"); + }); + + test("renders missing human duration without seconds suffix", async () => { + getReplaySpy.mockResolvedValue(sampleReplay({ duration: null })); + + const { context, stdoutWrite } = createMockContext(); + const func = await summarizeCommand.loader(); + await func.call( + context, + { + fresh: false, + json: false, + "limit-events": 5, + "limit-signals": 5, + }, + `test-org/web/${REPLAY_ID}` + ); + + const output = stdoutWrite.mock.calls.map((call) => call[0]).join(""); + expect(output).toContain("Duration: -"); + expect(output).not.toContain("Duration: -s"); + }); +}); diff --git a/test/commands/replay/view.test.ts b/test/commands/replay/view.test.ts index f94bc63ea..153aea4d9 100644 --- a/test/commands/replay/view.test.ts +++ b/test/commands/replay/view.test.ts @@ -208,6 +208,12 @@ describe("viewCommand.func", () => { expect(parsed.relatedIssues[0]?.shortId).toBe("CLI-123"); expect(parsed.relatedTraces[0]?.spanCount).toBe(8); expect(parsed.trace_ids[0]).toBe("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + expect(getReplayRecordingSegmentsSpy).toHaveBeenCalledWith( + "test-org", + "42", + REPLAY_ID, + { expectedSegments: 5 } + ); expect(listIssuesPaginatedSpy).toHaveBeenCalledWith( "test-org", "", diff --git a/test/lib/api/replays.test.ts b/test/lib/api/replays.test.ts index 0b2f0f18f..7b8e9f0d8 100644 --- a/test/lib/api/replays.test.ts +++ b/test/lib/api/replays.test.ts @@ -3,6 +3,7 @@ */ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { MAX_PAGINATION_PAGES } from "../../../src/lib/api/infrastructure.js"; import { getReplay, getReplayRecordingSegments, @@ -29,6 +30,24 @@ function replayRow(id = REPLAY_ID) { }; } +function recordingSegmentsResponse( + body: unknown, + nextCursor?: string +): Response { + const headers: Record = { + "Content-Type": "application/json", + }; + + if (nextCursor) { + headers.Link = `; rel="next"; results="true"; cursor="${nextCursor}"`; + } + + return new Response(JSON.stringify(body), { + status: 200, + headers, + }); +} + describe("listReplays", () => { let originalFetch: typeof globalThis.fetch; @@ -73,7 +92,6 @@ describe("listReplays", () => { expect(url.searchParams.get("statsPeriod")).toBe("24h"); expect(url.searchParams.get("per_page")).toBe("25"); expect(url.searchParams.getAll("field")).toContain("id"); - expect(url.searchParams.getAll("field")).toContain("ota_updates"); expect(url.searchParams.getAll("field")).toContain("user"); expect(result.data).toHaveLength(1); expect(result.nextCursor).toBe("0:25:0"); @@ -244,10 +262,7 @@ describe("getReplayRecordingSegments", () => { 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" }, - }); + return recordingSegmentsResponse([[{ timestamp: 1 }]]); }); const segments = await getReplayRecordingSegments( @@ -261,8 +276,76 @@ describe("getReplayRecordingSegments", () => { `/api/0/projects/test-org/42/replays/${REPLAY_ID}/recording-segments/` ); expect(url.searchParams.get("download")).toBe("true"); + expect(url.searchParams.get("per_page")).toBe("100"); expect(segments).toEqual([[{ timestamp: 1 }]]); }); + + test("auto-paginates recording segments using the link cursor", 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 + ? Array.from({ length: 100 }, (_, index) => [{ segment: index }]) + : [[{ segment: 100 }]]; + const nextCursor = callIndex === 0 ? "0:100:0" : undefined; + callIndex += 1; + + return recordingSegmentsResponse(body, nextCursor); + }); + + const segments = await getReplayRecordingSegments( + "test-org", + "42", + REPLAY_ID, + { expectedSegments: 101 } + ); + + expect(segments).toHaveLength(101); + expect(capturedUrls).toHaveLength(2); + + const firstUrl = new URL(capturedUrls[0]!); + expect(firstUrl.searchParams.get("per_page")).toBe("100"); + expect(firstUrl.searchParams.get("cursor")).toBeNull(); + + const secondUrl = new URL(capturedUrls[1]!); + expect(secondUrl.searchParams.get("cursor")).toBe("0:100:0"); + expect(secondUrl.searchParams.get("per_page")).toBe("100"); + }); + + test("stops recording segment pagination at the safety 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 nextCursor = `0:${(callIndex + 1) * 100}:0`; + const body = [[{ segment: callIndex }]]; + callIndex += 1; + + return recordingSegmentsResponse(body, nextCursor); + }); + + const segments = await getReplayRecordingSegments( + "test-org", + "42", + REPLAY_ID + ); + + expect(segments).toHaveLength(MAX_PAGINATION_PAGES); + expect(capturedUrls).toHaveLength(MAX_PAGINATION_PAGES); + + const finalUrl = new URL(capturedUrls.at(-1)!); + expect(finalUrl.searchParams.get("cursor")).toBe( + `0:${(MAX_PAGINATION_PAGES - 1) * 100}:0` + ); + }); }); describe("listReplayIdsForIssue", () => { diff --git a/test/lib/command.test.ts b/test/lib/command.test.ts index 0873e3a01..dfb00ae99 100644 --- a/test/lib/command.test.ts +++ b/test/lib/command.test.ts @@ -872,6 +872,30 @@ describe("buildCommand output config", () => { expect(receivedFlags!.fields).toBeUndefined(); }); + test("jsonLines emits compact JSON records without a command-specific flag", async () => { + const command = buildCommand<{ json: boolean }, [], TestContext>({ + auth: false, + docs: { brief: "Test" }, + output: { human: () => "unused", jsonLines: true }, + parameters: {}, + async *func(this: TestContext, _flags: { json: boolean }) { + yield new CommandOutput({ id: 1 }); + yield new CommandOutput({ id: 2 }); + }, + }); + + const routeMap = buildRouteMap({ + routes: { test: command }, + docs: { brief: "Test app" }, + }); + const app = buildApplication(routeMap, { name: "test" }); + const ctx = createTestContext(); + + await run(app, ["test", "--json"], ctx as TestContext); + + expect(ctx.output.join("")).toBe('{"id":1}\n{"id":2}\n'); + }); + test("does not inject --json/--fields without output: 'json'", async () => { let funcCalled = false; diff --git a/test/lib/replay-events.test.ts b/test/lib/replay-events.test.ts new file mode 100644 index 000000000..84aa73772 --- /dev/null +++ b/test/lib/replay-events.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, test } from "bun:test"; +import { + extractNormalizedReplayEvents, + filterNormalizedReplayEvents, + parseReplayOffset, +} from "../../src/lib/replay-events.js"; +import type { + ReplayDetails, + ReplayRecordingSegments, +} from "../../src/types/index.js"; + +const REPLAY_ID = "346789a703f6454384f1de473b8b9fcc"; + +function replay(): ReplayDetails { + return { + id: REPLAY_ID, + count_errors: 0, + count_segments: 1, + duration: 60, + error_ids: [], + info_ids: [], + project_id: "42", + started_at: "2025-01-01T00:00:00.000Z", + tags: {}, + trace_ids: [], + urls: ["/signup"], + user: null, + warning_ids: [], + }; +} + +describe("extractNormalizedReplayEvents", () => { + test("normalizes navigation, clicks, breadcrumbs, and input events", () => { + const segments: ReplayRecordingSegments = [ + [ + { + type: 4, + timestamp: Date.parse("2025-01-01T00:00:01.000Z"), + data: { href: "/signup" }, + }, + { + type: 5, + timestamp: Date.parse("2025-01-01T00:00:02.000Z"), + data: { + tag: "rageClick", + payload: { selector: "button[type=submit]", label: "Sign up" }, + }, + }, + { + type: 5, + timestamp: Date.parse("2025-01-01T00:00:03.000Z"), + data: { + tag: "breadcrumb", + payload: { + category: "fetch", + message: "POST /api/signup", + data: { status_code: 500, url: "/api/signup" }, + }, + }, + }, + { + type: 3, + timestamp: Date.parse("2025-01-01T00:00:04.000Z"), + data: { source: 5, id: 12, text: "********" }, + }, + ], + ]; + + const events = extractNormalizedReplayEvents(replay(), segments); + + expect(events.map((event) => event.kind)).toEqual([ + "navigation", + "click", + "network", + "input", + ]); + expect(events[0]?.offsetMs).toBe(1000); + expect(events[0]?.urlPath).toBe("/signup"); + expect(events[1]?.selector).toBe("button[type=submit]"); + expect(events[1]?.data?.isRageClick).toBe(true); + expect(events[2]?.url).toBe("/api/signup"); + expect(events[3]?.data?.masked).toBe(true); + expect(events[3]?.data?.textLength).toBe(8); + }); + + test("filters by kind, url, and offset window", () => { + const segments: ReplayRecordingSegments = [ + [ + { + type: 4, + timestamp: Date.parse("2025-01-01T00:00:01.000Z"), + data: { href: "/signup" }, + }, + { + type: 5, + timestamp: Date.parse("2025-01-01T00:00:20.000Z"), + data: { + tag: "breadcrumb", + payload: { category: "console", level: "error", message: "boom" }, + }, + }, + ], + ]; + + const events = extractNormalizedReplayEvents(replay(), segments); + const filtered = filterNormalizedReplayEvents(events, { + kinds: ["error"], + url: "/signup", + fromMs: 10_000, + toMs: 30_000, + }); + + expect(filtered).toHaveLength(1); + expect(filtered[0]?.message).toBe("boom"); + }); + + test("normalizes mixed-case log levels as errors", () => { + const segments: ReplayRecordingSegments = [ + [ + { + type: 4, + timestamp: Date.parse("2025-01-01T00:00:01.000Z"), + data: { href: "/signup" }, + }, + { + type: 3, + timestamp: Date.parse("2025-01-01T00:00:02.000Z"), + data: { + source: 11, + level: "Error", + payload: ["boom"], + }, + }, + ], + ]; + + const events = extractNormalizedReplayEvents(replay(), segments); + const errors = filterNormalizedReplayEvents(events, { kinds: ["error"] }); + + expect(errors).toHaveLength(1); + expect(errors[0]?.kind).toBe("error"); + expect(errors[0]?.message).toBe("boom"); + }); + + test("filters by parsed path without matching query text", () => { + const segments: ReplayRecordingSegments = [ + [ + { + type: 4, + timestamp: Date.parse("2025-01-01T00:00:01.000Z"), + data: { href: "https://example.com/replays/?query=/signup" }, + }, + { + type: 4, + timestamp: Date.parse("2025-01-01T00:00:02.000Z"), + data: { href: "https://example.com/signup/direct" }, + }, + ], + ]; + + const events = extractNormalizedReplayEvents(replay(), segments); + const filtered = filterNormalizedReplayEvents(events, { path: "/signup" }); + + expect(filtered).toHaveLength(1); + expect(filtered[0]?.urlPath).toBe("/signup/direct"); + }); +}); + +describe("parseReplayOffset", () => { + test("parses common replay offset formats", () => { + expect(parseReplayOffset("90")).toBe(90_000); + expect(parseReplayOffset("90s")).toBe(90_000); + expect(parseReplayOffset("01:30")).toBe(90_000); + expect(parseReplayOffset("1:01:30")).toBe(3_690_000); + expect(parseReplayOffset("83000ms")).toBe(83_000); + }); +}); diff --git a/test/lib/replay-search.test.ts b/test/lib/replay-search.test.ts index 94818471a..2c32418cb 100644 --- a/test/lib/replay-search.test.ts +++ b/test/lib/replay-search.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { getReplayRequestFields, isSupportedReplayField, + replayUrlPathMatches, } from "../../src/lib/replay-search.js"; describe("getReplayRequestFields", () => { @@ -38,3 +39,19 @@ describe("isSupportedReplayField", () => { expect(isSupportedReplayField("replay_type")).toBe(false); }); }); + +describe("replayUrlPathMatches", () => { + test("matches root filter against child paths", () => { + expect(replayUrlPathMatches("https://example.com/signup", "/")).toBe(true); + expect(replayUrlPathMatches("https://example.com/", "/")).toBe(true); + }); + + test("matches child paths without matching siblings", () => { + expect( + replayUrlPathMatches("https://example.com/signup/team", "/signup") + ).toBe(true); + expect( + replayUrlPathMatches("https://example.com/signup-flow", "/signup") + ).toBe(false); + }); +}); diff --git a/test/lib/replay-summary.test.ts b/test/lib/replay-summary.test.ts new file mode 100644 index 000000000..e8aee6166 --- /dev/null +++ b/test/lib/replay-summary.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, test } from "bun:test"; +import { extractNormalizedReplayEvents } from "../../src/lib/replay-events.js"; +import { summarizeReplay } from "../../src/lib/replay-summary.js"; +import type { + ReplayDetails, + ReplayRecordingSegments, +} from "../../src/types/index.js"; + +const REPLAY_ID = "346789a703f6454384f1de473b8b9fcc"; + +function replay(): ReplayDetails { + return { + id: REPLAY_ID, + count_errors: 0, + count_segments: 1, + duration: 20, + error_ids: [], + info_ids: [], + project_id: "42", + started_at: "2025-01-01T00:00:00.000Z", + tags: {}, + trace_ids: [], + urls: ["https://example.com/signup", "https://example.com/signup/step-2"], + user: null, + warning_ids: [], + }; +} + +describe("summarizeReplay", () => { + test("summarizes routes, timings, and friction signals", () => { + const segments: ReplayRecordingSegments = [ + [ + { + type: 4, + timestamp: Date.parse("2025-01-01T00:00:01.000Z"), + data: { href: "https://example.com/signup" }, + }, + { + type: 5, + timestamp: Date.parse("2025-01-01T00:00:02.000Z"), + data: { + tag: "performanceSpan", + payload: { + op: "navigation.navigate", + description: "https://example.com/signup", + data: { duration: 3500 }, + }, + }, + }, + { + type: 5, + timestamp: Date.parse("2025-01-01T00:00:03.000Z"), + data: { + tag: "breadcrumb", + payload: { + category: "fetch", + message: "POST /api/signup", + data: { status_code: 500, url: "/api/signup" }, + }, + }, + }, + { + type: 3, + timestamp: Date.parse("2025-01-01T00:00:04.000Z"), + data: { source: 2, type: 2, x: 100, y: 100 }, + }, + { + type: 3, + timestamp: Date.parse("2025-01-01T00:00:05.000Z"), + data: { source: 2, type: 2, x: 105, y: 103 }, + }, + ], + ]; + + const events = extractNormalizedReplayEvents(replay(), segments); + const summary = summarizeReplay(replay(), events, { + org: "test-org", + project: "web", + }); + + expect(summary.routes.map((route) => route.path)).toEqual(["/signup"]); + expect(summary.routes[0]?.counts.network).toBe(1); + expect(summary.recording).toEqual({ + segmentCount: 1, + frameCount: null, + normalizedEventCount: 5, + focusedEventCount: null, + }); + expect(summary.counts.clicks).toBe(2); + expect(summary.timings.navigationDurationMs).toBe(3500); + expect(summary.signals.map((signal) => signal.kind)).toEqual( + expect.arrayContaining([ + "slow_navigation", + "network_error", + "repeated_click", + ]) + ); + }); + + test("summarizes repeated route visits as route windows", () => { + const segments: ReplayRecordingSegments = [ + [ + { + type: 4, + timestamp: Date.parse("2025-01-01T00:00:01.000Z"), + data: { href: "https://example.com/signup" }, + }, + { + type: 3, + timestamp: Date.parse("2025-01-01T00:00:02.000Z"), + data: { source: 2, type: 2, x: 100, y: 100 }, + }, + { + type: 4, + timestamp: Date.parse("2025-01-01T00:00:03.000Z"), + data: { href: "https://example.com/dashboard" }, + }, + { + type: 3, + timestamp: Date.parse("2025-01-01T00:00:04.000Z"), + data: { source: 5, id: 12, text: "hello" }, + }, + { + type: 4, + timestamp: Date.parse("2025-01-01T00:00:05.000Z"), + data: { href: "https://example.com/signup" }, + }, + { + type: 3, + timestamp: Date.parse("2025-01-01T00:00:06.000Z"), + data: { source: 3, id: 12, x: 0, y: 500 }, + }, + ], + ]; + + const events = extractNormalizedReplayEvents(replay(), segments); + const summary = summarizeReplay(replay(), events, { + org: "test-org", + project: "web", + }); + + expect(summary.routes.map((route) => route.path)).toEqual([ + "/signup", + "/dashboard", + "/signup", + ]); + expect(summary.routes[0]).toMatchObject({ + enteredAtOffsetMs: 1000, + leftAtOffsetMs: 3000, + durationMs: 2000, + nextPath: "/dashboard", + eventCount: 2, + hadUserInteraction: true, + }); + expect(summary.routes[0]?.counts.clicks).toBe(1); + expect(summary.routes[1]?.counts.inputs).toBe(1); + expect(summary.routes[2]?.counts.scrolls).toBe(1); + expect(summary.routes[2]?.leftAtOffsetMs).toBe(20_000); + expect(summary.counts.inputs).toBe(1); + expect(summary.counts.focuses).toBe(0); + expect(summary.counts.scrolls).toBe(1); + + const focusedSummary = summarizeReplay(replay(), events, { + org: "test-org", + project: "web", + focusPath: "/signup", + }); + expect(focusedSummary.routes.map((route) => route.path)).toEqual([ + "/signup", + "/signup", + ]); + expect(focusedSummary.recording.focusedEventCount).toBe(4); + expect(focusedSummary.counts.inputs).toBe(0); + expect(focusedSummary.counts.scrolls).toBe(1); + }); +});