-
-
Notifications
You must be signed in to change notification settings - Fork 4.2k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
- Loading branch information
Showing
6 changed files
with
247 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
113 changes: 113 additions & 0 deletions
113
static/app/components/devtoolbar/components/replay/replayPanel.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,113 @@ | ||
import {useContext, useState} from 'react'; | ||
import {css} from '@emotion/react'; | ||
|
||
import {Button} from 'sentry/components/button'; | ||
import AnalyticsProvider, { | ||
AnalyticsContext, | ||
} from 'sentry/components/devtoolbar/components/analyticsProvider'; | ||
import SentryAppLink from 'sentry/components/devtoolbar/components/sentryAppLink'; | ||
import useReplayRecorder from 'sentry/components/devtoolbar/hooks/useReplayRecorder'; | ||
import {resetFlexRowCss} from 'sentry/components/devtoolbar/styles/reset'; | ||
import ProjectBadge from 'sentry/components/idBadge/projectBadge'; | ||
import {IconPause, IconPlay} from 'sentry/icons'; | ||
import type {PlatformKey} from 'sentry/types/project'; | ||
|
||
import useConfiguration from '../../hooks/useConfiguration'; | ||
import {panelInsetContentCss, panelSectionCss} from '../../styles/panel'; | ||
import {smallCss} from '../../styles/typography'; | ||
import PanelLayout from '../panelLayout'; | ||
|
||
const TRUNC_ID_LENGTH = 16; | ||
|
||
export default function ReplayPanel() { | ||
const {trackAnalytics} = useConfiguration(); | ||
|
||
const { | ||
disabledReason, | ||
isDisabled, | ||
isRecording, | ||
lastReplayId, | ||
recordingMode, | ||
startRecordingSession, | ||
stopRecording, | ||
} = useReplayRecorder(); | ||
const isRecordingSession = isRecording && recordingMode === 'session'; | ||
|
||
const {eventName, eventKey} = useContext(AnalyticsContext); | ||
const [buttonLoading, setButtonLoading] = useState(false); | ||
return ( | ||
<PanelLayout title="Session Replay"> | ||
<Button | ||
size="sm" | ||
icon={isDisabled ? undefined : isRecordingSession ? <IconPause /> : <IconPlay />} | ||
disabled={isDisabled || buttonLoading} | ||
onClick={async () => { | ||
setButtonLoading(true); | ||
isRecordingSession ? await stopRecording() : await startRecordingSession(); | ||
setButtonLoading(false); | ||
const type = isRecordingSession ? 'stop' : 'start'; | ||
trackAnalytics?.({ | ||
eventKey: eventKey + `.${type}-button-click`, | ||
eventName: eventName + `${type} button clicked`, | ||
}); | ||
}} | ||
> | ||
{isDisabled | ||
? disabledReason | ||
: isRecordingSession | ||
? 'Recording in progress, click to stop' | ||
: isRecording | ||
? 'Replay buffering, click to flush and record' | ||
: 'Start recording the current session'} | ||
</Button> | ||
<div css={[smallCss, panelSectionCss, panelInsetContentCss]}> | ||
{lastReplayId ? ( | ||
<span css={[resetFlexRowCss, {gap: 'var(--space50)'}]}> | ||
{isRecording ? 'Current replay: ' : 'Last recorded replay: '} | ||
<AnalyticsProvider keyVal="replay-details-link" nameVal="replay details link"> | ||
<ReplayLink lastReplayId={lastReplayId} /> | ||
</AnalyticsProvider> | ||
</span> | ||
) : ( | ||
'No replay is recording this session.' | ||
)} | ||
</div> | ||
</PanelLayout> | ||
); | ||
} | ||
|
||
function ReplayLink({lastReplayId}: {lastReplayId: string}) { | ||
const {projectSlug, projectId, projectPlatform} = useConfiguration(); | ||
return ( | ||
<SentryAppLink | ||
to={{ | ||
url: `/replays/${lastReplayId}/`, | ||
query: {project: projectId}, | ||
}} | ||
> | ||
<div | ||
css={[ | ||
resetFlexRowCss, | ||
{ | ||
display: 'inline-flex', | ||
gap: 'var(--space50)', | ||
alignItems: 'center', | ||
}, | ||
]} | ||
> | ||
<ProjectBadge | ||
css={css({'&& img': {boxShadow: 'none'}})} | ||
project={{ | ||
slug: projectSlug, | ||
id: projectId, | ||
platform: projectPlatform as PlatformKey, | ||
}} | ||
avatarSize={16} | ||
hideName | ||
avatarProps={{hasTooltip: true}} | ||
/> | ||
{lastReplayId.slice(0, TRUNC_ID_LENGTH)} | ||
</div> | ||
</SentryAppLink> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
114 changes: 114 additions & 0 deletions
114
static/app/components/devtoolbar/hooks/useReplayRecorder.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,114 @@ | ||
import {useCallback, useEffect, useState} from 'react'; | ||
import type {replayIntegration} from '@sentry/react'; | ||
import type {ReplayRecordingMode} from '@sentry/types'; | ||
|
||
import useConfiguration from 'sentry/components/devtoolbar/hooks/useConfiguration'; | ||
import {useSessionStorage} from 'sentry/utils/useSessionStorage'; | ||
|
||
type ReplayRecorderState = { | ||
disabledReason: string | undefined; | ||
isDisabled: boolean; | ||
isRecording: boolean; | ||
lastReplayId: string | undefined; | ||
recordingMode: ReplayRecordingMode | undefined; | ||
startRecordingSession(): Promise<boolean>; // returns false if called in the wrong state | ||
stopRecording(): Promise<boolean>; // returns false if called in the wrong state | ||
}; | ||
|
||
interface ReplayInternalAPI { | ||
[other: string]: any; | ||
getSessionId(): string | undefined; | ||
isEnabled(): boolean; | ||
recordingMode: ReplayRecordingMode; | ||
} | ||
|
||
function getReplayInternal( | ||
replay: ReturnType<typeof replayIntegration> | ||
): ReplayInternalAPI { | ||
// While the toolbar is internal, we can use the private API for added functionality and reduced dependence on SDK release versions | ||
// @ts-ignore:next-line | ||
return replay._replay; | ||
} | ||
|
||
const LAST_REPLAY_STORAGE_KEY = 'devtoolbar.last_replay_id'; | ||
|
||
export default function useReplayRecorder(): ReplayRecorderState { | ||
const {SentrySDK} = useConfiguration(); | ||
const replay = | ||
SentrySDK && 'getReplay' in SentrySDK ? SentrySDK.getReplay() : undefined; | ||
const replayInternal = replay ? getReplayInternal(replay) : undefined; | ||
|
||
// sessionId is defined if we are recording in session OR buffer mode. | ||
const [sessionId, setSessionId] = useState<string | undefined>(() => | ||
replayInternal?.getSessionId() | ||
); | ||
const [recordingMode, setRecordingMode] = useState<ReplayRecordingMode | undefined>( | ||
() => replayInternal?.recordingMode | ||
); | ||
|
||
const isDisabled = replay === undefined; | ||
const disabledReason = !SentrySDK | ||
? 'Failed to load the Sentry SDK.' | ||
: !('getReplay' in SentrySDK) | ||
? 'Your SDK version is too old to support Replays.' | ||
: !replay | ||
? 'You need to install the SDK Replay integration.' | ||
: undefined; | ||
|
||
const [isRecording, setIsRecording] = useState<boolean>( | ||
() => replayInternal?.isEnabled() ?? false | ||
); | ||
const [lastReplayId, setLastReplayId] = useSessionStorage<string | undefined>( | ||
LAST_REPLAY_STORAGE_KEY, | ||
undefined | ||
); | ||
useEffect(() => { | ||
if (isRecording && recordingMode === 'session' && sessionId) { | ||
setLastReplayId(sessionId); | ||
} | ||
}, [isRecording, recordingMode, sessionId, setLastReplayId]); | ||
|
||
const refreshState = useCallback(() => { | ||
setIsRecording(replayInternal?.isEnabled() ?? false); | ||
setSessionId(replayInternal?.getSessionId()); | ||
setRecordingMode(replayInternal?.recordingMode); | ||
}, [replayInternal]); | ||
|
||
const startRecordingSession = useCallback(async () => { | ||
let success = false; | ||
if (replay) { | ||
// Note SDK v8.19 and older will throw if a replay is already started. | ||
// Details at https://github.com/getsentry/sentry-javascript/pull/13000 | ||
if (!isRecording) { | ||
replay.start(); | ||
success = true; | ||
} else if (recordingMode === 'buffer') { | ||
// For SDK v8.20+, flush() would work for both cases, but we're staying version-agnostic. | ||
await replay.flush(); | ||
success = true; | ||
} | ||
refreshState(); | ||
} | ||
return success; | ||
}, [replay, isRecording, recordingMode, refreshState]); | ||
|
||
const stopRecording = useCallback(async () => { | ||
let success = false; | ||
if (replay && isRecording) { | ||
await replay.stop(); | ||
success = true; | ||
refreshState(); | ||
} | ||
return success; | ||
}, [isRecording, replay, refreshState]); | ||
|
||
return { | ||
disabledReason, | ||
isDisabled, | ||
isRecording, | ||
lastReplayId, | ||
recordingMode, | ||
startRecordingSession, | ||
stopRecording, | ||
}; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters