Browse Source

feat(event-error-list): Add Proguard error message (#24099)

Priscila Oliveira 4 years ago
parent
commit
24484c0e1e

+ 10 - 7
src/sentry/static/sentry/app/components/events/errors.tsx

@@ -21,12 +21,13 @@ import {BannerContainer, BannerSummary} from './styles';
 
 const MAX_ERRORS = 100;
 
-type Error = ErrorItem['props']['error'];
+export type Error = ErrorItem['props']['error'];
 
 type Props = {
   api: Client;
   orgSlug: Organization['slug'];
   projectSlug: Project['slug'];
+  proGuardErrors: Array<Error>;
   event: Event;
 };
 
@@ -87,7 +88,7 @@ class Errors extends React.Component<Props, State> {
     const {event} = this.props;
     const {errors} = event;
 
-    const sourceCodeErrors = errors.filter(
+    const sourceCodeErrors = (errors ?? []).filter(
       error => error.type === 'js_no_source' && error.data.url
     );
 
@@ -124,19 +125,21 @@ class Errors extends React.Component<Props, State> {
   };
 
   render() {
-    const {event} = this.props;
+    const {event, proGuardErrors} = this.props;
     const {isOpen, releaseArtifacts} = this.state;
-    const {dist} = event;
+    const {dist, errors: eventErrors = []} = event;
 
     // XXX: uniqWith returns unique errors and is not performant with large datasets
-    const errors: Array<Error> =
-      event.errors.length > MAX_ERRORS ? event.errors : uniqWith(event.errors, isEqual);
+    const otherErrors: Array<Error> =
+      eventErrors.length > MAX_ERRORS ? eventErrors : uniqWith(eventErrors, isEqual);
+
+    const errors = [...otherErrors, ...proGuardErrors];
 
     return (
       <StyledBanner priority="danger">
         <BannerSummary>
           <StyledIconWarning />
-          <span>
+          <span data-test-id="errors-banner-summary-info">
             {tn(
               'There was %s error encountered while processing this event',
               'There were %s errors encountered while processing this event',

+ 224 - 33
src/sentry/static/sentry/app/components/events/eventEntries.tsx

@@ -1,12 +1,14 @@
 import React from 'react';
 import styled from '@emotion/styled';
+import * as Sentry from '@sentry/react';
 import {Location} from 'history';
 
+import {Client} from 'app/api';
 import ErrorBoundary from 'app/components/errorBoundary';
 import EventContexts from 'app/components/events/contexts';
 import EventContextSummary from 'app/components/events/contextSummary/contextSummary';
 import EventDevice from 'app/components/events/device';
-import EventErrors from 'app/components/events/errors';
+import EventErrors, {Error} from 'app/components/events/errors';
 import EventAttachments from 'app/components/events/eventAttachments';
 import EventCause from 'app/components/events/eventCause';
 import EventCauseEmpty from 'app/components/events/eventCauseEmpty';
@@ -20,23 +22,41 @@ import RRWebIntegration from 'app/components/events/rrwebIntegration';
 import EventSdkUpdates from 'app/components/events/sdkUpdates';
 import {DataSection} from 'app/components/events/styles';
 import EventUserFeedback from 'app/components/events/userFeedback';
-import {t} from 'app/locale';
+import ExternalLink from 'app/components/links/externalLink';
+import {t, tct} from 'app/locale';
 import space from 'app/styles/space';
-import {Group, Organization, Project, SharedViewOrganization} from 'app/types';
-import {Entry, Event} from 'app/types/event';
+import {
+  ExceptionValue,
+  Group,
+  Organization,
+  Project,
+  SharedViewOrganization,
+} from 'app/types';
+import {DebugFile} from 'app/types/debugFiles';
+import {Image} from 'app/types/debugImage';
+import {Entry, EntryType, Event} from 'app/types/event';
+import {Thread} from 'app/types/events';
 import {isNotSharedOrganization} from 'app/types/utils';
-import {objectIsEmpty} from 'app/utils';
+import {defined, objectIsEmpty} from 'app/utils';
 import {analytics} from 'app/utils/analytics';
+import withApi from 'app/utils/withApi';
 import withOrganization from 'app/utils/withOrganization';
+import {projectProcessingIssuesMessages} from 'app/views/settings/project/projectProcessingIssues';
 
+import findBestThread from './interfaces/threads/threadSelector/findBestThread';
+import getThreadException from './interfaces/threads/threadSelector/getThreadException';
 import EventEntry from './eventEntry';
 
+const MINIFIED_DATA_JAVA_EVENT_REGEX_MATCH = /^(\w|\w{2}\.\w{1,2}|\w{3}((\.\w)|(\.\w{2}){2}))(\.|$)/g;
+
 const defaultProps = {
   isShare: false,
   showExampleCommit: false,
   showTagSummary: true,
 };
 
+type ProGuardErrors = Array<Error>;
+
 type Props = {
   /**
    * The organization can be the shared view on a public issue view.
@@ -45,39 +65,210 @@ type Props = {
   event: Event;
   project: Project;
   location: Location;
-
+  api: Client;
   group?: Group;
   className?: string;
 } & typeof defaultProps;
 
-class EventEntries extends React.Component<Props> {
+type State = {
+  isLoading: boolean;
+  proGuardErrors: ProGuardErrors;
+};
+
+class EventEntries extends React.Component<Props, State> {
   static defaultProps = defaultProps;
 
-  componentDidMount() {
-    const {event} = this.props;
+  state: State = {
+    isLoading: true,
+    proGuardErrors: [],
+  };
 
-    if (!event || !event.errors || !(event.errors.length > 0)) {
-      return;
-    }
-    const errors = event.errors;
-    const errorTypes = errors.map(errorEntries => errorEntries.type);
-    const errorMessages = errors.map(errorEntries => errorEntries.message);
-
-    this.recordIssueError(errorTypes, errorMessages);
+  componentDidMount() {
+    this.checkProGuardError();
+    this.recordIssueError();
   }
 
-  shouldComponentUpdate(nextProps: Props) {
+  shouldComponentUpdate(nextProps: Props, nextState: State) {
     const {event, showExampleCommit} = this.props;
 
     return (
       (event && nextProps.event && event.id !== nextProps.event.id) ||
-      showExampleCommit !== nextProps.showExampleCommit
+      showExampleCommit !== nextProps.showExampleCommit ||
+      nextState.isLoading !== this.state.isLoading
+    );
+  }
+
+  async fetchProguardMappingFiles(query: string): Promise<Array<DebugFile>> {
+    const {api, organization, project} = this.props;
+    try {
+      const proguardMappingFiles = await api.requestPromise(
+        `/projects/${organization.slug}/${project.slug}/files/dsyms/`,
+        {
+          method: 'GET',
+          query: {
+            query,
+            file_formats: 'proguard',
+          },
+        }
+      );
+      return proguardMappingFiles;
+    } catch (error) {
+      Sentry.captureException(error);
+      // do nothing, the UI will not display extra error details
+      return [];
+    }
+  }
+
+  isDataMinified(str: string | null) {
+    if (!str) {
+      return false;
+    }
+
+    return !![...str.matchAll(MINIFIED_DATA_JAVA_EVENT_REGEX_MATCH)].length;
+  }
+
+  hasThreadOrExceptionMinifiedFrameData(event: Event, bestThread?: Thread) {
+    if (!bestThread) {
+      const exceptionValues: Array<ExceptionValue> =
+        event.entries?.find(e => e.type === EntryType.EXCEPTION)?.data?.values ?? [];
+
+      return !!exceptionValues.find(exceptionValue =>
+        exceptionValue.stacktrace?.frames?.find(frame =>
+          this.isDataMinified(frame.module)
+        )
+      );
+    }
+
+    const threadExceptionValues = getThreadException(event, bestThread)?.values;
+
+    return !!(threadExceptionValues
+      ? threadExceptionValues.find(threadExceptionValue =>
+          threadExceptionValue.stacktrace?.frames?.find(frame =>
+            this.isDataMinified(frame.module)
+          )
+        )
+      : bestThread?.stacktrace?.frames?.find(frame => this.isDataMinified(frame.module)));
+  }
+
+  async checkProGuardError() {
+    const {event, isShare} = this.props;
+
+    if (!event || event.platform !== 'java') {
+      this.setState({isLoading: false});
+      return;
+    }
+
+    const hasEventErrorsProGuardMissingMapping = event.errors?.find(
+      error => error.type === 'proguard_missing_mapping'
     );
+
+    if (hasEventErrorsProGuardMissingMapping) {
+      this.setState({isLoading: false});
+      return;
+    }
+
+    const proGuardErrors: ProGuardErrors = [];
+
+    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;
+
+    // If an entry is of type 'proguard' and has 'uuid',
+    // it means that the Sentry Gradle plugin has been executed,
+    // otherwise the proguard id wouldn't be in the event.
+    // But maybe it failed to upload the mappings file
+    if (defined(proGuardImageUuid)) {
+      if (isShare) {
+        this.setState({isLoading: false});
+        return;
+      }
+
+      const proguardMappingFiles = await this.fetchProguardMappingFiles(
+        proGuardImageUuid
+      );
+
+      if (!proguardMappingFiles.length) {
+        proGuardErrors.push({
+          type: 'proguard_missing_mapping',
+          message: projectProcessingIssuesMessages.proguard_missing_mapping,
+          data: {mapping_uuid: proGuardImageUuid},
+        });
+      }
+
+      this.setState({proGuardErrors, isLoading: false});
+      return;
+    } else {
+      if (proGuardImage) {
+        Sentry.withScope(function (s) {
+          s.setLevel(Sentry.Severity.Warning);
+          if (event.sdk) {
+            s.setTag('offending.event.sdk.name', event.sdk.name);
+            s.setTag('offending.event.sdk.version', event.sdk.version);
+          }
+          Sentry.captureMessage('Event contains proguard image but not uuid');
+        });
+      }
+    }
+
+    const threads: Array<Thread> =
+      event.entries?.find(e => e.type === EntryType.THREADS)?.data?.values ?? [];
+
+    const bestThread = findBestThread(threads);
+    const hasThreadOrExceptionMinifiedData = this.hasThreadOrExceptionMinifiedFrameData(
+      event,
+      bestThread
+    );
+
+    if (hasThreadOrExceptionMinifiedData) {
+      proGuardErrors.push({
+        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">
+                Sentry Gradle Plugin
+              </ExternalLink>
+            ),
+          }
+        ),
+      });
+
+      // This capture will be removed once we're confident with the level of effectiveness
+      Sentry.withScope(function (s) {
+        s.setLevel(Sentry.Severity.Warning);
+        if (event.sdk) {
+          s.setTag('offending.event.sdk.name', event.sdk.name);
+          s.setTag('offending.event.sdk.version', event.sdk.version);
+        }
+        Sentry.captureMessage(
+          !proGuardImage
+            ? 'No Proguard is used at all, but a frame did match the regex'
+            : "Displaying ProGuard warning 'proguard_potentially_misconfigured_plugin' for suspected event"
+        );
+      });
+    }
+
+    this.setState({proGuardErrors, isLoading: false});
   }
 
-  recordIssueError(errorTypes: any[], errorMessages: string[]) {
+  recordIssueError() {
     const {organization, project, event} = this.props;
 
+    if (!event || !event.errors || !(event.errors.length > 0)) {
+      return;
+    }
+
+    const errors = event.errors;
+    const errorTypes = errors.map(errorEntries => errorEntries.type);
+    const errorMessages = errors.map(errorEntries => errorEntries.message);
+
     const orgId = organization.id;
     const platform = project.platform;
 
@@ -131,9 +322,9 @@ class EventEntries extends React.Component<Props> {
       showTagSummary,
       location,
     } = this.props;
+    const {proGuardErrors, isLoading} = this.state;
 
-    const features =
-      organization && organization.features ? new Set(organization.features) : new Set();
+    const features = new Set(organization?.features);
     const hasQueryFeature = features.has('discover-query');
 
     if (!event) {
@@ -143,19 +334,19 @@ class EventEntries extends React.Component<Props> {
         </div>
       );
     }
+
     const hasContext = !objectIsEmpty(event.user) || !objectIsEmpty(event.contexts);
-    const hasErrors = !objectIsEmpty(event.errors);
+    const hasErrors = !objectIsEmpty(event.errors) || !!proGuardErrors.length;
 
     return (
-      <div className={className} data-test-id="event-entries">
-        {hasErrors && (
-          <ErrorContainer>
-            <EventErrors
-              event={event}
-              orgSlug={organization.slug}
-              projectSlug={project.slug}
-            />
-          </ErrorContainer>
+      <div className={className} data-test-id={`event-entries-loading-${isLoading}`}>
+        {hasErrors && !isLoading && (
+          <EventErrors
+            event={event}
+            orgSlug={organization.slug}
+            projectSlug={project.slug}
+            proGuardErrors={proGuardErrors}
+          />
         )}
         {!isShare &&
           isNotSharedOrganization(organization) &&
@@ -260,5 +451,5 @@ const StyledEventUserFeedback = styled(EventUserFeedback)<StyledEventUserFeedbac
 `;
 
 // TODO(ts): any required due to our use of SharedViewOrganization
-export default withOrganization<any>(EventEntries);
+export default withOrganization<any>(withApi(EventEntries));
 export {BorderlessEventEntries};

+ 1 - 0
src/sentry/static/sentry/app/views/eventsV2/eventDetails/content.tsx

@@ -231,6 +231,7 @@ class EventDetailsContent extends AsyncComponent<Props, State> {
                         location={location}
                         showExampleCommit={false}
                         showTagSummary={false}
+                        api={this.api}
                       />
                     </QuickTraceContext.Provider>
                   </SpanEntryContext.Provider>

+ 1 - 0
src/sentry/static/sentry/app/views/organizationGroupDetails/groupEventDetails/groupEventDetails.tsx

@@ -200,6 +200,7 @@ class GroupEventDetails extends React.Component<Props, State> {
       />
     );
   }
+
   render() {
     const {
       className,

+ 1 - 0
src/sentry/static/sentry/app/views/performance/transactionDetails/content.tsx

@@ -182,6 +182,7 @@ class EventDetailsContent extends AsyncComponent<Props, State> {
                       showExampleCommit={false}
                       showTagSummary={false}
                       location={location}
+                      api={this.api}
                     />
                   </QuickTraceContext.Provider>
                 </SpanEntryContext.Provider>

+ 2 - 2
src/sentry/static/sentry/app/views/settings/project/projectProcessingIssues.tsx

@@ -33,7 +33,7 @@ import JsonForm from 'app/views/settings/components/forms/jsonForm';
 import SettingsPageHeader from 'app/views/settings/components/settingsPageHeader';
 import TextBlock from 'app/views/settings/components/text/textBlock';
 
-const MESSAGES = {
+export const projectProcessingIssuesMessages = {
   native_no_crashed_thread: t('No crashed thread found in crash report'),
   native_internal_failure: t('Internal failure when attempting to symbolicate: {error}'),
   native_bad_dsym: t('The debug information file used was broken.'),
@@ -264,7 +264,7 @@ class ProjectProcessingIssues extends React.Component<Props, State> {
   }
 
   getProblemDescription(item: ProcessingIssueItem) {
-    const msg = MESSAGES[item.type];
+    const msg = projectProcessingIssuesMessages[item.type];
     return msg || t('Unknown Error');
   }
 

+ 2 - 1
src/sentry/static/sentry/app/views/sharedGroupDetails/index.tsx

@@ -102,7 +102,7 @@ class SharedGroupDetails extends React.Component<Props, State> {
       return <LoadingError onRetry={this.handleRetry} />;
     }
 
-    const {location} = this.props;
+    const {location, api} = this.props;
     const {permalink, latestEvent, project} = group;
     const title = this.getTitle();
 
@@ -131,6 +131,7 @@ class SharedGroupDetails extends React.Component<Props, State> {
                     group={group}
                     event={latestEvent}
                     project={project}
+                    api={api}
                     isShare
                   />
                 </Container>

+ 1 - 1
tests/acceptance/page_objects/issue_details.py

@@ -78,7 +78,7 @@ class IssueDetailsPage(BasePage):
 
     def wait_until_loaded(self):
         self.browser.wait_until_not(".loading-indicator")
-        self.browser.wait_until_test_id("event-entries")
+        self.browser.wait_until_test_id("event-entries-loading-false")
         self.browser.wait_until_test_id("linked-issues")
         self.browser.wait_until_test_id("loaded-device-name")
         if self.browser.element_exists("#grouping-info"):

+ 1 - 1
tests/acceptance/test_shared_issue.py

@@ -22,5 +22,5 @@ class SharedIssueTest(AcceptanceTestCase):
 
         self.browser.get(f"/share/issue/{event.group.get_share_id()}/")
         self.browser.wait_until_not(".loading-indicator")
-        self.browser.wait_until_test_id("event-entries")
+        self.browser.wait_until_test_id("event-entries-loading-false")
         self.browser.snapshot("shared issue python")

+ 1 - 0
tests/js/sentry-test/fixtures/event.js

@@ -7,6 +7,7 @@ export function Event(params) {
     eventID: '12345678901234567890123456789012',
     dateCreated: '2019-05-21T18:01:48.762Z',
     tags: [],
+    errors: [],
     ...params,
   };
 }

Some files were not shown because too many files changed in this diff