Skip to content
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

feat(toolbar): add a replay panel for start/stop current replay #75403

Merged
merged 31 commits into from
Aug 8, 2024
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
6c83718
Initial start, stop, and link w/project platform
aliu39 Jul 30, 2024
7c9db6c
Merge branch 'master' of github.com:getsentry/sentry into aliu/start-…
aliu39 Jul 31, 2024
72868f6
more progress to MVP + improve comments
aliu39 Aug 1, 2024
73d5217
Merge branch 'master' of github.com:getsentry/sentry into aliu/start-…
aliu39 Aug 1, 2024
a324791
Working start/stop links
aliu39 Aug 1, 2024
c1e1ba7
Cleanup todos
aliu39 Aug 1, 2024
1991afe
Handle buffer mode
aliu39 Aug 2, 2024
9246bac
Add tooltip + some comments and cleanup
aliu39 Aug 2, 2024
3ba529b
Refactor all state and sdk calls to a context provider, and handle ol…
aliu39 Aug 2, 2024
a7ee558
Merge branch 'master' into aliu/start-replay
aliu39 Aug 2, 2024
0219e21
npx yarn-deduplicate
aliu39 Aug 3, 2024
e0a5a52
Switch to a hook + sessionStorage
aliu39 Aug 3, 2024
0359480
Rm initial states (will be set by useEffect
aliu39 Aug 3, 2024
c67d984
Refresh state on successful start/stop instead of polling
aliu39 Aug 3, 2024
eeb7485
Hardcode sentry-test url for non-prod environments
aliu39 Aug 5, 2024
d9eed6e
Use isEnabled() for isRecording and always refresh state in start/stop
aliu39 Aug 5, 2024
1fc533d
Comment on sdk versioning
aliu39 Aug 5, 2024
286b844
Use AnalyticsProvider
aliu39 Aug 5, 2024
7f9903e
Rm sdk debug flag
aliu39 Aug 5, 2024
fc8d85a
Merge branch 'master' into aliu/start-replay
aliu39 Aug 5, 2024
608e113
Refactor private API function calls
aliu39 Aug 5, 2024
1276d6b
Move analytics provider
aliu39 Aug 5, 2024
7f99b48
Address review comments and track button failures
aliu39 Aug 6, 2024
fb7bede
Merge branch 'master' into aliu/start-replay
aliu39 Aug 8, 2024
f30b5a9
Undo package.json formatting
aliu39 Aug 8, 2024
44ed001
Update button label + url
aliu39 Aug 8, 2024
bd0d0ef
Ref start/stop to use finally
aliu39 Aug 8, 2024
bcd8f25
Update disabled msgs to show in button, instead of tooltip
aliu39 Aug 8, 2024
697a09a
Update missing integration msg
aliu39 Aug 8, 2024
b9fc69c
Fix broken buffer case, update buffer label and rm try-catch
aliu39 Aug 8, 2024
87ab1de
Revert useDevToolbar
aliu39 Aug 8, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 7 additions & 9 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,13 @@
"@sentry-internal/rrweb-player": "2.25.0",
"@sentry-internal/rrweb-snapshot": "2.25.0",
"@sentry/babel-plugin-component-annotate": "^2.16.1",
"@sentry/core": "^8.18.0",
"@sentry/node": "^8.18.0",
"@sentry/react": "^8.18.0",
"@sentry/core": "^8.20.0",
aliu39 marked this conversation as resolved.
Show resolved Hide resolved
"@sentry/node": "^8.20.0",
"@sentry/react": "^8.20.0",
"@sentry/release-parser": "^1.3.1",
"@sentry/status-page-list": "^0.3.0",
"@sentry/types": "^8.18.0",
"@sentry/utils": "^8.18.0",
"@sentry/types": "^8.20.0",
"@sentry/utils": "^8.20.0",
"@spotlightjs/spotlight": "^2.0.0-alpha.1",
"@tanstack/react-query": "^4.29.7",
"@tanstack/react-query-devtools": "^4.36.1",
Expand Down Expand Up @@ -180,7 +180,7 @@
"@codecov/webpack-plugin": "^0.0.1-beta.8",
"@pmmmwh/react-refresh-webpack-plugin": "0.5.11",
"@sentry/jest-environment": "6.0.0",
"@sentry/profiling-node": "^8.18.0",
"@sentry/profiling-node": "^8.20.0",
"@styled/typescript-styled-plugin": "^1.0.1",
"@testing-library/dom": "10.1.0",
"@testing-library/jest-dom": "6.4.5",
Expand Down Expand Up @@ -263,9 +263,7 @@
"last 3 iOS major versions",
"Firefox ESR"
],
"test": [
"current node"
]
"test": ["current node"]
},
"volta": {
"extends": ".volta.json"
Expand Down
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
111 changes: 111 additions & 0 deletions static/app/components/devtoolbar/components/replay/replayPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import {useContext} 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 ExternalLink from 'sentry/components/links/externalLink';
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 {projectSlug, projectId, projectPlatform, trackAnalytics} = useConfiguration();

const {disabledReason, isDisabled, isRecording, lastReplayId, start, stop} =
useReplayRecorder();

function ReplayLink({children}: {children: React.ReactNode}) {
aliu39 marked this conversation as resolved.
Show resolved Hide resolved
const {eventName, eventKey} = useContext(AnalyticsContext);
return process.env.NODE_ENV === 'production' ? (
<SentryAppLink
to={{
url: `/replays/${lastReplayId}`,
query: {project: projectId},
}}
>
{children}
</SentryAppLink>
) : (
<ExternalLink
href={`https://sentry-test.sentry.io/replays/${lastReplayId}/?project=5270453`}
aliu39 marked this conversation as resolved.
Show resolved Hide resolved
onClick={() => {
trackAnalytics?.({
eventKey: eventKey + '.click',
eventName: eventName + ' clicked',
});
}}
>
{children}
</ExternalLink>
);
}

return (
<PanelLayout title="Session Replay">
<AnalyticsProvider
keyVal={`replay-button-${isRecording ? 'stop' : 'start'}`}
nameVal={`replay button ${isRecording ? 'stop' : 'start'}`}
>
aliu39 marked this conversation as resolved.
Show resolved Hide resolved
<Button
size="sm"
icon={isRecording ? <IconPause /> : <IconPlay />}
disabled={isDisabled}
title={disabledReason}
onClick={() => (isRecording ? stop() : start())}
>
{isRecording
? 'In progress. Click to stop recording'
: 'Start recording the current session'}
</Button>
</AnalyticsProvider>
<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>
<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: false}}
/>
{lastReplayId.slice(0, TRUNC_ID_LENGTH)}
</div>
</ReplayLink>
</AnalyticsProvider>
</span>
) : (
'No replay is recording this session.'
)}
</div>
</PanelLayout>
);
}
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
116 changes: 116 additions & 0 deletions static/app/components/devtoolbar/hooks/useReplayRecorder.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
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';

type ReplayRecorderState = {
disabledReason: string | undefined;
isDisabled: boolean;
isRecording: boolean;
lastReplayId: string | undefined;
start(): Promise<boolean>;
stop(): Promise<boolean>;
};

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;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you can use SentrySDK.getIntegrationByName(), it's just a bit more annoying dealing with types

Copy link
Member Author

Choose a reason for hiding this comment

The 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..

Copy link
Member

Choose a reason for hiding this comment

The 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

Copy link
Member Author

Choose a reason for hiding this comment

The 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 disabledReason (see screenshots I just added in the description)

Copy link
Member

Choose a reason for hiding this comment

The 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 getReplay as it was a relatively recent addition, but it's fine not to support older

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 outdated to use the Replay integration.'
: !replay
? "Failed to load your SDK's Replay integration."
: undefined;

const [isRecording, setIsRecording] = useState<boolean>(
() => replayInternal?.isEnabled() ?? false
);
const [lastReplayId, setLastReplayId] = useState<string | undefined>(
() => sessionStorage.getItem(LAST_REPLAY_STORAGE_KEY) || undefined
aliu39 marked this conversation as resolved.
Show resolved Hide resolved
);
aliu39 marked this conversation as resolved.
Show resolved Hide resolved
useEffect(() => {
if (isRecording && sessionId) {
setLastReplayId(sessionId);
sessionStorage.setItem(LAST_REPLAY_STORAGE_KEY, sessionId);
}
}, [isRecording, sessionId]);

const refreshState = useCallback(() => {
setIsRecording(replayInternal?.isEnabled() ?? false);
setSessionId(replayInternal?.getSessionId());
setRecordingMode(replayInternal?.recordingMode);
}, [replayInternal]);

const start = useCallback(async () => {
let success = false;
if (replay && !isRecording) {
try {
// SDK v8.19.0 and older will throw if a replay is already started.
// Details at https://github.com/getsentry/sentry-javascript/pull/13000
if (recordingMode === 'session') {
replay.start();
} else {
// For SDK v8.20.0 and up, flush() works for both cases.
await replay.flush();
}
success = true;
// eslint-disable-next-line no-empty
} catch {}
}
refreshState();
return success;
}, [isRecording, recordingMode, replay, refreshState]);

const stop = useCallback(async () => {
let success = false;
if (replay && isRecording) {
try {
await replay.stop();
success = true;
// eslint-disable-next-line no-empty
} catch {}
}
refreshState();
return success;
aliu39 marked this conversation as resolved.
Show resolved Hide resolved
}, [isRecording, replay, refreshState]);

return {
disabledReason,
isDisabled,
isRecording,
lastReplayId,
start,
stop,
};
}
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
Loading
Loading