Browse Source

feat(profiling): profile details page aggregate views (#40460)

## Summary
This PR introduces new aggregated views to the Profile Details page;
- All Functions (renamed from slowest functions) 
  - Symbol, Package, File, Thread, Type, Self Weight, Total Weight
- Group by Symbol
  -   Symbol, Type, Package, p75(Self Weight), p95(Self Weight), Count 
- Group by Package
  -   Package, Type,  p75(Self Weight), p95(Self Weight), Count  
- Group by File
  -   File, Type, Package,  p75(Self Weight), p95(Self Weight), Count  

Notes for reviewers:
- `slowestFunctions` moved to `profileDetailsTable` but its not diffed;
i'm guessing `git` treated it as a new file since it was a large change
- `profileDetailsTable` was decomposed into a number of smaller hooks to
make it a bit easier to reason about
- some of these hooks can be moved to a more shareable place; unless
theres a strong opinion on this, I prefer to leave them co-located and
move them as necessary, there will be more work to this in following
PRs.


What's missing:
- linking back to flamechart only works from the `All Functions` view as
this existed before; i will follow up with another PR

Tracking here: https://github.com/getsentry/team-profiling/issues/61
Elias Hussary 2 years ago
parent
commit
34d0b86688

+ 13 - 3
static/app/components/gridEditable/index.tsx

@@ -100,8 +100,12 @@ type GridEditableProps<DataRow, ColumnKey> = {
    * in these buttons and updating props to the GridEditable instance.
    */
   headerButtons?: () => React.ReactNode;
+  height?: string | number;
   isLoading?: boolean;
 
+  scrollable?: boolean;
+  stickyHeader?: boolean;
+
   /**
    * GridEditable (mostly) do not maintain any internal state and relies on the
    * parent component to tell it how/what to render and will mutate the view
@@ -301,7 +305,7 @@ class GridEditable<
   }
 
   renderGridHead() {
-    const {error, isLoading, columnOrder, grid, data} = this.props;
+    const {error, isLoading, columnOrder, grid, data, stickyHeader} = this.props;
 
     // Ensure that the last column cannot be removed
     const numColumn = columnOrder.length;
@@ -326,6 +330,7 @@ class GridEditable<
               data-test-id="grid-head-cell"
               key={`${i}.${column.key}`}
               isFirst={i === 0}
+              sticky={stickyHeader}
             >
               {grid.renderHeadCell ? grid.renderHeadCell(column, i) : column.name}
               {i !== numColumn - 1 && (
@@ -419,7 +424,7 @@ class GridEditable<
   }
 
   render() {
-    const {title, headerButtons} = this.props;
+    const {title, headerButtons, scrollable, height} = this.props;
     const showHeader = title || headerButtons;
     return (
       <Fragment>
@@ -433,7 +438,12 @@ class GridEditable<
             </Header>
           )}
           <Body>
-            <Grid data-test-id="grid-editable" ref={this.refGrid}>
+            <Grid
+              data-test-id="grid-editable"
+              scrollable={scrollable}
+              height={height}
+              ref={this.refGrid}
+            >
               <GridHead>{this.renderGridHead()}</GridHead>
               <GridBody>{this.renderGridBody()}</GridBody>
             </Grid>

+ 12 - 4
static/app/components/gridEditable/styles.tsx

@@ -68,20 +68,26 @@ export const Body = styled(({children, ...props}) => (
  * <thead>, <tbody>, <tr> are ignored by CSS Grid.
  * The entire layout is determined by the usage of <th> and <td>.
  */
-export const Grid = styled('table')`
+export const Grid = styled('table')<{height?: string | number; scrollable?: boolean}>`
   position: inherit;
   display: grid;
 
   /* Overwritten by GridEditable.setGridTemplateColumns */
   grid-template-columns: repeat(auto-fill, minmax(50px, auto));
-
   box-sizing: border-box;
   border-collapse: collapse;
   margin: 0;
 
   z-index: ${Z_INDEX_GRID};
   overflow-x: auto;
-  overflow-y: hidden;
+  overflow-y: ${p => (p.scrollable ? 'scroll' : 'hidden')};
+  ${p =>
+    p.height
+      ? `
+      height: 100%;
+      max-height: ${typeof p.height === 'number' ? p.height + 'px' : p.height}
+      `
+      : ''}
 `;
 
 export const GridRow = styled('tr')`
@@ -104,7 +110,7 @@ export const GridHead = styled('thead')`
   display: contents;
 `;
 
-export const GridHeadCell = styled('th')<{isFirst: boolean}>`
+export const GridHeadCell = styled('th')<{isFirst: boolean; sticky?: boolean}>`
   /* By default, a grid item cannot be smaller than the size of its content.
      We override this by setting min-width to be 0. */
   position: relative; /* Used by GridResizer */
@@ -124,6 +130,8 @@ export const GridHeadCell = styled('th')<{isFirst: boolean}>`
   text-transform: uppercase;
   user-select: none;
 
+  ${p => (p.sticky ? `position: sticky; top: 0;` : '')}
+
   a,
   div,
   span {

+ 9 - 1
static/app/components/searchBar.tsx

@@ -1,4 +1,4 @@
-import {useCallback, useRef, useState} from 'react';
+import {useCallback, useEffect, useRef, useState} from 'react';
 import styled from '@emotion/styled';
 
 import Button from 'sentry/components/button';
@@ -36,6 +36,14 @@ function SearchBar({
 
   const [query, setQuery] = useState(queryProp ?? defaultQuery);
 
+  // if query prop keeps changing we should treat this as
+  // a controlled component and its internal state should be in sync
+  useEffect(() => {
+    if (typeof queryProp === 'string') {
+      setQuery(queryProp);
+    }
+  }, [queryProp]);
+
   const onQueryChange = useCallback(
     (e: React.ChangeEvent<HTMLInputElement>) => {
       const {value} = e.target;

+ 1 - 1
static/app/utils/profiling/profile/importProfile.spec.tsx

@@ -8,7 +8,7 @@ import {JSSelfProfile} from 'sentry/utils/profiling/profile/jsSelfProfile';
 import {SampledProfile} from 'sentry/utils/profiling/profile/sampledProfile';
 
 import {SentrySampledProfile} from './sentrySampledProfile';
-import {makeSentrySampledProfile} from './sentrySampledProfile.spec';
+import {makeSentrySampledProfile} from './sentrySampledProfile.specutil';
 
 describe('importProfile', () => {
   it('imports evented profile', () => {

+ 1 - 62
static/app/utils/profiling/profile/sentrySampledProfile.spec.tsx

@@ -1,69 +1,8 @@
-import merge from 'lodash/merge';
-
-import {DeepPartial} from 'sentry/types/utils';
-
 import {makeTestingBoilerplate} from './profile.spec';
 import {SentrySampledProfile} from './sentrySampledProfile';
+import {makeSentrySampledProfile} from './sentrySampledProfile.specutil';
 import {createSentrySampleProfileFrameIndex} from './utils';
 
-export const makeSentrySampledProfile = (
-  profile?: DeepPartial<Profiling.SentrySampledProfile>
-) => {
-  return merge(
-    {
-      event_id: '1',
-      version: '1',
-      os: {
-        name: 'iOS',
-        version: '16.0',
-        build_number: '19H253',
-      },
-      device: {
-        architecture: 'arm64e',
-        is_emulator: false,
-        locale: 'en_US',
-        manufacturer: 'Apple',
-        model: 'iPhone14,3',
-      },
-      timestamp: '2022-09-01T09:45:00.000Z',
-      release: '0.1 (199)',
-      platform: 'cocoa',
-      profile: {
-        samples: [
-          {
-            stack_id: 0,
-            thread_id: '0',
-            elapsed_since_start_ns: '0',
-          },
-          {
-            stack_id: 1,
-            thread_id: '0',
-            elapsed_since_start_ns: '1000',
-          },
-        ],
-        frames: [
-          {
-            function: 'foo',
-            instruction_addr: '',
-            lineno: 2,
-            colno: 2,
-            file: 'main.c',
-          },
-          {
-            function: 'main',
-            instruction_addr: '',
-            lineno: 1,
-            colno: 1,
-            file: 'main.c',
-          },
-        ],
-        stacks: [[0], [0, 1]],
-      },
-    },
-    profile
-  ) as Profiling.SentrySampledProfile;
-};
-
 describe('SentrySampledProfile', () => {
   it('constructs a profile', () => {
     const sampledProfile: Profiling.SentrySampledProfile = makeSentrySampledProfile();

+ 61 - 0
static/app/utils/profiling/profile/sentrySampledProfile.specutil.ts

@@ -0,0 +1,61 @@
+import merge from 'lodash/merge';
+
+import {DeepPartial} from 'sentry/types/utils';
+
+export const makeSentrySampledProfile = (
+  profile?: DeepPartial<Profiling.SentrySampledProfile>
+) => {
+  return merge(
+    {
+      event_id: '1',
+      version: '1',
+      os: {
+        name: 'iOS',
+        version: '16.0',
+        build_number: '19H253',
+      },
+      device: {
+        architecture: 'arm64e',
+        is_emulator: false,
+        locale: 'en_US',
+        manufacturer: 'Apple',
+        model: 'iPhone14,3',
+      },
+      timestamp: '2022-09-01T09:45:00.000Z',
+      release: '0.1 (199)',
+      platform: 'cocoa',
+      profile: {
+        samples: [
+          {
+            stack_id: 0,
+            thread_id: '0',
+            elapsed_since_start_ns: '0',
+          },
+          {
+            stack_id: 1,
+            thread_id: '0',
+            elapsed_since_start_ns: '1000',
+          },
+        ],
+        frames: [
+          {
+            function: 'foo',
+            instruction_addr: '',
+            lineno: 2,
+            colno: 2,
+            file: 'main.c',
+          },
+          {
+            function: 'main',
+            instruction_addr: '',
+            lineno: 1,
+            colno: 1,
+            file: 'main.c',
+          },
+        ],
+        stacks: [[0], [0, 1]],
+      },
+    },
+    profile
+  ) as Profiling.SentrySampledProfile;
+};

+ 0 - 488
static/app/views/profiling/profileDetails.tsx

@@ -1,488 +0,0 @@
-import {Fragment, useCallback, useEffect, useMemo, useState} from 'react';
-import {browserHistory, Link} from 'react-router';
-import styled from '@emotion/styled';
-import Fuse from 'fuse.js';
-import * as qs from 'query-string';
-
-import CompactSelect from 'sentry/components/compactSelect';
-import GridEditable, {
-  COL_WIDTH_UNDEFINED,
-  GridColumnOrder,
-  GridColumnSortBy,
-} from 'sentry/components/gridEditable';
-import * as Layout from 'sentry/components/layouts/thirds';
-import Pagination from 'sentry/components/pagination';
-import SearchBar from 'sentry/components/searchBar';
-import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
-import {t} from 'sentry/locale';
-import space from 'sentry/styles/space';
-import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
-import {Container, NumberContainer} from 'sentry/utils/discover/styles';
-import {CallTreeNode} from 'sentry/utils/profiling/callTreeNode';
-import {Profile} from 'sentry/utils/profiling/profile/profile';
-import {generateProfileFlamechartRouteWithQuery} from 'sentry/utils/profiling/routes';
-import {renderTableHead} from 'sentry/utils/profiling/tableRenderer';
-import {makeFormatter} from 'sentry/utils/profiling/units/units';
-import {decodeScalar} from 'sentry/utils/queryString';
-import {useEffectAfterFirstRender} from 'sentry/utils/useEffectAfterFirstRender';
-import {useLocation} from 'sentry/utils/useLocation';
-import useOrganization from 'sentry/utils/useOrganization';
-import {useParams} from 'sentry/utils/useParams';
-
-import {useProfileGroup} from './profileGroupProvider';
-
-function collectTopProfileFrames(profile: Profile) {
-  const nodes: CallTreeNode[] = [];
-
-  profile.forEach(
-    node => {
-      if (node.selfWeight > 0) {
-        nodes.push(node);
-      }
-    },
-    () => {}
-  );
-
-  return (
-    nodes
-      .sort((a, b) => b.selfWeight - a.selfWeight)
-      // take only the slowest nodes from each thread because the rest
-      // aren't useful to display
-      .slice(0, 500)
-      .map(node => ({
-        symbol: node.frame.name,
-        image: node.frame.image,
-        thread: profile.threadId,
-        type: node.frame.is_application ? 'application' : 'system',
-        'self weight': node.selfWeight,
-        'total weight': node.totalWeight,
-      }))
-  );
-}
-
-const RESULTS_PER_PAGE = 50;
-
-function ProfileDetails() {
-  const location = useLocation();
-  const [state] = useProfileGroup();
-  const organization = useOrganization();
-
-  useEffect(() => {
-    trackAdvancedAnalyticsEvent('profiling_views.profile_summary', {
-      organization,
-    });
-  }, [organization]);
-
-  const cursor = useMemo<number>(() => {
-    const cursorQuery = decodeScalar(location.query.cursor, '');
-    return parseInt(cursorQuery, 10) || 0;
-  }, [location.query.cursor]);
-
-  const query = useMemo<string>(() => decodeScalar(location.query.query, ''), [location]);
-
-  const allFunctions: TableDataRow[] = useMemo(() => {
-    return state.type === 'resolved'
-      ? state.data.profiles
-          .flatMap(collectTopProfileFrames)
-          // Self weight desc sort
-          .sort((a, b) => b['self weight'] - a['self weight'])
-      : [];
-  }, [state]);
-
-  const searchIndex = useMemo(() => {
-    return new Fuse(allFunctions, {
-      keys: ['symbol'],
-      threshold: 0.3,
-    });
-  }, [allFunctions]);
-
-  const search = useCallback(
-    (queryString: string) => {
-      if (!queryString) {
-        return allFunctions;
-      }
-      return searchIndex.search(queryString).map(result => result.item);
-    },
-    [searchIndex, allFunctions]
-  );
-
-  const [slowestFunctions, setSlowestFunctions] = useState<TableDataRow[]>(() => {
-    return search(query);
-  });
-
-  useEffectAfterFirstRender(() => {
-    setSlowestFunctions(search(query));
-  }, [allFunctions, query, search]);
-
-  const pageLinks = useMemo(() => {
-    const prevResults = cursor >= RESULTS_PER_PAGE ? 'true' : 'false';
-    const prevCursor = cursor >= RESULTS_PER_PAGE ? cursor - RESULTS_PER_PAGE : 0;
-    const prevQuery = {...location.query, cursor: prevCursor};
-    const prevHref = `${location.pathname}${qs.stringify(prevQuery)}`;
-    const prev = `<${prevHref}>; rel="previous"; results="${prevResults}"; cursor="${prevCursor}"`;
-
-    const nextResults =
-      cursor + RESULTS_PER_PAGE < slowestFunctions.length ? 'true' : 'false';
-    const nextCursor =
-      cursor + RESULTS_PER_PAGE < slowestFunctions.length ? cursor + RESULTS_PER_PAGE : 0;
-    const nextQuery = {...location.query, cursor: nextCursor};
-    const nextHref = `${location.pathname}${qs.stringify(nextQuery)}`;
-    const next = `<${nextHref}>; rel="next"; results="${nextResults}"; cursor="${nextCursor}"`;
-
-    return `${prev},${next}`;
-  }, [cursor, location, slowestFunctions]);
-
-  const handleSearch = useCallback(
-    searchString => {
-      browserHistory.replace({
-        ...location,
-        query: {
-          ...location.query,
-          query: searchString,
-          cursor: undefined,
-        },
-      });
-
-      setSlowestFunctions(search(searchString));
-    },
-    [location, search]
-  );
-
-  const [filters, setFilters] = useState<Partial<Record<TableColumnKey, string[]>>>({});
-
-  const columnFilters = useMemo(() => {
-    function makeOnFilterChange(key: string) {
-      return values => {
-        setFilters(prevFilters => ({
-          ...prevFilters,
-          [key]: values.length > 0 ? values.map(val => val.value) : undefined,
-        }));
-      };
-    }
-    return {
-      type: {
-        values: ['application', 'system'],
-        onChange: makeOnFilterChange('type'),
-      },
-      image: {
-        values: pluckUniqueValues(slowestFunctions, 'image').sort((a, b) =>
-          a.localeCompare(b)
-        ),
-        onChange: makeOnFilterChange('image'),
-      },
-    };
-  }, [slowestFunctions]);
-
-  const currentSort = useMemo<GridColumnSortBy<TableColumnKey>>(() => {
-    let key = location.query?.functionsSort ?? '';
-
-    const defaultSort = {
-      key: 'self weight',
-      order: 'desc',
-    } as GridColumnSortBy<TableColumnKey>;
-
-    const isDesc = key[0] === '-';
-    if (isDesc) {
-      key = key.slice(1);
-    }
-
-    if (!key || !tableColumnKey.includes(key as TableColumnKey)) {
-      return defaultSort;
-    }
-
-    return {
-      key,
-      order: isDesc ? 'desc' : 'asc',
-    } as GridColumnSortBy<TableColumnKey>;
-  }, [location.query]);
-
-  useEffect(() => {
-    const removeListener = browserHistory.listenBefore((nextLocation, next) => {
-      if (location.pathname === nextLocation.pathname) {
-        next(nextLocation);
-        return;
-      }
-
-      if ('functionsSort' in nextLocation.query) {
-        delete nextLocation.query.functionsSort;
-      }
-
-      next(nextLocation);
-    });
-
-    return removeListener;
-  });
-
-  const generateSortLink = useCallback(
-    (column: TableColumnKey) => {
-      if (!SORTABLE_COLUMNS.has(column)) {
-        return () => undefined;
-      }
-      if (!currentSort) {
-        return () => ({
-          ...location,
-          query: {
-            ...location.query,
-            functionsSort: column,
-          },
-        });
-      }
-
-      const direction =
-        currentSort.key !== column
-          ? 'desc'
-          : currentSort.order === 'desc'
-          ? 'asc'
-          : 'desc';
-
-      return () => ({
-        ...location,
-        query: {
-          ...location.query,
-          functionsSort: `${direction === 'desc' ? '-' : ''}${column}`,
-        },
-      });
-    },
-    [location, currentSort]
-  );
-
-  const data = slowestFunctions
-    .filter(row => {
-      let include = true;
-      for (const key in filters) {
-        const values = filters[key];
-        if (!values) {
-          continue;
-        }
-        include = values.includes(row[key]);
-        if (!include) {
-          return false;
-        }
-      }
-      return include;
-    })
-    .sort((a, b) => {
-      if (currentSort.order === 'asc') {
-        return a[currentSort.key] - b[currentSort.key];
-      }
-      return b[currentSort.key] - a[currentSort.key];
-    })
-    .slice(cursor, cursor + RESULTS_PER_PAGE);
-
-  return (
-    <Fragment>
-      <SentryDocumentTitle
-        title={t('Profiling \u2014 Details')}
-        orgSlug={organization.slug}
-      >
-        <Layout.Body>
-          <Layout.Main fullWidth>
-            <ActionBar>
-              <SearchBar
-                defaultQuery=""
-                query={query}
-                placeholder={t('Search for frames')}
-                onChange={handleSearch}
-              />
-
-              <CompactSelect
-                options={columnFilters.type.values.map(value => ({value, label: value}))}
-                value={filters.type}
-                triggerLabel={
-                  !filters.type ||
-                  (Array.isArray(filters.type) &&
-                    filters.type.length === columnFilters.type.values.length)
-                    ? t('All')
-                    : undefined
-                }
-                triggerProps={{
-                  prefix: t('Type'),
-                }}
-                multiple
-                onChange={columnFilters.type.onChange}
-                placement="bottom right"
-              />
-
-              <CompactSelect
-                options={columnFilters.image.values.map(value => ({value, label: value}))}
-                value={filters.image}
-                triggerLabel={
-                  !filters.image ||
-                  (Array.isArray(filters.image) &&
-                    filters.image.length === columnFilters.image.values.length)
-                    ? t('All')
-                    : undefined
-                }
-                triggerProps={{
-                  prefix: t('Package'),
-                }}
-                multiple
-                onChange={columnFilters.image.onChange}
-                placement="bottom right"
-              />
-            </ActionBar>
-
-            <GridEditable
-              title={t('Slowest Functions')}
-              isLoading={state.type === 'loading'}
-              error={state.type === 'errored'}
-              data={data}
-              columnOrder={COLUMN_ORDER.map(key => COLUMNS[key])}
-              columnSortBy={[currentSort]}
-              grid={{
-                renderHeadCell: renderTableHead({
-                  rightAlignedColumns: RIGHT_ALIGNED_COLUMNS,
-                  sortableColumns: RIGHT_ALIGNED_COLUMNS,
-                  currentSort,
-                  generateSortLink,
-                }),
-                renderBodyCell: renderFunctionCell,
-              }}
-              location={location}
-            />
-
-            <Pagination pageLinks={pageLinks} />
-          </Layout.Main>
-        </Layout.Body>
-      </SentryDocumentTitle>
-    </Fragment>
-  );
-}
-
-function pluckUniqueValues<T extends Record<string, any>>(collection: T[], key: keyof T) {
-  return collection.reduce((acc, val) => {
-    if (!acc.includes(val[key])) {
-      acc.push(val[key]);
-    }
-    return acc;
-  }, [] as string[]);
-}
-
-const RIGHT_ALIGNED_COLUMNS = new Set<TableColumnKey>(['self weight', 'total weight']);
-const SORTABLE_COLUMNS = new Set<TableColumnKey>(['self weight', 'total weight']);
-
-const ActionBar = styled('div')`
-  display: grid;
-  grid-template-columns: 1fr auto auto;
-  gap: ${space(2)};
-  margin-bottom: ${space(2)};
-`;
-
-function renderFunctionCell(
-  column: TableColumn,
-  dataRow: TableDataRow,
-  rowIndex: number,
-  columnIndex: number
-) {
-  return (
-    <ProfilingFunctionsTableCell
-      column={column}
-      dataRow={dataRow}
-      rowIndex={rowIndex}
-      columnIndex={columnIndex}
-    />
-  );
-}
-
-interface ProfilingFunctionsTableCellProps {
-  column: TableColumn;
-  columnIndex: number;
-  dataRow: TableDataRow;
-  rowIndex: number;
-}
-
-const formatter = makeFormatter('nanoseconds');
-function ProfilingFunctionsTableCell({
-  column,
-  dataRow,
-}: ProfilingFunctionsTableCellProps) {
-  const value = dataRow[column.key];
-  const {orgId, projectId, eventId} = useParams();
-
-  switch (column.key) {
-    case 'self weight':
-      return <NumberContainer>{formatter(value)}</NumberContainer>;
-    case 'total weight':
-      return <NumberContainer>{formatter(value)}</NumberContainer>;
-    case 'image':
-      return <Container>{value ?? 'Unknown'}</Container>;
-    case 'thread': {
-      return (
-        <Container>
-          <Link
-            to={generateProfileFlamechartRouteWithQuery({
-              orgSlug: orgId,
-              projectSlug: projectId,
-              profileId: eventId,
-              query: {tid: dataRow.thread},
-            })}
-          >
-            {value}
-          </Link>
-        </Container>
-      );
-    }
-    default:
-      return <Container>{value}</Container>;
-  }
-}
-
-const tableColumnKey = [
-  'symbol',
-  'image',
-  'thread',
-  'type',
-  'self weight',
-  'total weight',
-] as const;
-
-type TableColumnKey = typeof tableColumnKey[number];
-
-type TableDataRow = Record<TableColumnKey, any>;
-
-type TableColumn = GridColumnOrder<TableColumnKey>;
-
-const COLUMN_ORDER: TableColumnKey[] = [
-  'symbol',
-  'image',
-  'thread',
-  'type',
-  'self weight',
-  'total weight',
-];
-
-// TODO: looks like these column names change depending on the platform?
-const COLUMNS: Record<TableColumnKey, TableColumn> = {
-  symbol: {
-    key: 'symbol',
-    name: t('Symbol'),
-    width: COL_WIDTH_UNDEFINED,
-  },
-  image: {
-    key: 'image',
-    name: t('Package'),
-    width: COL_WIDTH_UNDEFINED,
-  },
-  thread: {
-    key: 'thread',
-    name: t('Thread'),
-    width: COL_WIDTH_UNDEFINED,
-  },
-  type: {
-    key: 'type',
-    name: t('Type'),
-    width: COL_WIDTH_UNDEFINED,
-  },
-  'self weight': {
-    key: 'self weight',
-    name: t('Self Weight'),
-    width: COL_WIDTH_UNDEFINED,
-  },
-  'total weight': {
-    key: 'total weight',
-    name: t('Total Weight'),
-    width: COL_WIDTH_UNDEFINED,
-  },
-};
-
-export default ProfileDetails;

+ 81 - 0
static/app/views/profiling/profileDetails/components/profileDetailsTable.spec.tsx

@@ -0,0 +1,81 @@
+import {initializeOrg} from 'sentry-test/initializeOrg';
+import {render, screen, userEvent, within} from 'sentry-test/reactTestingLibrary';
+
+import {RequestState} from 'sentry/types';
+import {importProfile, ProfileGroup} from 'sentry/utils/profiling/profile/importProfile';
+import {makeSentrySampledProfile} from 'sentry/utils/profiling/profile/sentrySampledProfile.specutil';
+
+import * as profileGroupProviderMod from '../../profileGroupProvider';
+
+import {ProfileDetailsTable} from './profileDetailsTable';
+
+const {routerContext} = initializeOrg();
+
+jest.mock('../../profileGroupProvider', () => {
+  return {
+    useProfileGroup: jest.fn(),
+  };
+});
+
+const useProfileGroupSpy = jest.spyOn(profileGroupProviderMod, 'useProfileGroup');
+
+const mockUseProfileData: RequestState<ProfileGroup> = {
+  type: 'resolved',
+  data: importProfile(makeSentrySampledProfile(), ''),
+};
+useProfileGroupSpy.mockImplementation(() => [mockUseProfileData, () => {}]);
+
+function assertTableHeaders(headerText: string[]) {
+  const gridHeadRow = screen.getByTestId('grid-head-row');
+  headerText.forEach(txt => {
+    expect(within(gridHeadRow).getByText(txt)).toBeInTheDocument();
+  });
+}
+
+async function selectView(selection: string) {
+  const dropdownSelect = screen.getByText('View');
+  userEvent.click(dropdownSelect);
+  // attempt to select option from the select option list
+  // not what is being shown as active selection
+  const option = await screen.findAllByText(selection);
+  userEvent.click(option[1] ?? option[0]);
+}
+
+describe('profileDetailsTable', () => {
+  it.each([
+    {
+      view: 'Slowest Functions',
+      tableHeaders: [
+        'Symbol',
+        'Package',
+        'File',
+        'Thread',
+        'Type',
+        'Self Weight',
+        'Total Weight',
+      ],
+    },
+    {
+      view: 'Group by Symbol',
+      tableHeaders: ['Symbol', 'Type', 'Package', 'P75(Self)', 'P95(Self)', 'Count'],
+    },
+    {
+      view: 'Group by Package',
+      tableHeaders: ['Package', 'Type', 'P75(Self)', 'P95(Self)', 'Count'],
+    },
+    {
+      view: 'Group by File',
+      tableHeaders: ['File', 'Type', 'P75(Self)', 'P95(Self)', 'Count'],
+    },
+  ])('renders the "$view" view', async ({view, tableHeaders}) => {
+    render(<ProfileDetailsTable />, {
+      context: routerContext,
+    });
+
+    await selectView(view);
+
+    expect(screen.getByTestId('grid-editable')).toBeInTheDocument();
+    expect(screen.getByText(view)).toBeInTheDocument();
+    assertTableHeaders(tableHeaders);
+  });
+});

+ 506 - 0
static/app/views/profiling/profileDetails/components/profileDetailsTable.tsx

@@ -0,0 +1,506 @@
+import {Fragment, useCallback, useEffect, useMemo, useState} from 'react';
+import {Link} from 'react-router';
+import styled from '@emotion/styled';
+import debounce from 'lodash/debounce';
+
+import CompactSelect from 'sentry/components/compactSelect';
+import GridEditable, {
+  COL_WIDTH_UNDEFINED,
+  GridColumnOrder,
+} from 'sentry/components/gridEditable';
+import Pagination from 'sentry/components/pagination';
+import SearchBar from 'sentry/components/searchBar';
+import {t} from 'sentry/locale';
+import space from 'sentry/styles/space';
+import {Container, NumberContainer} from 'sentry/utils/discover/styles';
+import {generateProfileFlamechartRouteWithQuery} from 'sentry/utils/profiling/routes';
+import {renderTableHead} from 'sentry/utils/profiling/tableRenderer';
+import {makeFormatter} from 'sentry/utils/profiling/units/units';
+import {useEffectAfterFirstRender} from 'sentry/utils/useEffectAfterFirstRender';
+import {useLocation} from 'sentry/utils/useLocation';
+import {useParams} from 'sentry/utils/useParams';
+
+import {useProfileGroup} from '../../profileGroupProvider';
+import {useColumnFilters} from '../hooks/useColumnFilters';
+import {useFuseSearch} from '../hooks/useFuseSearch';
+import {usePageLinks} from '../hooks/usePageLinks';
+import {useQuerystringState} from '../hooks/useQuerystringState';
+import {useSortableColumns} from '../hooks/useSortableColumn';
+import {aggregate, AggregateColumnConfig, collectProfileFrames} from '../utils';
+
+const RESULTS_PER_PAGE = 50;
+
+export function ProfileDetailsTable() {
+  const location = useLocation();
+  const [state] = useProfileGroup();
+  const [groupByViewKey, setGroupByView] = useQuerystringState({
+    key: 'detailView',
+    initialState: 'occurrence',
+  });
+
+  const [searchQuery, setSearchQuery] = useQuerystringState({
+    key: 'query',
+    initialState: '',
+  });
+
+  const [paginationCursor, setPaginationCursor] = useQuerystringState({
+    key: 'cursor',
+    initialState: '',
+  });
+
+  const groupByView = GROUP_BY_OPTIONS[groupByViewKey] ?? GROUP_BY_OPTIONS.occurrence;
+
+  const cursor = parseInt(paginationCursor, 10) || 0;
+
+  const allData: TableDataRow[] = useMemo(() => {
+    const data =
+      state.type === 'resolved' ? state.data.profiles.flatMap(collectProfileFrames) : [];
+
+    return groupByView.transform(data);
+  }, [state, groupByView]);
+
+  const {search} = useFuseSearch(allData, {
+    keys: groupByView.search.key,
+    threshold: 0.3,
+  });
+
+  const debouncedSearch = useMemo(
+    () => debounce(searchString => setFilteredDataBySearch(search(searchString)), 500),
+    [search]
+  );
+
+  const [filteredDataBySearch, setFilteredDataBySearch] = useState<TableDataRow[]>(() => {
+    return search(searchQuery);
+  });
+
+  const [typeFilter, setTypeFilter] = useQuerystringState({
+    key: 'type',
+  });
+
+  const [imageFilter, setImageFilter] = useQuerystringState({
+    key: 'image',
+  });
+
+  const {filters, columnFilters, filterPredicate} = useColumnFilters(allData, {
+    columns: ['type', 'image'],
+    initialState: {
+      type: typeFilter,
+      image: imageFilter,
+    },
+  });
+
+  useEffect(() => {
+    setTypeFilter(filters.type);
+    setImageFilter(filters.image);
+  }, [filters, setTypeFilter, setImageFilter]);
+
+  const {currentSort, generateSortLink, sortCompareFn} = useSortableColumns({
+    ...groupByView.sort,
+    querystringKey: 'functionsSort',
+  });
+
+  const handleSearch = useCallback(
+    searchString => {
+      setSearchQuery(searchString);
+      setPaginationCursor(undefined);
+      debouncedSearch(searchString);
+    },
+    [setPaginationCursor, setSearchQuery, debouncedSearch]
+  );
+
+  useEffectAfterFirstRender(() => {
+    setFilteredDataBySearch(search(searchQuery));
+    // purposely omitted `searchQuery` as we only want this to run once.
+    // future search filters are called by handleSearch
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [allData, search]);
+
+  const filteredData = useMemo(
+    () => filteredDataBySearch.filter(filterPredicate),
+    [filterPredicate, filteredDataBySearch]
+  );
+
+  const sortedData = useMemo(
+    () => filteredData.sort(sortCompareFn),
+    [filteredData, sortCompareFn]
+  );
+
+  const pageLinks = usePageLinks(sortedData, cursor);
+
+  const data = sortedData.slice(cursor, cursor + RESULTS_PER_PAGE);
+
+  return (
+    <Fragment>
+      <ActionBar>
+        <CompactSelect
+          options={Object.values(GROUP_BY_OPTIONS).map(view => view.option)}
+          value={groupByView.option.value}
+          triggerProps={{
+            prefix: t('View'),
+          }}
+          placement="bottom right"
+          onChange={option => {
+            setSearchQuery('');
+            setPaginationCursor(undefined);
+            setGroupByView(option.value);
+          }}
+        />
+        <SearchBar
+          defaultQuery=""
+          query={searchQuery}
+          placeholder={groupByView.search.placeholder}
+          onChange={handleSearch}
+        />
+
+        <CompactSelect
+          options={columnFilters.type.values.map(value => ({value, label: value}))}
+          value={filters.type}
+          triggerLabel={
+            !filters.type ||
+            (Array.isArray(filters.type) &&
+              filters.type.length === columnFilters.type.values.length)
+              ? t('All')
+              : undefined
+          }
+          triggerProps={{
+            prefix: t('Type'),
+          }}
+          multiple
+          onChange={columnFilters.type.onChange}
+          placement="bottom right"
+        />
+        <CompactSelect
+          options={columnFilters.image.values.map(value => ({value, label: value}))}
+          value={filters.image}
+          triggerLabel={
+            !filters.image ||
+            (Array.isArray(filters.image) &&
+              filters.image.length === columnFilters.image.values.length)
+              ? t('All')
+              : undefined
+          }
+          triggerProps={{
+            prefix: t('Package'),
+          }}
+          multiple
+          onChange={columnFilters.image.onChange}
+          placement="bottom right"
+          isSearchable
+        />
+      </ActionBar>
+
+      <GridEditable
+        isLoading={state.type === 'loading'}
+        error={state.type === 'errored'}
+        data={data}
+        columnOrder={groupByView.columns.map(key => COLUMNS[key])}
+        columnSortBy={[currentSort]}
+        scrollable
+        stickyHeader
+        height="75vh"
+        grid={{
+          renderHeadCell: renderTableHead({
+            rightAlignedColumns: new Set(groupByView.rightAlignedColumns),
+            sortableColumns: new Set(groupByView.rightAlignedColumns),
+            currentSort,
+            generateSortLink,
+          }),
+          renderBodyCell: renderFunctionCell,
+        }}
+        location={location}
+      />
+
+      <Pagination pageLinks={pageLinks} />
+    </Fragment>
+  );
+}
+
+const ActionBar = styled('div')`
+  display: grid;
+  grid-template-columns: auto 1fr auto auto;
+  gap: ${space(2)};
+  margin-bottom: ${space(2)};
+`;
+
+function renderFunctionCell(
+  column: TableColumn,
+  dataRow: TableDataRow,
+  rowIndex: number,
+  columnIndex: number
+) {
+  return (
+    <ProfilingFunctionsTableCell
+      column={column}
+      dataRow={dataRow}
+      rowIndex={rowIndex}
+      columnIndex={columnIndex}
+    />
+  );
+}
+
+interface ProfilingFunctionsTableCellProps {
+  column: TableColumn;
+  columnIndex: number;
+  dataRow: TableDataRow;
+  rowIndex: number;
+}
+
+const formatter = makeFormatter('nanoseconds');
+function ProfilingFunctionsTableCell({
+  column,
+  dataRow,
+}: ProfilingFunctionsTableCellProps) {
+  const value = dataRow[column.key];
+  const {orgId, projectId, eventId} = useParams();
+
+  switch (column.key) {
+    case 'p75':
+    case 'p95':
+    case 'self weight':
+    case 'total weight':
+      return <NumberContainer>{formatter(value as number)}</NumberContainer>;
+    case 'count':
+      return <NumberContainer>{value}</NumberContainer>;
+    case 'image':
+      return <Container>{value ?? t('Unknown')}</Container>;
+    case 'thread': {
+      return (
+        <Container>
+          <Link
+            to={generateProfileFlamechartRouteWithQuery({
+              orgSlug: orgId,
+              projectSlug: projectId,
+              profileId: eventId,
+              query: {tid: dataRow.thread as string},
+            })}
+          >
+            {value}
+          </Link>
+        </Container>
+      );
+    }
+    default:
+      return <Container>{value}</Container>;
+  }
+}
+
+const tableColumnKey = [
+  'symbol',
+  'image',
+  'file',
+  'thread',
+  'type',
+  'self weight',
+  'total weight',
+  'p75',
+  'p95',
+  'count',
+] as const;
+
+type TableColumnKey = typeof tableColumnKey[number];
+
+type TableDataRow = Partial<Record<TableColumnKey, string | number>>;
+
+type TableColumn = GridColumnOrder<TableColumnKey>;
+
+// TODO: looks like these column names change depending on the platform?
+const COLUMNS: Record<TableColumnKey, TableColumn> = {
+  symbol: {
+    key: 'symbol',
+    name: t('Symbol'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  image: {
+    key: 'image',
+    name: t('Package'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  file: {
+    key: 'file',
+    name: t('File'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  thread: {
+    key: 'thread',
+    name: t('Thread'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  type: {
+    key: 'type',
+    name: t('Type'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  'self weight': {
+    key: 'self weight',
+    name: t('Self Weight'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  'total weight': {
+    key: 'total weight',
+    name: t('Total Weight'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  p75: {
+    key: 'p75',
+    name: t('P75(Self)'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  p95: {
+    key: 'p95',
+    name: t('P95(Self)'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  count: {
+    key: 'count',
+    name: t('Count'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+};
+
+const quantile = (arr: readonly number[], q: number) => {
+  const sorted = Array.from(arr).sort((a, b) => a - b);
+  const position = q * (sorted.length - 1);
+  const int = Math.floor(position);
+  const frac = position % 1;
+  if (position === int) {
+    return sorted[position];
+  }
+
+  return sorted[int] * (1 - frac) + sorted[int + 1] * frac;
+};
+
+const p75AggregateColumn: AggregateColumnConfig<TableColumnKey> = {
+  key: 'p75',
+  compute: rows => quantile(rows.map(v => v['self weight']) as number[], 0.75),
+};
+
+const p95AggregateColumn: AggregateColumnConfig<TableColumnKey> = {
+  key: 'p95',
+  compute: rows => quantile(rows.map(v => v['self weight']) as number[], 0.95),
+};
+
+const countAggregateColumn: AggregateColumnConfig<TableColumnKey> = {
+  key: 'count',
+  compute: rows => rows.length,
+};
+
+interface GroupByOptions<T> {
+  columns: T[];
+  option: {
+    label: string;
+    value: string;
+  };
+  rightAlignedColumns: T[];
+  search: {
+    key: T[];
+    placeholder: string;
+  };
+  sort: {
+    defaultSort: {
+      key: T;
+      order: 'asc' | 'desc';
+    };
+    sortableColumns: T[];
+  };
+  transform: (
+    data: Partial<Record<Extract<T, string>, string | number | undefined>>[]
+  ) => Record<Extract<T, string>, string | number | undefined>[];
+}
+
+const GROUP_BY_OPTIONS: Record<string, GroupByOptions<TableColumnKey>> = {
+  occurrence: {
+    option: {
+      label: t('Slowest Functions'),
+      value: 'occurrence',
+    },
+    columns: ['symbol', 'image', 'file', 'thread', 'type', 'self weight', 'total weight'],
+    transform: (data: any[]) => data.slice(0, 500),
+    search: {
+      key: ['symbol'],
+      placeholder: t('Search for frames'),
+    },
+    sort: {
+      sortableColumns: ['self weight', 'total weight'],
+      defaultSort: {
+        key: 'self weight',
+        order: 'desc',
+      },
+    },
+    rightAlignedColumns: ['self weight', 'total weight'],
+  },
+  symbol: {
+    option: {
+      label: t('Group by Symbol'),
+      value: 'symbol',
+    },
+    columns: ['symbol', 'type', 'image', 'p75', 'p95', 'count'],
+    search: {
+      key: ['symbol'],
+      placeholder: t('Search for frames'),
+    },
+    transform: data =>
+      aggregate(
+        data,
+        ['symbol', 'type', 'image'],
+        [p75AggregateColumn, p95AggregateColumn, countAggregateColumn]
+      ),
+    sort: {
+      sortableColumns: ['p75', 'p95', 'count'],
+      defaultSort: {
+        key: 'p75',
+        order: 'desc',
+      },
+    },
+    rightAlignedColumns: ['p75', 'p95', 'count'],
+  },
+  package: {
+    option: {
+      label: t('Group by Package'),
+      value: 'package',
+    },
+    columns: ['image', 'type', 'p75', 'p95', 'count'],
+    search: {
+      key: ['image'],
+      placeholder: t('Search for packages'),
+    },
+    transform: data =>
+      aggregate(
+        data,
+        ['type', 'image'],
+        [p75AggregateColumn, p95AggregateColumn, countAggregateColumn]
+      ),
+    sort: {
+      sortableColumns: ['p75', 'p95', 'count'],
+      defaultSort: {
+        key: 'p75',
+        order: 'desc',
+      },
+    },
+    rightAlignedColumns: ['p75', 'p95', 'count'],
+  },
+  file: {
+    option: {
+      label: t('Group by File'),
+      value: 'file',
+    },
+    columns: ['file', 'type', 'image', 'p75', 'p95', 'count'],
+    search: {
+      key: ['file'],
+      placeholder: t('Search for files'),
+    },
+    transform: data =>
+      aggregate(
+        data,
+        ['type', 'image', 'file'],
+        [p75AggregateColumn, p95AggregateColumn, countAggregateColumn]
+      ),
+    sort: {
+      sortableColumns: ['p75', 'p95', 'count'],
+      defaultSort: {
+        key: 'p75',
+        order: 'desc',
+      },
+    },
+    rightAlignedColumns: ['p75', 'p95', 'count'],
+  },
+};

+ 110 - 0
static/app/views/profiling/profileDetails/hooks/useColumnFilters.ts

@@ -0,0 +1,110 @@
+import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
+
+import {pluckUniqueValues} from '../utils';
+
+type ColumnFilters<T extends string | number | symbol> = {
+  [key in T]: {
+    onChange: (values: {value: string}[]) => void;
+    values: string[];
+  };
+};
+
+interface ColumnFiltersOptions<K extends string> {
+  columns: K[];
+  initialState?: Record<K, string[] | string | undefined>;
+}
+
+function parseInitialState(state?: Record<string, string[] | string | undefined>) {
+  if (!state) {
+    return {};
+  }
+  return Object.entries(state).reduce((acc, [key, val]) => {
+    acc[key] = undefined;
+
+    if (Array.isArray(val)) {
+      acc[key] = val;
+    }
+
+    if (typeof val === 'string') {
+      acc[key] = [val];
+    }
+
+    return acc;
+  }, {});
+}
+
+export function useColumnFilters<
+  T extends Record<string, string | number>,
+  K extends string = Extract<keyof T, string>
+>(data: T[], options: ColumnFiltersOptions<K>) {
+  const {columns} = options;
+  const [filters, setFilters] = useState<Partial<Record<K, string[]>>>(
+    parseInitialState(options.initialState)
+  );
+
+  const columnFilters = useMemo(() => {
+    function makeOnFilterChange(key: string) {
+      return (values: {value: string}[]) => {
+        setFilters(prevFilters => ({
+          ...prevFilters,
+          [key]: values.length > 0 ? values.map(val => val.value) : undefined,
+        }));
+      };
+    }
+
+    return columns.reduce((acc, key) => {
+      acc[key] = {
+        values: pluckUniqueValues(data, key as string).sort((a, b) => a.localeCompare(b)),
+        onChange: makeOnFilterChange(key as string),
+      };
+      return acc;
+    }, {} as ColumnFilters<K>);
+  }, [data, columns]);
+
+  // we need to validate that the initial state contain valid values
+  // if they do not we need to filter those out and update filters
+  // we only want this to run once
+  const didRun = useRef(false);
+  useEffect(() => {
+    if (didRun.current || data.length === 0) {
+      return;
+    }
+
+    setFilters(currentFilters => {
+      return Object.entries(currentFilters).reduce((acc, entry) => {
+        const [filterKey, filterValues] = entry as [string, any[] | undefined];
+        const possibleValues = columnFilters[filterKey].values;
+        const validValues = filterValues?.filter(v => possibleValues.includes(v));
+        acc[filterKey] =
+          Array.isArray(validValues) && validValues.length > 0 ? validValues : undefined;
+        return acc;
+      }, {});
+    });
+    didRun.current = true;
+  }, [data.length, columnFilters, filters]);
+
+  const filterPredicate = useCallback(
+    (row: T) => {
+      let include = true;
+      for (const key in filters) {
+        const filterValues = filters[key];
+        if (!filterValues) {
+          continue;
+        }
+        const rowValue = row[key];
+        include = filterValues.includes(rowValue as string);
+        if (!include) {
+          return false;
+        }
+      }
+      return include;
+    },
+    [filters]
+  );
+
+  return {
+    filters,
+    columnFilters,
+    filterPredicate,
+  };
+}

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