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

feat(perf): Performance change explorer (#51802)

The Performance Change Explorer is a slide-out panel that can be
accessed by clicking any of the transaction links on the Trends page
(only Trends Page for now). The panel contains the transaction name, the
breakpoint start time, the duration graph, and a metrics chart comparing
the before and after of the four most important metrics. Tests were
updated and new tests were made for the new feature.

<img width="500" alt="Screenshot 2023-06-28 at 11 51 03 AM"
src="https://github.com/getsentry/sentry/assets/72356613/2a21662f-23d9-4376-b86e-78b65fd410e7">

---------

Co-authored-by: getsantry[bot] <66042841+getsantry[bot]@users.noreply.github.com>
nikkikapadia 1 год назад
Родитель
Сommit
fed99e5ba4

+ 300 - 0
static/app/views/performance/trends/changeExplorer.spec.tsx

@@ -0,0 +1,300 @@
+import React from 'react';
+import moment from 'moment';
+
+import {initializeData} from 'sentry-test/performance/initializePerformanceData';
+import {act, render, screen, waitFor} from 'sentry-test/reactTestingLibrary';
+
+import ProjectsStore from 'sentry/stores/projectsStore';
+import {PerformanceChangeExplorer} from 'sentry/views/performance/trends/changeExplorer';
+import {
+  COLUMNS,
+  MetricsTable,
+  renderBodyCell,
+} from 'sentry/views/performance/trends/changeExplorerUtils/metricsTable';
+import {
+  NormalizedTrendsTransaction,
+  TrendChangeType,
+  TrendFunctionField,
+} from 'sentry/views/performance/trends/types';
+import {TRENDS_PARAMETERS} from 'sentry/views/performance/trends/utils';
+
+async function waitForMockCall(mock: jest.Mock) {
+  await waitFor(() => {
+    expect(mock).toHaveBeenCalled();
+  });
+}
+
+const transaction: NormalizedTrendsTransaction = {
+  aggregate_range_1: 78.2757131147541,
+  aggregate_range_2: 110.50465131578949,
+  breakpoint: 1687262400,
+  project: 'sentry',
+  transaction: 'sentry.tasks.store.save_event',
+  trend_difference: 32.22893820103539,
+  trend_percentage: 1.411736117354651,
+  count: 3459,
+  received_at: moment(1601251200000),
+};
+
+describe('Performance > Trends > Performance Change Explorer', function () {
+  let eventsMockBefore;
+  beforeEach(function () {
+    eventsMockBefore = MockApiClient.addMockResponse({
+      url: '/organizations/org-slug/events/',
+      body: {
+        data: [
+          {
+            'p95()': 1010.9232499999998,
+            'p50()': 47.34580982348902,
+            'tps()': 3.7226926286168966,
+            'count()': 345,
+          },
+        ],
+        meta: {
+          fields: {
+            'p95()': 'duration',
+            '950()': 'duration',
+            'tps()': 'number',
+            'count()': 'number',
+          },
+          units: {
+            'p95()': 'millisecond',
+            'p50()': 'millisecond',
+            'tps()': null,
+            'count()': null,
+          },
+          isMetricsData: true,
+          tips: {},
+          dataset: 'metrics',
+        },
+      },
+    });
+  });
+
+  afterEach(function () {
+    MockApiClient.clearMockResponses();
+    act(() => ProjectsStore.reset());
+  });
+
+  it('renders basic UI elements', async function () {
+    const data = initializeData();
+    const statsData = {
+      ['/organizations/:orgId/performance/']: {
+        data: [],
+        order: 0,
+      },
+    };
+
+    render(
+      <PerformanceChangeExplorer
+        collapsed={false}
+        transaction={transaction}
+        onClose={() => {}}
+        trendChangeType={TrendChangeType.REGRESSION}
+        trendFunction={TrendFunctionField.P50}
+        trendParameter={TRENDS_PARAMETERS[0]}
+        trendView={data.eventView}
+        statsData={statsData}
+        isLoading={false}
+        organization={data.organization}
+        projects={data.projects}
+        location={data.location}
+      />,
+      {
+        context: data.routerContext,
+        organization: data.organization,
+      }
+    );
+
+    await waitForMockCall(eventsMockBefore);
+
+    await waitFor(() => {
+      expect(screen.getByTestId('pce-header')).toBeInTheDocument();
+      expect(screen.getByTestId('pce-graph')).toBeInTheDocument();
+      expect(screen.getByTestId('grid-editable')).toBeInTheDocument();
+      expect(screen.getAllByTestId('pce-metrics-chart-row-metric')).toHaveLength(4);
+      expect(screen.getAllByTestId('pce-metrics-chart-row-before')).toHaveLength(4);
+      expect(screen.getAllByTestId('pce-metrics-chart-row-after')).toHaveLength(4);
+      expect(screen.getAllByTestId('pce-metrics-chart-row-change')).toHaveLength(4);
+    });
+  });
+
+  it('shows correct change notation for no change', async () => {
+    const data = initializeData();
+
+    render(
+      <MetricsTable
+        isLoading={false}
+        location={data.location}
+        trendFunction={TrendFunctionField.P50}
+        transaction={transaction}
+        trendView={data.eventView}
+        organization={data.organization}
+      />
+    );
+
+    await waitForMockCall(eventsMockBefore);
+
+    await waitFor(() => {
+      expect(screen.getAllByText('3.7 ps')).toHaveLength(2);
+      expect(screen.getAllByTestId('pce-metrics-text-change')[0]).toHaveTextContent('-');
+    });
+  });
+
+  it('shows correct change notation for positive change', async () => {
+    const data = initializeData();
+
+    render(
+      <MetricsTable
+        isLoading={false}
+        location={data.location}
+        trendFunction={TrendFunctionField.P50}
+        transaction={transaction}
+        trendView={data.eventView}
+        organization={data.organization}
+      />
+    );
+
+    await waitForMockCall(eventsMockBefore);
+
+    await waitFor(() => {
+      expect(screen.getAllByTestId('pce-metrics-text-before')[1]).toHaveTextContent(
+        '78.3 ms'
+      );
+      expect(screen.getAllByTestId('pce-metrics-text-after')[1]).toHaveTextContent(
+        '110.5 ms'
+      );
+      expect(screen.getAllByTestId('pce-metrics-text-change')[1]).toHaveTextContent(
+        '+41.2%'
+      );
+    });
+  });
+
+  it('shows correct change notation for negative change', async () => {
+    const data = initializeData();
+    const negativeTransaction = {
+      ...transaction,
+      aggregate_range_1: 110.50465131578949,
+      aggregate_range_2: 78.2757131147541,
+      trend_percentage: 0.588263882645349,
+    };
+
+    render(
+      <MetricsTable
+        isLoading={false}
+        location={data.location}
+        trendFunction={TrendFunctionField.P50}
+        transaction={negativeTransaction}
+        trendView={data.eventView}
+        organization={data.organization}
+      />
+    );
+
+    await waitForMockCall(eventsMockBefore);
+
+    await waitFor(() => {
+      expect(screen.getAllByTestId('pce-metrics-text-after')[1]).toHaveTextContent(
+        '78.3 ms'
+      );
+      expect(screen.getAllByTestId('pce-metrics-text-before')[1]).toHaveTextContent(
+        '110.5 ms'
+      );
+      expect(screen.getAllByTestId('pce-metrics-text-change')[1]).toHaveTextContent(
+        '-41.2%'
+      );
+    });
+  });
+
+  it('shows correct change notation for no results', async () => {
+    const data = initializeData();
+
+    const nullEventsMock = MockApiClient.addMockResponse({
+      url: '/organizations/org-slug/events/',
+      body: {
+        data: [
+          {
+            'p95()': 1010.9232499999998,
+            'p50()': 47.34580982348902,
+            'count()': 345,
+          },
+        ],
+        meta: {
+          fields: {
+            'p95()': 'duration',
+            '950()': 'duration',
+            'count()': 'number',
+          },
+          units: {
+            'p95()': 'millisecond',
+            'p50()': 'millisecond',
+            'count()': null,
+          },
+          isMetricsData: true,
+          tips: {},
+          dataset: 'metrics',
+        },
+      },
+    });
+
+    render(
+      <MetricsTable
+        isLoading={false}
+        location={data.location}
+        trendFunction={TrendFunctionField.P50}
+        transaction={transaction}
+        trendView={data.eventView}
+        organization={data.organization}
+      />
+    );
+
+    await waitForMockCall(nullEventsMock);
+
+    await waitFor(() => {
+      expect(screen.getAllByTestId('pce-metrics-text-after')[0]).toHaveTextContent('-');
+      expect(screen.getAllByTestId('pce-metrics-text-before')[0]).toHaveTextContent('-');
+      expect(screen.getAllByTestId('pce-metrics-text-change')[0]).toHaveTextContent('-');
+    });
+  });
+
+  it('returns correct null formatting for change column', () => {
+    render(
+      <React.Fragment>
+        {renderBodyCell(COLUMNS.change, {
+          metric: null,
+          before: null,
+          after: null,
+          change: '0%',
+        })}
+        {renderBodyCell(COLUMNS.change, {
+          metric: null,
+          before: null,
+          after: null,
+          change: '+NaN%',
+        })}
+        {renderBodyCell(COLUMNS.change, {
+          metric: null,
+          before: null,
+          after: null,
+          change: '-',
+        })}
+      </React.Fragment>
+    );
+
+    expect(screen.getAllByTestId('pce-metrics-text-change')[0]).toHaveTextContent('-');
+    expect(screen.getAllByTestId('pce-metrics-text-change')[1]).toHaveTextContent('-');
+    expect(screen.getAllByTestId('pce-metrics-text-change')[2]).toHaveTextContent('-');
+  });
+
+  it('returns correct positive formatting for change column', () => {
+    render(
+      renderBodyCell(COLUMNS.change, {
+        metric: null,
+        before: null,
+        after: null,
+        change: '40.3%',
+      })
+    );
+
+    expect(screen.getByText('+40.3%')).toBeInTheDocument();
+  });
+});

+ 241 - 0
static/app/views/performance/trends/changeExplorer.tsx

@@ -0,0 +1,241 @@
+import {Fragment} from 'react';
+import styled from '@emotion/styled';
+import {Location} from 'history';
+import moment from 'moment';
+
+import {getArbitraryRelativePeriod} from 'sentry/components/organizations/timeRangeSelector/utils';
+import {DEFAULT_RELATIVE_PERIODS} from 'sentry/constants';
+import {IconFire} from 'sentry/icons';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import {Organization, Project} from 'sentry/types';
+import theme from 'sentry/utils/theme';
+import {MetricsTable} from 'sentry/views/performance/trends/changeExplorerUtils/metricsTable';
+import {Chart} from 'sentry/views/performance/trends/chart';
+import {
+  NormalizedTrendsTransaction,
+  TrendChangeType,
+  TrendParameter,
+  TrendsStats,
+  TrendView,
+} from 'sentry/views/performance/trends/types';
+import DetailPanel from 'sentry/views/starfish/components/detailPanel';
+
+type PerformanceChangeExplorerProps = {
+  collapsed: boolean;
+  isLoading: boolean;
+  location: Location;
+  onClose: () => void;
+  organization: Organization;
+  projects: Project[];
+  statsData: TrendsStats;
+  transaction: NormalizedTrendsTransaction;
+  trendChangeType: TrendChangeType;
+  trendFunction: string;
+  trendParameter: TrendParameter;
+  trendView: TrendView;
+};
+
+type ExplorerBodyProps = {
+  isLoading: boolean;
+  location: Location;
+  organization: Organization;
+  projects: Project[];
+  statsData: TrendsStats;
+  transaction: NormalizedTrendsTransaction;
+  trendChangeType: TrendChangeType;
+  trendFunction: string;
+  trendParameter: TrendParameter;
+  trendView: TrendView;
+};
+
+type HeaderProps = {
+  transaction: NormalizedTrendsTransaction;
+  trendChangeType: TrendChangeType;
+};
+
+export function PerformanceChangeExplorer({
+  collapsed,
+  transaction,
+  onClose,
+  trendChangeType,
+  trendFunction,
+  trendView,
+  statsData,
+  isLoading,
+  organization,
+  projects,
+  trendParameter,
+  location,
+}: PerformanceChangeExplorerProps) {
+  return (
+    <DetailPanel detailKey={!collapsed ? transaction.transaction : ''} onClose={onClose}>
+      {!collapsed && (
+        <PanelBodyWrapper>
+          <ExplorerBody
+            transaction={transaction}
+            trendChangeType={trendChangeType}
+            trendFunction={trendFunction}
+            trendView={trendView}
+            statsData={statsData}
+            isLoading={isLoading}
+            organization={organization}
+            projects={projects}
+            trendParameter={trendParameter}
+            location={location}
+          />
+        </PanelBodyWrapper>
+      )}
+    </DetailPanel>
+  );
+}
+
+function ExplorerBody(props: ExplorerBodyProps) {
+  const {
+    transaction,
+    trendChangeType,
+    trendFunction,
+    trendView,
+    trendParameter,
+    isLoading,
+    location,
+    organization,
+  } = props;
+  const breakpointDate = transaction.breakpoint
+    ? moment(transaction.breakpoint * 1000).format('ddd, DD MMM YYYY HH:mm:ss z')
+    : '';
+
+  const start = moment(trendView.start).format('DD MMM YYYY HH:mm:ss z');
+  const end = moment(trendView.end).format('DD MMM YYYY HH:mm:ss z');
+  return (
+    <Fragment>
+      <Header transaction={transaction} trendChangeType={trendChangeType} />
+      <div style={{display: 'flex', gap: space(4)}}>
+        <InfoItem
+          label={
+            trendChangeType === TrendChangeType.REGRESSION
+              ? t('Regression Metric')
+              : t('Improvement Metric')
+          }
+          value={trendFunction}
+        />
+        <InfoItem label={t('Start Time')} value={breakpointDate} />
+      </div>
+      <GraphPanel data-test-id="pce-graph">
+        <strong>{`${trendParameter.label} (${trendFunction})`}</strong>
+        <ExplorerText color={theme.gray300} margin={`-${space(3)}`}>
+          {trendView.statsPeriod
+            ? DEFAULT_RELATIVE_PERIODS[trendView.statsPeriod] ||
+              getArbitraryRelativePeriod(trendView.statsPeriod)[trendView.statsPeriod]
+            : `${start} - ${end}`}
+        </ExplorerText>
+        <Chart
+          query={trendView.query}
+          project={trendView.project}
+          environment={trendView.environment}
+          start={trendView.start}
+          end={trendView.end}
+          statsPeriod={trendView.statsPeriod}
+          disableXAxis
+          disableLegend
+          neutralColor
+          {...props}
+        />
+      </GraphPanel>
+      <MetricsTable
+        isLoading={isLoading}
+        location={location}
+        transaction={transaction}
+        trendFunction={trendFunction}
+        trendView={trendView}
+        organization={organization}
+      />
+    </Fragment>
+  );
+}
+
+function InfoItem({label, value}: {label: string; value: string}) {
+  return (
+    <div>
+      <InfoLabel>{label}</InfoLabel>
+      <InfoText>{value}</InfoText>
+    </div>
+  );
+}
+
+function Header(props: HeaderProps) {
+  const {transaction, trendChangeType} = props;
+
+  const regression = trendChangeType === TrendChangeType.REGRESSION;
+
+  return (
+    <HeaderWrapper data-test-id="pce-header">
+      <FireIcon regression={regression}>
+        <IconFire color="white" />
+      </FireIcon>
+      <HeaderTextWrapper>
+        <ChangeType regression={regression}>
+          {regression ? t('Ongoing Regression') : t('Ongoing Improvement')}
+        </ChangeType>
+        <TransactionName>{transaction.transaction}</TransactionName>
+      </HeaderTextWrapper>
+    </HeaderWrapper>
+  );
+}
+
+const PanelBodyWrapper = styled('div')`
+  padding: 0 ${space(2)};
+  margin-top: ${space(4)};
+`;
+
+const HeaderWrapper = styled('div')`
+  display: flex;
+  flex-wrap: nowrap;
+  margin-bottom: ${space(3)};
+`;
+const HeaderTextWrapper = styled('div')`
+  ${p => p.theme.overflowEllipsis};
+`;
+type ChangeTypeProps = {regression: boolean};
+
+const ChangeType = styled('p')<ChangeTypeProps>`
+  color: ${p => (p.regression ? p.theme.danger : p.theme.success)};
+  margin-bottom: ${space(0)};
+`;
+
+const FireIcon = styled('div')<ChangeTypeProps>`
+  padding: ${space(1.5)};
+  background-color: ${p => (p.regression ? p.theme.danger : p.theme.success)};
+  border-radius: ${space(0.5)};
+  margin-right: ${space(2)};
+  float: left;
+  height: 40px;
+`;
+
+const TransactionName = styled('h4')`
+  margin-right: ${space(1)};
+  ${p => p.theme.overflowEllipsis};
+`;
+const InfoLabel = styled('strong')`
+  color: ${p => p.theme.gray300};
+`;
+const InfoText = styled('h3')`
+  font-weight: normal;
+`;
+const GraphPanel = styled('div')`
+  border: 1px solid ${p => p.theme.border};
+  border-radius: ${p => p.theme.panelBorderRadius};
+  margin-bottom: ${space(2)};
+  padding: ${space(3)};
+  display: block;
+`;
+
+export const ExplorerText = styled('p')<{
+  align?: string;
+  color?: string;
+  margin?: string;
+}>`
+  margin-bottom: ${p => (p.margin ? p.margin : space(0))};
+  color: ${p => p.color};
+  text-align: ${p => p.align};
+`;

+ 420 - 0
static/app/views/performance/trends/changeExplorerUtils/metricsTable.tsx

@@ -0,0 +1,420 @@
+import {ReactNode, useMemo} from 'react';
+import {Location} from 'history';
+import moment from 'moment';
+
+import GridEditable, {
+  COL_WIDTH_UNDEFINED,
+  GridColumnOrder,
+} from 'sentry/components/gridEditable';
+import SortLink from 'sentry/components/gridEditable/sortLink';
+import {t} from 'sentry/locale';
+import {Organization} from 'sentry/types';
+import {parsePeriodToHours} from 'sentry/utils/dates';
+import {TableData, useDiscoverQuery} from 'sentry/utils/discover/discoverQuery';
+import EventView from 'sentry/utils/discover/eventView';
+import {
+  AggregationKeyWithAlias,
+  ColumnType,
+  fieldAlignment,
+  QueryFieldValue,
+} from 'sentry/utils/discover/fields';
+import {Container} from 'sentry/utils/discover/styles';
+import {DiscoverDatasets} from 'sentry/utils/discover/types';
+import {formatPercentage} from 'sentry/utils/formatters';
+import theme from 'sentry/utils/theme';
+import {MutableSearch} from 'sentry/utils/tokenizeSearch';
+import {TransactionThresholdMetric} from 'sentry/views/performance/transactionSummary/transactionThresholdModal';
+import {ExplorerText} from 'sentry/views/performance/trends/changeExplorer';
+import {
+  NormalizedTrendsTransaction,
+  TrendFunctionField,
+  TrendsTransaction,
+  TrendView,
+} from 'sentry/views/performance/trends/types';
+
+type MetricsTableProps = {
+  isLoading: boolean;
+  location: Location;
+  organization: Organization;
+  transaction: NormalizedTrendsTransaction;
+  trendFunction: string;
+  trendView: TrendView;
+};
+
+const fieldsNeeded: AggregationKeyWithAlias[] = ['tps', 'p50', 'p95'];
+
+type MetricColumnKey = 'metric' | 'before' | 'after' | 'change';
+
+type MetricColumn = GridColumnOrder<MetricColumnKey>;
+
+type TableDataRow = Record<MetricColumnKey, any>;
+
+const MetricColumnOrder = ['metric', 'before', 'after', 'change'];
+
+export const COLUMNS: Record<MetricColumnKey, MetricColumn> = {
+  metric: {
+    key: 'metric',
+    name: t('Metric'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  before: {
+    key: 'before',
+    name: t('Before'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  after: {
+    key: 'after',
+    name: t('After'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  change: {
+    key: 'change',
+    name: t('Change'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+};
+
+const COLUMN_TYPE: Record<MetricColumnKey, ColumnType> = {
+  metric: 'string',
+  before: 'duration',
+  after: 'duration',
+  change: 'percentage',
+};
+
+export function MetricsTable(props: MetricsTableProps) {
+  const {trendFunction, transaction, trendView, organization, location, isLoading} =
+    props;
+  const p50 =
+    trendFunction === TrendFunctionField.P50
+      ? getTrendsRowData(transaction, TrendFunctionField.P50)
+      : undefined;
+  const p95 =
+    trendFunction === TrendFunctionField.P95
+      ? getTrendsRowData(transaction, TrendFunctionField.P95)
+      : undefined;
+
+  const breakpoint = transaction.breakpoint;
+
+  const hours = trendView.statsPeriod ? parsePeriodToHours(trendView.statsPeriod) : 0;
+  const startTime = useMemo(
+    () =>
+      trendView.start ? trendView.start : moment().subtract(hours, 'h').toISOString(),
+    [hours, trendView.start]
+  );
+  const breakpointTime = breakpoint ? new Date(breakpoint * 1000).toISOString() : '';
+  const endTime = useMemo(
+    () => (trendView.end ? trendView.end : moment().toISOString()),
+    [trendView.end]
+  );
+
+  const {data: beforeBreakpoint, isLoading: isLoadingBefore} = useDiscoverQuery(
+    getQueryParams(
+      startTime,
+      breakpointTime,
+      fieldsNeeded,
+      'transaction',
+      DiscoverDatasets.METRICS,
+      organization,
+      trendView,
+      transaction.transaction,
+      location
+    )
+  );
+
+  const {data: afterBreakpoint, isLoading: isLoadingAfter} = useDiscoverQuery(
+    getQueryParams(
+      breakpointTime,
+      endTime,
+      fieldsNeeded,
+      'transaction',
+      DiscoverDatasets.METRICS,
+      organization,
+      trendView,
+      transaction.transaction,
+      location
+    )
+  );
+
+  const {data: beforeBreakpointErrors, isLoading: isLoadingBeforeErrors} =
+    useDiscoverQuery(
+      getQueryParams(
+        startTime,
+        breakpointTime,
+        ['count'],
+        'error',
+        DiscoverDatasets.DISCOVER,
+        organization,
+        trendView,
+        transaction.transaction,
+        location
+      )
+    );
+
+  const {data: afterBreakpointErrors, isLoading: isLoadingAfterErrors} = useDiscoverQuery(
+    getQueryParams(
+      breakpointTime,
+      endTime,
+      ['count'],
+      'error',
+      DiscoverDatasets.DISCOVER,
+      organization,
+      trendView,
+      transaction.transaction,
+      location
+    )
+  );
+
+  const throughput: TableDataRow = getEventsRowData(
+    'tps()',
+    'Throughput',
+    'ps',
+    '-',
+    false,
+    beforeBreakpoint,
+    afterBreakpoint
+  );
+
+  const p50Events = !p50
+    ? getEventsRowData(
+        'p50()',
+        'P50',
+        'ms',
+        '-',
+        false,
+        beforeBreakpoint,
+        afterBreakpoint
+      )
+    : p50;
+
+  const p95Events = !p95
+    ? getEventsRowData(
+        'p95()',
+        'P95',
+        'ms',
+        '-',
+        false,
+        beforeBreakpoint,
+        afterBreakpoint
+      )
+    : p95;
+
+  const errors: TableDataRow = getEventsRowData(
+    'count()',
+    'Errors',
+    '',
+    0,
+    true,
+    beforeBreakpointErrors,
+    afterBreakpointErrors
+  );
+
+  const columnOrder = MetricColumnOrder.map(column => COLUMNS[column]);
+
+  return (
+    <GridEditable
+      data={[throughput, p50Events, p95Events, errors]}
+      columnOrder={columnOrder}
+      columnSortBy={[]}
+      grid={{
+        renderHeadCell,
+        renderBodyCell,
+      }}
+      location={location}
+      isLoading={
+        isLoadingBefore ||
+        isLoadingAfter ||
+        isLoading ||
+        isLoadingBeforeErrors ||
+        isLoadingAfterErrors
+      }
+    />
+  );
+}
+
+function getEventsRowData(
+  field: string,
+  rowTitle: string,
+  suffix: string,
+  nullValue: string | number,
+  wholeNumbers: boolean,
+  beforeData?: TableData,
+  afterData?: TableData
+): TableDataRow {
+  if (beforeData?.data[0][field] && afterData?.data[0][field]) {
+    return {
+      metric: rowTitle,
+      before: !wholeNumbers
+        ? toFormattedNumber(beforeData.data[0][field].toString(), 1) + ' ' + suffix
+        : beforeData.data[0][field],
+      after: !wholeNumbers
+        ? toFormattedNumber(afterData.data[0][field].toString(), 1) + ' ' + suffix
+        : afterData.data[0][field],
+      change: formatPercentage(
+        percentChange(
+          beforeData.data[0][field] as number,
+          afterData.data[0][field] as number
+        ),
+        1
+      ),
+    };
+  }
+  return {
+    metric: rowTitle,
+    before: nullValue,
+    after: nullValue,
+    change: '-',
+  };
+}
+
+function getTrendsRowData(
+  aggregateData: TrendsTransaction | undefined,
+  metric: TrendFunctionField
+): TableDataRow | undefined {
+  if (aggregateData) {
+    return {
+      metric: metric.toString().toUpperCase(),
+      before: aggregateData?.aggregate_range_1.toFixed(1) + ' ms',
+      after: aggregateData?.aggregate_range_2.toFixed(1) + ' ms',
+      change:
+        aggregateData?.trend_percentage !== 1
+          ? formatPercentage(aggregateData?.trend_percentage! - 1, 1)
+          : '-',
+    };
+  }
+  return undefined;
+}
+
+function getEventViewWithFields(
+  _organization: Organization,
+  eventView: EventView,
+  start: string,
+  end: string,
+  fields: AggregationKeyWithAlias[],
+  eventType: string,
+  transactionName: string,
+  dataset: DiscoverDatasets
+): EventView {
+  const newEventView = eventView.clone();
+  newEventView.start = start;
+  newEventView.end = end;
+  newEventView.statsPeriod = undefined;
+  newEventView.dataset = dataset;
+  newEventView.query = 'event.type:' + eventType + ' transaction:' + transactionName;
+  newEventView.additionalConditions = new MutableSearch('');
+
+  const chartFields: QueryFieldValue[] = fields.map(field => {
+    return {
+      kind: 'function',
+      function: [field, '', undefined, undefined],
+    };
+  });
+
+  return newEventView.withColumns(chartFields);
+}
+
+function toFormattedNumber(numberString: string, decimal: number) {
+  return parseFloat(numberString).toFixed(decimal);
+}
+
+function percentChange(before: number, after: number) {
+  return (after - before) / before;
+}
+
+function renderHeadCell(column: MetricColumn, _index: number): ReactNode {
+  const align = fieldAlignment(column.key, COLUMN_TYPE[column.key]);
+  return (
+    <SortLink
+      title={column.name}
+      align={align}
+      direction={undefined}
+      canSort={false}
+      generateSortLink={() => undefined}
+    />
+  );
+}
+
+export function renderBodyCell(
+  column: GridColumnOrder<MetricColumnKey>,
+  dataRow: TableDataRow
+) {
+  let data = '';
+  let color = '';
+  if (column.key === 'change') {
+    if (
+      dataRow[column.key] === '0%' ||
+      dataRow[column.key] === '+NaN%' ||
+      dataRow[column.key] === '-'
+    ) {
+      data = '-';
+    } else if (dataRow[column.key].charAt(0) !== '-') {
+      color = theme.red300;
+      data = '+' + dataRow[column.key];
+    } else {
+      color = theme.green300;
+      data = dataRow[column.key];
+    }
+  } else {
+    data = dataRow[column.key];
+  }
+
+  return (
+    <Container data-test-id={'pce-metrics-chart-row-' + column.key}>
+      <ExplorerText
+        data-test-id={'pce-metrics-text-' + column.key}
+        align={column.key !== 'metric' ? 'right' : 'left'}
+        color={color}
+      >
+        {data}
+      </ExplorerText>
+    </Container>
+  );
+}
+
+function getQueryParams(
+  startTime: string,
+  endTime: string,
+  fields: AggregationKeyWithAlias[],
+  query: string,
+  dataset: DiscoverDatasets,
+  organization: Organization,
+  eventView: EventView,
+  transactionName: string,
+  location: Location
+) {
+  const newLocation = {
+    ...location,
+    start: startTime,
+    end: endTime,
+    statsPeriod: undefined,
+    dataset,
+    sort: undefined,
+    query: {
+      query: `event.type: ${query} transaction: ${transactionName}`,
+      statsPeriod: undefined,
+      start: startTime,
+      end: endTime,
+    },
+  };
+
+  const newEventView = getEventViewWithFields(
+    organization,
+    eventView,
+    startTime,
+    endTime,
+    fields,
+    query,
+    transactionName,
+    dataset
+  );
+
+  return {
+    eventView: newEventView,
+    location: newLocation,
+    orgSlug: organization.slug,
+    transactionName,
+    transactionThresholdMetric: TransactionThresholdMetric.TRANSACTION_DURATION,
+    options: {
+      refetchOnWindowFocus: false,
+    },
+  };
+}

+ 146 - 98
static/app/views/performance/trends/changedTransactions.tsx

@@ -1,9 +1,10 @@
-import {Fragment} from 'react';
+import React, {Fragment, useCallback, useState} from 'react';
 import {browserHistory} from 'react-router';
 import styled from '@emotion/styled';
 import {Location} from 'history';
 
 import {Client} from 'sentry/api';
+import Feature from 'sentry/components/acl/feature';
 import {Button} from 'sentry/components/button';
 import {HeaderTitleLegend} from 'sentry/components/charts/styles';
 import Count from 'sentry/components/count';
@@ -37,6 +38,7 @@ import {
   DisplayModes,
   transactionSummaryRouteWithQuery,
 } from 'sentry/views/performance/transactionSummary/utils';
+import {PerformanceChangeExplorer} from 'sentry/views/performance/trends/changeExplorer';
 import {getSelectedTransaction} from 'sentry/views/performance/utils';
 
 import Chart from './chart';
@@ -44,6 +46,7 @@ import {
   NormalizedTrendsTransaction,
   TrendChangeType,
   TrendFunctionField,
+  TrendParameter,
   TrendParameterColumn,
   TrendsStats,
   TrendView,
@@ -312,7 +315,7 @@ function ChangedTransactions(props: Props) {
                           api={api}
                           currentTrendFunction={currentTrendFunction}
                           currentTrendColumn={currentTrendColumn}
-                          trendView={props.trendView}
+                          trendView={trendView}
                           organization={organization}
                           transaction={transaction}
                           key={transaction.transaction}
@@ -327,6 +330,8 @@ function ChangedTransactions(props: Props) {
                             organization,
                             trendChangeType
                           )}
+                          isLoading={isLoading}
+                          trendParameter={trendParameter}
                         />
                       ))}
                     </Fragment>
@@ -356,6 +361,7 @@ type TrendsListItemProps = {
   currentTrendFunction: string;
   handleSelectTransaction: (transaction: NormalizedTrendsTransaction) => void;
   index: number;
+  isLoading: boolean;
   location: Location;
   organization: Organization;
   projects: Project[];
@@ -363,6 +369,7 @@ type TrendsListItemProps = {
   transaction: NormalizedTrendsTransaction;
   transactions: NormalizedTrendsTransaction[];
   trendChangeType: TrendChangeType;
+  trendParameter: TrendParameter;
   trendView: TrendView;
 };
 
@@ -379,9 +386,14 @@ function TrendsListItem(props: TrendsListItemProps) {
     projects,
     handleSelectTransaction,
     trendView,
+    statsData,
+    isLoading,
+    trendParameter,
   } = props;
   const color = trendToColor[trendChangeType].default;
 
+  const [openedTransaction, setOpenedTransaction] = useState<null | string>(null);
+
   const selectedTransaction = getSelectedTransaction(
     location,
     trendChangeType,
@@ -428,110 +440,128 @@ function TrendsListItem(props: TrendsListItemProps) {
     trendChangeType === TrendChangeType.IMPROVED ? previousDuration : currentDuration;
 
   return (
-    <ListItemContainer data-test-id={'trends-list-item-' + trendChangeType}>
-      <ItemRadioContainer color={color}>
-        {transaction.count_range_1 && transaction.count_range_2 ? (
-          <Tooltip
-            title={
-              <TooltipContent>
-                <span>{t('Total Events')}</span>
-                <span>
-                  <Count value={transaction.count_range_1} />
-                  <StyledIconArrow direction="right" size="xs" />
-                  <Count value={transaction.count_range_2} />
-                </span>
-              </TooltipContent>
-            }
-            disableForVisualTest // Disabled tooltip in snapshots because of overlap order issues.
-          >
+    <Fragment>
+      <ListItemContainer data-test-id={'trends-list-item-' + trendChangeType}>
+        <ItemRadioContainer color={color}>
+          {transaction.count_range_1 && transaction.count_range_2 ? (
+            <Tooltip
+              title={
+                <TooltipContent>
+                  <span>{t('Total Events')}</span>
+                  <span>
+                    <Count value={transaction.count_range_1} />
+                    <StyledIconArrow direction="right" size="xs" />
+                    <Count value={transaction.count_range_2} />
+                  </span>
+                </TooltipContent>
+              }
+              disableForVisualTest // Disabled tooltip in snapshots because of overlap order issues.
+            >
+              <RadioLineItem index={index} role="radio">
+                <Radio
+                  checked={isSelected}
+                  onChange={() => handleSelectTransaction(transaction)}
+                />
+              </RadioLineItem>
+            </Tooltip>
+          ) : (
             <RadioLineItem index={index} role="radio">
               <Radio
                 checked={isSelected}
                 onChange={() => handleSelectTransaction(transaction)}
               />
             </RadioLineItem>
+          )}
+        </ItemRadioContainer>
+        <TransactionSummaryLink {...props} onItemClicked={setOpenedTransaction} />
+        <ItemTransactionPercentage>
+          <Tooltip title={percentChangeExplanation}>
+            <Fragment>
+              {trendChangeType === TrendChangeType.REGRESSION ? '+' : ''}
+              {formatPercentage(transaction.trend_percentage - 1, 0)}
+            </Fragment>
           </Tooltip>
-        ) : (
-          <RadioLineItem index={index} role="radio">
-            <Radio
-              checked={isSelected}
-              onChange={() => handleSelectTransaction(transaction)}
+        </ItemTransactionPercentage>
+        <DropdownLink
+          caret={false}
+          anchorRight
+          title={
+            <StyledButton
+              size="xs"
+              icon={<IconEllipsis data-test-id="trends-item-action" size="xs" />}
+              aria-label={t('Actions')}
             />
-          </RadioLineItem>
-        )}
-      </ItemRadioContainer>
-      <TransactionSummaryLink {...props} />
-      <ItemTransactionPercentage>
-        <Tooltip title={percentChangeExplanation}>
-          <Fragment>
-            {trendChangeType === TrendChangeType.REGRESSION ? '+' : ''}
-            {formatPercentage(transaction.trend_percentage - 1, 0)}
-          </Fragment>
-        </Tooltip>
-      </ItemTransactionPercentage>
-      <DropdownLink
-        caret={false}
-        anchorRight
-        title={
-          <StyledButton
-            size="xs"
-            icon={<IconEllipsis data-test-id="trends-item-action" size="xs" />}
-            aria-label={t('Actions')}
-          />
-        }
-      >
-        {!organization.features.includes('performance-new-trends') && (
-          <Fragment>
-            <MenuItem
-              onClick={() =>
-                handleFilterDuration(
-                  location,
-                  organization,
-                  longestPeriodValue,
-                  FilterSymbols.LESS_THAN_EQUALS,
-                  trendChangeType,
-                  projects,
-                  trendView.project
-                )
-              }
-            >
-              <MenuAction>{t('Show \u2264 %s', longestDuration)}</MenuAction>
-            </MenuItem>
-            <MenuItem
-              onClick={() =>
-                handleFilterDuration(
-                  location,
-                  organization,
-                  longestPeriodValue,
-                  FilterSymbols.GREATER_THAN_EQUALS,
-                  trendChangeType,
-                  projects,
-                  trendView.project
-                )
-              }
-            >
-              <MenuAction>{t('Show \u2265 %s', longestDuration)}</MenuAction>
-            </MenuItem>
-          </Fragment>
-        )}
-        <MenuItem
-          onClick={() => handleFilterTransaction(location, transaction.transaction)}
+          }
         >
-          <MenuAction>{t('Hide from list')}</MenuAction>
-        </MenuItem>
-      </DropdownLink>
-      <ItemTransactionDurationChange>
-        {project && (
-          <Tooltip title={transaction.project}>
-            <IdBadge avatarSize={16} project={project} hideName />
-          </Tooltip>
-        )}
-        <CompareDurations {...props} />
-      </ItemTransactionDurationChange>
-      <ItemTransactionStatus color={color}>
-        <ValueDelta {...props} />
-      </ItemTransactionStatus>
-    </ListItemContainer>
+          {!organization.features.includes('performance-new-trends') && (
+            <Fragment>
+              <MenuItem
+                onClick={() =>
+                  handleFilterDuration(
+                    location,
+                    organization,
+                    longestPeriodValue,
+                    FilterSymbols.LESS_THAN_EQUALS,
+                    trendChangeType,
+                    projects,
+                    trendView.project
+                  )
+                }
+              >
+                <MenuAction>{t('Show \u2264 %s', longestDuration)}</MenuAction>
+              </MenuItem>
+              <MenuItem
+                onClick={() =>
+                  handleFilterDuration(
+                    location,
+                    organization,
+                    longestPeriodValue,
+                    FilterSymbols.GREATER_THAN_EQUALS,
+                    trendChangeType,
+                    projects,
+                    trendView.project
+                  )
+                }
+              >
+                <MenuAction>{t('Show \u2265 %s', longestDuration)}</MenuAction>
+              </MenuItem>
+            </Fragment>
+          )}
+          <MenuItem
+            onClick={() => handleFilterTransaction(location, transaction.transaction)}
+          >
+            <MenuAction>{t('Hide from list')}</MenuAction>
+          </MenuItem>
+        </DropdownLink>
+        <ItemTransactionDurationChange>
+          {project && (
+            <Tooltip title={transaction.project}>
+              <IdBadge avatarSize={16} project={project} hideName />
+            </Tooltip>
+          )}
+          <CompareDurations {...props} />
+        </ItemTransactionDurationChange>
+        <ItemTransactionStatus color={color}>
+          <ValueDelta {...props} />
+        </ItemTransactionStatus>
+      </ListItemContainer>
+      <Feature features={['performance-change-explorer']}>
+        <PerformanceChangeExplorer
+          collapsed={openedTransaction === null}
+          onClose={() => setOpenedTransaction(null)}
+          transaction={transaction}
+          trendChangeType={trendChangeType}
+          trendFunction={currentTrendFunction}
+          trendView={trendView}
+          statsData={statsData}
+          isLoading={isLoading}
+          organization={organization}
+          projects={projects}
+          trendParameter={trendParameter}
+          location={location}
+        />
+      </Feature>
+    </Fragment>
   );
 }
 
@@ -567,7 +597,9 @@ function ValueDelta({transaction, trendChangeType}: TrendsListItemProps) {
   );
 }
 
-type TransactionSummaryLinkProps = TrendsListItemProps & {};
+type TransactionSummaryLinkProps = TrendsListItemProps & {
+  onItemClicked: React.Dispatch<React.SetStateAction<null | string>>;
+};
 
 function TransactionSummaryLink(props: TransactionSummaryLinkProps) {
   const {
@@ -577,6 +609,7 @@ function TransactionSummaryLink(props: TransactionSummaryLinkProps) {
     projects,
     location,
     currentTrendFunction,
+    onItemClicked: onTransactionSelection,
   } = props;
   const summaryView = eventView.clone();
   const projectID = getTrendProjectId(transaction, projects);
@@ -592,6 +625,21 @@ function TransactionSummaryLink(props: TransactionSummaryLinkProps) {
     },
   });
 
+  const handleClick = useCallback(() => {
+    onTransactionSelection(transaction.transaction);
+  }, [onTransactionSelection, transaction.transaction]);
+
+  if (organization.features.includes('performance-change-explorer')) {
+    return (
+      <ItemTransactionName
+        to=""
+        data-test-id="item-transaction-name"
+        onClick={handleClick}
+      >
+        {transaction.transaction}
+      </ItemTransactionName>
+    );
+  }
   return (
     <ItemTransactionName to={target} data-test-id="item-transaction-name">
       {transaction.transaction}

+ 4 - 1
static/app/views/performance/trends/chart.tsx

@@ -49,6 +49,7 @@ type Props = ViewProps & {
   disableXAxis?: boolean;
   grid?: LineChartProps['grid'];
   height?: number;
+  neutralColor?: boolean;
   transaction?: NormalizedTrendsTransaction;
   trendFunctionField?: TrendFunctionField;
 };
@@ -97,6 +98,7 @@ export function Chart({
   trendFunctionField,
   disableXAxis,
   disableLegend,
+  neutralColor,
   grid,
   height,
   projects,
@@ -128,7 +130,8 @@ export function Chart({
   const derivedTrendChangeType = organization.features.includes('performance-new-trends')
     ? transaction?.change
     : trendChangeType;
-  const lineColor = trendToColor[derivedTrendChangeType || trendChangeType];
+  const lineColor =
+    trendToColor[neutralColor ? 'neutral' : derivedTrendChangeType || trendChangeType];
 
   const events =
     statsData && transaction?.project && transaction?.transaction

+ 69 - 3
static/app/views/performance/trends/index.spec.tsx

@@ -124,7 +124,8 @@ function _initializeData(
 function initializeTrendsData(
   projects: null | any[] = null,
   query = {},
-  includeDefaultQuery = true
+  includeDefaultQuery = true,
+  extraFeatures?: string[]
 ) {
   const _projects = Array.isArray(projects)
     ? projects
@@ -132,7 +133,9 @@ function initializeTrendsData(
         TestStubs.Project({id: '1', firstTransactionEvent: false}),
         TestStubs.Project({id: '2', firstTransactionEvent: true}),
       ];
-  const features = ['transaction-event', 'performance-view'];
+  const features = extraFeatures
+    ? ['transaction-event', 'performance-view', ...extraFeatures]
+    : ['transaction-event', 'performance-view'];
   const organization = TestStubs.Organization({
     features,
     projects: _projects,
@@ -211,6 +214,7 @@ describe('Performance > Trends', function () {
             count_range_1: 'integer',
             count_range_2: 'integer',
             count_percentage: 'percentage',
+            breakpoint: 'number',
             trend_percentage: 'percentage',
             trend_difference: 'number',
             aggregate_range_1: 'duration',
@@ -224,6 +228,7 @@ describe('Performance > Trends', function () {
               count_range_1: 2,
               count_range_2: 6,
               count_percentage: 3,
+              breakpoint: 1686967200,
               trend_percentage: 1.9235225955967554,
               trend_difference: 797,
               aggregate_range_1: 863,
@@ -236,6 +241,7 @@ describe('Performance > Trends', function () {
               count_range_1: 20,
               count_range_2: 40,
               count_percentage: 2,
+              breakpoint: 1686967200,
               trend_percentage: 1.204968944099379,
               trend_difference: 66,
               aggregate_range_1: 322,
@@ -246,6 +252,34 @@ describe('Performance > Trends', function () {
         },
       },
     });
+
+    MockApiClient.addMockResponse({
+      url: '/organizations/org-slug/events/',
+      body: {
+        data: [
+          {
+            'p95()': 1010.9232499999998,
+            'p50()': 47.34580982348902,
+            'tps()': 3.7226926286168966,
+          },
+        ],
+        meta: {
+          fields: {
+            'p95()': 'duration',
+            '950()': 'duration',
+            'tps()': 'number',
+          },
+          units: {
+            'p95()': 'millisecond',
+            'p50()': 'millisecond',
+            'tps()': null,
+          },
+          isMetricsData: true,
+          tips: {},
+          dataset: 'metrics',
+        },
+      },
+    });
   });
 
   afterEach(function () {
@@ -304,10 +338,42 @@ describe('Performance > Trends', function () {
 
     expect(summaryLink.closest('a')).toHaveAttribute(
       'href',
-      '/organizations/org-slug/performance/summary/?display=trend&project=1&query=tpm%28%29%3A%3E0.01%20transaction.duration%3A%3E0%20transaction.duration%3A%3C15min&referrer=performance-transaction-summary&statsPeriod=14d&transaction=%2Forganizations%2F%3AorgId%2Fperformance%2F&trendFunction=p50&unselectedSeries=p100%28%29'
+      '/organizations/org-slug/performance/summary/?display=trend&project=1&query=tpm%28%29%3A%3E0.01%20transaction.duration%3A%3E0%20transaction.duration%3A%3C15min%20count_percentage%28%29%3A%3E0.25%20count_percentage%28%29%3A%3C4%20trend_percentage%28%29%3A%3E0%25%20confidence%28%29%3A%3E6&referrer=performance-transaction-summary&statsPeriod=14d&transaction=%2Forganizations%2F%3AorgId%2Fperformance%2F&trendFunction=p50&unselectedSeries=p100%28%29'
     );
   });
 
+  it('view summary menu action opens performance change explorer with feature flag', async function () {
+    const projects = [TestStubs.Project({id: 1, slug: 'internal'}), TestStubs.Project()];
+    const data = initializeTrendsData(projects, {project: ['1']}, true, [
+      'performance-change-explorer',
+    ]);
+
+    render(
+      <TrendsIndex location={data.router.location} organization={data.organization} />,
+      {
+        context: data.routerContext,
+        organization: data.organization,
+      }
+    );
+
+    const transactions = await screen.findAllByTestId('trends-list-item-improved');
+    expect(transactions).toHaveLength(2);
+    const firstTransaction = transactions[0];
+
+    const summaryLink = within(firstTransaction).getByTestId('item-transaction-name');
+
+    expect(summaryLink.closest('a')).not.toHaveAttribute('href');
+
+    await clickEl(summaryLink);
+    await waitFor(() => {
+      expect(screen.getByText('Ongoing Improvement')).toBeInTheDocument();
+      expect(screen.getByText('Throughput')).toBeInTheDocument();
+      expect(screen.getByText('P95')).toBeInTheDocument();
+      expect(screen.getByText('P50')).toBeInTheDocument();
+      expect(screen.getByText('Errors')).toBeInTheDocument();
+    });
+  });
+
   it('hide from list menu action modifies query', async function () {
     const projects = [TestStubs.Project({id: 1, slug: 'internal'}), TestStubs.Project()];
     const data = initializeTrendsData(projects, {project: ['1']});

+ 4 - 0
static/app/views/performance/trends/utils.tsx

@@ -116,6 +116,10 @@ export const trendToColor = {
     lighter: theme.red200,
     default: theme.red300,
   },
+  neutral: {
+    lighter: theme.yellow200,
+    default: theme.yellow300,
+  },
   // TODO remove this once backend starts sending
   // TrendChangeType.IMPROVED as change type
   improvement: {