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

feat(replays): display error count by project (#48374)

## Summary
This change adds the error count grouped by project. 
~Note this depends on: https://github.com/getsentry/sentry/pull/48339 -
for legibility ive cut this PR against that branch instead of master.~

**No errs:**

![image](https://user-images.githubusercontent.com/7349258/236025424-02912ad7-9e01-46e4-abb1-bb7c21748183.png)


**1 project w/ errs:**

![image](https://user-images.githubusercontent.com/7349258/236025605-d6227063-aae6-4557-a446-db7d67ddd125.png)

**2 project w/ errs:**

![image](https://user-images.githubusercontent.com/7349258/236026985-3e317521-6829-4f76-a2d8-a91927665675.png)

**3+ project w/ errs:**

![image](https://user-images.githubusercontent.com/7349258/236025232-41c96fc7-e31a-4bf3-acb6-c68022bb3f1e.png)



Relates to: https://github.com/getsentry/sentry/issues/48249
Closes: https://github.com/getsentry/sentry/issues/48363
Elias Hussary 1 год назад
Родитель
Сommit
cea61f6861

+ 15 - 2
static/app/components/replays/header/errorCount.tsx

@@ -1,17 +1,30 @@
+import {Fragment} from 'react';
 import styled from '@emotion/styled';
 
+import ProjectBadge from 'sentry/components/idBadge/projectBadge';
 import {IconFire} from 'sentry/icons';
 import {space} from 'sentry/styles/space';
+import {Project} from 'sentry/types';
 
 type Props = {
   countErrors: number;
   className?: string;
+  hideIcon?: boolean;
+  project?: Project;
 };
 
-const ErrorCount = styled(({countErrors, className}: Props) =>
+const ErrorCount = styled(({countErrors, project, className, hideIcon}: Props) =>
   countErrors ? (
     <span className={className}>
-      <IconFire />
+      {!hideIcon && (
+        <Fragment>
+          {project ? (
+            <ProjectBadge project={project} disableLink hideName />
+          ) : (
+            <IconFire />
+          )}
+        </Fragment>
+      )}
       {countErrors}
     </span>
   ) : (

+ 3 - 0
static/app/utils/replays/hooks/useReplayData.spec.tsx

@@ -79,6 +79,7 @@ describe('useReplayData', () => {
       fetching: false,
       onRetry: expect.any(Function),
       replay: expect.any(ReplayReader),
+      replayErrors: expect.any(Array),
       replayRecord: expectedReplay,
       projectSlug: project.slug,
       replayId: expectedReplay.id,
@@ -277,6 +278,7 @@ describe('useReplayData', () => {
       fetchError: undefined,
       fetching: true,
       onRetry: expect.any(Function),
+      replayErrors: expect.any(Array),
       replay: null,
       replayRecord: undefined,
       projectSlug: null,
@@ -360,6 +362,7 @@ describe('useReplayData', () => {
       replay: expect.any(ReplayReader),
       replayRecord: expectedReplay,
       projectSlug: project.slug,
+      replayErrors: expect.any(Array),
       replayId: expectedReplay.id,
     });
   });

+ 2 - 0
static/app/utils/replays/hooks/useReplayData.tsx

@@ -55,6 +55,7 @@ interface Result {
   onRetry: () => void;
   projectSlug: string | null;
   replay: ReplayReader | null;
+  replayErrors: ReplayError[];
   replayId: string;
   replayRecord: ReplayRecord | undefined;
 }
@@ -224,6 +225,7 @@ function useReplayData({
   }, [attachments, errors, replayRecord]);
 
   return {
+    replayErrors: errors,
     fetchError: state.fetchError,
     fetching: state.fetchingAttachments || state.fetchingErrors || state.fetchingReplay,
     onRetry: loadData,

+ 11 - 3
static/app/views/replays/detail/page.tsx

@@ -14,17 +14,25 @@ import {t} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
 import type {Crumb} from 'sentry/types/breadcrumbs';
 import ReplayMetaData from 'sentry/views/replays/detail/replayMetaData';
-import type {ReplayRecord} from 'sentry/views/replays/types';
+import type {ReplayError, ReplayRecord} from 'sentry/views/replays/types';
 
 type Props = {
   children: ReactNode;
   orgSlug: string;
   projectSlug: string | null;
+  replayErrors: ReplayError[];
   replayRecord: undefined | ReplayRecord;
   crumbs?: Crumb[];
 };
 
-function Page({children, crumbs, orgSlug, replayRecord, projectSlug}: Props) {
+function Page({
+  children,
+  crumbs,
+  orgSlug,
+  replayRecord,
+  projectSlug,
+  replayErrors,
+}: Props) {
   const title = replayRecord
     ? `${replayRecord.id} - Session Replay - ${orgSlug}`
     : `Session Replay - ${orgSlug}`;
@@ -71,7 +79,7 @@ function Page({children, crumbs, orgSlug, replayRecord, projectSlug}: Props) {
         <HeaderPlaceholder width="100%" height="58px" />
       )}
 
-      <ReplayMetaData replayRecord={replayRecord} />
+      <ReplayMetaData replayRecord={replayRecord} replayErrors={replayErrors} />
     </Header>
   );
 

+ 77 - 4
static/app/views/replays/detail/replayMetaData.tsx

@@ -1,6 +1,9 @@
-import {Fragment} from 'react';
+import {Fragment, useMemo} from 'react';
 import styled from '@emotion/styled';
+import countBy from 'lodash/countBy';
 
+import Badge from 'sentry/components/badge';
+import ProjectBadge from 'sentry/components/idBadge/projectBadge';
 import Link from 'sentry/components/links/link';
 import ContextIcon from 'sentry/components/replays/contextIcon';
 import ErrorCount from 'sentry/components/replays/header/errorCount';
@@ -9,15 +12,19 @@ import TimeSince from 'sentry/components/timeSince';
 import {IconCalendar} from 'sentry/icons';
 import {t} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
+import {Project} from 'sentry/types';
 import {useLocation} from 'sentry/utils/useLocation';
-import type {ReplayRecord} from 'sentry/views/replays/types';
+import useProjects from 'sentry/utils/useProjects';
+import type {ReplayError, ReplayRecord} from 'sentry/views/replays/types';
 
 type Props = {
+  replayErrors: ReplayError[];
   replayRecord: ReplayRecord | undefined;
 };
 
-function ReplayMetaData({replayRecord}: Props) {
+function ReplayMetaData({replayRecord, replayErrors}: Props) {
   const {pathname, query} = useLocation();
+  const {projects} = useProjects();
 
   const errorsTabHref = {
     pathname,
@@ -29,6 +36,18 @@ function ReplayMetaData({replayRecord}: Props) {
     },
   };
 
+  const errorCountByProject = useMemo(
+    () =>
+      Object.entries(countBy(replayErrors, 'project.name'))
+        .map(([projectSlug, count]) => ({
+          project: projects.find(p => p.slug === projectSlug),
+          count,
+        }))
+        // sort to prioritize the replay errors first
+        .sort(a => (a.project?.id !== replayRecord?.project_id ? 1 : -1)),
+    [replayErrors, projects, replayRecord]
+  );
+
   return (
     <KeyMetrics>
       <KeyMetricLabel>{t('OS')}</KeyMetricLabel>
@@ -62,7 +81,19 @@ function ReplayMetaData({replayRecord}: Props) {
       <KeyMetricData>
         {replayRecord ? (
           <StyledLink to={errorsTabHref}>
-            <ErrorCount countErrors={replayRecord.count_errors} />
+            {errorCountByProject.length > 0 ? (
+              <Fragment>
+                {errorCountByProject.length < 3 ? (
+                  errorCountByProject.map(({project, count}, idx) => (
+                    <ErrorCount key={idx} countErrors={count} project={project} />
+                  ))
+                ) : (
+                  <StackedErrorCount errorCounts={errorCountByProject} />
+                )}
+              </Fragment>
+            ) : (
+              <ErrorCount countErrors={0} />
+            )}
           </StyledLink>
         ) : (
           <HeaderPlaceholder width="80px" height="16px" />
@@ -72,6 +103,48 @@ function ReplayMetaData({replayRecord}: Props) {
   );
 }
 
+function StackedErrorCount({
+  errorCounts,
+}: {
+  errorCounts: Array<{count: number; project: Project | undefined}>;
+}) {
+  const projectCount = errorCounts.length - 2;
+  const totalErrors = errorCounts.reduce((acc, val) => acc + val.count, 0);
+  return (
+    <Fragment>
+      <StackedProjectBadges>
+        {errorCounts.slice(0, 2).map((v, idx) => {
+          if (!v.project) {
+            return null;
+          }
+
+          return <ProjectBadge key={idx} project={v.project} hideName disableLink />;
+        })}
+        <Badge>+{projectCount}</Badge>
+      </StackedProjectBadges>
+      <ErrorCount countErrors={totalErrors} hideIcon />
+    </Fragment>
+  );
+}
+
+const StackedProjectBadges = styled('div')`
+  display: flex;
+  align-items: center;
+  & * {
+    margin-left: 0;
+    margin-right: 0;
+    cursor: pointer;
+  }
+
+  & *:hover {
+    z-index: unset;
+  }
+
+  & > :not(:first-child) {
+    margin-left: -${space(0.5)};
+  }
+`;
+
 const KeyMetrics = styled('dl')`
   display: grid;
   grid-template-rows: max-content 1fr;

+ 36 - 9
static/app/views/replays/details.tsx

@@ -21,7 +21,7 @@ import useOrganization from 'sentry/utils/useOrganization';
 import ReplaysLayout from 'sentry/views/replays/detail/layout';
 import Page from 'sentry/views/replays/detail/page';
 import ReplayTransactionContext from 'sentry/views/replays/detail/trace/replayTransactionContext';
-import type {ReplayRecord} from 'sentry/views/replays/types';
+import type {ReplayError, ReplayRecord} from 'sentry/views/replays/types';
 
 type Props = RouteComponentProps<
   {replaySlug: string},
@@ -37,11 +37,19 @@ function ReplayDetails({params: {replaySlug}}: Props) {
 
   // TODO: replayId is known ahead of time and useReplayData is parsing it from the replaySlug
   // once we fix the route params and links we should fix this to accept replayId and stop returning it
-  const {fetching, onRetry, replay, replayRecord, fetchError, projectSlug, replayId} =
-    useReplayData({
-      replaySlug,
-      orgSlug,
-    });
+  const {
+    fetching,
+    onRetry,
+    replay,
+    replayRecord,
+    fetchError,
+    projectSlug,
+    replayId,
+    replayErrors,
+  } = useReplayData({
+    replaySlug,
+    orgSlug,
+  });
 
   const initialTimeOffsetMs = useInitialTimeOffsetMs({
     orgSlug,
@@ -53,7 +61,12 @@ function ReplayDetails({params: {replaySlug}}: Props) {
   if (fetchError) {
     if (fetchError.statusText === 'Not Found') {
       return (
-        <Page orgSlug={orgSlug} replayRecord={replayRecord} projectSlug={projectSlug}>
+        <Page
+          orgSlug={orgSlug}
+          replayRecord={replayRecord}
+          projectSlug={projectSlug}
+          replayErrors={replayErrors}
+        >
           <Layout.Page withPadding>
             <NotFound />
           </Layout.Page>
@@ -67,7 +80,12 @@ function ReplayDetails({params: {replaySlug}}: Props) {
       t('There is an internal systems error'),
     ];
     return (
-      <Page orgSlug={orgSlug} replayRecord={replayRecord} projectSlug={projectSlug}>
+      <Page
+        orgSlug={orgSlug}
+        replayRecord={replayRecord}
+        projectSlug={projectSlug}
+        replayErrors={replayErrors}
+      >
         <Layout.Page>
           <DetailedError
             onRetry={onRetry}
@@ -91,7 +109,12 @@ function ReplayDetails({params: {replaySlug}}: Props) {
 
   if (!fetching && replay && replay.getRRWebEvents().length < 2) {
     return (
-      <Page orgSlug={orgSlug} replayRecord={replayRecord} projectSlug={projectSlug}>
+      <Page
+        orgSlug={orgSlug}
+        replayRecord={replayRecord}
+        projectSlug={projectSlug}
+        replayErrors={replayErrors}
+      >
         <DetailedError
           hideSupportLinks
           heading={t('Error loading replay')}
@@ -125,6 +148,7 @@ function ReplayDetails({params: {replaySlug}}: Props) {
           orgSlug={orgSlug}
           replayRecord={replayRecord}
           projectSlug={projectSlug}
+          replayErrors={replayErrors}
         />
       </ReplayTransactionContext>
     </ReplayContextProvider>
@@ -135,9 +159,11 @@ function DetailsInsideContext({
   orgSlug,
   replayRecord,
   projectSlug,
+  replayErrors,
 }: {
   orgSlug: string;
   projectSlug: string | null;
+  replayErrors: ReplayError[];
   replayRecord: ReplayRecord | undefined;
 }) {
   const {getLayout} = useReplayLayout();
@@ -149,6 +175,7 @@ function DetailsInsideContext({
       crumbs={replay?.getRawCrumbs()}
       replayRecord={replayRecord}
       projectSlug={projectSlug}
+      replayErrors={replayErrors}
     >
       <ReplaysLayout layout={getLayout()} />
     </Page>