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

feat(exception-group): Display related exceptions in UI (#47176)

Closes https://github.com/getsentry/sentry/issues/47085
Malachi Willey 1 год назад
Родитель
Сommit
0aaef53d7b

+ 112 - 0
fixtures/js-stubs/eventEntryExceptionGroup.tsx

@@ -0,0 +1,112 @@
+export function EventEntryExceptionGroup() {
+  return {
+    type: 'exception',
+    data: {
+      values: [
+        {
+          type: 'ValueError',
+          value: 'test',
+          mechanism: {
+            exception_id: 4,
+            is_exception_group: false,
+            parent_id: 3,
+            source: 'exceptions[2]',
+          },
+          stacktrace: {
+            frames: [
+              {
+                function: 'func4',
+                module: 'helpers',
+                filename: 'file4.py',
+                absPath: 'file4.py',
+                lineNo: 50,
+                colNo: null,
+                context: [[50, 'raise ValueError("test")']],
+                inApp: true,
+                data: {},
+              },
+            ],
+          },
+          rawStacktrace: null,
+        },
+        {
+          type: 'ExceptionGroup 2',
+          value: 'child',
+          mechanism: {
+            exception_id: 3,
+            is_exception_group: true,
+            parent_id: 1,
+            source: 'exceptions[1]',
+          },
+          stacktrace: {
+            frames: [
+              {
+                function: 'func3',
+                module: 'helpers',
+                filename: 'file3.py',
+                absPath: 'file3.py',
+                lineNo: 50,
+                colNo: null,
+                context: [],
+                inApp: true,
+                data: {},
+              },
+            ],
+          },
+          rawStacktrace: null,
+        },
+        {
+          type: 'TypeError',
+          value: 'nested',
+          mechanism: {
+            exception_id: 2,
+            is_exception_group: false,
+            parent_id: 1,
+            source: 'exceptions[0]',
+          },
+          stacktrace: {
+            frames: [
+              {
+                function: 'func2',
+                module: 'helpers',
+                filename: 'file2.py',
+                absPath: 'file2.py',
+                lineNo: 50,
+                colNo: null,
+                context: [[50, 'raise TypeError("int")']],
+                inApp: true,
+                data: {},
+              },
+            ],
+          },
+          rawStacktrace: null,
+        },
+        {
+          type: 'ExceptionGroup 1',
+          value: 'parent',
+          mechanism: {
+            exception_id: 1,
+            is_exception_group: true,
+            source: '__context__',
+          },
+          stacktrace: {
+            frames: [
+              {
+                function: 'func1',
+                module: 'helpers',
+                filename: 'file1.py',
+                absPath: 'file1.py',
+                lineNo: 50,
+                colNo: null,
+                context: [[50, 'raise ExceptionGroup("parent")']],
+                inApp: true,
+                data: {},
+              },
+            ],
+          },
+          rawStacktrace: null,
+        },
+      ],
+    },
+  };
+}

+ 2 - 0
fixtures/js-stubs/types.tsx

@@ -1,3 +1,4 @@
+import {EntryException} from 'sentry/types';
 import type {ReplayListRecord, ReplayRecord} from 'sentry/views/replays/types';
 
 type SimpleStub<T = any> = () => T;
@@ -43,6 +44,7 @@ type TestStubFixtures = {
   EventAttachment: OverridableStub;
   EventEntry: OverridableStub;
   EventEntryDebugMeta: OverridableStub;
+  EventEntryExceptionGroup: SimpleStub<EntryException>;
   EventEntryStacktrace: OverridableStub;
   EventIdQueryResult: OverridableStub;
   EventStacktraceException: OverridableStub;

+ 65 - 1
static/app/components/events/interfaces/crashContent/exception/content.spec.tsx

@@ -1,5 +1,5 @@
 import {initializeOrg} from 'sentry-test/initializeOrg';
-import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
+import {render, screen, userEvent, within} from 'sentry-test/reactTestingLibrary';
 import {textWithMarkupMatcher} from 'sentry-test/utils';
 
 import {Content} from 'sentry/components/events/interfaces/crashContent/exception/content';
@@ -145,4 +145,68 @@ describe('Exception Content', function () {
       '/settings/org-slug/projects/project-slug/security-and-privacy/'
     );
   });
+
+  describe('exception groups', function () {
+    const event = TestStubs.Event({entries: [TestStubs.EventEntryExceptionGroup()]});
+    const project = TestStubs.Project();
+
+    const defaultProps = {
+      type: STACK_TYPE.ORIGINAL,
+      hasHierarchicalGrouping: false,
+      newestFirst: true,
+      platform: 'python' as const,
+      stackView: STACK_VIEW.APP,
+      event,
+      values: event.entries[0].data.values,
+      projectSlug: project.slug,
+    };
+
+    it('displays exception group tree in first frame', function () {
+      render(<Content {...defaultProps} />);
+
+      const exceptions = screen.getAllByTestId('exception-value');
+
+      // There are 4 exceptions in the exception group fixture
+      expect(exceptions).toHaveLength(4);
+
+      // First exception should be the parent ExceptionGroup and the tree should be visible
+      // in the top frame context
+      expect(
+        within(exceptions[0]).getByRole('heading', {name: 'ExceptionGroup 1'})
+      ).toBeInTheDocument();
+      const exception1FrameContext = within(exceptions[0]).getByTestId('frame-context');
+      expect(
+        within(exception1FrameContext).getByRole('cell', {name: 'Related Exceptions'})
+      ).toBeInTheDocument();
+    });
+
+    it('displays exception group tree in first frame when sorting by oldest', function () {
+      render(<Content {...defaultProps} newestFirst={false} />);
+
+      const exceptions = screen.getAllByTestId('exception-value');
+
+      // Last exception should be the parent ExceptionGroup and the tree should be visible
+      // in the top frame context
+      expect(
+        within(exceptions[3]).getByRole('heading', {name: 'ExceptionGroup 1'})
+      ).toBeInTheDocument();
+      const exception1FrameContext = within(exceptions[3]).getByTestId('frame-context');
+      expect(
+        within(exception1FrameContext).getByRole('cell', {name: 'Related Exceptions'})
+      ).toBeInTheDocument();
+    });
+
+    it('displays exception group tree in first frame when there is no other context', function () {
+      render(<Content {...defaultProps} />);
+
+      const exceptions = screen.getAllByTestId('exception-value');
+
+      const exceptionGroupWithNoContext = exceptions[2];
+      expect(
+        within(exceptionGroupWithNoContext).getByRole('cell', {
+          name: 'Related Exceptions',
+        })
+      ).toBeInTheDocument();
+    });
+  });
 });

+ 7 - 3
static/app/components/events/interfaces/crashContent/exception/content.tsx

@@ -71,14 +71,17 @@ export function Content({
     const hasSourcemapDebug = debugFrames.some(
       ({query}) => query.exceptionIdx === excIdx
     );
+    const id = defined(exc.mechanism?.exception_id)
+      ? `exception-${exc.mechanism?.exception_id}`
+      : undefined;
     return (
-      <div key={excIdx} className="exception">
+      <div key={excIdx} className="exception" data-test-id="exception-value">
         {defined(exc?.module) ? (
           <Tooltip title={tct('from [exceptionModule]', {exceptionModule: exc?.module})}>
-            <Title>{exc.type}</Title>
+            <Title id={id}>{exc.type}</Title>
           </Tooltip>
         ) : (
-          <Title>{exc.type}</Title>
+          <Title id={id}>{exc.type}</Title>
         )}
         <StyledPre className="exc-message">
           {meta?.[excIdx]?.value?.[''] && !exc.value ? (
@@ -112,6 +115,7 @@ export function Content({
           groupingCurrentLevel={groupingCurrentLevel}
           meta={meta?.[excIdx]?.stacktrace}
           debugFrames={hasSourcemapDebug ? debugFrames : undefined}
+          mechanism={exc.mechanism}
         />
       </div>
     );

+ 5 - 1
static/app/components/events/interfaces/crashContent/exception/mechanism.tsx

@@ -21,7 +21,7 @@ type Props = {
 };
 
 export function Mechanism({data: mechanism, meta: mechanismMeta}: Props) {
-  const {type, description, help_link, handled, meta = {}, data = {}} = mechanism;
+  const {type, description, help_link, handled, source, meta = {}, data = {}} = mechanism;
 
   const {errno, signal, mach_exception} = meta;
 
@@ -73,6 +73,10 @@ export function Mechanism({data: mechanism, meta: mechanismMeta}: Props) {
     pills.push(<Pill key="mach" name="mach exception" value={value} />);
   }
 
+  if (source) {
+    pills.push(<Pill key="source" name="source" value={source} />);
+  }
+
   if (signal) {
     const code = signal.code_name || `${t('code')} ${signal.code}`;
     const name = signal.name || signal.number;

+ 1 - 0
static/app/components/events/interfaces/crashContent/exception/stackTrace.spec.tsx

@@ -88,6 +88,7 @@ const props: React.ComponentProps<typeof ExceptionStacktraceContent> = {
   hasHierarchicalGrouping: false,
   groupingCurrentLevel: undefined,
   meta: undefined,
+  mechanism: null,
 };
 
 describe('ExceptionStacktraceContent', function () {

+ 5 - 1
static/app/components/events/interfaces/crashContent/exception/stackTrace.tsx

@@ -5,7 +5,7 @@ import {IconWarning} from 'sentry/icons';
 import {t} from 'sentry/locale';
 import {ExceptionValue, Group, PlatformType} from 'sentry/types';
 import {Event} from 'sentry/types/event';
-import {STACK_VIEW} from 'sentry/types/stacktrace';
+import {STACK_VIEW, StackTraceMechanism} from 'sentry/types/stacktrace';
 import {defined} from 'sentry/utils';
 import {isNativePlatform} from 'sentry/utils/platform';
 
@@ -18,6 +18,7 @@ type Props = {
   data: ExceptionValue['stacktrace'];
   event: Event;
   hasHierarchicalGrouping: boolean;
+  mechanism: StackTraceMechanism | null;
   platform: PlatformType;
   stacktrace: ExceptionValue['stacktrace'];
   debugFrames?: StacktraceFilenameQuery[];
@@ -41,6 +42,7 @@ function StackTrace({
   expandFirstFrame,
   event,
   meta,
+  mechanism,
 }: Props) {
   if (!defined(stacktrace)) {
     return null;
@@ -109,6 +111,7 @@ function StackTrace({
         event={event}
         meta={meta}
         debugFrames={debugFrames}
+        mechanism={mechanism}
       />
     );
   }
@@ -123,6 +126,7 @@ function StackTrace({
       event={event}
       meta={meta}
       debugFrames={debugFrames}
+      mechanism={mechanism}
     />
   );
 }

+ 5 - 2
static/app/components/events/interfaces/crashContent/stackTrace/content.tsx

@@ -7,7 +7,7 @@ import Panel from 'sentry/components/panels/panel';
 import {t} from 'sentry/locale';
 import {Frame, Organization, PlatformType} from 'sentry/types';
 import {Event} from 'sentry/types/event';
-import {StacktraceType} from 'sentry/types/stacktrace';
+import {StackTraceMechanism, StacktraceType} from 'sentry/types/stacktrace';
 import {defined} from 'sentry/utils';
 import withOrganization from 'sentry/utils/withOrganization';
 
@@ -30,6 +30,7 @@ type Props = {
   hideIcon?: boolean;
   isHoverPreviewed?: boolean;
   maxDepth?: number;
+  mechanism?: StackTraceMechanism | null;
   meta?: Record<any, any>;
   newestFirst?: boolean;
   organization?: Organization;
@@ -146,6 +147,7 @@ class Content extends Component<Props, State> {
       meta,
       debugFrames,
       hideIcon,
+      mechanism,
     } = this.props;
 
     const {showingAbsoluteAddresses, showCompleteFunctionName} = this.state;
@@ -238,10 +240,11 @@ class Content extends Component<Props, State> {
             onFunctionNameToggle={this.handleToggleFunctionName}
             showCompleteFunctionName={showCompleteFunctionName}
             isHoverPreviewed={isHoverPreviewed}
-            isFirst={newestFirst ? frameIdx === lastFrameIdx : frameIdx === 0}
+            isNewestFrame={frameIdx === lastFrameIdx}
             frameMeta={meta?.frames?.[frameIdx]}
             registersMeta={meta?.registers}
             debugFrames={debugFrames}
+            mechanism={mechanism}
           />
         );
       }

+ 5 - 1
static/app/components/events/interfaces/crashContent/stackTrace/hierarchicalGroupingContent.tsx

@@ -7,7 +7,7 @@ import Panel from 'sentry/components/panels/panel';
 import {t} from 'sentry/locale';
 import {Frame, Group, PlatformType} from 'sentry/types';
 import {Event} from 'sentry/types/event';
-import {StacktraceType} from 'sentry/types/stacktrace';
+import {StackTraceMechanism, StacktraceType} from 'sentry/types/stacktrace';
 import {defined} from 'sentry/utils';
 
 import Line from '../../frame/line';
@@ -28,6 +28,7 @@ type Props = {
   includeSystemFrames?: boolean;
   isHoverPreviewed?: boolean;
   maxDepth?: number;
+  mechanism?: StackTraceMechanism | null;
   meta?: Record<any, any>;
   newestFirst?: boolean;
 };
@@ -46,6 +47,7 @@ export function HierarchicalGroupingContent({
   hideIcon,
   includeSystemFrames = true,
   expandFirstFrame = true,
+  mechanism,
 }: Props) {
   const [showingAbsoluteAddresses, setShowingAbsoluteAddresses] = useState(false);
   const [showCompleteFunctionName, setShowCompleteFunctionName] = useState(false);
@@ -205,6 +207,8 @@ export function HierarchicalGroupingContent({
             frameMeta: meta?.frames?.[frameIndex],
             registersMeta: meta?.registers,
             debugFrames,
+            mechanism,
+            isNewestFrame: frameIndex === lastFrameIndex,
           };
 
           nRepeats = 0;

+ 18 - 1
static/app/components/events/interfaces/frame/context.tsx

@@ -4,7 +4,9 @@ import keyBy from 'lodash/keyBy';
 
 import ClippedBox from 'sentry/components/clippedBox';
 import ErrorBoundary from 'sentry/components/errorBoundary';
+import {ExceptionGroupContext} from 'sentry/components/events/interfaces/frame/exceptionGroupContext';
 import {StacktraceLink} from 'sentry/components/events/interfaces/frame/stacktraceLink';
+import {hasExceptionGroupTree} from 'sentry/components/events/interfaces/frame/utils';
 import {IconFlag} from 'sentry/icons';
 import {t} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
@@ -15,6 +17,7 @@ import {
   LineCoverage,
   Organization,
   SentryAppComponent,
+  StackTraceMechanism,
 } from 'sentry/types';
 import {Event} from 'sentry/types/event';
 import {defined} from 'sentry/utils';
@@ -45,6 +48,9 @@ type Props = {
   hasContextSource?: boolean;
   hasContextVars?: boolean;
   isExpanded?: boolean;
+  isFirst?: boolean;
+  isNewestFrame?: boolean;
+  mechanism?: StackTraceMechanism | null;
   organization?: Organization;
   registersMeta?: Record<any, any>;
 };
@@ -80,6 +86,8 @@ function Context({
   className,
   frameMeta,
   registersMeta,
+  mechanism,
+  isNewestFrame,
 }: Props) {
   const {projects} = useProjects();
   const project = useMemo(
@@ -127,7 +135,13 @@ function Context({
       : {}
   );
 
-  if (!hasContextSource && !hasContextVars && !hasContextRegisters && !hasAssembly) {
+  if (
+    !hasContextSource &&
+    !hasContextVars &&
+    !hasContextRegisters &&
+    !hasAssembly &&
+    !hasExceptionGroupTree({isNewestFrame, mechanism})
+  ) {
     return emptySourceNotation ? (
       <EmptyContext>
         <StyledIconFlag size="xs" />
@@ -148,6 +162,7 @@ function Context({
       start={startLineNo}
       startLineNo={startLineNo}
       className={`${className} context ${isExpanded ? 'expanded' : ''}`}
+      data-test-id="frame-context"
     >
       {defined(frame.errors) && (
         <li className={expandable ? 'expandable error' : 'error'} key="errors">
@@ -192,6 +207,8 @@ function Context({
           );
         })}
 
+      <ExceptionGroupContext {...{event, isNewestFrame, mechanism}} />
+
       {hasContextVars && (
         <StyledClippedBox clipHeight={100}>
           <FrameVariables data={frame.vars ?? {}} meta={frameMeta?.vars} />

Некоторые файлы не были показаны из-за большого количества измененных файлов