Browse Source

feat(issue-details): Adds in actionable items widget (#55360)

this pr adds in a new widget to show on the front end to replace the
existing source maps debug alert and the event errors alert. As of now,
it will only show event errors, source maps alerts will be added later
when we have more trust in the backend logic.

<img width="1237" alt="Screenshot 2023-08-29 at 3 46 31 PM"
src="https://github.com/getsentry/sentry/assets/46740234/a196f89f-d82e-4798-821e-c804ce820102">


Closes https://github.com/getsentry/sentry/issues/53169
Richard Roggenkemper 1 year ago
parent
commit
23b2a84b4e

+ 154 - 0
static/app/components/events/interfaces/crashContent/exception/actionableItems.spec.tsx

@@ -0,0 +1,154 @@
+import {render, screen} from 'sentry-test/reactTestingLibrary';
+
+import {ActionableItems} from 'sentry/components/events/interfaces/crashContent/exception/actionableItems';
+import {EntryType} from 'sentry/types';
+
+describe('Actionable Items', () => {
+  const organization = TestStubs.Organization({});
+  const project = TestStubs.Project();
+
+  const url = `/projects/${organization.slug}/${project.slug}/events/1/actionable-items/`;
+
+  const defaultProps = {
+    project: TestStubs.Project(),
+    event: TestStubs.Event(),
+    isShare: false,
+  };
+
+  beforeEach(() => {
+    jest.resetAllMocks();
+    MockApiClient.clearMockResponses();
+  });
+
+  it('does not render anything when no errors', () => {
+    MockApiClient.addMockResponse({
+      url,
+      body: {
+        errors: [],
+      },
+      method: 'GET',
+    });
+
+    const {container} = render(<ActionableItems {...defaultProps} />);
+    expect(container).toBeEmptyDOMElement();
+  });
+
+  it('renders with errors in event', async () => {
+    const eventErrors = [
+      {
+        type: 'invalid_data',
+        data: {
+          name: 'logentry',
+        },
+        message: 'no message present',
+      },
+      {
+        type: 'invalid_data',
+        data: {
+          name: 'breadcrumbs.values.2.data',
+        },
+        message: 'expected an object',
+      },
+    ];
+
+    MockApiClient.addMockResponse({
+      url,
+      body: {
+        errors: eventErrors,
+      },
+      method: 'GET',
+    });
+
+    const eventWithErrors = TestStubs.Event({
+      errors: eventErrors,
+    });
+
+    render(<ActionableItems {...defaultProps} event={eventWithErrors} />);
+
+    expect(await screen.findByText('Discarded invalid value (2)')).toBeInTheDocument();
+    expect(await screen.findByText('Fix Processing Error')).toBeInTheDocument();
+  });
+
+  it('does not render hidden cocoa errors', async () => {
+    const eventErrors = [
+      {
+        type: 'invalid_attribute',
+        data: {
+          name: 'logentry',
+        },
+        message: 'no message present',
+      },
+      {
+        type: 'invalid_data',
+        data: {
+          name: 'contexts.trace.sampled',
+        },
+        message: 'expected an object',
+      },
+    ];
+
+    MockApiClient.addMockResponse({
+      url,
+      body: {
+        errors: eventErrors,
+      },
+      method: 'GET',
+    });
+
+    const eventWithErrors = TestStubs.Event({
+      errors: eventErrors,
+      sdk: {
+        name: 'sentry.cocoa',
+        version: '8.7.3',
+      },
+    });
+
+    render(<ActionableItems {...defaultProps} event={eventWithErrors} />);
+
+    expect(
+      await screen.findByText('Discarded unknown attribute (1)')
+    ).toBeInTheDocument();
+    expect(await screen.findByText('Fix Processing Error')).toBeInTheDocument();
+  });
+
+  it('displays missing mapping file', async () => {
+    const eventError = [
+      {
+        type: 'proguard_missing_mapping',
+        message: 'A proguard mapping file was missing.',
+        data: {mapping_uuid: 'a59c8fcc-2f27-49f8-af9e-02661fc3e8d7'},
+      },
+    ];
+    const eventWithDebugMeta = TestStubs.Event({
+      platform: 'java',
+      entries: [
+        {
+          type: EntryType.DEBUGMETA,
+          data: {
+            images: [{type: 'proguard', uuid: 'a59c8fcc-2f27-49f8-af9e-02661fc3e8d7'}],
+          },
+        },
+      ],
+      errors: eventError,
+    });
+
+    MockApiClient.addMockResponse({
+      url,
+      body: {
+        errors: eventError,
+      },
+      method: 'GET',
+    });
+    MockApiClient.addMockResponse({
+      url: `/projects/org-slug/project-slug/files/dsyms/`,
+      body: [],
+    });
+
+    render(<ActionableItems {...defaultProps} event={eventWithDebugMeta} />);
+
+    expect(
+      await screen.findByText('A proguard mapping file was missing (1)')
+    ).toBeInTheDocument();
+    expect(await screen.findByText('Fix Proguard Processing Error')).toBeInTheDocument();
+  });
+});

+ 506 - 0
static/app/components/events/interfaces/crashContent/exception/actionableItems.tsx

@@ -0,0 +1,506 @@
+import React, {Fragment, useEffect, useMemo, useState} from 'react';
+import styled from '@emotion/styled';
+import startCase from 'lodash/startCase';
+import moment from 'moment';
+
+import Alert from 'sentry/components/alert';
+import {Button} from 'sentry/components/button';
+import {EventErrorData} from 'sentry/components/events/errorItem';
+import KeyValueList from 'sentry/components/events/interfaces/keyValueList';
+import List from 'sentry/components/list';
+import ListItem from 'sentry/components/list/listItem';
+import {
+  GenericSchemaErrors,
+  HttpProcessingErrors,
+  JavascriptProcessingErrors,
+  NativeProcessingErrors,
+  ProguardProcessingErrors,
+} from 'sentry/constants/eventErrors';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import {Event, Project} from 'sentry/types';
+import {defined} from 'sentry/utils';
+import {trackAnalytics} from 'sentry/utils/analytics';
+import {getAnalyticsDataForEvent} from 'sentry/utils/events';
+import useRouteAnalyticsParams from 'sentry/utils/routeAnalytics/useRouteAnalyticsParams';
+import useOrganization from 'sentry/utils/useOrganization';
+
+import {
+  ActionableItemErrors,
+  ActionableItemTypes,
+  ActionableItemWarning,
+  shouldErrorBeShown,
+  useFetchProguardMappingFiles,
+} from './actionableItemsUtils';
+import {ActionableItemsResponse, useActionableItems} from './useActionableItems';
+
+interface ErrorMessage {
+  desc: React.ReactNode;
+  expandTitle: string;
+  title: string;
+  data?: {
+    absPath?: string;
+    image_path?: string;
+    mage_name?: string;
+    message?: string;
+    name?: string;
+    partialMatchPath?: string;
+    sdk_time?: string;
+    server_time?: string;
+    url?: string;
+    urlPrefix?: string;
+  } & Record<string, any>;
+  meta?: Record<string, any>;
+}
+
+const keyMapping = {
+  image_uuid: 'Debug ID',
+  image_name: 'File Name',
+  image_path: 'File Path',
+};
+
+function getErrorMessage(
+  error: ActionableItemErrors | EventErrorData,
+  meta?: Record<string, any>
+): Array<ErrorMessage> {
+  const errorData = error.data ?? {};
+  const metaData = meta ?? {};
+  switch (error.type) {
+    // Event Errors
+    case ProguardProcessingErrors.PROGUARD_MISSING_LINENO:
+      return [
+        {
+          title: t('A proguard mapping file does not contain line info'),
+          desc: null,
+          expandTitle: t('Fix Proguard Processing Error'),
+          data: errorData,
+          meta: metaData,
+        },
+      ];
+    case ProguardProcessingErrors.PROGUARD_MISSING_MAPPING:
+      return [
+        {
+          title: t('A proguard mapping file was missing'),
+          desc: null,
+          expandTitle: t('Fix Proguard Processing Error'),
+          data: errorData,
+          meta: metaData,
+        },
+      ];
+    case NativeProcessingErrors.NATIVE_MISSING_OPTIONALLY_BUNDLED_DSYM:
+      return [
+        {
+          title: t('An optional debug information file was missing'),
+          desc: null,
+          expandTitle: t('Fix Native Processing Error'),
+          data: errorData,
+          meta: metaData,
+        },
+      ];
+
+    case NativeProcessingErrors.NATIVE_MISSING_DSYM:
+      return [
+        {
+          title: t('A required debug information file was missing'),
+          desc: null,
+          expandTitle: t('Fix Native Processing Error'),
+          data: errorData,
+          meta: metaData,
+        },
+      ];
+    case NativeProcessingErrors.NATIVE_BAD_DSYM:
+      return [
+        {
+          title: t('The debug information file used was broken'),
+          desc: null,
+          expandTitle: t('Fix Native Processing Error'),
+          data: errorData,
+          meta: metaData,
+        },
+      ];
+    case JavascriptProcessingErrors.JS_MISSING_SOURCES_CONTEXT:
+      return [
+        {
+          title: t('Missing Sources Context'),
+          desc: null,
+          expandTitle: t('Fix Processing Error'),
+          data: errorData,
+          meta: metaData,
+        },
+      ];
+    case HttpProcessingErrors.FETCH_GENERIC_ERROR:
+      return [
+        {
+          title: t('Unable to fetch HTTP resource'),
+          desc: null,
+          expandTitle: t('Fix Processing Error'),
+          data: errorData,
+          meta: metaData,
+        },
+      ];
+    case HttpProcessingErrors.RESTRICTED_IP:
+      return [
+        {
+          title: t('Cannot fetch resource due to restricted IP address'),
+          desc: null,
+          expandTitle: t('Fix Processing Error'),
+          data: errorData,
+          meta: metaData,
+        },
+      ];
+    case HttpProcessingErrors.SECURITY_VIOLATION:
+      return [
+        {
+          title: t('Cannot fetch resource due to security violation'),
+          desc: null,
+          expandTitle: t('Fix Processing Error'),
+          data: errorData,
+          meta: metaData,
+        },
+      ];
+    case GenericSchemaErrors.FUTURE_TIMESTAMP:
+      return [
+        {
+          title: t('Invalid timestamp (in future)'),
+          desc: null,
+          expandTitle: t('Fix Processing Error'),
+          data: errorData,
+          meta: metaData,
+        },
+      ];
+
+    case GenericSchemaErrors.CLOCK_DRIFT:
+      return [
+        {
+          title: t('Clock drift detected in SDK'),
+          desc: null,
+          expandTitle: t('Fix Processing Error'),
+          data: errorData,
+          meta: metaData,
+        },
+      ];
+    case GenericSchemaErrors.PAST_TIMESTAMP:
+      return [
+        {
+          title: t('Invalid timestamp (too old)'),
+          desc: null,
+          expandTitle: t('Fix Processing Error'),
+          data: errorData,
+          meta: metaData,
+        },
+      ];
+    case GenericSchemaErrors.VALUE_TOO_LONG:
+      return [
+        {
+          title: t('Discarded value due to exceeding maximum length'),
+          desc: null,
+          expandTitle: t('Fix Processing Error'),
+          data: errorData,
+          meta: metaData,
+        },
+      ];
+
+    case GenericSchemaErrors.INVALID_DATA:
+      return [
+        {
+          title: t('Discarded invalid value'),
+          desc: null,
+          expandTitle: t('Fix Processing Error'),
+          data: errorData,
+          meta: metaData,
+        },
+      ];
+    case GenericSchemaErrors.INVALID_ENVIRONMENT:
+      return [
+        {
+          title: t('Environment cannot contain "/" or newlines'),
+          desc: null,
+          expandTitle: t('Fix Processing Error'),
+          data: errorData,
+          meta: metaData,
+        },
+      ];
+    case GenericSchemaErrors.INVALID_ATTRIBUTE:
+      return [
+        {
+          title: t('Discarded unknown attribute'),
+          desc: null,
+          expandTitle: t('Fix Processing Error'),
+          data: errorData,
+          meta: metaData,
+        },
+      ];
+
+    default:
+      return [];
+  }
+}
+
+interface ExpandableErrorListProps {
+  errorList: ErrorMessageType[];
+  handleExpandClick: (type: ActionableItemTypes) => void;
+}
+
+function ExpandableErrorList({handleExpandClick, errorList}: ExpandableErrorListProps) {
+  const [expanded, setExpanded] = useState(false);
+  const firstError = errorList[0];
+  const {title, desc, expandTitle, type} = firstError;
+  const numErrors = errorList.length;
+  const errorDataList = errorList.map(error => error.data ?? {});
+
+  const cleanedData = useMemo(() => {
+    const cleaned = errorDataList.map(errorData => {
+      const data = {...errorData};
+      // The name is rendered as path in front of the message
+      if (typeof data.name === 'string') {
+        delete data.name;
+      }
+
+      if (data.message === 'None') {
+        // Python ensures a message string, but "None" doesn't make sense here
+        delete data.message;
+      }
+
+      if (typeof data.image_path === 'string') {
+        // Separate the image name for readability
+        const separator = /^([a-z]:\\|\\\\)/i.test(data.image_path) ? '\\' : '/';
+        const path = data.image_path.split(separator);
+        data.image_name = path.splice(-1, 1)[0];
+        data.image_path = path.length ? path.join(separator) + separator : '';
+      }
+
+      if (typeof data.server_time === 'string' && typeof data.sdk_time === 'string') {
+        data.message = t(
+          'Adjusted timestamps by %s',
+          moment
+            .duration(moment.utc(data.server_time).diff(moment.utc(data.sdk_time)))
+            .humanize()
+        );
+      }
+
+      return Object.entries(data)
+        .map(([key, value]) => ({
+          key,
+          value,
+          subject: keyMapping[key] || startCase(key),
+        }))
+        .filter(d => {
+          if (!d.value) {
+            return true;
+          }
+          return !!d.value;
+        });
+    });
+    return cleaned;
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [errorDataList]);
+
+  return (
+    <List symbol="bullet">
+      <StyledListItem>
+        <ErrorTitleFlex>
+          <strong>
+            {title} ({numErrors})
+          </strong>
+          <ToggleButton
+            priority="link"
+            size="zero"
+            onClick={() => {
+              setExpanded(!expanded);
+              handleExpandClick(type);
+            }}
+          >
+            {expandTitle}
+          </ToggleButton>
+        </ErrorTitleFlex>
+        {expanded && (
+          <div>
+            {desc && <Description>{desc}</Description>}
+            {cleanedData.map((data, idx) => {
+              return (
+                <div key={idx}>
+                  <KeyValueList data={data} isContextData />
+                  {idx !== numErrors - 1 && <hr />}
+                </div>
+              );
+            })}
+          </div>
+        )}
+      </StyledListItem>
+    </List>
+  );
+}
+
+interface ErrorMessageType extends ErrorMessage {
+  type: ActionableItemTypes;
+}
+
+function groupedErrors(
+  event: Event,
+  data?: ActionableItemsResponse,
+  progaurdErrors?: EventErrorData[]
+): Record<ActionableItemTypes, ErrorMessageType[]> | {} {
+  if (!data || !progaurdErrors || !event) {
+    return {};
+  }
+  const {_meta} = event;
+  const errors = [...data.errors, ...progaurdErrors]
+    .filter(error => shouldErrorBeShown(error, event))
+    .map((error, errorIdx) =>
+      getErrorMessage(error, _meta?.errors?.[errorIdx]).map(message => ({
+        ...message,
+        type: error.type,
+      }))
+    )
+    .flat();
+
+  const grouped = errors.reduce((rv, error) => {
+    rv[error.type] = rv[error.type] || [];
+    rv[error.type].push(error);
+    return rv;
+  }, Object.create(null));
+  return grouped;
+}
+
+interface ActionableItemsProps {
+  event: Event;
+  isShare: boolean;
+  project: Project;
+}
+
+export function ActionableItems({event, project, isShare}: ActionableItemsProps) {
+  const organization = useOrganization();
+  const {data, isLoading} = useActionableItems({
+    eventId: event.id,
+    orgSlug: organization.slug,
+    projectSlug: project.slug,
+  });
+
+  const {proguardErrorsLoading, proguardErrors} = useFetchProguardMappingFiles({
+    event,
+    project,
+    isShare,
+  });
+
+  useEffect(() => {
+    if (proguardErrors?.length) {
+      if (proguardErrors[0]?.type === 'proguard_potentially_misconfigured_plugin') {
+        trackAnalytics('issue_error_banner.proguard_misconfigured.displayed', {
+          organization,
+          group: event?.groupID,
+          platform: project.platform,
+        });
+      } else if (proguardErrors[0]?.type === 'proguard_missing_mapping') {
+        trackAnalytics('issue_error_banner.proguard_missing_mapping.displayed', {
+          organization,
+          group: event?.groupID,
+          platform: project.platform,
+        });
+      }
+    }
+    // Just for analytics, only track this once per visit
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, []);
+
+  const errorMessages = groupedErrors(event, data, proguardErrors);
+
+  useRouteAnalyticsParams({
+    show_actionable_items_cta: data ? data.errors.length > 0 : false,
+    actionable_items: data ? Object.keys(errorMessages) : [],
+  });
+
+  if (isLoading || !defined(data) || data.errors.length === 0) {
+    return null;
+  }
+
+  if (proguardErrorsLoading) {
+    // XXX: This is necessary for acceptance tests to wait until removal since there is
+    // no visual loading state.
+    return <HiddenDiv data-test-id="event-errors-loading" />;
+  }
+
+  const analyticsParams = {
+    organization,
+    project_id: event.projectID,
+    group_id: event.groupID,
+    ...getAnalyticsDataForEvent(event),
+  };
+
+  const handleExpandClick = (type: ActionableItemTypes) => {
+    trackAnalytics('actionable_items.expand_clicked', {
+      ...analyticsParams,
+      type,
+    });
+  };
+
+  const hasErrorAlert = Object.keys(errorMessages).some(error =>
+    ActionableItemWarning.includes(
+      error as ProguardProcessingErrors | NativeProcessingErrors | GenericSchemaErrors
+    )
+  );
+
+  for (const errorKey in Object.keys(errorMessages)) {
+    const isWarning = ActionableItemWarning.includes(
+      errorKey as ProguardProcessingErrors | NativeProcessingErrors | GenericSchemaErrors
+    );
+    const shouldDelete = hasErrorAlert ? isWarning : !isWarning;
+
+    if (shouldDelete) {
+      delete errorMessages[errorKey];
+    }
+  }
+
+  return (
+    <StyledAlert
+      defaultExpanded
+      showIcon
+      type={hasErrorAlert ? 'error' : 'warning'}
+      expand={
+        <Fragment>
+          {Object.keys(errorMessages).map((error, idx) => {
+            return (
+              <ExpandableErrorList
+                key={idx}
+                errorList={errorMessages[error]}
+                handleExpandClick={handleExpandClick}
+              />
+            );
+          })}
+        </Fragment>
+      }
+    >
+      {t("There are problems you'll need to fix for future events")}
+    </StyledAlert>
+  );
+}
+
+const Description = styled('div')`
+  margin-top: ${space(0.5)};
+`;
+const StyledAlert = styled(Alert)`
+  margin: 0 30px;
+`;
+const StyledListItem = styled(ListItem)`
+  margin-bottom: ${space(0.75)};
+`;
+
+const ToggleButton = styled(Button)`
+  color: ${p => p.theme.subText};
+  text-decoration: underline;
+
+  :hover,
+  :focus {
+    color: ${p => p.theme.textColor};
+  }
+`;
+
+const ErrorTitleFlex = styled('div')`
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  gap: ${space(1)};
+`;
+
+const HiddenDiv = styled('div')`
+  display: none;
+`;

+ 276 - 0
static/app/components/events/interfaces/crashContent/exception/actionableItemsUtils.tsx

@@ -0,0 +1,276 @@
+import {EventErrorData} from 'sentry/components/events/errorItem';
+import findBestThread from 'sentry/components/events/interfaces/threads/threadSelector/findBestThread';
+import getThreadException from 'sentry/components/events/interfaces/threads/threadSelector/getThreadException';
+import ExternalLink from 'sentry/components/links/externalLink';
+import {
+  CocoaProcessingErrors,
+  GenericSchemaErrors,
+  HttpProcessingErrors,
+  JavascriptProcessingErrors,
+  NativeProcessingErrors,
+  ProguardProcessingErrors,
+} from 'sentry/constants/eventErrors';
+import {tct} from 'sentry/locale';
+import {Project} from 'sentry/types';
+import {DebugFile} from 'sentry/types/debugFiles';
+import {Image} from 'sentry/types/debugImage';
+import {EntryType, Event, ExceptionValue, Thread} from 'sentry/types/event';
+import {defined} from 'sentry/utils';
+import {trackAnalytics} from 'sentry/utils/analytics';
+import {useApiQuery} from 'sentry/utils/queryClient';
+import useOrganization from 'sentry/utils/useOrganization';
+import {semverCompare} from 'sentry/utils/versions';
+import {projectProcessingIssuesMessages} from 'sentry/views/settings/project/projectProcessingIssues';
+
+const MINIFIED_DATA_JAVA_EVENT_REGEX_MATCH =
+  /^(([\w\$]\.[\w\$]{1,2})|([\w\$]{2}\.[\w\$]\.[\w\$]))(\.|$)/g;
+
+export type ActionableItemTypes =
+  | JavascriptProcessingErrors
+  | HttpProcessingErrors
+  | GenericSchemaErrors
+  | ProguardProcessingErrors
+  | NativeProcessingErrors;
+
+export const ActionableItemWarning = [
+  ProguardProcessingErrors.PROGUARD_MISSING_LINENO,
+  NativeProcessingErrors.NATIVE_MISSING_OPTIONALLY_BUNDLED_DSYM,
+  GenericSchemaErrors.FUTURE_TIMESTAMP,
+  GenericSchemaErrors.CLOCK_DRIFT,
+  GenericSchemaErrors.PAST_TIMESTAMP,
+  GenericSchemaErrors.VALUE_TOO_LONG,
+];
+
+interface BaseActionableItem {
+  data: any;
+  message: string;
+  type: ActionableItemTypes;
+}
+interface ProguardMissingLineNoError extends BaseActionableItem {
+  type: ProguardProcessingErrors.PROGUARD_MISSING_LINENO;
+}
+interface ProguardMissingMappingError extends BaseActionableItem {
+  type: ProguardProcessingErrors.PROGUARD_MISSING_MAPPING;
+}
+
+interface NativeMissingOptionalBundledDSYMError extends BaseActionableItem {
+  type: NativeProcessingErrors.NATIVE_MISSING_OPTIONALLY_BUNDLED_DSYM;
+}
+interface NativeMissingDSYMError extends BaseActionableItem {
+  type: NativeProcessingErrors.NATIVE_MISSING_DSYM;
+}
+interface NativeBadDSYMError extends BaseActionableItem {
+  type: NativeProcessingErrors.NATIVE_BAD_DSYM;
+}
+
+interface JSMissingSourcesContentError extends BaseActionableItem {
+  type: JavascriptProcessingErrors.JS_MISSING_SOURCES_CONTEXT;
+}
+
+interface FetchGenericError extends BaseActionableItem {
+  type: HttpProcessingErrors.FETCH_GENERIC_ERROR;
+}
+interface RestrictedIpError extends BaseActionableItem {
+  type: HttpProcessingErrors.RESTRICTED_IP;
+}
+interface SecurityViolationError extends BaseActionableItem {
+  type: HttpProcessingErrors.SECURITY_VIOLATION;
+}
+
+interface FutureTimestampError extends BaseActionableItem {
+  type: GenericSchemaErrors.FUTURE_TIMESTAMP;
+}
+interface ClockDriftError extends BaseActionableItem {
+  type: GenericSchemaErrors.CLOCK_DRIFT;
+}
+interface PastTimestampError extends BaseActionableItem {
+  type: GenericSchemaErrors.PAST_TIMESTAMP;
+}
+
+interface ValueTooLongError extends BaseActionableItem {
+  type: GenericSchemaErrors.VALUE_TOO_LONG;
+}
+interface InvalidDataError extends BaseActionableItem {
+  type: GenericSchemaErrors.INVALID_DATA;
+}
+interface InvalidEnvironmentError extends BaseActionableItem {
+  type: GenericSchemaErrors.INVALID_ENVIRONMENT;
+}
+interface InvalidAttributeError extends BaseActionableItem {
+  type: GenericSchemaErrors.INVALID_ATTRIBUTE;
+}
+
+export type ActionableItemErrors =
+  | ProguardMissingLineNoError
+  | ProguardMissingMappingError
+  | NativeMissingOptionalBundledDSYMError
+  | NativeMissingDSYMError
+  | NativeBadDSYMError
+  | JSMissingSourcesContentError
+  | FetchGenericError
+  | RestrictedIpError
+  | SecurityViolationError
+  | FutureTimestampError
+  | ClockDriftError
+  | PastTimestampError
+  | ValueTooLongError
+  | InvalidDataError
+  | InvalidEnvironmentError
+  | InvalidAttributeError;
+
+export function shouldErrorBeShown(error: EventErrorData, event: Event) {
+  if (
+    error.type === CocoaProcessingErrors.COCOA_INVALID_DATA &&
+    event.sdk?.name === 'sentry.cocoa' &&
+    error.data?.name === 'contexts.trace.sampled' &&
+    semverCompare(event.sdk?.version || '', '8.7.4') === -1
+  ) {
+    // The Cocoa SDK sends wrong values for contexts.trace.sampled before 8.7.4
+    return false;
+  }
+  return true;
+}
+
+function isDataMinified(str: string | null) {
+  if (!str) {
+    return false;
+  }
+
+  return MINIFIED_DATA_JAVA_EVENT_REGEX_MATCH.test(str);
+}
+
+const hasThreadOrExceptionMinifiedFrameData = (
+  definedEvent: Event,
+  bestThread?: Thread
+) => {
+  if (!bestThread) {
+    const exceptionValues: Array<ExceptionValue> =
+      definedEvent.entries?.find(e => e.type === EntryType.EXCEPTION)?.data?.values ?? [];
+
+    return exceptionValues.some(exceptionValue =>
+      exceptionValue.stacktrace?.frames?.some(frame => isDataMinified(frame.module))
+    );
+  }
+
+  const threadExceptionValues = getThreadException(definedEvent, bestThread)?.values;
+
+  return threadExceptionValues
+    ? threadExceptionValues.some(threadExceptionValue =>
+        threadExceptionValue.stacktrace?.frames?.some(frame =>
+          isDataMinified(frame.module)
+        )
+      )
+    : bestThread?.stacktrace?.frames?.some(frame => isDataMinified(frame.module));
+};
+
+export const useFetchProguardMappingFiles = ({
+  event,
+  isShare,
+  project,
+}: {
+  event: Event;
+  isShare: boolean;
+  project: Project;
+}): {proguardErrors: EventErrorData[]; proguardErrorsLoading: boolean} => {
+  const organization = useOrganization();
+  const hasEventErrorsProGuardMissingMapping = event.errors?.find(
+    error => error.type === 'proguard_missing_mapping'
+  );
+
+  const debugImages = event.entries?.find(e => e.type === EntryType.DEBUGMETA)?.data
+    .images as undefined | Array<Image>;
+
+  // When debugImages contains a 'proguard' entry, it must always be only one entry
+  const proGuardImage = debugImages?.find(debugImage => debugImage?.type === 'proguard');
+
+  const proGuardImageUuid = proGuardImage?.uuid;
+
+  const shouldFetch =
+    defined(proGuardImageUuid) &&
+    event.platform === 'java' &&
+    !hasEventErrorsProGuardMissingMapping &&
+    !isShare;
+
+  const {
+    data: proguardMappingFiles,
+    isSuccess,
+    isLoading,
+  } = useApiQuery<DebugFile[]>(
+    [
+      `/projects/${organization.slug}/${project.slug}/files/dsyms/`,
+      {
+        query: {
+          query: proGuardImageUuid,
+          file_formats: 'proguard',
+        },
+      },
+    ],
+    {
+      staleTime: Infinity,
+      enabled: shouldFetch,
+      retry: false,
+    }
+  );
+
+  function getProguardErrorsFromMappingFiles(): EventErrorData[] {
+    if (isShare) {
+      return [];
+    }
+
+    if (shouldFetch) {
+      if (!isSuccess || proguardMappingFiles.length > 0) {
+        return [];
+      }
+
+      return [
+        {
+          type: 'proguard_missing_mapping',
+          message: projectProcessingIssuesMessages.proguard_missing_mapping,
+          data: {mapping_uuid: proGuardImageUuid},
+        },
+      ];
+    }
+
+    const threads: Array<Thread> =
+      event.entries?.find(e => e.type === EntryType.THREADS)?.data?.values ?? [];
+
+    const bestThread = findBestThread(threads);
+    const hasThreadOrExceptionMinifiedData = hasThreadOrExceptionMinifiedFrameData(
+      event,
+      bestThread
+    );
+
+    if (hasThreadOrExceptionMinifiedData) {
+      return [
+        {
+          type: 'proguard_potentially_misconfigured_plugin',
+          message: tct(
+            'Some frames appear to be minified. Did you configure the [plugin]?',
+            {
+              plugin: (
+                <ExternalLink
+                  href="https://docs.sentry.io/platforms/android/proguard/#gradle"
+                  onClick={() => {
+                    trackAnalytics('issue_error_banner.proguard_misconfigured.clicked', {
+                      organization,
+                      group: event?.groupID,
+                    });
+                  }}
+                >
+                  Sentry Gradle Plugin
+                </ExternalLink>
+              ),
+            }
+          ),
+        },
+      ];
+    }
+
+    return [];
+  }
+
+  return {
+    proguardErrorsLoading: shouldFetch && isLoading,
+    proguardErrors: getProguardErrorsFromMappingFiles(),
+  };
+};

+ 6 - 2
static/app/components/events/interfaces/crashContent/exception/sourcemapsWizard.tsx

@@ -58,7 +58,7 @@ export default function SourceMapsWizard({analyticsParams}: Props) {
   }
 
   return (
-    <Panel dashedBorder data-test-id="sourcemaps-wizard">
+    <StyledPanel dashedBorder data-test-id="sourcemaps-wizard">
       <CloseButton
         onClick={() => {
           setIsHidden(true);
@@ -106,7 +106,7 @@ export default function SourceMapsWizard({analyticsParams}: Props) {
           {wizardCommand}
         </StyledCodeSnippet>
       </EmptyMessage>
-    </Panel>
+    </StyledPanel>
   );
 }
 
@@ -120,6 +120,10 @@ const StyledCodeSnippet = styled(CodeSnippet)<{isDarkMode: boolean}>`
   }
 `;
 
+const StyledPanel = styled(Panel)`
+  margin: 0 30px;
+`;
+
 const CloseButton = styled(Button)`
   position: absolute;
   top: -${space(1.5)};

+ 54 - 0
static/app/components/events/interfaces/crashContent/exception/useActionableItems.tsx

@@ -0,0 +1,54 @@
+import type {Organization, SharedViewOrganization} from 'sentry/types';
+import {defined} from 'sentry/utils';
+import {ApiQueryKey, useApiQuery} from 'sentry/utils/queryClient';
+
+import {ActionableItemErrors} from './actionableItemsUtils';
+
+const actionableItemsQuery = ({
+  orgSlug,
+  projectSlug,
+  eventId,
+}: UseActionableItemsProps): ApiQueryKey => [
+  `/projects/${orgSlug}/${projectSlug}/events/${eventId}/actionable-items/`,
+];
+
+export interface ActionableItemsResponse {
+  errors: ActionableItemErrors[];
+}
+
+interface UseActionableItemsProps {
+  eventId: string;
+  orgSlug: string;
+  projectSlug: string;
+}
+
+export function useActionableItems(props?: UseActionableItemsProps) {
+  return useApiQuery<ActionableItemsResponse>(
+    props ? actionableItemsQuery(props) : [''],
+    {
+      staleTime: Infinity,
+      retry: false,
+      refetchOnWindowFocus: false,
+      notifyOnChangeProps: ['data'],
+      enabled: defined(props),
+    }
+  );
+}
+
+/**
+ * Check we have all required props and feature flag
+ */
+export function actionableItemsEnabled({
+  eventId,
+  organization,
+  projectSlug,
+}: {
+  eventId?: string;
+  organization?: Organization | SharedViewOrganization | null;
+  projectSlug?: string;
+}) {
+  if (!organization || !organization.features || !projectSlug || !eventId) {
+    return false;
+  }
+  return organization.features.includes('actionable-items');
+}

+ 1 - 0
static/app/constants/eventErrors.tsx

@@ -11,6 +11,7 @@ export enum JavascriptProcessingErrors {
   JS_TOO_LARGE = 'js_too_large',
   JS_FETCH_TIMEOUT = 'js_fetch_timeout',
   JS_SCRAPING_DISABLED = 'js_scraping_disabled',
+  JS_MISSING_SOURCES_CONTEXT = 'js_missing_sources_context',
 }
 
 export enum HttpProcessingErrors {

+ 7 - 0
static/app/utils/analytics/issueAnalyticsEvents.tsx

@@ -14,6 +14,11 @@ type SourceMapDebugParam = {
   group_id?: string;
 } & BaseEventAnalyticsParams;
 
+type ActionableItemDebugParam = {
+  type: string;
+  group_id?: string;
+} & BaseEventAnalyticsParams;
+
 type SourceMapWizardParam = {
   project_id: string;
   group_id?: string;
@@ -34,6 +39,7 @@ interface ExternalIssueParams extends CommonGroupAnalyticsData {
 }
 
 export type IssueEventParameters = {
+  'actionable_items.expand_clicked': ActionableItemDebugParam;
   'device.classification.high.end.android.device': {
     processor_count: number;
     processor_frequency: number;
@@ -287,6 +293,7 @@ export const issueEventMap: Record<IssueEventKey, string | null> = {
     'Performance Issue Details: Hidden Spans Expanded',
   'source_map_debug.docs_link_clicked': 'Source Map Debug: Docs Clicked',
   'source_map_debug.expand_clicked': 'Source Map Debug: Expand Clicked',
+  'actionable_items.expand_clicked': 'Actionable Item: Expand Clicked',
   'issue_details.copy_event_link_clicked': 'Issue Details: Copy Event Link Clicked',
   'issue_details.event_details_clicked': 'Issue Details: Full Event Details Clicked',
   'issue_details.event_dropdown_option_selected':

+ 15 - 1
static/app/views/issueDetails/groupEventDetails/groupEventDetailsContent.tsx

@@ -21,6 +21,8 @@ import RegressionMessage from 'sentry/components/events/eventStatisticalDetector
 import {EventTagsAndScreenshot} from 'sentry/components/events/eventTagsAndScreenshot';
 import {EventViewHierarchy} from 'sentry/components/events/eventViewHierarchy';
 import {EventGroupingInfo} from 'sentry/components/events/groupingInfo';
+import {ActionableItems} from 'sentry/components/events/interfaces/crashContent/exception/actionableItems';
+import {actionableItemsEnabled} from 'sentry/components/events/interfaces/crashContent/exception/useActionableItems';
 import {CronTimelineSection} from 'sentry/components/events/interfaces/crons/cronTimelineSection';
 import {AnrRootCause} from 'sentry/components/events/interfaces/performance/anrRootCause';
 import {SpanEvidenceSection} from 'sentry/components/events/interfaces/performance/spanEvidence';
@@ -73,6 +75,7 @@ function GroupEventDetailsContent({
 }: GroupEventDetailsContentProps) {
   const organization = useOrganization();
   const location = useLocation();
+  const projectSlug = project.slug;
   const hasReplay = Boolean(event?.tags?.find(({key}) => key === 'replayId')?.value);
   const mechanism = event?.tags?.find(({key}) => key === 'mechanism')?.value;
   const isANR = mechanism === 'ANR' || mechanism === 'AppExitInfo';
@@ -88,6 +91,12 @@ function GroupEventDetailsContent({
 
   const eventEntryProps = {group, event, project};
 
+  const hasActionableItems = actionableItemsEnabled({
+    eventId: event.id,
+    organization,
+    projectSlug,
+  });
+
   if (group.issueType === IssueType.PERFORMANCE_DURATION_REGRESSION) {
     return (
       <Feature
@@ -107,7 +116,12 @@ function GroupEventDetailsContent({
 
   return (
     <Fragment>
-      <EventErrors event={event} project={project} isShare={false} />
+      {!hasActionableItems && (
+        <EventErrors event={event} project={project} isShare={false} />
+      )}
+      {hasActionableItems && (
+        <ActionableItems event={event} project={project} isShare={false} />
+      )}
       <EventCause
         project={project}
         eventId={event.id}