Browse Source

feat(browser-starfish): fetch dom selectors, add sorting, refactor (#55856)

This PR makes multiple changes
1. Fetch dom selectors to populate list

![image](https://github.com/getsentry/sentry/assets/44422760/f223ee58-3eae-42e1-a79e-18ca33d85db1)

2. Add the `count()` column

![image](https://github.com/getsentry/sentry/assets/44422760/35e31bde-b730-48b2-9c13-fb813c1a7c67)

3. Make sorting work
Dominik Buszowiecki 1 year ago
parent
commit
c94d7bcd1a

+ 60 - 31
static/app/views/performance/browser/interactionTable.tsx

@@ -1,80 +1,109 @@
+import {Fragment} from 'react';
 import {Link} from 'react-router';
 
 import GridEditable, {
   COL_WIDTH_UNDEFINED,
   GridColumnHeader,
   GridColumnOrder,
-  GridColumnSortBy,
 } from 'sentry/components/gridEditable';
+import Pagination from 'sentry/components/pagination';
 import {useLocation} from 'sentry/utils/useLocation';
+import {ValidSort} from 'sentry/views/performance/browser/useBrowserSort';
 import {useInteractionsQuery} from 'sentry/views/performance/browser/useInteractionsQuery';
+import {DurationCell} from 'sentry/views/starfish/components/tableCells/durationCell';
+import {renderHeadCell} from 'sentry/views/starfish/components/tableCells/renderHeadCell';
+import {TextAlignRight} from 'sentry/views/starfish/components/textAlign';
 
 type Row = {
-  component: string;
-  p75: number;
-  page: string;
-  'span.action': string;
+  'count()': number;
+  interactionElement: string;
+  'p75(transaction.duration)': number;
   'span.group': string;
+  transaction: string;
+  'transaction.op': string;
 };
 
 type Column = GridColumnHeader<keyof Row>;
 
-function InteractionsTable() {
+type Props = {
+  sort: ValidSort;
+};
+
+function InteractionsTable({sort}: Props) {
   const location = useLocation();
   const columnOrder: GridColumnOrder<keyof Row>[] = [
     {key: 'span.group', width: COL_WIDTH_UNDEFINED, name: 'Interaction'},
-    {key: 'page', width: COL_WIDTH_UNDEFINED, name: 'Page'},
-    {key: 'p75', width: COL_WIDTH_UNDEFINED, name: 'Duration (p75)'},
+    {key: 'transaction', width: COL_WIDTH_UNDEFINED, name: 'Page'},
+    {key: 'count()', width: COL_WIDTH_UNDEFINED, name: 'Count'},
+    {
+      key: 'p75(transaction.duration)',
+      width: COL_WIDTH_UNDEFINED,
+      name: 'Duration (p75)',
+    },
   ];
-  const {data, isLoading} = useInteractionsQuery();
+  const {data, isLoading, pageLinks} = useInteractionsQuery({sort});
   const tableData: Row[] =
     !isLoading && data.length
       ? data.map(row => ({
           'span.group': 'NOT IMPLEMENTED',
-          component: row.interactionElement,
-          p75: row['p75(transaction.duration)'],
-          page: row.transaction,
-          'span.action': getFriendlyActionName(row['transaction.op']),
+          ...row,
         }))
       : [];
 
-  const sort: GridColumnSortBy<keyof Row> = {key: 'p75', order: 'desc'};
-
-  const renderHeadCell = (col: Column) => {
-    return <span>{col.name}</span>;
-  };
-
   const renderBodyCell = (col: Column, row: Row) => {
     const {key} = col;
     if (key === 'span.group') {
       const spanGroup = row['span.group'];
       return (
         <Link to={`/performance/browser/interactions/${spanGroup}`}>
-          {row['span.action']} on {row.component}
+          {getActionName(row['transaction.op'])}
+          <span style={{fontWeight: 'bold'}}> {row.interactionElement}</span>
         </Link>
       );
     }
+    if (key === 'p75(transaction.duration)') {
+      return <DurationCell milliseconds={row[key]} />;
+    }
+    if (key === 'count()') {
+      return <TextAlignRight>{row[key]}</TextAlignRight>;
+    }
     return <span>{row[key]}</span>;
   };
 
   return (
-    <GridEditable
-      data={tableData}
-      isLoading={isLoading}
-      columnOrder={columnOrder}
-      columnSortBy={[sort]}
-      grid={{renderHeadCell, renderBodyCell}}
-      location={location}
-    />
+    <Fragment>
+      <GridEditable
+        data={tableData}
+        isLoading={isLoading}
+        columnOrder={columnOrder}
+        columnSortBy={[
+          {
+            key: sort.field,
+            order: sort.kind,
+          },
+        ]}
+        grid={{
+          renderHeadCell: column =>
+            renderHeadCell({
+              column,
+              location,
+              sort,
+            }),
+          renderBodyCell,
+        }}
+        location={location}
+      />
+      <Pagination pageLinks={pageLinks} />
+    </Fragment>
   );
 }
 
-const getFriendlyActionName = (action: string) => {
-  switch (action) {
+const getActionName = (transactionOp: string) => {
+  switch (transactionOp) {
     case 'ui.action.click':
       return 'Click';
     default:
-      return action;
+      return transactionOp;
   }
 };
 

+ 36 - 29
static/app/views/performance/browser/interactionsLandingPage.tsx

@@ -20,10 +20,12 @@ import {
   BrowserStarfishFields,
   useBrowserModuleFilters,
 } from 'sentry/views/performance/browser/useBrowserFilters';
+import {useBrowserSort} from 'sentry/views/performance/browser/useBrowserSort';
+import {useInteractionElementQuery} from 'sentry/views/performance/browser/useInteractionElementQuery';
 import {usePagesQuery} from 'sentry/views/performance/browser/usePageQuery';
 import {ModulePageProviders} from 'sentry/views/performance/database/modulePageProviders';
 
-const {COMPONENT, PAGE, SPAN_ACTION} = BrowserStarfishFields;
+const {COMPONENT, PAGE, TRANSACTION_OP} = BrowserStarfishFields;
 
 type Option = {
   label: string;
@@ -33,6 +35,7 @@ type Option = {
 function InteractionsLandingPage() {
   const organization = useOrganization();
   const filters = useBrowserModuleFilters();
+  const sort = useBrowserSort();
 
   return (
     <ModulePageProviders title={[t('Performance'), t('Interactions')].join(' — ')}>
@@ -59,26 +62,22 @@ function InteractionsLandingPage() {
       </Layout.Header>
 
       <Layout.Body>
-        <Layout.Main fullWidth />
-
-        <PaddedContainer>
-          <PageFilterBar condensed>
-            <ProjectPageFilter />
-            <DatePageFilter alignDropdown="left" />
-          </PageFilterBar>
-        </PaddedContainer>
-
-        <div />
-
-        <FilterOptionsContainer>
-          <ComponentSelector value={filters[COMPONENT] || ''} />
-          <ActionSelector value={filters[SPAN_ACTION] || ''} />
-          <PageSelector value={filters[PAGE] || ''} />
-        </FilterOptionsContainer>
-
-        <div />
-
-        <InteractionsTable />
+        <Layout.Main fullWidth>
+          <PaddedContainer>
+            <PageFilterBar condensed>
+              <ProjectPageFilter />
+              <DatePageFilter alignDropdown="left" />
+            </PageFilterBar>
+          </PaddedContainer>
+
+          <FilterOptionsContainer>
+            <ComponentSelector value={filters[COMPONENT] || ''} />
+            <ActionSelector value={filters[TRANSACTION_OP] || ''} />
+            <PageSelector value={filters[PAGE] || ''} />
+          </FilterOptionsContainer>
+
+          <InteractionsTable sort={sort} />
+        </Layout.Main>
       </Layout.Body>
     </ModulePageProviders>
   );
@@ -87,11 +86,19 @@ function InteractionsLandingPage() {
 function ComponentSelector({value}: {value?: string}) {
   const location = useLocation();
 
-  const options: Option[] = [
-    {value: '', label: 'All'},
-    {value: 'downloadButton', label: '<DownloadButton/>'},
-    {value: 'closeButton', label: '<CloseButton/>'},
-  ];
+  const {data, isLoading} = useInteractionElementQuery();
+
+  const options: Option[] =
+    !isLoading && data.length
+      ? [
+          {label: 'All', value: ''},
+          ...data.map(element => ({
+            label: element,
+            value: element,
+          })),
+        ]
+      : [];
+
   return (
     <SelectControlWithProps
       inFieldLabel={`${t('Component')}:`}
@@ -115,8 +122,8 @@ function ActionSelector({value}: {value?: string}) {
 
   const options: Option[] = [
     {value: '', label: 'All'},
-    {value: 'click', label: 'Click'},
-    {value: 'change', label: 'Change'},
+    {value: 'ui.action.click', label: 'Click'},
+    {value: 'ui.action.right.click', label: 'Right Click'},
   ];
   return (
     <SelectControlWithProps
@@ -128,7 +135,7 @@ function ActionSelector({value}: {value?: string}) {
           ...location,
           query: {
             ...location.query,
-            [SPAN_ACTION]: newValue?.value,
+            [TRANSACTION_OP]: newValue?.value,
           },
         });
       }}

+ 3 - 4
static/app/views/performance/browser/useBrowserFilters.ts

@@ -1,16 +1,15 @@
 import pick from 'lodash/pick';
 
 import {useLocation} from 'sentry/utils/useLocation';
-import {SpanMetricsField} from 'sentry/views/starfish/types';
 
 export enum BrowserStarfishFields {
-  SPAN_ACTION = SpanMetricsField.SPAN_ACTION,
+  TRANSACTION_OP = 'transaction.op',
   COMPONENT = 'component',
   PAGE = 'page',
 }
 
 export type ModuleFilters = {
-  [BrowserStarfishFields.SPAN_ACTION]?: string;
+  [BrowserStarfishFields.TRANSACTION_OP]?: string;
   [BrowserStarfishFields.COMPONENT]?: string;
   [BrowserStarfishFields.PAGE]?: string;
 };
@@ -19,7 +18,7 @@ export const useBrowserModuleFilters = () => {
   const location = useLocation<ModuleFilters>();
 
   return pick(location.query, [
-    BrowserStarfishFields.SPAN_ACTION,
+    BrowserStarfishFields.TRANSACTION_OP,
     BrowserStarfishFields.COMPONENT,
     BrowserStarfishFields.PAGE,
   ]);

+ 36 - 0
static/app/views/performance/browser/useBrowserSort.ts

@@ -0,0 +1,36 @@
+import {fromSorts} from 'sentry/utils/discover/eventView';
+import type {Sort} from 'sentry/utils/discover/fields';
+import {useLocation} from 'sentry/utils/useLocation';
+import {QueryParameterNames} from 'sentry/views/starfish/views/queryParameters';
+
+type Query = {
+  sort?: string;
+};
+
+const SORTABLE_FIELDS = ['count()', 'p75(transaction.duration)', 'transaction'] as const;
+
+export type ValidSort = Sort & {
+  field: (typeof SORTABLE_FIELDS)[number];
+};
+
+/**
+ * Parses a `Sort` object from the URL. In case of multiple specified sorts
+ * picks the first one, since span module UIs only support one sort at a time.
+ */
+export function useBrowserSort(
+  sortParameterName: QueryParameterNames | 'sort' = 'sort',
+  fallback: Sort = DEFAULT_SORT
+) {
+  const location = useLocation<Query>();
+
+  return fromSorts(location.query[sortParameterName]).filter(isAValidSort)[0] ?? fallback;
+}
+
+const DEFAULT_SORT: Sort = {
+  kind: 'desc',
+  field: SORTABLE_FIELDS[0],
+};
+
+function isAValidSort(sort: Sort): sort is ValidSort {
+  return (SORTABLE_FIELDS as unknown as string[]).includes(sort.field);
+}

+ 50 - 0
static/app/views/performance/browser/useInteractionElementQuery.ts

@@ -0,0 +1,50 @@
+import {useDiscoverQuery} from 'sentry/utils/discover/discoverQuery';
+import EventView from 'sentry/utils/discover/eventView';
+import {useLocation} from 'sentry/utils/useLocation';
+import useOrganization from 'sentry/utils/useOrganization';
+import usePageFilters from 'sentry/utils/usePageFilters';
+import {
+  BrowserStarfishFields,
+  useBrowserModuleFilters,
+} from 'sentry/views/performance/browser/useBrowserFilters';
+
+/**
+ * Gets a list of all interactionElements on the selected project(s)
+ */
+export const useInteractionElementQuery = () => {
+  const location = useLocation();
+  const pageFilters = usePageFilters();
+  const {slug: orgSlug} = useOrganization();
+  const browserFilters = useBrowserModuleFilters();
+
+  const fields = ['interactionElement', 'count()'];
+  const queryConditions = [
+    'has:interactionElement',
+    browserFilters.page ? `transaction:"${browserFilters.page}"` : '',
+    browserFilters['transaction.op']
+      ? `transaction.op:"${browserFilters[BrowserStarfishFields.TRANSACTION_OP]}"`
+      : '',
+  ];
+
+  const eventView = EventView.fromNewQueryWithPageFilters(
+    {
+      fields,
+      name: 'Interaction module - page selector',
+      version: 2,
+      query: queryConditions.join(' '),
+      orderby: 'interactionElement',
+    },
+    pageFilters.selection
+  );
+
+  const result = useDiscoverQuery({
+    eventView,
+    location,
+    orgSlug,
+    limit: 100,
+  });
+
+  const interactionElements =
+    result?.data?.data.map(row => row.interactionElement.toString()) || [];
+  return {...result, data: interactionElements};
+};

+ 15 - 3
static/app/views/performance/browser/useInteractionsQuery.ts

@@ -3,16 +3,24 @@ import EventView from 'sentry/utils/discover/eventView';
 import {useLocation} from 'sentry/utils/useLocation';
 import useOrganization from 'sentry/utils/useOrganization';
 import usePageFilters from 'sentry/utils/usePageFilters';
-import {useBrowserModuleFilters} from 'sentry/views/performance/browser/useBrowserFilters';
+import {
+  BrowserStarfishFields,
+  useBrowserModuleFilters,
+} from 'sentry/views/performance/browser/useBrowserFilters';
+import {ValidSort} from 'sentry/views/performance/browser/useBrowserSort';
 
-export const useInteractionsQuery = () => {
+export const useInteractionsQuery = ({sort}: {sort: ValidSort}) => {
   const pageFilters = usePageFilters();
   const browserFilters = useBrowserModuleFilters();
   const location = useLocation();
   const {slug: orgSlug} = useOrganization();
   const queryConditions = [
     'has:interactionElement',
-    browserFilters.page ? `transaction:${browserFilters.page}` : '',
+    browserFilters.page ? `transaction:"${browserFilters.page}"` : '',
+    browserFilters.component ? `interactionElement:"${browserFilters.component}"` : '',
+    browserFilters['transaction.op']
+      ? `transaction.op:"${browserFilters[BrowserStarfishFields.TRANSACTION_OP]}"`
+      : '',
   ];
 
   // TODO - we should be using metrics data here
@@ -33,6 +41,10 @@ export const useInteractionsQuery = () => {
     pageFilters.selection
   );
 
+  if (sort) {
+    eventView.sorts = [sort];
+  }
+
   const result = useDiscoverQuery({eventView, limit: 50, location, orgSlug});
 
   const data = result?.data?.data.map(row => ({

+ 12 - 3
static/app/views/performance/browser/usePageQuery.ts

@@ -1,9 +1,12 @@
 import {useDiscoverQuery} from 'sentry/utils/discover/discoverQuery';
 import EventView from 'sentry/utils/discover/eventView';
-import {DiscoverDatasets} from 'sentry/utils/discover/types';
 import {useLocation} from 'sentry/utils/useLocation';
 import useOrganization from 'sentry/utils/useOrganization';
 import usePageFilters from 'sentry/utils/usePageFilters';
+import {
+  BrowserStarfishFields,
+  useBrowserModuleFilters,
+} from 'sentry/views/performance/browser/useBrowserFilters';
 
 /**
  * Gets a list of pages on the selected project(s)
@@ -12,16 +15,22 @@ export const usePagesQuery = () => {
   const location = useLocation();
   const pageFilters = usePageFilters();
   const {slug: orgSlug} = useOrganization();
+  const browserFilters = useBrowserModuleFilters();
 
   const fields = ['transaction', 'p75(transaction.duration)', 'tpm()'];
-  const queryConditions = ['event.type:transaction', 'transaction.op:pageload']; // TODO: We will need to consider other ops
+  const queryConditions = [
+    'event.type:transaction',
+    browserFilters.component ? `interactionElement:"${browserFilters.component}"` : '',
+    browserFilters['transaction.op']
+      ? `transaction.op:"${browserFilters[BrowserStarfishFields.TRANSACTION_OP]}"`
+      : '',
+  ]; // TODO: We will need to consider other ops
 
   const eventView = EventView.fromNewQueryWithPageFilters(
     {
       fields, // for some reason we need a function, otherwise the query fails
       name: 'Interaction module - page selector',
       version: 2,
-      dataset: DiscoverDatasets.METRICS,
       query: queryConditions.join(' '),
       orderby: 'transaction',
     },

+ 3 - 0
static/app/views/starfish/components/tableCells/renderHeadCell.tsx

@@ -29,6 +29,9 @@ const {TIME_SPENT_PERCENTAGE, SPS, SPM, HTTP_ERROR_COUNT} = SpanFunction;
 export const SORTABLE_FIELDS = new Set([
   `avg(${SPAN_SELF_TIME})`,
   `p95(${SPAN_SELF_TIME})`,
+  `p75(transaction.duration)`,
+  'transaction',
+  `count()`,
   `${SPS}()`,
   `${SPM}()`,
   `${TIME_SPENT_PERCENTAGE}()`,