Browse Source

ref(replay): Refactor the Replay Timeline to use typed network frames instead of spans (#51416)

I checked that rendering is the same as before. The secret is that the
network data needs to be sorted, so i added that to the ReplayReader
class.

Also, updated the tooltips:
Before:

![SCR-20230621-nxiw](https://github.com/getsentry/sentry/assets/187460/02e5a655-79be-461f-b3b4-d2cb7d41f604)


After:

![SCR-20230621-nxlh](https://github.com/getsentry/sentry/assets/187460/08ad050b-628a-46a3-a9cf-c35d9c35e105)


Relates to https://github.com/getsentry/sentry/issues/47991
Ryan Albrecht 1 year ago
parent
commit
408fbd16a3

+ 2 - 2
static/app/components/replays/breadcrumbs/replayTimeline.tsx

@@ -30,7 +30,7 @@ function ReplayTimeline({}: Props) {
   const durationMs = replay.getDurationMs();
   const startTimestampMs = replay.getReplay().started_at.getTime();
   const userCrumbs = replay.getUserActionCrumbs();
-  const networkSpans = replay.getNetworkSpans();
+  const networkFrames = replay.getSortedNetworkFrames();
 
   return (
     <Panel ref={elem} {...mouseTrackingProps}>
@@ -43,7 +43,7 @@ function ReplayTimeline({}: Props) {
             <UnderTimestamp paddingTop="36px">
               <ReplayTimelineSpans
                 durationMs={durationMs}
-                spans={networkSpans}
+                frames={networkFrames}
                 startTimestampMs={startTimestampMs}
               />
             </UnderTimestamp>

+ 15 - 18
static/app/components/replays/breadcrumbs/replayTimelineSpans.tsx

@@ -1,13 +1,14 @@
-import {Fragment, memo} from 'react';
+import {memo} from 'react';
 import styled from '@emotion/styled';
 
-import {divide, flattenSpans} from 'sentry/components/replays/utils';
+import CountTooltipContent from 'sentry/components/replays/countTooltipContent';
+import {divide, flattenFrames} from 'sentry/components/replays/utils';
 import {Tooltip} from 'sentry/components/tooltip';
-import {tn} from 'sentry/locale';
+import {t} from 'sentry/locale';
 import ConfigStore from 'sentry/stores/configStore';
 import {space} from 'sentry/styles/space';
 import useActiveReplayTab from 'sentry/utils/replays/hooks/useActiveReplayTab';
-import type {ReplaySpan} from 'sentry/views/replays/types';
+import type {SpanFrame} from 'sentry/utils/replays/types';
 
 type Props = {
   /**
@@ -18,7 +19,7 @@ type Props = {
   /**
    * The spans to render into the timeline
    */
-  spans: ReplaySpan[];
+  frames: SpanFrame[];
 
   /**
    * Timestamp when the timeline begins, in milliseconds
@@ -31,31 +32,27 @@ type Props = {
   className?: string;
 };
 
-function ReplayTimelineEvents({className, durationMs, spans, startTimestampMs}: Props) {
-  const flattenedSpans = flattenSpans(spans);
+function ReplayTimelineEvents({className, durationMs, frames, startTimestampMs}: Props) {
+  const flattened = flattenFrames(frames);
   const {setActiveTab} = useActiveReplayTab();
 
   return (
     <Spans className={className}>
-      {flattenedSpans.map((span, i) => {
+      {flattened.map((span, i) => {
         const sinceStart = span.startTimestamp - startTimestampMs;
         const startPct = divide(sinceStart, durationMs);
         const widthPct = divide(span.duration, durationMs);
 
-        const requestsCount = tn(
-          '%s network request',
-          '%s network requests',
-          span.spanCount
-        );
         return (
           <Tooltip
             key={i}
             title={
-              <Fragment>
-                {requestsCount}
-                <br />
-                {span.duration.toFixed(2)}ms
-              </Fragment>
+              <CountTooltipContent>
+                <dt>{t('Network Requests:')}</dt>
+                <dd>{span.frameCount}</dd>
+                <dt>{t('Duration:')}</dt>
+                <dd>{span.duration.toLocaleString()}ms</dd>
+              </CountTooltipContent>
             }
             skipWrapper
             disableForVisualTest

+ 1 - 1
static/app/components/replays/contextIcon.tsx

@@ -3,7 +3,7 @@ import styled from '@emotion/styled';
 
 import {generateIconName} from 'sentry/components/events/contextSummary/utils';
 import LoadingMask from 'sentry/components/loadingMask';
-import CountTooltipContent from 'sentry/components/replays/header/countTooltipContent';
+import CountTooltipContent from 'sentry/components/replays/countTooltipContent';
 import {Tooltip} from 'sentry/components/tooltip';
 import {t} from 'sentry/locale';
 import {space} from 'sentry/styles/space';

+ 0 - 0
static/app/components/replays/header/countTooltipContent.tsx → static/app/components/replays/countTooltipContent.tsx


+ 1 - 1
static/app/components/replays/header/errorCounts.tsx

@@ -4,7 +4,7 @@ import styled from '@emotion/styled';
 import ProjectAvatar from 'sentry/components/avatar/projectAvatar';
 import Badge from 'sentry/components/badge';
 import Link from 'sentry/components/links/link';
-import CountTooltipContent from 'sentry/components/replays/header/countTooltipContent';
+import CountTooltipContent from 'sentry/components/replays/countTooltipContent';
 import useErrorCountPerProject from 'sentry/components/replays/header/useErrorCountPerProject';
 import {Tooltip} from 'sentry/components/tooltip';
 import {IconFire} from 'sentry/icons';

+ 62 - 62
static/app/components/replays/utils.spec.tsx

@@ -1,24 +1,17 @@
 import {
   countColumns,
   divide,
-  flattenSpans,
+  flattenFrames,
   formatTime,
   getCrumbsByColumn,
   relativeTimeInMs,
   showPlayerTime,
 } from 'sentry/components/replays/utils';
 import {BreadcrumbLevelType, BreadcrumbType, Crumb} from 'sentry/types/breadcrumbs';
-import type {ReplaySpan} from 'sentry/views/replays/types';
+import hydrateSpans from 'sentry/utils/replays/hydrateSpans';
 
 const SECOND = 1000;
 
-function createSpan(span: Partial<ReplaySpan>): ReplaySpan {
-  return {
-    data: {},
-    ...span,
-  } as ReplaySpan;
-}
-
 function createCrumb({timestamp}: Pick<Crumb, 'timestamp'>): Crumb {
   return {
     timestamp,
@@ -151,106 +144,113 @@ describe('getCrumbsByColumn', () => {
   });
 });
 
-describe('flattenSpans', () => {
+describe('flattenFrames', () => {
   it('should return an empty array if there ar eno spans', () => {
-    expect(flattenSpans([])).toStrictEqual([]);
+    expect(flattenFrames([])).toStrictEqual([]);
   });
 
   it('should return the FlattenedSpanRange for a single span', () => {
-    const span = createSpan({
-      op: 'span',
-      startTimestamp: 10,
-      endTimestamp: 30,
-    });
-    expect(flattenSpans([span])).toStrictEqual([
+    const frames = hydrateSpans(TestStubs.ReplayRecord(), [
+      TestStubs.Replay.RequestFrame({
+        op: 'resource.fetch',
+        startTimestamp: new Date(10000),
+        endTimestamp: new Date(30000),
+      }),
+    ]);
+    expect(flattenFrames(frames)).toStrictEqual([
       {
         duration: 20000,
         endTimestamp: 30000,
-        spanCount: 1,
+        frameCount: 1,
         startTimestamp: 10000,
       },
     ]);
   });
 
   it('should return two non-overlapping spans', () => {
-    const span1 = createSpan({
-      op: 'span1',
-      startTimestamp: 10,
-      endTimestamp: 30,
-    });
-    const span2 = createSpan({
-      op: 'span2',
-      startTimestamp: 60,
-      endTimestamp: 90,
-    });
+    const frames = hydrateSpans(TestStubs.ReplayRecord(), [
+      TestStubs.Replay.RequestFrame({
+        op: 'resource.fetch',
+        startTimestamp: new Date(10000),
+        endTimestamp: new Date(30000),
+      }),
+      TestStubs.Replay.RequestFrame({
+        op: 'resource.fetch',
+        startTimestamp: new Date(60000),
+        endTimestamp: new Date(90000),
+      }),
+    ]);
 
-    expect(flattenSpans([span1, span2])).toStrictEqual([
+    expect(flattenFrames(frames)).toStrictEqual([
       {
         duration: 20000,
         endTimestamp: 30000,
-        spanCount: 1,
+        frameCount: 1,
         startTimestamp: 10000,
       },
       {
         duration: 30000,
         endTimestamp: 90000,
-        spanCount: 1,
+        frameCount: 1,
         startTimestamp: 60000,
       },
     ]);
   });
 
   it('should merge two overlapping spans', () => {
-    const span1 = createSpan({
-      op: 'span1',
-      data: {},
-      startTimestamp: 10,
-      endTimestamp: 30,
-    });
-    const span2 = createSpan({
-      op: 'span2',
-      startTimestamp: 20,
-      endTimestamp: 40,
-    });
+    const frames = hydrateSpans(TestStubs.ReplayRecord(), [
+      TestStubs.Replay.RequestFrame({
+        op: 'resource.fetch',
+        startTimestamp: new Date(10000),
+        endTimestamp: new Date(30000),
+      }),
+      TestStubs.Replay.RequestFrame({
+        op: 'resource.fetch',
+        startTimestamp: new Date(20000),
+        endTimestamp: new Date(40000),
+      }),
+    ]);
 
-    expect(flattenSpans([span1, span2])).toStrictEqual([
+    expect(flattenFrames(frames)).toStrictEqual([
       {
         duration: 30000,
         endTimestamp: 40000,
-        spanCount: 2,
+        frameCount: 2,
         startTimestamp: 10000,
       },
     ]);
   });
 
   it('should merge overlapping spans that are not first in the list', () => {
-    const span0 = createSpan({
-      op: 'span0',
-      startTimestamp: 0,
-      endTimestamp: 1,
-    });
-    const span1 = createSpan({
-      op: 'span1',
-      startTimestamp: 10,
-      endTimestamp: 30,
-    });
-    const span2 = createSpan({
-      op: 'span2',
-      startTimestamp: 20,
-      endTimestamp: 40,
-    });
+    const frames = hydrateSpans(TestStubs.ReplayRecord(), [
+      TestStubs.Replay.RequestFrame({
+        op: 'resource.fetch',
+        startTimestamp: new Date(0),
+        endTimestamp: new Date(1000),
+      }),
+      TestStubs.Replay.RequestFrame({
+        op: 'resource.fetch',
+        startTimestamp: new Date(10000),
+        endTimestamp: new Date(30000),
+      }),
+      TestStubs.Replay.RequestFrame({
+        op: 'resource.fetch',
+        startTimestamp: new Date(20000),
+        endTimestamp: new Date(40000),
+      }),
+    ]);
 
-    expect(flattenSpans([span0, span1, span2])).toStrictEqual([
+    expect(flattenFrames(frames)).toStrictEqual([
       {
         duration: 1000,
         endTimestamp: 1000,
-        spanCount: 1,
+        frameCount: 1,
         startTimestamp: 0,
       },
       {
         duration: 30000,
         endTimestamp: 40000,
-        spanCount: 2,
+        frameCount: 2,
         startTimestamp: 10000,
       },
     ]);

+ 20 - 31
static/app/components/replays/utils.tsx

@@ -1,6 +1,6 @@
 import {Crumb} from 'sentry/types/breadcrumbs';
 import {formatSecondsToClock} from 'sentry/utils/formatters';
-import type {ReplaySpan} from 'sentry/views/replays/types';
+import type {SpanFrame} from 'sentry/utils/replays/types';
 
 const SECOND = 1000;
 const MINUTE = 60 * SECOND;
@@ -147,12 +147,7 @@ type FlattenedSpanRange = {
   /**
    * Number of spans that got flattened into this range
    */
-  spanCount: number;
-  /**
-   * ID of the original span that created this range
-   */
-  spanId: string;
-  //
+  frameCount: number;
   /**
    * Absolute time in ms when the span starts
    */
@@ -167,45 +162,39 @@ function doesOverlap(a: FlattenedSpanRange, b: FlattenedSpanRange) {
   return bStartsWithinA || bEndsWithinA;
 }
 
-export function flattenSpans(rawSpans: ReplaySpan[]): FlattenedSpanRange[] {
-  if (!rawSpans.length) {
+export function flattenFrames(frames: SpanFrame[]): FlattenedSpanRange[] {
+  if (!frames.length) {
     return [];
   }
 
-  const spans = rawSpans.map(span => {
-    const startTimestamp = span.startTimestamp * 1000;
-
-    // `endTimestamp` is at least msPerPixel wide, otherwise it disappears
-    const endTimestamp = span.endTimestamp * 1000;
+  const [first, ...rest] = frames.map((span): FlattenedSpanRange => {
     return {
-      spanCount: 1,
-      // spanId: span.span_id,
-      startTimestamp,
-      endTimestamp,
-      duration: endTimestamp - startTimestamp,
-    } as FlattenedSpanRange;
+      frameCount: 1,
+      startTimestamp: span.timestampMs,
+      endTimestamp: span.endTimestampMs,
+      duration: span.endTimestampMs - span.timestampMs,
+    };
   });
 
-  const [firstSpan, ...restSpans] = spans;
-  const flatSpans = [firstSpan];
+  const flattened = [first];
 
-  for (const span of restSpans) {
+  for (const span of rest) {
     let overlap = false;
-    for (const fspan of flatSpans) {
-      if (doesOverlap(fspan, span)) {
+    for (const range of flattened) {
+      if (doesOverlap(range, span)) {
         overlap = true;
-        fspan.spanCount += 1;
-        fspan.startTimestamp = Math.min(fspan.startTimestamp, span.startTimestamp);
-        fspan.endTimestamp = Math.max(fspan.endTimestamp, span.endTimestamp);
-        fspan.duration = fspan.endTimestamp - fspan.startTimestamp;
+        range.frameCount += 1;
+        range.startTimestamp = Math.min(range.startTimestamp, span.startTimestamp);
+        range.endTimestamp = Math.max(range.endTimestamp, span.endTimestamp);
+        range.duration = range.endTimestamp - range.startTimestamp;
         break;
       }
     }
     if (!overlap) {
-      flatSpans.push(span);
+      flattened.push(span);
     }
   }
-  return flatSpans;
+  return flattened;
 }
 
 /**

+ 11 - 7
static/app/utils/replays/hydrateSpans.spec.tsx

@@ -1,6 +1,7 @@
 import hydrateSpans from 'sentry/utils/replays/hydrateSpans';
 
-const ONE_DAY_MS = 60 * 60 * 24 * 1000;
+const ONE_HOUR_MS = 60 * 60 * 1000;
+const ONE_DAY_MS = ONE_HOUR_MS * 24;
 
 describe('hydrateSpans', () => {
   const replayRecord = TestStubs.ReplayRecord({started_at: new Date('2023/12/23')});
@@ -26,28 +27,31 @@ describe('hydrateSpans', () => {
         op: 'memory',
         data: {memory: expect.any(Object)},
         description: '',
-        startTimestamp: new Date('2023/12/23'),
         endTimestamp: new Date('2023/12/23 23:00'),
-        timestampMs: 1703307600000,
+        endTimestampMs: 1703307600000 + ONE_HOUR_MS * 23,
         offsetMs: 0,
+        startTimestamp: new Date('2023/12/23'),
+        timestampMs: 1703307600000,
       },
       {
         op: 'memory',
         data: {memory: expect.any(Object)},
         description: '',
-        startTimestamp: new Date('2023/12/24'),
         endTimestamp: new Date('2023/12/24 23:00'),
-        timestampMs: 1703307600000 + ONE_DAY_MS,
+        endTimestampMs: 1703307600000 + ONE_DAY_MS + ONE_HOUR_MS * 23,
         offsetMs: ONE_DAY_MS,
+        startTimestamp: new Date('2023/12/24'),
+        timestampMs: 1703307600000 + ONE_DAY_MS,
       },
       {
         op: 'memory',
         data: {memory: expect.any(Object)},
         description: '',
-        startTimestamp: new Date('2023/12/25'),
         endTimestamp: new Date('2023/12/25 23:00'),
-        timestampMs: 1703307600000 + ONE_DAY_MS * 2,
+        endTimestampMs: 1703307600000 + ONE_DAY_MS * 2 + ONE_HOUR_MS * 23,
         offsetMs: ONE_DAY_MS * 2,
+        startTimestamp: new Date('2023/12/25'),
+        timestampMs: 1703307600000 + ONE_DAY_MS * 2,
       },
     ]);
   });

+ 1 - 0
static/app/utils/replays/hydrateSpans.tsx

@@ -25,6 +25,7 @@ export default function hydrateSpans(
         return {
           ...frame,
           endTimestamp: end,
+          endTimestampMs: end.getTime(),
           offsetMs: Math.abs(start.getTime() - startTimestampMs),
           startTimestamp: start,
           timestampMs: start.getTime(),

+ 1 - 1
static/app/utils/replays/replayReader.spec.tsx

@@ -136,7 +136,7 @@ describe('ReplayReader', () => {
         expected: [expect.objectContaining({category: 'console'})],
       },
       {
-        method: 'getNetworkFrames',
+        method: 'getSortedNetworkFrames',
         expected: [expect.objectContaining({op: 'navigation.navigate'})],
       },
       {

Some files were not shown because too many files changed in this diff