Skip to content

Commit

Permalink
ref(replay): Replay timeline gaps using timestamp (#74406)
Browse files Browse the repository at this point in the history
Refactors replay timeline gaps to use frame timestamps and styles `left`
and `width` instead of frame columns. Using timestamps instead of the
frame column makes it line up with breadcrumbs better. Switching to
styling `left` and `width` instead of the range of frames also fixed the
gap bug when zooming in on the timeline.

Also modified the gap logic:
When there's a background frame, a gap is created until the next frame
that's not a background frame or error frame. In most cases it will be
the foreground frame, but in cases of bad data it could be any other
replay frame.

Before:

![image](https://github.com/user-attachments/assets/51ad1b87-74f8-4c03-8274-b5409e5bac75)

After:

![image](https://github.com/user-attachments/assets/bbf19677-99e9-46d3-96aa-036f086542dc)
The gap is now a consistent `div` so there's no weird spots that the gap
doesn't cover. Additionally, the gap ends as soon as there's a frame
even if it isn't a foreground frame, which is more accurate.

Relates to #71665
  • Loading branch information
c298lee authored Jul 19, 2024
1 parent 8c04ccf commit d10148c
Show file tree
Hide file tree
Showing 3 changed files with 54 additions and 66 deletions.
6 changes: 2 additions & 4 deletions static/app/components/replays/breadcrumbs/replayTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ export default function ReplayTimeline() {
const durationMs = replay.getDurationMs();
const startTimestampMs = replay.getStartTimestampMs();
const chapterFrames = replay.getChapterFrames();
const appFrames = replay.getAppFrames();

// timeline is in the middle
const initialTranslate = 0.5 / timelineScale;
Expand Down Expand Up @@ -74,9 +73,8 @@ export default function ReplayTimeline() {
{organization.features.includes('session-replay-timeline-gap') ? (
<TimelineGaps
durationMs={durationMs}
frames={appFrames}
totalFrames={chapterFrames.length}
width={width}
startTimestampMs={startTimestampMs}
frames={chapterFrames}
/>
) : null}
<TimelineEventsContainer>
Expand Down
106 changes: 52 additions & 54 deletions static/app/components/replays/breadcrumbs/timelineGaps.tsx
Original file line number Diff line number Diff line change
@@ -1,88 +1,86 @@
import {Fragment} from 'react';
import styled from '@emotion/styled';

import * as Timeline from 'sentry/components/replays/breadcrumbs/timeline';
import {getFramesByColumn} from 'sentry/components/replays/utils';
import {Tooltip} from 'sentry/components/tooltip';
import {t} from 'sentry/locale';
import toPercent from 'sentry/utils/number/toPercent';
import {
isBackgroundFrame,
isForegroundFrame,
isErrorFrame,
type ReplayFrame,
} from 'sentry/utils/replays/types';

interface Props {
durationMs: number;
frames: ReplayFrame[];
totalFrames: number;
width: number;
startTimestampMs: number;
}

// create gaps in the timeline by finding all columns between a background frame and foreground frame
// or background frame to end of replay
export default function TimelineGaps({durationMs, frames, totalFrames, width}: Props) {
const markerWidth = totalFrames < 200 ? 4 : totalFrames < 500 ? 6 : 10;
const totalColumns = Math.floor(width / markerWidth);
const framesByCol = getFramesByColumn(durationMs, frames, totalColumns);
export default function TimelineGaps({durationMs, startTimestampMs, frames}: Props) {
const ranges: Array<{left: string; width: string}> = [];

// returns all numbers in the range, exclusive of start and inclusive of stop
const range = (start, stop) =>
Array.from({length: stop - start}, (_, i) => start + i + 1);
let start = -1;
let end = -1;

const gapCol: number[] = [];

const gapFrames = framesByCol.entries();
let currFrame = gapFrames.next();

while (!currFrame.done) {
let start = -1;
let end = -1;

// iterate through all frames until we have a start (background frame) and end (foreground frame) of gap or no more frames
while ((start === -1 || end === -1) && !currFrame.done) {
const [column, colFrame] = currFrame.value;
for (const frame of colFrame) {
// only considered start of gap if background frame hasn't been found yet
if (start === -1 && isBackgroundFrame(frame)) {
start = column;
}
// gap only ends if background frame has been found
if (start !== -1 && isForegroundFrame(frame)) {
end = column;
}
}
currFrame = gapFrames.next();
for (const currFrame of frames) {
// only considered start of gap if background frame hasn't been found yet
if (start === -1 && isBackgroundFrame(currFrame)) {
start = currFrame.timestampMs - startTimestampMs;
}
// gap only ends if a frame that's not a background frame or error frame has been found
if (start !== -1 && !isBackgroundFrame(currFrame) && !isErrorFrame(currFrame)) {
end = currFrame.timestampMs - startTimestampMs;
}

// create gap if we found have start (background frame) and end (foreground frame)
// create gap if we found have start (background frame) and end (another frame)
if (start !== -1 && end !== -1) {
gapCol.push(...range(start, end));
}
// if we have start but no end, that means we have a gap until end of replay
if (start !== -1 && end === -1) {
gapCol.push(...range(start, totalColumns));
ranges.push({
left: toPercent(start / durationMs),
width: toPercent((end - start) / durationMs),
});
start = -1;
end = -1;
}
}

// create gap if we still have start (background frame) until end of replay
if (start !== -1) {
ranges.push({
left: toPercent(start / durationMs),
width: toPercent((durationMs - start) / durationMs),
});
}

// TODO: Fix tooltip position to follow mouse (it currently goes off the timeline when zoomed too much)
return (
<Timeline.Columns totalColumns={totalColumns} remainder={0}>
{gapCol.map(column => (
<Column key={column} column={column}>
<Tooltip title={t('App is suspended')} isHoverable containerDisplayMode="grid">
<Gap style={{width: `${markerWidth}px`}} />
</Tooltip>
</Column>
))}
</Timeline.Columns>
<Fragment>
{ranges.map(rangeCss => {
return (
<Range key={`${rangeCss.left}-${rangeCss.width}`} style={rangeCss}>
<Tooltip
title={t('App is suspended')}
isHoverable
containerDisplayMode="block"
position="top"
>
<Gap />
</Tooltip>
</Range>
);
})}
</Fragment>
);
}

const Column = styled(Timeline.Col)<{column: number}>`
grid-column: ${p => p.column};
line-height: 14px;
const Range = styled('div')`
position: absolute;
`;

const Gap = styled('span')`
const Gap = styled('div')`
background: ${p => p.theme.gray400};
opacity: 16%;
height: 20px;
width: 100%;
`;
8 changes: 0 additions & 8 deletions static/app/utils/replays/replayReader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,8 @@ import {
BreadcrumbCategories,
EventType,
IncrementalSource,
isBackgroundFrame,
isDeadClick,
isDeadRageClick,
isForegroundFrame,
isPaintFrame,
isWebVitalFrame,
} from 'sentry/utils/replays/types';
Expand Down Expand Up @@ -555,12 +553,6 @@ export default class ReplayReader {
return [];
});

getAppFrames = memoize(() => {
return this._sortedBreadcrumbFrames.filter(
frame => isBackgroundFrame(frame) || isForegroundFrame(frame)
);
});

getVideoEvents = () => this._videoEvents;

getPaintFrames = memoize(() => this._sortedSpanFrames.filter(isPaintFrame));
Expand Down

0 comments on commit d10148c

Please sign in to comment.