Browse Source

feat(trends): Add improved/regressed projects to trends (#20826)

* feat(trends): Add improved/regressed projects to trends

This will show widgets to link to the most improved and worst regressed project under the selected filters

* Fix magic number font size and get rid of unnecessary overloading
k-fish 4 years ago
parent
commit
1be86aea9a

+ 306 - 0
src/sentry/static/sentry/app/views/performance/trends/changedProjects.tsx

@@ -0,0 +1,306 @@
+import React from 'react';
+import {Location} from 'history';
+import {browserHistory} from 'react-router';
+import styled from '@emotion/styled';
+
+import {Panel} from 'app/components/panels';
+import Button from 'app/components/button';
+import LoadingIndicator from 'app/components/loadingIndicator';
+import withApi from 'app/utils/withApi';
+import withProjects from 'app/utils/withProjects';
+import withOrganization from 'app/utils/withOrganization';
+import DiscoverQuery from 'app/utils/discover/discoverQuery';
+import space from 'app/styles/space';
+import {Organization, Project} from 'app/types';
+import {Client} from 'app/api';
+import {t, tct} from 'app/locale';
+import QuestionTooltip from 'app/components/questionTooltip';
+import {formatPercentage, getDuration} from 'app/utils/formatters';
+import {DEFAULT_RELATIVE_PERIODS} from 'app/constants';
+
+import {
+  TrendChangeType,
+  TrendFunctionField,
+  TrendView,
+  ProjectTrendsData,
+  NormalizedProjectTrend,
+} from './types';
+import {modifyTrendView, normalizeTrends, trendToColor, getTrendProjectId} from './utils';
+import {HeaderTitleLegend} from '../styles';
+
+type Props = {
+  api: Client;
+  organization: Organization;
+  trendChangeType: TrendChangeType;
+  previousTrendFunction?: TrendFunctionField;
+  trendView: TrendView;
+  location: Location;
+  projects: Project[];
+};
+
+function getTitle(trendChangeType: TrendChangeType): string {
+  switch (trendChangeType) {
+    case TrendChangeType.IMPROVED:
+      return t('Most Improved Project');
+    case TrendChangeType.REGRESSION:
+      return t('Worst Regressed Project');
+    default:
+      throw new Error('No trend type passed');
+  }
+}
+
+function getDescription(
+  trendChangeType: TrendChangeType,
+  trendView: TrendView,
+  projectTrend: NormalizedProjectTrend
+) {
+  const absolutePercentChange = formatPercentage(
+    Math.abs(projectTrend.percentage_aggregate_range_2_aggregate_range_1 - 1),
+    0
+  );
+
+  const project = <strong>{projectTrend.project}</strong>;
+
+  const currentPeriodValue = projectTrend.aggregate_range_2;
+  const previousPeriodValue = projectTrend.aggregate_range_1;
+
+  const previousValue = getDuration(
+    previousPeriodValue / 1000,
+    previousPeriodValue < 1000 ? 0 : 2
+  );
+  const currentValue = getDuration(
+    currentPeriodValue / 1000,
+    currentPeriodValue < 1000 ? 0 : 2
+  );
+
+  const absoluteChange = Math.abs(currentPeriodValue - previousPeriodValue);
+
+  const absoluteChangeDuration = getDuration(
+    absoluteChange / 1000,
+    absoluteChange < 1000 ? 0 : 2
+  );
+
+  const period = trendView.statsPeriod
+    ? DEFAULT_RELATIVE_PERIODS[trendView.statsPeriod].toLowerCase()
+    : t('given timeframe');
+
+  const improvedTemplate =
+    'In the [period], [project] sped up by [absoluteChangeDuration] (a [percent] decrease in duration). See the top transactions that made that happen.';
+  const regressedTemplate =
+    'In the [period], [project] slowed down by [absoluteChangeDuration] (a [percent] increase in duration). See the top transactions that made that happen.';
+  const template =
+    trendChangeType === TrendChangeType.IMPROVED ? improvedTemplate : regressedTemplate;
+
+  return tct(template, {
+    project,
+    period,
+    percent: absolutePercentChange,
+    absoluteChangeDuration,
+    previousValue,
+    currentValue,
+  });
+}
+
+function getNoResultsDescription(trendChangeType: TrendChangeType) {
+  return trendChangeType === TrendChangeType.IMPROVED
+    ? t('The glass is half empty today. There are only regressions so get back to work.')
+    : t(
+        'The glass is half full today. There are only improvements so get some ice cream.'
+      );
+}
+
+function handleViewTransactions(
+  projectTrend: NormalizedProjectTrend,
+  projects: Project[],
+  location: Location
+) {
+  const projectId = getTrendProjectId(projectTrend, projects);
+  browserHistory.push({
+    pathname: location.pathname,
+    query: {
+      ...location.query,
+      project: [projectId],
+    },
+  });
+}
+
+function ChangedProjects(props: Props) {
+  const {location, trendView, organization, projects, trendChangeType} = props;
+  const projectTrendView = trendView.clone();
+
+  const containerTitle = getTitle(trendChangeType);
+  modifyTrendView(projectTrendView, location, trendChangeType, true);
+
+  return (
+    <DiscoverQuery
+      eventView={projectTrendView}
+      orgSlug={organization.slug}
+      location={location}
+      trendChangeType={trendChangeType}
+      limit={1}
+    >
+      {({isLoading, tableData}) => {
+        const eventsTrendsData = (tableData as unknown) as ProjectTrendsData;
+        const trends = eventsTrendsData?.events?.data || [];
+        const events = normalizeTrends(trends);
+
+        const transactionsList = events && events.slice ? events.slice(0, 5) : [];
+        const projectTrend = transactionsList[0];
+
+        const titleTooltipContent = t(
+          'This shows the project with largest changes across its transactions'
+        );
+
+        return (
+          <ChangedProjectsContainer>
+            <StyledPanel>
+              <DescriptionContainer>
+                <ContainerTitle>
+                  <HeaderTitleLegend>
+                    {containerTitle}{' '}
+                    <QuestionTooltip
+                      size="sm"
+                      position="top"
+                      title={titleTooltipContent}
+                    />
+                  </HeaderTitleLegend>
+                </ContainerTitle>
+                {isLoading ? (
+                  <LoadingIndicatorContainer>
+                    <LoadingIndicator mini />
+                  </LoadingIndicatorContainer>
+                ) : (
+                  <React.Fragment>
+                    {transactionsList.length ? (
+                      <React.Fragment>
+                        <ProjectTrendContainer>
+                          <div>
+                            {getDescription(trendChangeType, trendView, projectTrend)}
+                          </div>
+                        </ProjectTrendContainer>
+                      </React.Fragment>
+                    ) : (
+                      <ProjectTrendContainer>
+                        <div>{getNoResultsDescription(trendChangeType)}</div>
+                      </ProjectTrendContainer>
+                    )}
+                    {projectTrend && (
+                      <ButtonContainer>
+                        <Button
+                          onClick={() =>
+                            handleViewTransactions(projectTrend, projects, location)
+                          }
+                          size="small"
+                        >
+                          {t('View Transactions')}
+                        </Button>
+                      </ButtonContainer>
+                    )}
+                  </React.Fragment>
+                )}
+              </DescriptionContainer>
+              <VisualizationContainer>
+                {projectTrend &&
+                  !isLoading &&
+                  getVisualization(trendChangeType, projectTrend)}
+              </VisualizationContainer>
+            </StyledPanel>
+          </ChangedProjectsContainer>
+        );
+      }}
+    </DiscoverQuery>
+  );
+}
+
+const StyledPanel = styled(Panel)`
+  display: flex;
+  flex-direction: row;
+`;
+const DescriptionContainer = styled('div')`
+  display: flex;
+  flex-direction: column;
+  flex-grow: 1;
+  min-height: 185px;
+`;
+const VisualizationContainer = styled('div')``;
+const ChangedProjectsContainer = styled('div')``;
+const ContainerTitle = styled('div')`
+  padding-top: ${space(3)};
+  padding-left: ${space(2)};
+`;
+const LoadingIndicatorContainer = styled('div')`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  height: 100%;
+`;
+const ProjectTrendContainer = styled('div')`
+  padding: ${space(2)};
+
+  margin-top: ${space(1)};
+  margin-left: ${space(1)};
+
+  font-size: ${p => p.theme.fontSizeMedium};
+  color: ${p => p.theme.gray600};
+`;
+const ButtonContainer = styled('div')`
+  padding-left: ${space(2)};
+  margin-left: ${space(1)};
+  padding-bottom: ${space(2)};
+`;
+
+function getVisualization(
+  trendChangeType: TrendChangeType,
+  projectTrend: NormalizedProjectTrend
+) {
+  const color = trendToColor[trendChangeType];
+
+  const trendPercent = formatPercentage(
+    projectTrend.percentage_aggregate_range_2_aggregate_range_1 - 1,
+    0
+  );
+
+  return (
+    <div>
+      <TrendCircle color={color}>
+        <TrendCircleContent>
+          <TrendCirclePrimary>
+            {trendChangeType === TrendChangeType.REGRESSION ? '+' : ''}
+            {trendPercent}
+          </TrendCirclePrimary>
+          <TrendCircleSecondary>{projectTrend.project}</TrendCircleSecondary>
+        </TrendCircleContent>
+      </TrendCircle>
+    </div>
+  );
+}
+
+const TrendCircle = styled('div')<{color: string}>`
+  width: 124px;
+  height: 124px;
+  margin: ${space(3)};
+  border-style: solid;
+  border-width: 5px;
+  border-radius: 50%;
+  border-color: ${p => p.color};
+
+  display: flex;
+  align-items: center;
+  justify-content: center;
+`;
+const TrendCircleContent = styled('div')`
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+`;
+const TrendCirclePrimary = styled('div')`
+  font-size: 26px;
+  line-height: 37px;
+`;
+const TrendCircleSecondary = styled('div')`
+  font-size: 12px;
+  line-height: 12px;
+  color: ${p => p.theme.gray500};
+`;
+
+export default withApi(withProjects(withOrganization(ChangedProjects)));

+ 4 - 16
src/sentry/static/sentry/app/views/performance/trends/changedTransactions.tsx

@@ -41,12 +41,13 @@ import {
   transformValueDelta,
   transformDeltaSpread,
   modifyTrendView,
-  normalizeTrendsTransactions,
+  normalizeTrends,
   getSelectedQueryKey,
   getCurrentTrendFunction,
   getTrendBaselinesForTransaction,
   getIntervalRatio,
   StyledIconArrow,
+  getTrendProjectId,
 } from './utils';
 import {transactionSummaryRouteWithQuery} from '../transactionSummary/utils';
 import {HeaderTitleLegend} from '../styles';
@@ -86,19 +87,6 @@ function onTrendsCursor(trendChangeType: TrendChangeType) {
   };
 }
 
-function getTransactionProjectId(
-  transaction: NormalizedTrendsTransaction,
-  projects?: Project[]
-): string | undefined {
-  if (!transaction.project || !projects) {
-    return undefined;
-  }
-  const transactionProject = projects.find(
-    project => project.slug === transaction.project
-  );
-  return transactionProject?.id;
-}
-
 function getChartTitle(trendChangeType: TrendChangeType): string {
   switch (trendChangeType) {
     case TrendChangeType.IMPROVED:
@@ -174,7 +162,7 @@ function ChangedTransactions(props: Props) {
     >
       {({isLoading, tableData, pageLinks}) => {
         const eventsTrendsData = (tableData as unknown) as TrendsData;
-        const events = normalizeTrendsTransactions(
+        const events = normalizeTrends(
           (eventsTrendsData && eventsTrendsData.events && eventsTrendsData.events.data) ||
             []
         );
@@ -473,7 +461,7 @@ const TransactionSummaryLink = (props: TransactionSummaryLinkProps) => {
   const {organization, trendView: eventView, transaction, projects} = props;
 
   const summaryView = eventView.clone();
-  const projectID = getTransactionProjectId(transaction, projects);
+  const projectID = getTrendProjectId(transaction, projects);
   const target = transactionSummaryRouteWithQuery({
     orgSlug: organization.slug,
     transaction: String(transaction.transaction),

+ 36 - 15
src/sentry/static/sentry/app/views/performance/trends/content.tsx

@@ -15,6 +15,7 @@ import {getTransactionSearchQuery} from '../utils';
 import {TrendChangeType, TrendView, TrendFunctionField} from './types';
 import {TRENDS_FUNCTIONS, getCurrentTrendFunction, getSelectedQueryKey} from './utils';
 import ChangedTransactions from './changedTransactions';
+import ChangedProjects from './changedProjects';
 
 type Props = {
   organization: Organization;
@@ -101,20 +102,36 @@ class TrendsContent extends React.Component<Props, State> {
             </DropdownControl>
           </TrendsDropdown>
         </StyledSearchContainer>
-        <ChangedTransactionContainer>
-          <ChangedTransactions
-            trendChangeType={TrendChangeType.IMPROVED}
-            previousTrendFunction={previousTrendFunction}
-            trendView={trendView}
-            location={location}
-          />
-          <ChangedTransactions
-            trendChangeType={TrendChangeType.REGRESSION}
-            previousTrendFunction={previousTrendFunction}
-            trendView={trendView}
-            location={location}
-          />
-        </ChangedTransactionContainer>
+        <TrendsLayoutContainer>
+          <TrendsColumn>
+            <ChangedProjects
+              trendChangeType={TrendChangeType.IMPROVED}
+              previousTrendFunction={previousTrendFunction}
+              trendView={trendView}
+              location={location}
+            />
+            <ChangedTransactions
+              trendChangeType={TrendChangeType.IMPROVED}
+              previousTrendFunction={previousTrendFunction}
+              trendView={trendView}
+              location={location}
+            />
+          </TrendsColumn>
+          <TrendsColumn>
+            <ChangedProjects
+              trendChangeType={TrendChangeType.REGRESSION}
+              previousTrendFunction={previousTrendFunction}
+              trendView={trendView}
+              location={location}
+            />
+            <ChangedTransactions
+              trendChangeType={TrendChangeType.REGRESSION}
+              previousTrendFunction={previousTrendFunction}
+              trendView={trendView}
+              location={location}
+            />
+          </TrendsColumn>
+        </TrendsLayoutContainer>
       </Feature>
     );
   }
@@ -134,7 +151,7 @@ const StyledSearchContainer = styled('div')`
   display: flex;
 `;
 
-const ChangedTransactionContainer = styled('div')`
+const TrendsLayoutContainer = styled('div')`
   @media (min-width: ${p => p.theme.breakpoints[1]}) {
     display: block;
   }
@@ -145,5 +162,9 @@ const ChangedTransactionContainer = styled('div')`
     grid-template-columns: 50% 50%;
   }
 `;
+const TrendsColumn = styled('div')`
+  display: flex;
+  flex-direction: column;
+`;
 
 export default TrendsContent;

+ 15 - 1
src/sentry/static/sentry/app/views/performance/trends/types.ts

@@ -47,9 +47,19 @@ export type TrendsData = {
   stats: TrendsStats;
 };
 
+export type ProjectTrendsDataEvents = {
+  data: ProjectTrend[];
+  meta: any;
+};
+
+export type ProjectTrendsData = {
+  events: ProjectTrendsDataEvents;
+  stats: TrendsStats;
+};
+
 type BaseTrendsTransaction = {
   transaction: string;
-  project?: string;
+  project: string;
   count: number;
 
   count_range_1: number;
@@ -83,9 +93,13 @@ export type TrendsTransaction =
   | TrendsAvgTransaction
   | TrendsUserMiseryTransaction;
 
+export type ProjectTrend = Omit<TrendsTransaction, 'transaction'>;
+
 export type NormalizedTrendsTransaction = BaseTrendsTransaction & {
   aggregate_range_1: number;
   aggregate_range_2: number;
   percentage_aggregate_range_2_aggregate_range_1: number;
   minus_aggregate_range_2_aggregate_range_1: number;
 };
+
+export type NormalizedProjectTrend = Omit<NormalizedTrendsTransaction, 'transaction'>;

+ 40 - 8
src/sentry/static/sentry/app/views/performance/trends/utils.tsx

@@ -18,7 +18,7 @@ import {Sort, Field} from 'app/utils/discover/fields';
 import {t} from 'app/locale';
 import space from 'app/styles/space';
 import Count from 'app/components/count';
-import {Organization} from 'app/types';
+import {Organization, Project} from 'app/types';
 import EventView from 'app/utils/discover/eventView';
 import {Client} from 'app/api';
 import {getUtcDateString} from 'app/utils/dates';
@@ -32,6 +32,8 @@ import {
   NormalizedTrendsTransaction,
   TrendFunctionField,
   TrendsStats,
+  ProjectTrend,
+  NormalizedProjectTrend,
 } from './types';
 import {BaselineQueryResults} from '../transactionSummary/baselineQuery';
 
@@ -153,15 +155,28 @@ export function transformDeltaSpread(
   );
 }
 
+export function getTrendProjectId(
+  trend: NormalizedTrendsTransaction | NormalizedProjectTrend,
+  projects?: Project[]
+): string | undefined {
+  if (!trend.project || !projects) {
+    return undefined;
+  }
+  const transactionProject = projects.find(project => project.slug === trend.project);
+  return transactionProject?.id;
+}
+
 export function modifyTrendView(
   trendView: TrendView,
   location: Location,
-  trendsType: TrendChangeType
+  trendsType: TrendChangeType,
+  isProjectOnly?: boolean
 ) {
   const trendFunction = getCurrentTrendFunction(location);
 
   const trendFunctionFields = TRENDS_FUNCTIONS.map(({field}) => field);
-  const fields = [...trendFunctionFields, 'transaction', 'project', 'count()'].map(
+  const transactionField = isProjectOnly ? [] : ['transaction'];
+  const fields = [...trendFunctionFields, ...transactionField, 'project', 'count()'].map(
     field => ({
       field,
     })
@@ -307,10 +322,17 @@ export function transformValueDelta(
  * This will normalize the trends transactions while the current trend function and current data are out of sync
  * To minimize extra renders with missing results.
  */
-export function normalizeTrendsTransactions(data: TrendsTransaction[]) {
+export function normalizeTrends(
+  data: Array<TrendsTransaction>
+): Array<NormalizedTrendsTransaction>;
+
+export function normalizeTrends(data: Array<ProjectTrend>): Array<NormalizedProjectTrend>;
+
+export function normalizeTrends(
+  data: Array<TrendsTransaction | ProjectTrend>
+): Array<NormalizedTrendsTransaction | NormalizedProjectTrend> {
   return data.map(row => {
     const {
-      transaction,
       project,
       count_range_1,
       count_range_2,
@@ -329,15 +351,25 @@ export function normalizeTrendsTransactions(data: TrendsTransaction[]) {
       }
     });
 
-    return {
+    const normalized = {
       ...aliasedFields,
-      transaction,
       project,
 
       count_range_1,
       count_range_2,
       percentage_count_range_2_count_range_1,
-    } as NormalizedTrendsTransaction;
+    };
+
+    if ('transaction' in row) {
+      return {
+        ...normalized,
+        transaction: row.transaction,
+      } as NormalizedTrendsTransaction;
+    } else {
+      return {
+        ...normalized,
+      } as NormalizedProjectTrend;
+    }
   });
 }
 

+ 71 - 8
tests/js/spec/views/performance/trends.spec.jsx

@@ -260,6 +260,32 @@ describe('Performance > Trends', function() {
     }
   });
 
+  it('clicking project trend view transactions changes location', async function() {
+    const projectId = 42;
+    const projects = [TestStubs.Project({id: projectId, slug: 'internal'})];
+    const data = initializeData(projects, {project: ['-1']});
+    const wrapper = mountWithTheme(
+      <PerformanceLanding
+        organization={data.organization}
+        location={data.router.location}
+      />,
+      data.routerContext
+    );
+
+    await tick();
+    wrapper.update();
+
+    const mostImprovedProject = wrapper.find('ChangedProjectsContainer').first();
+    const viewTransactions = mostImprovedProject.find('Button').first();
+    viewTransactions.simulate('click');
+
+    expect(browserHistory.push).toHaveBeenCalledWith({
+      query: expect.objectContaining({
+        project: [projectId],
+      }),
+    });
+  });
+
   it('trend functions in location make api calls', async function() {
     const projects = [TestStubs.Project(), TestStubs.Project()];
     const data = initializeData(projects, {project: ['-1']});
@@ -283,7 +309,7 @@ describe('Performance > Trends', function() {
       wrapper.update();
       await tick();
 
-      expect(trendsMock).toHaveBeenCalledTimes(2);
+      expect(trendsMock).toHaveBeenCalledTimes(4);
 
       const aliasedFieldDivide = getTrendAliasedFieldPercentage(trendFunction.alias);
       const aliasedQueryDivide = getTrendAliasedQueryPercentage(trendFunction.alias);
@@ -293,14 +319,20 @@ describe('Performance > Trends', function() {
           ? getTrendAliasedMinus(trendFunction.alias)
           : aliasedFieldDivide;
 
-      const defaultFields = ['transaction', 'project', 'count()'];
+      const defaultTrendsFields = ['project', 'count()'];
       const trendFunctionFields = TRENDS_FUNCTIONS.map(({field}) => field);
 
-      const field = [...trendFunctionFields, ...defaultFields];
+      const transactionFields = [
+        ...trendFunctionFields,
+        'transaction',
+        ...defaultTrendsFields,
+      ];
+      const projectFields = [...trendFunctionFields, ...defaultTrendsFields];
 
-      expect(field).toHaveLength(8);
+      expect(transactionFields).toHaveLength(8);
+      expect(projectFields).toHaveLength(transactionFields.length - 1);
 
-      // Improved trends call
+      // Improved projects call
       expect(trendsMock).toHaveBeenNthCalledWith(
         1,
         expect.anything(),
@@ -310,23 +342,54 @@ describe('Performance > Trends', function() {
             sort,
             query: expect.stringContaining(aliasedQueryDivide + ':<1'),
             interval: '12h',
-            field,
+            field: projectFields,
             statsPeriod: '14d',
           }),
         })
       );
 
-      // Regression trends call
+      // Improved transactions call
       expect(trendsMock).toHaveBeenNthCalledWith(
         2,
         expect.anything(),
+        expect.objectContaining({
+          query: expect.objectContaining({
+            trendFunction: trendFunction.field,
+            sort,
+            query: expect.stringContaining(aliasedQueryDivide + ':<1'),
+            interval: '12h',
+            field: transactionFields,
+            statsPeriod: '14d',
+          }),
+        })
+      );
+      // Regression projects call
+      expect(trendsMock).toHaveBeenNthCalledWith(
+        3,
+        expect.anything(),
+        expect.objectContaining({
+          query: expect.objectContaining({
+            trendFunction: trendFunction.field,
+            sort: '-' + sort,
+            query: expect.stringContaining(aliasedQueryDivide + ':>1'),
+            interval: '12h',
+            field: projectFields,
+            statsPeriod: '14d',
+          }),
+        })
+      );
+
+      // Regression transactions call
+      expect(trendsMock).toHaveBeenNthCalledWith(
+        4,
+        expect.anything(),
         expect.objectContaining({
           query: expect.objectContaining({
             trendFunction: trendFunction.field,
             sort: '-' + sort,
             query: expect.stringContaining(aliasedQueryDivide + ':>1'),
             interval: '12h',
-            field,
+            field: transactionFields,
             statsPeriod: '14d',
           }),
         })