Browse Source

feat(stacktrace): Add toggle on Native stacktrace for non-'In App' frames (#53313)

Closes https://github.com/getsentry/sentry/issues/53300

Add toggle for show/hide x more frame(s) on stacktrace for hidden
non-inApp frames.
![Screenshot 2023-07-20 at 3 42 18
PM](https://github.com/getsentry/sentry/assets/22582037/840b7202-1436-4709-80d7-ce1036915651)
![Screenshot 2023-07-20 at 3 42 28
PM](https://github.com/getsentry/sentry/assets/22582037/aec77325-9639-4ac9-8344-d00e14c338ae)
Julia Hoge 1 year ago
parent
commit
7943160ee0

+ 118 - 17
static/app/components/events/interfaces/crashContent/stackTrace/nativeContent.tsx

@@ -18,14 +18,30 @@ type Props = {
   platform: PlatformType;
   platform: PlatformType;
   expandFirstFrame?: boolean;
   expandFirstFrame?: boolean;
   groupingCurrentLevel?: Group['metadata']['current_level'];
   groupingCurrentLevel?: Group['metadata']['current_level'];
+  hiddenFrameCount?: number;
   includeSystemFrames?: boolean;
   includeSystemFrames?: boolean;
   inlined?: boolean;
   inlined?: boolean;
   isHoverPreviewed?: boolean;
   isHoverPreviewed?: boolean;
+  isShowFramesToggleExpanded?: boolean;
+  isSubFrame?: boolean;
   maxDepth?: number;
   maxDepth?: number;
   meta?: Record<any, any>;
   meta?: Record<any, any>;
   newestFirst?: boolean;
   newestFirst?: boolean;
 };
 };
 
 
+function isRepeatedFrame(frame: Frame, nextFrame?: Frame) {
+  if (!nextFrame) {
+    return false;
+  }
+  return (
+    frame.lineNo === nextFrame.lineNo &&
+    frame.instructionAddr === nextFrame.instructionAddr &&
+    frame.package === nextFrame.package &&
+    frame.module === nextFrame.module &&
+    frame.function === nextFrame.function
+  );
+}
+
 export function NativeContent({
 export function NativeContent({
   data,
   data,
   platform,
   platform,
@@ -41,9 +57,86 @@ export function NativeContent({
 }: Props) {
 }: Props) {
   const [showingAbsoluteAddresses, setShowingAbsoluteAddresses] = useState(false);
   const [showingAbsoluteAddresses, setShowingAbsoluteAddresses] = useState(false);
   const [showCompleteFunctionName, setShowCompleteFunctionName] = useState(false);
   const [showCompleteFunctionName, setShowCompleteFunctionName] = useState(false);
+  const [toggleFrameMap, setToggleFrameMap] = useState(setInitialFrameMap());
 
 
   const {frames = [], framesOmitted, registers} = data;
   const {frames = [], framesOmitted, registers} = data;
 
 
+  function frameIsVisible(frame: Frame, nextFrame: Frame) {
+    return (
+      includeSystemFrames ||
+      frame.inApp ||
+      (nextFrame && nextFrame.inApp) ||
+      // the last non-app frame
+      (!frame.inApp && !nextFrame) ||
+      isFrameUsedForGrouping(frame)
+    );
+  }
+
+  function setInitialFrameMap(): {[frameIndex: number]: boolean} {
+    const indexMap = {};
+    (data.frames ?? []).forEach((frame, frameIdx) => {
+      const nextFrame = (data.frames ?? [])[frameIdx + 1];
+      const repeatedFrame = isRepeatedFrame(frame, nextFrame);
+      if (frameIsVisible(frame, nextFrame) && !repeatedFrame && !frame.inApp) {
+        indexMap[frameIdx] = false;
+      }
+    });
+    return indexMap;
+  }
+
+  function getInitialFrameCounts(): {[frameIndex: number]: number} {
+    let count = 0;
+    const countMap = {};
+    (data.frames ?? []).forEach((frame, frameIdx) => {
+      const nextFrame = (data.frames ?? [])[frameIdx + 1];
+      const repeatedFrame = isRepeatedFrame(frame, nextFrame);
+      if (frameIsVisible(frame, nextFrame) && !repeatedFrame && !frame.inApp) {
+        countMap[frameIdx] = count;
+        count = 0;
+      } else {
+        if (!repeatedFrame && !frame.inApp) {
+          count += 1;
+        }
+      }
+    });
+    return countMap;
+  }
+
+  function getRepeatedFrameIndices() {
+    const repeats: number[] = [];
+    (data.frames ?? []).forEach((frame, frameIdx) => {
+      const nextFrame = (data.frames ?? [])[frameIdx + 1];
+      const repeatedFrame = isRepeatedFrame(frame, nextFrame);
+
+      if (repeatedFrame) {
+        repeats.push(frameIdx);
+      }
+    });
+    return repeats;
+  }
+
+  function getHiddenFrameIndices(frameCountMap: {[frameIndex: number]: number}) {
+    const repeatedIndeces = getRepeatedFrameIndices();
+    let hiddenFrameIndices: number[] = [];
+    Object.keys(toggleFrameMap)
+      .filter(frameIndex => toggleFrameMap[frameIndex] === true)
+      .forEach(indexString => {
+        const index = parseInt(indexString, 10);
+        const indicesToBeAdded: number[] = [];
+        let i = 1;
+        let numHidden = frameCountMap[index];
+        while (numHidden > 0) {
+          if (!repeatedIndeces.includes(index - i)) {
+            indicesToBeAdded.push(index - i);
+            numHidden -= 1;
+          }
+          i += 1;
+        }
+        hiddenFrameIndices = [...hiddenFrameIndices, ...indicesToBeAdded];
+      });
+    return hiddenFrameIndices;
+  }
+
   function findImageForAddress(
   function findImageForAddress(
     address: Frame['instructionAddr'],
     address: Frame['instructionAddr'],
     addrMode: Frame['addrMode']
     addrMode: Frame['addrMode']
@@ -86,6 +179,18 @@ export function NativeContent({
     setShowCompleteFunctionName(!showCompleteFunctionName);
     setShowCompleteFunctionName(!showCompleteFunctionName);
   }
   }
 
 
+  const handleToggleFrames = (
+    mouseEvent: React.MouseEvent<HTMLElement>,
+    frameIndex: number
+  ) => {
+    mouseEvent.stopPropagation(); // to prevent toggling frame context
+
+    setToggleFrameMap(prevState => ({
+      ...prevState,
+      [frameIndex]: !prevState[frameIndex],
+    }));
+  };
+
   function getLastFrameIndex() {
   function getLastFrameIndex() {
     const inAppFrameIndexes = frames
     const inAppFrameIndexes = frames
       .map((frame, frameIndex) => {
       .map((frame, frameIndex) => {
@@ -112,6 +217,8 @@ export function NativeContent({
   const firstFrameOmitted = framesOmitted?.[0] ?? null;
   const firstFrameOmitted = framesOmitted?.[0] ?? null;
   const lastFrameOmitted = framesOmitted?.[1] ?? null;
   const lastFrameOmitted = framesOmitted?.[1] ?? null;
   const lastFrameIndex = getLastFrameIndex();
   const lastFrameIndex = getLastFrameIndex();
+  const frameCountMap = getInitialFrameCounts();
+  const hiddenFrameIndices: number[] = getHiddenFrameIndices(frameCountMap);
 
 
   let nRepeats = 0;
   let nRepeats = 0;
 
 
@@ -142,14 +249,7 @@ export function NativeContent({
     .map((frame, frameIndex) => {
     .map((frame, frameIndex) => {
       const prevFrame = frames[frameIndex - 1];
       const prevFrame = frames[frameIndex - 1];
       const nextFrame = frames[frameIndex + 1];
       const nextFrame = frames[frameIndex + 1];
-
-      const repeatedFrame =
-        nextFrame &&
-        frame.lineNo === nextFrame.lineNo &&
-        frame.instructionAddr === nextFrame.instructionAddr &&
-        frame.package === nextFrame.package &&
-        frame.module === nextFrame.module &&
-        frame.function === nextFrame.function;
+      const repeatedFrame = isRepeatedFrame(frame, nextFrame);
 
 
       if (repeatedFrame) {
       if (repeatedFrame) {
         nRepeats++;
         nRepeats++;
@@ -157,15 +257,10 @@ export function NativeContent({
 
 
       const isUsedForGrouping = isFrameUsedForGrouping(frame);
       const isUsedForGrouping = isFrameUsedForGrouping(frame);
 
 
-      const isVisible =
-        includeSystemFrames ||
-        frame.inApp ||
-        (nextFrame && nextFrame.inApp) ||
-        // the last non-app frame
-        (!frame.inApp && !nextFrame) ||
-        isUsedForGrouping;
-
-      if (isVisible && !repeatedFrame) {
+      if (
+        (frameIsVisible(frame, nextFrame) && !repeatedFrame) ||
+        hiddenFrameIndices.includes(frameIndex)
+      ) {
         const frameProps = {
         const frameProps = {
           event,
           event,
           frame,
           frame,
@@ -179,13 +274,19 @@ export function NativeContent({
           timesRepeated: nRepeats,
           timesRepeated: nRepeats,
           showingAbsoluteAddress: showingAbsoluteAddresses,
           showingAbsoluteAddress: showingAbsoluteAddresses,
           onAddressToggle: handleToggleAddresses,
           onAddressToggle: handleToggleAddresses,
+          onShowFramesToggle: (e: React.MouseEvent<HTMLElement>) => {
+            handleToggleFrames(e, frameIndex);
+          },
           image: findImageForAddress(frame.instructionAddr, frame.addrMode),
           image: findImageForAddress(frame.instructionAddr, frame.addrMode),
           maxLengthOfRelativeAddress: maxLengthOfAllRelativeAddresses,
           maxLengthOfRelativeAddress: maxLengthOfAllRelativeAddresses,
           registers: {},
           registers: {},
           includeSystemFrames,
           includeSystemFrames,
           onFunctionNameToggle: handleToggleFunctionName,
           onFunctionNameToggle: handleToggleFunctionName,
           showCompleteFunctionName,
           showCompleteFunctionName,
+          hiddenFrameCount: frameCountMap[frameIndex],
           isHoverPreviewed,
           isHoverPreviewed,
+          isShowFramesToggleExpanded: toggleFrameMap[frameIndex],
+          isSubFrame: hiddenFrameIndices.includes(frameIndex),
           isUsedForGrouping,
           isUsedForGrouping,
           frameMeta: meta?.frames?.[frameIndex],
           frameMeta: meta?.frames?.[frameIndex],
           registersMeta: meta?.registers,
           registersMeta: meta?.registers,

+ 49 - 3
static/app/components/events/interfaces/nativeFrame.tsx

@@ -23,7 +23,7 @@ import {IconChevron} from 'sentry/icons/iconChevron';
 import {IconFileBroken} from 'sentry/icons/iconFileBroken';
 import {IconFileBroken} from 'sentry/icons/iconFileBroken';
 import {IconRefresh} from 'sentry/icons/iconRefresh';
 import {IconRefresh} from 'sentry/icons/iconRefresh';
 import {IconWarning} from 'sentry/icons/iconWarning';
 import {IconWarning} from 'sentry/icons/iconWarning';
-import {t} from 'sentry/locale';
+import {t, tn} from 'sentry/locale';
 import DebugMetaStore from 'sentry/stores/debugMetaStore';
 import DebugMetaStore from 'sentry/stores/debugMetaStore';
 import {space} from 'sentry/styles/space';
 import {space} from 'sentry/styles/space';
 import {Frame, PlatformType, SentryAppComponent} from 'sentry/types';
 import {Frame, PlatformType, SentryAppComponent} from 'sentry/types';
@@ -46,15 +46,23 @@ type Props = {
   registers: Record<string, string>;
   registers: Record<string, string>;
   emptySourceNotation?: boolean;
   emptySourceNotation?: boolean;
   frameMeta?: Record<any, any>;
   frameMeta?: Record<any, any>;
+  hiddenFrameCount?: number;
   image?: React.ComponentProps<typeof DebugImage>['image'];
   image?: React.ComponentProps<typeof DebugImage>['image'];
   includeSystemFrames?: boolean;
   includeSystemFrames?: boolean;
   isExpanded?: boolean;
   isExpanded?: boolean;
   isHoverPreviewed?: boolean;
   isHoverPreviewed?: boolean;
   isOnlyFrame?: boolean;
   isOnlyFrame?: boolean;
+  isShowFramesToggleExpanded?: boolean;
+  /**
+   * Frames that are hidden under the most recent non-InApp frame
+   */
+  isSubFrame?: boolean;
   maxLengthOfRelativeAddress?: number;
   maxLengthOfRelativeAddress?: number;
   nextFrame?: Frame;
   nextFrame?: Frame;
+  onShowFramesToggle?: (event: React.MouseEvent<HTMLElement>) => void;
   prevFrame?: Frame;
   prevFrame?: Frame;
   registersMeta?: Record<any, any>;
   registersMeta?: Record<any, any>;
+  showStackedFrames?: boolean;
 };
 };
 
 
 function NativeFrame({
 function NativeFrame({
@@ -69,6 +77,10 @@ function NativeFrame({
   isOnlyFrame,
   isOnlyFrame,
   event,
   event,
   components,
   components,
+  hiddenFrameCount,
+  isShowFramesToggleExpanded,
+  isSubFrame,
+  onShowFramesToggle,
   isExpanded,
   isExpanded,
   platform,
   platform,
   registersMeta,
   registersMeta,
@@ -228,6 +240,7 @@ function NativeFrame({
           expandable={expandable}
           expandable={expandable}
           expanded={expanded}
           expanded={expanded}
           stacktraceChangesEnabled={stacktraceChangesEnabled && !frame.inApp}
           stacktraceChangesEnabled={stacktraceChangesEnabled && !frame.inApp}
+          isSubFrame={!!isSubFrame}
         >
         >
           <SymbolicatorIcon>
           <SymbolicatorIcon>
             {status === 'error' ? (
             {status === 'error' ? (
@@ -306,6 +319,25 @@ function NativeFrame({
               </Tooltip>
               </Tooltip>
             )}
             )}
           </GroupingCell>
           </GroupingCell>
+          {stacktraceChangesEnabled && hiddenFrameCount ? (
+            <ShowHideButton
+              analyticsEventName="Stacktrace Frames: toggled"
+              analyticsEventKey="stacktrace_frames.toggled"
+              analyticsParams={{
+                frame_count: hiddenFrameCount,
+                is_frame_expanded: isShowFramesToggleExpanded,
+              }}
+              size="xs"
+              borderless
+              onClick={e => {
+                onShowFramesToggle?.(e);
+              }}
+            >
+              {isShowFramesToggleExpanded
+                ? tn('Hide %s more frame', 'Hide %s more frames', hiddenFrameCount)
+                : tn('Show %s more frame', 'Show %s more frames', hiddenFrameCount)}
+            </ShowHideButton>
+          ) : null}
           <TypeCell>
           <TypeCell>
             {!frame.inApp ? (
             {!frame.inApp ? (
               stacktraceChangesEnabled ? null : (
               stacktraceChangesEnabled ? null : (
@@ -423,6 +455,7 @@ const FileName = styled('span')`
 const RowHeader = styled('span')<{
 const RowHeader = styled('span')<{
   expandable: boolean;
   expandable: boolean;
   expanded: boolean;
   expanded: boolean;
+  isSubFrame: boolean;
   stacktraceChangesEnabled: boolean;
   stacktraceChangesEnabled: boolean;
 }>`
 }>`
   display: grid;
   display: grid;
@@ -431,7 +464,10 @@ const RowHeader = styled('span')<{
   align-items: center;
   align-items: center;
   align-content: center;
   align-content: center;
   column-gap: ${space(1)};
   column-gap: ${space(1)};
-  background-color: ${p => p.theme.bodyBackground};
+  background-color: ${p =>
+    p.stacktraceChangesEnabled && p.isSubFrame
+      ? `${p.theme.surface100}`
+      : `${p.theme.bodyBackground}`};
   font-size: ${p => p.theme.codeFontSize};
   font-size: ${p => p.theme.codeFontSize};
   padding: ${space(1)};
   padding: ${space(1)};
   color: ${p => (p.stacktraceChangesEnabled ? p.theme.subText : '')};
   color: ${p => (p.stacktraceChangesEnabled ? p.theme.subText : '')};
@@ -439,7 +475,7 @@ const RowHeader = styled('span')<{
   ${p => p.expandable && `cursor: pointer;`};
   ${p => p.expandable && `cursor: pointer;`};
 
 
   @media (min-width: ${p => p.theme.breakpoints.small}) {
   @media (min-width: ${p => p.theme.breakpoints.small}) {
-    grid-template-columns: auto 150px 120px 4fr auto auto ${space(2)};
+    grid-template-columns: auto 150px 120px 4fr repeat(2, auto) ${space(2)};
     padding: ${space(0.5)} ${space(1.5)};
     padding: ${space(0.5)} ${space(1.5)};
     min-height: 32px;
     min-height: 32px;
   }
   }
@@ -456,3 +492,13 @@ const StackTraceFrame = styled('li')`
 const SymbolicatorIcon = styled('div')`
 const SymbolicatorIcon = styled('div')`
   width: ${p => p.theme.iconSizes.sm};
   width: ${p => p.theme.iconSizes.sm};
 `;
 `;
+
+const ShowHideButton = styled(Button)`
+  color: ${p => p.theme.subText};
+  font-style: italic;
+  font-weight: normal;
+  padding: ${space(0.25)} ${space(0.5)};
+  &:hover {
+    color: ${p => p.theme.subText};
+  }
+`;