Skip to content

Commit f0929f8

Browse files
feat(video-replay): Add download JSON and Video segment buttons (#92310)
This PR adds Download JSON and Download Video Segment button for Mobile/Video Replays. This doesn't help users, as they don't need to download nor see the Replay JSON, but helps Sentry employees debugging issue with Video Replays. ### Added UI ![Screenshot 2025-05-27 at 16 29 08](https://github.com/user-attachments/assets/4966fb38-c3c5-4f91-86a8-fb127e67f82e) ### Example Workflow https://github.com/user-attachments/assets/071e2e8e-573b-4929-aa45-331eb352b23a Fixes #44919 --------- Co-authored-by: Ryan Albrecht <ryan.albrecht@sentry.io>
1 parent 65535a1 commit f0929f8

File tree

4 files changed

+173
-79
lines changed

4 files changed

+173
-79
lines changed
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export function downloadObjectAsJson(exportObj: any, exportName: any) {
2+
const dataStr = `data:text/json;charset=utf-8,${encodeURIComponent(
3+
JSON.stringify(exportObj)
4+
)}`;
5+
const downloadAnchorNode = document.createElement('a');
6+
downloadAnchorNode.setAttribute('href', dataStr);
7+
downloadAnchorNode.setAttribute('download', `${exportName}.json`);
8+
document.body.appendChild(downloadAnchorNode); // required for firefox
9+
downloadAnchorNode.click();
10+
downloadAnchorNode.remove();
11+
}

static/app/views/dashboards/exportDashboard.tsx

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import cloneDeep from 'lodash/cloneDeep';
22

33
import {addErrorMessage} from 'sentry/actionCreators/indicator';
4+
import {downloadObjectAsJson} from 'sentry/utils/downloadObjectAsJson';
45

56
import type {DashboardDetails} from './types';
67

@@ -143,18 +144,6 @@ function getPropertyStructure(property: any) {
143144
return structure;
144145
}
145146

146-
function downloadObjectAsJson(exportObj: any, exportName: any) {
147-
const dataStr = `data:text/json;charset=utf-8,${encodeURIComponent(
148-
JSON.stringify(exportObj)
149-
)}`;
150-
const downloadAnchorNode = document.createElement('a');
151-
downloadAnchorNode.setAttribute('href', dataStr);
152-
downloadAnchorNode.setAttribute('download', `${exportName}.json`);
153-
document.body.appendChild(downloadAnchorNode); // required for firefox
154-
downloadAnchorNode.click();
155-
downloadAnchorNode.remove();
156-
}
157-
158147
function cleanTitle(title: any) {
159148
const regex = /[^a-z0-9]/gi;
160149
const formattedTitle = title.replace(regex, '-');
Lines changed: 8 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,14 @@
11
import styled from '@emotion/styled';
22

3-
import type {MenuItemProps} from 'sentry/components/dropdownMenu';
4-
import {DropdownMenu} from 'sentry/components/dropdownMenu';
53
import FeedbackWidgetButton from 'sentry/components/feedback/widget/feedbackWidgetButton';
64
import * as Layout from 'sentry/components/layouts/thirds';
75
import Placeholder from 'sentry/components/placeholder';
86
import ConfigureReplayCard from 'sentry/components/replays/header/configureReplayCard';
97
import FeedbackButton from 'sentry/components/replays/header/feedbackButton';
108
import ReplayLoadingState from 'sentry/components/replays/player/replayLoadingState';
11-
import {IconDelete, IconEllipsis, IconUpload} from 'sentry/icons';
12-
import {t} from 'sentry/locale';
139
import {space} from 'sentry/styles/space';
14-
import useDeleteReplay from 'sentry/utils/replays/hooks/useDeleteReplay';
1510
import type useLoadReplayReader from 'sentry/utils/replays/hooks/useLoadReplayReader';
16-
import useShareReplayAtTimestamp from 'sentry/utils/replays/hooks/useShareReplayAtTimestamp';
17-
import type {ReplayRecord} from 'sentry/views/replays/types';
11+
import ReplayItemDropdown from 'sentry/views/replays/detail/header/replayItemDropdown';
1812

1913
interface Props {
2014
readerResult: ReturnType<typeof useLoadReplayReader>;
@@ -32,7 +26,11 @@ export default function ReplayDetailsHeaderActions({readerResult}: Props) {
3226
<ButtonActionsWrapper>
3327
<FeedbackButton />
3428
<ConfigureReplayCard isMobile={false} replayRecord={replayRecord} />
35-
<ReplayItemDropdown replayRecord={replayRecord} projectSlug={projectSlug} />
29+
<ReplayItemDropdown
30+
projectSlug={projectSlug}
31+
replay={undefined}
32+
replayRecord={replayRecord}
33+
/>
3634
</ButtonActionsWrapper>
3735
)}
3836
>
@@ -44,67 +42,16 @@ export default function ReplayDetailsHeaderActions({readerResult}: Props) {
4442
replayRecord={replay.getReplay()}
4543
/>
4644
<ReplayItemDropdown
47-
replayRecord={replay.getReplay()}
4845
projectSlug={readerResult.projectSlug}
46+
replay={replay}
47+
replayRecord={replay.getReplay()}
4948
/>
5049
</ButtonActionsWrapper>
5150
)}
5251
</ReplayLoadingState>
5352
);
5453
}
5554

56-
function ReplayItemDropdown({
57-
replayRecord,
58-
projectSlug,
59-
}: {
60-
projectSlug: string | null;
61-
replayRecord: ReplayRecord | undefined;
62-
}) {
63-
const onShareReplay = useShareReplayAtTimestamp();
64-
const onDeleteReplay = useDeleteReplay({
65-
replayId: replayRecord?.id,
66-
projectSlug,
67-
});
68-
69-
const dropdownItems: MenuItemProps[] = replayRecord
70-
? [
71-
{
72-
key: 'share',
73-
label: (
74-
<ItemSpacer>
75-
<IconUpload size="sm" />
76-
{t('Share')}
77-
</ItemSpacer>
78-
),
79-
onAction: onShareReplay,
80-
},
81-
{
82-
key: 'delete',
83-
label: (
84-
<ItemSpacer>
85-
<IconDelete size="sm" />
86-
{t('Delete')}
87-
</ItemSpacer>
88-
),
89-
onAction: onDeleteReplay,
90-
},
91-
]
92-
: [];
93-
94-
return (
95-
<DropdownMenu
96-
position="bottom-end"
97-
triggerProps={{
98-
showChevron: false,
99-
icon: <IconEllipsis color="subText" />,
100-
}}
101-
size="sm"
102-
items={dropdownItems}
103-
isDisabled={dropdownItems.length === 0}
104-
/>
105-
);
106-
}
107-
10855
// TODO(replay); This could make a lot of sense to put inside HeaderActions by default
10956
const ButtonActionsWrapper = styled(Layout.HeaderActions)`
11057
flex-direction: row;
@@ -114,9 +61,3 @@ const ButtonActionsWrapper = styled(Layout.HeaderActions)`
11461
margin-bottom: 0;
11562
}
11663
`;
117-
118-
const ItemSpacer = styled('div')`
119-
display: flex;
120-
gap: ${space(1)};
121-
align-items: center;
122-
`;
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import styled from '@emotion/styled';
2+
import * as Sentry from '@sentry/react';
3+
4+
import {addErrorMessage} from 'sentry/actionCreators/indicator';
5+
import type {MenuItemProps} from 'sentry/components/dropdownMenu';
6+
import {DropdownMenu} from 'sentry/components/dropdownMenu';
7+
import {IconDelete, IconDownload, IconEllipsis, IconUpload} from 'sentry/icons';
8+
import {t} from 'sentry/locale';
9+
import {space} from 'sentry/styles/space';
10+
import {defined} from 'sentry/utils';
11+
import {downloadObjectAsJson} from 'sentry/utils/downloadObjectAsJson';
12+
import {isActiveSuperuser} from 'sentry/utils/isActiveSuperuser';
13+
import useDeleteReplay from 'sentry/utils/replays/hooks/useDeleteReplay';
14+
import useShareReplayAtTimestamp from 'sentry/utils/replays/hooks/useShareReplayAtTimestamp';
15+
import type ReplayReader from 'sentry/utils/replays/replayReader';
16+
import {useIsSentryEmployee} from 'sentry/utils/useIsSentryEmployee';
17+
import {useNavigate} from 'sentry/utils/useNavigate';
18+
import useOrganization from 'sentry/utils/useOrganization';
19+
import type {ReplayRecord} from 'sentry/views/replays/types';
20+
21+
interface Props {
22+
projectSlug: string | null;
23+
replay: ReplayReader | undefined;
24+
// Accept the replay and replayRecord in case the replay doesn't load properly,
25+
// we still want to be able to sent the Delete request.
26+
replayRecord: ReplayRecord | undefined;
27+
}
28+
29+
export default function ReplayItemDropdown({projectSlug, replay, replayRecord}: Props) {
30+
const navigate = useNavigate();
31+
const organization = useOrganization();
32+
const isEmployee = useIsSentryEmployee();
33+
const isSuperUser = isActiveSuperuser();
34+
35+
const replayId = replayRecord?.id;
36+
const isMobile = replay?.isVideoReplay();
37+
38+
const canSeeEmployeeLinks = isEmployee || isSuperUser;
39+
const canDownload = projectSlug && replay;
40+
41+
const onShareReplay = useShareReplayAtTimestamp();
42+
43+
const canDelete = replayId && projectSlug;
44+
const onDeleteReplay = useDeleteReplay({replayId, projectSlug});
45+
46+
const dropdownItems: MenuItemProps[] = [
47+
{
48+
key: 'download-rrweb',
49+
label: (
50+
<ItemSpacer>
51+
<IconDownload />
52+
{t('Download JSON')}
53+
</ItemSpacer>
54+
),
55+
onAction: () => {
56+
try {
57+
if (!replay) {
58+
addErrorMessage(t('Replay not found'));
59+
return;
60+
}
61+
downloadObjectAsJson(replay.getRRWebFrames(), 'rrweb');
62+
} catch (error) {
63+
Sentry.captureException(error);
64+
addErrorMessage(
65+
'Could not export replay as rrweb data. Please wait or try again'
66+
);
67+
}
68+
},
69+
disabled: !canDownload,
70+
},
71+
canSeeEmployeeLinks
72+
? {
73+
key: 'download-replay-record',
74+
label: (
75+
<ItemSpacer>
76+
<IconDownload />
77+
{t('Download Replay Record (superuser)')}
78+
</ItemSpacer>
79+
),
80+
onAction: () => {
81+
try {
82+
if (!replay) {
83+
addErrorMessage(t('Replay not found'));
84+
return;
85+
}
86+
downloadObjectAsJson(replay.getReplay(), 'replay-record');
87+
} catch (error) {
88+
Sentry.captureException(error);
89+
addErrorMessage('Could not export replay record. Please wait or try again');
90+
}
91+
},
92+
disabled: !canDownload,
93+
}
94+
: null,
95+
canSeeEmployeeLinks && isMobile
96+
? {
97+
key: 'download-1st-video',
98+
label: (
99+
<ItemSpacer>
100+
<IconDownload />
101+
{t('Download 1st video segment (superuser)')}
102+
</ItemSpacer>
103+
),
104+
onAction: () =>
105+
navigate(
106+
`/api/0/projects/${organization.slug}/${projectSlug}/replays/${replayId}/videos/0/`
107+
),
108+
disabled: !canDownload,
109+
}
110+
: null,
111+
{
112+
key: 'share',
113+
label: (
114+
<ItemSpacer>
115+
<IconUpload />
116+
{t('Share')}
117+
</ItemSpacer>
118+
),
119+
onAction: onShareReplay,
120+
disabled: !replayId,
121+
},
122+
{
123+
key: 'delete',
124+
label: (
125+
<ItemSpacer>
126+
<IconDelete />
127+
{t('Delete')}
128+
</ItemSpacer>
129+
),
130+
onAction: onDeleteReplay,
131+
disabled: !canDelete,
132+
},
133+
].filter(defined);
134+
135+
return (
136+
<DropdownMenu
137+
position="bottom-end"
138+
triggerProps={{
139+
showChevron: false,
140+
icon: <IconEllipsis color="subText" />,
141+
}}
142+
size="sm"
143+
items={dropdownItems}
144+
isDisabled={dropdownItems.length === 0}
145+
/>
146+
);
147+
}
148+
149+
const ItemSpacer = styled('div')`
150+
display: flex;
151+
gap: ${space(1)};
152+
align-items: center;
153+
`;

0 commit comments

Comments
 (0)