Browse Source

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

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
Andrew Liu 7 months ago
parent
commit
a055c3b068

+ 2 - 0
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({
       <NavButton panelName="releases" label="Releases" icon={<IconReleases />}>
         <SessionStatusBadge />
       </NavButton>
+      <NavButton panelName="replay" label="Session Replay" icon={<IconPlay />} />
     </dialog>
   );
 }

+ 7 - 0
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() {
           <PanelReleases />
         </AnalyticsProvider>
       );
+    case 'replay':
+      return (
+        <AnalyticsProvider keyVal="replay-panel" nameVal="Replay panel">
+          <PanelReplay />
+        </AnalyticsProvider>
+      );
     default:
       return null;
   }

+ 113 - 0
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 (
+    <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 - 0
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);

+ 114 - 0
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<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,
+  };
+}

+ 8 - 1
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<{