Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
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();
Copy link
Member Author

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.

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

Choose a reason for hiding this comment

The 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,
};
}
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