Browse Source

feat(replay): Support absolute time (#77251)

Adds support for absolute time.

All timestamps on the replay details page and breadcrumbs list support
absolute time
<img width="1506" alt="image"
src="https://github.com/user-attachments/assets/d8e39002-8b61-460d-88ea-efc22edbd5f0">

The timestamp tooltip has changes to show the absolute time and the time
within replay in milliseconds
<img width="784" alt="image"
src="https://github.com/user-attachments/assets/10bd42e8-605f-480b-8e2a-d976b890da98">



Closes: https://github.com/getsentry/sentry/issues/52682
Catherine Lee 6 months ago
parent
commit
d3fa88d571

+ 20 - 5
static/app/components/replays/player/scrubber.tsx

@@ -8,10 +8,12 @@ import * as Progress from 'sentry/components/replays/progress';
 import {useReplayContext} from 'sentry/components/replays/replayContext';
 import {t} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
+import {getFormattedDate, shouldUse24Hours} from 'sentry/utils/dates';
 import formatDuration from 'sentry/utils/duration/formatDuration';
 import divide from 'sentry/utils/number/divide';
 import toPercent from 'sentry/utils/number/toPercent';
 import useTimelineScale from 'sentry/utils/replays/hooks/useTimelineScale';
+import {useReplayPrefs} from 'sentry/utils/replays/playback/providers/replayPreferencesContext';
 import useCurrentHoverTime from 'sentry/utils/replays/playback/providers/useCurrentHoverTime';
 
 type Props = {
@@ -21,9 +23,12 @@ type Props = {
 
 function Scrubber({className, showZoomIndicators = false}: Props) {
   const {replay, currentTime, setCurrentTime} = useReplayContext();
+  const [prefs] = useReplayPrefs();
+  const timestampType = prefs.timestampType;
   const [currentHoverTime] = useCurrentHoverTime();
   const [timelineScale] = useTimelineScale();
 
+  const startTimestamp = replay?.getStartTimestampMs() ?? 0;
   const durationMs = replay?.getDurationMs() ?? 0;
   const percentComplete = divide(currentTime, durationMs);
   const hoverPlace = divide(currentHoverTime || 0, durationMs);
@@ -63,11 +68,21 @@ function Scrubber({className, showZoomIndicators = false}: Props) {
         {currentHoverTime ? (
           <div>
             <TimelineTooltip
-              labelText={formatDuration({
-                duration: [currentHoverTime, 'ms'],
-                precision: 'ms',
-                style: 'hh:mm:ss.sss',
-              })}
+              labelText={
+                timestampType === 'absolute'
+                  ? getFormattedDate(
+                      startTimestamp + currentHoverTime,
+                      shouldUse24Hours() ? 'HH:mm:ss.SSS' : 'hh:mm:ss.SSS',
+                      {
+                        local: true,
+                      }
+                    )
+                  : formatDuration({
+                      duration: [currentHoverTime, 'ms'],
+                      precision: 'ms',
+                      style: 'hh:mm:ss.sss',
+                    })
+              }
             />
             <MouseTrackingValue
               style={{

+ 12 - 0
static/app/components/replays/preferences/replayPreferenceDropdown.tsx

@@ -3,6 +3,9 @@ import {CompositeSelect} from 'sentry/components/compactSelect/composite';
 import {IconSettings} from 'sentry/icons';
 import {t} from 'sentry/locale';
 import {useReplayPrefs} from 'sentry/utils/replays/playback/providers/replayPreferencesContext';
+import {toTitleCase} from 'sentry/utils/string/toTitleCase';
+
+const timestampOptions: ('relative' | 'absolute')[] = ['relative', 'absolute'];
 
 export default function ReplayPreferenceDropdown({
   speedOptions,
@@ -36,6 +39,15 @@ export default function ReplayPreferenceDropdown({
           value: option,
         }))}
       />
+      <CompositeSelect.Region
+        label={t('Timestamps')}
+        value={prefs.timestampType}
+        onChange={opt => setPrefs({timestampType: opt.value})}
+        options={timestampOptions.map(option => ({
+          label: `${toTitleCase(option)}`,
+          value: option,
+        }))}
+      />
       {hideFastForward ? null : (
         <CompositeSelect.Region
           aria-label={t('Fast-Forward Inactivity')}

+ 3 - 0
static/app/components/replays/preferences/replayPreferences.tsx

@@ -5,16 +5,19 @@ const LOCAL_STORAGE_KEY = 'replay-config';
 export type ReplayPrefs = {
   isSkippingInactive: boolean;
   playbackSpeed: number;
+  timestampType: 'relative' | 'absolute';
 };
 
 const CAN_SKIP_PREFS: ReplayPrefs = {
   isSkippingInactive: true,
   playbackSpeed: 1,
+  timestampType: 'relative',
 };
 
 const NO_SKIP_PREFS: ReplayPrefs = {
   isSkippingInactive: false,
   playbackSpeed: 1,
+  timestampType: 'relative',
 };
 
 export interface PrefsStrategy {

+ 12 - 1
static/app/components/replays/timeAndScrubberGrid.tsx

@@ -3,6 +3,7 @@ import styled from '@emotion/styled';
 
 import {Button} from 'sentry/components/button';
 import ButtonBar from 'sentry/components/buttonBar';
+import DateTime from 'sentry/components/dateTime';
 import Duration from 'sentry/components/duration/duration';
 import ReplayTimeline from 'sentry/components/replays/breadcrumbs/replayTimeline';
 import {PlayerScrubber} from 'sentry/components/replays/player/scrubber';
@@ -14,6 +15,7 @@ import {space} from 'sentry/styles/space';
 import useTimelineScale, {
   TimelineScaleContextProvider,
 } from 'sentry/utils/replays/hooks/useTimelineScale';
+import {useReplayPrefs} from 'sentry/utils/replays/playback/providers/replayPreferencesContext';
 
 type TimeAndScrubberGridProps = {
   isCompact?: boolean;
@@ -59,6 +61,9 @@ export default function TimeAndScrubberGrid({
   showZoom = false,
 }: TimeAndScrubberGridProps) {
   const {currentTime, replay} = useReplayContext();
+  const [prefs] = useReplayPrefs();
+  const timestampType = prefs.timestampType;
+  const startTimestamp = replay?.getStartTimestampMs() ?? 0;
   const durationMs = replay?.getDurationMs();
   const elem = useRef<HTMLDivElement>(null);
   const mouseTrackingProps = useScrubberMouseTracking({elem});
@@ -67,7 +72,11 @@ export default function TimeAndScrubberGrid({
     <TimelineScaleContextProvider>
       <Grid id="replay-timeline-player" isCompact={isCompact}>
         <Numeric style={{gridArea: 'currentTime'}}>
-          <Duration duration={[currentTime, 'ms']} precision="sec" />
+          {timestampType === 'absolute' ? (
+            <DateTime timeOnly seconds date={startTimestamp + currentTime} />
+          ) : (
+            <Duration duration={[currentTime, 'ms']} precision="sec" />
+          )}
         </Numeric>
 
         <div style={{gridArea: 'timeline'}}>
@@ -82,6 +91,8 @@ export default function TimeAndScrubberGrid({
         <Numeric style={{gridArea: 'duration'}}>
           {durationMs === undefined ? (
             '--:--'
+          ) : timestampType === 'absolute' ? (
+            <DateTime timeOnly seconds date={startTimestamp + durationMs} />
           ) : (
             <Duration duration={[durationMs, 'ms']} precision="sec" />
           )}

+ 1 - 1
static/app/utils/replays/playback/providers/replayPreferencesContext.spec.tsx

@@ -49,7 +49,7 @@ describe('replayPlayerPluginsContext', () => {
     });
 
     expect(result.current).toEqual([
-      {isSkippingInactive: true, playbackSpeed: 1},
+      {isSkippingInactive: true, playbackSpeed: 1, timestampType: 'relative'},
       expect.any(Function),
     ]);
 

+ 48 - 6
static/app/views/replays/detail/timestampButton.tsx

@@ -5,11 +5,15 @@ import {DateTime} from 'sentry/components/dateTime';
 import Duration from 'sentry/components/duration/duration';
 import {Tooltip} from 'sentry/components/tooltip';
 import {IconPlay} from 'sentry/icons';
+import {t} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
+import {getFormat, getFormattedDate} from 'sentry/utils/dates';
+import formatDuration from 'sentry/utils/duration/formatDuration';
+import {useReplayPrefs} from 'sentry/utils/replays/playback/providers/replayPreferencesContext';
 
 type Props = {
   startTimestampMs: number;
-  timestampMs: string | number | Date;
+  timestampMs: number;
   className?: string;
   onClick?: (event: MouseEvent) => void;
   precision?: 'sec' | 'ms';
@@ -22,23 +26,61 @@ export default function TimestampButton({
   startTimestampMs,
   timestampMs,
 }: Props) {
+  const [prefs] = useReplayPrefs();
+  const timestampType = prefs.timestampType;
   return (
-    <Tooltip title={<DateTime seconds date={timestampMs} />} skipWrapper>
+    <Tooltip
+      title={
+        <div>
+          <TooltipTime>
+            {t(
+              'Date: %s',
+              getFormattedDate(
+                timestampMs,
+                `${getFormat({year: true, seconds: true, timeZone: true})}`,
+                {
+                  local: true,
+                }
+              )
+            )}
+          </TooltipTime>
+          <TooltipTime>
+            {t(
+              'Time within replay: %s',
+              formatDuration({
+                duration: [Math.abs(timestampMs - startTimestampMs), 'ms'],
+                precision: 'ms',
+                style: 'hh:mm:ss.sss',
+              })
+            )}
+          </TooltipTime>
+        </div>
+      }
+      skipWrapper
+    >
       <StyledButton
         as={onClick ? 'button' : 'span'}
         onClick={onClick}
         className={className}
       >
         <IconPlay size="xs" />
-        <Duration
-          duration={[Math.abs(new Date(timestampMs).getTime() - startTimestampMs), 'ms']}
-          precision={precision}
-        />
+        {timestampType === 'absolute' ? (
+          <DateTime timeOnly seconds date={timestampMs} />
+        ) : (
+          <Duration
+            duration={[Math.abs(timestampMs - startTimestampMs), 'ms']}
+            precision={precision}
+          />
+        )}
       </StyledButton>
     </Tooltip>
   );
 }
 
+const TooltipTime = styled('div')`
+  text-align: left;
+`;
+
 const StyledButton = styled('button')`
   background: transparent;
   border: none;