Skip to content

Commit 6054887

Browse files
gggritsoclaude
andauthored
feat(cli): Add replay download command (#1392)
Adds `sentry replay download`, which saves a Session Replay recording as rrweb JSON: the flat, time-ordered event array that rrweb-player and rrvideo consume. ```bash sentry replay download my-org/346789a703f6454384f1de473b8b9fcc sentry replay download my-org/346789a703f6454384f1de473b8b9fcc --output ./replay.json ``` The command mirrors `replay view` for arguments (bare ID, `<org>/<id>`, `<org>/<project>/<id>`, replay URL, same project-scope check) and `build download` for output (`<replay-id>.rrweb.json` in the current directory, `--output`/`-o` to override). Stdout and `--json` carry a summary (path, segment and event counts, duration), not the recording. Adjust some error handling so the usage hints can be re-used by multiple replay-related commands. Refs REPLAY-1011 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent c1ca2cb commit 6054887

13 files changed

Lines changed: 751 additions & 21 deletions

File tree

‎apps/cli-docs/src/content/docs/contributing.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ toolkit/
7878
│ │ │ ├── project/ # create, delete, list, view
7979
│ │ │ ├── react-native/# gradle, xcode
8080
│ │ │ ├── release/ # archive, create, delete, deploy, deploys, finalize, list, propose-version, restore, set-commits, view
81-
│ │ │ ├── replay/ # list, view
81+
│ │ │ ├── replay/ # download, list, view
8282
│ │ │ ├── repo/ # list
8383
│ │ │ ├── snapshots/ # diff, download, upload
8484
│ │ │ ├── sourcemap/ # inject, resolve, upload

‎apps/cli-docs/src/fragments/commands/replay.md‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,21 @@ sentry replay view my-org/346789a703f6454384f1de473b8b9fcc --web
3939
# View the replay linked to a trace
4040
sentry replay view my-org/frontend/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
4141
```
42+
43+
### Download a replay
44+
45+
```bash
46+
# Download a replay as rrweb JSON to ./<replay-id>.rrweb.json
47+
sentry replay download my-org/346789a703f6454384f1de473b8b9fcc
48+
49+
# Choose where the file goes
50+
sentry replay download my-org/346789a703f6454384f1de473b8b9fcc --output ./replay.json
51+
52+
# Download from a replay URL
53+
sentry replay download https://sentry.io/organizations/my-org/explore/replays/346789a703f6454384f1de473b8b9fcc/
54+
```
55+
56+
The file is a flat, time-ordered array of rrweb events, ready for
57+
[rrweb-player](https://github.com/rrweb-io/rrweb/tree/master/packages/rrweb-player)
58+
or [rrvideo](https://github.com/rrweb-io/rrweb/tree/master/packages/rrvideo).
59+
Sentry's custom events (breadcrumbs, performance spans) are kept.

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -581,6 +581,7 @@ Search and inspect Session Replays
581581

582582
- `sentry replay list <org/project>` — List recent Session Replays
583583
- `sentry replay view <replay-id-or-url...>` — View a Session Replay
584+
- `sentry replay download <replay-id-or-url...>` — Download a Session Replay as rrweb JSON
584585

585586
→ Full flags and examples: `references/replay.md`
586587

‎packages/cli/plugins/sentry-cli/skills/sentry-cli/references/replay.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,4 +148,25 @@ sentry replay view my-org/346789a703f6454384f1de473b8b9fcc --web
148148
sentry replay view my-org/frontend/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
149149
```
150150

151+
### `sentry replay download <replay-id-or-url...>`
152+
153+
Download a Session Replay as rrweb JSON
154+
155+
**Flags:**
156+
- `-o, --output <value> - Output path (default: <replay-id>.rrweb.json in the current directory)`
157+
- `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data`
158+
159+
**Examples:**
160+
161+
```bash
162+
# Download a replay as rrweb JSON to ./<replay-id>.rrweb.json
163+
sentry replay download my-org/346789a703f6454384f1de473b8b9fcc
164+
165+
# Choose where the file goes
166+
sentry replay download my-org/346789a703f6454384f1de473b8b9fcc --output ./replay.json
167+
168+
# Download from a replay URL
169+
sentry replay download https://sentry.io/organizations/my-org/explore/replays/346789a703f6454384f1de473b8b9fcc/
170+
```
171+
151172
All commands also support `--json`, `--fields`, `--help`, `--log-level`, and `--verbose` flags.
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
/**
2+
* sentry replay download
3+
*
4+
* Download a Session Replay recording as rrweb JSON: the flat event array
5+
* that rrweb-player and rrvideo consume.
6+
*/
7+
8+
import { mkdir, writeFile } from "node:fs/promises";
9+
import { dirname, resolve } from "node:path";
10+
import type { SentryContext } from "../../context.js";
11+
import {
12+
getReplayRecordingSegments,
13+
resolveReplay,
14+
} from "../../lib/api-client.js";
15+
import { buildCommand } from "../../lib/command.js";
16+
import { ApiError, ResolutionError } from "../../lib/errors.js";
17+
import { CommandOutput } from "../../lib/formatters/output.js";
18+
import {
19+
formatReplayDownloadResult,
20+
type ReplayDownloadData,
21+
} from "../../lib/formatters/replay.js";
22+
import { validateHexId } from "../../lib/hex-id.js";
23+
import {
24+
applyFreshFlag,
25+
FRESH_ALIASES,
26+
FRESH_FLAG,
27+
} from "../../lib/list-command.js";
28+
import { logger } from "../../lib/logger.js";
29+
import {
30+
hasFullSnapshot,
31+
rrwebDurationMs,
32+
toRRWebEvents,
33+
} from "../../lib/replay-rrweb.js";
34+
import { resolveOrgOptionalFromArg } from "../../lib/resolve-target.js";
35+
import type { ReplayDetails } from "../../types/index.js";
36+
import { parsePositionalArgs, validateReplayProjectScope } from "./view.js";
37+
38+
type DownloadFlags = {
39+
readonly output?: string;
40+
readonly fresh: boolean;
41+
};
42+
43+
const USAGE_HINT =
44+
"sentry replay download [<org>/<project>/]<replay-id> | <replay-url>";
45+
46+
const log = logger.withTag("replay.download");
47+
48+
export const downloadCommand = buildCommand({
49+
docs: {
50+
brief: "Download a Session Replay as rrweb JSON",
51+
fullDescription:
52+
"Download a Session Replay recording as rrweb JSON: a single flat, " +
53+
"time-ordered array of events that rrweb-player and rrvideo can play.\n\n" +
54+
"All recorded events are kept, including Sentry's custom events " +
55+
"(breadcrumbs, performance spans).\n\n" +
56+
"ID formats:\n" +
57+
" <id> - auto-detect org from config or DSN\n" +
58+
" <org>/<id> - explicit organization\n" +
59+
" <org>/<project>/<id> - explicit org/project context\n" +
60+
" <replay-url> - parse org and replay ID from a Sentry URL\n\n" +
61+
"Examples:\n" +
62+
" sentry replay download 346789a703f6454384f1de473b8b9fcc\n" +
63+
" sentry replay download sentry/346789a703f6454384f1de473b8b9fcc\n" +
64+
" sentry replay download sentry/346789a703f6454384f1de473b8b9fcc --output ./replay.json\n" +
65+
" sentry replay download https://sentry.io/organizations/sentry/explore/replays/346789a703f6454384f1de473b8b9fcc/",
66+
},
67+
output: {
68+
human: formatReplayDownloadResult,
69+
},
70+
parameters: {
71+
positional: {
72+
kind: "array",
73+
parameter: {
74+
placeholder: "replay-id-or-url",
75+
brief: "[<org>/<project>] <replay-id or trace-id> or <replay-url>",
76+
parse: String,
77+
},
78+
},
79+
flags: {
80+
output: {
81+
kind: "parsed",
82+
parse: String,
83+
brief:
84+
"Output path (default: <replay-id>.rrweb.json in the current directory)",
85+
optional: true,
86+
},
87+
fresh: FRESH_FLAG,
88+
},
89+
aliases: { ...FRESH_ALIASES, o: "output" },
90+
},
91+
async *func(this: SentryContext, flags: DownloadFlags, ...args: string[]) {
92+
applyFreshFlag(flags);
93+
const { cwd } = this;
94+
95+
const parsedArgs = parsePositionalArgs(args, USAGE_HINT);
96+
if (parsedArgs.warning) {
97+
log.warn(parsedArgs.warning);
98+
}
99+
100+
const replayId = validateHexId(parsedArgs.replayId, "replay ID");
101+
const resolved = await resolveOrgOptionalFromArg(
102+
parsedArgs.targetArg,
103+
cwd,
104+
"replay download"
105+
);
106+
107+
let replay: ReplayDetails;
108+
try {
109+
replay = await resolveReplay(resolved.org, replayId, {
110+
projectSlugs: resolved.project ? [resolved.project] : undefined,
111+
});
112+
} catch (error) {
113+
if (error instanceof ApiError && error.status === 404) {
114+
throw new ResolutionError(
115+
`Replay '${replayId}'`,
116+
"not found",
117+
`sentry replay download ${resolved.org}/${replayId}`,
118+
[
119+
"Check that you are querying the right organization",
120+
"The replay may be past your retention window",
121+
]
122+
);
123+
}
124+
throw error;
125+
}
126+
127+
await validateReplayProjectScope({
128+
org: resolved.org,
129+
project: resolved.project,
130+
expectedProjectId: resolved.projectData?.id,
131+
replayId,
132+
replay,
133+
command: "download",
134+
});
135+
136+
if (replay.is_archived || !replay.project_id) {
137+
throw noRecordingError(resolved.org, replay.id);
138+
}
139+
140+
// No expectedSegments hint: follow the cursor to the end so a stale
141+
// count_segments can't cut the download short.
142+
const segments = await getReplayRecordingSegments(
143+
resolved.org,
144+
String(replay.project_id),
145+
replay.id
146+
);
147+
const events = toRRWebEvents(segments);
148+
if (events.length === 0) {
149+
throw noRecordingError(resolved.org, replay.id);
150+
}
151+
if (!hasFullSnapshot(events)) {
152+
log.warn(
153+
"This recording has no full DOM snapshot (e.g. a mobile replay), so rrweb players cannot render it."
154+
);
155+
}
156+
157+
const output = resolve(cwd, flags.output ?? `${replay.id}.rrweb.json`);
158+
await mkdir(dirname(output), { recursive: true });
159+
await writeFile(output, JSON.stringify(events));
160+
161+
yield new CommandOutput<ReplayDownloadData>({
162+
org: resolved.org,
163+
replayId: replay.id,
164+
output,
165+
segmentCount: segments.length,
166+
eventCount: events.length,
167+
durationMs: rrwebDurationMs(events),
168+
});
169+
return { hint: `Downloaded replay ${replay.id} to ${output}` };
170+
},
171+
});
172+
173+
/** The segments endpoint answers 200 [] for archived or expired recordings. */
174+
function noRecordingError(org: string, replayId: string): ResolutionError {
175+
return new ResolutionError(
176+
`Replay '${replayId}'`,
177+
"has no recording to download",
178+
`sentry replay view ${org}/${replayId}`,
179+
["The replay may be archived or past your retention window"]
180+
);
181+
}

‎packages/cli/src/commands/replay/index.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
11
/**
22
* sentry replay
33
*
4-
* Search and inspect Session Replays.
4+
* Search, inspect, and download Session Replays.
55
*/
66

77
import { buildRouteMap } from "../../lib/route-map.js";
8+
import { downloadCommand } from "./download.js";
89
import { listCommand } from "./list.js";
910
import { viewCommand } from "./view.js";
1011

1112
export const replayRoute = buildRouteMap({
1213
routes: {
1314
list: listCommand,
1415
view: viewCommand,
16+
download: downloadCommand,
1517
},
1618
defaultCommand: "view",
1719
docs: {
@@ -20,7 +22,8 @@ export const replayRoute = buildRouteMap({
2022
"Search and inspect Session Replays from your Sentry organization.\n\n" +
2123
"Commands:\n" +
2224
" list List recent replays in an org or project\n" +
23-
" view View details of a specific replay\n\n" +
25+
" view View details of a specific replay\n" +
26+
" download Download a replay recording as rrweb JSON\n\n" +
2427
"Alias: `sentry replays` → `sentry replay list`",
2528
hideRoute: {},
2629
},

0 commit comments

Comments
 (0)