Browse Source

feat(profiling): Switch to handle new suspect functions response (#36941)

In preparation for the new suspect functions response, we need to update the
frontend to handle the new response format. This also allows sorting on the
numeric columns.
Tony Xiao 2 years ago
parent
commit
ee8684e6fb

+ 117 - 178
static/app/components/profiling/functionsTable.tsx

@@ -1,9 +1,6 @@
-import {Fragment, useMemo, useState} from 'react';
+import {useCallback, useMemo} from 'react';
 import styled from '@emotion/styled';
 
-import Button from 'sentry/components/button';
-import ButtonBar from 'sentry/components/buttonBar';
-import {SectionHeading} from 'sentry/components/charts/styles';
 import Count from 'sentry/components/count';
 import GridEditable, {
   COL_WIDTH_UNDEFINED,
@@ -11,14 +8,11 @@ import GridEditable, {
 } from 'sentry/components/gridEditable';
 import PerformanceDuration from 'sentry/components/performanceDuration';
 import {ArrayLinks} from 'sentry/components/profiling/arrayLinks';
-import {IconChevron} from 'sentry/icons';
 import {t} from 'sentry/locale';
-import space from 'sentry/styles/space';
 import {Project} from 'sentry/types';
-import {FunctionCall} from 'sentry/types/profiling/core';
+import {SuspectFunction} from 'sentry/types/profiling/core';
 import {Container, NumberContainer} from 'sentry/utils/discover/styles';
 import {getShortEventId} from 'sentry/utils/events';
-import {formatPercentage} from 'sentry/utils/formatters';
 import {generateProfileFlamegraphRouteWithQuery} from 'sentry/utils/profiling/routes';
 import {renderTableHead} from 'sentry/utils/profiling/tableRenderer';
 import {useLocation} from 'sentry/utils/useLocation';
@@ -26,104 +20,103 @@ import useOrganization from 'sentry/utils/useOrganization';
 
 interface FunctionsTableProps {
   error: string | null;
-  functionCalls: FunctionCall[];
+  functions: SuspectFunction[];
   isLoading: boolean;
   project: Project;
-  limit?: number;
+  sort: string;
 }
 
 function FunctionsTable(props: FunctionsTableProps) {
-  const limit = props.limit ?? 5;
-  const [offset, setOffset] = useState(0);
-
   const location = useLocation();
   const organization = useOrganization();
 
-  const allFunctions: TableDataRow[] = useMemo(() => {
-    return props.functionCalls.map(functionCall => ({
-      symbol: functionCall.symbol,
-      image: functionCall.image,
-      p50Duration: functionCall.duration_ns.p50,
-      p75Duration: functionCall.duration_ns.p75,
-      p90Duration: functionCall.duration_ns.p90,
-      p95Duration: functionCall.duration_ns.p95,
-      p99Duration: functionCall.duration_ns.p99,
-      mainThreadPercent: functionCall.main_thread_percent,
-      p50Frequency: functionCall.frequency.p50,
-      p75Frequency: functionCall.frequency.p75,
-      p90Frequency: functionCall.frequency.p90,
-      p95Frequency: functionCall.frequency.p95,
-      p99Frequency: functionCall.frequency.p99,
-      profileIdToThreadId: Object.entries(functionCall.profile_id_to_thread_id).map(
-        ([profileId, threadId]) => {
+  const sort = useMemo(() => {
+    let column = props.sort;
+    let direction: 'asc' | 'desc' = 'asc' as const;
+
+    if (props.sort.startsWith('-')) {
+      column = props.sort.substring(1);
+      direction = 'desc' as const;
+    }
+
+    if (!SORTABLE_COLUMNS.has(column as any)) {
+      column = 'p99';
+    }
+
+    return {
+      column: column as TableColumnKey,
+      direction,
+    };
+  }, [props.sort]);
+
+  const functions: TableDataRow[] = useMemo(() => {
+    return props.functions.map(func => {
+      const {worst, examples, ...rest} = func;
+
+      const allExamples = examples.filter(example => example !== worst);
+      allExamples.unshift(worst);
+
+      return {
+        ...rest,
+        examples: allExamples.map(example => {
+          const profileId = example.replaceAll('-', '');
           return {
             value: getShortEventId(profileId),
             target: generateProfileFlamegraphRouteWithQuery({
               orgSlug: organization.slug,
               projectSlug: props.project.slug,
               profileId,
-              query: {tid: threadId.toString()},
+              query: {}, // TODO: we should try to go to the right thread
             }),
           };
-        }
-      ),
-    }));
-  }, [organization.slug, props.project.slug, props.functionCalls]);
-
-  const functions: TableDataRow[] = useMemo(() => {
-    return allFunctions.slice(offset, offset + limit);
-  }, [allFunctions, limit, offset]);
+        }),
+      };
+    });
+  }, [organization.slug, props.project.slug, props.functions]);
+
+  const generateSortLink = useCallback(
+    (column: TableColumnKey) => {
+      if (!SORTABLE_COLUMNS.has(column)) {
+        return () => undefined;
+      }
+
+      const direction =
+        sort.column !== column ? 'desc' : sort.direction === 'desc' ? 'asc' : 'desc';
+
+      return () => ({
+        ...location,
+        query: {
+          ...location.query,
+          functionsSort: `${direction === 'desc' ? '-' : ''}${column}`,
+        },
+      });
+    },
+    [location, sort]
+  );
 
   return (
-    <Fragment>
-      <TableHeader>
-        <SectionHeading>{t('Suspect Functions')}</SectionHeading>
-        <ButtonBar merged>
-          <Button
-            icon={<IconChevron direction="left" size="sm" />}
-            aria-label={t('Previous')}
-            size="xs"
-            disabled={offset === 0}
-            onClick={() => setOffset(offset - limit)}
-          />
-          <Button
-            icon={<IconChevron direction="right" size="sm" />}
-            aria-label={t('Next')}
-            size="xs"
-            disabled={offset + limit >= allFunctions.length}
-            onClick={() => setOffset(offset + limit)}
-          />
-        </ButtonBar>
-      </TableHeader>
-      <GridEditable
-        isLoading={props.isLoading}
-        error={props.error}
-        data={functions}
-        columnOrder={COLUMN_ORDER.map(key => COLUMNS[key])}
-        columnSortBy={[]}
-        grid={{
-          renderHeadCell: renderTableHead(RIGHT_ALIGNED_COLUMNS),
-          renderBodyCell: renderFunctionsTableCell,
-        }}
-        location={location}
-      />
-    </Fragment>
+    <GridEditable
+      isLoading={props.isLoading}
+      error={props.error}
+      data={functions}
+      columnOrder={COLUMN_ORDER.map(key => COLUMNS[key])}
+      columnSortBy={[]}
+      grid={{
+        renderHeadCell: renderTableHead({
+          currentSort: sort,
+          rightAlignedColumns: RIGHT_ALIGNED_COLUMNS,
+          sortableColumns: SORTABLE_COLUMNS,
+          generateSortLink,
+        }),
+        renderBodyCell: renderFunctionsTableCell,
+      }}
+      location={location}
+    />
   );
 }
 
-const RIGHT_ALIGNED_COLUMNS = new Set<TableColumnKey>([
-  'p50Duration',
-  'p75Duration',
-  'p90Duration',
-  'p95Duration',
-  'p99Duration',
-  'mainThreadPercent',
-  'p50Frequency',
-  'p75Frequency',
-  'p90Frequency',
-  'p95Frequency',
-  'p99Frequency',
-]);
+const RIGHT_ALIGNED_COLUMNS = new Set<TableColumnKey>(['p75', 'p95', 'p99', 'count']);
+const SORTABLE_COLUMNS = RIGHT_ALIGNED_COLUMNS;
 
 function renderFunctionsTableCell(
   column: TableColumn,
@@ -148,6 +141,10 @@ interface ProfilingFunctionsTableCellProps {
   rowIndex: number;
 }
 
+const EmptyValueContainer = styled('span')`
+  color: ${p => p.theme.gray300};
+`;
+
 function ProfilingFunctionsTableCell({
   column,
   dataRow,
@@ -155,144 +152,86 @@ function ProfilingFunctionsTableCell({
   const value = dataRow[column.key];
 
   switch (column.key) {
-    case 'p50Frequency':
-    case 'p75Frequency':
-    case 'p90Frequency':
-    case 'p95Frequency':
-    case 'p99Frequency':
+    case 'count':
       return (
         <NumberContainer>
           <Count value={value} />
         </NumberContainer>
       );
-    case 'mainThreadPercent':
-      return <NumberContainer>{formatPercentage(value)}</NumberContainer>;
-    case 'p50Duration':
-    case 'p75Duration':
-    case 'p90Duration':
-    case 'p95Duration':
-    case 'p99Duration':
+    case 'p75':
+    case 'p95':
+    case 'p99':
       return (
         <NumberContainer>
           <PerformanceDuration nanoseconds={value} abbreviation />
         </NumberContainer>
       );
-    case 'profileIdToThreadId':
+    case 'examples':
       return <ArrayLinks items={value} />;
+    case 'name':
+      const name = value || <EmptyValueContainer>{t('Unknown')}</EmptyValueContainer>;
+      return <Container>{name}</Container>;
     default:
       return <Container>{value}</Container>;
   }
 }
 
-type TableColumnKey =
-  | 'symbol'
-  | 'image'
-  | 'p50Duration'
-  | 'p75Duration'
-  | 'p90Duration'
-  | 'p95Duration'
-  | 'p99Duration'
-  | 'mainThreadPercent'
-  | 'p50Frequency'
-  | 'p75Frequency'
-  | 'p90Frequency'
-  | 'p95Frequency'
-  | 'p99Frequency'
-  | 'profileIdToThreadId';
+type TableColumnKey = keyof Omit<SuspectFunction, 'fingerprint' | 'worst'>;
 
 type TableDataRow = Record<TableColumnKey, any>;
 
 type TableColumn = GridColumnOrder<TableColumnKey>;
 
 const COLUMN_ORDER: TableColumnKey[] = [
-  'symbol',
-  'image',
-  'p75Duration',
-  'p99Duration',
-  'mainThreadPercent',
-  'p75Frequency',
-  'p99Frequency',
-  'profileIdToThreadId',
+  'name',
+  'package',
+  'count',
+  'p75',
+  'p99',
+  'examples',
 ];
 
-// TODO: looks like these column names change depending on the platform?
 const COLUMNS: Record<TableColumnKey, TableColumn> = {
-  symbol: {
-    key: 'symbol',
-    name: t('Symbol'),
+  name: {
+    key: 'name',
+    name: t('Name'),
     width: COL_WIDTH_UNDEFINED,
   },
-  image: {
-    key: 'image',
-    name: t('Binary'),
+  package: {
+    key: 'package',
+    name: t('Package'),
     width: COL_WIDTH_UNDEFINED,
   },
-  p50Duration: {
-    key: 'p50Duration',
-    name: t('P50 Duration'),
+  path: {
+    key: 'path',
+    name: t('Path'),
     width: COL_WIDTH_UNDEFINED,
   },
-  p75Duration: {
-    key: 'p75Duration',
+  p75: {
+    key: 'p75',
     name: t('P75 Duration'),
     width: COL_WIDTH_UNDEFINED,
   },
-  p90Duration: {
-    key: 'p90Duration',
-    name: t('P90 Duration'),
-    width: COL_WIDTH_UNDEFINED,
-  },
-  p95Duration: {
-    key: 'p95Duration',
+  p95: {
+    key: 'p95',
     name: t('P95 Duration'),
     width: COL_WIDTH_UNDEFINED,
   },
-  p99Duration: {
-    key: 'p99Duration',
+  p99: {
+    key: 'p99',
     name: t('P99 Duration'),
     width: COL_WIDTH_UNDEFINED,
   },
-  mainThreadPercent: {
-    key: 'mainThreadPercent',
-    name: t('Main Thread %'),
-    width: COL_WIDTH_UNDEFINED,
-  },
-  p50Frequency: {
-    key: 'p50Frequency',
-    name: t('P50 Frequency'),
-    width: COL_WIDTH_UNDEFINED,
-  },
-  p75Frequency: {
-    key: 'p75Frequency',
-    name: t('P75 Frequency'),
-    width: COL_WIDTH_UNDEFINED,
-  },
-  p90Frequency: {
-    key: 'p90Frequency',
-    name: t('P90 Frequency'),
-    width: COL_WIDTH_UNDEFINED,
-  },
-  p95Frequency: {
-    key: 'p95Frequency',
-    name: t('P95 Frequency'),
+  count: {
+    key: 'count',
+    name: t('Count'),
     width: COL_WIDTH_UNDEFINED,
   },
-  p99Frequency: {
-    key: 'p99Frequency',
-    name: t('P99 Frequency'),
-    width: COL_WIDTH_UNDEFINED,
-  },
-  profileIdToThreadId: {
-    key: 'profileIdToThreadId',
+  examples: {
+    key: 'examples',
     name: t('Example Profiles'),
     width: COL_WIDTH_UNDEFINED,
   },
 };
 
-const TableHeader = styled('div')`
-  display: flex;
-  justify-content: space-between;
-  margin-bottom: ${space(1)};
-`;
-
 export {FunctionsTable};

+ 298 - 0
static/app/components/profiling/legacyFunctionsTable.tsx

@@ -0,0 +1,298 @@
+import {Fragment, useMemo, useState} from 'react';
+import styled from '@emotion/styled';
+
+import Button from 'sentry/components/button';
+import ButtonBar from 'sentry/components/buttonBar';
+import {SectionHeading} from 'sentry/components/charts/styles';
+import Count from 'sentry/components/count';
+import GridEditable, {
+  COL_WIDTH_UNDEFINED,
+  GridColumnOrder,
+} from 'sentry/components/gridEditable';
+import PerformanceDuration from 'sentry/components/performanceDuration';
+import {ArrayLinks} from 'sentry/components/profiling/arrayLinks';
+import {IconChevron} from 'sentry/icons';
+import {t} from 'sentry/locale';
+import space from 'sentry/styles/space';
+import {Project} from 'sentry/types';
+import {FunctionCall} from 'sentry/types/profiling/core';
+import {Container, NumberContainer} from 'sentry/utils/discover/styles';
+import {getShortEventId} from 'sentry/utils/events';
+import {formatPercentage} from 'sentry/utils/formatters';
+import {generateProfileFlamegraphRouteWithQuery} from 'sentry/utils/profiling/routes';
+import {renderTableHead} from 'sentry/utils/profiling/tableRenderer';
+import {useLocation} from 'sentry/utils/useLocation';
+import useOrganization from 'sentry/utils/useOrganization';
+
+interface LegacyFunctionsTableProps {
+  error: string | null;
+  functionCalls: FunctionCall[];
+  isLoading: boolean;
+  project: Project;
+  limit?: number;
+}
+
+function LegacyFunctionsTable(props: LegacyFunctionsTableProps) {
+  const limit = props.limit ?? 5;
+  const [offset, setOffset] = useState(0);
+
+  const location = useLocation();
+  const organization = useOrganization();
+
+  const allFunctions: TableDataRow[] = useMemo(() => {
+    return props.functionCalls.map(functionCall => ({
+      symbol: functionCall.symbol,
+      image: functionCall.image,
+      p50Duration: functionCall.duration_ns.p50,
+      p75Duration: functionCall.duration_ns.p75,
+      p90Duration: functionCall.duration_ns.p90,
+      p95Duration: functionCall.duration_ns.p95,
+      p99Duration: functionCall.duration_ns.p99,
+      mainThreadPercent: functionCall.main_thread_percent,
+      p50Frequency: functionCall.frequency.p50,
+      p75Frequency: functionCall.frequency.p75,
+      p90Frequency: functionCall.frequency.p90,
+      p95Frequency: functionCall.frequency.p95,
+      p99Frequency: functionCall.frequency.p99,
+      profileIdToThreadId: Object.entries(functionCall.profile_id_to_thread_id).map(
+        ([profileId, threadId]) => {
+          return {
+            value: getShortEventId(profileId),
+            target: generateProfileFlamegraphRouteWithQuery({
+              orgSlug: organization.slug,
+              projectSlug: props.project.slug,
+              profileId,
+              query: {tid: threadId.toString()},
+            }),
+          };
+        }
+      ),
+    }));
+  }, [organization.slug, props.project.slug, props.functionCalls]);
+
+  const functions: TableDataRow[] = useMemo(() => {
+    return allFunctions.slice(offset, offset + limit);
+  }, [allFunctions, limit, offset]);
+
+  return (
+    <Fragment>
+      <TableHeader>
+        <SectionHeading>{t('Suspect Functions')}</SectionHeading>
+        <ButtonBar merged>
+          <Button
+            icon={<IconChevron direction="left" size="sm" />}
+            aria-label={t('Previous')}
+            size="xs"
+            disabled={offset === 0}
+            onClick={() => setOffset(offset - limit)}
+          />
+          <Button
+            icon={<IconChevron direction="right" size="sm" />}
+            aria-label={t('Next')}
+            size="xs"
+            disabled={offset + limit >= allFunctions.length}
+            onClick={() => setOffset(offset + limit)}
+          />
+        </ButtonBar>
+      </TableHeader>
+      <GridEditable
+        isLoading={props.isLoading}
+        error={props.error}
+        data={functions}
+        columnOrder={COLUMN_ORDER.map(key => COLUMNS[key])}
+        columnSortBy={[]}
+        grid={{
+          renderHeadCell: renderTableHead({rightAlignedColumns: RIGHT_ALIGNED_COLUMNS}),
+          renderBodyCell: renderFunctionsTableCell,
+        }}
+        location={location}
+      />
+    </Fragment>
+  );
+}
+
+const RIGHT_ALIGNED_COLUMNS = new Set<TableColumnKey>([
+  'p50Duration',
+  'p75Duration',
+  'p90Duration',
+  'p95Duration',
+  'p99Duration',
+  'mainThreadPercent',
+  'p50Frequency',
+  'p75Frequency',
+  'p90Frequency',
+  'p95Frequency',
+  'p99Frequency',
+]);
+
+function renderFunctionsTableCell(
+  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;
+}
+
+function ProfilingFunctionsTableCell({
+  column,
+  dataRow,
+}: ProfilingFunctionsTableCellProps) {
+  const value = dataRow[column.key];
+
+  switch (column.key) {
+    case 'p50Frequency':
+    case 'p75Frequency':
+    case 'p90Frequency':
+    case 'p95Frequency':
+    case 'p99Frequency':
+      return (
+        <NumberContainer>
+          <Count value={value} />
+        </NumberContainer>
+      );
+    case 'mainThreadPercent':
+      return <NumberContainer>{formatPercentage(value)}</NumberContainer>;
+    case 'p50Duration':
+    case 'p75Duration':
+    case 'p90Duration':
+    case 'p95Duration':
+    case 'p99Duration':
+      return (
+        <NumberContainer>
+          <PerformanceDuration nanoseconds={value} abbreviation />
+        </NumberContainer>
+      );
+    case 'profileIdToThreadId':
+      return <ArrayLinks items={value} />;
+    default:
+      return <Container>{value}</Container>;
+  }
+}
+
+type TableColumnKey =
+  | 'symbol'
+  | 'image'
+  | 'p50Duration'
+  | 'p75Duration'
+  | 'p90Duration'
+  | 'p95Duration'
+  | 'p99Duration'
+  | 'mainThreadPercent'
+  | 'p50Frequency'
+  | 'p75Frequency'
+  | 'p90Frequency'
+  | 'p95Frequency'
+  | 'p99Frequency'
+  | 'profileIdToThreadId';
+
+type TableDataRow = Record<TableColumnKey, any>;
+
+type TableColumn = GridColumnOrder<TableColumnKey>;
+
+const COLUMN_ORDER: TableColumnKey[] = [
+  'symbol',
+  'image',
+  'p75Duration',
+  'p99Duration',
+  'mainThreadPercent',
+  'p75Frequency',
+  'p99Frequency',
+  'profileIdToThreadId',
+];
+
+// 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('Binary'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  p50Duration: {
+    key: 'p50Duration',
+    name: t('P50 Duration'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  p75Duration: {
+    key: 'p75Duration',
+    name: t('P75 Duration'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  p90Duration: {
+    key: 'p90Duration',
+    name: t('P90 Duration'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  p95Duration: {
+    key: 'p95Duration',
+    name: t('P95 Duration'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  p99Duration: {
+    key: 'p99Duration',
+    name: t('P99 Duration'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  mainThreadPercent: {
+    key: 'mainThreadPercent',
+    name: t('Main Thread %'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  p50Frequency: {
+    key: 'p50Frequency',
+    name: t('P50 Frequency'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  p75Frequency: {
+    key: 'p75Frequency',
+    name: t('P75 Frequency'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  p90Frequency: {
+    key: 'p90Frequency',
+    name: t('P90 Frequency'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  p95Frequency: {
+    key: 'p95Frequency',
+    name: t('P95 Frequency'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  p99Frequency: {
+    key: 'p99Frequency',
+    name: t('P99 Frequency'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  profileIdToThreadId: {
+    key: 'profileIdToThreadId',
+    name: t('Example Profiles'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+};
+
+const TableHeader = styled('div')`
+  display: flex;
+  justify-content: space-between;
+  margin-bottom: ${space(1)};
+`;
+
+export {LegacyFunctionsTable};

+ 1 - 1
static/app/components/profiling/profileTransactionsTable.tsx

@@ -68,7 +68,7 @@ function ProfileTransactionsTable(props: ProfileTransactionsTableProps) {
       columnOrder={COLUMN_ORDER.map(key => COLUMNS[key])}
       columnSortBy={[]}
       grid={{
-        renderHeadCell: renderTableHead(RIGHT_ALIGNED_COLUMNS),
+        renderHeadCell: renderTableHead({rightAlignedColumns: RIGHT_ALIGNED_COLUMNS}),
         renderBodyCell: renderTableBody,
       }}
       location={location}

+ 1 - 1
static/app/components/profiling/profilesTable.tsx

@@ -49,7 +49,7 @@ function ProfilesTable(props: ProfilesTableProps) {
         columnOrder={(props.columnOrder ?? DEFAULT_COLUMN_ORDER).map(key => COLUMNS[key])}
         columnSortBy={[]}
         grid={{
-          renderHeadCell: renderTableHead(RIGHT_ALIGNED_COLUMNS),
+          renderHeadCell: renderTableHead({rightAlignedColumns: RIGHT_ALIGNED_COLUMNS}),
           renderBodyCell: renderProfilesTableCell,
         }}
         location={location}

+ 11 - 2
static/app/types/profiling/core.tsx

@@ -64,8 +64,17 @@ export type FunctionCall = {
   transaction_names: string[];
 };
 
-export type VersionedFunctionCalls = {
-  Versions: Record<string, {FunctionCalls: FunctionCall[]}>;
+export type SuspectFunction = {
+  count: number;
+  examples: string[];
+  fingerprint: number;
+  name: string;
+  p75: number;
+  p95: number;
+  p99: number;
+  package: string;
+  path: string;
+  worst: string;
 };
 
 export type ProfileTransaction = {

+ 63 - 10
static/app/utils/profiling/hooks/useFunctions.tsx

@@ -5,15 +5,42 @@ import {Client} from 'sentry/api';
 import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
 import {t} from 'sentry/locale';
 import {Organization, PageFilters, Project, RequestState} from 'sentry/types';
-import {FunctionCall} from 'sentry/types/profiling/core';
+import {FunctionCall, SuspectFunction} from 'sentry/types/profiling/core';
 import {MutableSearch} from 'sentry/utils/tokenizeSearch';
 import useApi from 'sentry/utils/useApi';
 import useOrganization from 'sentry/utils/useOrganization';
 
+type FunctionsResultV1 = {
+  functions: FunctionCall[];
+  version: 1;
+};
+
+type FunctionsResultV2 = {
+  functions: SuspectFunction[];
+  pageLinks: string | null;
+  version: 2;
+};
+
+type FunctionsResult = FunctionsResultV1 | FunctionsResultV2;
+
+export function isFunctionsResultV1(
+  result: FunctionsResult
+): result is FunctionsResultV1 {
+  return result.version === 1;
+}
+
+export function isFunctionsResultV2(
+  result: FunctionsResult
+): result is FunctionsResultV2 {
+  return result.version === 2;
+}
+
 interface UseFunctionsOptions {
   project: Project;
   query: string;
+  sort: string;
   transaction: string;
+  cursor?: string;
   selection?: PageFilters;
 }
 
@@ -21,12 +48,14 @@ function useFunctions({
   project,
   query,
   transaction,
+  sort,
+  cursor,
   selection,
-}: UseFunctionsOptions): RequestState<FunctionCall[]> {
+}: UseFunctionsOptions): RequestState<FunctionsResult> {
   const api = useApi();
   const organization = useOrganization();
 
-  const [requestState, setRequestState] = useState<RequestState<FunctionCall[]>>({
+  const [requestState, setRequestState] = useState<RequestState<FunctionsResult>>({
     type: 'initial',
   });
 
@@ -41,13 +70,31 @@ function useFunctions({
       projectSlug: project.slug,
       query,
       selection,
+      sort,
       transaction,
+      cursor,
     })
-      .then(functions => {
-        setRequestState({
-          type: 'resolved',
-          data: functions.functions ?? [],
-        });
+      .then(([functions, , response]) => {
+        const isLegacy =
+          functions.functions.length && functions.functions[0].hasOwnProperty('symbol');
+        if (isLegacy) {
+          setRequestState({
+            type: 'resolved',
+            data: {
+              functions: functions.functions ?? [],
+              version: 1,
+            },
+          });
+        } else {
+          setRequestState({
+            type: 'resolved',
+            data: {
+              functions: functions.functions ?? [],
+              pageLinks: response?.getResponseHeader('Link') ?? null,
+              version: 2,
+            },
+          });
+        }
       })
       .catch(err => {
         setRequestState({type: 'errored', error: t('Error: Unable to load functions')});
@@ -55,7 +102,7 @@ function useFunctions({
       });
 
     return () => api.clear();
-  }, [api, organization, project.slug, query, selection, transaction]);
+  }, [api, cursor, organization, project.slug, query, selection, sort, transaction]);
 
   return requestState;
 }
@@ -64,14 +111,18 @@ function fetchFunctions(
   api: Client,
   organization: Organization,
   {
+    cursor,
     projectSlug,
     query,
     selection,
+    sort,
     transaction,
   }: {
+    cursor: string | undefined;
     projectSlug: Project['slug'];
     query: string;
     selection: PageFilters;
+    sort: string;
     transaction: string;
   }
 ) {
@@ -82,11 +133,13 @@ function fetchFunctions(
     `/projects/${organization.slug}/${projectSlug}/profiling/functions/`,
     {
       method: 'GET',
-      includeAllArgs: false,
+      includeAllArgs: true,
       query: {
+        cursor,
         environment: selection.environments,
         ...normalizeDateTimeParams(selection.datetime),
         query: conditions.formatString(),
+        sort,
       },
     }
   );

+ 26 - 5
static/app/utils/profiling/tableRenderer.tsx

@@ -1,15 +1,36 @@
+import {LocationDescriptorObject} from 'history';
+
 import {GridColumnOrder} from 'sentry/components/gridEditable';
 import SortLink from 'sentry/components/gridEditable/sortLink';
 
-export function renderTableHead<K>(rightAlignedColumns: Set<K>) {
+type Sort<K> = {
+  column: K;
+  direction: 'asc' | 'desc';
+};
+
+interface TableHeadProps<K> {
+  currentSort?: Sort<K>;
+  generateSortLink?: (column: K) => () => LocationDescriptorObject | undefined;
+  rightAlignedColumns?: Set<K>;
+  sortableColumns?: Set<K>;
+}
+
+export function renderTableHead<K>({
+  currentSort,
+  generateSortLink,
+  rightAlignedColumns,
+  sortableColumns,
+}: TableHeadProps<K>) {
   return (column: GridColumnOrder<K>, _columnIndex: number) => {
     return (
       <SortLink
-        align={rightAlignedColumns.has(column.key) ? 'right' : 'left'}
+        align={rightAlignedColumns?.has(column.key) ? 'right' : 'left'}
         title={column.name}
-        direction={undefined}
-        canSort={false}
-        generateSortLink={() => undefined}
+        direction={
+          currentSort?.column === column.key ? currentSort?.direction : undefined
+        }
+        canSort={sortableColumns?.has(column.key) || false}
+        generateSortLink={generateSortLink?.(column.key) ?? (() => undefined)}
       />
     );
   };

+ 67 - 10
static/app/views/profiling/profileSummary/content.tsx

@@ -1,4 +1,5 @@
-import {useMemo} from 'react';
+import {Fragment, useCallback, useMemo} from 'react';
+import {browserHistory} from 'react-router';
 import styled from '@emotion/styled';
 import {Location} from 'history';
 
@@ -6,14 +7,21 @@ import {SectionHeading} from 'sentry/components/charts/styles';
 import * as Layout from 'sentry/components/layouts/thirds';
 import Pagination from 'sentry/components/pagination';
 import {FunctionsTable} from 'sentry/components/profiling/functionsTable';
+import {LegacyFunctionsTable} from 'sentry/components/profiling/legacyFunctionsTable';
 import {ProfilesTable} from 'sentry/components/profiling/profilesTable';
 import {t} from 'sentry/locale';
 import space from 'sentry/styles/space';
 import {PageFilters, Project} from 'sentry/types';
-import {useFunctions} from 'sentry/utils/profiling/hooks/useFunctions';
+import {
+  isFunctionsResultV1,
+  isFunctionsResultV2,
+  useFunctions,
+} from 'sentry/utils/profiling/hooks/useFunctions';
 import {useProfiles} from 'sentry/utils/profiling/hooks/useProfiles';
 import {decodeScalar} from 'sentry/utils/queryString';
 
+const FUNCTIONS_CURSOR_NAME = 'functionsCursor';
+
 const PROFILES_COLUMN_ORDER = [
   'failed',
   'id',
@@ -33,25 +41,44 @@ interface ProfileSummaryContentProps {
 }
 
 function ProfileSummaryContent(props: ProfileSummaryContentProps) {
-  const cursor = useMemo(
+  const profilesCursor = useMemo(
     () => decodeScalar(props.location.query.cursor),
     [props.location.query.cursor]
   );
 
+  const functionsCursor = useMemo(
+    () => decodeScalar(props.location.query.functionsCursor),
+    [props.location.query.functionsCursor]
+  );
+
+  const functionsSort = useMemo(
+    () => decodeScalar(props.location.query.functionsSort, '-p99'),
+    [props.location.query.functionsSort]
+  );
+
   const profiles = useProfiles({
-    cursor,
+    cursor: profilesCursor,
     limit: 5,
     query: props.query,
     selection: props.selection,
   });
 
   const functions = useFunctions({
+    cursor: functionsCursor,
     project: props.project,
     query: props.query,
     selection: props.selection,
     transaction: props.transaction,
+    sort: functionsSort,
   });
 
+  const handleFunctionsCursor = useCallback((cursor, pathname, query) => {
+    browserHistory.push({
+      pathname,
+      query: {...query, [FUNCTIONS_CURSOR_NAME]: cursor},
+    });
+  }, []);
+
   return (
     <Layout.Main fullWidth>
       <TableHeader>
@@ -67,12 +94,42 @@ function ProfileSummaryContent(props: ProfileSummaryContentProps) {
         traces={profiles.type === 'resolved' ? profiles.data.traces : []}
         columnOrder={PROFILES_COLUMN_ORDER}
       />
-      <FunctionsTable
-        error={functions.type === 'errored' ? functions.error : null}
-        functionCalls={functions.type === 'resolved' ? functions.data : []}
-        isLoading={functions.type === 'initial' || functions.type === 'loading'}
-        project={props.project}
-      />
+      {functions.type === 'resolved' && isFunctionsResultV1(functions.data) ? (
+        // this does result in some flickering if we get the v1 results
+        // back but this is temporary and will be removed in the near future
+        <LegacyFunctionsTable
+          error={null}
+          functionCalls={functions.data.functions}
+          isLoading={false}
+          project={props.project}
+        />
+      ) : (
+        <Fragment>
+          <TableHeader>
+            <SectionHeading>{t('Suspect Functions')}</SectionHeading>
+            <StyledPagination
+              pageLinks={
+                functions.type === 'resolved' && isFunctionsResultV2(functions.data)
+                  ? functions.data.pageLinks
+                  : null
+              }
+              onCursor={handleFunctionsCursor}
+              size="xs"
+            />
+          </TableHeader>
+          <FunctionsTable
+            error={functions.type === 'errored' ? functions.error : null}
+            isLoading={functions.type === 'initial' || functions.type === 'loading'}
+            functions={
+              functions.type === 'resolved' && isFunctionsResultV2(functions.data)
+                ? functions.data.functions
+                : []
+            }
+            project={props.project}
+            sort={functionsSort}
+          />
+        </Fragment>
+      )}
     </Layout.Main>
   );
 }

+ 83 - 2
tests/js/spec/components/profiling/functionsTable.spec.tsx

@@ -4,6 +4,7 @@ import {initializeOrg} from 'sentry-test/initializeOrg';
 import {render, screen} from 'sentry-test/reactTestingLibrary';
 
 import {FunctionsTable} from 'sentry/components/profiling/functionsTable';
+import {LegacyFunctionsTable} from 'sentry/components/profiling/legacyFunctionsTable';
 import ProjectsStore from 'sentry/stores/projectsStore';
 import {OrganizationContext} from 'sentry/views/organizationContext';
 import {RouteContext} from 'sentry/views/routeContext';
@@ -39,7 +40,13 @@ describe('FunctionsTable', function () {
   it('renders loading', function () {
     render(
       <TestContext>
-        <FunctionsTable isLoading error={null} functionCalls={[]} project={project} />
+        <FunctionsTable
+          isLoading
+          error={null}
+          functions={[]}
+          project={project}
+          sort="p99"
+        />
       </TestContext>
     );
     expect(screen.getByTestId('loading-indicator')).toBeInTheDocument();
@@ -49,6 +56,80 @@ describe('FunctionsTable', function () {
     render(
       <TestContext>
         <FunctionsTable
+          isLoading={false}
+          error={null}
+          functions={[]}
+          project={project}
+          sort="-p99"
+        />
+      </TestContext>
+    );
+
+    expect(screen.getByText('No results found for your query')).toBeInTheDocument();
+  });
+
+  it('renders one function', function () {
+    const func = {
+      count: 10,
+      examples: ['aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'],
+      fingerprint: 1234,
+      name: 'foo',
+      p75: 10000000,
+      p95: 12000000,
+      p99: 12500000,
+      package: 'bar',
+      path: 'baz',
+      worst: 'cccccccccccccccccccccccccccccccc',
+    };
+
+    render(
+      <TestContext>
+        <FunctionsTable
+          isLoading={false}
+          error={null}
+          functions={[func]}
+          project={project}
+          sort="-p99"
+        />
+      </TestContext>
+    );
+
+    expect(screen.getByText('Name')).toBeInTheDocument();
+    expect(screen.getByText('foo')).toBeInTheDocument();
+
+    expect(screen.getByText('Package')).toBeInTheDocument();
+    expect(screen.getByText('bar')).toBeInTheDocument();
+
+    expect(screen.getByText('Count')).toBeInTheDocument();
+    expect(screen.getByText('10')).toBeInTheDocument();
+
+    expect(screen.getByText('P75 Duration')).toBeInTheDocument();
+    expect(screen.getByText('10.00ms')).toBeInTheDocument();
+
+    expect(screen.getByText('P99 Duration')).toBeInTheDocument();
+    expect(screen.getByText('12.50ms')).toBeInTheDocument();
+  });
+});
+
+describe('LegacyFunctionsTable', function () {
+  it('renders loading', function () {
+    render(
+      <TestContext>
+        <LegacyFunctionsTable
+          isLoading
+          error={null}
+          functionCalls={[]}
+          project={project}
+        />
+      </TestContext>
+    );
+    expect(screen.getByTestId('loading-indicator')).toBeInTheDocument();
+  });
+
+  it('renders empty data', function () {
+    render(
+      <TestContext>
+        <LegacyFunctionsTable
           isLoading={false}
           error={null}
           functionCalls={[]}
@@ -96,7 +177,7 @@ describe('FunctionsTable', function () {
 
     render(
       <TestContext>
-        <FunctionsTable
+        <LegacyFunctionsTable
           isLoading={false}
           error={null}
           functionCalls={[func]}

+ 36 - 4
tests/js/spec/utils/profiling/hooks/useFunctions.spec.tsx

@@ -42,19 +42,46 @@ describe('useFunctions', function () {
           project,
           query: '',
           transaction: '',
+          sort: '-p99',
         }),
       {wrapper: TestContext}
     );
     expect(hook.result.current).toEqual({type: 'initial'});
   });
 
-  it('fetches functions', async function () {
+  it('fetches functions legacy', async function () {
     MockApiClient.addMockResponse({
       url: `/projects/org-slug/${project.slug}/profiling/functions/`,
-      body: {
-        functions: [],
+      body: {functions: [{symbol: ''}]}, // only the legacy response contains symbol
+    });
+
+    const hook = reactHooks.renderHook(
+      () =>
+        useFunctions({
+          project,
+          query: '',
+          transaction: '',
+          selection,
+          sort: '-p99',
+        }),
+      {wrapper: TestContext}
+    );
+    expect(hook.result.current).toEqual({type: 'loading'});
+    await hook.waitForNextUpdate();
+    expect(hook.result.current).toEqual({
+      type: 'resolved',
+      data: {
+        functions: [{symbol: ''}],
+        version: 1,
       },
     });
+  });
+
+  it('fetches functions', async function () {
+    MockApiClient.addMockResponse({
+      url: `/projects/org-slug/${project.slug}/profiling/functions/`,
+      body: {functions: []},
+    });
 
     const hook = reactHooks.renderHook(
       () =>
@@ -63,6 +90,7 @@ describe('useFunctions', function () {
           query: '',
           transaction: '',
           selection,
+          sort: '-p99',
         }),
       {wrapper: TestContext}
     );
@@ -70,7 +98,11 @@ describe('useFunctions', function () {
     await hook.waitForNextUpdate();
     expect(hook.result.current).toEqual({
       type: 'resolved',
-      data: [],
+      data: {
+        functions: [],
+        pageLinks: null,
+        version: 2,
+      },
     });
   });
 });