Browse Source

feat(replays): added heap memory chart to replay details (#34073)

# Description
Closes #33623 
Added area chart of heap memory usage over the duration of the session.

# Screenshots
<img width="1037" alt="image" src="https://user-images.githubusercontent.com/7014871/165967010-bc7509be-396d-404c-9129-170c2f1f25fc.png">

Full screen light theme:
<img width="1724" alt="image" src="https://user-images.githubusercontent.com/7014871/165967161-ec94836e-a742-4721-b75c-6245f18e6538.png">

When there's no memory events: 
<img width="1724" alt="image" src="https://user-images.githubusercontent.com/7014871/165967238-7dfa5c47-f749-4a11-96cf-4fcea62f00b9.png">
Dublin Anondson 2 years ago
parent
commit
093f25fb03

+ 10 - 0
static/app/components/events/interfaces/spans/types.tsx

@@ -11,6 +11,16 @@ export type GapSpanType = {
   description?: string;
 };
 
+export type MemorySpanType = RawSpanType & {
+  data: {
+    memory: {
+      jsHeapSizeLimit: number;
+      totalJSHeapSize: number;
+      usedJSHeapSize: number;
+    };
+  };
+};
+
 export type RawSpanType = {
   data: Object;
   span_id: string;

+ 11 - 2
static/app/views/replays/detail/focusArea.tsx

@@ -1,6 +1,7 @@
 import React, {useState} from 'react';
 
 import EventEntry from 'sentry/components/events/eventEntry';
+import {MemorySpanType} from 'sentry/components/events/interfaces/spans/types';
 import TagsTable from 'sentry/components/tagsTable';
 import {Event} from 'sentry/types/event';
 import useOrganization from 'sentry/utils/useOrganization';
@@ -9,10 +10,12 @@ import {useRouteContext} from 'sentry/utils/useRouteContext';
 import {TabBarId} from '../types';
 
 import FocusButtons from './focusButtons';
+import MemoryChart from './memoryChart';
 
 type Props = {
   event: Event;
   eventWithSpans: Event | undefined;
+  memorySpans: MemorySpanType[] | undefined;
 };
 
 function FocusArea(props: Props) {
@@ -26,10 +29,14 @@ function FocusArea(props: Props) {
   );
 }
 
-function ActiveTab({active, event, eventWithSpans}: Props & {active: TabBarId}) {
+function ActiveTab({
+  active,
+  event,
+  eventWithSpans,
+  memorySpans,
+}: Props & {active: TabBarId}) {
   const {routes, router} = useRouteContext();
   const organization = useOrganization();
-
   switch (active) {
     case 'console':
       return <div id="console">TODO: Add a console view</div>;
@@ -56,6 +63,8 @@ function ActiveTab({active, event, eventWithSpans}: Props & {active: TabBarId})
           <TagsTable generateUrl={() => ''} event={event} query="" />
         </div>
       );
+    case 'memory':
+      return <MemoryChart memorySpans={memorySpans} />;
     default:
       return null;
   }

+ 5 - 0
static/app/views/replays/detail/focusButtons.tsx

@@ -37,6 +37,11 @@ function FocusButtons({active, setActive}: Props) {
           {t('Tags')}
         </a>
       </li>
+      <li className={active === 'memory' ? 'active' : ''}>
+        <a href="#memory" onClick={select('memory')}>
+          {t('Memory')}
+        </a>
+      </li>
     </NavTabs>
   );
 }

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

@@ -0,0 +1,100 @@
+import styled from '@emotion/styled';
+
+import {AreaChart, AreaChartProps} from 'sentry/components/charts/areaChart';
+import {MemorySpanType} from 'sentry/components/events/interfaces/spans/types';
+import {t} from 'sentry/locale';
+import space from 'sentry/styles/space';
+import {formatBytesBase2} from 'sentry/utils';
+import {getFormattedDate} from 'sentry/utils/dates';
+import theme from 'sentry/utils/theme';
+import EmptyMessage from 'sentry/views/settings/components/emptyMessage';
+
+type Props = {
+  memorySpans: MemorySpanType[] | undefined;
+};
+
+const formatTimestamp = timestamp =>
+  getFormattedDate(timestamp * 1000, 'MMM D, YYYY HH:mm:ss');
+
+function MemoryChart({memorySpans = []}: Props) {
+  if (memorySpans.length <= 0) {
+    return <EmptyMessage>{t('No memory metrics exist for replay.')}</EmptyMessage>;
+  }
+
+  const chartOptions: Omit<AreaChartProps, 'series'> = {
+    grid: {
+      left: '10px',
+      right: '10px',
+      top: '40px',
+      bottom: '10px',
+    },
+    tooltip: {
+      trigger: 'axis',
+      valueFormatter: (value: number | null) => formatBytesBase2(value || 0),
+    },
+    xAxis: {
+      type: 'category',
+      min: formatTimestamp(memorySpans[0]?.timestamp),
+      max: formatTimestamp(memorySpans[memorySpans.length - 1]?.timestamp),
+    },
+    yAxis: {
+      type: 'value',
+      name: t('Heap Size'),
+      nameTextStyle: {
+        padding: 8,
+        fontSize: theme.fontSizeLarge,
+        fontWeight: 600,
+        lineHeight: 1.2,
+        color: theme.gray300,
+      },
+      min: 0,
+      // we don't set a max because we let echarts figure it out for us
+      axisLabel: {
+        formatter: value => formatBytesBase2(value),
+      },
+    },
+  };
+
+  const series = [
+    {
+      seriesName: t('Used Heap Memory'),
+      data: memorySpans.map(span => ({
+        value: span.data.memory.usedJSHeapSize,
+        name: formatTimestamp(span.timestamp),
+      })),
+      stack: 'heap-memory',
+      color: theme.purple300,
+      lineStyle: {
+        opacity: 0.75,
+        width: 1,
+      },
+    },
+    {
+      seriesName: t('Free Heap Memory'),
+      data: memorySpans.map(span => ({
+        value: span.data.memory.totalJSHeapSize - span.data.memory.usedJSHeapSize,
+        name: formatTimestamp(span.timestamp),
+      })),
+      stack: 'heap-memory',
+      color: theme.green300,
+      lineStyle: {
+        opacity: 0.75,
+        width: 1,
+      },
+    },
+  ];
+  return (
+    <MemoryChartWrapper>
+      <AreaChart series={series} {...chartOptions} />
+    </MemoryChartWrapper>
+  );
+}
+
+const MemoryChartWrapper = styled('div')`
+  margin-top: ${space(2)};
+  margin-bottom: ${space(3)};
+  border-radius: ${space(0.5)};
+  border: 1px solid ${p => p.theme.border};
+`;
+
+export default MemoryChart;

+ 6 - 1
static/app/views/replays/details.tsx

@@ -30,6 +30,7 @@ function ReplayDetails() {
     breadcrumbEntry,
     event,
     mergedReplayEvent,
+    memorySpans,
     fetchError,
     fetching,
     onRetry,
@@ -97,7 +98,11 @@ function ReplayDetails() {
           </Layout.Side>
           <Layout.Main fullWidth>
             <BreadcrumbTimeline crumbs={breadcrumbEntry?.data.values || []} />
-            <FocusArea event={event} eventWithSpans={mergedReplayEvent} />
+            <FocusArea
+              event={event}
+              eventWithSpans={mergedReplayEvent}
+              memorySpans={memorySpans}
+            />
           </Layout.Main>
         </Layout.Body>
       </ReplayContextProvider>

+ 1 - 1
static/app/views/replays/types.tsx

@@ -7,4 +7,4 @@ export type Replay = {
   'user.display': string;
 };
 
-export type TabBarId = 'console' | 'performance' | 'errors' | 'tags';
+export type TabBarId = 'console' | 'performance' | 'errors' | 'tags' | 'memory';

+ 16 - 0
static/app/views/replays/utils/useReplayEvent.tsx

@@ -2,6 +2,7 @@ import {useCallback, useEffect, useState} from 'react';
 import * as Sentry from '@sentry/react';
 import type {eventWithTime} from 'rrweb/typings/types';
 
+import {MemorySpanType} from 'sentry/components/events/interfaces/spans/types';
 import {IssueAttachment} from 'sentry/types';
 import {Entry, Event} from 'sentry/types/event';
 import EventView from 'sentry/utils/discover/eventView';
@@ -34,6 +35,8 @@ type State = {
    */
   fetching: boolean;
 
+  memorySpans: undefined | MemorySpanType[];
+
   mergedReplayEvent: undefined | Event;
 
   /**
@@ -86,6 +89,7 @@ function useReplayEvent({eventSlug, location, orgId}: Options): Result {
     replayEvents: undefined,
     rrwebEvents: undefined,
     mergedReplayEvent: undefined,
+    memorySpans: undefined,
   });
 
   function fetchEvent() {
@@ -152,6 +156,7 @@ function useReplayEvent({eventSlug, location, orgId}: Options): Result {
       replayEvents: undefined,
       rrwebEvents: undefined,
       mergedReplayEvent: undefined,
+      memorySpans: undefined,
     });
     try {
       const [event, rrwebEvents, replayEvents] = await Promise.all([
@@ -162,6 +167,14 @@ function useReplayEvent({eventSlug, location, orgId}: Options): Result {
 
       const breadcrumbEntry = mergeBreadcrumbsEntries(replayEvents || [], event);
       const mergedReplayEvent = mergeEventsWithSpans(replayEvents || []);
+      const memorySpans =
+        mergedReplayEvent?.entries[0]?.data?.filter(datum => datum?.data?.memory) || [];
+
+      if (mergedReplayEvent.entries[0]) {
+        mergedReplayEvent.entries[0].data = mergedReplayEvent?.entries[0]?.data?.filter(
+          datum => !datum?.data?.memory
+        );
+      }
 
       setState({
         ...state,
@@ -172,6 +185,7 @@ function useReplayEvent({eventSlug, location, orgId}: Options): Result {
         replayEvents,
         rrwebEvents,
         breadcrumbEntry,
+        memorySpans,
       });
     } catch (error) {
       Sentry.captureException(error);
@@ -184,6 +198,7 @@ function useReplayEvent({eventSlug, location, orgId}: Options): Result {
         replayEvents: undefined,
         rrwebEvents: undefined,
         mergedReplayEvent: undefined,
+        memorySpans: undefined,
       });
     }
   }
@@ -204,6 +219,7 @@ function useReplayEvent({eventSlug, location, orgId}: Options): Result {
     replayEvents: state.replayEvents,
     rrwebEvents: state.rrwebEvents,
     mergedReplayEvent: state.mergedReplayEvent,
+    memorySpans: state.memorySpans,
   };
 }