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

feat(perf): performance change explorer spans list + analytics (#53529)

The spans list on the Performance Change Explorer is a list of 6 of the
most changed and relevant suspect spans. We are comparing the avg total
self time before and after the breakpoint. The suspect spans are sorted
by most changed in duration and only displayed if the span has changed
more than 1%. The list is displayed as such
<img width="500" alt="image"
src="https://github.com/getsentry/sentry/assets/72356613/e19b9bb3-9cd3-4d10-ad61-c43f15e6334a">

Spans are either Added, Removed, Regressed, or Improved. Clicking on the
name of the span will take users to the span summary.
nikkikapadia 1 год назад
Родитель
Сommit
9e06e0018c

+ 17 - 0
static/app/utils/analytics/performanceAnalyticsEvents.tsx

@@ -92,6 +92,17 @@ export type PerformanceEventParameters = {
     project_platforms: string;
   };
   'performance_views.overview.search': {};
+  'performance_views.performance_change_explorer.open': {
+    transaction: string;
+  };
+  'performance_views.performance_change_explorer.span_link_clicked': {
+    group: string;
+    op: string;
+    transaction: string;
+  };
+  'performance_views.performance_change_explorer.summary_link_clicked': {
+    transaction: string;
+  };
   'performance_views.project_issue_detection_threshold_changed': {
     organization: Organization;
     project_slug: string;
@@ -330,4 +341,10 @@ export const performanceEventMap: Record<PerformanceEventKey, string | null> = {
   'performance_views.landing.table.unparameterized':
     'Performance Views: Landing Page - Table Unparameterized',
   'performance_views.landing.table.seen': 'Performance Views: Landing Page - Table Seen',
+  'performance_views.performance_change_explorer.open':
+    'Performance Views: Performance Change Explorer - Opened',
+  'performance_views.performance_change_explorer.span_link_clicked':
+    'Performance Views: Performance Change Explorer - Link to Span',
+  'performance_views.performance_change_explorer.summary_link_clicked':
+    'Performance Views: Performance Change Explorer - Link to Summary',
 };

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

@@ -5,12 +5,14 @@ import {initializeData} from 'sentry-test/performance/initializePerformanceData'
 import {act, render, screen, waitFor} from 'sentry-test/reactTestingLibrary';
 
 import ProjectsStore from 'sentry/stores/projectsStore';
+import {SuspectSpans} from 'sentry/utils/performance/suspectSpans/types';
 import {PerformanceChangeExplorer} from 'sentry/views/performance/trends/changeExplorer';
 import {
   COLUMNS,
   MetricsTable,
   renderBodyCell,
 } from 'sentry/views/performance/trends/changeExplorerUtils/metricsTable';
+import {SpansList} from 'sentry/views/performance/trends/changeExplorerUtils/spansList';
 import {
   NormalizedTrendsTransaction,
   TrendChangeType,
@@ -36,8 +38,166 @@ const transaction: NormalizedTrendsTransaction = {
   received_at: moment(1601251200000),
 };
 
+const spanResults: SuspectSpans = [
+  {
+    op: 'db',
+    group: '1',
+    description: 'span1',
+    frequency: 4,
+    count: 4,
+    avgOccurrences: undefined,
+    sumExclusiveTime: 345345,
+    p50ExclusiveTime: undefined,
+    p75ExclusiveTime: 25,
+    p95ExclusiveTime: undefined,
+    p99ExclusiveTime: undefined,
+    examples: [],
+  },
+  {
+    op: 'db',
+    group: '2',
+    description: 'span2',
+    frequency: 4,
+    count: 4,
+    avgOccurrences: undefined,
+    sumExclusiveTime: 345345,
+    p50ExclusiveTime: undefined,
+    p75ExclusiveTime: 25,
+    p95ExclusiveTime: undefined,
+    p99ExclusiveTime: undefined,
+    examples: [],
+  },
+  {
+    op: 'db',
+    group: '3',
+    description: 'span3',
+    frequency: 4,
+    count: 4,
+    avgOccurrences: undefined,
+    sumExclusiveTime: 345345,
+    p50ExclusiveTime: undefined,
+    p75ExclusiveTime: 25,
+    p95ExclusiveTime: undefined,
+    p99ExclusiveTime: undefined,
+    examples: [],
+  },
+  {
+    op: 'db',
+    group: '4',
+    description: 'span4',
+    frequency: 4,
+    count: 4,
+    avgOccurrences: undefined,
+    sumExclusiveTime: 345345,
+    p50ExclusiveTime: undefined,
+    p75ExclusiveTime: 25,
+    p95ExclusiveTime: undefined,
+    p99ExclusiveTime: undefined,
+    examples: [],
+  },
+  {
+    op: 'db',
+    group: '5',
+    description: 'span5',
+    frequency: 4,
+    count: 4,
+    avgOccurrences: undefined,
+    sumExclusiveTime: 345345,
+    p50ExclusiveTime: undefined,
+    p75ExclusiveTime: 25,
+    p95ExclusiveTime: undefined,
+    p99ExclusiveTime: undefined,
+    examples: [],
+  },
+  {
+    op: 'db',
+    group: '6',
+    description: 'span6',
+    frequency: 4,
+    count: 4,
+    avgOccurrences: undefined,
+    sumExclusiveTime: 345345,
+    p50ExclusiveTime: undefined,
+    p75ExclusiveTime: 25,
+    p95ExclusiveTime: undefined,
+    p99ExclusiveTime: undefined,
+    examples: [],
+  },
+  {
+    op: 'db',
+    group: '7',
+    description: 'span7',
+    frequency: 4,
+    count: 4,
+    avgOccurrences: undefined,
+    sumExclusiveTime: 345345,
+    p50ExclusiveTime: undefined,
+    p75ExclusiveTime: 25,
+    p95ExclusiveTime: undefined,
+    p99ExclusiveTime: undefined,
+    examples: [],
+  },
+  {
+    op: 'db',
+    group: '8',
+    description: 'span8',
+    frequency: 4,
+    count: 4,
+    avgOccurrences: undefined,
+    sumExclusiveTime: 345345,
+    p50ExclusiveTime: undefined,
+    p75ExclusiveTime: 25,
+    p95ExclusiveTime: undefined,
+    p99ExclusiveTime: undefined,
+    examples: [],
+  },
+  {
+    op: 'db',
+    group: '9',
+    description: 'span9',
+    frequency: 4,
+    count: 4,
+    avgOccurrences: undefined,
+    sumExclusiveTime: 345345,
+    p50ExclusiveTime: undefined,
+    p75ExclusiveTime: 25,
+    p95ExclusiveTime: undefined,
+    p99ExclusiveTime: undefined,
+    examples: [],
+  },
+  {
+    op: 'db',
+    group: '10',
+    description: 'span10',
+    frequency: 4,
+    count: 4,
+    avgOccurrences: undefined,
+    sumExclusiveTime: 345345,
+    p50ExclusiveTime: undefined,
+    p75ExclusiveTime: 25,
+    p95ExclusiveTime: undefined,
+    p99ExclusiveTime: undefined,
+    examples: [],
+  },
+  {
+    op: 'db',
+    group: '11',
+    description: 'span11',
+    frequency: 4,
+    count: 4,
+    avgOccurrences: undefined,
+    sumExclusiveTime: 345345,
+    p50ExclusiveTime: undefined,
+    p75ExclusiveTime: 25,
+    p95ExclusiveTime: undefined,
+    p99ExclusiveTime: undefined,
+    examples: [],
+  },
+];
+
 describe('Performance > Trends > Performance Change Explorer', function () {
   let eventsMockBefore;
+  let spansMock;
   beforeEach(function () {
     eventsMockBefore = MockApiClient.addMockResponse({
       url: '/organizations/org-slug/events/',
@@ -69,6 +229,11 @@ describe('Performance > Trends > Performance Change Explorer', function () {
         },
       },
     });
+
+    spansMock = MockApiClient.addMockResponse({
+      url: '/organizations/org-slug/events-spans-performance/',
+      body: [],
+    });
   });
 
   afterEach(function () {
@@ -107,6 +272,7 @@ describe('Performance > Trends > Performance Change Explorer', function () {
     );
 
     await waitForMockCall(eventsMockBefore);
+    await waitForMockCall(spansMock);
 
     await waitFor(() => {
       expect(screen.getByTestId('pce-header')).toBeInTheDocument();
@@ -116,6 +282,7 @@ describe('Performance > Trends > Performance Change Explorer', function () {
       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);
+      expect(screen.getByTestId('spans-no-results')).toBeInTheDocument();
     });
   });
 
@@ -297,4 +464,72 @@ describe('Performance > Trends > Performance Change Explorer', function () {
 
     expect(screen.getByText('+40.3%')).toBeInTheDocument();
   });
+
+  it('renders spans list with no results', async () => {
+    const data = initializeData();
+
+    render(
+      <SpansList
+        location={data.location}
+        organization={data.organization}
+        trendView={data.eventView}
+        breakpoint={transaction.breakpoint!}
+        transaction={transaction}
+        trendChangeType={TrendChangeType.REGRESSION}
+      />
+    );
+
+    await waitForMockCall(spansMock);
+    await waitFor(() => {
+      expect(screen.getByTestId('empty-state')).toBeInTheDocument();
+      expect(screen.getByTestId('spans-no-results')).toBeInTheDocument();
+    });
+  });
+
+  it('renders spans list with error message', async () => {
+    MockApiClient.addMockResponse({
+      url: '/organizations/org-slug/events-spans-performance/',
+      statusCode: 504,
+    });
+    const data = initializeData();
+
+    render(
+      <SpansList
+        location={data.location}
+        organization={data.organization}
+        trendView={data.eventView}
+        breakpoint={transaction.breakpoint!}
+        transaction={transaction}
+        trendChangeType={TrendChangeType.REGRESSION}
+      />
+    );
+
+    await waitFor(() => {
+      expect(screen.getByTestId('error-indicator')).toBeInTheDocument();
+    });
+  });
+
+  it('renders spans list with no changes message', async () => {
+    MockApiClient.addMockResponse({
+      url: '/organizations/org-slug/events-spans-performance/',
+      body: spanResults,
+    });
+    const data = initializeData();
+
+    render(
+      <SpansList
+        location={data.location}
+        organization={data.organization}
+        trendView={data.eventView}
+        breakpoint={transaction.breakpoint!}
+        transaction={transaction}
+        trendChangeType={TrendChangeType.REGRESSION}
+      />
+    );
+
+    await waitFor(() => {
+      expect(screen.getByTestId('empty-state')).toBeInTheDocument();
+      expect(screen.getByTestId('spans-no-changes')).toBeInTheDocument();
+    });
+  });
 });

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

@@ -10,6 +10,7 @@ import {IconFire, IconOpen} from 'sentry/icons';
 import {t} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
 import {Organization, Project} from 'sentry/types';
+import {trackAnalytics} from 'sentry/utils/analytics';
 import theme from 'sentry/utils/theme';
 import {normalizeUrl} from 'sentry/utils/withDomainRequired';
 import {
@@ -17,6 +18,7 @@ import {
   transactionSummaryRouteWithQuery,
 } from 'sentry/views/performance/transactionSummary/utils';
 import {MetricsTable} from 'sentry/views/performance/trends/changeExplorerUtils/metricsTable';
+import {SpansList} from 'sentry/views/performance/trends/changeExplorerUtils/spansList';
 import {Chart} from 'sentry/views/performance/trends/chart';
 import {
   NormalizedTrendsTransaction,
@@ -171,6 +173,14 @@ function ExplorerBody(props: ExplorerBodyProps) {
         trendView={trendView}
         organization={organization}
       />
+      <SpansList
+        location={location}
+        organization={organization}
+        trendView={trendView}
+        transaction={transaction}
+        breakpoint={transaction.breakpoint!}
+        trendChangeType={trendChangeType}
+      />
     </Fragment>
   );
 }
@@ -205,6 +215,13 @@ function Header(props: HeaderProps) {
     trendParameter
   );
 
+  const handleClickAnalytics = () => {
+    trackAnalytics('performance_views.performance_change_explorer.summary_link_clicked', {
+      organization,
+      transaction: transaction.transaction,
+    });
+  };
+
   return (
     <HeaderWrapper data-test-id="pce-header">
       <FireIcon regression={regression}>
@@ -221,6 +238,7 @@ function Header(props: HeaderProps) {
             to={normalizeUrl(transactionSummaryLink)}
             icon={<IconOpen />}
             aria-label={t('View transaction summary')}
+            onClick={handleClickAnalytics}
           />
         </TransactionNameWrapper>
       </HeaderTextWrapper>

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

@@ -250,7 +250,7 @@ function getEventsRowData(
         ? toFormattedNumber(afterData.data[0][field].toString(), 1) + ' ' + suffix
         : afterData.data[0][field],
       change: formatPercentage(
-        percentChange(
+        relativeChange(
           beforeData.data[0][field] as number,
           afterData.data[0][field] as number
         ),
@@ -316,7 +316,7 @@ function toFormattedNumber(numberString: string, decimal: number) {
   return parseFloat(numberString).toFixed(decimal);
 }
 
-function percentChange(before: number, after: number) {
+export function relativeChange(before: number, after: number) {
   return (after - before) / before;
 }
 
@@ -370,7 +370,7 @@ export function renderBodyCell(
   );
 }
 
-function getQueryParams(
+export function getQueryParams(
   startTime: string,
   endTime: string,
   fields: AggregationKeyWithAlias[],

+ 391 - 0
static/app/views/performance/trends/changeExplorerUtils/spansList.spec.tsx

@@ -0,0 +1,391 @@
+import moment from 'moment';
+
+import {initializeData} from 'sentry-test/performance/initializePerformanceData';
+import {render, screen, waitFor} from 'sentry-test/reactTestingLibrary';
+
+import {
+  ChangedSuspectSpan,
+  NumberedList,
+  SpanChangeType,
+} from 'sentry/views/performance/trends/changeExplorerUtils/spansList';
+import {NormalizedTrendsTransaction} from 'sentry/views/performance/trends/types';
+
+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),
+};
+
+const longSpanList: ChangedSuspectSpan[] = [
+  {
+    op: 'db',
+    group: '1',
+    description: 'span1',
+    frequency: 4,
+    count: 4,
+    avgOccurrences: undefined,
+    sumExclusiveTime: 345345,
+    p50ExclusiveTime: undefined,
+    p75ExclusiveTime: 25,
+    p95ExclusiveTime: undefined,
+    p99ExclusiveTime: undefined,
+    examples: [],
+    avgSumExclusiveTime: 34,
+    percentChange: 5,
+    avgTimeDifference: 20,
+    changeType: SpanChangeType.regressed,
+  },
+  {
+    op: 'db',
+    group: '2',
+    description: 'span2',
+    frequency: 4,
+    count: 4,
+    avgOccurrences: undefined,
+    sumExclusiveTime: 345345,
+    p50ExclusiveTime: undefined,
+    p75ExclusiveTime: 25,
+    p95ExclusiveTime: undefined,
+    p99ExclusiveTime: undefined,
+    examples: [],
+    avgSumExclusiveTime: 34,
+    percentChange: 2,
+    avgTimeDifference: 19,
+    changeType: SpanChangeType.regressed,
+  },
+  {
+    op: 'db',
+    group: '3',
+    description: 'span3',
+    frequency: 4,
+    count: 4,
+    avgOccurrences: undefined,
+    sumExclusiveTime: 345345,
+    p50ExclusiveTime: undefined,
+    p75ExclusiveTime: 25,
+    p95ExclusiveTime: undefined,
+    p99ExclusiveTime: undefined,
+    examples: [],
+    avgSumExclusiveTime: 34,
+    percentChange: 100,
+    avgTimeDifference: 18,
+    changeType: SpanChangeType.added,
+  },
+  {
+    op: 'db',
+    group: '4',
+    description: 'span4',
+    frequency: 4,
+    count: 4,
+    avgOccurrences: undefined,
+    sumExclusiveTime: 345345,
+    p50ExclusiveTime: undefined,
+    p75ExclusiveTime: 25,
+    p95ExclusiveTime: undefined,
+    p99ExclusiveTime: undefined,
+    examples: [],
+    avgSumExclusiveTime: 34,
+    percentChange: 0.5,
+    avgTimeDifference: 17,
+    changeType: SpanChangeType.regressed,
+  },
+  {
+    op: 'db',
+    group: '5',
+    description: 'span5',
+    frequency: 4,
+    count: 4,
+    avgOccurrences: undefined,
+    sumExclusiveTime: 345345,
+    p50ExclusiveTime: undefined,
+    p75ExclusiveTime: 25,
+    p95ExclusiveTime: undefined,
+    p99ExclusiveTime: undefined,
+    examples: [],
+    avgSumExclusiveTime: 34,
+    percentChange: 0.5,
+    avgTimeDifference: 16,
+    changeType: SpanChangeType.regressed,
+  },
+  {
+    op: 'db',
+    group: '6',
+    description: 'span6',
+    frequency: 4,
+    count: 4,
+    avgOccurrences: undefined,
+    sumExclusiveTime: 345345,
+    p50ExclusiveTime: undefined,
+    p75ExclusiveTime: 25,
+    p95ExclusiveTime: undefined,
+    p99ExclusiveTime: undefined,
+    examples: [],
+    avgSumExclusiveTime: 34,
+    percentChange: -5,
+    avgTimeDifference: -1,
+    changeType: SpanChangeType.improved,
+  },
+  {
+    op: 'db',
+    group: '7',
+    description: 'span7',
+    frequency: 4,
+    count: 4,
+    avgOccurrences: undefined,
+    sumExclusiveTime: 345345,
+    p50ExclusiveTime: undefined,
+    p75ExclusiveTime: 25,
+    p95ExclusiveTime: undefined,
+    p99ExclusiveTime: undefined,
+    examples: [],
+    avgSumExclusiveTime: 34,
+    percentChange: -0.5,
+    avgTimeDifference: -2,
+    changeType: SpanChangeType.improved,
+  },
+  {
+    op: 'db',
+    group: '8',
+    description: 'span8',
+    frequency: 4,
+    count: 4,
+    avgOccurrences: undefined,
+    sumExclusiveTime: 345345,
+    p50ExclusiveTime: undefined,
+    p75ExclusiveTime: 25,
+    p95ExclusiveTime: undefined,
+    p99ExclusiveTime: undefined,
+    examples: [],
+    avgSumExclusiveTime: 34,
+    percentChange: -100,
+    avgTimeDifference: -3,
+    changeType: SpanChangeType.removed,
+  },
+  {
+    op: 'db',
+    group: '9',
+    description: 'span9',
+    frequency: 4,
+    count: 4,
+    avgOccurrences: undefined,
+    sumExclusiveTime: 345345,
+    p50ExclusiveTime: undefined,
+    p75ExclusiveTime: 25,
+    p95ExclusiveTime: undefined,
+    p99ExclusiveTime: undefined,
+    examples: [],
+    avgSumExclusiveTime: 34,
+    percentChange: -100,
+    avgTimeDifference: -4,
+    changeType: SpanChangeType.removed,
+  },
+  {
+    op: 'db',
+    group: '10',
+    description: 'span10',
+    frequency: 4,
+    count: 4,
+    avgOccurrences: undefined,
+    sumExclusiveTime: 345345,
+    p50ExclusiveTime: undefined,
+    p75ExclusiveTime: 25,
+    p95ExclusiveTime: undefined,
+    p99ExclusiveTime: undefined,
+    examples: [],
+    avgSumExclusiveTime: 34,
+    percentChange: -3,
+    avgTimeDifference: -5,
+    changeType: SpanChangeType.improved,
+  },
+  {
+    op: 'db',
+    group: '11',
+    description: 'span11',
+    frequency: 4,
+    count: 4,
+    avgOccurrences: undefined,
+    sumExclusiveTime: 345345,
+    p50ExclusiveTime: undefined,
+    p75ExclusiveTime: 25,
+    p95ExclusiveTime: undefined,
+    p99ExclusiveTime: undefined,
+    examples: [],
+    avgSumExclusiveTime: 34,
+    percentChange: -0.5,
+    avgTimeDifference: -6,
+    changeType: SpanChangeType.improved,
+  },
+];
+
+const shortSpanList: ChangedSuspectSpan[] = [
+  {
+    op: 'db',
+    group: '1',
+    description: 'span1',
+    frequency: 4,
+    count: 4,
+    avgOccurrences: undefined,
+    sumExclusiveTime: 345345,
+    p50ExclusiveTime: undefined,
+    p75ExclusiveTime: 25,
+    p95ExclusiveTime: undefined,
+    p99ExclusiveTime: undefined,
+    examples: [],
+    avgSumExclusiveTime: 34,
+    percentChange: 5,
+    avgTimeDifference: 20,
+    changeType: SpanChangeType.regressed,
+  },
+  {
+    op: 'db',
+    group: '2',
+    description: 'span2',
+    frequency: 4,
+    count: 4,
+    avgOccurrences: undefined,
+    sumExclusiveTime: 345345,
+    p50ExclusiveTime: undefined,
+    p75ExclusiveTime: 25,
+    p95ExclusiveTime: undefined,
+    p99ExclusiveTime: undefined,
+    examples: [],
+    avgSumExclusiveTime: 34,
+    percentChange: 100,
+    avgTimeDifference: 19,
+    changeType: SpanChangeType.added,
+  },
+  {
+    op: 'db',
+    group: '3',
+    description: 'span3',
+    frequency: 4,
+    count: 4,
+    avgOccurrences: undefined,
+    sumExclusiveTime: 345345,
+    p50ExclusiveTime: undefined,
+    p75ExclusiveTime: 25,
+    p95ExclusiveTime: undefined,
+    p99ExclusiveTime: undefined,
+    examples: [],
+    avgSumExclusiveTime: 34,
+    percentChange: -0.5,
+    avgTimeDifference: -3,
+    changeType: SpanChangeType.improved,
+  },
+  {
+    op: 'db',
+    group: '4',
+    description: 'span4',
+    frequency: 4,
+    count: 4,
+    avgOccurrences: undefined,
+    sumExclusiveTime: 345345,
+    p50ExclusiveTime: undefined,
+    p75ExclusiveTime: 25,
+    p95ExclusiveTime: undefined,
+    p99ExclusiveTime: undefined,
+    examples: [],
+    avgSumExclusiveTime: 34,
+    percentChange: -100,
+    avgTimeDifference: -17,
+    changeType: SpanChangeType.removed,
+  },
+];
+
+describe('Performance > Trends > Performance Change Explorer > Spans List', function () {
+  it('renders spans list for regression', async () => {
+    const data = initializeData();
+
+    render(
+      <NumberedList
+        spans={longSpanList}
+        location={data.location}
+        organization={data.organization}
+        transactionName={transaction.transaction}
+        limit={6}
+        isLoading={false}
+        isError={false}
+      />
+    );
+
+    await waitFor(() => {
+      expect(screen.getAllByTestId('list-item')[0]).toHaveTextContent('span1');
+      expect(screen.getAllByTestId('list-item')[0]).toHaveTextContent('Regressed');
+      expect(screen.getAllByTestId('list-item')[1]).toHaveTextContent('span2');
+      expect(screen.getAllByTestId('list-item')[1]).toHaveTextContent('Regressed');
+      expect(screen.getAllByTestId('list-item')[2]).toHaveTextContent('span3');
+      expect(screen.getAllByTestId('list-item')[2]).toHaveTextContent('Added');
+      expect(screen.getAllByTestId('list-item')[3]).toHaveTextContent('span6');
+      expect(screen.getAllByTestId('list-item')[3]).toHaveTextContent('Improved');
+      expect(screen.getAllByTestId('list-item')[4]).toHaveTextContent('span8');
+      expect(screen.getAllByTestId('list-item')[4]).toHaveTextContent('Removed');
+      expect(screen.getAllByTestId('list-item')[5]).toHaveTextContent('span9');
+      expect(screen.getAllByTestId('list-item')[5]).toHaveTextContent('Removed');
+    });
+  });
+
+  it('renders spans list for improvement', async () => {
+    const data = initializeData();
+
+    render(
+      <NumberedList
+        spans={longSpanList.reverse()}
+        location={data.location}
+        organization={data.organization}
+        transactionName={transaction.transaction}
+        limit={6}
+        isLoading={false}
+        isError={false}
+      />
+    );
+
+    await waitFor(() => {
+      expect(screen.getAllByTestId('list-item')[0]).toHaveTextContent('span10');
+      expect(screen.getAllByTestId('list-item')[0]).toHaveTextContent('Improved');
+      expect(screen.getAllByTestId('list-item')[1]).toHaveTextContent('span9');
+      expect(screen.getAllByTestId('list-item')[1]).toHaveTextContent('Removed');
+      expect(screen.getAllByTestId('list-item')[2]).toHaveTextContent('span8');
+      expect(screen.getAllByTestId('list-item')[2]).toHaveTextContent('Removed');
+      expect(screen.getAllByTestId('list-item')[3]).toHaveTextContent('span6');
+      expect(screen.getAllByTestId('list-item')[3]).toHaveTextContent('Improved');
+      expect(screen.getAllByTestId('list-item')[4]).toHaveTextContent('span3');
+      expect(screen.getAllByTestId('list-item')[4]).toHaveTextContent('Added');
+      expect(screen.getAllByTestId('list-item')[5]).toHaveTextContent('span2');
+      expect(screen.getAllByTestId('list-item')[5]).toHaveTextContent('Regressed');
+    });
+  });
+
+  it('renders spans list for smaller changed spans list', async () => {
+    const data = initializeData();
+
+    render(
+      <NumberedList
+        spans={shortSpanList}
+        location={data.location}
+        organization={data.organization}
+        transactionName={transaction.transaction}
+        limit={6}
+        isLoading={false}
+        isError={false}
+      />
+    );
+
+    await waitFor(() => {
+      expect(screen.getAllByTestId('list-item')[0]).toHaveTextContent('span1');
+      expect(screen.getAllByTestId('list-item')[0]).toHaveTextContent('Regressed');
+      expect(screen.getAllByTestId('list-item')[1]).toHaveTextContent('span2');
+      expect(screen.getAllByTestId('list-item')[1]).toHaveTextContent('Added');
+      expect(screen.getAllByTestId('list-item')[2]).toHaveTextContent('span3');
+      expect(screen.getAllByTestId('list-item')[2]).toHaveTextContent('Improved');
+      expect(screen.getAllByTestId('list-item')[3]).toHaveTextContent('span4');
+      expect(screen.getAllByTestId('list-item')[3]).toHaveTextContent('Removed');
+      expect(screen.getAllByTestId('list-item')[4]).toBeUndefined();
+    });
+  });
+});

+ 534 - 0
static/app/views/performance/trends/changeExplorerUtils/spansList.tsx

@@ -0,0 +1,534 @@
+import {useMemo} from 'react';
+import styled from '@emotion/styled';
+import {Location} from 'history';
+import moment from 'moment';
+
+import EmptyStateWarning from 'sentry/components/emptyStateWarning';
+import Link from 'sentry/components/links/link';
+import LoadingIndicator from 'sentry/components/loadingIndicator';
+import {IconWarning} from 'sentry/icons';
+import {t, tct} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import {Organization} from 'sentry/types';
+import {trackAnalytics} from 'sentry/utils/analytics';
+import {parsePeriodToHours} from 'sentry/utils/dates';
+import {useDiscoverQuery} from 'sentry/utils/discover/discoverQuery';
+import {DiscoverDatasets} from 'sentry/utils/discover/types';
+import SuspectSpansQuery from 'sentry/utils/performance/suspectSpans/suspectSpansQuery';
+import {SuspectSpan, SuspectSpans} from 'sentry/utils/performance/suspectSpans/types';
+import {MutableSearch} from 'sentry/utils/tokenizeSearch';
+import useProjects from 'sentry/utils/useProjects';
+import {spanDetailsRouteWithQuery} from 'sentry/views/performance/transactionSummary/transactionSpans/spanDetails/utils';
+import {
+  SpanSortOption,
+  SpanSortOthers,
+  SpanSortPercentiles,
+} from 'sentry/views/performance/transactionSummary/transactionSpans/types';
+import {
+  getSuspectSpanSortFromLocation,
+  SPAN_SORT_TO_FIELDS,
+} from 'sentry/views/performance/transactionSummary/transactionSpans/utils';
+import {
+  getQueryParams,
+  relativeChange,
+} from 'sentry/views/performance/trends/changeExplorerUtils/metricsTable';
+import {
+  NormalizedTrendsTransaction,
+  TrendChangeType,
+  TrendView,
+} from 'sentry/views/performance/trends/types';
+import {getTrendProjectId} from 'sentry/views/performance/trends/utils';
+
+type SpansListProps = {
+  breakpoint: number;
+  location: Location;
+  organization: Organization;
+  transaction: NormalizedTrendsTransaction;
+  trendChangeType: TrendChangeType;
+  trendView: TrendView;
+};
+
+type AveragedSuspectSpan = SuspectSpan & {
+  avgSumExclusiveTime: number;
+};
+
+export type ChangedSuspectSpan = AveragedSuspectSpan & {
+  avgTimeDifference: number;
+  changeType: string;
+  percentChange: number;
+};
+
+type NumberedListProps = {
+  isError: boolean;
+  isLoading: boolean;
+  limit: number;
+  location: Location;
+  organization: Organization;
+  transactionName: string;
+  projectID?: string;
+  spans?: ChangedSuspectSpan[];
+};
+
+export const SpanChangeType = {
+  added: t('Added'),
+  removed: t('Removed'),
+  regressed: t('Regressed'),
+  improved: t('Improved'),
+};
+
+export function SpansList(props: SpansListProps) {
+  const {trendView, location, organization, breakpoint, transaction, trendChangeType} =
+    props;
+
+  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 {projects} = useProjects();
+  const projectID = getTrendProjectId(transaction, projects);
+
+  const beforeLocation = updateLocation(
+    location,
+    startTime,
+    breakpointTime,
+    transaction,
+    projectID
+  );
+
+  const beforeSort = getSuspectSpanSortFromLocation(beforeLocation, 'spanSort');
+
+  const beforeEventView = updateEventView(
+    trendView,
+    startTime,
+    breakpointTime,
+    transaction,
+    beforeSort,
+    projectID
+  );
+
+  const beforeFields = SPAN_SORT_TO_FIELDS[beforeSort.field];
+  beforeEventView.fields = beforeFields ? beforeFields.map(field => ({field})) : [];
+
+  const afterLocation = updateLocation(
+    location,
+    startTime,
+    breakpointTime,
+    transaction,
+    projectID
+  );
+
+  const afterSort = getSuspectSpanSortFromLocation(afterLocation, 'spanSort');
+
+  const afterEventView = updateEventView(
+    trendView,
+    breakpointTime,
+    endTime,
+    transaction,
+    afterSort,
+    projectID
+  );
+
+  const afterFields = SPAN_SORT_TO_FIELDS[afterSort.field];
+  afterEventView.fields = afterFields ? afterFields.map(field => ({field})) : [];
+
+  const {
+    data: totalTransactionsBefore,
+    isLoading: transactionsLoadingBefore,
+    isError: transactionsErrorBefore,
+  } = useDiscoverQuery(
+    getQueryParams(
+      startTime,
+      breakpointTime,
+      ['count'],
+      'transaction',
+      DiscoverDatasets.METRICS,
+      organization,
+      trendView,
+      transaction.transaction,
+      location
+    )
+  );
+
+  const transactionCountBefore = totalTransactionsBefore?.data[0]['count()'] as number;
+
+  const {
+    data: totalTransactionsAfter,
+    isLoading: transactionsLoadingAfter,
+    isError: transactionsErrorAfter,
+  } = useDiscoverQuery(
+    getQueryParams(
+      breakpointTime,
+      endTime,
+      ['count'],
+      'transaction',
+      DiscoverDatasets.METRICS,
+      organization,
+      trendView,
+      transaction.transaction,
+      location
+    )
+  );
+
+  const transactionCountAfter = totalTransactionsAfter?.data[0]['count()'] as number;
+
+  return (
+    <SuspectSpansQuery
+      location={beforeLocation}
+      orgSlug={organization.slug}
+      eventView={beforeEventView}
+      limit={50}
+      perSuspect={0}
+    >
+      {({
+        suspectSpans: suspectSpansBefore,
+        isLoading: spansLoadingBefore,
+        error: spansErrorBefore,
+      }) => {
+        const hasSpansErrorBefore = spansErrorBefore !== null;
+        return (
+          <SuspectSpansQuery
+            location={afterLocation}
+            orgSlug={organization.slug}
+            eventView={afterEventView}
+            limit={50}
+            perSuspect={0}
+          >
+            {({
+              suspectSpans: suspectSpansAfter,
+              isLoading: spansLoadingAfter,
+              error: spansErrorAfter,
+            }) => {
+              const hasSpansErrorAfter = spansErrorAfter !== null;
+
+              // need these averaged fields because comparing total self times may be inaccurate depending on
+              // where the breakpoint is
+              const spansAveragedAfter = addAvgSumExclusiveTime(
+                suspectSpansAfter,
+                transactionCountAfter
+              );
+              const spansAveragedBefore = addAvgSumExclusiveTime(
+                suspectSpansBefore,
+                transactionCountBefore
+              );
+
+              const addedSpans = addChangeFields(
+                findSpansNotIn(spansAveragedAfter, spansAveragedBefore),
+                true
+              );
+              const removedSpans = addChangeFields(
+                findSpansNotIn(spansAveragedBefore, spansAveragedAfter),
+                false
+              );
+
+              const remainingSpansBefore = findSpansIn(
+                spansAveragedBefore,
+                spansAveragedAfter
+              );
+              const remainingSpansAfter = findSpansIn(
+                spansAveragedAfter,
+                spansAveragedBefore
+              );
+
+              const remainingSpansWithChange = addPercentChange(
+                remainingSpansBefore,
+                remainingSpansAfter
+              );
+
+              const allSpansUpdated = remainingSpansWithChange
+                ?.concat(addedSpans ? addedSpans : [])
+                .concat(removedSpans ? removedSpans : []);
+
+              // sorts all spans in descending order of avgTimeDifference (change in avg total self time)
+              const spanList = allSpansUpdated?.sort(
+                (a, b) => b.avgTimeDifference - a.avgTimeDifference
+              );
+              // reverse the span list when trendChangeType is improvement so most negative (improved) change is first
+              return (
+                <NumberedList
+                  spans={
+                    trendChangeType === TrendChangeType.REGRESSION
+                      ? spanList
+                      : spanList?.reverse()
+                  }
+                  projectID={projectID}
+                  location={location}
+                  organization={organization}
+                  transactionName={transaction.transaction}
+                  limit={6}
+                  isLoading={
+                    transactionsLoadingBefore ||
+                    transactionsLoadingAfter ||
+                    spansLoadingBefore ||
+                    spansLoadingAfter
+                  }
+                  isError={
+                    hasSpansErrorBefore ||
+                    hasSpansErrorAfter ||
+                    transactionsErrorBefore ||
+                    transactionsErrorAfter
+                  }
+                />
+              );
+            }}
+          </SuspectSpansQuery>
+        );
+      }}
+    </SuspectSpansQuery>
+  );
+}
+
+function updateLocation(
+  location: Location,
+  start: string,
+  end: string,
+  transaction: NormalizedTrendsTransaction,
+  projectID?: string
+) {
+  return {
+    ...location,
+    start,
+    end,
+    statsPeriod: undefined,
+    sort: SpanSortOthers.SUM_EXCLUSIVE_TIME,
+    project: projectID,
+    query: {
+      query: 'transaction:' + transaction.transaction,
+      statsPeriod: undefined,
+      start,
+      end,
+      project: projectID,
+    },
+  };
+}
+
+function updateEventView(
+  trendView: TrendView,
+  start: string,
+  end: string,
+  transaction: NormalizedTrendsTransaction,
+  sort: SpanSortOption,
+  projectID?: string
+) {
+  const newEventView = trendView.clone();
+  newEventView.start = start;
+  newEventView.end = end;
+  newEventView.statsPeriod = undefined;
+  newEventView.query = `event.type:transaction transaction:${transaction.transaction}`;
+  newEventView.project = projectID ? [parseInt(projectID, 10)] : [];
+  newEventView.additionalConditions = new MutableSearch('');
+  return newEventView
+    .withColumns(
+      [...Object.values(SpanSortOthers), ...Object.values(SpanSortPercentiles)].map(
+        field => ({kind: 'field', field})
+      )
+    )
+    .withSorts([{kind: 'desc', field: sort.field}]);
+}
+
+function findSpansNotIn(
+  initialSpans: AveragedSuspectSpan[] | undefined,
+  comparingSpans: AveragedSuspectSpan[] | undefined
+) {
+  return initialSpans?.filter(initialValue => {
+    const spanInComparingSet = comparingSpans?.find(
+      comparingValue =>
+        comparingValue.op === initialValue.op &&
+        comparingValue.group === initialValue.group
+    );
+    return spanInComparingSet === undefined;
+  });
+}
+
+function findSpansIn(
+  initialSpans: AveragedSuspectSpan[] | undefined,
+  comparingSpans: AveragedSuspectSpan[] | undefined
+) {
+  return initialSpans?.filter(initialValue => {
+    const spanInComparingSet = comparingSpans?.find(
+      comparingValue =>
+        comparingValue.op === initialValue.op &&
+        comparingValue.group === initialValue.group
+    );
+    return spanInComparingSet !== undefined;
+  });
+}
+
+/**
+ *
+ * adds an average of the sumExclusive time so it is more comparable when the breakpoint
+ * is not close to the middle of the timeseries
+ */
+function addAvgSumExclusiveTime(
+  suspectSpans: SuspectSpans | null,
+  transactionCount: number
+) {
+  return suspectSpans?.map(span => {
+    return {
+      ...span,
+      avgSumExclusiveTime: span.sumExclusiveTime
+        ? span.sumExclusiveTime / transactionCount
+        : 0,
+    };
+  });
+}
+
+function addPercentChange(
+  before: AveragedSuspectSpan[] | undefined,
+  after: AveragedSuspectSpan[] | undefined
+) {
+  return after?.map(spanAfter => {
+    const spanBefore = before?.find(
+      beforeValue =>
+        spanAfter.op === beforeValue.op && spanAfter.group === beforeValue.group
+    );
+    const percentageChange =
+      relativeChange(
+        spanBefore?.avgSumExclusiveTime || 0,
+        spanAfter.avgSumExclusiveTime
+      ) * 100;
+    return {
+      ...spanAfter,
+      percentChange: percentageChange,
+      avgTimeDifference:
+        spanAfter.avgSumExclusiveTime - (spanBefore?.avgSumExclusiveTime || 0),
+      changeType:
+        percentageChange < 0 ? SpanChangeType.improved : SpanChangeType.regressed,
+    };
+  });
+}
+
+function addChangeFields(
+  spans: AveragedSuspectSpan[] | undefined,
+  added: boolean
+): ChangedSuspectSpan[] | undefined {
+  // percent change is hardcoded to pass the 1% change threshold,
+  // avoid infinite values and reflect correct change type
+  return spans?.map(span => {
+    if (added) {
+      return {
+        ...span,
+        percentChange: 100,
+        avgTimeDifference: span.avgSumExclusiveTime,
+        changeType: SpanChangeType.added,
+      };
+    }
+    return {
+      ...span,
+      percentChange: -100,
+      avgTimeDifference: 0 - span.avgSumExclusiveTime,
+      changeType: SpanChangeType.removed,
+    };
+  });
+}
+
+export function NumberedList(props: NumberedListProps) {
+  const {
+    spans,
+    projectID,
+    location,
+    transactionName,
+    organization,
+    limit,
+    isLoading,
+    isError,
+  } = props;
+
+  if (isLoading) {
+    return <LoadingIndicator />;
+  }
+
+  if (isError) {
+    return (
+      <ErrorWrapper>
+        <IconWarning data-test-id="error-indicator" color="gray200" size="xxl" />
+        <p>{t('There was an issue finding suspect spans for this transaction')}</p>
+      </ErrorWrapper>
+    );
+  }
+
+  if (spans?.length === 0) {
+    return (
+      <EmptyStateWarning>
+        <p data-test-id="spans-no-results">{t('No results found for your query')}</p>
+      </EmptyStateWarning>
+    );
+  }
+
+  // percent change of a span must be more than 1%
+  const formattedSpans = spans
+    ?.filter(span => (spans.length > 10 ? Math.abs(span.percentChange) >= 1 : true))
+    .slice(0, limit)
+    .map((span, index) => {
+      const spanDetailsPage = spanDetailsRouteWithQuery({
+        orgSlug: organization.slug,
+        transaction: transactionName,
+        query: location.query,
+        spanSlug: {op: span.op, group: span.group},
+        projectID,
+      });
+
+      const handleClickAnalytics = () => {
+        trackAnalytics(
+          'performance_views.performance_change_explorer.span_link_clicked',
+          {
+            organization,
+            transaction: transactionName,
+            op: span.op,
+            group: span.group,
+          }
+        );
+      };
+
+      return (
+        <li key={`list-item-${index}`}>
+          <ListItemWrapper data-test-id="list-item">
+            <p style={{marginLeft: space(2)}}>
+              {tct('[changeType] suspect span', {changeType: span.changeType})}
+            </p>
+            <SpanLink to={spanDetailsPage} onClick={handleClickAnalytics}>
+              {span.description ? `${span.op} - ${span.description}` : span.op}
+            </SpanLink>
+          </ListItemWrapper>
+        </li>
+      );
+    });
+
+  if (formattedSpans?.length === 0) {
+    return (
+      <EmptyStateWarning>
+        <p data-test-id="spans-no-changes">{t('No sizable changes in suspect spans')}</p>
+      </EmptyStateWarning>
+    );
+  }
+
+  return (
+    <div style={{marginTop: space(4)}}>
+      <ol>{formattedSpans}</ol>
+    </div>
+  );
+}
+
+const SpanLink = styled(Link)`
+  margin-left: ${space(1)};
+  ${p => p.theme.overflowEllipsis}
+`;
+const ListItemWrapper = styled('div')`
+  display: flex;
+  white-space: nowrap;
+`;
+
+const ErrorWrapper = styled('div')`
+  display: flex;
+  margin-top: ${space(4)};
+  flex-direction: column;
+  align-items: center;
+  gap: ${space(3)};
+`;

+ 5 - 1
static/app/views/performance/trends/changedTransactions.tsx

@@ -627,7 +627,11 @@ function TransactionSummaryLink(props: TransactionSummaryLinkProps) {
 
   const handleClick = useCallback(() => {
     onTransactionSelection(transaction.transaction);
-  }, [onTransactionSelection, transaction.transaction]);
+    trackAnalytics('performance_views.performance_change_explorer.open', {
+      organization,
+      transaction: transaction.transaction,
+    });
+  }, [onTransactionSelection, transaction.transaction, organization]);
 
   if (organization.features.includes('performance-change-explorer')) {
     return (

+ 5 - 0
static/app/views/performance/trends/index.spec.tsx

@@ -280,6 +280,11 @@ describe('Performance > Trends', function () {
         },
       },
     });
+
+    MockApiClient.addMockResponse({
+      url: '/organizations/org-slug/events-spans-performance/',
+      body: [],
+    });
   });
 
   afterEach(function () {