Browse Source

feat(replays): add dom nodes chart (#56696)

<img width="1236" alt="269767716-0c5921ab-9432-4362-a464-445d4fcee5ff"
src="https://github.com/getsentry/sentry/assets/56095982/06973c46-1ac7-4e09-afe8-4e0bea040e8a">

& some minor formatting changes for the memory chart

Closes https://github.com/getsentry/sentry/issues/56480

---------

Co-authored-by: Ryan Albrecht <ryan.albrecht@sentry.io>
Michelle Zhang 1 year ago
parent
commit
d2c1ec7fa6

+ 108 - 0
static/app/utils/replays/countDomNodes.tsx

@@ -0,0 +1,108 @@
+import {Replayer} from '@sentry-internal/rrweb';
+
+import type {RecordingFrame} from 'sentry/utils/replays/types';
+
+export type DomNodeChartDatapoint = {
+  count: number;
+  endTimestampMs: number;
+  startTimestampMs: number;
+  timestampMs: number;
+};
+
+type Args = {
+  frames: RecordingFrame[] | undefined;
+  rrwebEvents: RecordingFrame[] | undefined;
+  startTimestampMs: number;
+};
+
+export default function countDomNodes({
+  frames = [],
+  rrwebEvents,
+  startTimestampMs,
+}: Args): Promise<DomNodeChartDatapoint[]> {
+  return new Promise(resolve => {
+    const datapoints = new Map<RecordingFrame, DomNodeChartDatapoint>();
+    const player = createPlayer(rrwebEvents);
+
+    const nextFrame = (function () {
+      let i = 0;
+      const len = frames.length;
+      // how many frames we look at depends on the number of total frames
+      return () => frames[(i += Math.max(Math.round(len * 0.007), 1))];
+    })();
+
+    const onDone = () => {
+      resolve(Array.from(datapoints.values()));
+    };
+
+    const nextOrDone = () => {
+      const next = nextFrame();
+      if (next) {
+        matchFrame(next);
+      } else {
+        onDone();
+      }
+    };
+
+    type FrameRef = {
+      frame: undefined | RecordingFrame;
+    };
+
+    const nodeIdRef: FrameRef = {
+      frame: undefined,
+    };
+
+    const handlePause = () => {
+      const frame = nodeIdRef.frame as RecordingFrame;
+      const idCount = player.getMirror().getIds().length; // gets number of DOM nodes present
+      datapoints.set(frame as RecordingFrame, {
+        count: idCount,
+        timestampMs: frame.timestamp,
+        startTimestampMs: frame.timestamp,
+        endTimestampMs: frame.timestamp,
+      });
+      nextOrDone();
+    };
+
+    const matchFrame = frame => {
+      if (!frame) {
+        nextOrDone();
+        return;
+      }
+      nodeIdRef.frame = frame;
+
+      window.setTimeout(() => {
+        player.pause(frame.timestamp - startTimestampMs);
+      }, 0);
+    };
+
+    player.on('pause', handlePause);
+    matchFrame(nextFrame());
+  });
+}
+
+function createPlayer(rrwebEvents): Replayer {
+  const domRoot = document.createElement('div');
+  domRoot.className = 'sentry-block';
+  const {style} = domRoot;
+
+  style.position = 'fixed';
+  style.inset = '0';
+  style.width = '0';
+  style.height = '0';
+  style.overflow = 'hidden';
+
+  document.body.appendChild(domRoot);
+
+  const replayerRef = new Replayer(rrwebEvents, {
+    root: domRoot,
+    loadTimeout: 1,
+    showWarning: false,
+    blockClass: 'sentry-block',
+    speed: 99999,
+    skipInactive: true,
+    triggerFocus: false,
+    mouseTail: false,
+  });
+  return replayerRef;
+}

+ 20 - 0
static/app/utils/replays/replayReader.tsx

@@ -1,9 +1,11 @@
 import * as Sentry from '@sentry/react';
 import * as Sentry from '@sentry/react';
+import {incrementalSnapshotEvent, IncrementalSource} from '@sentry-internal/rrweb';
 import memoize from 'lodash/memoize';
 import memoize from 'lodash/memoize';
 import {duration} from 'moment';
 import {duration} from 'moment';
 
 
 import domId from 'sentry/utils/domId';
 import domId from 'sentry/utils/domId';
 import localStorageWrapper from 'sentry/utils/localStorage';
 import localStorageWrapper from 'sentry/utils/localStorage';
+import countDomNodes from 'sentry/utils/replays/countDomNodes';
 import extractDomNodes from 'sentry/utils/replays/extractDomNodes';
 import extractDomNodes from 'sentry/utils/replays/extractDomNodes';
 import hydrateBreadcrumbs, {
 import hydrateBreadcrumbs, {
   replayInitBreadcrumb,
   replayInitBreadcrumb,
@@ -27,6 +29,7 @@ import type {
 } from 'sentry/utils/replays/types';
 } from 'sentry/utils/replays/types';
 import {
 import {
   BreadcrumbCategories,
   BreadcrumbCategories,
+  EventType,
   isDeadClick,
   isDeadClick,
   isDeadRageClick,
   isDeadRageClick,
   isLCPFrame,
   isLCPFrame,
@@ -194,6 +197,15 @@ export default class ReplayReader {
 
 
   getRRWebFrames = () => this._sortedRRWebEvents;
   getRRWebFrames = () => this._sortedRRWebEvents;
 
 
+  getRRWebMutations = () =>
+    this._sortedRRWebEvents.filter(
+      event =>
+        [EventType.IncrementalSnapshot].includes(event.type) &&
+        [IncrementalSource.Mutation].includes(
+          (event as incrementalSnapshotEvent).data.source
+        ) // filter only for mutation events
+    );
+
   getErrorFrames = () => this._errors;
   getErrorFrames = () => this._errors;
 
 
   getConsoleFrames = memoize(() =>
   getConsoleFrames = memoize(() =>
@@ -234,6 +246,14 @@ export default class ReplayReader {
     ].sort(sortFrames)
     ].sort(sortFrames)
   );
   );
 
 
+  countDomNodes = memoize(() =>
+    countDomNodes({
+      frames: this.getRRWebMutations(),
+      rrwebEvents: this.getRRWebFrames(),
+      startTimestampMs: this._replayRecord.started_at.getTime(),
+    })
+  );
+
   getDomNodes = memoize(() =>
   getDomNodes = memoize(() =>
     extractDomNodes({
     extractDomNodes({
       frames: this.getDOMFrames(),
       frames: this.getDOMFrames(),

+ 286 - 0
static/app/views/replays/detail/domNodesChart.tsx

@@ -0,0 +1,286 @@
+import {forwardRef, memo, useEffect, useRef} from 'react';
+import {useTheme} from '@emotion/react';
+import styled from '@emotion/styled';
+import moment from 'moment';
+
+import {AreaChart, AreaChartProps} from 'sentry/components/charts/areaChart';
+import Grid from 'sentry/components/charts/components/grid';
+import {ChartTooltip} from 'sentry/components/charts/components/tooltip';
+import XAxis from 'sentry/components/charts/components/xAxis';
+import YAxis from 'sentry/components/charts/components/yAxis';
+import Placeholder from 'sentry/components/placeholder';
+import {useReplayContext} from 'sentry/components/replays/replayContext';
+import {showPlayerTime} from 'sentry/components/replays/utils';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import {ReactEchartsRef, Series} from 'sentry/types/echarts';
+import {getFormattedDate} from 'sentry/utils/dates';
+import {axisLabelFormatter} from 'sentry/utils/discover/charts';
+import {useQuery} from 'sentry/utils/queryClient';
+import {DomNodeChartDatapoint} from 'sentry/utils/replays/countDomNodes';
+import ReplayReader from 'sentry/utils/replays/replayReader';
+import FluidHeight from 'sentry/views/replays/detail/layout/fluidHeight';
+
+interface Props {
+  datapoints: DomNodeChartDatapoint[];
+  setCurrentHoverTime: (time: undefined | number) => void;
+  setCurrentTime: (time: number) => void;
+  startTimestampMs: undefined | number;
+}
+
+interface DomNodesChartProps extends Props {
+  forwardedRef: React.Ref<ReactEchartsRef>;
+}
+
+const formatTimestamp = timestamp =>
+  getFormattedDate(timestamp, 'MMM D, YYYY hh:mm:ss A z', {local: false});
+
+const formatTimestampTrim = timestamp =>
+  getFormattedDate(timestamp, 'MMM D hh:mm', {local: false});
+
+function DomNodesChart({
+  forwardedRef,
+  datapoints,
+  startTimestampMs = 0,
+  setCurrentTime,
+  setCurrentHoverTime,
+}: DomNodesChartProps) {
+  const theme = useTheme();
+
+  if (!datapoints) {
+    return null;
+  }
+
+  if (!datapoints.length) {
+    return (
+      <DomNodesChartWrapper>
+        <Placeholder height="100%" />
+      </DomNodesChartWrapper>
+    );
+  }
+
+  const chartOptions: Omit<AreaChartProps, 'series'> = {
+    grid: Grid({
+      top: '40px',
+      left: space(1),
+      right: space(1),
+    }),
+    tooltip: ChartTooltip({
+      appendToBody: true,
+      trigger: 'axis',
+      renderMode: 'html',
+      chartId: 'replay-dom-nodes-chart',
+      formatter: values => {
+        const seriesTooltips = values.map(
+          value => `
+            <div>
+              <span className="tooltip-label">${value.marker}<strong>${value.seriesName}</strong></span>
+          ${value.data[1]}
+            </div>
+          `
+        );
+        const template = [
+          '<div class="tooltip-series">',
+          ...seriesTooltips,
+          '</div>',
+          `<div class="tooltip-footer" style="display: inline-block; width: max-content;">${t(
+            'Span Time'
+          )}:
+            ${formatTimestamp(values[0].axisValue)}
+          </div>`,
+          `<div class="tooltip-footer" style="border: none;">${'Relative Time'}:
+            ${showPlayerTime(
+              moment(values[0].axisValue).toDate().toUTCString(),
+              startTimestampMs
+            )}
+          </div>`,
+          '<div class="tooltip-arrow"></div>',
+        ].join('');
+        return template;
+      },
+    }),
+    xAxis: XAxis({
+      type: 'time',
+      axisLabel: {
+        formatter: formatTimestampTrim,
+      },
+      theme,
+    }),
+    yAxis: YAxis({
+      type: 'value',
+      name: t('DOM Nodes'),
+      theme,
+      nameTextStyle: {
+        padding: [8, 8, 8, 48],
+        fontSize: theme.fontSizeLarge,
+        fontWeight: 600,
+        lineHeight: 1.2,
+        fontFamily: theme.text.family,
+        color: theme.gray300,
+      },
+      minInterval: 100,
+      maxInterval: Math.pow(1024, 4),
+      axisLabel: {
+        formatter: (value: number) => axisLabelFormatter(value, 'number', true),
+      },
+    }),
+    onMouseOver: ({data}) => {
+      if (data[0]) {
+        setCurrentHoverTime(data[0] - startTimestampMs);
+      }
+    },
+    onMouseOut: () => {
+      setCurrentHoverTime(undefined);
+    },
+    onClick: ({data}) => {
+      if (data.value) {
+        setCurrentTime(data.value - startTimestampMs);
+      }
+    },
+  };
+
+  const series: Series[] = [
+    {
+      seriesName: t('Number of DOM nodes'),
+      data: datapoints.map(d => ({
+        value: d.count,
+        name: d.endTimestampMs,
+      })),
+      stack: 'dom-nodes',
+      lineStyle: {
+        opacity: 0.75,
+        width: 1,
+      },
+    },
+    {
+      id: 'currentTime',
+      seriesName: t('Current player time'),
+      data: [],
+      markLine: {
+        symbol: ['', ''],
+        data: [],
+        label: {
+          show: false,
+        },
+        lineStyle: {
+          type: 'solid' as const,
+          color: theme.purple300,
+          width: 2,
+        },
+      },
+    },
+    {
+      id: 'hoverTime',
+      seriesName: t('Hover player time'),
+      data: [],
+      markLine: {
+        symbol: ['', ''],
+        data: [],
+        label: {
+          show: false,
+        },
+        lineStyle: {
+          type: 'solid' as const,
+          color: theme.purple200,
+          width: 2,
+        },
+      },
+    },
+  ];
+
+  return (
+    <DomNodesChartWrapper id="replay-dom-nodes-chart">
+      <AreaChart forwardedRef={forwardedRef} series={series} {...chartOptions} />
+    </DomNodesChartWrapper>
+  );
+}
+
+const DomNodesChartWrapper = styled(FluidHeight)`
+  border: 1px solid ${p => p.theme.border};
+  border-radius: ${space(0.5)};
+  justify-content: center;
+  padding: ${space(1)};
+`;
+
+const MemoizedDomNodesChart = memo(
+  forwardRef<ReactEchartsRef, Props>((props, ref) => (
+    <DomNodesChart forwardedRef={ref} {...props} />
+  ))
+);
+
+function useCountDomNodes({replay}: {replay: null | ReplayReader}) {
+  return useQuery(['countDomNodes', replay], () => replay?.countDomNodes() ?? [], {
+    enabled: Boolean(replay),
+    initialData: [],
+    cacheTime: Infinity,
+  });
+}
+
+function DomNodesChartContainer() {
+  const {currentTime, currentHoverTime, replay, setCurrentTime, setCurrentHoverTime} =
+    useReplayContext();
+  const chart = useRef<ReactEchartsRef>(null);
+  const theme = useTheme();
+  const {data: datapoints} = useCountDomNodes({replay});
+  const startTimestampMs = replay?.getReplay()?.started_at?.getTime() ?? 0;
+
+  useEffect(() => {
+    if (!chart.current) {
+      return;
+    }
+    const echarts = chart.current.getEchartsInstance();
+
+    echarts.setOption({
+      series: [
+        {
+          id: 'currentTime',
+          markLine: {
+            data: [
+              {
+                xAxis: currentTime + startTimestampMs,
+              },
+            ],
+          },
+        },
+      ],
+    });
+  }, [currentTime, startTimestampMs, theme]);
+
+  useEffect(() => {
+    if (!chart.current) {
+      return;
+    }
+    const echarts = chart.current.getEchartsInstance();
+
+    echarts.setOption({
+      series: [
+        {
+          id: 'hoverTime',
+          markLine: {
+            data: [
+              ...(currentHoverTime
+                ? [
+                    {
+                      xAxis: currentHoverTime + startTimestampMs,
+                    },
+                  ]
+                : []),
+            ],
+          },
+        },
+      ],
+    });
+  }, [currentHoverTime, startTimestampMs, theme]);
+
+  return (
+    <MemoizedDomNodesChart
+      ref={chart}
+      datapoints={datapoints}
+      setCurrentHoverTime={setCurrentHoverTime}
+      setCurrentTime={setCurrentTime}
+      startTimestampMs={startTimestampMs}
+    />
+  );
+}
+
+export default DomNodesChartContainer;

+ 18 - 1
static/app/views/replays/detail/layout/focusArea.tsx

@@ -1,8 +1,13 @@
+import styled from '@emotion/styled';
+
+import {space} from 'sentry/styles/space';
 import useActiveReplayTab, {TabKey} from 'sentry/utils/replays/hooks/useActiveReplayTab';
 import useActiveReplayTab, {TabKey} from 'sentry/utils/replays/hooks/useActiveReplayTab';
 import A11y from 'sentry/views/replays/detail/accessibility/index';
 import A11y from 'sentry/views/replays/detail/accessibility/index';
 import Console from 'sentry/views/replays/detail/console';
 import Console from 'sentry/views/replays/detail/console';
 import DomMutations from 'sentry/views/replays/detail/domMutations';
 import DomMutations from 'sentry/views/replays/detail/domMutations';
+import DomNodesChart from 'sentry/views/replays/detail/domNodesChart';
 import ErrorList from 'sentry/views/replays/detail/errorList/index';
 import ErrorList from 'sentry/views/replays/detail/errorList/index';
+import FluidHeight from 'sentry/views/replays/detail/layout/fluidHeight';
 import MemoryChart from 'sentry/views/replays/detail/memoryChart';
 import MemoryChart from 'sentry/views/replays/detail/memoryChart';
 import NetworkList from 'sentry/views/replays/detail/network';
 import NetworkList from 'sentry/views/replays/detail/network';
 import PerfTable from 'sentry/views/replays/detail/perfTable/index';
 import PerfTable from 'sentry/views/replays/detail/perfTable/index';
@@ -27,7 +32,12 @@ function FocusArea({}: Props) {
     case TabKey.DOM:
     case TabKey.DOM:
       return <DomMutations />;
       return <DomMutations />;
     case TabKey.MEMORY:
     case TabKey.MEMORY:
-      return <MemoryChart />;
+      return (
+        <MemoryTabWrapper>
+          <MemoryChart />
+          <DomNodesChart />
+        </MemoryTabWrapper>
+      );
     case TabKey.CONSOLE:
     case TabKey.CONSOLE:
     default: {
     default: {
       return <Console />;
       return <Console />;
@@ -35,4 +45,11 @@ function FocusArea({}: Props) {
   }
   }
 }
 }
 
 
+const MemoryTabWrapper = styled(FluidHeight)`
+  justify-content: center;
+  gap: ${space(1)};
+  height: 100%;
+  display: flex;
+`;
+
 export default FocusArea;
 export default FocusArea;

+ 18 - 11
static/app/views/replays/detail/memoryChart.tsx

@@ -34,6 +34,9 @@ interface MemoryChartProps extends Props {
 const formatTimestamp = timestamp =>
 const formatTimestamp = timestamp =>
   getFormattedDate(timestamp, 'MMM D, YYYY hh:mm:ss A z', {local: false});
   getFormattedDate(timestamp, 'MMM D, YYYY hh:mm:ss A z', {local: false});
 
 
+const formatTimestampTrim = timestamp =>
+  getFormattedDate(timestamp, 'MMM D hh:mm', {local: false});
+
 function MemoryChart({
 function MemoryChart({
   forwardedRef,
   forwardedRef,
   memoryFrames,
   memoryFrames,
@@ -42,7 +45,6 @@ function MemoryChart({
   setCurrentHoverTime,
   setCurrentHoverTime,
 }: MemoryChartProps) {
 }: MemoryChartProps) {
   const theme = useTheme();
   const theme = useTheme();
-
   if (!memoryFrames) {
   if (!memoryFrames) {
     return (
     return (
       <MemoryChartWrapper>
       <MemoryChartWrapper>
@@ -53,13 +55,15 @@ function MemoryChart({
 
 
   if (!memoryFrames.length) {
   if (!memoryFrames.length) {
     return (
     return (
-      <EmptyMessage
-        data-test-id="replay-details-memory-tab"
-        title={t('No memory metrics found')}
-        description={t(
-          'Memory metrics are only captured within Chromium based browser sessions.'
-        )}
-      />
+      <MemoryChartWrapper>
+        <EmptyMessage
+          data-test-id="replay-details-memory-tab"
+          title={t('No memory metrics found')}
+          description={t(
+            'Memory metrics are only captured within Chromium based browser sessions.'
+          )}
+        />
+      </MemoryChartWrapper>
     );
     );
   }
   }
 
 
@@ -110,7 +114,7 @@ function MemoryChart({
     xAxis: XAxis({
     xAxis: XAxis({
       type: 'time',
       type: 'time',
       axisLabel: {
       axisLabel: {
-        formatter: formatTimestamp,
+        formatter: formatTimestampTrim,
       },
       },
       theme,
       theme,
     }),
     }),
@@ -119,10 +123,11 @@ function MemoryChart({
       name: t('Heap Size'),
       name: t('Heap Size'),
       theme,
       theme,
       nameTextStyle: {
       nameTextStyle: {
-        padding: 8,
+        padding: [8, 8, 8, -25],
         fontSize: theme.fontSizeLarge,
         fontSize: theme.fontSizeLarge,
         fontWeight: 600,
         fontWeight: 600,
         lineHeight: 1.2,
         lineHeight: 1.2,
+        fontFamily: theme.text.family,
         color: theme.gray300,
         color: theme.gray300,
       },
       },
       // input is in bytes, minInterval is a megabyte
       // input is in bytes, minInterval is a megabyte
@@ -224,8 +229,10 @@ function MemoryChart({
 }
 }
 
 
 const MemoryChartWrapper = styled(FluidHeight)`
 const MemoryChartWrapper = styled(FluidHeight)`
-  border-radius: ${space(0.5)};
   border: 1px solid ${p => p.theme.border};
   border: 1px solid ${p => p.theme.border};
+  border-radius: ${space(0.5)};
+  justify-content: center;
+  padding: ${space(1)};
 `;
 `;
 
 
 const MemoizedMemoryChart = memo(
 const MemoizedMemoryChart = memo(