Просмотр исходного кода

feat(performance): events tab filtering and links to event tab (#26922)

Added ops breakdown filter and performance display filter to All Events tab.
Updated Open In Discover links in the Transaction Summary and Web Vitals page to Open events in the All Events tab with ops, vital, and/or display filters propagation.
edwardgou-sentry 3 лет назад
Родитель
Сommit
5eb1016730

+ 51 - 14
static/app/components/discover/transactionsList.tsx

@@ -4,6 +4,7 @@ import styled from '@emotion/styled';
 import {Location, LocationDescriptor, Query} from 'history';
 
 import GuideAnchor from 'app/components/assistant/guideAnchor';
+import Button from 'app/components/button';
 import DiscoverButton from 'app/components/discoverButton';
 import DropdownButton from 'app/components/dropdownButton';
 import DropdownControl, {DropdownItem} from 'app/components/dropdownControl';
@@ -21,6 +22,9 @@ import {tokenizeSearch} from 'app/utils/tokenizeSearch';
 import {Actions} from 'app/views/eventsV2/table/cellAction';
 import {TableColumn} from 'app/views/eventsV2/table/types';
 import {decodeColumnOrder} from 'app/views/eventsV2/utils';
+import {SpanOperationBreakdownFilter} from 'app/views/performance/transactionSummary/filter';
+import {mapShowTransactionToPercentile} from 'app/views/performance/transactionSummary/transactionEvents/utils';
+import {TransactionFilterOptions} from 'app/views/performance/transactionSummary/utils';
 import {TrendChangeType, TrendView} from 'app/views/performance/trends/types';
 
 import TransactionsTable from './transactionsTable';
@@ -108,6 +112,10 @@ type Props = {
    * The callback for when Open in Discover is clicked.
    */
   handleOpenInDiscoverClick?: (e: React.MouseEvent<Element>) => void;
+  /**
+   * The callback for when Open All Events is clicked.
+   */
+  handleOpenAllEventsClick?: (e: React.MouseEvent<Element>) => void;
   /**
    * Show a loading indicator instead of the table, used for transaction summary p95.
    */
@@ -117,6 +125,9 @@ type Props = {
    * for generating the Discover query.
    */
   generateDiscoverEventView?: () => EventView;
+  generatePerformanceTransactionEventsView?: () => EventView;
+  showTransactions?: TransactionFilterOptions;
+  breakdown?: SpanOperationBreakdownFilter;
 };
 
 class TransactionsList extends React.Component<Props> {
@@ -154,13 +165,21 @@ class TransactionsList extends React.Component<Props> {
     return this.getEventView();
   }
 
+  generatePerformanceTransactionEventsView(): EventView {
+    const {generatePerformanceTransactionEventsView} = this.props;
+    return generatePerformanceTransactionEventsView?.() ?? this.getEventView();
+  }
+
   renderHeader(): React.ReactNode {
     const {
       organization,
       selected,
       options,
       handleDropdownChange,
+      handleOpenAllEventsClick,
       handleOpenInDiscoverClick,
+      showTransactions,
+      breakdown,
     } = this.props;
 
     return (
@@ -192,20 +211,38 @@ class TransactionsList extends React.Component<Props> {
             ))}
           </DropdownControl>
         </div>
-        {!this.isTrend() && (
-          <GuideAnchor target="release_transactions_open_in_discover">
-            <DiscoverButton
-              onClick={handleOpenInDiscoverClick}
-              to={this.generateDiscoverEventView().getResultsViewUrlTarget(
-                organization.slug
-              )}
-              size="small"
-              data-test-id="discover-open"
-            >
-              {t('Open in Discover')}
-            </DiscoverButton>
-          </GuideAnchor>
-        )}
+        {!this.isTrend() &&
+          (handleOpenAllEventsClick ? (
+            <GuideAnchor target="release_transactions_open_in_transaction_events">
+              <Button
+                onClick={handleOpenAllEventsClick}
+                to={this.generatePerformanceTransactionEventsView().getPerformanceTransactionEventsViewUrlTarget(
+                  organization.slug,
+                  {
+                    showTransactions: mapShowTransactionToPercentile(showTransactions),
+                    breakdown,
+                  }
+                )}
+                size="small"
+                data-test-id="transaction-events-open"
+              >
+                {t('Open All Events')}
+              </Button>
+            </GuideAnchor>
+          ) : (
+            <GuideAnchor target="release_transactions_open_in_discover">
+              <DiscoverButton
+                onClick={handleOpenInDiscoverClick}
+                to={this.generateDiscoverEventView().getResultsViewUrlTarget(
+                  organization.slug
+                )}
+                size="small"
+                data-test-id="discover-open"
+              >
+                {t('Open in Discover')}
+              </DiscoverButton>
+            </GuideAnchor>
+          ))}
       </React.Fragment>
     );
   }

+ 48 - 13
static/app/utils/discover/eventView.tsx

@@ -14,18 +14,6 @@ import {DEFAULT_PER_PAGE} from 'app/constants';
 import {URL_PARAM} from 'app/constants/globalSelectionHeader';
 import {t} from 'app/locale';
 import {GlobalSelection, NewQuery, SavedQuery, SelectValue, User} from 'app/types';
-import {decodeList, decodeScalar} from 'app/utils/queryString';
-import {
-  FieldValueKind,
-  TableColumn,
-  TableColumnSort,
-} from 'app/views/eventsV2/table/types';
-import {decodeColumnOrder} from 'app/views/eventsV2/utils';
-
-import {statsPeriodToDays} from '../dates';
-import {QueryResults, tokenizeSearch} from '../tokenizeSearch';
-
-import {getSortField} from './fieldRenderers';
 import {
   aggregateOutputType,
   Column,
@@ -39,7 +27,22 @@ import {
   isEquation,
   isLegalYAxisType,
   Sort,
-} from './fields';
+  WebVital,
+} from 'app/utils/discover/fields';
+import {decodeList, decodeScalar} from 'app/utils/queryString';
+import {
+  FieldValueKind,
+  TableColumn,
+  TableColumnSort,
+} from 'app/views/eventsV2/table/types';
+import {decodeColumnOrder} from 'app/views/eventsV2/utils';
+import {SpanOperationBreakdownFilter} from 'app/views/performance/transactionSummary/filter';
+import {EventsDisplayFilterName} from 'app/views/performance/transactionSummary/transactionEvents/utils';
+
+import {statsPeriodToDays} from '../dates';
+import {QueryResults, tokenizeSearch} from '../tokenizeSearch';
+
+import {getSortField} from './fieldRenderers';
 import {
   CHART_AXIS_OPTIONS,
   DISPLAY_MODE_FALLBACK_OPTIONS,
@@ -1110,6 +1113,38 @@ class EventView {
     };
   }
 
+  getPerformanceTransactionEventsViewUrlTarget(
+    slug: string,
+    options: {
+      showTransactions?: EventsDisplayFilterName;
+      breakdown?: SpanOperationBreakdownFilter;
+      webVital?: WebVital;
+    }
+  ): {pathname: string; query: Query} {
+    const {showTransactions, breakdown, webVital} = options;
+    const output = {
+      sort: encodeSorts(this.sorts),
+      project: this.project,
+      query: this.query,
+      transaction: this.name,
+      showTransactions,
+      breakdown,
+      webVital,
+    };
+
+    for (const field of EXTERNAL_QUERY_STRING_KEYS) {
+      if (this[field] && this[field].length) {
+        output[field] = this[field];
+      }
+    }
+
+    const query = cloneDeep(output as any);
+    return {
+      pathname: `/organizations/${slug}/performance/summary/events/`,
+      query,
+    };
+  }
+
   sortForField(field: Field, tableMeta: MetaType | undefined): Sort | undefined {
     if (!tableMeta) {
       return undefined;

+ 1 - 1
static/app/utils/discover/fieldRenderers.tsx

@@ -568,7 +568,7 @@ const spanOperationRelativeBreakdownRenderer = (
 
   let otherPercentage = 1;
   let orderedSpanOpsBreakdownFields;
-  const sortingOnField = eventView?.sorts?.[0].field;
+  const sortingOnField = eventView?.sorts?.[0]?.field;
   if (sortingOnField && SPAN_OP_BREAKDOWN_FIELDS.includes(sortingOnField)) {
     orderedSpanOpsBreakdownFields = [
       sortingOnField,

+ 65 - 23
static/app/views/performance/transactionSummary/content.tsx

@@ -39,6 +39,7 @@ import {isSummaryViewFrontendPageLoad} from '../utils';
 
 import TransactionSummaryCharts from './charts';
 import Filter, {
+  decodeFilterFromLocation,
   filterToField,
   filterToSearchConditions,
   SpanOperationBreakdownFilter,
@@ -153,6 +154,15 @@ class SummaryContent extends React.Component<Props, State> {
     browserHistory.push(target);
   };
 
+  handleAllEventsViewClick = () => {
+    const {organization} = this.props;
+    trackAnalyticsEvent({
+      eventKey: 'performance_views.summary.view_in_transaction_events',
+      eventName: 'Performance Views: View in All Events from Transaction Summary',
+      organization_id: parseInt(organization.id, 10),
+    });
+  };
+
   handleDiscoverViewClick = () => {
     const {organization} = this.props;
     trackAnalyticsEvent({
@@ -171,6 +181,31 @@ class SummaryContent extends React.Component<Props, State> {
     });
   };
 
+  generateEventView(
+    transactionsListEventView: EventView,
+    transactionsListTitles: string[]
+  ) {
+    const {location, totalValues, spanOperationBreakdownFilter} = this.props;
+    const {selected} = getTransactionsListSort(location, {
+      p95: totalValues?.p95 ?? 0,
+      spanOperationBreakdownFilter,
+    });
+    const sortedEventView = transactionsListEventView.withSorts([selected.sort]);
+
+    if (spanOperationBreakdownFilter === SpanOperationBreakdownFilter.None) {
+      const fields = [
+        // Remove the extra field columns
+        ...sortedEventView.fields.slice(0, transactionsListTitles.length),
+      ];
+
+      // omit "Operation Duration" column
+      sortedEventView.fields = fields.filter(({field}) => {
+        return !isRelativeSpanOperationBreakdownField(field);
+      });
+    }
+    return sortedEventView;
+  }
+
   render() {
     let {eventView} = this.props;
     const {
@@ -188,6 +223,10 @@ class SummaryContent extends React.Component<Props, State> {
       transactionThresholdMetric,
       loadingThreshold,
     } = this.props;
+    const hasPerformanceEventsPage = organization.features.includes(
+      'performance-events-page'
+    );
+
     const {incompatibleAlertNotice} = this.state;
     const query = decodeScalar(location.query.query, '');
     const totalCount = totalValues === null ? null : totalValues.count;
@@ -265,6 +304,24 @@ class SummaryContent extends React.Component<Props, State> {
       transactionsListEventView.fields = fields;
     }
 
+    const openAllEventsProps = {
+      generatePerformanceTransactionEventsView: () => {
+        const performanceTransactionEventsView = this.generateEventView(
+          transactionsListEventView,
+          transactionsListTitles
+        );
+        performanceTransactionEventsView.query = query;
+        return performanceTransactionEventsView;
+      },
+      handleOpenAllEventsClick: this.handleAllEventsViewClick,
+    };
+
+    const openInDiscoverProps = {
+      generateDiscoverEventView: () =>
+        this.generateEventView(transactionsListEventView, transactionsListTitles),
+      handleOpenInDiscoverClick: this.handleDiscoverViewClick,
+    };
+
     return (
       <React.Fragment>
         <TransactionHeader
@@ -313,28 +370,14 @@ class SummaryContent extends React.Component<Props, State> {
               location={location}
               organization={organization}
               eventView={transactionsListEventView}
-              generateDiscoverEventView={() => {
-                const {selected} = getTransactionsListSort(location, {
-                  p95: totalValues?.p95 ?? 0,
-                  spanOperationBreakdownFilter,
-                });
-                const sortedEventView = transactionsListEventView.withSorts([
-                  selected.sort,
-                ]);
-
-                if (spanOperationBreakdownFilter === SpanOperationBreakdownFilter.None) {
-                  const fields = [
-                    // Remove the extra field columns
-                    ...sortedEventView.fields.slice(0, transactionsListTitles.length),
-                  ];
-
-                  // omit "Operation Duration" column
-                  sortedEventView.fields = fields.filter(({field}) => {
-                    return !isRelativeSpanOperationBreakdownField(field);
-                  });
-                }
-                return sortedEventView;
-              }}
+              {...(hasPerformanceEventsPage ? openAllEventsProps : openInDiscoverProps)}
+              showTransactions={
+                decodeScalar(
+                  location.query.showTransactions,
+                  TransactionFilterOptions.SLOW
+                ) as TransactionFilterOptions
+              }
+              breakdown={decodeFilterFromLocation(location)}
               titles={transactionsListTitles}
               handleDropdownChange={this.handleTransactionsListSortChange}
               generateLink={{
@@ -344,7 +387,6 @@ class SummaryContent extends React.Component<Props, State> {
               baseline={transactionName}
               handleBaselineClick={this.handleViewDetailsClick}
               handleCellAction={this.handleCellAction}
-              handleOpenInDiscoverClick={this.handleDiscoverViewClick}
               {...getTransactionsListSort(location, {
                 p95: totalValues?.p95 ?? 0,
                 spanOperationBreakdownFilter,

+ 142 - 29
static/app/views/performance/transactionSummary/transactionEvents/content.tsx

@@ -7,24 +7,29 @@ import omit from 'lodash/omit';
 
 import Alert from 'app/components/alert';
 import {CreateAlertFromViewButton} from 'app/components/createAlertButton';
+import DropdownControl, {DropdownItem} from 'app/components/dropdownControl';
 import SearchBar from 'app/components/events/searchBar';
 import GlobalSdkUpdateAlert from 'app/components/globalSdkUpdateAlert';
 import * as Layout from 'app/components/layouts/thirds';
+import LoadingIndicator from 'app/components/loadingIndicator';
 import {getParams} from 'app/components/organizations/globalSelectionHeader/getParams';
 import {IconFlag} from 'app/icons';
 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 {WebVital} from 'app/utils/discover/fields';
 import {decodeScalar} from 'app/utils/queryString';
 import {tokenizeSearch} from 'app/utils/tokenizeSearch';
 import {Actions, updateQuery} from 'app/views/eventsV2/table/cellAction';
 import {TableColumn} from 'app/views/eventsV2/table/types';
 
 import {getCurrentLandingDisplay, LandingDisplayField} from '../../landing/utils';
+import Filter, {filterToSearchConditions, SpanOperationBreakdownFilter} from '../filter';
 import TransactionHeader, {Tab} from '../header';
 
 import EventsTable from './eventsTable';
+import {EventsDisplayFilterName, getEventsFilterOptions} from './utils';
 
 type Props = {
   location: Location;
@@ -32,6 +37,13 @@ type Props = {
   transactionName: string;
   organization: Organization;
   projects: Project[];
+  spanOperationBreakdownFilter: SpanOperationBreakdownFilter;
+  onChangeSpanOperationBreakdownFilter: (newFilter: SpanOperationBreakdownFilter) => void;
+  eventsDisplayFilterName: EventsDisplayFilterName;
+  onChangeEventsDisplayFilter: (eventsDisplayFilterName: EventsDisplayFilterName) => void;
+  percentileValues?: Record<EventsDisplayFilterName, number>;
+  isLoading: boolean;
+  webVital?: WebVital;
 };
 
 type State = {
@@ -98,16 +110,9 @@ class EventsPageContent extends React.Component<Props, State> {
   };
 
   render() {
-    const {eventView, location, organization, projects, transactionName} = this.props;
+    const {eventView, location, organization, projects, transactionName, isLoading} =
+      this.props;
     const {incompatibleAlertNotice} = this.state;
-    const transactionsListTitles = [
-      t('event id'),
-      t('user'),
-      t('operation duration'),
-      t('total duration'),
-      t('trace id'),
-      t('timestamp'),
-    ];
 
     return (
       <Fragment>
@@ -131,17 +136,11 @@ class EventsPageContent extends React.Component<Props, State> {
             <Layout.Main fullWidth>{incompatibleAlertNotice}</Layout.Main>
           )}
           <Layout.Main fullWidth>
-            <Search {...this.props} />
-            <StyledTable>
-              <EventsTable
-                eventView={eventView}
-                organization={organization}
-                location={location}
-                setError={this.setError}
-                columnTitles={transactionsListTitles}
-                transactionName={transactionName}
-              />
-            </StyledTable>
+            {isLoading ? (
+              <LoadingIndicator />
+            ) : (
+              <Body {...this.props} setError={this.setError} />
+            )}
           </Layout.Main>
         </Layout.Body>
       </Fragment>
@@ -149,8 +148,79 @@ class EventsPageContent extends React.Component<Props, State> {
   }
 }
 
+class Body extends React.Component<
+  Props & {setError: (error: string | undefined) => void},
+  State
+> {
+  render() {
+    let {eventView} = this.props;
+    const {
+      location,
+      organization,
+      transactionName,
+      spanOperationBreakdownFilter,
+      eventsDisplayFilterName,
+      onChangeEventsDisplayFilter,
+      setError,
+      webVital,
+    } = this.props;
+    const transactionsListTitles = [
+      t('event id'),
+      t('user'),
+      t('operation duration'),
+      t('total duration'),
+      t('trace id'),
+      t('timestamp'),
+    ];
+
+    if (webVital) {
+      transactionsListTitles.splice(3, 0, t(webVital));
+    }
+
+    const spanOperationBreakdownConditions = filterToSearchConditions(
+      spanOperationBreakdownFilter,
+      location
+    );
+
+    if (spanOperationBreakdownConditions) {
+      eventView = eventView.clone();
+      eventView.query = `${eventView.query} ${spanOperationBreakdownConditions}`.trim();
+      transactionsListTitles.splice(2, 1, t(`${spanOperationBreakdownFilter} duration`));
+    }
+
+    return (
+      <React.Fragment>
+        <Search
+          {...this.props}
+          onChangeEventsDisplayFilter={onChangeEventsDisplayFilter}
+          eventsDisplayFilterName={eventsDisplayFilterName}
+        />
+        <StyledTable>
+          <EventsTable
+            eventView={eventView}
+            organization={organization}
+            location={location}
+            setError={setError}
+            columnTitles={transactionsListTitles}
+            transactionName={transactionName}
+          />
+        </StyledTable>
+      </React.Fragment>
+    );
+  }
+}
+
 const Search = (props: Props) => {
-  const {eventView, location, organization} = props;
+  const {
+    eventView,
+    location,
+    organization,
+    spanOperationBreakdownFilter,
+    onChangeSpanOperationBreakdownFilter,
+    eventsDisplayFilterName,
+    onChangeEventsDisplayFilter,
+    percentileValues,
+  } = props;
 
   const handleSearch = (query: string) => {
     const queryParams = getParams({
@@ -168,17 +238,56 @@ const Search = (props: Props) => {
   };
 
   const query = decodeScalar(location.query.query, '');
+
+  const eventsFilterOptions = getEventsFilterOptions(
+    spanOperationBreakdownFilter,
+    percentileValues
+  );
+
   return (
-    <StyledSearchBar
-      organization={organization}
-      projectIds={eventView.project}
-      query={query}
-      fields={eventView.fields}
-      onSearch={handleSearch}
-    />
+    <SearchWrapper>
+      <Filter
+        organization={organization}
+        currentFilter={spanOperationBreakdownFilter}
+        onChangeFilter={onChangeSpanOperationBreakdownFilter}
+      />
+      <StyledSearchBar
+        organization={organization}
+        projectIds={eventView.project}
+        query={query}
+        fields={eventView.fields}
+        onSearch={handleSearch}
+      />
+      <LatencyDropdown>
+        <DropdownControl
+          buttonProps={{prefix: t('Display')}}
+          label={eventsFilterOptions[eventsDisplayFilterName].label}
+        >
+          {Object.entries(eventsFilterOptions).map(([name, filter]) => {
+            return (
+              <DropdownItem
+                key={name}
+                onSelect={onChangeEventsDisplayFilter}
+                eventKey={name}
+                data-test-id={name}
+                isActive={eventsDisplayFilterName === name}
+              >
+                {filter.label}
+              </DropdownItem>
+            );
+          })}
+        </DropdownControl>
+      </LatencyDropdown>
+    </SearchWrapper>
   );
 };
 
+const SearchWrapper = styled('div')`
+  display: flex;
+  width: 100%;
+  margin-bottom: ${space(3)};
+`;
+
 const StyledAlert = styled(Alert)`
   grid-column: 1/3;
   margin: 0;
@@ -190,7 +299,6 @@ const StyledSearchBar = styled(SearchBar)`
 
 const StyledTable = styled('div')`
   flex-grow: 1;
-  padding-top: ${space(2)};
 `;
 
 const StyledSdkUpdatesAlert = styled(GlobalSdkUpdateAlert)`
@@ -199,6 +307,11 @@ const StyledSdkUpdatesAlert = styled(GlobalSdkUpdateAlert)`
   }
 `;
 
+const LatencyDropdown = styled('div')`
+  margin-left: ${space(1)};
+  flex-grow: 0;
+`;
+
 StyledSdkUpdatesAlert.defaultProps = {
   Wrapper: p => <Layout.Main fullWidth {...p} />,
 };

+ 193 - 12
static/app/views/performance/transactionSummary/transactionEvents/index.tsx

@@ -9,12 +9,17 @@ import GlobalSelectionHeader from 'app/components/organizations/globalSelectionH
 import SentryDocumentTitle from 'app/components/sentryDocumentTitle';
 import {t} from 'app/locale';
 import {GlobalSelection, Organization, Project} from 'app/types';
+import {trackAnalyticsEvent} from 'app/utils/analytics';
+import DiscoverQuery from 'app/utils/discover/discoverQuery';
 import EventView from 'app/utils/discover/eventView';
 import {
   isAggregateField,
+  QueryFieldValue,
   SPAN_OP_BREAKDOWN_FIELDS,
   SPAN_OP_RELATIVE_BREAKDOWN_FIELD,
+  WebVital,
 } from 'app/utils/discover/fields';
+import {removeHistogramQueryStrings} from 'app/utils/performance/histogram';
 import {decodeScalar} from 'app/utils/queryString';
 import {tokenizeSearch} from 'app/utils/tokenizeSearch';
 import withGlobalSelection from 'app/utils/withGlobalSelection';
@@ -22,8 +27,21 @@ import withOrganization from 'app/utils/withOrganization';
 import withProjects from 'app/utils/withProjects';
 
 import {getTransactionName} from '../../utils';
+import {
+  decodeFilterFromLocation,
+  filterToLocationQuery,
+  SpanOperationBreakdownFilter,
+} from '../filter';
+import {ZOOM_END, ZOOM_START} from '../latencyChart';
 
 import EventsPageContent from './content';
+import {
+  decodeEventsDisplayFilterFromLocation,
+  EventsDisplayFilterName,
+  EventsFilterPercentileValues,
+  filterEventsDisplayToLocationQuery,
+  getEventsFilterOptions,
+} from './utils';
 
 type Props = {
   location: Location;
@@ -33,11 +51,16 @@ type Props = {
 } & Pick<WithRouterProps, 'router'>;
 
 type State = {
-  eventView: EventView | undefined;
+  spanOperationBreakdownFilter: SpanOperationBreakdownFilter;
+  eventsDisplayFilterName: EventsDisplayFilterName;
+  eventView?: EventView;
 };
 
+type PercentileValues = Record<EventsDisplayFilterName, number>;
 class TransactionEvents extends Component<Props, State> {
   state: State = {
+    spanOperationBreakdownFilter: decodeFilterFromLocation(this.props.location),
+    eventsDisplayFilterName: decodeEventsDisplayFilterFromLocation(this.props.location),
     eventView: generateEventsEventView(
       this.props.location,
       getTransactionName(this.props.location)
@@ -47,6 +70,8 @@ class TransactionEvents extends Component<Props, State> {
   static getDerivedStateFromProps(nextProps: Readonly<Props>, prevState: State): State {
     return {
       ...prevState,
+      spanOperationBreakdownFilter: decodeFilterFromLocation(nextProps.location),
+      eventsDisplayFilterName: decodeEventsDisplayFilterFromLocation(nextProps.location),
       eventView: generateEventsEventView(
         nextProps.location,
         getTransactionName(nextProps.location)
@@ -54,6 +79,91 @@ class TransactionEvents extends Component<Props, State> {
     };
   }
 
+  onChangeSpanOperationBreakdownFilter = (newFilter: SpanOperationBreakdownFilter) => {
+    const {location, organization} = this.props;
+    const {spanOperationBreakdownFilter, eventsDisplayFilterName, eventView} = this.state;
+
+    trackAnalyticsEvent({
+      eventName: 'Performance Views: Transaction Events Ops Breakdown Filter Dropdown',
+      eventKey: 'performance_views.transactionEvents.ops_filter_dropdown.selection',
+      organization_id: parseInt(organization.id, 10),
+      action: newFilter as string,
+    });
+
+    // Check to see if the current table sort matches the EventsDisplayFilter.
+    // If it does, we can re-sort using the new SpanOperationBreakdownFilter
+    const eventsFilterOptionSort = getEventsFilterOptions(spanOperationBreakdownFilter)[
+      eventsDisplayFilterName
+    ].sort;
+    const currentSort = eventView?.sorts?.[0];
+    let sortQuery = {};
+
+    if (
+      eventsFilterOptionSort?.kind === currentSort?.kind &&
+      eventsFilterOptionSort?.field === currentSort?.field
+    ) {
+      sortQuery = filterEventsDisplayToLocationQuery(eventsDisplayFilterName, newFilter);
+    }
+
+    const nextQuery: Location['query'] = {
+      ...removeHistogramQueryStrings(location, [ZOOM_START, ZOOM_END]),
+      ...filterToLocationQuery(newFilter),
+      ...sortQuery,
+    };
+
+    if (newFilter === SpanOperationBreakdownFilter.None) {
+      delete nextQuery.breakdown;
+    }
+    browserHistory.push({
+      pathname: location.pathname,
+      query: nextQuery,
+    });
+  };
+
+  onChangeEventsDisplayFilter = (newFilterName: EventsDisplayFilterName) => {
+    const {organization} = this.props;
+
+    trackAnalyticsEvent({
+      eventName: 'Performance Views: Transaction Events Display Filter Dropdown',
+      eventKey: 'performance_views.transactionEvents.display_filter_dropdown.selection',
+      organization_id: parseInt(organization.id, 10),
+      action: newFilterName as string,
+    });
+    this.filterDropdownSortEvents(newFilterName);
+  };
+
+  filterDropdownSortEvents = (newFilterName: EventsDisplayFilterName) => {
+    const {location} = this.props;
+    const {spanOperationBreakdownFilter} = this.state;
+    const nextQuery: Location['query'] = {
+      ...removeHistogramQueryStrings(location, [ZOOM_START, ZOOM_END]),
+      ...filterEventsDisplayToLocationQuery(newFilterName, spanOperationBreakdownFilter),
+    };
+
+    if (newFilterName === EventsDisplayFilterName.p100) {
+      delete nextQuery.showTransaction;
+    }
+
+    browserHistory.push({
+      pathname: location.pathname,
+      query: nextQuery,
+    });
+  };
+
+  getFilteredEventView = (percentiles: EventsFilterPercentileValues) => {
+    const {eventsDisplayFilterName, spanOperationBreakdownFilter, eventView} = this.state;
+    const filter = getEventsFilterOptions(spanOperationBreakdownFilter, percentiles)[
+      eventsDisplayFilterName
+    ];
+    const filteredEventView = eventView?.clone();
+    if (filteredEventView && filter?.query) {
+      const query = tokenizeSearch(filteredEventView.query);
+      filter.query.forEach(item => query.setTagValues(item[0], [item[1]]));
+      filteredEventView.query = query.formatString();
+    }
+    return filteredEventView;
+  };
+
   getDocumentTitle(): string {
     const name = getTransactionName(this.props.location);
 
@@ -66,6 +176,37 @@ class TransactionEvents extends Component<Props, State> {
     return [t('Summary'), t('Events')].join(' \u2014 ');
   }
 
+  getPercentilesEventView(eventView: EventView): EventView {
+    const percentileColumns: QueryFieldValue[] = [
+      {
+        kind: 'function',
+        function: ['p100', '', undefined, undefined],
+      },
+      {
+        kind: 'function',
+        function: ['p99', '', undefined, undefined],
+      },
+      {
+        kind: 'function',
+        function: ['p95', '', undefined, undefined],
+      },
+      {
+        kind: 'function',
+        function: ['p75', '', undefined, undefined],
+      },
+      {
+        kind: 'function',
+        function: ['p50', '', undefined, undefined],
+      },
+      {
+        kind: 'function',
+        function: ['avg', 'transaction.duration', undefined, undefined],
+      },
+    ];
+
+    return eventView.withColumns([...percentileColumns]);
+  }
+
   renderNoAccess = () => {
     return <Alert type="warning">{t("You don't have access to this feature")}</Alert>;
   };
@@ -74,6 +215,7 @@ class TransactionEvents extends Component<Props, State> {
     const {organization, projects, location} = this.props;
     const {eventView} = this.state;
     const transactionName = getTransactionName(location);
+    const webVital = getWebVital(location);
     if (!eventView || transactionName === undefined) {
       // If there is no transaction name, redirect to the Performance landing page
       browserHistory.replace({
@@ -84,6 +226,7 @@ class TransactionEvents extends Component<Props, State> {
       });
       return null;
     }
+    const percentilesView = this.getPercentilesEventView(eventView);
 
     const shouldForceProject = eventView.project.length === 1;
     const forceProject = shouldForceProject
@@ -93,6 +236,7 @@ class TransactionEvents extends Component<Props, State> {
       .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()}
@@ -113,13 +257,36 @@ class TransactionEvents extends Component<Props, State> {
             showProjectSettingsLink
           >
             <LightWeightNoProjectMessage organization={organization}>
-              <EventsPageContent
+              <DiscoverQuery
+                eventView={percentilesView}
+                orgSlug={organization.slug}
                 location={location}
-                eventView={eventView}
-                transactionName={transactionName}
-                organization={organization}
-                projects={projects}
-              />
+                referrer="api.performance.transaction-events"
+              >
+                {({isLoading, tableData}) => {
+                  const percentiles: PercentileValues = tableData?.data?.[0];
+                  return (
+                    <EventsPageContent
+                      location={location}
+                      eventView={this.getFilteredEventView(percentiles) as EventView}
+                      transactionName={transactionName}
+                      organization={organization}
+                      projects={projects}
+                      spanOperationBreakdownFilter={
+                        this.state.spanOperationBreakdownFilter
+                      }
+                      onChangeSpanOperationBreakdownFilter={
+                        this.onChangeSpanOperationBreakdownFilter
+                      }
+                      eventsDisplayFilterName={this.state.eventsDisplayFilterName}
+                      onChangeEventsDisplayFilter={this.onChangeEventsDisplayFilter}
+                      percentileValues={percentiles}
+                      isLoading={isLoading}
+                      webVital={webVital}
+                    />
+                  );
+                }}
+              </DiscoverQuery>
             </LightWeightNoProjectMessage>
           </GlobalSelectionHeader>
         </Feature>
@@ -128,15 +295,21 @@ class TransactionEvents extends Component<Props, State> {
   }
 }
 
+function getWebVital(location: Location): WebVital | undefined {
+  const webVital = decodeScalar(location.query.webVital, '') as WebVital;
+  if (Object.values(WebVital).includes(webVital)) {
+    return webVital;
+  }
+  return undefined;
+}
+
 function generateEventsEventView(
   location: Location,
-  transactionName: string | undefined
+  transactionName?: string
 ): EventView | undefined {
   if (transactionName === undefined) {
     return undefined;
   }
-  // Use the user supplied query but overwrite any transaction or event type
-  // conditions they applied.
   const query = decodeScalar(location.query.query, '');
   const conditions = tokenizeSearch(query);
   conditions
@@ -155,9 +328,17 @@ function generateEventsEventView(
     'transaction.duration',
     'trace',
     'timestamp',
-    'spans.total.time',
   ];
-  fields.push(...SPAN_OP_BREAKDOWN_FIELDS);
+  const breakdown = decodeFilterFromLocation(location);
+  if (breakdown !== SpanOperationBreakdownFilter.None) {
+    fields.splice(2, 1, `spans.${breakdown}`);
+  } else {
+    fields.push(...SPAN_OP_BREAKDOWN_FIELDS, 'spans.total.time');
+  }
+  const webVital = getWebVital(location);
+  if (webVital) {
+    fields.splice(3, 0, webVital);
+  }
 
   return EventView.fromNewQueryWithLocation(
     {

+ 138 - 1
static/app/views/performance/transactionSummary/transactionEvents/utils.tsx

@@ -1,4 +1,96 @@
-import {Query} from 'history';
+import {Location, Query} from 'history';
+
+import {t} from 'app/locale';
+import {decodeScalar} from 'app/utils/queryString';
+
+import {filterToField, SpanOperationBreakdownFilter} from '../filter';
+import {TransactionFilterOptions} from '../utils';
+
+export enum EventsDisplayFilterName {
+  p50 = 'p50',
+  p75 = 'p75',
+  p95 = 'p95',
+  p99 = 'p99',
+  p100 = 'p100',
+  avg_transaction_duration = 'avg_transaction_duration',
+}
+
+export type EventsDisplayFilter = {
+  name: EventsDisplayFilterName;
+  sort?: {kind: 'desc' | 'asc'; field: string};
+  label: string;
+  query?: string[][];
+};
+
+export type EventsFilterOptions = {
+  [name in EventsDisplayFilterName]: EventsDisplayFilter;
+};
+
+export type EventsFilterPercentileValues = {
+  [name in Exclude<EventsDisplayFilterName, EventsDisplayFilterName.p100>]: number;
+};
+
+export function getEventsFilterOptions(
+  spanOperationBreakdownFilter: SpanOperationBreakdownFilter,
+  percentileValues?: EventsFilterPercentileValues
+): EventsFilterOptions {
+  const {p99, p95, p75, p50, avg_transaction_duration} = percentileValues
+    ? percentileValues
+    : {p99: 0, p95: 0, p75: 0, p50: 0, avg_transaction_duration: 0};
+  return {
+    [EventsDisplayFilterName.p50]: {
+      name: EventsDisplayFilterName.p50,
+      query: p50 ? [['transaction.duration', `<=${p50.toFixed(0)}`]] : undefined,
+      sort: {
+        kind: 'desc',
+        field: filterToField(spanOperationBreakdownFilter) || 'transaction.duration',
+      },
+      label: t('p50'),
+    },
+    [EventsDisplayFilterName.p75]: {
+      name: EventsDisplayFilterName.p75,
+      query: p75 ? [['transaction.duration', `<=${p75.toFixed(0)}`]] : undefined,
+      sort: {
+        kind: 'desc',
+        field: filterToField(spanOperationBreakdownFilter) || 'transaction.duration',
+      },
+      label: t('p75'),
+    },
+    [EventsDisplayFilterName.p95]: {
+      name: EventsDisplayFilterName.p95,
+      query: p95 ? [['transaction.duration', `<=${p95.toFixed(0)}`]] : undefined,
+      sort: {
+        kind: 'desc',
+        field: filterToField(spanOperationBreakdownFilter) || 'transaction.duration',
+      },
+      label: t('p95'),
+    },
+    [EventsDisplayFilterName.p99]: {
+      name: EventsDisplayFilterName.p99,
+      query: p99 ? [['transaction.duration', `<=${p99.toFixed(0)}`]] : undefined,
+      sort: {
+        kind: 'desc',
+        field: filterToField(spanOperationBreakdownFilter) || 'transaction.duration',
+      },
+      label: t('p99'),
+    },
+    [EventsDisplayFilterName.p100]: {
+      name: EventsDisplayFilterName.p100,
+      label: t('p100'),
+    },
+    [EventsDisplayFilterName.avg_transaction_duration]: {
+      name: EventsDisplayFilterName.avg_transaction_duration,
+      query: avg_transaction_duration
+        ? [['transaction.duration', `<=${avg_transaction_duration.toFixed(0)}`]]
+        : undefined,
+      sort: {
+        kind: 'desc',
+        field: filterToField(spanOperationBreakdownFilter) || 'transaction.duration',
+      },
+      label: t('average'),
+    },
+  };
+}
 
 export function eventsRouteWithQuery({
   orgSlug,
@@ -25,3 +117,48 @@ export function eventsRouteWithQuery({
     },
   };
 }
+
+function stringToFilter(option: string) {
+  if (
+    Object.values(EventsDisplayFilterName).includes(option as EventsDisplayFilterName)
+  ) {
+    return option as EventsDisplayFilterName;
+  }
+
+  return EventsDisplayFilterName.p100;
+}
+export function decodeEventsDisplayFilterFromLocation(location: Location) {
+  return stringToFilter(
+    decodeScalar(location.query.showTransactions, EventsDisplayFilterName.p100)
+  );
+}
+
+export function filterEventsDisplayToLocationQuery(
+  option: EventsDisplayFilterName,
+  spanOperationBreakdownFilter: SpanOperationBreakdownFilter
+) {
+  const eventsFilterOptions = getEventsFilterOptions(spanOperationBreakdownFilter);
+  const kind = eventsFilterOptions[option].sort?.kind;
+  const field = eventsFilterOptions[option].sort?.field;
+
+  const query: {showTransactions: string; sort?: string} = {
+    showTransactions: option,
+  };
+  if (kind && field) {
+    query.sort = `${kind === 'desc' ? '-' : ''}${field}`;
+  }
+  return query;
+}
+
+export function mapShowTransactionToPercentile(
+  showTransaction
+): EventsDisplayFilterName | undefined {
+  switch (showTransaction) {
+    case TransactionFilterOptions.OUTLIER:
+      return EventsDisplayFilterName.p100;
+    case TransactionFilterOptions.SLOW:
+      return EventsDisplayFilterName.p95;
+    default:
+      return undefined;
+  }
+}

+ 53 - 9
static/app/views/performance/transactionSummary/transactionVitals/vitalCard.tsx

@@ -5,6 +5,7 @@ import {Location} from 'history';
 import isEqual from 'lodash/isEqual';
 import throttle from 'lodash/throttle';
 
+import Button from 'app/components/button';
 import BarChart from 'app/components/charts/barChart';
 import BarChartZoom from 'app/components/charts/barChartZoom';
 import MarkLine from 'app/components/charts/components/markLine';
@@ -19,12 +20,13 @@ import EventView from 'app/utils/discover/eventView';
 import {getAggregateAlias, WebVital} from 'app/utils/discover/fields';
 import {formatAbbreviatedNumber, formatFloat, getDuration} from 'app/utils/formatters';
 import getDynamicText from 'app/utils/getDynamicText';
-import {HistogramData} from 'app/utils/performance/histogram/types';
+import {DataFilter, HistogramData} from 'app/utils/performance/histogram/types';
 import {computeBuckets, formatHistogramData} from 'app/utils/performance/histogram/utils';
 import {Vital} from 'app/utils/performance/vitals/types';
 import {VitalData} from 'app/utils/performance/vitals/vitalsCardsDiscoverQuery';
 import {Theme} from 'app/utils/theme';
 import {tokenizeSearch} from 'app/utils/tokenizeSearch';
+import {EventsDisplayFilterName} from 'app/views/performance/transactionSummary/transactionEvents/utils';
 
 import {VitalBar} from '../../landing/vitalsCards';
 import {
@@ -54,6 +56,7 @@ type Props = {
   min?: number;
   max?: number;
   precision?: number;
+  dataFilter?: DataFilter;
 };
 
 type State = {
@@ -122,6 +125,18 @@ class VitalCard extends Component<Props, State> {
     });
   };
 
+  trackOpenAllEventsClicked = () => {
+    const {organization} = this.props;
+    const {vitalDetails: vital} = this.props;
+
+    trackAnalyticsEvent({
+      eventKey: 'performance_views.vitals.open_all_events',
+      eventName: 'Performance Views: Open vitals in all events',
+      organization_id: organization.id,
+      vital: vital.slug,
+    });
+  };
+
   get summary() {
     const {summaryData} = this.props;
     return summaryData?.p75 ?? null;
@@ -147,8 +162,18 @@ class VitalCard extends Component<Props, State> {
   }
 
   renderSummary() {
-    const {vitalDetails: vital, eventView, organization, min, max} = this.props;
+    const {
+      vitalDetails: vital,
+      eventView,
+      organization,
+      min,
+      max,
+      dataFilter,
+    } = this.props;
     const {slug, name, description} = vital;
+    const hasPerformanceEventsPage = organization.features.includes(
+      'performance-events-page'
+    );
 
     const column = `measurements.${slug}`;
 
@@ -194,13 +219,32 @@ class VitalCard extends Component<Props, State> {
         </StatNumber>
         <Description>{description}</Description>
         <div>
-          <DiscoverButton
-            size="small"
-            to={newEventView.getResultsViewUrlTarget(organization.slug)}
-            onClick={this.trackOpenInDiscoverClicked}
-          >
-            {t('Open in Discover')}
-          </DiscoverButton>
+          {hasPerformanceEventsPage ? (
+            <Button
+              size="small"
+              to={newEventView
+                .withColumns([{kind: 'field', field: column}])
+                .withSorts([{kind: 'desc', field: column}])
+                .getPerformanceTransactionEventsViewUrlTarget(organization.slug, {
+                  showTransactions:
+                    dataFilter === 'all'
+                      ? EventsDisplayFilterName.p100
+                      : EventsDisplayFilterName.p75,
+                  webVital: column as WebVital,
+                })}
+              onClick={this.trackOpenAllEventsClicked}
+            >
+              {t('Open All Events')}
+            </Button>
+          ) : (
+            <DiscoverButton
+              size="small"
+              to={newEventView.getResultsViewUrlTarget(organization.slug)}
+              onClick={this.trackOpenInDiscoverClicked}
+            >
+              {t('Open in Discover')}
+            </DiscoverButton>
+          )}
         </div>
       </CardSummary>
     );

+ 1 - 0
static/app/views/performance/transactionSummary/transactionVitals/vitalsPanel.tsx

@@ -72,6 +72,7 @@ class VitalsPanel extends Component<Props> {
               min={min}
               max={max}
               precision={precision}
+              dataFilter={dataFilter}
             />
           );
         }}

+ 23 - 0
tests/js/spec/components/discover/transactionsList.spec.jsx

@@ -402,5 +402,28 @@ describe('TransactionsList', function () {
         expect(cell.text()).toEqual(cellTexts[i]);
       });
     });
+
+    it('renders Open All Events button when provided with handler', async function () {
+      wrapper = mountWithTheme(
+        <TransactionsList
+          api={api}
+          location={location}
+          organization={organization}
+          eventView={eventView}
+          selected={options[0]}
+          options={options}
+          handleDropdownChange={handleDropdownChange}
+          baseline="/"
+          handleOpenAllEventsClick={() => {}}
+        />
+      );
+
+      await tick();
+      wrapper.update();
+
+      expect(wrapper.find('Button').last().find('span').children().html()).toEqual(
+        'Open All Events'
+      );
+    });
   });
 });

Некоторые файлы не были показаны из-за большого количества измененных файлов