Browse Source

feat(starfish): Add selectors to span view (#49771)

Allow changing the span operation, domain, and action via dropdowns! Add
some intelligence so the labels make sense in the context of a provided
module (if one is given). e.g., the 'Domain' dropdown becomes 'Table' or
'Host' depending on the `moduleName=` query parameter.

The selector components are all very similar so the code is copypasta,
but the differences are annoying to extract. I'll work on that later.
George Gritsouk 1 year ago
parent
commit
7d64ff41c6

+ 6 - 0
static/app/views/starfish/types.tsx

@@ -0,0 +1,6 @@
+export enum ModuleName {
+  HTTP = 'http',
+  DB = 'db',
+  NONE = 'none',
+  ALL = '',
+}

+ 8 - 30
static/app/views/starfish/views/spans/index.tsx

@@ -1,21 +1,19 @@
 import {useState} from 'react';
 import {RouteComponentProps} from 'react-router';
-import styled from '@emotion/styled';
 import {Location} from 'history';
 
 import * as Layout from 'sentry/components/layouts/thirds';
 import PageFiltersContainer from 'sentry/components/organizations/pageFilters/container';
 import {t} from 'sentry/locale';
-import {space} from 'sentry/styles/space';
 import {
   PageErrorAlert,
   PageErrorProvider,
 } from 'sentry/utils/performance/contexts/pageError';
-import {Filter} from 'sentry/views/starfish/views/spans/filter';
+import {ModuleName} from 'sentry/views/starfish/types';
 import SpanDetail from 'sentry/views/starfish/views/spans/spanDetails';
 import {SpanDataRow} from 'sentry/views/starfish/views/spans/spansTable';
 
-import SpansView, {SPAN_FILTER_KEY_LABELS} from './spansView';
+import SpansView from './spansView';
 
 type State = {
   selectedRow?: SpanDataRow;
@@ -31,31 +29,12 @@ export default function Spans(props: Props) {
   const {selectedRow} = state;
   const setSelectedRow = (row: SpanDataRow) => setState({selectedRow: row});
 
-  const appliedFilters = Object.keys(props.location.query)
-    .map(queryKey => {
-      const queryKeyLabel = SPAN_FILTER_KEY_LABELS[queryKey];
-      const queryValue = props.location.query[queryKey];
-
-      return queryKeyLabel && queryValue
-        ? {kkey: queryKeyLabel, value: queryValue}
-        : null;
-    })
-    .filter((item): item is NonNullable<typeof item> => Boolean(item));
-
   return (
     <Layout.Page>
       <PageErrorProvider>
         <Layout.Header>
           <Layout.HeaderContent>
             <Layout.Title>{t('Spans')}</Layout.Title>
-            {appliedFilters.length > 0 ? (
-              <FiltersContainer>
-                Applied Filters:
-                {appliedFilters.map(filterProps => {
-                  return <Filter key={filterProps.kkey} {...filterProps} />;
-                })}
-              </FiltersContainer>
-            ) : null}
           </Layout.HeaderContent>
         </Layout.Header>
 
@@ -63,7 +42,12 @@ export default function Spans(props: Props) {
           <Layout.Main fullWidth>
             <PageErrorAlert />
             <PageFiltersContainer>
-              <SpansView location={props.location} onSelect={setSelectedRow} />
+              <SpansView
+                location={props.location}
+                onSelect={setSelectedRow}
+                moduleName={props.location.query.moduleName ?? ModuleName.ALL}
+                appliedFilters={props.location.query}
+              />
               <SpanDetail row={selectedRow} onClose={unsetSelectedSpanGroup} />
             </PageFiltersContainer>
           </Layout.Main>
@@ -72,9 +56,3 @@ export default function Spans(props: Props) {
     </Layout.Page>
   );
 }
-
-const FiltersContainer = styled('span')`
-  display: flex;
-  gap: ${space(1)};
-  padding: ${space(1)};
-`;

+ 100 - 0
static/app/views/starfish/views/spans/selectors/actionSelector.tsx

@@ -0,0 +1,100 @@
+import {ReactNode} from 'react';
+import {browserHistory} from 'react-router';
+
+import {CompactSelect} from 'sentry/components/compactSelect';
+import {t} from 'sentry/locale';
+import EventView from 'sentry/utils/discover/eventView';
+import {DiscoverDatasets} from 'sentry/utils/discover/types';
+import {useLocation} from 'sentry/utils/useLocation';
+import {ModuleName} from 'sentry/views/starfish/types';
+import {useSpansQuery} from 'sentry/views/starfish/utils/useSpansQuery';
+
+type Props = {
+  moduleName?: ModuleName;
+  value?: string;
+};
+
+export function ActionSelector({value = '', moduleName = ModuleName.ALL}: Props) {
+  // TODO: This only returns the top 25 actions. It should either load them all, or paginate, or allow searching
+  //
+  const location = useLocation();
+  const query = getQuery(moduleName);
+  const eventView = getEventView(moduleName);
+
+  const useHTTPActions = moduleName === ModuleName.HTTP;
+
+  const {data: actions} = useSpansQuery<[{action: string}]>({
+    eventView,
+    queryString: query,
+    initialData: [],
+    enabled: Boolean(query && !useHTTPActions),
+  });
+
+  const options = useHTTPActions
+    ? HTTP_ACTION_OPTIONS
+    : [
+        {value: '', label: 'All'},
+        ...actions.map(({action}) => ({
+          value: action,
+          label: action,
+        })),
+      ];
+
+  return (
+    <CompactSelect
+      triggerProps={{
+        prefix: LABEL_FOR_MODULE_NAME[moduleName],
+      }}
+      value={value}
+      options={options ?? []}
+      onChange={newValue => {
+        browserHistory.push({
+          ...location,
+          query: {
+            ...location.query,
+            action: newValue.value,
+          },
+        });
+      }}
+    />
+  );
+}
+
+const HTTP_ACTION_OPTIONS = [
+  {value: '', label: 'All'},
+  ...['GET', 'POST', 'PUT', 'DELETE'].map(action => ({
+    value: action,
+    label: action,
+  })),
+];
+
+const LABEL_FOR_MODULE_NAME: {[key in ModuleName]: ReactNode} = {
+  http: t('HTTP Method'),
+  db: t('SQL Command'),
+  none: t('Action'),
+  '': t('Action'),
+};
+
+function getQuery(moduleName?: string) {
+  return `SELECT action, count()
+    FROM spans_experimental_starfish
+    WHERE 1 = 1
+    ${moduleName ? `AND module = '${moduleName}'` : ''}
+    AND action != ''
+    GROUP BY action
+    ORDER BY count() DESC
+    LIMIT 25
+  `;
+}
+
+function getEventView(moduleName?: string) {
+  return EventView.fromSavedQuery({
+    name: '',
+    fields: ['action', 'count()'],
+    orderby: '-count',
+    query: moduleName ? `module:${moduleName}` : '',
+    dataset: DiscoverDatasets.SPANS_INDEXED,
+    projects: [1],
+    version: 2,
+  });
+}

+ 88 - 0
static/app/views/starfish/views/spans/selectors/domainSelector.tsx

@@ -0,0 +1,88 @@
+import {ReactNode} from 'react';
+import {browserHistory} from 'react-router';
+
+import {CompactSelect} from 'sentry/components/compactSelect';
+import {t} from 'sentry/locale';
+import EventView from 'sentry/utils/discover/eventView';
+import {DiscoverDatasets} from 'sentry/utils/discover/types';
+import {useLocation} from 'sentry/utils/useLocation';
+import {ModuleName} from 'sentry/views/starfish/types';
+import {useSpansQuery} from 'sentry/views/starfish/utils/useSpansQuery';
+
+type Props = {
+  moduleName?: ModuleName;
+  value?: string;
+};
+
+export function DomainSelector({value = '', moduleName = ModuleName.ALL}: Props) {
+  // TODO: This only returns the top 25 domains. It should either load them all, or paginate, or allow searching
+  //
+  const location = useLocation();
+  const query = getQuery(moduleName);
+  const eventView = getEventView(moduleName);
+
+  const {data: operations} = useSpansQuery<[{domain: string}]>({
+    eventView,
+    queryString: query,
+    initialData: [],
+    enabled: Boolean(query),
+  });
+
+  const options = [
+    {value: '', label: 'All'},
+    ...operations.map(({domain}) => ({
+      value: domain,
+      label: domain,
+    })),
+  ];
+
+  return (
+    <CompactSelect
+      triggerProps={{
+        prefix: LABEL_FOR_MODULE_NAME[moduleName],
+      }}
+      value={value}
+      options={options ?? []}
+      onChange={newValue => {
+        browserHistory.push({
+          ...location,
+          query: {
+            ...location.query,
+            domain: newValue.value,
+          },
+        });
+      }}
+    />
+  );
+}
+
+const LABEL_FOR_MODULE_NAME: {[key in ModuleName]: ReactNode} = {
+  http: t('Host'),
+  db: t('Table'),
+  none: t('Domain'),
+  '': t('Domain'),
+};
+
+function getQuery(moduleName?: string) {
+  return `SELECT domain, count()
+    FROM spans_experimental_starfish
+    WHERE 1 = 1
+    ${moduleName ? `AND module = '${moduleName}'` : ''}
+    AND domain != ''
+    GROUP BY domain
+    ORDER BY count() DESC
+    LIMIT 25
+  `;
+}
+
+function getEventView(moduleName?: string) {
+  return EventView.fromSavedQuery({
+    name: '',
+    fields: ['domain', 'count()'],
+    orderby: '-count',
+    query: moduleName ? `module:${moduleName}` : '',
+    dataset: DiscoverDatasets.SPANS_INDEXED,
+    projects: [1],
+    version: 2,
+  });
+}

+ 73 - 0
static/app/views/starfish/views/spans/selectors/spanOperationSelector.tsx

@@ -0,0 +1,73 @@
+import {browserHistory} from 'react-router';
+
+import {CompactSelect} from 'sentry/components/compactSelect';
+import {t} from 'sentry/locale';
+import EventView from 'sentry/utils/discover/eventView';
+import {DiscoverDatasets} from 'sentry/utils/discover/types';
+import {useLocation} from 'sentry/utils/useLocation';
+import {useSpansQuery} from 'sentry/views/starfish/utils/useSpansQuery';
+
+type Props = {
+  value: string;
+};
+
+export function SpanOperationSelector({value = ''}: Props) {
+  // TODO: This only returns the top 25 operations. It should either load them all, or paginate, or allow searching
+  //
+  const location = useLocation();
+  const query = getQuery();
+  const eventView = getEventView();
+
+  const {data: operations} = useSpansQuery<[{span_operation: string}]>({
+    eventView,
+    queryString: query,
+    initialData: [],
+    enabled: Boolean(query),
+  });
+
+  const options = [
+    {value: '', label: 'All'},
+    ...operations.map(({span_operation}) => ({
+      value: span_operation,
+      label: span_operation,
+    })),
+  ];
+
+  return (
+    <CompactSelect
+      triggerProps={{prefix: t('Operation')}}
+      value={value}
+      options={options ?? []}
+      onChange={newValue => {
+        browserHistory.push({
+          ...location,
+          query: {
+            ...location.query,
+            span_operation: newValue.value,
+          },
+        });
+      }}
+    />
+  );
+}
+
+function getQuery() {
+  return `SELECT span_operation, count()
+    FROM spans_experimental_starfish
+    WHERE span_operation != ''
+    GROUP BY span_operation
+    ORDER BY count() DESC
+    LIMIT 25
+  `;
+}
+
+function getEventView() {
+  return EventView.fromSavedQuery({
+    name: '',
+    fields: ['span_operation', 'count()'],
+    orderby: '-count',
+    dataset: DiscoverDatasets.SPANS_INDEXED,
+    projects: [1],
+    version: 2,
+  });
+}

+ 29 - 17
static/app/views/starfish/views/spans/spansView.tsx

@@ -8,7 +8,11 @@ import DatePageFilter from 'sentry/components/datePageFilter';
 import SearchBar from 'sentry/components/searchBar';
 import {space} from 'sentry/styles/space';
 import usePageFilters from 'sentry/utils/usePageFilters';
+import {ModuleName} from 'sentry/views/starfish/types';
 import {HOST} from 'sentry/views/starfish/utils/constants';
+import {ActionSelector} from 'sentry/views/starfish/views/spans/selectors/actionSelector';
+import {DomainSelector} from 'sentry/views/starfish/views/spans/selectors/domainSelector';
+import {SpanOperationSelector} from 'sentry/views/starfish/views/spans/selectors/spanOperationSelector';
 import {SpanTimeCharts} from 'sentry/views/starfish/views/spans/spanTimeCharts';
 
 import {getSpanListQuery, getSpansTrendsQuery} from './queries';
@@ -18,8 +22,10 @@ import SpansTable from './spansTable';
 const LIMIT: number = 25;
 
 type Props = {
+  appliedFilters: {[key: string]: string};
   location: Location;
   onSelect: (row: SpanDataRow) => void;
+  moduleName?: ModuleName;
 };
 
 type State = {
@@ -37,19 +43,17 @@ export default function SpansView(props: Props) {
 
   const descriptionFilter = didConfirmSearch && searchTerm ? `${searchTerm}` : undefined;
   const queryConditions = buildQueryFilterFromLocation(location);
+  const query = getSpanListQuery(
+    descriptionFilter,
+    pageFilter.selection.datetime,
+    queryConditions,
+    orderBy,
+    LIMIT
+  );
 
   const {isLoading: areSpansLoading, data: spansData} = useQuery<SpanDataRow[]>({
-    queryKey: ['spans', descriptionFilter, orderBy, pageFilter.selection.datetime],
-    queryFn: () =>
-      fetch(
-        `${HOST}/?query=${getSpanListQuery(
-          descriptionFilter,
-          pageFilter.selection.datetime,
-          queryConditions,
-          orderBy,
-          LIMIT
-        )}&format=sql`
-      ).then(res => res.json()),
+    queryKey: ['spans', query],
+    queryFn: () => fetch(`${HOST}/?query=${query}&format=sql`).then(res => res.json()),
     retry: false,
     refetchOnWindowFocus: false,
     initialData: [],
@@ -79,6 +83,18 @@ export default function SpansView(props: Props) {
     <Fragment>
       <FilterOptionsContainer>
         <DatePageFilter alignDropdown="left" />
+
+        <SpanOperationSelector value={props.appliedFilters.span_operation} />
+
+        <DomainSelector
+          moduleName={props.moduleName}
+          value={props.appliedFilters.domain}
+        />
+
+        <ActionSelector
+          moduleName={props.moduleName}
+          value={props.appliedFilters.action}
+        />
       </FilterOptionsContainer>
 
       <PaddedContainer>
@@ -129,17 +145,13 @@ const FilterOptionsContainer = styled(PaddedContainer)`
   margin-bottom: ${space(2)};
 `;
 
-export const SPAN_FILTER_KEYS = ['action', 'span_operation', 'domain'];
-export const SPAN_FILTER_KEY_LABELS = {
-  action: 'Action',
-  span_operation: 'Operation',
-  domain: 'Domain',
-};
+const SPAN_FILTER_KEYS = ['span_operation', 'domain', 'action'];
 
 const buildQueryFilterFromLocation = (location: Location) => {
   const {query} = location;
   const result = Object.keys(query)
     .filter(key => SPAN_FILTER_KEYS.includes(key))
+    .filter(key => Boolean(query[key]))
     .map(key => {
       return `${key} = '${query[key]}'`;
     });