-
-
Notifications
You must be signed in to change notification settings - Fork 4.5k
feat(toolbar): add a replay panel for start/stop current replay #75403
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
6c83718
7c9db6c
72868f6
73d5217
a324791
c1e1ba7
1991afe
9246bac
3ba529b
a7ee558
0219e21
e0a5a52
0359480
c67d984
eeb7485
d9eed6e
1fc533d
286b844
7f9903e
fc8d85a
608e113
1276d6b
7f99b48
fb7bede
f30b5a9
44ed001
bd0d0ef
bcd8f25
697a09a
b9fc69c
87ab1de
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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> | ||
); | ||
} |
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; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. you can use There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hm my VSCode can't find this method in browser or react packages.. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. hmm it might be on the SDK client instead of the Sentry namespace... e.g. SentrySDK.getClient().getIntegrationByName There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Got it, does this have any improvements over the current approach? Right now I'm using these conditions to get the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah just as a fallback if the SDK is too old to support |
||
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) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Don't want to try-catch here because we can already condition to make sure we're in a safe state. |
||
// 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, | ||
}; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not allowing users to stop buffering.