Skip to content

Commit

Permalink
feat(toolbar): add a replay panel for start/stop current replay (#75403)
Browse files Browse the repository at this point in the history
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
aliu39 authored Aug 8, 2024
1 parent 70aca29 commit a055c3b
Show file tree
Hide file tree
Showing 6 changed files with 247 additions and 1 deletion.
2 changes: 2 additions & 0 deletions static/app/components/devtoolbar/components/navigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
IconFlag,
IconIssues,
IconMegaphone,
IconPlay,
IconReleases,
IconSiren,
} from 'sentry/icons';
Expand Down Expand Up @@ -60,6 +61,7 @@ export default function Navigation({
<NavButton panelName="releases" label="Releases" icon={<IconReleases />}>
<SessionStatusBadge />
</NavButton>
<NavButton panelName="replay" label="Session Replay" icon={<IconPlay />} />
</dialog>
);
}
Expand Down
7 changes: 7 additions & 0 deletions static/app/components/devtoolbar/components/panelRouter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const PanelFeedback = lazy(() => import('./feedback/feedbackPanel'));
const PanelIssues = lazy(() => import('./issues/issuesPanel'));
const PanelFeatureFlags = lazy(() => import('./featureFlags/featureFlagsPanel'));
const PanelReleases = lazy(() => import('./releases/releasesPanel'));
const PanelReplay = lazy(() => import('./replay/replayPanel'));

export default function PanelRouter() {
const {state} = useToolbarRoute();
Expand Down Expand Up @@ -44,6 +45,12 @@ export default function PanelRouter() {
<PanelReleases />
</AnalyticsProvider>
);
case 'replay':
return (
<AnalyticsProvider keyVal="replay-panel" nameVal="Replay panel">
<PanelReplay />
</AnalyticsProvider>
);
default:
return null;
}
Expand Down
113 changes: 113 additions & 0 deletions static/app/components/devtoolbar/components/replay/replayPanel.tsx
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>
);
}
3 changes: 3 additions & 0 deletions static/app/components/devtoolbar/components/sentryAppLink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ interface Props {
onClick?: (event: MouseEvent) => void;
}

/**
* Inline link to orgSlug.sentry.io/{to} with built-in click analytic.
*/
export default function SentryAppLink({children, to}: Props) {
const {organizationSlug, trackAnalytics} = useConfiguration();
const {eventName, eventKey} = useContext(AnalyticsContext);
Expand Down
114 changes: 114 additions & 0 deletions static/app/components/devtoolbar/hooks/useReplayRecorder.tsx
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,
};
}
9 changes: 8 additions & 1 deletion static/app/components/devtoolbar/hooks/useToolbarRoute.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import {createContext, useCallback, useContext, useState} from 'react';

type State = {
activePanel: null | 'alerts' | 'feedback' | 'issues' | 'featureFlags' | 'releases';
activePanel:
| null
| 'alerts'
| 'feedback'
| 'issues'
| 'featureFlags'
| 'releases'
| 'replay';
};

const context = createContext<{
Expand Down

0 comments on commit a055c3b

Please sign in to comment.