|
| 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 | +} |
0 commit comments