Browse Source

feat(issues): Add sourcemap debug component (#43525)

Scott Cooper 2 years ago
parent
commit
e3124689a8

+ 3 - 1
static/app/components/alert.tsx

@@ -10,6 +10,7 @@ import {defined} from 'sentry/utils';
 import PanelProvider from 'sentry/utils/panelProvider';
 
 export interface AlertProps extends React.HTMLAttributes<HTMLDivElement> {
+  defaultExpanded?: boolean;
   expand?: React.ReactNode;
   icon?: React.ReactNode;
   opaque?: boolean;
@@ -28,12 +29,13 @@ function Alert({
   opaque,
   system,
   expand,
+  defaultExpanded = false,
   trailingItems,
   className,
   children,
   ...props
 }: AlertProps) {
-  const [isExpanded, setIsExpanded] = useState(false);
+  const [isExpanded, setIsExpanded] = useState(defaultExpanded);
   const showExpand = defined(expand);
   const showTrailingItems = defined(trailingItems);
 

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

@@ -114,6 +114,7 @@ describe('Exception Content', function () {
         event={event}
         values={event.entries[0].data.values}
         meta={event._meta.entries[0].data.values}
+        projectSlug={project.slug}
       />,
       {organization, router, context: routerContext}
     );

+ 33 - 2
static/app/components/events/interfaces/crashContent/exception/content.tsx

@@ -1,23 +1,28 @@
+import {useContext} from 'react';
 import styled from '@emotion/styled';
 
 import {AnnotatedText} from 'sentry/components/events/meta/annotatedText';
 import {Tooltip} from 'sentry/components/tooltip';
 import {tct} from 'sentry/locale';
 import space from 'sentry/styles/space';
-import {ExceptionType} from 'sentry/types';
+import {ExceptionType, Project} from 'sentry/types';
 import {Event} from 'sentry/types/event';
 import {STACK_TYPE} from 'sentry/types/stacktrace';
 import {defined} from 'sentry/utils';
+import {OrganizationContext} from 'sentry/views/organizationContext';
 
 import {Mechanism} from './mechanism';
 import {SetupSourceMapsAlert} from './setupSourceMapsAlert';
+import {SourceMapDebug} from './sourceMapDebug';
 import StackTrace from './stackTrace';
+import {debugFramesEnabled, getUniqueFilesFromException} from './useSourceMapDebug';
 
 type StackTraceProps = React.ComponentProps<typeof StackTrace>;
 
 type Props = {
   event: Event;
   platform: StackTraceProps['platform'];
+  projectSlug: Project['slug'];
   type: STACK_TYPE;
   meta?: Record<any, any>;
   newestFirst?: boolean;
@@ -35,15 +40,37 @@ export function Content({
   groupingCurrentLevel,
   hasHierarchicalGrouping,
   platform,
+  projectSlug,
   values,
   type,
   meta,
 }: Props) {
+  // Organization context may be unavailable for the shared event view, so we
+  // avoid using the `useOrganization` hook here and directly useContext
+  // instead.
+  const organization = useContext(OrganizationContext);
   if (!values) {
     return null;
   }
 
+  const shouldDebugFrames = debugFramesEnabled({
+    platform,
+    organization,
+    eventId: event.id,
+    projectSlug,
+  });
+  const debugFrames = shouldDebugFrames
+    ? getUniqueFilesFromException(values, {
+        eventId: event.id,
+        projectSlug: projectSlug!,
+        orgSlug: organization!.slug,
+      })
+    : [];
+
   const children = values.map((exc, excIdx) => {
+    const hasSourcemapDebug = debugFrames.some(
+      ({query}) => query.exceptionIdx === excIdx
+    );
     return (
       <div key={excIdx} className="exception">
         {defined(exc?.module) ? (
@@ -63,7 +90,10 @@ export function Content({
         {exc.mechanism && (
           <Mechanism data={exc.mechanism} meta={meta?.[excIdx]?.mechanism} />
         )}
-        <SetupSourceMapsAlert event={event} />
+        {!shouldDebugFrames && <SetupSourceMapsAlert event={event} />}
+        {hasSourcemapDebug && (
+          <SourceMapDebug debugFrames={debugFrames} platform={platform} />
+        )}
         <StackTrace
           data={
             type === STACK_TYPE.ORIGINAL
@@ -80,6 +110,7 @@ export function Content({
           hasHierarchicalGrouping={hasHierarchicalGrouping}
           groupingCurrentLevel={groupingCurrentLevel}
           meta={meta?.[excIdx]?.stacktrace}
+          debugFrames={hasSourcemapDebug ? debugFrames : undefined}
         />
       </div>
     );

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

@@ -46,6 +46,7 @@ function Exception({
           stackView={stackView}
           values={values}
           platform={platform}
+          projectSlug={projectSlug}
           newestFirst={newestFirst}
           event={event}
           hasHierarchicalGrouping={hasHierarchicalGrouping}

+ 306 - 0
static/app/components/events/interfaces/crashContent/exception/sourceMapDebug.tsx

@@ -0,0 +1,306 @@
+import React, {Fragment, useState} from 'react';
+import styled from '@emotion/styled';
+import uniqBy from 'lodash/uniqBy';
+
+import Alert from 'sentry/components/alert';
+import {Button} from 'sentry/components/button';
+import ExternalLink from 'sentry/components/links/externalLink';
+import List from 'sentry/components/list';
+import ListItem from 'sentry/components/list/listItem';
+import {IconWarning} from 'sentry/icons';
+import {t, tct, tn} from 'sentry/locale';
+import space from 'sentry/styles/space';
+import type {PlatformType} from 'sentry/types';
+import {defined} from 'sentry/utils';
+import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
+import useRouteAnalyticsParams from 'sentry/utils/routeAnalytics/useRouteAnalyticsParams';
+import useOrganization from 'sentry/utils/useOrganization';
+
+import {
+  SourceMapDebugError,
+  SourceMapDebugResponse,
+  SourceMapProcessingIssueType,
+  StacktraceFilenameQuery,
+  useSourceMapDebugQueries,
+} from './useSourceMapDebug';
+
+const platformDocsMap: Record<string, string> = {
+  javascript: 'javascript',
+  node: 'node',
+  'javascript-react': 'react',
+  'javascript-angular': 'angular',
+  // Sending angularjs to angular docs since it's not supported, has limited docs
+  'javascript-angularjs': 'angular',
+  // Sending backbone to javascript docs since it's not supported
+  'javascript-backbone': 'javascript',
+  'javascript-ember': 'ember',
+  'javascript-gatsby': 'gatsby',
+  'javascript-vue': 'vue',
+  'javascript-nextjs': 'nextjs',
+  'javascript-remix': 'remix',
+  'javascript-svelte': 'svelte',
+};
+
+const shortPathPlatforms = ['javascript', 'node'];
+
+function getErrorMessage(
+  error: SourceMapDebugError,
+  platform: PlatformType
+): Array<{
+  title: string;
+  /**
+   * Expandable description
+   */
+  desc?: string;
+  docsLink?: string;
+}> {
+  const docPlatform = platformDocsMap[platform] ?? 'javascript';
+  const useShortPath = shortPathPlatforms.includes(docPlatform);
+
+  switch (error.type) {
+    case SourceMapProcessingIssueType.MISSING_RELEASE:
+      return [
+        {
+          title: tct('Update your [init] call to pass in the release argument', {
+            init: <code>Sentry.init</code>,
+          }),
+          docsLink: useShortPath
+            ? `https://docs.sentry.io/platforms/${docPlatform}/configuration/options/#release`
+            : `https://docs.sentry.io/platforms/javascript/guides/${docPlatform}/configuration/options/#release`,
+        },
+        {
+          title: t(
+            'Integrate Sentry into your release pipeline. You can do this with a tool like webpack or using the CLI. Note the release must be the same as in step 1.'
+          ),
+          docsLink: useShortPath
+            ? `https://docs.sentry.io/platforms/${docPlatform}/sourcemaps/#uploading-source-maps-to-sentry`
+            : `https://docs.sentry.io/platforms/javascript/guides/${docPlatform}/sourcemaps/#uploading-source-maps-to-sentry`,
+        },
+      ];
+    case SourceMapProcessingIssueType.PARTIAL_MATCH:
+      return [
+        {
+          title: t(
+            'The abs_path of the stack frame is a partial match. The stack frame has the path %s which is a partial match to %s. You might need to modify the value of url-prefix.',
+            error.data.insertPath,
+            error.data.matchedSourcemapPath
+          ),
+          docsLink: useShortPath
+            ? `https://docs.sentry.io/platforms/${docPlatform}/sourcemaps/troubleshooting_js/#verify-artifact-names-match-stack-trace-frames`
+            : `https://docs.sentry.io/platforms/javascript/guides/${docPlatform}/sourcemaps/troubleshooting_js/#verify-artifact-names-match-stack-trace-frames`,
+        },
+      ];
+    case SourceMapProcessingIssueType.MISSING_USER_AGENT:
+      return [
+        {
+          title: t('Event has Release but no User-Agent'),
+          desc: tct(
+            'Integrate Sentry into your release pipeline. You can do this with a tool like Webpack or using the CLI. Please note the release must be the same as being set in your [init]. The value for this event is [version]',
+            {
+              init: <code>Sentry.init</code>,
+              version: error.data.version,
+            }
+          ),
+          docsLink: useShortPath
+            ? `https://docs.sentry.io/platforms/${docPlatform}/sourcemaps/#uploading-source-maps-to-sentry`
+            : `https://docs.sentry.io/platforms/javascript/guides/${docPlatform}/sourcemaps/#uploading-source-maps-to-sentry`,
+        },
+      ];
+    case SourceMapProcessingIssueType.MISSING_SOURCEMAPS:
+      return [
+        {
+          title: t('Source Maps not uploaded'),
+          desc: t(
+            'It looks like you are creating but not uploading your source maps. Please refer to the instructions in our docs guide for help with troubleshooting the issue.'
+          ),
+          docsLink: useShortPath
+            ? `https://docs.sentry.io/platforms/${docPlatform}/sourcemaps/`
+            : `https://docs.sentry.io/platforms/javascript/guides/${docPlatform}/sourcemaps/`,
+        },
+      ];
+    case SourceMapProcessingIssueType.URL_NOT_VALID:
+      return [
+        {
+          title: t('Invalid Absolute Path URL'),
+          desc: tct(
+            'The abs_path of the stack frame has [absValue] which is not a valid URL. Please refer to the instructions in our docs guide for help with troubleshooting the issue.',
+            {absValue: <code>{error.data.absValue}</code>}
+          ),
+          docsLink: useShortPath
+            ? `https://docs.sentry.io/platforms/${docPlatform}/sourcemaps/troubleshooting_js/#verify-artifact-names-match-stack-trace-frames`
+            : `https://docs.sentry.io/platforms/javascript/guides/${docPlatform}/sourcemaps/troubleshooting_js/#verify-artifact-names-match-stack-trace-frames`,
+        },
+      ];
+    case SourceMapProcessingIssueType.UNKNOWN_ERROR:
+    default:
+      return [];
+  }
+}
+
+interface ExpandableErrorListProps {
+  title: React.ReactNode;
+  children?: React.ReactNode;
+  docsLink?: React.ReactNode;
+  onExpandClick?: () => void;
+}
+
+/**
+ * Kinda making this reuseable since we have this pattern in a few places
+ */
+function ExpandableErrorList({
+  title,
+  children,
+  docsLink,
+  onExpandClick,
+}: ExpandableErrorListProps) {
+  const [expanded, setExpanded] = useState(false);
+  return (
+    <List symbol="bullet">
+      <StyledListItem>
+        <ErrorTitleFlex>
+          <ErrorTitleFlex>
+            <strong>{title}</strong>
+            {children && (
+              <ToggleButton
+                priority="link"
+                size="zero"
+                onClick={() => {
+                  setExpanded(!expanded);
+                  onExpandClick?.();
+                }}
+              >
+                {expanded ? t('Collapse') : t('Expand')}
+              </ToggleButton>
+            )}
+          </ErrorTitleFlex>
+          {docsLink}
+        </ErrorTitleFlex>
+        {expanded && <div>{children}</div>}
+      </StyledListItem>
+    </List>
+  );
+}
+
+function combineErrors(
+  response: Array<SourceMapDebugResponse | undefined | null>,
+  platform: PlatformType
+) {
+  const combinedErrors = uniqBy(
+    response
+      .map(res => res?.errors)
+      .flat()
+      .filter(defined),
+    error => error?.type
+  );
+  const errors = combinedErrors
+    .map(error =>
+      getErrorMessage(error, platform).map(message => ({...message, type: error.type}))
+    )
+    .flat();
+
+  return errors;
+}
+
+interface SourcemapDebugProps {
+  /**
+   * A subset of the total error frames to validate sourcemaps
+   */
+  debugFrames: StacktraceFilenameQuery[];
+  platform: PlatformType;
+}
+
+export function SourceMapDebug({debugFrames, platform}: SourcemapDebugProps) {
+  const organization = useOrganization();
+  const results = useSourceMapDebugQueries(debugFrames.map(debug => debug.query));
+
+  const isLoading = results.every(result => result.isLoading);
+  const errorMessages = combineErrors(
+    results.map(result => result.data).filter(defined),
+    platform
+  );
+
+  useRouteAnalyticsParams({
+    show_fix_source_map_cta: errorMessages.length > 0,
+  });
+
+  if (isLoading || !errorMessages.length) {
+    return null;
+  }
+
+  const handleDocsClick = (type: SourceMapProcessingIssueType) => {
+    trackAdvancedAnalyticsEvent('growth.sourcemap_docs_clicked', {
+      organization,
+      platform,
+      type,
+    });
+  };
+
+  const handleExpandClick = (type: SourceMapProcessingIssueType) => {
+    trackAdvancedAnalyticsEvent('growth.sourcemap_expand_clicked', {
+      organization,
+      platform,
+      type,
+    });
+  };
+
+  return (
+    <Alert
+      defaultExpanded
+      showIcon
+      type="error"
+      icon={<IconWarning />}
+      expand={
+        <Fragment>
+          {errorMessages.map((message, idx) => {
+            return (
+              <ExpandableErrorList
+                key={idx}
+                title={message.title}
+                docsLink={
+                  <DocsExternalLink
+                    href={message.docsLink}
+                    onClick={() => handleDocsClick(message.type)}
+                  >
+                    {t('Read Guide')}
+                  </DocsExternalLink>
+                }
+                onExpandClick={() => handleExpandClick(message.type)}
+              >
+                {message.desc}
+              </ExpandableErrorList>
+            );
+          })}
+        </Fragment>
+      }
+    >
+      {tn(
+        'We’ve encountered %s problem de-minifying your applications source code!',
+        'We’ve encountered %s problems de-minifying your applications source code!',
+        errorMessages.length
+      )}
+    </Alert>
+  );
+}
+
+const StyledListItem = styled(ListItem)`
+  margin-bottom: ${space(0.75)};
+`;
+
+const ToggleButton = styled(Button)`
+  color: ${p => p.theme.subText};
+  :hover,
+  :focus {
+    color: ${p => p.theme.textColor};
+  }
+`;
+
+const ErrorTitleFlex = styled('div')`
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  gap: ${space(1)};
+`;
+
+const DocsExternalLink = styled(ExternalLink)`
+  white-space: nowrap;
+`;

+ 190 - 0
static/app/components/events/interfaces/crashContent/exception/sourceaMapDebug.spec.tsx

@@ -0,0 +1,190 @@
+import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
+import {textWithMarkupMatcher} from 'sentry-test/utils';
+
+import {ExceptionValue} from 'sentry/types';
+import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
+
+import {SourceMapDebug} from './sourceMapDebug';
+import {
+  getUniqueFilesFromException,
+  SourceMapDebugError,
+  SourceMapProcessingIssueType,
+} from './useSourceMapDebug';
+
+jest.mock('sentry/utils/analytics/trackAdvancedAnalyticsEvent');
+
+describe('SourceMapDebug', () => {
+  const organization = TestStubs.Organization({features: ['fix-source-map-cta']});
+  const project = TestStubs.Project();
+  const eventId = '1ec1bd65b0b1484b97162087a652421b';
+  const exceptionValues: ExceptionValue[] = [
+    {
+      type: 'TypeError',
+      value: "Cannot read properties of undefined (reading 'map')",
+      mechanism: {
+        type: 'generic',
+        handled: true,
+      },
+      threadId: null,
+      module: null,
+      stacktrace: {
+        frames: [
+          {
+            filename: './app/views/organizationStats/teamInsights/controls.tsx',
+            absPath: 'webpack:///./app/views/organizationStats/teamInsights/controls.tsx',
+            module: 'app/views/organizationStats/teamInsights/controls',
+            package: null,
+            platform: null,
+            instructionAddr: null,
+            symbolAddr: null,
+            function: 'TeamStatsControls',
+            rawFunction: null,
+            symbol: null,
+            context: [],
+            lineNo: 53,
+            colNo: 25,
+            inApp: true,
+            trust: null,
+            errors: null,
+            vars: null,
+            minGroupingLevel: 0,
+          },
+        ],
+        framesOmitted: null,
+        registers: null,
+        hasSystemFrames: true,
+      },
+      rawStacktrace: {} as any,
+    },
+  ];
+  const url = `/projects/${organization.slug}/${project.slug}/events/${eventId}/source-map-debug/`;
+  const platform = 'javascript';
+  const debugFrames = getUniqueFilesFromException(exceptionValues, {
+    orgSlug: organization.slug,
+    projectSlug: project.slug,
+    eventId,
+  });
+
+  it('should use unqiue in app frames', () => {
+    expect(debugFrames).toHaveLength(1);
+    expect(debugFrames[0].filename).toBe(
+      './app/views/organizationStats/teamInsights/controls.tsx'
+    );
+  });
+
+  it('should show two messages for MISSING_RELEASE', async () => {
+    MockApiClient.addMockResponse({
+      url,
+      body: {
+        errors: [
+          {
+            type: SourceMapProcessingIssueType.MISSING_RELEASE,
+            message: '',
+            data: null,
+          },
+        ],
+      },
+      match: [MockApiClient.matchQuery({exception_idx: '0', frame_idx: '0'})],
+    });
+
+    render(<SourceMapDebug debugFrames={debugFrames} platform={platform} />, {
+      organization,
+    });
+    expect(
+      await screen.findByText(
+        'We’ve encountered 2 problems de-minifying your applications source code!'
+      )
+    ).toBeInTheDocument();
+
+    // Step 1
+    expect(
+      screen.getByText(
+        textWithMarkupMatcher(
+          'Update your Sentry.init call to pass in the release argument'
+        )
+      )
+    ).toBeInTheDocument();
+    // Step 2
+    expect(
+      screen.getByText(/Integrate Sentry into your release pipeline/)
+    ).toBeInTheDocument();
+    const links = screen.getAllByRole('link', {name: 'Read Guide'});
+    expect(links[0]).toHaveAttribute(
+      'href',
+      'https://docs.sentry.io/platforms/javascript/configuration/options/#release'
+    );
+    expect(links[1]).toHaveAttribute(
+      'href',
+      'https://docs.sentry.io/platforms/javascript/sourcemaps/#uploading-source-maps-to-sentry'
+    );
+  });
+
+  it('should fill message with data for PARTIAL_MATCH', async () => {
+    const error: SourceMapDebugError = {
+      type: SourceMapProcessingIssueType.PARTIAL_MATCH,
+      message: '',
+      data: {insertPath: 'insertPath', matchedSourcemapPath: 'matchedSourcemapPath'},
+    };
+    MockApiClient.addMockResponse({
+      url,
+      body: {errors: [error]},
+      match: [MockApiClient.matchQuery({exception_idx: '0', frame_idx: '0'})],
+    });
+
+    render(<SourceMapDebug debugFrames={debugFrames} platform={platform} />, {
+      organization,
+    });
+    expect(
+      await screen.findByText(
+        'We’ve encountered 1 problem de-minifying your applications source code!'
+      )
+    ).toBeInTheDocument();
+
+    expect(
+      screen.getByText(
+        'The abs_path of the stack frame is a partial match. The stack frame has the path insertPath which is a partial match to matchedSourcemapPath.',
+        {exact: false}
+      )
+    ).toBeInTheDocument();
+    expect(screen.getByRole('link', {name: 'Read Guide'})).toHaveAttribute(
+      'href',
+      'https://docs.sentry.io/platforms/javascript/sourcemaps/troubleshooting_js/#verify-artifact-names-match-stack-trace-frames'
+    );
+  });
+
+  it('should expand URL_NOT_VALID description and emit an analytics event', async () => {
+    const error: SourceMapDebugError = {
+      type: SourceMapProcessingIssueType.URL_NOT_VALID,
+      message: '',
+      data: {absValue: 'absValue'},
+    };
+    MockApiClient.addMockResponse({
+      url,
+      body: {errors: [error]},
+    });
+
+    render(<SourceMapDebug debugFrames={debugFrames} platform={platform} />, {
+      organization,
+    });
+    expect(
+      await screen.findByText(
+        'We’ve encountered 1 problem de-minifying your applications source code!'
+      )
+    ).toBeInTheDocument();
+
+    const expandedMessage =
+      'The abs_path of the stack frame has absValue which is not a valid URL.';
+    expect(
+      screen.queryByText(textWithMarkupMatcher(expandedMessage))
+    ).not.toBeInTheDocument();
+
+    userEvent.click(screen.getByRole('button', {name: 'Expand'}));
+    expect(trackAdvancedAnalyticsEvent).toHaveBeenCalledTimes(1);
+
+    expect(screen.getByText(textWithMarkupMatcher(expandedMessage))).toBeInTheDocument();
+    expect(screen.getByRole('link', {name: 'Read Guide'})).toHaveAttribute(
+      'href',
+      'https://docs.sentry.io/platforms/javascript/sourcemaps/troubleshooting_js/#verify-artifact-names-match-stack-trace-frames'
+    );
+  });
+});

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

@@ -1,6 +1,7 @@
 import {useContext} from 'react';
 
 import EmptyMessage from 'sentry/components/emptyMessage';
+import type {StacktraceFilenameQuery} from 'sentry/components/events/interfaces/crashContent/exception/useSourceMapDebug';
 import {Panel} from 'sentry/components/panels';
 import {IconWarning} from 'sentry/icons';
 import {t} from 'sentry/locale';
@@ -22,6 +23,7 @@ type Props = {
   hasHierarchicalGrouping: boolean;
   platform: PlatformType;
   stacktrace: ExceptionValue['stacktrace'];
+  debugFrames?: StacktraceFilenameQuery[];
   expandFirstFrame?: boolean;
   groupingCurrentLevel?: Group['metadata']['current_level'];
   meta?: Record<any, any>;
@@ -33,6 +35,7 @@ function StackTrace({
   stackView,
   stacktrace,
   chainedException,
+  debugFrames,
   platform,
   newestFirst,
   groupingCurrentLevel,
@@ -116,6 +119,7 @@ function StackTrace({
         newestFirst={newestFirst}
         event={event}
         meta={meta}
+        debugFrames={debugFrames}
       />
     );
   }
@@ -129,6 +133,7 @@ function StackTrace({
       newestFirst={newestFirst}
       event={event}
       meta={meta}
+      debugFrames={debugFrames}
     />
   );
 }

+ 193 - 0
static/app/components/events/interfaces/crashContent/exception/useSourceMapDebug.tsx

@@ -0,0 +1,193 @@
+import uniqBy from 'lodash/uniqBy';
+
+import type {ExceptionValue, Frame, Organization, PlatformType} from 'sentry/types';
+import {defined} from 'sentry/utils';
+import {QueryKey, useQueries, useQuery, UseQueryOptions} from 'sentry/utils/queryClient';
+import useApi from 'sentry/utils/useApi';
+
+interface BaseSourceMapDebugError {
+  message: string;
+  type: SourceMapProcessingIssueType;
+}
+
+interface UnknownErrorDebugError extends BaseSourceMapDebugError {
+  type: SourceMapProcessingIssueType.UNKNOWN_ERROR;
+}
+interface MissingReleaseDebugError extends BaseSourceMapDebugError {
+  type: SourceMapProcessingIssueType.MISSING_RELEASE;
+}
+interface MissingUserAgentDebugError extends BaseSourceMapDebugError {
+  data: {version: string};
+  type: SourceMapProcessingIssueType.MISSING_USER_AGENT;
+}
+interface MissingSourcemapsDebugError extends BaseSourceMapDebugError {
+  type: SourceMapProcessingIssueType.MISSING_SOURCEMAPS;
+}
+interface UrlNotValidDebugError extends BaseSourceMapDebugError {
+  data: {absValue: string};
+  type: SourceMapProcessingIssueType.URL_NOT_VALID;
+}
+interface PartialMatchDebugError extends BaseSourceMapDebugError {
+  data: {insertPath: string; matchedSourcemapPath: string};
+  type: SourceMapProcessingIssueType.PARTIAL_MATCH;
+}
+
+export type SourceMapDebugError =
+  | UnknownErrorDebugError
+  | MissingReleaseDebugError
+  | MissingUserAgentDebugError
+  | MissingSourcemapsDebugError
+  | UrlNotValidDebugError
+  | PartialMatchDebugError;
+
+export interface SourceMapDebugResponse {
+  errors: SourceMapDebugError[];
+}
+
+export enum SourceMapProcessingIssueType {
+  UNKNOWN_ERROR = 'unknown_error',
+  MISSING_RELEASE = 'no_release_on_event',
+  MISSING_USER_AGENT = 'no_user_agent_on_release',
+  MISSING_SOURCEMAPS = 'no_sourcemaps_on_release',
+  URL_NOT_VALID = 'url_not_valid',
+  PARTIAL_MATCH = 'partial_match',
+}
+
+const sourceMapDebugQuery = ({
+  orgSlug,
+  projectSlug,
+  eventId,
+  frameIdx,
+  exceptionIdx,
+}: UseSourceMapDebugProps): QueryKey => [
+  `/projects/${orgSlug}/${projectSlug}/events/${eventId}/source-map-debug/`,
+  {
+    query: {
+      frame_idx: `${frameIdx}`,
+      exception_idx: `${exceptionIdx}`,
+    },
+  },
+];
+
+interface UseSourceMapDebugProps {
+  eventId: string;
+  exceptionIdx: number;
+  frameIdx: number;
+  orgSlug: string;
+  projectSlug: string;
+}
+
+export type StacktraceFilenameQuery = {filename: string; query: UseSourceMapDebugProps};
+
+export function useSourceMapDebug(
+  props?: UseSourceMapDebugProps,
+  options: Partial<UseQueryOptions<SourceMapDebugResponse>> = {}
+) {
+  return useQuery<SourceMapDebugResponse>(props ? sourceMapDebugQuery(props) : [''], {
+    staleTime: Infinity,
+    retry: false,
+    refetchOnWindowFocus: false,
+    notifyOnChangeProps: ['data'],
+    ...options,
+    enabled: !!options.enabled && defined(props),
+  });
+}
+
+export function useSourceMapDebugQueries(props: UseSourceMapDebugProps[]) {
+  const api = useApi({persistInFlight: true});
+
+  const options = {
+    staleTime: Infinity,
+    retry: false,
+  };
+  return useQueries({
+    queries: props.map<UseQueryOptions<SourceMapDebugResponse>>(p => {
+      const key = sourceMapDebugQuery(p);
+      return {
+        queryKey: sourceMapDebugQuery(p),
+        // TODO: Move queryFn as a default in queryClient.tsx
+        queryFn: () =>
+          api.requestPromise(key[0], {
+            method: 'GET',
+            query: key[1]?.query,
+          }),
+        ...options,
+      };
+    }),
+  });
+}
+
+const ALLOWED_PLATFORMS = [
+  'node',
+  'javascript',
+  'javascript-react',
+  'javascript-angular',
+  'javascript-angularjs',
+  'javascript-backbone',
+  'javascript-ember',
+  'javascript-gatsby',
+  'javascript-vue',
+  'javascript-nextjs',
+  'javascript-remix',
+  'javascript-svelte',
+  // dart and unity might require more docs links
+  // 'dart',
+  // 'unity',
+];
+const MAX_FRAMES = 3;
+
+/**
+ * Check we have all required props and platform is supported
+ */
+export function debugFramesEnabled({
+  platform,
+  eventId,
+  organization,
+  projectSlug,
+}: {
+  platform: PlatformType;
+  eventId?: string;
+  organization?: Organization | null;
+  projectSlug?: string;
+}) {
+  if (!organization || !organization.features || !projectSlug || !eventId) {
+    return false;
+  }
+
+  if (!organization.features.includes('fix-source-map-cta')) {
+    return false;
+  }
+
+  return ALLOWED_PLATFORMS.includes(platform);
+}
+
+/**
+ * Returns an array of unique filenames and the first frame they appear in.
+ * Filters out non inApp frames and frames without a line number.
+ * Limited to only the first 3 unique filenames.
+ */
+export function getUniqueFilesFromException(
+  excValues: ExceptionValue[],
+  props: Omit<UseSourceMapDebugProps, 'frameIdx' | 'exceptionIdx'>
+): StacktraceFilenameQuery[] {
+  // Not using .at(-1) because we need to use the index later
+  const exceptionIdx = excValues.length - 1;
+  const fileFrame = (excValues[exceptionIdx]?.stacktrace?.frames ?? [])
+    // Get the frame numbers before filtering
+    .map<[Frame, number]>((frame, idx) => [frame, idx])
+    .filter(
+      ([frame]) =>
+        frame.inApp &&
+        frame.filename &&
+        // Line number might not work for non-javascript languages
+        defined(frame.lineNo)
+    )
+    .map<StacktraceFilenameQuery>(([frame, idx]) => ({
+      filename: frame.filename!,
+      query: {...props, frameIdx: idx, exceptionIdx},
+    }));
+
+  // Return only the first 3 unique filenames
+  // TODO: reverse only applies to newest first
+  return uniqBy(fileFrame.reverse(), ({filename}) => filename).slice(0, MAX_FRAMES);
+}

+ 4 - 0
static/app/components/events/interfaces/crashContent/stackTrace/content.tsx

@@ -2,6 +2,7 @@ import {cloneElement, Component} from 'react';
 import styled from '@emotion/styled';
 
 import GuideAnchor from 'sentry/components/assistant/guideAnchor';
+import {StacktraceFilenameQuery} from 'sentry/components/events/interfaces/crashContent/exception/useSourceMapDebug';
 import Panel from 'sentry/components/panels/panel';
 import {t} from 'sentry/locale';
 import {Frame, Organization, PlatformType} from 'sentry/types';
@@ -24,6 +25,7 @@ type Props = {
   event: Event;
   platform: PlatformType;
   className?: string;
+  debugFrames?: StacktraceFilenameQuery[];
   isHoverPreviewed?: boolean;
   meta?: Record<any, any>;
   newestFirst?: boolean;
@@ -138,6 +140,7 @@ class Content extends Component<Props, State> {
       includeSystemFrames,
       isHoverPreviewed,
       meta,
+      debugFrames,
     } = this.props;
 
     const {showingAbsoluteAddresses, showCompleteFunctionName} = this.state;
@@ -233,6 +236,7 @@ class Content extends Component<Props, State> {
             isFirst={newestFirst ? frameIdx === lastFrameIdx : frameIdx === 0}
             frameMeta={meta?.frames?.[frameIdx]}
             registersMeta={meta?.registers}
+            debugFrames={debugFrames}
           />
         );
       }

+ 4 - 0
static/app/components/events/interfaces/crashContent/stackTrace/contentV2.tsx

@@ -11,6 +11,7 @@ import {StacktraceType} from 'sentry/types/stacktrace';
 
 import Line from '../../frame/lineV2';
 import {getImageRange, parseAddress, stackTracePlatformIcon} from '../../utils';
+import {StacktraceFilenameQuery} from '../exception/useSourceMapDebug';
 
 import StacktracePlatformIcon from './platformIcon';
 
@@ -19,6 +20,7 @@ type Props = {
   event: Event;
   platform: PlatformType;
   className?: string;
+  debugFrames?: StacktraceFilenameQuery[];
   expandFirstFrame?: boolean;
   groupingCurrentLevel?: Group['metadata']['current_level'];
   includeSystemFrames?: boolean;
@@ -29,6 +31,7 @@ type Props = {
 
 function Content({
   data,
+  debugFrames,
   platform,
   event,
   newestFirst,
@@ -196,6 +199,7 @@ function Content({
             isUsedForGrouping,
             frameMeta: meta?.frames?.[frameIndex],
             registersMeta: meta?.registers,
+            debugFrames,
           };
 
           nRepeats = 0;

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