Browse Source

feat(starfish): adds recommendations for fcp in webvitals module (#59052)

Adds recommendations to improve fcp in pageoverview slideout
edwardgou-sentry 1 year ago
parent
commit
a618e87598

+ 27 - 11
static/app/views/performance/browser/resources/utils/useResourcesQuery.ts

@@ -22,9 +22,11 @@ const {
 type Props = {
   sort: ValidSort;
   defaultResourceTypes?: string[];
+  limit?: number;
+  query?: string;
 };
 
-export const useResourcesQuery = ({sort, defaultResourceTypes}: Props) => {
+export const useResourcesQuery = ({sort, defaultResourceTypes, query, limit}: Props) => {
   const pageFilters = usePageFilters();
   const location = useLocation();
   const resourceFilters = useResourceModuleFilters();
@@ -34,17 +36,22 @@ export const useResourcesQuery = ({sort, defaultResourceTypes}: Props) => {
     `${SPAN_OP}:${
       resourceFilters[SPAN_OP] || `[${defaultResourceTypes?.join(',')}]` || 'resource.*'
     }`,
-    ...(resourceFilters.transaction
-      ? [`transaction:"${resourceFilters.transaction}"`]
-      : []),
-    ...(resourceFilters[SPAN_DOMAIN]
-      ? [`${SPAN_DOMAIN}:${resourceFilters[SPAN_DOMAIN]}`]
-      : []),
-    ...(resourceFilters['resource.render_blocking_status']
+    ...(!query
       ? [
-          `resource.render_blocking_status:${resourceFilters['resource.render_blocking_status']}`,
+          ...(resourceFilters.transaction
+            ? [`transaction:"${resourceFilters.transaction}"`]
+            : []),
+          ...(resourceFilters[SPAN_DOMAIN]
+            ? [`${SPAN_DOMAIN}:${resourceFilters[SPAN_DOMAIN]}`]
+            : []),
+          ...(resourceFilters['resource.render_blocking_status']
+            ? [
+                `resource.render_blocking_status:${resourceFilters['resource.render_blocking_status']}`,
+              ]
+            : [`!resource.render_blocking_status:blocking`]),
         ]
-      : [`!resource.render_blocking_status:blocking`]),
+      : []),
+    query,
   ];
 
   // TODO - we should be using metrics data here
@@ -74,7 +81,15 @@ export const useResourcesQuery = ({sort, defaultResourceTypes}: Props) => {
     eventView.sorts = [sort];
   }
 
-  const result = useDiscoverQuery({eventView, limit: 100, location, orgSlug});
+  const result = useDiscoverQuery({
+    eventView,
+    limit: limit ?? 100,
+    location,
+    orgSlug,
+    options: {
+      refetchOnWindowFocus: false,
+    },
+  });
 
   const data = result?.data?.data.map(row => ({
     [SPAN_OP]: row[SPAN_OP].toString() as `resource.${string}`,
@@ -92,6 +107,7 @@ export const useResourcesQuery = ({sort, defaultResourceTypes}: Props) => {
     [`avg(http.response_content_length)`]: row[
       `avg(${HTTP_RESPONSE_CONTENT_LENGTH})`
     ] as number,
+    ['count_unique(transaction)']: row['count_unique(transaction)'] as number,
   }));
 
   return {...result, data: data || []};

+ 173 - 0
static/app/views/performance/browser/webVitals/components/recommendations.tsx

@@ -0,0 +1,173 @@
+import styled from '@emotion/styled';
+
+import {Tooltip} from 'sentry/components/tooltip';
+import {IconFile} from 'sentry/icons';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import {getDuration} from 'sentry/utils/formatters';
+import {useResourcesQuery} from 'sentry/views/performance/browser/resources/utils/useResourcesQuery';
+import {WebVitals} from 'sentry/views/performance/browser/webVitals/utils/types';
+import {SpanMetricsField} from 'sentry/views/starfish/types';
+
+export function Recommendations({
+  transaction,
+  webVital,
+}: {
+  transaction: string;
+  webVital: WebVitals;
+}) {
+  switch (webVital) {
+    case 'lcp':
+      return null;
+    case 'fid':
+      return null;
+    case 'cls':
+      return null;
+    case 'fcp':
+      return <FcpRecommendations transaction={transaction} />;
+    case 'ttfb':
+      return null;
+    default:
+      return null;
+  }
+}
+
+function FcpRecommendations({transaction}: {transaction: string}) {
+  const query = `transaction:"${transaction}" resource.render_blocking_status:blocking`;
+  const {data, isLoading} = useResourcesQuery({
+    query,
+    sort: {field: `avg(${SpanMetricsField.SPAN_SELF_TIME})`, kind: 'desc'},
+    defaultResourceTypes: ['resource.script', 'resource.css', 'resource.img'],
+    limit: 7,
+  });
+  if (isLoading || !data) {
+    return null;
+  }
+  return (
+    <RecommendationsContainer>
+      <RecommendationsHeader />
+      <ul>
+        <RecommendationSubHeader>
+          {t('Eliminate render blocking resources')}
+        </RecommendationSubHeader>
+        <ResourceList>
+          {data.map(
+            ({
+              'span.op': op,
+              'span.description': description,
+              'avg(span.self_time)': duration,
+            }) => {
+              return (
+                <ResourceListItem key={description}>
+                  <Flex>
+                    <ResourceDescription>
+                      <StyledTooltip title={description}>
+                        <ResourceType resourceType={op} />
+                        {description}
+                      </StyledTooltip>
+                    </ResourceDescription>
+                    <span>{getFormattedDuration(duration)}</span>
+                  </Flex>
+                </ResourceListItem>
+              );
+            }
+          )}
+        </ResourceList>
+      </ul>
+    </RecommendationsContainer>
+  );
+}
+
+function RecommendationsHeader() {
+  return (
+    <RecommendationsHeaderContainer>
+      <b>{t('Recommendations')}</b>
+    </RecommendationsHeaderContainer>
+  );
+}
+
+function ResourceType({resourceType}: {resourceType: `resource.${string}`}) {
+  switch (resourceType) {
+    case 'resource.script':
+      return (
+        <b>
+          <StyledIconFile size="xs" />
+          {t('js')}
+          {' \u2014 '}
+        </b>
+      );
+    case 'resource.css':
+      return (
+        <b>
+          <StyledIconFile size="xs" />
+          {t('css')}
+          {' \u2014 '}
+        </b>
+      );
+    case 'resource.img':
+      return (
+        <b>
+          <StyledIconFile size="xs" />
+          {t('img')}
+          {' \u2014 '}
+        </b>
+      );
+    default:
+      return null;
+  }
+}
+
+const getFormattedDuration = (value: number | null) => {
+  if (value === null) {
+    return null;
+  }
+  if (value < 1000) {
+    return getDuration(value / 1000, 0, true);
+  }
+  return getDuration(value / 1000, 2, true);
+};
+
+const StyledIconFile = styled(IconFile)`
+  margin-right: ${space(0.5)};
+`;
+
+const RecommendationSubHeader = styled('li')`
+  margin-bottom: ${space(1)};
+`;
+
+const RecommendationsHeaderContainer = styled('div')`
+  margin-bottom: ${space(1)};
+  font-size: ${p => p.theme.fontSizeExtraLarge};
+`;
+
+const ResourceList = styled('ul')`
+  padding-left: ${space(1)};
+`;
+
+const ResourceListItem = styled('li')`
+  margin-bottom: ${space(0.5)};
+  list-style: none;
+  white-space: nowrap;
+`;
+
+const ResourceDescription = styled('span')`
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+`;
+
+const Flex = styled('span')`
+  display: flex;
+  justify-content: space-between;
+  gap: ${space(1)};
+`;
+
+const RecommendationsContainer = styled('div')`
+  margin-bottom: ${space(4)};
+`;
+
+const StyledTooltip = styled(Tooltip)`
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+`;

+ 16 - 9
static/app/views/performance/browser/webVitals/pageOverviewWebVitalsDetailPanel.tsx

@@ -24,6 +24,7 @@ import useOrganization from 'sentry/utils/useOrganization';
 import useProjects from 'sentry/utils/useProjects';
 import {useRoutes} from 'sentry/utils/useRoutes';
 import {PerformanceBadge} from 'sentry/views/performance/browser/webVitals/components/performanceBadge';
+import {Recommendations} from 'sentry/views/performance/browser/webVitals/components/recommendations';
 import {WebVitalDetailHeader} from 'sentry/views/performance/browser/webVitals/components/webVitalDescription';
 import {
   calculatePerformanceScore,
@@ -145,6 +146,9 @@ export function PageOverviewWebVitalsDetailPanel({
     if (col.key === 'score') {
       return <AlignCenter>{`${webVital} ${col.name}`}</AlignCenter>;
     }
+    if (col.key === 'replayId' || col.key === 'profile.id') {
+      return <AlignCenter>{col.name}</AlignCenter>;
+    }
     return <NoOverflow>{col.name}</NoOverflow>;
   };
 
@@ -204,17 +208,17 @@ export function PageOverviewWebVitalsDetailPanel({
           undefined
         );
 
-      return (
-        <NoOverflow>
-          {row.replayId && replayTarget && (
-            <Link to={replayTarget}>{getShortEventId(row.replayId)}</Link>
-          )}
-        </NoOverflow>
+      return row.replayId && replayTarget ? (
+        <AlignCenter>
+          <Link to={replayTarget}>{getShortEventId(row.replayId)}</Link>
+        </AlignCenter>
+      ) : (
+        <AlignCenter>{' \u2014 '}</AlignCenter>
       );
     }
     if (key === 'profile.id') {
       if (!defined(project) || !defined(row['profile.id'])) {
-        return null;
+        return <AlignCenter>{' \u2014 '}</AlignCenter>;
       }
       const target = generateProfileFlamechartRoute({
         orgSlug: organization.slug,
@@ -223,11 +227,11 @@ export function PageOverviewWebVitalsDetailPanel({
       });
 
       return (
-        <NoOverflow>
+        <AlignCenter>
           <Link to={target} onClick={onClose}>
             {getShortEventId(row['profile.id'])}
           </Link>
-        </NoOverflow>
+        </AlignCenter>
       );
     }
     return <AlignRight>{row[key]}</AlignRight>;
@@ -254,6 +258,9 @@ export function PageOverviewWebVitalsDetailPanel({
             score={projectScore[`${webVital}Score`]}
           />
         )}
+        {transaction && webVital && (
+          <Recommendations transaction={transaction} webVital={webVital} />
+        )}
         <GridEditable
           data={tableData}
           isLoading={isTransactionWebVitalsQueryLoading}