Skip to content

Commit a055c3b

Browse files
authored
feat(toolbar): add a replay panel for start/stop current replay (#75403)
Closes #74583 Closes #74452 `getReplay` returns undefined: ![Screenshot 2024-08-08 at 10 28 18 AM](https://github.com/user-attachments/assets/ef919679-c1f2-4824-b9fb-3b88981762f7) SentrySDK doesn't have `getReplay` method: ![Screenshot 2024-08-08 at 10 22 25 AM](https://github.com/user-attachments/assets/1a022dfc-1d71-4904-8879-f2463c907822) SentrySDK is falsey (failed to import the package): ![Screenshot 2024-08-08 at 10 22 02 AM](https://github.com/user-attachments/assets/e0eb9a39-96bf-4647-820e-d245512ebf65) If you want to checkout this branch to test it, you need to run dev-ui in getsentry and make local changes: - Comment out https://github.com/getsentry/getsentry/blob/2a1da081f3a9e4e4111b577d5551fa24691da374/static/getsentry/gsApp/utils/useReplayInit.tsx#L85-L87 - Right below that, manually control the sample rates by commenting out/overriding ^. Best to test with 1.0/0.0, 0.0/1.0, 0/0. Notes: - "last recorded replay" is persisted with sessionStorage - stopping then starting will make a new replay - uses some try-catch logic to handle older SDK versions where the recording fxs throw. Follow-ups before merging - [x] Analytics context provider and start/stop button analytics (todo in this PR) - [x] comment on SDK versioning - exact release of `getReplay`/public API is unknown, but it was ~2yr ago near the release of the whole product - [x] test with v8.18 - [x] remove the debug flag - [x] make the links work for dev mode (can hard-code the sentry-test/app-frontend org/proj) Follow-ups after merging - [ ] test the links work in prod - [ ] account for minimum replay duration (so users don't stop too early) - [ ] add more content to the panel! Open to ideas. Can do this in a separate PR - [ ] keep dogfooding for bugs, w/different sample rates, sdk versions
1 parent 70aca29 commit a055c3b

File tree

6 files changed

+247
-1
lines changed

6 files changed

+247
-1
lines changed

static/app/components/devtoolbar/components/navigation.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
IconFlag,
99
IconIssues,
1010
IconMegaphone,
11+
IconPlay,
1112
IconReleases,
1213
IconSiren,
1314
} from 'sentry/icons';
@@ -60,6 +61,7 @@ export default function Navigation({
6061
<NavButton panelName="releases" label="Releases" icon={<IconReleases />}>
6162
<SessionStatusBadge />
6263
</NavButton>
64+
<NavButton panelName="replay" label="Session Replay" icon={<IconPlay />} />
6365
</dialog>
6466
);
6567
}

static/app/components/devtoolbar/components/panelRouter.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const PanelFeedback = lazy(() => import('./feedback/feedbackPanel'));
99
const PanelIssues = lazy(() => import('./issues/issuesPanel'));
1010
const PanelFeatureFlags = lazy(() => import('./featureFlags/featureFlagsPanel'));
1111
const PanelReleases = lazy(() => import('./releases/releasesPanel'));
12+
const PanelReplay = lazy(() => import('./replay/replayPanel'));
1213

1314
export default function PanelRouter() {
1415
const {state} = useToolbarRoute();
@@ -44,6 +45,12 @@ export default function PanelRouter() {
4445
<PanelReleases />
4546
</AnalyticsProvider>
4647
);
48+
case 'replay':
49+
return (
50+
<AnalyticsProvider keyVal="replay-panel" nameVal="Replay panel">
51+
<PanelReplay />
52+
</AnalyticsProvider>
53+
);
4754
default:
4855
return null;
4956
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import {useContext, useState} from 'react';
2+
import {css} from '@emotion/react';
3+
4+
import {Button} from 'sentry/components/button';
5+
import AnalyticsProvider, {
6+
AnalyticsContext,
7+
} from 'sentry/components/devtoolbar/components/analyticsProvider';
8+
import SentryAppLink from 'sentry/components/devtoolbar/components/sentryAppLink';
9+
import useReplayRecorder from 'sentry/components/devtoolbar/hooks/useReplayRecorder';
10+
import {resetFlexRowCss} from 'sentry/components/devtoolbar/styles/reset';
11+
import ProjectBadge from 'sentry/components/idBadge/projectBadge';
12+
import {IconPause, IconPlay} from 'sentry/icons';
13+
import type {PlatformKey} from 'sentry/types/project';
14+
15+
import useConfiguration from '../../hooks/useConfiguration';
16+
import {panelInsetContentCss, panelSectionCss} from '../../styles/panel';
17+
import {smallCss} from '../../styles/typography';
18+
import PanelLayout from '../panelLayout';
19+
20+
const TRUNC_ID_LENGTH = 16;
21+
22+
export default function ReplayPanel() {
23+
const {trackAnalytics} = useConfiguration();
24+
25+
const {
26+
disabledReason,
27+
isDisabled,
28+
isRecording,
29+
lastReplayId,
30+
recordingMode,
31+
startRecordingSession,
32+
stopRecording,
33+
} = useReplayRecorder();
34+
const isRecordingSession = isRecording && recordingMode === 'session';
35+
36+
const {eventName, eventKey} = useContext(AnalyticsContext);
37+
const [buttonLoading, setButtonLoading] = useState(false);
38+
return (
39+
<PanelLayout title="Session Replay">
40+
<Button
41+
size="sm"
42+
icon={isDisabled ? undefined : isRecordingSession ? <IconPause /> : <IconPlay />}
43+
disabled={isDisabled || buttonLoading}
44+
onClick={async () => {
45+
setButtonLoading(true);
46+
isRecordingSession ? await stopRecording() : await startRecordingSession();
47+
setButtonLoading(false);
48+
const type = isRecordingSession ? 'stop' : 'start';
49+
trackAnalytics?.({
50+
eventKey: eventKey + `.${type}-button-click`,
51+
eventName: eventName + `${type} button clicked`,
52+
});
53+
}}
54+
>
55+
{isDisabled
56+
? disabledReason
57+
: isRecordingSession
58+
? 'Recording in progress, click to stop'
59+
: isRecording
60+
? 'Replay buffering, click to flush and record'
61+
: 'Start recording the current session'}
62+
</Button>
63+
<div css={[smallCss, panelSectionCss, panelInsetContentCss]}>
64+
{lastReplayId ? (
65+
<span css={[resetFlexRowCss, {gap: 'var(--space50)'}]}>
66+
{isRecording ? 'Current replay: ' : 'Last recorded replay: '}
67+
<AnalyticsProvider keyVal="replay-details-link" nameVal="replay details link">
68+
<ReplayLink lastReplayId={lastReplayId} />
69+
</AnalyticsProvider>
70+
</span>
71+
) : (
72+
'No replay is recording this session.'
73+
)}
74+
</div>
75+
</PanelLayout>
76+
);
77+
}
78+
79+
function ReplayLink({lastReplayId}: {lastReplayId: string}) {
80+
const {projectSlug, projectId, projectPlatform} = useConfiguration();
81+
return (
82+
<SentryAppLink
83+
to={{
84+
url: `/replays/${lastReplayId}/`,
85+
query: {project: projectId},
86+
}}
87+
>
88+
<div
89+
css={[
90+
resetFlexRowCss,
91+
{
92+
display: 'inline-flex',
93+
gap: 'var(--space50)',
94+
alignItems: 'center',
95+
},
96+
]}
97+
>
98+
<ProjectBadge
99+
css={css({'&& img': {boxShadow: 'none'}})}
100+
project={{
101+
slug: projectSlug,
102+
id: projectId,
103+
platform: projectPlatform as PlatformKey,
104+
}}
105+
avatarSize={16}
106+
hideName
107+
avatarProps={{hasTooltip: true}}
108+
/>
109+
{lastReplayId.slice(0, TRUNC_ID_LENGTH)}
110+
</div>
111+
</SentryAppLink>
112+
);
113+
}

static/app/components/devtoolbar/components/sentryAppLink.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ interface Props {
1212
onClick?: (event: MouseEvent) => void;
1313
}
1414

15+
/**
16+
* Inline link to orgSlug.sentry.io/{to} with built-in click analytic.
17+
*/
1518
export default function SentryAppLink({children, to}: Props) {
1619
const {organizationSlug, trackAnalytics} = useConfiguration();
1720
const {eventName, eventKey} = useContext(AnalyticsContext);
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import {useCallback, useEffect, useState} from 'react';
2+
import type {replayIntegration} from '@sentry/react';
3+
import type {ReplayRecordingMode} from '@sentry/types';
4+
5+
import useConfiguration from 'sentry/components/devtoolbar/hooks/useConfiguration';
6+
import {useSessionStorage} from 'sentry/utils/useSessionStorage';
7+
8+
type ReplayRecorderState = {
9+
disabledReason: string | undefined;
10+
isDisabled: boolean;
11+
isRecording: boolean;
12+
lastReplayId: string | undefined;
13+
recordingMode: ReplayRecordingMode | undefined;
14+
startRecordingSession(): Promise<boolean>; // returns false if called in the wrong state
15+
stopRecording(): Promise<boolean>; // returns false if called in the wrong state
16+
};
17+
18+
interface ReplayInternalAPI {
19+
[other: string]: any;
20+
getSessionId(): string | undefined;
21+
isEnabled(): boolean;
22+
recordingMode: ReplayRecordingMode;
23+
}
24+
25+
function getReplayInternal(
26+
replay: ReturnType<typeof replayIntegration>
27+
): ReplayInternalAPI {
28+
// While the toolbar is internal, we can use the private API for added functionality and reduced dependence on SDK release versions
29+
// @ts-ignore:next-line
30+
return replay._replay;
31+
}
32+
33+
const LAST_REPLAY_STORAGE_KEY = 'devtoolbar.last_replay_id';
34+
35+
export default function useReplayRecorder(): ReplayRecorderState {
36+
const {SentrySDK} = useConfiguration();
37+
const replay =
38+
SentrySDK && 'getReplay' in SentrySDK ? SentrySDK.getReplay() : undefined;
39+
const replayInternal = replay ? getReplayInternal(replay) : undefined;
40+
41+
// sessionId is defined if we are recording in session OR buffer mode.
42+
const [sessionId, setSessionId] = useState<string | undefined>(() =>
43+
replayInternal?.getSessionId()
44+
);
45+
const [recordingMode, setRecordingMode] = useState<ReplayRecordingMode | undefined>(
46+
() => replayInternal?.recordingMode
47+
);
48+
49+
const isDisabled = replay === undefined;
50+
const disabledReason = !SentrySDK
51+
? 'Failed to load the Sentry SDK.'
52+
: !('getReplay' in SentrySDK)
53+
? 'Your SDK version is too old to support Replays.'
54+
: !replay
55+
? 'You need to install the SDK Replay integration.'
56+
: undefined;
57+
58+
const [isRecording, setIsRecording] = useState<boolean>(
59+
() => replayInternal?.isEnabled() ?? false
60+
);
61+
const [lastReplayId, setLastReplayId] = useSessionStorage<string | undefined>(
62+
LAST_REPLAY_STORAGE_KEY,
63+
undefined
64+
);
65+
useEffect(() => {
66+
if (isRecording && recordingMode === 'session' && sessionId) {
67+
setLastReplayId(sessionId);
68+
}
69+
}, [isRecording, recordingMode, sessionId, setLastReplayId]);
70+
71+
const refreshState = useCallback(() => {
72+
setIsRecording(replayInternal?.isEnabled() ?? false);
73+
setSessionId(replayInternal?.getSessionId());
74+
setRecordingMode(replayInternal?.recordingMode);
75+
}, [replayInternal]);
76+
77+
const startRecordingSession = useCallback(async () => {
78+
let success = false;
79+
if (replay) {
80+
// Note SDK v8.19 and older will throw if a replay is already started.
81+
// Details at https://github.com/getsentry/sentry-javascript/pull/13000
82+
if (!isRecording) {
83+
replay.start();
84+
success = true;
85+
} else if (recordingMode === 'buffer') {
86+
// For SDK v8.20+, flush() would work for both cases, but we're staying version-agnostic.
87+
await replay.flush();
88+
success = true;
89+
}
90+
refreshState();
91+
}
92+
return success;
93+
}, [replay, isRecording, recordingMode, refreshState]);
94+
95+
const stopRecording = useCallback(async () => {
96+
let success = false;
97+
if (replay && isRecording) {
98+
await replay.stop();
99+
success = true;
100+
refreshState();
101+
}
102+
return success;
103+
}, [isRecording, replay, refreshState]);
104+
105+
return {
106+
disabledReason,
107+
isDisabled,
108+
isRecording,
109+
lastReplayId,
110+
recordingMode,
111+
startRecordingSession,
112+
stopRecording,
113+
};
114+
}

static/app/components/devtoolbar/hooks/useToolbarRoute.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
import {createContext, useCallback, useContext, useState} from 'react';
22

33
type State = {
4-
activePanel: null | 'alerts' | 'feedback' | 'issues' | 'featureFlags' | 'releases';
4+
activePanel:
5+
| null
6+
| 'alerts'
7+
| 'feedback'
8+
| 'issues'
9+
| 'featureFlags'
10+
| 'releases'
11+
| 'replay';
512
};
613

714
const context = createContext<{

0 commit comments

Comments
 (0)