diff --git a/static/app/components/devtoolbar/components/navigation.tsx b/static/app/components/devtoolbar/components/navigation.tsx index 5d87f7872417c4..9ccc55b080acc2 100644 --- a/static/app/components/devtoolbar/components/navigation.tsx +++ b/static/app/components/devtoolbar/components/navigation.tsx @@ -8,6 +8,7 @@ import { IconFlag, IconIssues, IconMegaphone, + IconPlay, IconReleases, IconSiren, } from 'sentry/icons'; @@ -60,6 +61,7 @@ export default function Navigation({ }> + } /> ); } diff --git a/static/app/components/devtoolbar/components/panelRouter.tsx b/static/app/components/devtoolbar/components/panelRouter.tsx index bfe56b324298a5..5e96e00a973908 100644 --- a/static/app/components/devtoolbar/components/panelRouter.tsx +++ b/static/app/components/devtoolbar/components/panelRouter.tsx @@ -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(); @@ -44,6 +45,12 @@ export default function PanelRouter() { ); + case 'replay': + return ( + + + + ); default: return null; } diff --git a/static/app/components/devtoolbar/components/replay/replayPanel.tsx b/static/app/components/devtoolbar/components/replay/replayPanel.tsx new file mode 100644 index 00000000000000..2c0e3486d2ce4d --- /dev/null +++ b/static/app/components/devtoolbar/components/replay/replayPanel.tsx @@ -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 ( + + +
+ {lastReplayId ? ( + + {isRecording ? 'Current replay: ' : 'Last recorded replay: '} + + + + + ) : ( + 'No replay is recording this session.' + )} +
+
+ ); +} + +function ReplayLink({lastReplayId}: {lastReplayId: string}) { + const {projectSlug, projectId, projectPlatform} = useConfiguration(); + return ( + +
+ + {lastReplayId.slice(0, TRUNC_ID_LENGTH)} +
+
+ ); +} diff --git a/static/app/components/devtoolbar/components/sentryAppLink.tsx b/static/app/components/devtoolbar/components/sentryAppLink.tsx index 1d86f873dae5ff..7a5fe65048a5c8 100644 --- a/static/app/components/devtoolbar/components/sentryAppLink.tsx +++ b/static/app/components/devtoolbar/components/sentryAppLink.tsx @@ -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); diff --git a/static/app/components/devtoolbar/hooks/useReplayRecorder.tsx b/static/app/components/devtoolbar/hooks/useReplayRecorder.tsx new file mode 100644 index 00000000000000..70d1be99034da1 --- /dev/null +++ b/static/app/components/devtoolbar/hooks/useReplayRecorder.tsx @@ -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; // returns false if called in the wrong state + stopRecording(): Promise; // returns false if called in the wrong state +}; + +interface ReplayInternalAPI { + [other: string]: any; + getSessionId(): string | undefined; + isEnabled(): boolean; + recordingMode: ReplayRecordingMode; +} + +function getReplayInternal( + replay: ReturnType +): 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(() => + replayInternal?.getSessionId() + ); + const [recordingMode, setRecordingMode] = useState( + () => 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( + () => replayInternal?.isEnabled() ?? false + ); + const [lastReplayId, setLastReplayId] = useSessionStorage( + 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, + }; +} diff --git a/static/app/components/devtoolbar/hooks/useToolbarRoute.tsx b/static/app/components/devtoolbar/hooks/useToolbarRoute.tsx index 6ff627611fb668..5e0a65f8bf8550 100644 --- a/static/app/components/devtoolbar/hooks/useToolbarRoute.tsx +++ b/static/app/components/devtoolbar/hooks/useToolbarRoute.tsx @@ -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<{