123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420 |
- import {Fragment} from 'react';
- import styled from '@emotion/styled';
- import {openModal} from 'sentry/actionCreators/modal';
- import {promptsCheck, promptsUpdate} from 'sentry/actionCreators/prompts';
- import {ResponseMeta} from 'sentry/api';
- import Access from 'sentry/components/acl/access';
- import AsyncComponent from 'sentry/components/asyncComponent';
- import {Body, Header, Hovercard} from 'sentry/components/hovercard';
- import {IconInfo} from 'sentry/icons';
- import {IconClose} from 'sentry/icons/iconClose';
- import {t, tct} from 'sentry/locale';
- import space from 'sentry/styles/space';
- import {
- Frame,
- Integration,
- Organization,
- Project,
- RepositoryProjectPathConfigWithIntegration,
- } from 'sentry/types';
- import {Event} from 'sentry/types/event';
- import {StacktraceLinkEvents} from 'sentry/utils/analytics/integrations/stacktraceLinkAnalyticsEvents';
- import handleXhrErrorResponse from 'sentry/utils/handleXhrErrorResponse';
- import {
- getIntegrationIcon,
- trackIntegrationAnalytics,
- } from 'sentry/utils/integrationUtil';
- import {promptIsDismissed} from 'sentry/utils/promptIsDismissed';
- import withOrganization from 'sentry/utils/withOrganization';
- import withProjects from 'sentry/utils/withProjects';
- import {OpenInContainer, OpenInLink, OpenInName} from './openInContextLine';
- import StacktraceLinkModal from './stacktraceLinkModal';
- type Props = AsyncComponent['props'] & {
- event: Event;
- frame: Frame;
- lineNo: number;
- organization: Organization;
- projects: Project[];
- };
- export type StacktraceErrorMessage =
- | 'file_not_found'
- | 'stack_root_mismatch'
- | 'integration_link_forbidden';
- // format of the ProjectStacktraceLinkEndpoint response
- type StacktraceResultItem = {
- integrations: Integration[];
- attemptedUrl?: string;
- config?: RepositoryProjectPathConfigWithIntegration;
- error?: StacktraceErrorMessage;
- sourceUrl?: string;
- };
- type State = AsyncComponent['state'] & {
- isDismissed: boolean;
- match: StacktraceResultItem;
- promptLoaded: boolean;
- };
- class StacktraceLink extends AsyncComponent<Props, State> {
- get project() {
- // we can't use the withProject HoC on an the issue page
- // so we ge around that by using the withProjects HoC
- // and look up the project from the list
- const {projects, event} = this.props;
- return projects.find(project => project.id === event.projectID);
- }
- get match() {
- return this.state.match;
- }
- get config() {
- return this.match.config;
- }
- get integrations() {
- return this.match.integrations;
- }
- get errorText() {
- const error = this.match.error;
- switch (error) {
- case 'stack_root_mismatch':
- return t('Error matching your configuration.');
- case 'file_not_found':
- return t('Source file not found.');
- case 'integration_link_forbidden':
- return t('The repository integration was disconnected.');
- default:
- return t('There was an error encountered with the code mapping for this project');
- }
- }
- componentDidMount() {
- this.promptsCheck();
- }
- async promptsCheck() {
- const {organization} = this.props;
- const prompt = await promptsCheck(this.api, {
- organizationId: organization.id,
- projectId: this.project?.id,
- feature: 'stacktrace_link',
- });
- this.setState({
- isDismissed: promptIsDismissed(prompt),
- promptLoaded: true,
- });
- }
- dismissPrompt() {
- const {organization} = this.props;
- promptsUpdate(this.api, {
- organizationId: organization.id,
- projectId: this.project?.id,
- feature: 'stacktrace_link',
- status: 'dismissed',
- });
- trackIntegrationAnalytics('integrations.stacktrace_link_cta_dismissed', {
- view: 'stacktrace_issue_details',
- organization,
- });
- this.setState({isDismissed: true});
- }
- getEndpoints(): ReturnType<AsyncComponent['getEndpoints']> {
- const {organization, frame, event} = this.props;
- const project = this.project;
- if (!project) {
- throw new Error('Unable to find project');
- }
- const commitId = event.release?.lastCommit?.id;
- const platform = event.platform;
- const sdkName = event.sdk?.name;
- return [
- [
- 'match',
- `/projects/${organization.slug}/${project.slug}/stacktrace-link/`,
- {
- query: {
- file: frame.filename,
- platform,
- commitId,
- ...(sdkName && {sdkName}),
- ...(frame.absPath && {absPath: frame.absPath}),
- ...(frame.module && {module: frame.module}),
- ...(frame.package && {package: frame.package}),
- },
- },
- ],
- ];
- }
- onRequestError(resp: ResponseMeta) {
- handleXhrErrorResponse('Unable to fetch stack trace link')(resp);
- }
- getDefaultState(): State {
- return {
- ...super.getDefaultState(),
- showModal: false,
- sourceCodeInput: '',
- match: {integrations: []},
- isDismissed: false,
- promptLoaded: false,
- };
- }
- onOpenLink() {
- const provider = this.config?.provider;
- if (provider) {
- trackIntegrationAnalytics(
- StacktraceLinkEvents.OPEN_LINK,
- {
- view: 'stacktrace_issue_details',
- provider: provider.key,
- organization: this.props.organization,
- },
- {startSession: true}
- );
- }
- }
- onReconfigureMapping() {
- const provider = this.config?.provider;
- const error = this.match.error;
- if (provider) {
- trackIntegrationAnalytics(
- 'integrations.reconfigure_stacktrace_setup',
- {
- view: 'stacktrace_issue_details',
- provider: provider.key,
- error_reason: error,
- organization: this.props.organization,
- },
- {startSession: true}
- );
- }
- }
- handleSubmit = () => {
- this.reloadData();
- };
- // don't show the error boundary if the component fails.
- // capture the endpoint error on onRequestError
- renderError(): React.ReactNode {
- return null;
- }
- renderLoading() {
- // TODO: Add loading
- return null;
- }
- renderNoMatch() {
- const {organization} = this.props;
- const filename = this.props.frame.filename;
- const platform = this.props.event.platform;
- if (this.project && this.integrations.length > 0 && filename) {
- return (
- <Access organization={organization} access={['org:integrations']}>
- {({hasAccess}) =>
- hasAccess && (
- <CodeMappingButtonContainer columnQuantity={2}>
- {tct('[link:Link your stack trace to your source code.]', {
- link: (
- <a
- onClick={() => {
- trackIntegrationAnalytics(
- 'integrations.stacktrace_start_setup',
- {
- view: 'stacktrace_issue_details',
- platform,
- organization,
- },
- {startSession: true}
- );
- openModal(
- deps =>
- this.project && (
- <StacktraceLinkModal
- onSubmit={this.handleSubmit}
- filename={filename}
- project={this.project}
- organization={organization}
- integrations={this.integrations}
- {...deps}
- />
- )
- );
- }}
- />
- ),
- })}
- <StyledIconClose size="xs" onClick={() => this.dismissPrompt()} />
- </CodeMappingButtonContainer>
- )
- }
- </Access>
- );
- }
- return null;
- }
- renderHovercard() {
- const error = this.match.error;
- const url = this.match.attemptedUrl;
- const {frame} = this.props;
- const {config} = this.match;
- return (
- <Fragment>
- <StyledHovercard
- header={
- error === 'stack_root_mismatch' ? (
- <span>{t('Mismatch between filename and stack root')}</span>
- ) : (
- <span>{t('Unable to find source code url')}</span>
- )
- }
- body={
- error === 'stack_root_mismatch' ? (
- <HeaderContainer>
- <HovercardLine>
- filename: <code>{`${frame.filename}`}</code>
- </HovercardLine>
- <HovercardLine>
- stack root: <code>{`${config?.stackRoot}`}</code>
- </HovercardLine>
- </HeaderContainer>
- ) : (
- <HeaderContainer>
- <HovercardLine>{url}</HovercardLine>
- </HeaderContainer>
- )
- }
- >
- <StyledIconInfo size="xs" />
- </StyledHovercard>
- </Fragment>
- );
- }
- renderMatchNoUrl() {
- const {config, error} = this.match;
- const {organization} = this.props;
- const url = `/settings/${organization.slug}/integrations/${config?.provider.key}/${config?.integrationId}/?tab=codeMappings`;
- return (
- <CodeMappingButtonContainer columnQuantity={2}>
- <ErrorInformation>
- {error && this.renderHovercard()}
- <ErrorText>{this.errorText}</ErrorText>
- {tct('[link:Configure Stack Trace Linking] to fix this problem.', {
- link: (
- <a
- onClick={() => {
- this.onReconfigureMapping();
- }}
- href={url}
- />
- ),
- })}
- </ErrorInformation>
- </CodeMappingButtonContainer>
- );
- }
- renderMatchWithUrl(config: RepositoryProjectPathConfigWithIntegration, url: string) {
- url = `${url}#L${this.props.frame.lineNo}`;
- return (
- <OpenInContainer columnQuantity={2}>
- <div>{t('Open this line in')}</div>
- <OpenInLink onClick={() => this.onOpenLink()} href={url} openInNewTab>
- <StyledIconWrapper>{getIntegrationIcon(config.provider.key)}</StyledIconWrapper>
- <OpenInName>{config.provider.name}</OpenInName>
- </OpenInLink>
- </OpenInContainer>
- );
- }
- renderBody() {
- const {config, sourceUrl} = this.match || {};
- const {isDismissed, promptLoaded} = this.state;
- if (config && sourceUrl) {
- return this.renderMatchWithUrl(config, sourceUrl);
- }
- if (config) {
- return this.renderMatchNoUrl();
- }
- if (!promptLoaded || (promptLoaded && isDismissed)) {
- return null;
- }
- return this.renderNoMatch();
- }
- }
- export default withProjects(withOrganization(StacktraceLink));
- export {StacktraceLink};
- export const CodeMappingButtonContainer = styled(OpenInContainer)`
- justify-content: space-between;
- `;
- const StyledIconWrapper = styled('span')`
- color: inherit;
- line-height: 0;
- `;
- const StyledIconClose = styled(IconClose)`
- margin: auto;
- cursor: pointer;
- `;
- const StyledIconInfo = styled(IconInfo)`
- margin-right: ${space(0.5)};
- margin-bottom: -2px;
- cursor: pointer;
- line-height: 0;
- `;
- const StyledHovercard = styled(Hovercard)`
- font-weight: normal;
- width: inherit;
- line-height: 0;
- ${Header} {
- font-weight: strong;
- font-size: ${p => p.theme.fontSizeSmall};
- color: ${p => p.theme.subText};
- }
- ${Body} {
- font-weight: normal;
- font-size: ${p => p.theme.fontSizeSmall};
- }
- `;
- const HeaderContainer = styled('div')`
- width: 100%;
- display: flex;
- justify-content: space-between;
- `;
- const HovercardLine = styled('div')`
- padding-bottom: 3px;
- `;
- const ErrorInformation = styled('div')`
- padding-right: 5px;
- margin-right: ${space(1)};
- `;
- const ErrorText = styled('span')`
- margin-right: ${space(0.5)};
- `;
|