Browse Source

feat(issues): Setup integration stacktrace banner (#63406)

Scott Cooper 1 year ago
parent
commit
2b5de2caf2

+ 1 - 1
static/app/actionCreators/prompts.tsx

@@ -35,7 +35,7 @@ type PromptCheckParams = {
   /**
    * The prompt feature name
    */
-  feature: string;
+  feature: string | string[];
   organization: OrganizationSummary;
   /**
    * The numeric project ID as a string

+ 61 - 0
static/app/components/events/interfaces/crashContent/exception/banners/addCodecovBanner.tsx

@@ -0,0 +1,61 @@
+import styled from '@emotion/styled';
+
+import addCoverage from 'sentry-images/spot/add-coverage.svg';
+
+import {LinkButton} from 'sentry/components/button';
+import {IconClose} from 'sentry/icons';
+import {t} from 'sentry/locale';
+
+import {
+  CloseBannerButton,
+  IntegationBannerDescription,
+  IntegationBannerTitle,
+  StacktraceIntegrationBannerWrapper,
+} from './addIntegrationBanner';
+
+interface AddCodecovBannerProps {
+  onClick: () => void;
+  onDismiss: () => void;
+  orgSlug: string;
+}
+
+export function AddCodecovBanner({onDismiss, onClick, orgSlug}: AddCodecovBannerProps) {
+  return (
+    <StacktraceIntegrationBannerWrapper>
+      <div>
+        <IntegationBannerTitle>
+          {t('View Test Coverage with CodeCov')}
+        </IntegationBannerTitle>
+        <IntegationBannerDescription>
+          {t(
+            'Enable CodeCov to get quick, line-by-line test coverage information in stack traces.'
+          )}
+        </IntegationBannerDescription>
+        <LinkButton to={`/settings/${orgSlug}/organization/`} size="sm" onClick={onClick}>
+          {t('Enable in Settings')}
+        </LinkButton>
+      </div>
+      <CoverageBannerImage src={addCoverage} />
+      <CloseBannerButton
+        borderless
+        priority="link"
+        aria-label={t('Dismiss')}
+        icon={<IconClose color="subText" />}
+        size="xs"
+        onClick={onDismiss}
+      />
+    </StacktraceIntegrationBannerWrapper>
+  );
+}
+
+const CoverageBannerImage = styled('img')`
+  position: absolute;
+  display: block;
+  bottom: 6px;
+  right: 4rem;
+  pointer-events: none;
+
+  @media (max-width: ${p => p.theme.breakpoints.small}) {
+    display: none;
+  }
+`;

+ 97 - 0
static/app/components/events/interfaces/crashContent/exception/banners/addIntegrationBanner.tsx

@@ -0,0 +1,97 @@
+import styled from '@emotion/styled';
+
+import addIntegrationProvider from 'sentry-images/spot/add-integration-provider.svg';
+
+import {Button, LinkButton} from 'sentry/components/button';
+import {IconClose} from 'sentry/icons';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+
+interface AddIntegrationBannerProps {
+  onDismiss: () => void;
+  orgSlug: string;
+}
+
+/**
+ * Displayed when there are no installed source integrations (github/gitlab/etc)
+ */
+export function AddIntegrationBanner({orgSlug, onDismiss}: AddIntegrationBannerProps) {
+  return (
+    <StacktraceIntegrationBannerWrapper>
+      <div>
+        <IntegationBannerTitle>{t('Connect with Git Providers')}</IntegationBannerTitle>
+        <IntegationBannerDescription>
+          {t(
+            'Install Git providers (GitHub, Gitlab…) to enable features like code mapping and stack trace linking.'
+          )}
+        </IntegationBannerDescription>
+        <LinkButton
+          to={{
+            pathname: `/settings/${orgSlug}/integrations/`,
+            // This should filter to only source code management integrations
+            query: {category: 'source code management'},
+          }}
+          size="sm"
+        >
+          {t('Get Started')}
+        </LinkButton>
+      </div>
+      <IntegrationBannerImage src={addIntegrationProvider} />
+      <CloseBannerButton
+        borderless
+        priority="link"
+        aria-label={t('Dismiss')}
+        icon={<IconClose color="subText" />}
+        size="xs"
+        onClick={onDismiss}
+      />
+    </StacktraceIntegrationBannerWrapper>
+  );
+}
+
+export const StacktraceIntegrationBannerWrapper = styled('div')`
+  position: relative;
+  border: 1px solid ${p => p.theme.border};
+  border-radius: ${p => p.theme.borderRadius};
+  padding: ${space(2)};
+  margin: ${space(1)} 0;
+  background: linear-gradient(
+    90deg,
+    ${p => p.theme.backgroundSecondary}00 0%,
+    ${p => p.theme.backgroundSecondary}FF 70%,
+    ${p => p.theme.backgroundSecondary}FF 100%
+  );
+`;
+
+export const IntegationBannerTitle = styled('div')`
+  font-size: ${p => p.theme.fontSizeExtraLarge};
+  margin-bottom: ${space(1)};
+  font-weight: 600;
+`;
+
+export const IntegationBannerDescription = styled('div')`
+  margin-bottom: ${space(1.5)};
+  max-width: 340px;
+`;
+
+export const CloseBannerButton = styled(Button)`
+  position: absolute;
+  display: block;
+  top: ${space(2)};
+  right: ${space(2)};
+  color: ${p => p.theme.white};
+  cursor: pointer;
+  z-index: 1;
+`;
+
+const IntegrationBannerImage = styled('img')`
+  position: absolute;
+  display: block;
+  bottom: 0px;
+  right: 4rem;
+  pointer-events: none;
+
+  @media (max-width: ${p => p.theme.breakpoints.small}) {
+    display: none;
+  }
+`;

+ 196 - 0
static/app/components/events/interfaces/crashContent/exception/banners/stacktraceBanners.spec.tsx

@@ -0,0 +1,196 @@
+import {EventFixture} from 'sentry-fixture/event';
+import {EventEntryStacktraceFixture} from 'sentry-fixture/eventEntryStacktrace';
+import {GitHubIntegrationFixture} from 'sentry-fixture/githubIntegration';
+import {OrganizationFixture} from 'sentry-fixture/organization';
+import {ProjectFixture} from 'sentry-fixture/project';
+import {UserFixture} from 'sentry-fixture/user';
+
+import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
+
+import ConfigStore from 'sentry/stores/configStore';
+import HookStore from 'sentry/stores/hookStore';
+import ProjectsStore from 'sentry/stores/projectsStore';
+import {EventOrGroupType, StacktraceType} from 'sentry/types';
+import * as analytics from 'sentry/utils/analytics';
+
+import {StacktraceBanners} from './stacktraceBanners';
+
+describe('StacktraceBanners', () => {
+  const org = OrganizationFixture({
+    features: ['codecov-integration', 'issue-details-stacktrace-link-in-frame'],
+  });
+  const project = ProjectFixture({});
+
+  const eventEntryStacktrace = EventEntryStacktraceFixture();
+  const inAppFrame = eventEntryStacktrace.data.frames![0]!;
+  inAppFrame.inApp = true;
+  const event = EventFixture({
+    projectID: project.id,
+    entries: [eventEntryStacktrace],
+    type: EventOrGroupType.ERROR,
+  });
+  const stacktrace = eventEntryStacktrace.data as Required<StacktraceType>;
+
+  const analyticsSpy = jest.spyOn(analytics, 'trackAnalytics');
+  let promptActivity: jest.Mock;
+  let promptsUpdateMock: jest.Mock;
+
+  beforeEach(() => {
+    jest.clearAllMocks();
+    MockApiClient.clearMockResponses();
+    promptActivity = MockApiClient.addMockResponse({
+      method: 'GET',
+      url: `/organizations/${org.slug}/prompts-activity/`,
+      body: {},
+    });
+    promptsUpdateMock = MockApiClient.addMockResponse({
+      url: '/organizations/org-slug/prompts-activity/',
+      method: 'PUT',
+    });
+    ProjectsStore.loadInitialData([project]);
+    HookStore.init?.();
+    // Can be removed with issueDetailsNewExperienceQ42023
+    ConfigStore.set(
+      'user',
+      UserFixture({
+        options: {
+          ...UserFixture().options,
+          issueDetailsNewExperienceQ42023: true,
+        },
+      })
+    );
+  });
+
+  it('renders nothing with no in app frames', () => {
+    const {container} = render(
+      <StacktraceBanners stacktrace={EventEntryStacktraceFixture().data} event={event} />,
+      {
+        organization: org,
+      }
+    );
+    expect(container).toBeEmptyDOMElement();
+  });
+
+  it('renders add integration and allows dismissing', async () => {
+    const stacktraceLinkMock = MockApiClient.addMockResponse({
+      url: `/projects/${org.slug}/${project.slug}/stacktrace-link/`,
+      body: {config: null, sourceUrl: null, integrations: []},
+    });
+    const {container} = render(
+      <StacktraceBanners stacktrace={stacktrace} event={event} />,
+      {
+        organization: org,
+      }
+    );
+    expect(await screen.findByText('Connect with Git Providers')).toBeInTheDocument();
+    expect(stacktraceLinkMock).toHaveBeenCalledTimes(1);
+    expect(stacktraceLinkMock).toHaveBeenCalledWith(
+      `/projects/${org.slug}/${project.slug}/stacktrace-link/`,
+      expect.objectContaining({
+        query: {
+          file: inAppFrame.filename,
+          absPath: inAppFrame.absPath,
+          commitId: event.release?.lastCommit?.id,
+          lineNo: inAppFrame.lineNo,
+          module: inAppFrame.module,
+          platform: undefined,
+          groupId: event.groupID,
+        },
+      })
+    );
+    expect(promptActivity).toHaveBeenCalledTimes(1);
+    expect(promptActivity).toHaveBeenCalledWith(
+      `/organizations/${org.slug}/prompts-activity/`,
+      expect.objectContaining({
+        query: {
+          feature: ['stacktrace_link', 'codecov_stacktrace_prompt'],
+          organization_id: org.id,
+          project_id: project.id,
+        },
+      })
+    );
+
+    await userEvent.click(screen.getByRole('button', {name: 'Dismiss'}));
+    expect(container).toBeEmptyDOMElement();
+
+    expect(analyticsSpy).toHaveBeenCalledTimes(1);
+    expect(promptsUpdateMock).toHaveBeenCalledTimes(1);
+    expect(promptsUpdateMock).toHaveBeenCalledWith(
+      '/organizations/org-slug/prompts-activity/',
+      expect.objectContaining({
+        data: {
+          feature: 'stacktrace_link',
+          organization_id: org.id,
+          project_id: project.id,
+          status: 'dismissed',
+        },
+      })
+    );
+  });
+
+  it('renders add codecov and allows dismissing', async () => {
+    const stacktraceLinkMock = MockApiClient.addMockResponse({
+      url: `/projects/${org.slug}/${project.slug}/stacktrace-link/`,
+      body: {
+        config: {
+          provider: {
+            key: 'github',
+          },
+        },
+        sourceUrl: null,
+        integrations: [GitHubIntegrationFixture()],
+      },
+    });
+    const {container} = render(
+      <StacktraceBanners stacktrace={stacktrace} event={event} />,
+      {
+        organization: org,
+      }
+    );
+    expect(
+      await screen.findByText('View Test Coverage with CodeCov')
+    ).toBeInTheDocument();
+    expect(stacktraceLinkMock).toHaveBeenCalledTimes(1);
+    expect(stacktraceLinkMock).toHaveBeenCalledWith(
+      `/projects/${org.slug}/${project.slug}/stacktrace-link/`,
+      expect.objectContaining({
+        query: {
+          file: inAppFrame.filename,
+          absPath: inAppFrame.absPath,
+          commitId: event.release?.lastCommit?.id,
+          lineNo: inAppFrame.lineNo,
+          module: inAppFrame.module,
+          platform: undefined,
+          groupId: event.groupID,
+        },
+      })
+    );
+    expect(promptActivity).toHaveBeenCalledTimes(1);
+    expect(promptActivity).toHaveBeenCalledWith(
+      `/organizations/${org.slug}/prompts-activity/`,
+      expect.objectContaining({
+        query: {
+          feature: ['stacktrace_link', 'codecov_stacktrace_prompt'],
+          organization_id: org.id,
+          project_id: project.id,
+        },
+      })
+    );
+
+    await userEvent.click(screen.getByRole('button', {name: 'Dismiss'}));
+    expect(container).toBeEmptyDOMElement();
+
+    expect(promptsUpdateMock).toHaveBeenCalledTimes(1);
+    expect(promptsUpdateMock).toHaveBeenCalledWith(
+      '/organizations/org-slug/prompts-activity/',
+      expect.objectContaining({
+        data: {
+          feature: 'codecov_stacktrace_prompt',
+          organization_id: org.id,
+          project_id: project.id,
+          status: 'dismissed',
+        },
+      })
+    );
+  });
+});

+ 178 - 0
static/app/components/events/interfaces/crashContent/exception/banners/stacktraceBanners.tsx

@@ -0,0 +1,178 @@
+import {useMemo} from 'react';
+
+import {
+  makePromptsCheckQueryKey,
+  PromptResponse,
+  promptsUpdate,
+  usePromptsCheck,
+} from 'sentry/actionCreators/prompts';
+import useStacktraceLink from 'sentry/components/events/interfaces/frame/useStacktraceLink';
+import {
+  hasFileExtension,
+  hasStacktraceLinkInFrameFeature,
+} from 'sentry/components/events/interfaces/frame/utils';
+import type {
+  Event,
+  Frame,
+  Organization,
+  StacktraceLinkResult,
+  StacktraceType,
+} from 'sentry/types';
+import {trackAnalytics} from 'sentry/utils/analytics';
+import {getAnalyticsDataForEvent} from 'sentry/utils/events';
+import {promptIsDismissed} from 'sentry/utils/promptIsDismissed';
+import {setApiQueryData, useQueryClient} from 'sentry/utils/queryClient';
+import useApi from 'sentry/utils/useApi';
+import useOrganization from 'sentry/utils/useOrganization';
+import useProjects from 'sentry/utils/useProjects';
+import {useUser} from 'sentry/utils/useUser';
+
+import {AddCodecovBanner} from './addCodecovBanner';
+import {AddIntegrationBanner} from './addIntegrationBanner';
+
+function shouldShowCodecovPrompt(
+  organization: Organization,
+  stacktraceLink: StacktraceLinkResult
+) {
+  const enabled =
+    organization.features.includes('codecov-integration') && !organization.codecovAccess;
+
+  return enabled && stacktraceLink.config?.provider.key === 'github';
+}
+
+function getPromptStatus(promptData: ReturnType<typeof usePromptsCheck>, key: string) {
+  return promptData.isSuccess && promptData.data.features
+    ? promptIsDismissed({
+        dismissedTime: promptData.data.features[key]?.dismissed_ts,
+        snoozedTime: promptData.data.features[key]?.snoozed_ts,
+      })
+    : false;
+}
+
+const integrationPromptKey = 'stacktrace_link';
+const codecovPromptKey = 'codecov_stacktrace_prompt';
+
+interface StacktraceBannersProps {
+  event: Event;
+  stacktrace: StacktraceType;
+}
+
+export function StacktraceBanners({stacktrace, event}: StacktraceBannersProps) {
+  const user = useUser();
+  const organization = useOrganization({allowNull: true});
+  const api = useApi();
+  const queryClient = useQueryClient();
+  const {projects} = useProjects();
+  const expectedDefaultFrame: Frame | undefined = (stacktrace.frames ?? [])
+    .filter(
+      frame =>
+        frame && frame.inApp && hasFileExtension(frame.absPath || frame.filename || '')
+    )
+    .at(-1);
+  const project = useMemo(
+    () => projects.find(p => p.id === event.projectID),
+    [projects, event]
+  );
+
+  const hasInFrameFeature = hasStacktraceLinkInFrameFeature(organization, user);
+  const enabled =
+    !!organization && !!expectedDefaultFrame && !!project && hasInFrameFeature;
+
+  const {data: stacktraceLink} = useStacktraceLink(
+    {
+      event,
+      frame: expectedDefaultFrame ?? {},
+      orgSlug: organization?.slug!,
+      projectSlug: project?.slug!,
+    },
+    {
+      enabled,
+    }
+  );
+  const promptKeys = organization?.features.includes('codecov-integration')
+    ? [integrationPromptKey, codecovPromptKey]
+    : integrationPromptKey;
+  const prompt = usePromptsCheck(
+    {
+      organization: organization!,
+      feature: promptKeys,
+      projectId: project?.id,
+    },
+    {
+      enabled,
+    }
+  );
+
+  if (!enabled || !stacktraceLink) {
+    return null;
+  }
+
+  const dismissPrompt = (key: string) => {
+    promptsUpdate(api, {
+      organization,
+      projectId: project?.id,
+      feature: key,
+      status: 'dismissed',
+    });
+
+    // Update cached query data
+    // Will set prompt to dismissed
+    setApiQueryData<PromptResponse>(
+      queryClient,
+      makePromptsCheckQueryKey({
+        organization,
+        feature: promptKeys,
+        projectId: project?.id,
+      }),
+      () => {
+        const dimissedTs = new Date().getTime() / 1000;
+        return {
+          data: {dismissed_ts: dimissedTs},
+          features: {[key]: {dismissed_ts: dimissedTs}},
+        };
+      }
+    );
+  };
+
+  // No integrations installed, show banner
+  if (
+    stacktraceLink.integrations.length === 0 &&
+    !getPromptStatus(prompt, integrationPromptKey)
+  ) {
+    return (
+      <AddIntegrationBanner
+        orgSlug={organization.slug}
+        onDismiss={() => {
+          dismissPrompt(integrationPromptKey);
+          trackAnalytics('integrations.stacktrace_link_cta_dismissed', {
+            view: 'stacktrace_issue_details',
+            organization,
+            ...getAnalyticsDataForEvent(event),
+          });
+        }}
+      />
+    );
+  }
+
+  if (
+    shouldShowCodecovPrompt(organization, stacktraceLink) &&
+    !getPromptStatus(prompt, codecovPromptKey)
+  ) {
+    return (
+      <AddCodecovBanner
+        orgSlug={organization.slug}
+        onClick={() => {
+          trackAnalytics('integrations.stacktrace_codecov_prompt_clicked', {
+            view: 'stacktrace_link',
+            organization,
+          });
+        }}
+        onDismiss={() => {
+          dismissPrompt(codecovPromptKey);
+        }}
+      />
+    );
+  }
+
+  return null;
+}

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

@@ -2,6 +2,8 @@ import {useState} from 'react';
 import styled from '@emotion/styled';
 
 import {Button} from 'sentry/components/button';
+import ErrorBoundary from 'sentry/components/errorBoundary';
+import {StacktraceBanners} from 'sentry/components/events/interfaces/crashContent/exception/banners/stacktraceBanners';
 import {
   prepareSourceMapDebuggerFrameInformation,
   useSourceMapDebuggerData,
@@ -167,6 +169,9 @@ export function Content({
 
     const platform = getStacktracePlatform(event, exc.stacktrace);
 
+    // The banners should appear on the top exception only
+    const isTopException = newestFirst ? excIdx === values.length - 1 : excIdx === 0;
+
     return (
       <div key={excIdx} className="exception" data-test-id="exception-value">
         {defined(exc?.module) ? (
@@ -195,6 +200,11 @@ export function Content({
           newestFirst={newestFirst}
           onExceptionClick={expandException}
         />
+        {exc.stacktrace && isTopException && (
+          <ErrorBoundary customComponent={null}>
+            <StacktraceBanners event={event} stacktrace={exc.stacktrace} />
+          </ErrorBoundary>
+        )}
         <StackTrace
           data={
             type === StackType.ORIGINAL

+ 3 - 0
static/app/components/events/interfaces/frame/stacktraceLink.tsx

@@ -134,6 +134,9 @@ function shouldShowCodecovFeatures(
   );
 }
 
+/**
+ * TODO(scttcper): Should be removed w/ GA issue-details-stacktrace-link-in-frame
+ */
 function shouldShowCodecovPrompt(
   organization: Organization,
   match: StacktraceLinkResult

+ 26 - 6
static/app/components/events/interfaces/threads.tsx

@@ -1,7 +1,9 @@
 import {Fragment, useEffect, useState} from 'react';
 import styled from '@emotion/styled';
 
+import ErrorBoundary from 'sentry/components/errorBoundary';
 import {EventDataSection} from 'sentry/components/events/eventDataSection';
+import {StacktraceBanners} from 'sentry/components/events/interfaces/crashContent/exception/banners/stacktraceBanners';
 import {getLockReason} from 'sentry/components/events/interfaces/threads/threadSelector/lockReason';
 import {
   getMappedThreadState,
@@ -349,12 +351,30 @@ export function Threads({
         stackTraceNotFound={stackTraceNotFound}
         wrapTitle={false}
       >
-        {childrenProps => (
-          <Fragment>
-            {!organization.features.includes('anr-improvements') && renderPills()}
-            {renderContent(childrenProps)}
-          </Fragment>
-        )}
+        {childrenProps => {
+          // TODO(scttcper): These are duplicated from renderContent, should consolidate
+          const stackType = childrenProps.display.includes('minified')
+            ? StackType.MINIFIED
+            : StackType.ORIGINAL;
+          const isRaw = childrenProps.display.includes('raw-stack-trace');
+          const stackTrace = getThreadStacktrace(
+            stackType !== StackType.ORIGINAL,
+            activeThread
+          );
+
+          return (
+            <Fragment>
+              {!organization.features.includes('anr-improvements') && renderPills()}
+
+              {stackTrace && !isRaw && (
+                <ErrorBoundary customComponent={null}>
+                  <StacktraceBanners event={event} stacktrace={stackTrace} />
+                </ErrorBoundary>
+              )}
+              {renderContent(childrenProps)}
+            </Fragment>
+          );
+        }}
       </TraceEventDataSection>
     </Fragment>
   );

File diff suppressed because it is too large
+ 0 - 0
static/images/spot/add-coverage.svg


File diff suppressed because it is too large
+ 0 - 0
static/images/spot/add-integration-provider.svg


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