Browse Source

Revert "ref(profiling): Move profiling pages to use events api (#40999)"

This reverts commit 94aa41b720002cdd8416fd295d2073a38ea046b1.

Co-authored-by: Zylphrex <10239353+Zylphrex@users.noreply.github.com>
getsentry-bot 2 years ago
parent
commit
1482a3a2fc

+ 2 - 2
static/app/components/profiling/profileEventsTable.tsx

@@ -29,7 +29,7 @@ import useOrganization from 'sentry/utils/useOrganization';
 import useProjects from 'sentry/utils/useProjects';
 
 interface ProfileEventsTableProps<F extends FieldType> {
-  columns: readonly F[];
+  columns: F[];
   data: EventsResults<F> | null;
   error: string | null;
   isLoading: boolean;
@@ -425,6 +425,6 @@ function getColumnOrder<F extends FieldType>(field: F): GridColumnOrder<F> {
   };
 }
 
-function getRightAlignedColumns<F extends FieldType>(columns: readonly F[]): Set<F> {
+function getRightAlignedColumns<F extends FieldType>(columns: F[]): Set<F> {
   return new Set(columns.filter(col => RIGHT_ALIGNED_FIELDS.has(col)));
 }

+ 281 - 0
static/app/components/profiling/profileTransactionsTable.tsx

@@ -0,0 +1,281 @@
+import {useMemo} from 'react';
+
+import Count from 'sentry/components/count';
+import DateTime from 'sentry/components/dateTime';
+import GridEditable, {
+  COL_WIDTH_UNDEFINED,
+  GridColumnOrder,
+} from 'sentry/components/gridEditable';
+import ProjectBadge from 'sentry/components/idBadge/projectBadge';
+import Link from 'sentry/components/links/link';
+import PerformanceDuration from 'sentry/components/performanceDuration';
+import {t} from 'sentry/locale';
+import {ProfileTransaction} from 'sentry/types/profiling/core';
+import {defined} from 'sentry/utils';
+import {Container, NumberContainer} from 'sentry/utils/discover/styles';
+import {generateProfileSummaryRouteWithQuery} from 'sentry/utils/profiling/routes';
+import {renderTableHead} from 'sentry/utils/profiling/tableRenderer';
+import {decodeScalar} from 'sentry/utils/queryString';
+import {useLocation} from 'sentry/utils/useLocation';
+import useOrganization from 'sentry/utils/useOrganization';
+import useProjects from 'sentry/utils/useProjects';
+
+interface ProfileTransactionsTableProps {
+  error: string | null;
+  isLoading: boolean;
+  sort: string;
+  transactions: ProfileTransaction[];
+}
+
+function ProfileTransactionsTable(props: ProfileTransactionsTableProps) {
+  const location = useLocation();
+  const organization = useOrganization();
+  const {projects} = useProjects();
+
+  const sort = useMemo(() => {
+    let column = decodeScalar(props.sort, '-count()');
+    let order: 'asc' | 'desc' = 'asc' as const;
+
+    if (column.startsWith('-')) {
+      column = column.substring(1);
+      order = 'desc' as const;
+    }
+
+    if (!SORTABLE_COLUMNS.has(column as any)) {
+      column = 'count()';
+    }
+
+    return {
+      key: column as TableColumnKey,
+      order,
+    };
+  }, [props.sort]);
+
+  const transactions: TableDataRow[] = useMemo(() => {
+    return props.transactions.map(transaction => {
+      const project = projects.find(proj => proj.id === transaction.project_id);
+      return {
+        _transactionName: transaction.name,
+        transaction: project ? (
+          <Link
+            to={generateProfileSummaryRouteWithQuery({
+              query: location.query,
+              orgSlug: organization.slug,
+              projectSlug: project.slug,
+              transaction: transaction.name,
+            })}
+          >
+            {transaction.name}
+          </Link>
+        ) : (
+          transaction.name
+        ),
+        'count()': transaction.profiles_count,
+        project,
+        'p50()': transaction.duration_ms.p50,
+        'p75()': transaction.duration_ms.p75,
+        'p90()': transaction.duration_ms.p90,
+        'p95()': transaction.duration_ms.p95,
+        'p99()': transaction.duration_ms.p99,
+        'last_seen()': transaction.last_profile_at,
+      };
+    });
+  }, [props.transactions, location, organization, projects]);
+
+  const generateSortLink = (column: string) => () => {
+    let dir = 'desc';
+    if (column === sort.key && sort.order === dir) {
+      dir = 'asc';
+    }
+    return {
+      ...location,
+      query: {
+        ...location.query,
+        sort: `${dir === 'desc' ? '-' : ''}${column}`,
+      },
+    };
+  };
+
+  return (
+    <GridEditable
+      isLoading={props.isLoading}
+      error={props.error}
+      data={transactions}
+      columnOrder={COLUMN_ORDER.map(key => COLUMNS[key])}
+      columnSortBy={[sort]}
+      grid={{
+        renderHeadCell: renderTableHead<string>({
+          generateSortLink,
+          sortableColumns: SORTABLE_COLUMNS,
+          currentSort: sort,
+          rightAlignedColumns: RIGHT_ALIGNED_COLUMNS,
+        }),
+        renderBodyCell: renderTableBody,
+      }}
+      location={location}
+    />
+  );
+}
+
+const RIGHT_ALIGNED_COLUMNS = new Set<TableColumnKey>([
+  'count()',
+  'p50()',
+  'p75()',
+  'p90()',
+  'p95()',
+  'p99()',
+]);
+
+const SORTABLE_COLUMNS = new Set<TableColumnKey>([
+  'project',
+  'transaction',
+  'count()',
+  'p50()',
+  'p75()',
+  'p90()',
+  'p95()',
+  'p99()',
+  'last_seen()',
+]);
+
+function renderTableBody(
+  column: GridColumnOrder,
+  dataRow: TableDataRow,
+  rowIndex: number,
+  columnIndex: number
+) {
+  return (
+    <ProfilingTransactionsTableCell
+      column={column}
+      dataRow={dataRow}
+      rowIndex={rowIndex}
+      columnIndex={columnIndex}
+    />
+  );
+}
+
+interface ProfilingTransactionsTableCellProps {
+  column: GridColumnOrder;
+  columnIndex: number;
+  dataRow: TableDataRow;
+  rowIndex: number;
+}
+
+function ProfilingTransactionsTableCell({
+  column,
+  dataRow,
+}: ProfilingTransactionsTableCellProps) {
+  const value = dataRow[column.key];
+
+  switch (column.key) {
+    case 'project':
+      if (!defined(value)) {
+        // should never happen but just in case
+        return <Container>{t('n/a')}</Container>;
+      }
+
+      return (
+        <Container>
+          <ProjectBadge project={value} avatarSize={16} />
+        </Container>
+      );
+    case 'count()':
+      return (
+        <NumberContainer>
+          <Count value={value} />
+        </NumberContainer>
+      );
+    case 'p50()':
+    case 'p75()':
+    case 'p90()':
+    case 'p95()':
+    case 'p99()':
+      return (
+        <NumberContainer>
+          <PerformanceDuration milliseconds={value} abbreviation />
+        </NumberContainer>
+      );
+    case 'last_seen()':
+      return (
+        <Container>
+          <DateTime date={value} year seconds timeZone />
+        </Container>
+      );
+    default:
+      return <Container>{value}</Container>;
+  }
+}
+
+type TableColumnKey =
+  | 'transaction'
+  | 'count()'
+  | 'project'
+  | 'p50()'
+  | 'p75()'
+  | 'p90()'
+  | 'p95()'
+  | 'p99()'
+  | 'last_seen()';
+
+type TableDataRow = Record<TableColumnKey, any>;
+
+type TableColumn = GridColumnOrder<TableColumnKey>;
+
+const COLUMN_ORDER: TableColumnKey[] = [
+  'transaction',
+  'project',
+  'last_seen()',
+  'p75()',
+  'p95()',
+  'count()',
+];
+
+const COLUMNS: Record<TableColumnKey, TableColumn> = {
+  transaction: {
+    key: 'transaction',
+    name: t('Transaction'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  'count()': {
+    key: 'count()',
+    name: t('Count'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  project: {
+    key: 'project',
+    name: t('Project'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  'p50()': {
+    key: 'p50()',
+    name: t('P50'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  'p75()': {
+    key: 'p75()',
+    name: t('P75'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  'p90()': {
+    key: 'p90()',
+    name: t('P90'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  'p95()': {
+    key: 'p95()',
+    name: t('P95'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  'p99()': {
+    key: 'p99()',
+    name: t('P99'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  'last_seen()': {
+    key: 'last_seen()',
+    name: t('Last Seen'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+};
+
+export {ProfileTransactionsTable};

+ 92 - 0
static/app/components/profiling/profilesTable.spec.tsx

@@ -0,0 +1,92 @@
+import {ReactElement, useEffect} from 'react';
+
+import {render, screen} from 'sentry-test/reactTestingLibrary';
+
+import {ProfilesTable} from 'sentry/components/profiling/profilesTable';
+import ProjectsStore from 'sentry/stores/projectsStore';
+
+const project = TestStubs.Project();
+
+function TestContext({children}: {children: ReactElement}) {
+  useEffect(() => {
+    ProjectsStore.loadInitialData([project]);
+    return () => ProjectsStore.reset();
+  }, []);
+
+  return children;
+}
+
+describe('ProfilesTable', function () {
+  it('renders loading', function () {
+    render(
+      <TestContext>
+        <ProfilesTable isLoading error={null} traces={[]} />
+      </TestContext>
+    );
+    expect(screen.getByTestId('loading-indicator')).toBeInTheDocument();
+  });
+
+  it('renders empty data', function () {
+    render(
+      <TestContext>
+        <ProfilesTable isLoading={false} error={null} traces={[]} />
+      </TestContext>
+    );
+
+    expect(screen.getByText('No results found for your query')).toBeInTheDocument();
+  });
+
+  it('renders one trace', function () {
+    const trace = {
+      android_api_level: 0,
+      device_classification: 'low',
+      device_locale: 'en_US',
+      device_manufacturer: 'Apple',
+      device_model: 'iPhone7,2',
+      device_os_build_number: '14F89',
+      device_os_name: 'iOS',
+      device_os_version: '10.3.2',
+      failed: false,
+      id: '75a32ee2e6ed44458f4647b024b615bb',
+      project_id: '2',
+      timestamp: 1653426810,
+      trace_duration_ms: 931.404667,
+      transaction_id: '6051e1bfb94349a88ead9ffec6910eb9',
+      transaction_name: 'iOS_Swift.ViewController',
+      version_code: '1',
+      version_name: '7.16.0',
+    };
+
+    render(
+      <TestContext>
+        <ProfilesTable isLoading={false} error={null} traces={[trace]} />
+      </TestContext>
+    );
+
+    expect(screen.getByText('Status')).toBeInTheDocument();
+
+    expect(screen.getByText('Profile ID')).toBeInTheDocument();
+    expect(screen.getByText('75a32ee2')).toBeInTheDocument();
+
+    expect(screen.getByText('Project')).toBeInTheDocument();
+    expect(screen.getByText('project-slug')).toBeInTheDocument();
+
+    expect(screen.getByText('Transaction Name')).toBeInTheDocument();
+    expect(screen.getByText('iOS_Swift.ViewController')).toBeInTheDocument();
+
+    expect(screen.getByText('Version')).toBeInTheDocument();
+    expect(screen.getByText('7.16.0 (build 1)')).toBeInTheDocument();
+
+    expect(screen.getByText('Timestamp')).toBeInTheDocument();
+    expect(screen.getByText('May 24, 2022 9:13:30 PM UTC')).toBeInTheDocument();
+
+    expect(screen.getByText('Duration')).toBeInTheDocument();
+    expect(screen.getByText('931.40ms')).toBeInTheDocument();
+
+    expect(screen.getByText('Device Model')).toBeInTheDocument();
+    expect(screen.getByText('iPhone7,2')).toBeInTheDocument();
+
+    expect(screen.getByText('Device Classification')).toBeInTheDocument();
+    expect(screen.getByText('low')).toBeInTheDocument();
+  });
+});

+ 275 - 0
static/app/components/profiling/profilesTable.tsx

@@ -0,0 +1,275 @@
+import {Fragment} from 'react';
+import * as Sentry from '@sentry/react';
+
+import DateTime from 'sentry/components/dateTime';
+import GridEditable, {
+  COL_WIDTH_UNDEFINED,
+  GridColumnOrder,
+} from 'sentry/components/gridEditable';
+import ProjectBadge from 'sentry/components/idBadge/projectBadge';
+import Link from 'sentry/components/links/link';
+import PerformanceDuration from 'sentry/components/performanceDuration';
+import {IconCheckmark, IconClose} from 'sentry/icons';
+import {t} from 'sentry/locale';
+import {Trace} from 'sentry/types/profiling/core';
+import {defined} from 'sentry/utils';
+import {Container, NumberContainer} from 'sentry/utils/discover/styles';
+import {getShortEventId} from 'sentry/utils/events';
+import {
+  generateProfileFlamechartRoute,
+  generateProfileSummaryRouteWithQuery,
+} from 'sentry/utils/profiling/routes';
+import {renderTableHead} from 'sentry/utils/profiling/tableRenderer';
+import {useLocation} from 'sentry/utils/useLocation';
+import useOrganization from 'sentry/utils/useOrganization';
+import useProjects from 'sentry/utils/useProjects';
+
+const REQUIRE_PROJECT_COLUMNS: Set<TableColumnKey> = new Set([
+  'id',
+  'project_id',
+  'transaction_name',
+]);
+
+interface ProfilesTableProps {
+  error: string | null;
+  isLoading: boolean;
+  traces: Trace[];
+  columnOrder?: Readonly<TableColumnKey[]>;
+}
+
+function ProfilesTable(props: ProfilesTableProps) {
+  const location = useLocation();
+
+  return (
+    <Fragment>
+      <GridEditable
+        isLoading={props.isLoading}
+        error={props.error}
+        data={props.traces}
+        columnOrder={(props.columnOrder ?? DEFAULT_COLUMN_ORDER).map(key => COLUMNS[key])}
+        columnSortBy={[]}
+        grid={{
+          renderHeadCell: renderTableHead({rightAlignedColumns: RIGHT_ALIGNED_COLUMNS}),
+          renderBodyCell: renderProfilesTableCell,
+        }}
+        location={location}
+      />
+    </Fragment>
+  );
+}
+
+const RIGHT_ALIGNED_COLUMNS = new Set<TableColumnKey>(['trace_duration_ms']);
+
+function renderProfilesTableCell(
+  column: TableColumn,
+  dataRow: TableDataRow,
+  rowIndex: number,
+  columnIndex: number
+) {
+  return (
+    <ProfilesTableCell
+      column={column}
+      dataRow={dataRow}
+      rowIndex={rowIndex}
+      columnIndex={columnIndex}
+    />
+  );
+}
+
+interface ProfilesTableCellProps {
+  column: TableColumn;
+  columnIndex: number;
+  dataRow: TableDataRow;
+  rowIndex: number;
+}
+
+function ProfilesTableCell({column, dataRow}: ProfilesTableCellProps) {
+  const organization = useOrganization();
+  const {projects} = useProjects();
+  const location = useLocation();
+
+  // Not all columns need the project, so small optimization to avoid
+  // the linear lookup for every cell.
+  const project = REQUIRE_PROJECT_COLUMNS.has(column.key)
+    ? projects.find(proj => proj.id === dataRow.project_id)
+    : undefined;
+
+  if (REQUIRE_PROJECT_COLUMNS.has(column.key) && !defined(project)) {
+    Sentry.withScope(scope => {
+      scope.setFingerprint(['profiles table', 'cell', 'missing project']);
+      scope.setTag('cell_key', column.key);
+      scope.setTag('missing_project', dataRow.project_id);
+      scope.setTag('available_project', projects.length);
+      Sentry.captureMessage(`Project ${dataRow.project_id} missing for ${column.key}`);
+    });
+  }
+
+  const value = dataRow[column.key];
+
+  switch (column.key) {
+    case 'id':
+      if (!defined(project)) {
+        // should never happen but just in case
+        return <Container>{getShortEventId(dataRow.id)}</Container>;
+      }
+
+      const flamegraphTarget = generateProfileFlamechartRoute({
+        orgSlug: organization.slug,
+        projectSlug: project.slug,
+        profileId: dataRow.id,
+      });
+
+      return (
+        <Container>
+          <Link to={flamegraphTarget}>{getShortEventId(dataRow.id)}</Link>
+        </Container>
+      );
+    case 'project_id':
+      if (!defined(project)) {
+        // should never happen but just in case
+        return <Container>{t('n/a')}</Container>;
+      }
+
+      return (
+        <Container>
+          <ProjectBadge project={project} avatarSize={16} />
+        </Container>
+      );
+    case 'transaction_name':
+      if (!defined(project)) {
+        // should never happen but just in case
+        return <Container>{t('n/a')}</Container>;
+      }
+
+      const profileSummaryTarget = generateProfileSummaryRouteWithQuery({
+        query: location.query,
+        orgSlug: organization.slug,
+        projectSlug: project.slug,
+        transaction: dataRow.transaction_name,
+      });
+
+      return (
+        <Container>
+          <Link to={profileSummaryTarget}>{value}</Link>
+        </Container>
+      );
+    case 'version_name':
+      return (
+        <Container>
+          {dataRow.version_code ? t('%s (build %s)', value, dataRow.version_code) : value}
+        </Container>
+      );
+    case 'failed':
+      return (
+        <Container>
+          {value ? (
+            <IconClose size="sm" color="red300" isCircled />
+          ) : (
+            <IconCheckmark size="sm" color="green300" isCircled />
+          )}
+        </Container>
+      );
+    case 'timestamp':
+      return (
+        <Container>
+          <DateTime date={value * 1000} year seconds timeZone />
+        </Container>
+      );
+    case 'trace_duration_ms':
+      return (
+        <NumberContainer>
+          <PerformanceDuration milliseconds={value} abbreviation />
+        </NumberContainer>
+      );
+    default:
+      return <Container>{value}</Container>;
+  }
+}
+
+type TableColumnKey = keyof Trace;
+
+type NonTableColumnKey =
+  | 'version_code'
+  | 'device_locale'
+  | 'device_manufacturer'
+  | 'backtrace_available'
+  | 'error_code'
+  | 'error_code_name'
+  | 'error_description'
+  | 'span_annotations'
+  | 'spans'
+  | 'trace_annotations';
+
+type TableColumn = GridColumnOrder<TableColumnKey>;
+
+type TableDataRow = Omit<Record<TableColumnKey, any>, NonTableColumnKey> &
+  Partial<Record<TableColumnKey, any>>;
+
+type TableColumnOrders = Omit<Record<TableColumnKey, TableColumn>, NonTableColumnKey>;
+
+const DEFAULT_COLUMN_ORDER: TableColumnKey[] = [
+  'failed',
+  'id',
+  'project_id',
+  'transaction_name',
+  'version_name',
+  'timestamp',
+  'trace_duration_ms',
+  'device_model',
+  'device_classification',
+];
+
+const COLUMNS: TableColumnOrders = {
+  id: {
+    key: 'id',
+    name: t('Profile ID'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  project_id: {
+    key: 'project_id',
+    name: t('Project'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  failed: {
+    key: 'failed',
+    name: t('Status'),
+    width: 14, // make this as small as possible
+  },
+  version_name: {
+    key: 'version_name',
+    name: t('Version'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  transaction_name: {
+    key: 'transaction_name',
+    name: t('Transaction Name'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  timestamp: {
+    key: 'timestamp',
+    name: t('Timestamp'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  trace_duration_ms: {
+    key: 'trace_duration_ms',
+    name: t('Duration'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  device_model: {
+    key: 'device_model',
+    name: t('Device Model'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  device_classification: {
+    key: 'device_classification',
+    name: t('Device Classification'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  device_os_version: {
+    key: 'device_os_version',
+    name: t('Device OS Version'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+};
+
+export {ProfilesTable};

+ 2 - 2
static/app/utils/profiling/hooks/useProfileEvents.tsx

@@ -14,7 +14,7 @@ type Sort<F> = {
 };
 
 interface UseProfileEventsOptions<F> {
-  fields: readonly F[];
+  fields: F[];
   sort: Sort<F>;
   cursor?: string;
   limit?: number;
@@ -82,7 +82,7 @@ export function useProfileEvents<F extends string>({
 
 export function formatSort<F extends string>(
   value: string | undefined,
-  allowedKeys: readonly F[],
+  allowedKeys: F[],
   fallback: Sort<F>
 ): Sort<F> {
   value = value || '';

+ 5 - 23
static/app/utils/profiling/hooks/useProfileFilters.tsx

@@ -25,16 +25,11 @@ function useProfileFilters({query, selection}: ProfileFiltersOptions): TagCollec
     fetchProfileFilters(api, organization, query, selection).then(response => {
       const withPredefinedFilters = response.reduce(
         (filters: TagCollection, tag: Tag) => {
-          if (TAG_KEY_MAPPING[tag.key]) {
-            // for now, we're going to use this translation to handle auto
-            // completion but we should update the response in the future
-            tag.key = TAG_KEY_MAPPING[tag.key];
-            filters[tag.key] = {
-              ...tag,
-              // predefined allows us to specify a list of possible values
-              predefined: true,
-            };
-          }
+          filters[tag.key] = {
+            ...tag,
+            // predefined allows us to specify a list of possible values
+            predefined: true,
+          };
           return filters;
         },
         {}
@@ -66,17 +61,4 @@ function fetchProfileFilters(
   });
 }
 
-const TAG_KEY_MAPPING = {
-  version: 'release',
-  device_locale: 'device.locale',
-  platform: 'platform.name',
-  transaction_name: 'transaction',
-  device_os_build_number: 'os.build',
-  device_os_name: 'os.name',
-  device_os_version: 'os.version',
-  device_model: 'device.model',
-  device_manufacturer: 'device.manufacturer',
-  device_classification: 'device.classification',
-};
-
 export {useProfileFilters};

+ 108 - 0
static/app/utils/profiling/hooks/useProfileTransactions.tsx

@@ -0,0 +1,108 @@
+import {useEffect, useState} from 'react';
+import * as Sentry from '@sentry/react';
+
+import {Client} from 'sentry/api';
+import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
+import {t} from 'sentry/locale';
+import {Organization, PageFilters, RequestState} from 'sentry/types';
+import {ProfileTransaction} from 'sentry/types/profiling/core';
+import {defined} from 'sentry/utils';
+import useApi from 'sentry/utils/useApi';
+import useOrganization from 'sentry/utils/useOrganization';
+
+type ProfileTransactionsResult = {
+  pageLinks: string | null;
+  transactions: ProfileTransaction[];
+};
+
+interface UseProfileTransactionsOptions {
+  query: string;
+  sort: string;
+  cursor?: string;
+  limit?: number;
+  selection?: PageFilters;
+}
+
+function useProfileTransactions({
+  cursor,
+  sort,
+  limit,
+  query,
+  selection,
+}: UseProfileTransactionsOptions): RequestState<ProfileTransactionsResult> {
+  const api = useApi();
+  const organization = useOrganization();
+
+  const [requestState, setRequestState] = useState<
+    RequestState<ProfileTransactionsResult>
+  >({
+    type: 'initial',
+  });
+
+  useEffect(() => {
+    if (!defined(selection)) {
+      return undefined;
+    }
+
+    setRequestState({type: 'loading'});
+
+    fetchTransactions(api, organization, {cursor, limit, query, selection, sort})
+      .then(([transactions, , response]) => {
+        setRequestState({
+          type: 'resolved',
+          data: {
+            transactions,
+            pageLinks: response?.getResponseHeader('Link') ?? null,
+          },
+        });
+      })
+      .catch(err => {
+        setRequestState({
+          type: 'errored',
+          error: t('Error: Unable to load transactions'),
+        });
+        Sentry.captureException(err);
+      });
+
+    return () => api.clear();
+  }, [api, organization, cursor, limit, query, selection, sort]);
+
+  return requestState;
+}
+
+function fetchTransactions(
+  api: Client,
+  organization: Organization,
+  {
+    cursor,
+    limit,
+    query,
+    selection,
+    sort,
+  }: {
+    cursor: string | undefined;
+    limit: number | undefined;
+    query: string;
+    selection: PageFilters;
+    sort: string;
+  }
+) {
+  return api.requestPromise(
+    `/organizations/${organization.slug}/profiling/transactions/`,
+    {
+      method: 'GET',
+      includeAllArgs: true,
+      query: {
+        cursor,
+        sort,
+        query,
+        per_page: limit,
+        project: selection.projects,
+        environment: selection.environments,
+        ...normalizeDateTimeParams(selection.datetime),
+      },
+    }
+  );
+}
+
+export {useProfileTransactions};

+ 61 - 0
static/app/utils/profiling/hooks/useProfiles.spec.tsx

@@ -0,0 +1,61 @@
+import {ReactNode, useMemo} from 'react';
+
+import {initializeOrg} from 'sentry-test/initializeOrg';
+import {reactHooks} from 'sentry-test/reactTestingLibrary';
+
+import {PageFilters} from 'sentry/types';
+import {useProfiles} from 'sentry/utils/profiling/hooks/useProfiles';
+import {OrganizationContext} from 'sentry/views/organizationContext';
+
+const selection: PageFilters = {
+  datetime: {
+    period: '14d',
+    utc: null,
+    start: null,
+    end: null,
+  },
+  environments: [],
+  projects: [],
+};
+
+function TestContext({children}: {children?: ReactNode}) {
+  const {organization} = useMemo(() => initializeOrg(), []);
+
+  return (
+    <OrganizationContext.Provider value={organization}>
+      {children}
+    </OrganizationContext.Provider>
+  );
+}
+
+describe('useProfiles', function () {
+  afterEach(function () {
+    MockApiClient.clearMockResponses();
+  });
+
+  it('initializes with the initial state', function () {
+    const hook = reactHooks.renderHook(useProfiles, {
+      wrapper: TestContext,
+      initialProps: {query: ''},
+    });
+    expect(hook.result.current).toEqual({type: 'initial'});
+  });
+
+  it('fetches profiles', async function () {
+    MockApiClient.addMockResponse({
+      url: '/organizations/org-slug/profiling/profiles/',
+      body: [],
+    });
+
+    const hook = reactHooks.renderHook(useProfiles, {
+      wrapper: TestContext,
+      initialProps: {query: '', selection},
+    });
+    expect(hook.result.current).toEqual({type: 'loading'});
+    await hook.waitForNextUpdate();
+    expect(hook.result.current).toEqual({
+      type: 'resolved',
+      data: {traces: [], pageLinks: null},
+    });
+  });
+});

+ 95 - 0
static/app/utils/profiling/hooks/useProfiles.tsx

@@ -0,0 +1,95 @@
+import {useEffect, useState} from 'react';
+import * as Sentry from '@sentry/react';
+
+import {Client} from 'sentry/api';
+import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
+import {t} from 'sentry/locale';
+import {Organization, PageFilters, RequestState} from 'sentry/types';
+import {Trace} from 'sentry/types/profiling/core';
+import {defined} from 'sentry/utils';
+import useApi from 'sentry/utils/useApi';
+import useOrganization from 'sentry/utils/useOrganization';
+
+type ProfilesResult = {
+  pageLinks: string | null;
+  traces: Trace[];
+};
+
+interface UseProfilesOptions {
+  query: string;
+  cursor?: string;
+  limit?: number;
+  selection?: PageFilters;
+}
+
+function useProfiles({
+  cursor,
+  limit,
+  query,
+  selection,
+}: UseProfilesOptions): RequestState<ProfilesResult> {
+  const api = useApi();
+  const organization = useOrganization();
+
+  const [requestState, setRequestState] = useState<RequestState<ProfilesResult>>({
+    type: 'initial',
+  });
+
+  useEffect(() => {
+    if (!defined(selection)) {
+      return undefined;
+    }
+
+    setRequestState({type: 'loading'});
+
+    fetchTraces(api, organization, {cursor, limit, query, selection})
+      .then(([traces, , response]) => {
+        setRequestState({
+          type: 'resolved',
+          data: {
+            traces,
+            pageLinks: response?.getResponseHeader('Link') ?? null,
+          },
+        });
+      })
+      .catch(err => {
+        setRequestState({type: 'errored', error: t('Error: Unable to load profiles')});
+        Sentry.captureException(err);
+      });
+
+    return () => api.clear();
+  }, [api, organization, cursor, limit, query, selection]);
+
+  return requestState;
+}
+
+function fetchTraces(
+  api: Client,
+  organization: Organization,
+  {
+    cursor,
+    limit,
+    query,
+    selection,
+  }: {
+    cursor: string | undefined;
+    limit: number | undefined;
+    query: string;
+    selection: PageFilters;
+  }
+) {
+  return api.requestPromise(`/organizations/${organization.slug}/profiling/profiles/`, {
+    method: 'GET',
+    includeAllArgs: true,
+    query: {
+      cursor,
+      query,
+      per_page: limit,
+      project: selection.projects,
+      environment: selection.environments,
+      ...normalizeDateTimeParams(selection.datetime),
+    },
+  });
+}
+
+export {useProfiles};

+ 20 - 41
static/app/views/profiling/content.tsx

@@ -13,7 +13,7 @@ import PageFilterBar from 'sentry/components/organizations/pageFilterBar';
 import PageFiltersContainer from 'sentry/components/organizations/pageFilters/container';
 import PageHeading from 'sentry/components/pageHeading';
 import Pagination from 'sentry/components/pagination';
-import {ProfileEventsTable} from 'sentry/components/profiling/profileEventsTable';
+import {ProfileTransactionsTable} from 'sentry/components/profiling/profileTransactionsTable';
 import {ProfilingOnboardingModal} from 'sentry/components/profiling/ProfilingOnboarding/profilingOnboardingModal';
 import ProjectPageFilter from 'sentry/components/projectPageFilter';
 import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
@@ -26,11 +26,8 @@ import space from 'sentry/styles/space';
 import {Project} from 'sentry/types';
 import {PageFilters} from 'sentry/types/core';
 import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
-import {
-  formatSort,
-  useProfileEvents,
-} from 'sentry/utils/profiling/hooks/useProfileEvents';
 import {useProfileFilters} from 'sentry/utils/profiling/hooks/useProfileFilters';
+import {useProfileTransactions} from 'sentry/utils/profiling/hooks/useProfileTransactions';
 import {decodeScalar} from 'sentry/utils/queryString';
 import useOrganization from 'sentry/utils/useOrganization';
 import usePageFilters from 'sentry/utils/usePageFilters';
@@ -88,21 +85,15 @@ function ProfilingContent({location, router}: ProfilingContentProps) {
   const {selection} = usePageFilters();
   const cursor = decodeScalar(location.query.cursor);
   const query = decodeScalar(location.query.query, '');
-
-  const sort = formatSort<FieldType>(decodeScalar(location.query.sort), FIELDS, {
-    key: 'count()',
-    order: 'desc',
-  });
-
+  const transactionsSort = decodeScalar(location.query.sort, '-count()');
   const profileFilters = useProfileFilters({query: '', selection});
-  const {projects} = useProjects();
-
-  const transactions = useProfileEvents<FieldType>({
+  const transactions = useProfileTransactions({
     cursor,
-    fields: FIELDS,
     query,
-    sort,
+    selection,
+    sort: transactionsSort,
   });
+  const {projects} = useProjects();
 
   useEffect(() => {
     trackAdvancedAnalyticsEvent('profiling_views.landing', {
@@ -132,11 +123,11 @@ function ProfilingContent({location, router}: ProfilingContentProps) {
   }, [organization]);
 
   const shouldShowProfilingOnboardingPanel = useMemo((): boolean => {
-    if (transactions.status !== 'success') {
+    if (transactions.type !== 'resolved') {
       return false;
     }
 
-    if (transactions.data[0].data.length > 0) {
+    if (transactions.data.transactions.length > 0) {
       return false;
     }
     return !hasSetupProfilingForAtLeastOneProject(selection.projects, projects);
@@ -203,24 +194,24 @@ function ProfilingContent({location, router}: ProfilingContentProps) {
                 ) : (
                   <Fragment>
                     <ProfileCharts router={router} query={query} selection={selection} />
-                    <ProfileEventsTable
-                      columns={FIELDS.slice()}
-                      data={
-                        transactions.status === 'success' ? transactions.data[0] : null
-                      }
+                    <ProfileTransactionsTable
                       error={
-                        transactions.status === 'error'
+                        transactions.type === 'errored'
                           ? t('Unable to load profiles')
                           : null
                       }
-                      isLoading={transactions.status === 'loading'}
-                      sort={sort}
-                      sortableColumns={new Set(FIELDS)}
+                      isLoading={transactions.type === 'loading'}
+                      sort={transactionsSort}
+                      transactions={
+                        transactions.type === 'resolved'
+                          ? transactions.data.transactions
+                          : []
+                      }
                     />
                     <Pagination
                       pageLinks={
-                        transactions.status === 'success'
-                          ? transactions.data?.[2]?.getResponseHeader('Link') ?? null
+                        transactions.type === 'resolved'
+                          ? transactions.data.pageLinks
                           : null
                       }
                     />
@@ -235,18 +226,6 @@ function ProfilingContent({location, router}: ProfilingContentProps) {
   );
 }
 
-const FIELDS = [
-  'transaction',
-  'project.id',
-  'last_seen()',
-  'p75()',
-  'p95()',
-  'p99()',
-  'count()',
-] as const;
-
-type FieldType = typeof FIELDS[number];
-
 const StyledPageContent = styled(PageContent)`
   padding: 0;
 `;

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