Browse Source

bug(replay): Refactor the replay memory charts and render correct start/end times (#67634)

This is a bit of a refactor for our Replay Details memory & dom nodes
charts

"Big" changes include:

1. Insert a data series with start/end timestamps that align to the
replay. So the time axis matches the Timeline component.
There may be gaps at the start/end of that chart now like this, which is
a good thing! <img width="273" alt="SCR-20240325-liiw"
src="https://github.com/getsentry/sentry/assets/187460/64bb99a5-6923-4e9b-be45-873e79a60542">

2. Simplify the memoization story by using useMemo more
3. Let the charts adjust in height when the window resizes
4. Change the x-axis to be 'time with replay' instead of absolute
timestamp, so strings fit better side by side. Aligns timestamps with
the Timeline component. <img width="657" alt="SCR-20240325-ljox"
src="https://github.com/getsentry/sentry/assets/187460/3e17d091-9709-4e0d-b6d6-fd6d88826dfe">


Fixes https://github.com/getsentry/sentry/issues/51492
Related to https://github.com/getsentry/sentry/issues/51465
Related to https://github.com/getsentry/sentry/issues/65542
Ryan Albrecht 11 months ago
parent
commit
b922644c17

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

@@ -1,298 +0,0 @@
-import {forwardRef, memo, useEffect, useMemo, useRef} from 'react';
-import {useTheme} from '@emotion/react';
-import styled from '@emotion/styled';
-import moment from 'moment';
-
-import type {AreaChartProps} from 'sentry/components/charts/areaChart';
-import {AreaChart} 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 type {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 type {DomNodeChartDatapoint} from 'sentry/utils/replays/countDomNodes';
-import countDomNodes from 'sentry/utils/replays/countDomNodes';
-import type 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],
-    () =>
-      countDomNodes({
-        frames: replay?.getRRWebMutations(),
-        rrwebEvents: replay?.getRRWebFrames(),
-        startTimestampMs: replay?.getReplay().started_at.getTime() ?? 0,
-      }),
-    {enabled: Boolean(replay), cacheTime: Infinity}
-  );
-}
-
-function DomNodesChartContainer() {
-  const {currentTime, currentHoverTime, replay, setCurrentTime, setCurrentHoverTime} =
-    useReplayContext();
-  const chart = useRef<ReactEchartsRef>(null);
-  const theme = useTheme();
-  const {data: frameToCount} = useCountDomNodes({replay});
-  const startTimestampMs = replay?.getReplay()?.started_at?.getTime() ?? 0;
-
-  const datapoints = useMemo(
-    () => Array.from(frameToCount?.values() || []),
-    [frameToCount]
-  );
-
-  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;

+ 3 - 24
static/app/views/replays/detail/layout/focusArea.tsx

@@ -1,22 +1,15 @@
-import styled from '@emotion/styled';
-
-import {space} from 'sentry/styles/space';
 import useActiveReplayTab, {TabKey} from 'sentry/utils/replays/hooks/useActiveReplayTab';
 import A11y from 'sentry/views/replays/detail/accessibility/index';
 import Breadcrumbs from 'sentry/views/replays/detail/breadcrumbs';
 import Console from 'sentry/views/replays/detail/console';
-import DomNodesChart from 'sentry/views/replays/detail/domNodesChart';
 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 MemoryPanel from 'sentry/views/replays/detail/memoryPanel/index';
 import NetworkList from 'sentry/views/replays/detail/network';
 import PerfTable from 'sentry/views/replays/detail/perfTable/index';
 import TagPanel from 'sentry/views/replays/detail/tagPanel';
 import Trace from 'sentry/views/replays/detail/trace/index';
 
-type Props = {};
-
-function FocusArea({}: Props) {
+export default function FocusArea() {
   const {getActiveTab} = useActiveReplayTab();
 
   switch (getActiveTab()) {
@@ -31,12 +24,7 @@ function FocusArea({}: Props) {
     case TabKey.ERRORS:
       return <ErrorList />;
     case TabKey.MEMORY:
-      return (
-        <MemoryTabWrapper>
-          <MemoryChart />
-          <DomNodesChart />
-        </MemoryTabWrapper>
-      );
+      return <MemoryPanel />;
     case TabKey.CONSOLE:
       return <Console />;
     case TabKey.TAGS:
@@ -47,12 +35,3 @@ function FocusArea({}: Props) {
     }
   }
 }
-
-const MemoryTabWrapper = styled(FluidHeight)`
-  justify-content: center;
-  gap: ${space(1)};
-  height: 100%;
-  display: flex;
-`;
-
-export default FocusArea;

+ 0 - 321
static/app/views/replays/detail/memoryChart.tsx

@@ -1,321 +0,0 @@
-import {forwardRef, memo, useEffect, useRef} from 'react';
-import {useTheme} from '@emotion/react';
-import styled from '@emotion/styled';
-import moment from 'moment';
-
-import type {AreaChartProps} from 'sentry/components/charts/areaChart';
-import {AreaChart} 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 EmptyMessage from 'sentry/components/emptyMessage';
-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 type {ReactEchartsRef, Series} from 'sentry/types/echarts';
-import {formatBytesBase2} from 'sentry/utils';
-import {getFormattedDate} from 'sentry/utils/dates';
-import type {MemoryFrame} from 'sentry/utils/replays/types';
-import FluidHeight from 'sentry/views/replays/detail/layout/fluidHeight';
-
-interface Props {
-  memoryFrames: undefined | MemoryFrame[];
-  setCurrentHoverTime: (time: undefined | number) => void;
-  setCurrentTime: (time: number) => void;
-  startTimestampMs: undefined | number;
-}
-
-interface MemoryChartProps 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 MemoryChart({
-  forwardedRef,
-  memoryFrames,
-  startTimestampMs = 0,
-  setCurrentTime,
-  setCurrentHoverTime,
-}: MemoryChartProps) {
-  const theme = useTheme();
-  if (!memoryFrames) {
-    return (
-      <MemoryChartWrapper>
-        <Placeholder height="100%" />
-      </MemoryChartWrapper>
-    );
-  }
-
-  if (!memoryFrames.length) {
-    return (
-      <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>
-    );
-  }
-
-  const chartOptions: Omit<AreaChartProps, 'series'> = {
-    grid: Grid({
-      // makes space for the title
-      top: '40px',
-      left: space(1),
-      right: space(1),
-    }),
-    tooltip: ChartTooltip({
-      appendToBody: true,
-      trigger: 'axis',
-      renderMode: 'html',
-      chartId: 'replay-memory-chart',
-      formatter: values => {
-        const seriesTooltips = values.map(
-          value => `
-            <div>
-              <span className="tooltip-label">${value.marker}<strong>${value.seriesName}</strong></span>
-          ${formatBytesBase2(value.data[1])}
-            </div>
-          `
-        );
-        // showPlayerTime expects a timestamp so we take the captured time in seconds and convert it to a UTC timestamp
-        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('Heap Size'),
-      theme,
-      nameTextStyle: {
-        padding: [8, 8, 8, -25],
-        fontSize: theme.fontSizeLarge,
-        fontWeight: 600,
-        lineHeight: 1.2,
-        fontFamily: theme.text.family,
-        color: theme.gray300,
-      },
-      // input is in bytes, minInterval is a megabyte
-      minInterval: 1024 * 1024,
-      // maxInterval is a terabyte
-      maxInterval: Math.pow(1024, 4),
-      // format the axis labels to be whole number values
-      axisLabel: {
-        formatter: value => formatBytesBase2(value, 0),
-      },
-    }),
-
-    // XXX: For area charts, mouse events *only* occurs when interacting with
-    // the "line" of the area chart. Mouse events do not fire when interacting
-    // with the "area" under the line.
-    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('Used Heap Memory'),
-      data: memoryFrames.map(frame => ({
-        value: frame.data.memory.usedJSHeapSize,
-        name: frame.endTimestampMs,
-      })),
-      stack: 'heap-memory',
-      lineStyle: {
-        opacity: 0.75,
-        width: 1,
-      },
-    },
-    {
-      seriesName: t('Free Heap Memory'),
-      data: memoryFrames.map(frame => ({
-        value: frame.data.memory.totalJSHeapSize - frame.data.memory.usedJSHeapSize,
-        name: frame.endTimestampMs,
-      })),
-      stack: 'heap-memory',
-      lineStyle: {
-        opacity: 0.75,
-        width: 1,
-      },
-    },
-
-    // Inserting this here so we can update in Container
-    {
-      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 (
-    <MemoryChartWrapper id="replay-memory-chart">
-      <AreaChart forwardedRef={forwardedRef} series={series} {...chartOptions} />
-    </MemoryChartWrapper>
-  );
-}
-
-const MemoryChartWrapper = styled(FluidHeight)`
-  border: 1px solid ${p => p.theme.border};
-  border-radius: ${space(0.5)};
-  justify-content: center;
-  padding: ${space(1)};
-`;
-
-const MemoizedMemoryChart = memo(
-  forwardRef<ReactEchartsRef, Props>((props, ref) => (
-    <MemoryChart forwardedRef={ref} {...props} />
-  ))
-);
-
-/**
- * This container is used to update echarts outside of React. `currentTime` is
- * the current time of the player -- if replay is currently playing, this will
- * be updated quite frequently causing the chart to constantly re-render. The
- * re-renders will conflict with mouse interactions (e.g. hovers and tooltips).
- *
- * We need `MemoryChart` (which wraps an `<AreaChart>`) to re-render as
- * infrequently as possible, so we use React.memo and only pass in props that
- * are not frequently updated.
- */
-function MemoryChartContainer() {
-  const {currentTime, currentHoverTime, replay, setCurrentTime, setCurrentHoverTime} =
-    useReplayContext();
-  const chart = useRef<ReactEchartsRef>(null);
-  const theme = useTheme();
-
-  const memoryFrames = replay?.getMemoryFrames();
-  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 (
-    <MemoizedMemoryChart
-      ref={chart}
-      memoryFrames={memoryFrames}
-      setCurrentHoverTime={setCurrentHoverTime}
-      setCurrentTime={setCurrentTime}
-      startTimestampMs={startTimestampMs}
-    />
-  );
-}
-
-export default MemoryChartContainer;

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

@@ -0,0 +1,181 @@
+import {useMemo} from 'react';
+import {useTheme} from '@emotion/react';
+
+import type {AreaChartProps} from 'sentry/components/charts/areaChart';
+import {AreaChart} 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 type {useReplayContext} from 'sentry/components/replays/replayContext';
+import {formatTime} from 'sentry/components/replays/utils';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import type {Series} from 'sentry/types/echarts';
+import {getFormattedDate} from 'sentry/utils/dates';
+import {axisLabelFormatter} from 'sentry/utils/discover/charts';
+import type {DomNodeChartDatapoint} from 'sentry/utils/replays/countDomNodes';
+import toArray from 'sentry/utils/toArray';
+
+interface Props
+  extends Pick<
+    ReturnType<typeof useReplayContext>,
+    'currentTime' | 'currentHoverTime' | 'setCurrentTime' | 'setCurrentHoverTime'
+  > {
+  datapoints: DomNodeChartDatapoint[];
+  durationMs: number;
+  startOffsetMs: number;
+  startTimestampMs: number;
+}
+
+export default function DomNodesChart({
+  currentHoverTime,
+  currentTime,
+  durationMs,
+  datapoints,
+  setCurrentHoverTime,
+  setCurrentTime,
+  startOffsetMs,
+  startTimestampMs,
+}: Props) {
+  const theme = useTheme();
+
+  const chartOptions: Omit<AreaChartProps, 'series'> = {
+    autoHeightResize: true,
+    height: 'auto',
+    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 firstValue = Array.isArray(values) ? values[0] : values;
+        const seriesTooltips = toArray(values).map(
+          value => `
+            <div>
+              <span className="tooltip-label">${value.marker}<strong>${value.seriesName}</strong></span>
+              ${value.data[1]}
+            </div>
+          `
+        );
+
+        return `
+          <div class="tooltip-series">${seriesTooltips.join('')}</div>
+            <div class="tooltip-footer">
+              ${t('Date: %s', getFormattedDate(startOffsetMs + firstValue.axisValue, 'MMM D, YYYY hh:mm:ss A z', {local: false}))}
+            </div>
+            <div class="tooltip-footer" style="border: none;">
+              ${t('Time within replay: %s', formatTime(firstValue.axisValue))}
+            </div>
+          <div class="tooltip-arrow"></div>
+        `;
+      },
+    }),
+    xAxis: XAxis({
+      type: 'time',
+      axisLabel: {
+        formatter: (time: number) => formatTime(time, false),
+      },
+      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]);
+      }
+    },
+    onMouseOut: () => {
+      setCurrentHoverTime(undefined);
+    },
+    onClick: ({data}) => {
+      if (data.value) {
+        setCurrentTime(data.value);
+      }
+    },
+  };
+
+  const staticSeries: Series[] = useMemo(
+    () => [
+      {
+        id: 'nodeCount',
+        seriesName: t('Number of DOM nodes'),
+        data: datapoints.map(d => ({
+          value: d.count,
+          name: d.endTimestampMs - startTimestampMs,
+        })),
+        stack: 'dom-nodes',
+        lineStyle: {opacity: 0, width: 2},
+      },
+      {
+        id: 'replayStart',
+        seriesName: 'Replay Start',
+        data: [{value: 0, name: 0}],
+        lineStyle: {opacity: 0, width: 0},
+      },
+      {
+        id: 'replayEnd',
+        seriesName: 'Replay End',
+        data: [{value: 0, name: durationMs}],
+        lineStyle: {opacity: 0, width: 0},
+      },
+    ],
+    [datapoints, durationMs, startTimestampMs]
+  );
+
+  const dynamicSeries = useMemo(
+    (): Series[] => [
+      {
+        id: 'hoverTime',
+        seriesName: t('Hover player time'),
+        data: [],
+        markLine: {
+          symbol: ['', ''],
+          data: currentHoverTime ? [{xAxis: currentHoverTime}] : [],
+          label: {show: false},
+          lineStyle: {type: 'solid', color: theme.purple200, width: 2},
+        },
+      },
+      {
+        id: 'currentTime',
+        seriesName: t('Current player time'),
+        data: [],
+        markLine: {
+          symbol: ['', ''],
+          data: [{xAxis: currentTime}],
+          label: {show: false},
+          lineStyle: {type: 'solid', color: theme.purple300, width: 2},
+        },
+      },
+    ],
+    [currentHoverTime, currentTime, theme.purple200, theme.purple300]
+  );
+
+  const series = useMemo(
+    () => staticSeries.concat(dynamicSeries),
+    [dynamicSeries, staticSeries]
+  );
+
+  return <AreaChart series={series} {...chartOptions} />;
+}

+ 111 - 0
static/app/views/replays/detail/memoryPanel/index.tsx

@@ -0,0 +1,111 @@
+import {useMemo} from 'react';
+import styled from '@emotion/styled';
+
+import EmptyMessage from 'sentry/components/emptyMessage';
+import Placeholder from 'sentry/components/placeholder';
+import {useReplayContext} from 'sentry/components/replays/replayContext';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import {useQuery} from 'sentry/utils/queryClient';
+import countDomNodes from 'sentry/utils/replays/countDomNodes';
+import type ReplayReader from 'sentry/utils/replays/replayReader';
+import DomNodesChart from 'sentry/views/replays/detail/memoryPanel/domNodesChart';
+import MemoryChart from 'sentry/views/replays/detail/memoryPanel/memoryChart';
+
+function useCountDomNodes({replay}: {replay: null | ReplayReader}) {
+  return useQuery(
+    ['countDomNodes', replay],
+    () =>
+      countDomNodes({
+        frames: replay?.getRRWebMutations(),
+        rrwebEvents: replay?.getRRWebFrames(),
+        startTimestampMs: replay?.getStartTimestampMs() ?? 0,
+      }),
+    {enabled: Boolean(replay), cacheTime: Infinity}
+  );
+}
+
+export default function MemoryPanel() {
+  const {
+    currentTime,
+    currentHoverTime,
+    isFetching,
+    replay,
+    setCurrentHoverTime,
+    setCurrentTime,
+  } = useReplayContext();
+
+  const memoryFrames = replay?.getMemoryFrames();
+
+  const {data: frameToCount} = useCountDomNodes({replay});
+  const domNodeData = useMemo(
+    () => Array.from(frameToCount?.values() || []),
+    [frameToCount]
+  );
+
+  const memoryChart =
+    !replay || isFetching ? (
+      <Placeholder height="100%" />
+    ) : !replay || !memoryFrames?.length ? (
+      <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.'
+        )}
+      />
+    ) : (
+      <MemoryChart
+        currentHoverTime={currentHoverTime}
+        currentTime={currentTime}
+        durationMs={replay.getDurationMs()}
+        memoryFrames={memoryFrames}
+        setCurrentHoverTime={setCurrentHoverTime}
+        setCurrentTime={setCurrentTime}
+        startOffsetMs={replay.getStartOffsetMs()}
+      />
+    );
+
+  const domNodesChart =
+    !replay || isFetching ? (
+      <Placeholder height="100%" />
+    ) : (
+      <DomNodesChart
+        currentHoverTime={currentHoverTime}
+        currentTime={currentTime}
+        durationMs={replay.getDurationMs()}
+        datapoints={domNodeData}
+        setCurrentHoverTime={setCurrentHoverTime}
+        setCurrentTime={setCurrentTime}
+        startOffsetMs={replay.getStartOffsetMs()}
+        startTimestampMs={replay.getStartTimestampMs()}
+      />
+    );
+
+  return (
+    <Grid>
+      <ChartWrapper>{memoryChart}</ChartWrapper>
+      <ChartWrapper>{domNodesChart}</ChartWrapper>
+    </Grid>
+  );
+}
+
+const Grid = styled('div')`
+  display: grid;
+  grid-template-rows: 1fr 1fr;
+  grid-template-columns: 1fr;
+  gap: ${space(1)};
+  justify-content: center;
+  height: 100%;
+`;
+
+const ChartWrapper = styled('div')`
+  border: 1px solid ${p => p.theme.border};
+  border-radius: ${space(0.5)};
+  padding: ${space(1)};
+  overflow: hidden;
+  display: flex;
+  & > * {
+    flex-grow: 1;
+  }
+`;

+ 189 - 0
static/app/views/replays/detail/memoryPanel/memoryChart.tsx

@@ -0,0 +1,189 @@
+import {useMemo} from 'react';
+import {useTheme} from '@emotion/react';
+
+import type {AreaChartProps} from 'sentry/components/charts/areaChart';
+import {AreaChart} 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 type {useReplayContext} from 'sentry/components/replays/replayContext';
+import {formatTime} from 'sentry/components/replays/utils';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import type {Series} from 'sentry/types/echarts';
+import {formatBytesBase2} from 'sentry/utils';
+import {getFormattedDate} from 'sentry/utils/dates';
+import type {MemoryFrame} from 'sentry/utils/replays/types';
+import toArray from 'sentry/utils/toArray';
+
+interface Props
+  extends Pick<
+    ReturnType<typeof useReplayContext>,
+    'currentTime' | 'currentHoverTime' | 'setCurrentTime' | 'setCurrentHoverTime'
+  > {
+  durationMs: number;
+  memoryFrames: MemoryFrame[];
+  startOffsetMs: number;
+}
+
+export default function MemoryChart({
+  currentHoverTime,
+  currentTime,
+  durationMs,
+  memoryFrames,
+  setCurrentHoverTime,
+  setCurrentTime,
+  startOffsetMs,
+}: Props) {
+  const theme = useTheme();
+
+  const chartOptions: Omit<AreaChartProps, 'series'> = useMemo(
+    () => ({
+      autoHeightResize: true,
+      height: 'auto',
+      grid: Grid({
+        // makes space for the title
+        top: '40px',
+        left: space(1),
+        right: space(1),
+      }),
+      tooltip: ChartTooltip({
+        appendToBody: true,
+        trigger: 'axis',
+        renderMode: 'html',
+        chartId: 'replay-memory-chart',
+        formatter: values => {
+          const firstValue = Array.isArray(values) ? values[0] : values;
+          const seriesTooltips = toArray(values).map(
+            value => `
+              <div>
+                <span className="tooltip-label">${value.marker}<strong>${value.seriesName}</strong></span>
+                ${formatBytesBase2(value.data[1])}
+              </div>
+            `
+          );
+          return `
+            <div class="tooltip-series">${seriesTooltips.join('')}</div>
+              <div class="tooltip-footer">
+                ${t('Date: %s', getFormattedDate(startOffsetMs + firstValue.axisValue, 'MMM D, YYYY hh:mm:ss A z', {local: false}))}
+              </div>
+              <div class="tooltip-footer" style="border: none;">
+                ${t('Time within replay: %s', formatTime(firstValue.axisValue))}
+              </div>
+            <div class="tooltip-arrow"></div>
+          `;
+        },
+      }),
+      xAxis: XAxis({
+        type: 'time',
+        axisLabel: {
+          formatter: (time: number) => formatTime(time, false),
+        },
+        theme,
+      }),
+      yAxis: YAxis({
+        type: 'value',
+        name: t('Heap Size'),
+        theme,
+        nameTextStyle: {
+          padding: [8, 8, 8, -25],
+          fontSize: theme.fontSizeLarge,
+          fontWeight: 600,
+          lineHeight: 1.2,
+          fontFamily: theme.text.family,
+          color: theme.gray300,
+        },
+        // input is in bytes, minInterval is a megabyte
+        minInterval: 1024 * 1024,
+        // maxInterval is a terabyte
+        maxInterval: Math.pow(1024, 4),
+        // format the axis labels to be whole number values
+        axisLabel: {
+          formatter: value => formatBytesBase2(value, 0),
+        },
+      }),
+      onMouseOver: ({data}) => {
+        if (data[0]) {
+          setCurrentHoverTime(data[0]);
+        }
+      },
+      onMouseOut: () => {
+        setCurrentHoverTime(undefined);
+      },
+      onClick: ({data}) => {
+        if (data.value) {
+          setCurrentTime(data.value);
+        }
+      },
+    }),
+    [setCurrentHoverTime, setCurrentTime, startOffsetMs, theme]
+  );
+
+  const staticSeries: Series[] = useMemo(
+    () => [
+      {
+        id: 'usedMemory',
+        seriesName: t('Used Heap Memory'),
+        data: memoryFrames.map(frame => ({
+          value: frame.data.memory.usedJSHeapSize,
+          name: frame.offsetMs,
+        })),
+        stack: 'heap-memory',
+        lineStyle: {opacity: 0, width: 2},
+      },
+      {
+        id: 'replayStart',
+        seriesName: 'Replay Start',
+        data: [{value: 0, name: 0}],
+        lineStyle: {opacity: 0, width: 0},
+      },
+      {
+        id: 'replayEnd',
+        seriesName: 'Replay End',
+        data: [{value: 0, name: durationMs}],
+        lineStyle: {opacity: 0, width: 0},
+      },
+    ],
+    [durationMs, memoryFrames]
+  );
+
+  const dynamicSeries = useMemo(
+    (): Series[] => [
+      {
+        id: 'currentTime',
+        seriesName: t('Current player time'),
+        data: [],
+        markLine: {
+          symbol: ['', ''],
+          data: [{xAxis: currentTime}],
+          label: {show: false},
+          lineStyle: {type: 'solid', color: theme.purple300, width: 2},
+        },
+      },
+      {
+        id: 'hoverTime',
+        seriesName: t('Hover player time'),
+        data: [],
+        markLine: {
+          symbol: ['', ''],
+          data: currentHoverTime ? [{xAxis: currentHoverTime}] : [],
+          label: {show: false},
+          lineStyle: {type: 'solid', color: theme.purple200, width: 2},
+        },
+      },
+    ],
+    [currentTime, currentHoverTime, theme.purple200, theme.purple300]
+  );
+
+  const series = useMemo(
+    () => staticSeries.concat(dynamicSeries),
+    [dynamicSeries, staticSeries]
+  );
+
+  return (
+    <div id="replay-memory-chart">
+      <AreaChart autoHeightResize height="auto" series={series} {...chartOptions} />
+    </div>
+  );
+}