Browse Source

feat(perf): Add tags page preliminary UI (#26023)

* feat(perf): Add tags page preliminary UI

This adds a prototype for tags page that is forwards and backwards compatible (it shouldn't throw errors if the endpoint isn't updated once it goes in). It is still early UI and is behind a feature flag, and any of is subject to change.
k-fish 3 years ago
parent
commit
d2cb7e5c3e

+ 7 - 0
static/app/components/charts/baseChart.tsx

@@ -130,6 +130,10 @@ type Props = {
    * ECharts Grid options. multiple grids allow multiple sub-graphs.
    */
   grid?: EChartOption.Grid | EChartOption.Grid[];
+  /**
+   * ECharts Visual Map Options.
+   */
+  visualMap?: EChartOption.VisualMap | EChartOption.VisualMap[];
   /**
    * Chart legend
    */
@@ -245,6 +249,7 @@ function BaseChartUnwrapped({
   previousPeriod,
   echartsTheme,
   devicePixelRatio,
+  visualMap,
 
   showTimeInTooltip,
   useShortDate,
@@ -380,6 +385,8 @@ function BaseChartUnwrapped({
     graphic,
   };
 
+  chartOption.visualMap = visualMap as EChartOption.VisualMap[]; // TODO(ts): EChart types only allow an array whereas echart options accepts a single visual map object.
+
   const chartStyles = {
     height: getDimensionValue(height),
     width: getDimensionValue(width),

+ 39 - 0
static/app/components/charts/heatMapChart.tsx

@@ -0,0 +1,39 @@
+import * as React from 'react';
+import {EChartOption} from 'echarts';
+
+import {Series} from 'app/types/echarts';
+
+import HeatMapSeries from './series/heatMapSeries';
+import BaseChart from './baseChart';
+
+type ChartProps = React.ComponentProps<typeof BaseChart>;
+
+export type LineChartSeries = Series &
+  Omit<EChartOption.SeriesHeatmap, 'data' | 'name'> & {
+    dataArray?: EChartOption.SeriesHeatmap['data'];
+  };
+
+type Props = Omit<ChartProps, 'series'> & {
+  series: LineChartSeries[];
+  seriesOptions?: EChartOption.SeriesHeatmap;
+};
+
+export default class HeatMapChart extends React.Component<Props> {
+  render() {
+    const {series, seriesOptions, ...props} = this.props;
+
+    return (
+      <BaseChart
+        {...props}
+        series={series.map(({seriesName, data, dataArray, ...options}) =>
+          HeatMapSeries({
+            ...seriesOptions,
+            ...options,
+            name: seriesName,
+            data: dataArray || data.map(({value, name}) => [name, value]),
+          })
+        )}
+      />
+    );
+  }
+}

+ 19 - 0
static/app/components/charts/series/heatMapSeries.tsx

@@ -0,0 +1,19 @@
+import 'echarts/lib/chart/heatmap';
+import 'echarts/lib/component/visualMap';
+
+import {EChartOption} from 'echarts';
+
+import {SeriesDataUnit} from 'app/types/echarts';
+
+export default function HeatMapSeries(
+  props: Omit<EChartOption.SeriesHeatmap, 'data'> & {
+    data?: SeriesDataUnit[] | EChartOption.SeriesHeatmap['data'];
+  } = {}
+): EChartOption.SeriesHeatmap {
+  const {data, ...rest} = props;
+  return {
+    data: data as EChartOption.SeriesHeatmap['data'],
+    ...rest,
+    type: 'heatmap',
+  };
+}

+ 7 - 0
static/app/routes.tsx

@@ -1352,6 +1352,13 @@ function routes() {
               }
               component={errorHandler(LazyLoad)}
             />
+            <Route
+              path="/organizations/:orgId/performance/summary/tags/"
+              componentPromise={() =>
+                import('app/views/performance/transactionSummary/transactionTags')
+              }
+              component={errorHandler(LazyLoad)}
+            />
           </Route>
           <Route
             path="/organizations/:orgId/performance/vitaldetail/"

+ 16 - 4
static/app/utils/performance/segmentExplorer/segmentExplorerQuery.tsx

@@ -36,14 +36,18 @@ type ChildrenProps = Omit<GenericChildrenProps<TableData>, 'tableData'> & {
 
 type QueryProps = DiscoverQueryProps & {
   aggregateColumn: string;
-  order?: string;
+  allTagKeys?: boolean;
+  tagKey?: string;
+  sort?: string | string[];
   children: (props: ChildrenProps) => React.ReactNode;
 };
 
 type FacetQuery = LocationQuery &
   EventQuery & {
-    order?: string;
+    sort?: string | string[];
     aggregateColumn?: string;
+    allTagKeys?: boolean;
+    tagKey?: string;
   };
 
 export function getRequestFunction(_props: QueryProps) {
@@ -52,7 +56,13 @@ export function getRequestFunction(_props: QueryProps) {
     const {eventView} = props;
     const apiPayload: FacetQuery = eventView.getEventsAPIPayload(props.location);
     apiPayload.aggregateColumn = aggregateColumn;
-    apiPayload.order = _props.order ? _props.order : '-sumdelta';
+    apiPayload.sort = _props.sort ? _props.sort : apiPayload.sort;
+    if (_props.allTagKeys) {
+      apiPayload.allTagKeys = _props.allTagKeys;
+    }
+    if (_props.tagKey) {
+      apiPayload.tagKey = _props.tagKey;
+    }
     return apiPayload;
   }
   return getTagExplorerRequestPayload;
@@ -61,7 +71,9 @@ export function getRequestFunction(_props: QueryProps) {
 function shouldRefetchData(prevProps: QueryProps, nextProps: QueryProps) {
   return (
     prevProps.aggregateColumn !== nextProps.aggregateColumn ||
-    prevProps.order !== nextProps.order
+    prevProps.sort !== nextProps.sort ||
+    prevProps.allTagKeys !== nextProps.allTagKeys ||
+    prevProps.tagKey !== nextProps.tagKey
   );
 }
 

+ 80 - 0
static/app/utils/performance/segmentExplorer/tagKeyHistogramQuery.tsx

@@ -0,0 +1,80 @@
+import * as React from 'react';
+
+import {EventQuery} from 'app/actionCreators/events';
+import {LocationQuery} from 'app/utils/discover/eventView';
+import GenericDiscoverQuery, {
+  DiscoverQueryProps,
+  GenericChildrenProps,
+} from 'app/utils/discover/genericDiscoverQuery';
+import withApi from 'app/utils/withApi';
+
+/**
+ * An individual row in a Segment explorer result
+ */
+export type TableDataRow = {
+  tags_key: string;
+  tags_value: string;
+  count: number;
+  [key: string]: React.ReactText;
+};
+
+export type TableData = {
+  data: TableDataRow[];
+  meta: {};
+};
+
+/**
+ * A Segment Explorer result including rows and metadata.
+ */
+
+type ChildrenProps = Omit<GenericChildrenProps<TableData>, 'tableData'> & {
+  tableData: TableData | null;
+};
+
+type QueryProps = DiscoverQueryProps & {
+  aggregateColumn: string;
+  tagKey: string;
+  sort?: string | string[];
+  children: (props: ChildrenProps) => React.ReactNode;
+};
+
+type FacetQuery = LocationQuery &
+  EventQuery & {
+    tagKey?: string;
+    sort?: string | string[];
+    aggregateColumn?: string;
+  };
+
+export function getRequestFunction(_props: QueryProps) {
+  const {aggregateColumn} = _props;
+  function getTagExplorerRequestPayload(props: DiscoverQueryProps) {
+    const {eventView} = props;
+    const apiPayload: FacetQuery = eventView.getEventsAPIPayload(props.location);
+    apiPayload.aggregateColumn = aggregateColumn;
+    apiPayload.sort = _props.sort ? _props.sort : '-sumdelta';
+    apiPayload.tagKey = _props.tagKey;
+    return apiPayload;
+  }
+  return getTagExplorerRequestPayload;
+}
+
+function shouldRefetchData(prevProps: QueryProps, nextProps: QueryProps) {
+  return (
+    prevProps.aggregateColumn !== nextProps.aggregateColumn ||
+    prevProps.sort !== nextProps.sort ||
+    prevProps.tagKey !== nextProps.tagKey
+  );
+}
+
+function TagKeyHistogramQuery(props: QueryProps) {
+  return (
+    <GenericDiscoverQuery<TableData, QueryProps>
+      route="events-facets-performance-histogram"
+      getRequestPayload={getRequestFunction(props)}
+      shouldRefetchData={shouldRefetchData}
+      {...props}
+    />
+  );
+}
+
+export default withApi(TagKeyHistogramQuery);

+ 22 - 0
static/app/views/performance/transactionSummary/header.tsx

@@ -17,6 +17,7 @@ import EventView from 'app/utils/discover/eventView';
 import {decodeScalar} from 'app/utils/queryString';
 import Breadcrumb from 'app/views/performance/breadcrumb';
 
+import {tagsRouteWithQuery} from './transactionTags/utils';
 import {vitalsRouteWithQuery} from './transactionVitals/utils';
 import KeyTransactionButton from './keyTransactionButton';
 import {transactionSummaryRouteWithQuery} from './utils';
@@ -24,6 +25,7 @@ import {transactionSummaryRouteWithQuery} from './utils';
 export enum Tab {
   TransactionSummary,
   RealUserMonitoring,
+  Tags,
 }
 
 type Props = {
@@ -61,6 +63,10 @@ class TransactionHeader extends React.Component<Props> {
     });
   };
 
+  trackTagsTabClick = () => {
+    // TODO(k-fish): Add analytics for tags
+  };
+
   handleIncompatibleQuery: React.ComponentProps<
     typeof CreateAlertFromViewButton
   >['onIncompatibleQuery'] = (incompatibleAlertNoticeFn, errors) => {
@@ -122,6 +128,13 @@ class TransactionHeader extends React.Component<Props> {
       query: location.query,
     });
 
+    const tagsTarget = tagsRouteWithQuery({
+      orgSlug: organization.slug,
+      transaction: transactionName,
+      projectID: decodeScalar(location.query.project),
+      query: location.query,
+    });
+
     return (
       <Layout.Header>
         <Layout.HeaderContent>
@@ -163,6 +176,15 @@ class TransactionHeader extends React.Component<Props> {
                 {t('Web Vitals')}
               </ListLink>
             )}
+            <Feature features={['organizations:performance-tag-page']}>
+              <ListLink
+                to={tagsTarget}
+                isActive={() => currentTab === Tab.Tags}
+                onClick={this.trackTagsTabClick}
+              >
+                {t('Tags')}
+              </ListLink>
+            </Feature>
           </StyledNavTabs>
         </React.Fragment>
       </Layout.Header>

+ 1 - 2
static/app/views/performance/transactionSummary/tagExplorer.tsx

@@ -169,7 +169,7 @@ type TagValueProps = {
   row: TableDataRow;
 };
 
-function TagValue(props: TagValueProps) {
+export function TagValue(props: TagValueProps) {
   return <div className="truncate">{props.row.tags_value}</div>;
 }
 
@@ -423,7 +423,6 @@ class _TagExplorer extends React.Component<Props> {
         aggregateColumn={aggregateColumn}
         limit={5}
         cursor={cursor}
-        order={tagSort}
       >
         {({isLoading, tableData, pageLinks}) => {
           return (

+ 278 - 0
static/app/views/performance/transactionSummary/transactionTags/content.tsx

@@ -0,0 +1,278 @@
+import React, {Fragment, useEffect, useState} from 'react';
+import {browserHistory} from 'react-router';
+import styled from '@emotion/styled';
+import {Location} from 'history';
+
+import {SectionHeading} from 'app/components/charts/styles';
+import SearchBar from 'app/components/events/searchBar';
+import {getParams} from 'app/components/organizations/globalSelectionHeader/getParams';
+import QuestionTooltip from 'app/components/questionTooltip';
+import Radio from 'app/components/radio';
+import {t} from 'app/locale';
+import space from 'app/styles/space';
+import {Organization, Project} from 'app/types';
+import EventView from 'app/utils/discover/eventView';
+import SegmentExplorerQuery, {
+  TableData,
+} from 'app/utils/performance/segmentExplorer/segmentExplorerQuery';
+import {decodeScalar} from 'app/utils/queryString';
+import {SidebarSpacer} from 'app/views/performance/transactionSummary/utils';
+
+import {getCurrentLandingDisplay, LandingDisplayField} from '../../landing/utils';
+import {SpanOperationBreakdownFilter} from '../filter';
+import TransactionHeader, {Tab} from '../header';
+import {getTransactionField} from '../tagExplorer';
+
+import TagsDisplay from './tagsDisplay';
+
+type Props = {
+  eventView: EventView;
+  location: Location;
+  organization: Organization;
+  projects: Project[];
+  transactionName: string;
+};
+
+type TagOption = string;
+
+const TagsPageContent = (props: Props) => {
+  const {eventView, location, organization, projects, transactionName} = props;
+
+  const handleIncompatibleQuery = () => {};
+
+  const aggregateColumn = getTransactionField(
+    SpanOperationBreakdownFilter.None,
+    projects,
+    eventView
+  );
+
+  return (
+    <Fragment>
+      <TransactionHeader
+        eventView={eventView}
+        location={location}
+        organization={organization}
+        projects={projects}
+        transactionName={transactionName}
+        currentTab={Tab.Tags}
+        hasWebVitals={
+          getCurrentLandingDisplay(location, projects, eventView).field ===
+          LandingDisplayField.FRONTEND_PAGELOAD
+        }
+        handleIncompatibleQuery={handleIncompatibleQuery}
+      />
+
+      <SegmentExplorerQuery
+        eventView={eventView}
+        orgSlug={organization.slug}
+        location={location}
+        aggregateColumn={aggregateColumn}
+        limit={20}
+        sort="-sumdelta"
+        allTagKeys
+      >
+        {({isLoading, tableData}) => {
+          return <InnerContent {...props} isLoading={isLoading} tableData={tableData} />;
+        }}
+      </SegmentExplorerQuery>
+    </Fragment>
+  );
+};
+
+function getTagKeyOptions(tableData: TableData) {
+  const suspectTags: TagOption[] = [];
+  const otherTags: TagOption[] = [];
+  tableData.data.forEach(row => {
+    const tagArray = row.comparison > 1 ? suspectTags : otherTags;
+    tagArray.push(row.tags_key);
+  });
+
+  return {
+    suspectTags,
+    otherTags,
+  };
+}
+
+const InnerContent = (
+  props: Props & {tableData: TableData | null; isLoading?: boolean}
+) => {
+  const {eventView, location, organization, tableData} = props;
+
+  if (!tableData) {
+    return null;
+  }
+
+  const tagOptions = getTagKeyOptions(tableData);
+
+  const defaultTag = tagOptions.suspectTags.length
+    ? tagOptions.suspectTags[0]
+    : tagOptions.otherTags.length
+    ? tagOptions.otherTags[0]
+    : '';
+  const [tagSelected, changeTagSelected] = useState(defaultTag);
+
+  useEffect(() => {
+    if (defaultTag && !tagSelected) {
+      changeTagSelected(defaultTag);
+    }
+  }, [defaultTag]);
+
+  const handleSearch = (query: string) => {
+    const queryParams = getParams({
+      ...(location.query || {}),
+      query,
+    });
+
+    browserHistory.push({
+      pathname: location.pathname,
+      query: queryParams,
+    });
+  };
+
+  const changeTag = (tag: string) => {
+    return changeTagSelected(tag);
+  };
+  if (tagSelected) {
+    eventView.additionalConditions.setTagValues('has', [tagSelected]);
+  }
+
+  const query = decodeScalar(location.query.query, '');
+
+  return (
+    <ReversedLayoutBody>
+      <TagsSideBar
+        suspectTags={tagOptions.suspectTags}
+        otherTags={tagOptions.otherTags}
+        tagSelected={tagSelected}
+        changeTag={changeTag}
+      />
+      <StyledMain>
+        <StyledActions>
+          <StyledSearchBar
+            organization={organization}
+            projectIds={eventView.project}
+            query={query}
+            fields={eventView.fields}
+            onSearch={handleSearch}
+          />
+        </StyledActions>
+        <TagsDisplay {...props} tagKey={tagSelected} />
+      </StyledMain>
+    </ReversedLayoutBody>
+  );
+};
+
+const TagsSideBar = (props: {
+  tagSelected: string;
+  changeTag: (tag: string) => void;
+  suspectTags: TagOption[];
+  otherTags: TagOption[];
+}) => {
+  const {suspectTags, otherTags, changeTag, tagSelected} = props;
+  return (
+    <StyledSide>
+      {suspectTags.length ? (
+        <React.Fragment>
+          <StyledSectionHeading>
+            {t('Suspect Tags')}
+            <QuestionTooltip
+              position="top"
+              title={t(
+                'Suspect tags are tags that often correspond to slower transaction'
+              )}
+              size="sm"
+            />
+          </StyledSectionHeading>
+          {suspectTags.map(tag => (
+            <RadioLabel key={tag}>
+              <Radio
+                aria-label={tag}
+                checked={tagSelected === tag}
+                onChange={() => changeTag(tag)}
+              />
+              {tag}
+            </RadioLabel>
+          ))}
+
+          <SidebarSpacer />
+        </React.Fragment>
+      ) : null}
+      <StyledSectionHeading>
+        {t('Other Tags')}
+        <QuestionTooltip
+          position="top"
+          title={t('Other common tags for this transaction')}
+          size="sm"
+        />
+      </StyledSectionHeading>
+      {otherTags.map(tag => (
+        <RadioLabel key={tag}>
+          <Radio
+            aria-label={tag}
+            checked={tagSelected === tag}
+            onChange={() => changeTag(tag)}
+          />
+          {tag}
+        </RadioLabel>
+      ))}
+    </StyledSide>
+  );
+};
+
+const RadioLabel = styled('label')`
+  cursor: pointer;
+  margin-bottom: ${space(1)};
+  font-weight: normal;
+  display: grid;
+  grid-auto-flow: column;
+  grid-auto-columns: max-content;
+  align-items: center;
+  grid-gap: ${space(1)};
+`;
+
+const StyledSectionHeading = styled(SectionHeading)`
+  margin-bottom: ${space(2)};
+`;
+
+// TODO(k-fish): Adjust thirds layout to allow for this instead.
+const ReversedLayoutBody = styled('div')`
+  padding: ${space(2)};
+  margin: 0;
+  background-color: ${p => p.theme.background};
+  flex-grow: 1;
+
+  @media (min-width: ${p => p.theme.breakpoints[0]}) {
+    padding: ${space(3)} ${space(4)};
+  }
+
+  @media (min-width: ${p => p.theme.breakpoints[1]}) {
+    display: grid;
+    grid-template-columns: auto 66%;
+    align-content: start;
+    grid-gap: ${space(3)};
+  }
+
+  @media (min-width: ${p => p.theme.breakpoints[2]}) {
+    grid-template-columns: 225px minmax(100px, auto);
+  }
+`;
+
+const StyledSide = styled('div')`
+  grid-column: 1/2;
+`;
+
+const StyledMain = styled('div')`
+  grid-column: 2/4;
+  max-width: 100%;
+`;
+
+const StyledSearchBar = styled(SearchBar)`
+  flex-grow: 1;
+`;
+
+const StyledActions = styled('div')`
+  margin-top: ${space(1)};
+  margin-bottom: ${space(3)};
+`;
+
+export default TagsPageContent;

+ 157 - 0
static/app/views/performance/transactionSummary/transactionTags/index.tsx

@@ -0,0 +1,157 @@
+import {Component} from 'react';
+import {browserHistory, WithRouterProps} from 'react-router';
+import styled from '@emotion/styled';
+import {Location} from 'history';
+
+import Feature from 'app/components/acl/feature';
+import Alert from 'app/components/alert';
+import LightWeightNoProjectMessage from 'app/components/lightWeightNoProjectMessage';
+import GlobalSelectionHeader from 'app/components/organizations/globalSelectionHeader';
+import SentryDocumentTitle from 'app/components/sentryDocumentTitle';
+import {t} from 'app/locale';
+import {PageContent} from 'app/styles/organization';
+import {GlobalSelection, Organization, Project} from 'app/types';
+import EventView from 'app/utils/discover/eventView';
+import {decodeScalar} from 'app/utils/queryString';
+import {stringifyQueryObject, tokenizeSearch} from 'app/utils/tokenizeSearch';
+import withGlobalSelection from 'app/utils/withGlobalSelection';
+import withOrganization from 'app/utils/withOrganization';
+import withProjects from 'app/utils/withProjects';
+
+import {getTransactionName} from '../../utils';
+
+import TagsPageContent from './content';
+
+type Props = {
+  location: Location;
+  organization: Organization;
+  projects: Project[];
+  selection: GlobalSelection;
+} & Pick<WithRouterProps, 'router'>;
+
+type State = {
+  eventView: EventView | undefined;
+};
+
+class TransactionTags extends Component<Props> {
+  state: State = {
+    eventView: generateTagsEventView(
+      this.props.location,
+      getTransactionName(this.props.location)
+    ),
+  };
+
+  static getDerivedStateFromProps(nextProps: Readonly<Props>, prevState: State): State {
+    return {
+      ...prevState,
+      eventView: generateTagsEventView(
+        nextProps.location,
+        getTransactionName(nextProps.location)
+      ),
+    };
+  }
+
+  getDocumentTitle(): string {
+    const name = getTransactionName(this.props.location);
+
+    const hasTransactionName = typeof name === 'string' && String(name).trim().length > 0;
+
+    if (hasTransactionName) {
+      return [String(name).trim(), t('Tags')].join(' \u2014 ');
+    }
+
+    return [t('Summary'), t('Tags')].join(' \u2014 ');
+  }
+
+  renderNoAccess = () => {
+    return <Alert type="warning">{t("You don't have access to this feature")}</Alert>;
+  };
+
+  render() {
+    const {organization, projects, location} = this.props;
+    const {eventView} = this.state;
+    const transactionName = getTransactionName(location);
+    if (!eventView || transactionName === undefined) {
+      // If there is no transaction name, redirect to the Performance landing page
+      browserHistory.replace({
+        pathname: `/organizations/${organization.slug}/performance/`,
+        query: {
+          ...location.query,
+        },
+      });
+      return null;
+    }
+
+    const shouldForceProject = eventView.project.length === 1;
+    const forceProject = shouldForceProject
+      ? projects.find(p => parseInt(p.id, 10) === eventView.project[0])
+      : undefined;
+    const projectSlugs = eventView.project
+      .map(projectId => projects.find(p => parseInt(p.id, 10) === projectId))
+      .filter((p: Project | undefined): p is Project => p !== undefined)
+      .map(p => p.slug);
+
+    return (
+      <SentryDocumentTitle
+        title={this.getDocumentTitle()}
+        orgSlug={organization.slug}
+        projectSlug={forceProject?.slug}
+      >
+        <Feature
+          features={['performance-tag-page']}
+          organization={organization}
+          renderDisabled={this.renderNoAccess}
+        >
+          <GlobalSelectionHeader
+            lockedMessageSubject={t('transaction')}
+            shouldForceProject={shouldForceProject}
+            forceProject={forceProject}
+            specificProjectSlugs={projectSlugs}
+            disableMultipleProjectSelection
+            showProjectSettingsLink
+          >
+            <StyledPageContent>
+              <LightWeightNoProjectMessage organization={organization}>
+                <TagsPageContent
+                  location={location}
+                  eventView={eventView}
+                  transactionName={transactionName}
+                  organization={organization}
+                  projects={projects}
+                />
+              </LightWeightNoProjectMessage>
+            </StyledPageContent>
+          </GlobalSelectionHeader>
+        </Feature>
+      </SentryDocumentTitle>
+    );
+  }
+}
+
+const StyledPageContent = styled(PageContent)`
+  padding: 0;
+`;
+
+function generateTagsEventView(
+  location: Location,
+  transactionName: string | undefined
+): EventView | undefined {
+  if (transactionName === undefined) {
+    return undefined;
+  }
+  const query = decodeScalar(location.query.query, '');
+  const conditions = tokenizeSearch(query);
+  return EventView.fromNewQueryWithLocation(
+    {
+      id: undefined,
+      version: 2,
+      name: transactionName,
+      fields: ['transaction.duration'],
+      query: stringifyQueryObject(conditions),
+      projects: [],
+    },
+    location
+  );
+}
+
+export default withGlobalSelection(withProjects(withOrganization(TransactionTags)));

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