Browse Source

styles(stats-detectors): Update regression issue details to new designs (#59489)

After another round of design reviews, updating the regression issue
details again.
Tony Xiao 1 year ago
parent
commit
8ccaee284d

+ 90 - 125
static/app/components/events/eventStatisticalDetector/aggregateSpanDiff.tsx

@@ -1,25 +1,18 @@
-import {Location} from 'history';
+import {useMemo, useState} from 'react';
 
-import EmptyStateWarning from 'sentry/components/emptyStateWarning';
 import {EventDataSection} from 'sentry/components/events/eventDataSection';
-import GridEditable, {
-  COL_WIDTH_UNDEFINED,
-  GridColumnOrder,
-} from 'sentry/components/gridEditable';
-import Link from 'sentry/components/links/link';
-import LoadingIndicator from 'sentry/components/loadingIndicator';
-import TextOverflow from 'sentry/components/textOverflow';
-import {Tooltip} from 'sentry/components/tooltip';
+import {COL_WIDTH_UNDEFINED} from 'sentry/components/gridEditable';
+import {SegmentedControl} from 'sentry/components/segmentedControl';
 import {t} from 'sentry/locale';
-import {Event, Organization} from 'sentry/types';
-import {defined} from 'sentry/utils';
-import {NumericChange, renderHeadCell} from 'sentry/utils/performance/regression/table';
+import {Event, Project} from 'sentry/types';
 import {useRelativeDateTime} from 'sentry/utils/profiling/hooks/useRelativeDateTime';
 import {useApiQuery} from 'sentry/utils/queryClient';
 import {useLocation} from 'sentry/utils/useLocation';
 import useOrganization from 'sentry/utils/useOrganization';
 import {spanDetailsRouteWithQuery} from 'sentry/views/performance/transactionSummary/transactionSpans/spanDetails/utils';
 
+import {EventRegressionTable} from './eventRegressionTable';
+
 interface SpanDiff {
   p95_after: number;
   p95_before: number;
@@ -39,17 +32,6 @@ interface UseFetchAdvancedAnalysisProps {
   transaction: string;
 }
 
-interface RenderBodyCellProps {
-  column: GridColumnOrder<string>;
-  end: string;
-  location: Location;
-  organization: Organization;
-  projectId: string;
-  row: SpanDiff;
-  start: string;
-  transaction: string;
-}
-
 function useFetchAdvancedAnalysis({
   transaction,
   start,
@@ -79,70 +61,22 @@ function useFetchAdvancedAnalysis({
   );
 }
 
-function getColumns() {
-  return [
-    {key: 'span_op', name: t('Span Operation'), width: 200},
-    {key: 'span_description', name: t('Description'), width: COL_WIDTH_UNDEFINED},
-    {key: 'spm', name: t('Throughput'), width: COL_WIDTH_UNDEFINED},
-    {key: 'p95', name: t('P95'), width: COL_WIDTH_UNDEFINED},
-  ];
-}
-
-function renderBodyCell({
-  column,
-  row,
-  organization,
-  transaction,
-  projectId,
-  location,
-  start,
-  end,
-}: RenderBodyCellProps) {
-  if (column.key === 'span_description') {
-    const label = row[column.key] || t('unnamed span');
-    return (
-      <Tooltip title={label} showOnlyOnOverflow>
-        <TextOverflow>
-          <Link
-            to={spanDetailsRouteWithQuery({
-              orgSlug: organization.slug,
-              spanSlug: {op: row.span_op, group: row.span_group},
-              transaction,
-              projectID: projectId,
-              query: {
-                ...location.query,
-                statsPeriod: undefined,
-                query: undefined,
-                start,
-                end,
-              },
-            })}
-          >
-            {label}
-          </Link>
-        </TextOverflow>
-      </Tooltip>
-    );
-  }
+const ADDITIONAL_COLUMNS = [
+  {key: 'operation', name: t('Operation'), width: 120},
+  {key: 'description', name: t('Description'), width: COL_WIDTH_UNDEFINED},
+];
 
-  if (['p95', 'spm'].includes(column.key)) {
-    const beforeRawValue = row[`${column.key}_before`];
-    const afterRawValue = row[`${column.key}_after`];
-    return (
-      <NumericChange
-        columnKey={column.key}
-        beforeRawValue={beforeRawValue}
-        afterRawValue={afterRawValue}
-      />
-    );
-  }
-
-  return row[column.key];
+interface AggregateSpanDiffProps {
+  event: Event;
+  project: Project;
 }
 
-function AggregateSpanDiff({event, projectId}: {event: Event; projectId: string}) {
+function AggregateSpanDiff({event, project}: AggregateSpanDiffProps) {
   const location = useLocation();
   const organization = useOrganization();
+
+  const [causeType, setCauseType] = useState<'duration' | 'throughput'>('duration');
+
   const {transaction, breakpoint} = event?.occurrence?.evidenceData ?? {};
   const breakpointTimestamp = new Date(breakpoint * 1000).toISOString();
 
@@ -156,55 +90,86 @@ function AggregateSpanDiff({event, projectId}: {event: Event; projectId: string}
     start: (start as Date).toISOString(),
     end: (end as Date).toISOString(),
     breakpoint: breakpointTimestamp,
-    projectId,
+    projectId: project.id,
   });
 
-  if (isLoading) {
-    return <LoadingIndicator />;
-  }
-
-  let content;
-  if (isError) {
-    content = (
-      <EmptyStateWarning>
-        <p>{t('Oops! Something went wrong fetching span diffs')}</p>
-      </EmptyStateWarning>
-    );
-  } else if (!defined(data) || data.length === 0) {
-    content = (
-      <EmptyStateWarning>
-        <p>{t('Unable to find significant differences in spans')}</p>
-      </EmptyStateWarning>
+  const tableData = useMemo(() => {
+    return (
+      data?.map(row => {
+        if (causeType === 'throughput') {
+          return {
+            operation: row.span_op,
+            group: row.span_group,
+            description: row.span_description,
+            throughputBefore: row.spm_before,
+            throughputAfter: row.spm_after,
+            percentageChange: row.spm_after / row.spm_before - 1,
+          };
+        }
+        return {
+          operation: row.span_op,
+          group: row.span_group,
+          description: row.span_description,
+          durationBefore: row.p95_before / 1e3,
+          durationAfter: row.p95_after / 1e3,
+          percentageChange: row.p95_after / row.p95_before - 1,
+        };
+      }) || []
     );
-  } else {
-    content = (
-      <GridEditable
-        isLoading={isLoading}
-        data={data}
-        location={location}
-        columnOrder={getColumns()}
-        columnSortBy={[]}
-        grid={{
-          renderHeadCell,
-          renderBodyCell: (column, row) =>
-            renderBodyCell({
-              column,
-              row,
-              organization,
-              transaction,
-              projectId,
-              location,
+  }, [data, causeType]);
+
+  const tableOptions = useMemo(() => {
+    return {
+      description: {
+        defaultValue: t('(unnamed span)'),
+        link: dataRow => ({
+          target: spanDetailsRouteWithQuery({
+            orgSlug: organization.slug,
+            spanSlug: {op: dataRow.operation, group: dataRow.group},
+            transaction,
+            projectID: project.id,
+            query: {
+              ...location.query,
+              statsPeriod: undefined,
+              query: undefined,
               start: (start as Date).toISOString(),
               end: (end as Date).toISOString(),
-            }),
-        }}
-      />
-    );
-  }
+            },
+          }),
+        }),
+      },
+    };
+  }, [location, organization, project, transaction, start, end]);
 
   return (
-    <EventDataSection type="potential-causes" title={t('Potential Causes')}>
-      {content}
+    <EventDataSection
+      type="potential-causes"
+      title={t('Potential Causes')}
+      actions={
+        <SegmentedControl
+          size="xs"
+          aria-label={t('Duration or Throughput')}
+          value={causeType}
+          onChange={setCauseType}
+        >
+          <SegmentedControl.Item key="duration">
+            {t('Duration (P95)')}
+          </SegmentedControl.Item>
+          <SegmentedControl.Item key="throughput">
+            {t('Throughput')}
+          </SegmentedControl.Item>
+        </SegmentedControl>
+      }
+    >
+      <EventRegressionTable
+        causeType={causeType}
+        columns={ADDITIONAL_COLUMNS}
+        data={tableData}
+        isLoading={isLoading}
+        isError={isError}
+        // renderers={renderers}
+        options={tableOptions}
+      />
     </EventDataSection>
   );
 }

+ 141 - 201
static/app/components/events/eventStatisticalDetector/eventAffectedTransactions.tsx

@@ -1,24 +1,13 @@
-import {Fragment, useEffect, useMemo} from 'react';
-import styled from '@emotion/styled';
+import {useEffect, useMemo, useState} from 'react';
 import * as Sentry from '@sentry/react';
 
-import {LineChart} from 'sentry/components/charts/lineChart';
-import EmptyStateWarning from 'sentry/components/emptyStateWarning';
 import {EventDataSection} from 'sentry/components/events/eventDataSection';
-import Link from 'sentry/components/links/link';
-import LoadingIndicator from 'sentry/components/loadingIndicator';
-import PerformanceDuration from 'sentry/components/performanceDuration';
-import {Tooltip} from 'sentry/components/tooltip';
-import {IconArrow} from 'sentry/icons';
-import {t, tct} from 'sentry/locale';
-import {space} from 'sentry/styles/space';
+import {COL_WIDTH_UNDEFINED} from 'sentry/components/gridEditable';
+import {SegmentedControl} from 'sentry/components/segmentedControl';
+import {t} from 'sentry/locale';
 import {Event, Group, Project} from 'sentry/types';
-import {Series} from 'sentry/types/echarts';
 import {defined} from 'sentry/utils';
 import {trackAnalytics} from 'sentry/utils/analytics';
-import {tooltipFormatter} from 'sentry/utils/discover/charts';
-import {Container, NumberContainer} from 'sentry/utils/discover/styles';
-import {getDuration} from 'sentry/utils/formatters';
 import {useProfileFunctions} from 'sentry/utils/profiling/hooks/useProfileFunctions';
 import {useProfileTopEventsStats} from 'sentry/utils/profiling/hooks/useProfileTopEventsStats';
 import {useRelativeDateTime} from 'sentry/utils/profiling/hooks/useRelativeDateTime';
@@ -29,6 +18,8 @@ import {
 import {MutableSearch} from 'sentry/utils/tokenizeSearch';
 import useOrganization from 'sentry/utils/useOrganization';
 
+import {EventRegressionTable} from './eventRegressionTable';
+
 interface EventAffectedTransactionsProps {
   event: Event;
   group: Group;
@@ -79,7 +70,11 @@ export function EventAffectedTransactions({
   );
 }
 
-const TRANSACTIONS_LIMIT = 5;
+const TRANSACTIONS_LIMIT = 10;
+
+const ADDITIONAL_COLUMNS = [
+  {key: 'transaction', name: t('Transaction'), width: COL_WIDTH_UNDEFINED},
+];
 
 interface EventAffectedTransactionsInnerProps {
   breakpoint: number;
@@ -96,6 +91,8 @@ function EventAffectedTransactionsInner({
   framePackage,
   project,
 }: EventAffectedTransactionsInnerProps) {
+  const [causeType, setCauseType] = useState<'duration' | 'throughput'>('duration');
+
   const organization = useOrganization();
 
   const datetime = useRelativeDateTime({
@@ -105,11 +102,20 @@ function EventAffectedTransactionsInner({
 
   const percentileBefore = `percentile_before(function.duration, 0.95, ${breakpoint})`;
   const percentileAfter = `percentile_after(function.duration, 0.95, ${breakpoint})`;
+  const throughputBefore = `cpm_before(${breakpoint})`;
+  const throughputAfter = `cpm_after(${breakpoint})`;
   const regressionScore = `regression_score(function.duration, 0.95, ${breakpoint})`;
 
   const transactionsDeltaQuery = useProfileFunctions({
     datetime,
-    fields: ['transaction', percentileBefore, percentileAfter, regressionScore],
+    fields: [
+      'transaction',
+      percentileBefore,
+      percentileAfter,
+      throughputBefore,
+      throughputAfter,
+      regressionScore,
+    ],
     sort: {
       key: regressionScore,
       order: 'desc',
@@ -148,7 +154,7 @@ function EventAffectedTransactionsInner({
     others: false,
     referrer: 'api.profiling.functions.regression.transaction-stats',
     topEvents: TRANSACTIONS_LIMIT,
-    yAxes: ['p95()', 'worst()'],
+    yAxes: ['worst()'],
   });
 
   const examplesByTransaction = useMemo(() => {
@@ -178,186 +184,133 @@ function EventAffectedTransactionsInner({
     return allExamples;
   }, [breakpoint, transactionsDeltaQuery, functionStats]);
 
-  const timeseriesByTransaction: Record<string, Series> = useMemo(() => {
-    const allTimeseries: Record<string, Series> = {};
-    if (!defined(functionStats.data)) {
-      return allTimeseries;
-    }
+  const tableData = useMemo(() => {
+    return (
+      transactionsDeltaQuery.data?.data.map(row => {
+        const [exampleBefore, exampleAfter] = examplesByTransaction[
+          row.transaction as string
+        ] ?? [null, null];
 
-    const timestamps = functionStats.data.timestamps;
-
-    transactionsDeltaQuery.data?.data?.forEach(row => {
-      const transaction = row.transaction as string;
-      const data = functionStats.data.data.find(
-        ({axis, label}) => axis === 'p95()' && label === transaction
-      );
-      if (!defined(data)) {
-        return;
-      }
-
-      allTimeseries[transaction] = {
-        data: timestamps.map((timestamp, i) => {
+        if (causeType === 'throughput') {
+          const before = row[throughputBefore] as number;
+          const after = row[throughputAfter] as number;
           return {
-            name: timestamp * 1000,
-            value: data.values[i],
+            exampleBefore,
+            exampleAfter,
+            transaction: row.transaction,
+            throughputBefore: before,
+            throughputAfter: after,
+            percentageChange: after / before - 1,
           };
-        }),
-        seriesName: 'p95(function.duration)',
-      };
-    });
+        }
+
+        const before = (row[percentileBefore] as number) / 1e9;
+        const after = (row[percentileAfter] as number) / 1e9;
+        return {
+          exampleBefore,
+          exampleAfter,
+          transaction: row.transaction,
+          durationBefore: before,
+          durationAfter: after,
+          percentageChange: after / before - 1,
+        };
+      }) || []
+    );
+  }, [
+    causeType,
+    percentileBefore,
+    percentileAfter,
+    throughputBefore,
+    throughputAfter,
+    transactionsDeltaQuery.data?.data,
+    examplesByTransaction,
+  ]);
+
+  const options = useMemo(() => {
+    function handleGoToProfile() {
+      trackAnalytics('profiling_views.go_to_flamegraph', {
+        organization,
+        source: 'profiling.issue.function_regression.transactions',
+      });
+    }
 
-    return allTimeseries;
-  }, [transactionsDeltaQuery, functionStats]);
+    const before = dataRow =>
+      defined(dataRow.exampleBefore)
+        ? {
+            target: generateProfileFlamechartRouteWithQuery({
+              orgSlug: organization.slug,
+              projectSlug: project.slug,
+              profileId: dataRow.exampleBefore,
+              query: {
+                frameName,
+                framePackage,
+              },
+            }),
+            onClick: handleGoToProfile,
+          }
+        : undefined;
+
+    const after = dataRow =>
+      defined(dataRow.exampleAfter)
+        ? {
+            target: generateProfileFlamechartRouteWithQuery({
+              orgSlug: organization.slug,
+              projectSlug: project.slug,
+              profileId: dataRow.exampleAfter,
+              query: {
+                frameName,
+                framePackage,
+              },
+            }),
+            onClick: handleGoToProfile,
+          }
+        : undefined;
 
-  const chartOptions = useMemo(() => {
     return {
-      width: 300,
-      height: 20,
-      grid: {
-        top: '2px',
-        left: '2px',
-        right: '2px',
-        bottom: '2px',
-        containLabel: false,
-      },
-      xAxis: {
-        show: false,
-        type: 'time' as const,
-      },
-      yAxis: {
-        show: false,
-      },
-      tooltip: {
-        valueFormatter: value => tooltipFormatter(value, 'duration'),
+      transaction: {
+        link: dataRow => ({
+          target: generateProfileSummaryRouteWithQuery({
+            orgSlug: organization.slug,
+            projectSlug: project.slug,
+            transaction: dataRow.transaction as string,
+          }),
+        }),
       },
+      durationBefore: {link: before},
+      durationAfter: {link: after},
+      throughputBefore: {link: before},
+      throughputAfter: {link: after},
     };
-  }, []);
-
-  function handleGoToProfile() {
-    trackAnalytics('profiling_views.go_to_flamegraph', {
-      organization,
-      source: 'profiling.issue.function_regression.transactions',
-    });
-  }
+  }, [organization, project, frameName, framePackage]);
 
   return (
-    <EventDataSection type="most-affected" title={t('Most Affected')}>
-      {transactionsDeltaQuery.isLoading ? (
-        <LoadingIndicator hideMessage />
-      ) : transactionsDeltaQuery.isError ? (
-        <EmptyStateWarning>
-          <p>{t('Oops! Something went wrong fetching transaction impacted.')}</p>
-        </EmptyStateWarning>
-      ) : (
-        <ListContainer>
-          {(transactionsDeltaQuery.data?.data ?? []).map(transaction => {
-            const transactionName = transaction.transaction as string;
-            const series = timeseriesByTransaction[transactionName] ?? {
-              seriesName: 'p95()',
-              data: [],
-            };
-
-            const [beforeExample, afterExample] = examplesByTransaction[
-              transactionName
-            ] ?? [null, null];
-
-            let before = (
-              <PerformanceDuration
-                nanoseconds={transaction[percentileBefore] as number}
-                abbreviation
-              />
-            );
-
-            if (defined(beforeExample)) {
-              const beforeTarget = generateProfileFlamechartRouteWithQuery({
-                orgSlug: organization.slug,
-                projectSlug: project.slug,
-                profileId: beforeExample,
-                query: {
-                  frameName,
-                  framePackage,
-                },
-              });
-
-              before = (
-                <Link to={beforeTarget} onClick={handleGoToProfile}>
-                  {before}
-                </Link>
-              );
-            }
-
-            let after = (
-              <PerformanceDuration
-                nanoseconds={transaction[percentileAfter] as number}
-                abbreviation
-              />
-            );
-
-            if (defined(afterExample)) {
-              const afterTarget = generateProfileFlamechartRouteWithQuery({
-                orgSlug: organization.slug,
-                projectSlug: project.slug,
-                profileId: afterExample,
-                query: {
-                  frameName,
-                  framePackage,
-                },
-              });
-
-              after = (
-                <Link to={afterTarget} onClick={handleGoToProfile}>
-                  {after}
-                </Link>
-              );
-            }
-
-            const summaryTarget = generateProfileSummaryRouteWithQuery({
-              orgSlug: organization.slug,
-              projectSlug: project.slug,
-              transaction: transaction.transaction as string,
-            });
-            return (
-              <Fragment key={transaction.transaction as string}>
-                <Container>
-                  <Link to={summaryTarget}>{transaction.transaction}</Link>
-                </Container>
-                <LineChart
-                  {...chartOptions}
-                  series={[series]}
-                  isGroupedByDate
-                  showTimeInTooltip
-                />
-                <NumberContainer>
-                  <Tooltip
-                    title={tct(
-                      'The function duration in this transaction increased from [before] to [after]',
-                      {
-                        before: getDuration(
-                          (transaction[percentileBefore] as number) / 1_000_000_000,
-                          2,
-                          true
-                        ),
-                        after: getDuration(
-                          (transaction[percentileAfter] as number) / 1_000_000_000,
-                          2,
-                          true
-                        ),
-                      }
-                    )}
-                    position="top"
-                  >
-                    <DurationChange>
-                      {before}
-                      <IconArrow direction="right" size="xs" />
-                      {after}
-                    </DurationChange>
-                  </Tooltip>
-                </NumberContainer>
-              </Fragment>
-            );
-          })}
-        </ListContainer>
-      )}
+    <EventDataSection
+      type="most-affected"
+      title={t('Most Affected')}
+      actions={
+        <SegmentedControl
+          size="xs"
+          aria-label={t('Duration or Throughput')}
+          value={causeType}
+          onChange={setCauseType}
+        >
+          <SegmentedControl.Item key="duration">
+            {t('Duration (P95)')}
+          </SegmentedControl.Item>
+          <SegmentedControl.Item key="throughput">
+            {t('Throughput')}
+          </SegmentedControl.Item>
+        </SegmentedControl>
+      }
+    >
+      <EventRegressionTable
+        causeType={causeType}
+        columns={ADDITIONAL_COLUMNS}
+        data={tableData || []}
+        isLoading={transactionsDeltaQuery.isLoading}
+        isError={transactionsDeltaQuery.isError}
+        options={options}
+      />
     </EventDataSection>
   );
 }
@@ -421,16 +374,3 @@ function findExamplePair(
 
   return [before, after];
 }
-
-const ListContainer = styled('div')`
-  display: grid;
-  grid-template-columns: 1fr auto auto;
-  gap: ${space(1)};
-`;
-
-const DurationChange = styled('span')`
-  color: ${p => p.theme.gray300};
-  display: flex;
-  align-items: center;
-  gap: ${space(1)};
-`;

+ 121 - 0
static/app/components/events/eventStatisticalDetector/eventRegressionSummary.tsx

@@ -0,0 +1,121 @@
+import {useMemo} from 'react';
+
+import KeyValueList from 'sentry/components/events/interfaces/keyValueList';
+import {DataSection} from 'sentry/components/events/styles';
+import {t} from 'sentry/locale';
+import {Event, Group, IssueType, KeyValueListData} from 'sentry/types';
+import {defined} from 'sentry/utils';
+import {getFormattedDate} from 'sentry/utils/dates';
+import {formatPercentage, getDuration} from 'sentry/utils/formatters';
+
+interface EventRegressionSummaryProps {
+  event: Event;
+  group: Group;
+}
+
+export function EventRegressionSummary({event, group}: EventRegressionSummaryProps) {
+  const data = useMemo(() => getKeyValueListData(group, event), [event, group]);
+
+  if (!defined(data)) {
+    return null;
+  }
+
+  return (
+    <DataSection>
+      <KeyValueList data={data} shouldSort={false} />
+    </DataSection>
+  );
+}
+
+function getKeyValueListData(group: Group, event: Event): KeyValueListData | null {
+  const evidenceData = event.occurrence?.evidenceData;
+  if (!defined(evidenceData)) {
+    return null;
+  }
+
+  switch (group.issueType) {
+    case IssueType.PERFORMANCE_DURATION_REGRESSION:
+    case IssueType.PERFORMANCE_ENDPOINT_REGRESSION: {
+      return [
+        {
+          key: 'endpoint',
+          subject: t('Endpoint Name'),
+          value: evidenceData.transaction,
+        },
+        {
+          key: 'duration change',
+          subject: t('Change in Duration'),
+          value: formatDurationChange(
+            evidenceData.aggregateRange1 / 1e3,
+            evidenceData.aggregateRange2 / 1e3,
+            evidenceData.trendDifference,
+            evidenceData.trendPercentage
+          ),
+        },
+        {
+          key: 'regression date',
+          subject: t('Approx. Start Time'),
+          value: formatBreakpoint(evidenceData.breakpoint),
+        },
+      ];
+    }
+    case IssueType.PROFILE_FUNCTION_REGRESSION_EXPERIMENTAL:
+    case IssueType.PROFILE_FUNCTION_REGRESSION: {
+      return [
+        {
+          key: 'function',
+          subject: t('Function Name'),
+          value: evidenceData?.function || t('unknown'),
+        },
+        {
+          key: 'package',
+          subject: t('Package Name'),
+          value: evidenceData.package || evidenceData.module || t('unknown'),
+        },
+        {
+          key: 'file',
+          subject: t('File Name'),
+          value: evidenceData.file || t('unknown'),
+        },
+        {
+          key: 'duration change',
+          subject: t('Change in Duration'),
+          value: formatDurationChange(
+            evidenceData.aggregateRange1 / 1e9,
+            evidenceData.aggregateRange2 / 1e9,
+            evidenceData.trendDifference,
+            evidenceData.trendPercentage
+          ),
+        },
+        {
+          key: 'breakpoint',
+          subject: t('Approx. Start Time'),
+          value: formatBreakpoint(evidenceData.breakpoint),
+        },
+      ];
+    }
+    default:
+      return null;
+  }
+}
+
+function formatDurationChange(
+  before: number,
+  after: number,
+  difference: number,
+  percentage: number
+) {
+  return t(
+    '%s to %s (%s%s)',
+    getDuration(before, 0, true),
+    getDuration(after, 0, true),
+    difference > 0 ? '+' : difference < 0 ? '-' : '',
+    formatPercentage(percentage - 1)
+  );
+}
+
+function formatBreakpoint(breakpoint: number) {
+  return getFormattedDate(breakpoint * 1000, 'MMM D, YYYY hh:mm:ss A z', {
+    local: true,
+  });
+}

+ 184 - 0
static/app/components/events/eventStatisticalDetector/eventRegressionTable.tsx

@@ -0,0 +1,184 @@
+import {useMemo} from 'react';
+import styled from '@emotion/styled';
+import {LocationDescriptor} from 'history';
+
+import Duration from 'sentry/components/duration';
+import GridEditable, {GridColumnOrder} from 'sentry/components/gridEditable';
+import SortLink from 'sentry/components/gridEditable/sortLink';
+import Link from 'sentry/components/links/link';
+import {t} from 'sentry/locale';
+import {defined} from 'sentry/utils';
+import {RateUnits} from 'sentry/utils/discover/fields';
+import {Container, NumberContainer} from 'sentry/utils/discover/styles';
+import {formatPercentage, formatRate} from 'sentry/utils/formatters';
+import {useLocation} from 'sentry/utils/useLocation';
+
+type RawDataRow<K extends string> = Record<K, any>;
+
+type DurationDataRow<K extends string> = RawDataRow<K> & {
+  durationAfter: number;
+  durationBefore: number;
+  percentageChange: number;
+};
+
+type ThroughputDataRow<K extends string> = RawDataRow<K> & {
+  percentageChange: number;
+  throughputAfter: number;
+  throughputBefore: number;
+};
+
+interface EventRegressionTableProps<K extends string> {
+  causeType: 'duration' | 'throughput';
+  columns: GridColumnOrder<K>[];
+  data: (DurationDataRow<K> | ThroughputDataRow<K>)[];
+  isError: boolean;
+  isLoading: boolean;
+  options: Record<
+    string,
+    {
+      defaultValue?: React.ReactNode;
+      link?: (any) =>
+        | {
+            target: LocationDescriptor;
+            onClick?: () => void;
+          }
+        | undefined;
+    }
+  >;
+}
+
+export function EventRegressionTable<K extends string>(
+  props: EventRegressionTableProps<K>
+) {
+  const location = useLocation();
+
+  const columnOrder = useMemo(() => {
+    if (props.causeType === 'throughput') {
+      return [
+        ...props.columns,
+        {key: 'throughputBefore', name: t('Baseline'), width: 150},
+        {key: 'throughputAfter', name: t('Regressed'), width: 150},
+        {key: 'percentageChange', name: t('Change'), width: 150},
+      ];
+    }
+    return [
+      ...props.columns,
+      {key: 'durationBefore', name: t('Baseline'), width: 150},
+      {key: 'durationAfter', name: t('Regressed'), width: 150},
+      {key: 'percentageChange', name: t('Change'), width: 150},
+    ];
+  }, [props.causeType, props.columns]);
+
+  const renderBodyCell = useMemo(
+    () =>
+      bodyCellRenderer(props.options, {
+        throughputBefore: throughputRenderer,
+        throughputAfter: throughputRenderer,
+        durationBefore: durationRenderer,
+        durationAfter: durationRenderer,
+        percentageChange: changeRenderer,
+      }),
+    [props.options]
+  );
+
+  return (
+    <GridEditable
+      error={props.isError}
+      isLoading={props.isLoading}
+      data={props.data}
+      location={location}
+      columnOrder={columnOrder}
+      columnSortBy={[]}
+      grid={{renderHeadCell, renderBodyCell}}
+    />
+  );
+}
+
+const RIGHT_ALIGNED_COLUMNS = new Set([
+  'durationBefore',
+  'durationAfter',
+  'durationChange',
+  'throughputBefore',
+  'throughputAfter',
+  'percentageChange',
+]);
+
+function renderHeadCell(column): React.ReactNode {
+  return (
+    <SortLink
+      align={RIGHT_ALIGNED_COLUMNS.has(column.key) ? 'right' : 'left'}
+      title={column.name}
+      direction={undefined}
+      canSort={false}
+      generateSortLink={() => undefined}
+    />
+  );
+}
+
+function bodyCellRenderer(options, builtinRenderers) {
+  return function renderGridBodyCell(
+    column,
+    dataRow,
+    _rowIndex,
+    _columnIndex
+  ): React.ReactNode {
+    const option = options[column.key];
+    const renderer = option?.renderer || builtinRenderers[column.key] || defaultRenderer;
+    return renderer(dataRow[column.key], {dataRow, option});
+  };
+}
+
+function throughputRenderer(throughput, {dataRow, option}) {
+  const rendered = formatRate(throughput, RateUnits.PER_MINUTE);
+  return <NumberContainer>{wrap(rendered, dataRow, option)}</NumberContainer>;
+}
+
+function durationRenderer(duration, {dataRow, option}) {
+  const rendered = <Duration seconds={duration} fixedDigits={2} abbreviation />;
+  return <NumberContainer>{wrap(rendered, dataRow, option)}</NumberContainer>;
+}
+
+function changeRenderer(percentageChange) {
+  return (
+    <ChangeContainer
+      change={
+        percentageChange > 0 ? 'positive' : percentageChange < 0 ? 'negative' : 'neutral'
+      }
+    >
+      {percentageChange > 0 ? '+' : ''}
+      {formatPercentage(percentageChange)}
+    </ChangeContainer>
+  );
+}
+
+function defaultRenderer(value, {dataRow, option}) {
+  return <Container>{wrap(value, dataRow, option)}</Container>;
+}
+
+function wrap(value, dataRow, option) {
+  let rendered = value;
+  if (defined(option)) {
+    if (!defined(value) && defined(option.defaultValue)) {
+      rendered = option.defaultValue;
+    }
+    if (defined(option.link)) {
+      const link = option.link(dataRow);
+      if (defined(link?.target)) {
+        rendered = (
+          <Link to={link.target} onClick={link.onClick}>
+            {rendered}
+          </Link>
+        );
+      }
+    }
+  }
+  return rendered;
+}
+
+const ChangeContainer = styled(NumberContainer)<{
+  change: 'positive' | 'neutral' | 'negative';
+}>`
+  ${p => p.change === 'positive' && `color: ${p.theme.red300};`}
+  ${p => p.change === 'neutral' && `color: ${p.theme.gray300};`}
+  ${p => p.change === 'negative' && `color: ${p.theme.green300};`}
+`;

+ 7 - 7
static/app/views/issueDetails/groupEventDetails/groupEventDetailsContent.tsx

@@ -18,9 +18,8 @@ import EventBreakpointChart from 'sentry/components/events/eventStatisticalDetec
 import {EventAffectedTransactions} from 'sentry/components/events/eventStatisticalDetector/eventAffectedTransactions';
 import EventComparison from 'sentry/components/events/eventStatisticalDetector/eventComparison';
 import {EventFunctionComparisonList} from 'sentry/components/events/eventStatisticalDetector/eventFunctionComparisonList';
-import {EventFunctionRegressionEvidence} from 'sentry/components/events/eventStatisticalDetector/eventFunctionRegressionEvidence';
+import {EventRegressionSummary} from 'sentry/components/events/eventStatisticalDetector/eventRegressionSummary';
 import {EventFunctionBreakpointChart} from 'sentry/components/events/eventStatisticalDetector/functionBreakpointChart';
-import RegressionMessage from 'sentry/components/events/eventStatisticalDetector/regressionMessage';
 import {EventTagsAndScreenshot} from 'sentry/components/events/eventTagsAndScreenshot';
 import {EventViewHierarchy} from 'sentry/components/events/eventViewHierarchy';
 import {EventGroupingInfo} from 'sentry/components/events/groupingInfo';
@@ -195,12 +194,14 @@ function PerformanceDurationRegressionIssueDetailsContent({
       renderDisabled
     >
       <Fragment>
-        <RegressionMessage event={event} group={group} />
+        <ErrorBoundary mini>
+          <EventRegressionSummary event={event} group={group} />
+        </ErrorBoundary>
         <ErrorBoundary mini>
           <EventBreakpointChart event={event} />
         </ErrorBoundary>
         <ErrorBoundary mini>
-          <AggregateSpanDiff event={event} projectId={project.id} />
+          <AggregateSpanDiff event={event} project={project} />
         </ErrorBoundary>
         <ErrorBoundary mini>
           <EventComparison event={event} project={project} />
@@ -224,12 +225,11 @@ function ProfilingDurationRegressionIssueDetailsContent({
       renderDisabled
     >
       <Fragment>
-        <RegressionMessage event={event} group={group} />
         <ErrorBoundary mini>
-          <EventFunctionBreakpointChart event={event} />
+          <EventRegressionSummary event={event} group={group} />
         </ErrorBoundary>
         <ErrorBoundary mini>
-          <EventFunctionRegressionEvidence event={event} />
+          <EventFunctionBreakpointChart event={event} />
         </ErrorBoundary>
         <ErrorBoundary mini>
           <EventAffectedTransactions event={event} group={group} project={project} />