stacktraceLink.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420
  1. import {Fragment} from 'react';
  2. import styled from '@emotion/styled';
  3. import {openModal} from 'sentry/actionCreators/modal';
  4. import {promptsCheck, promptsUpdate} from 'sentry/actionCreators/prompts';
  5. import {ResponseMeta} from 'sentry/api';
  6. import Access from 'sentry/components/acl/access';
  7. import AsyncComponent from 'sentry/components/asyncComponent';
  8. import {Body, Header, Hovercard} from 'sentry/components/hovercard';
  9. import {IconInfo} from 'sentry/icons';
  10. import {IconClose} from 'sentry/icons/iconClose';
  11. import {t, tct} from 'sentry/locale';
  12. import space from 'sentry/styles/space';
  13. import {
  14. Frame,
  15. Integration,
  16. Organization,
  17. Project,
  18. RepositoryProjectPathConfigWithIntegration,
  19. } from 'sentry/types';
  20. import {Event} from 'sentry/types/event';
  21. import {StacktraceLinkEvents} from 'sentry/utils/analytics/integrations/stacktraceLinkAnalyticsEvents';
  22. import handleXhrErrorResponse from 'sentry/utils/handleXhrErrorResponse';
  23. import {
  24. getIntegrationIcon,
  25. trackIntegrationAnalytics,
  26. } from 'sentry/utils/integrationUtil';
  27. import {promptIsDismissed} from 'sentry/utils/promptIsDismissed';
  28. import withOrganization from 'sentry/utils/withOrganization';
  29. import withProjects from 'sentry/utils/withProjects';
  30. import {OpenInContainer, OpenInLink, OpenInName} from './openInContextLine';
  31. import StacktraceLinkModal from './stacktraceLinkModal';
  32. type Props = AsyncComponent['props'] & {
  33. event: Event;
  34. frame: Frame;
  35. lineNo: number;
  36. organization: Organization;
  37. projects: Project[];
  38. };
  39. export type StacktraceErrorMessage =
  40. | 'file_not_found'
  41. | 'stack_root_mismatch'
  42. | 'integration_link_forbidden';
  43. // format of the ProjectStacktraceLinkEndpoint response
  44. type StacktraceResultItem = {
  45. integrations: Integration[];
  46. attemptedUrl?: string;
  47. config?: RepositoryProjectPathConfigWithIntegration;
  48. error?: StacktraceErrorMessage;
  49. sourceUrl?: string;
  50. };
  51. type State = AsyncComponent['state'] & {
  52. isDismissed: boolean;
  53. match: StacktraceResultItem;
  54. promptLoaded: boolean;
  55. };
  56. class StacktraceLink extends AsyncComponent<Props, State> {
  57. get project() {
  58. // we can't use the withProject HoC on an the issue page
  59. // so we ge around that by using the withProjects HoC
  60. // and look up the project from the list
  61. const {projects, event} = this.props;
  62. return projects.find(project => project.id === event.projectID);
  63. }
  64. get match() {
  65. return this.state.match;
  66. }
  67. get config() {
  68. return this.match.config;
  69. }
  70. get integrations() {
  71. return this.match.integrations;
  72. }
  73. get errorText() {
  74. const error = this.match.error;
  75. switch (error) {
  76. case 'stack_root_mismatch':
  77. return t('Error matching your configuration.');
  78. case 'file_not_found':
  79. return t('Source file not found.');
  80. case 'integration_link_forbidden':
  81. return t('The repository integration was disconnected.');
  82. default:
  83. return t('There was an error encountered with the code mapping for this project');
  84. }
  85. }
  86. componentDidMount() {
  87. this.promptsCheck();
  88. }
  89. async promptsCheck() {
  90. const {organization} = this.props;
  91. const prompt = await promptsCheck(this.api, {
  92. organizationId: organization.id,
  93. projectId: this.project?.id,
  94. feature: 'stacktrace_link',
  95. });
  96. this.setState({
  97. isDismissed: promptIsDismissed(prompt),
  98. promptLoaded: true,
  99. });
  100. }
  101. dismissPrompt() {
  102. const {organization} = this.props;
  103. promptsUpdate(this.api, {
  104. organizationId: organization.id,
  105. projectId: this.project?.id,
  106. feature: 'stacktrace_link',
  107. status: 'dismissed',
  108. });
  109. trackIntegrationAnalytics('integrations.stacktrace_link_cta_dismissed', {
  110. view: 'stacktrace_issue_details',
  111. organization,
  112. });
  113. this.setState({isDismissed: true});
  114. }
  115. getEndpoints(): ReturnType<AsyncComponent['getEndpoints']> {
  116. const {organization, frame, event} = this.props;
  117. const project = this.project;
  118. if (!project) {
  119. throw new Error('Unable to find project');
  120. }
  121. const commitId = event.release?.lastCommit?.id;
  122. const platform = event.platform;
  123. const sdkName = event.sdk?.name;
  124. return [
  125. [
  126. 'match',
  127. `/projects/${organization.slug}/${project.slug}/stacktrace-link/`,
  128. {
  129. query: {
  130. file: frame.filename,
  131. platform,
  132. commitId,
  133. ...(sdkName && {sdkName}),
  134. ...(frame.absPath && {absPath: frame.absPath}),
  135. ...(frame.module && {module: frame.module}),
  136. ...(frame.package && {package: frame.package}),
  137. },
  138. },
  139. ],
  140. ];
  141. }
  142. onRequestError(resp: ResponseMeta) {
  143. handleXhrErrorResponse('Unable to fetch stack trace link')(resp);
  144. }
  145. getDefaultState(): State {
  146. return {
  147. ...super.getDefaultState(),
  148. showModal: false,
  149. sourceCodeInput: '',
  150. match: {integrations: []},
  151. isDismissed: false,
  152. promptLoaded: false,
  153. };
  154. }
  155. onOpenLink() {
  156. const provider = this.config?.provider;
  157. if (provider) {
  158. trackIntegrationAnalytics(
  159. StacktraceLinkEvents.OPEN_LINK,
  160. {
  161. view: 'stacktrace_issue_details',
  162. provider: provider.key,
  163. organization: this.props.organization,
  164. },
  165. {startSession: true}
  166. );
  167. }
  168. }
  169. onReconfigureMapping() {
  170. const provider = this.config?.provider;
  171. const error = this.match.error;
  172. if (provider) {
  173. trackIntegrationAnalytics(
  174. 'integrations.reconfigure_stacktrace_setup',
  175. {
  176. view: 'stacktrace_issue_details',
  177. provider: provider.key,
  178. error_reason: error,
  179. organization: this.props.organization,
  180. },
  181. {startSession: true}
  182. );
  183. }
  184. }
  185. handleSubmit = () => {
  186. this.reloadData();
  187. };
  188. // don't show the error boundary if the component fails.
  189. // capture the endpoint error on onRequestError
  190. renderError(): React.ReactNode {
  191. return null;
  192. }
  193. renderLoading() {
  194. // TODO: Add loading
  195. return null;
  196. }
  197. renderNoMatch() {
  198. const {organization} = this.props;
  199. const filename = this.props.frame.filename;
  200. const platform = this.props.event.platform;
  201. if (this.project && this.integrations.length > 0 && filename) {
  202. return (
  203. <Access organization={organization} access={['org:integrations']}>
  204. {({hasAccess}) =>
  205. hasAccess && (
  206. <CodeMappingButtonContainer columnQuantity={2}>
  207. {tct('[link:Link your stack trace to your source code.]', {
  208. link: (
  209. <a
  210. onClick={() => {
  211. trackIntegrationAnalytics(
  212. 'integrations.stacktrace_start_setup',
  213. {
  214. view: 'stacktrace_issue_details',
  215. platform,
  216. organization,
  217. },
  218. {startSession: true}
  219. );
  220. openModal(
  221. deps =>
  222. this.project && (
  223. <StacktraceLinkModal
  224. onSubmit={this.handleSubmit}
  225. filename={filename}
  226. project={this.project}
  227. organization={organization}
  228. integrations={this.integrations}
  229. {...deps}
  230. />
  231. )
  232. );
  233. }}
  234. />
  235. ),
  236. })}
  237. <StyledIconClose size="xs" onClick={() => this.dismissPrompt()} />
  238. </CodeMappingButtonContainer>
  239. )
  240. }
  241. </Access>
  242. );
  243. }
  244. return null;
  245. }
  246. renderHovercard() {
  247. const error = this.match.error;
  248. const url = this.match.attemptedUrl;
  249. const {frame} = this.props;
  250. const {config} = this.match;
  251. return (
  252. <Fragment>
  253. <StyledHovercard
  254. header={
  255. error === 'stack_root_mismatch' ? (
  256. <span>{t('Mismatch between filename and stack root')}</span>
  257. ) : (
  258. <span>{t('Unable to find source code url')}</span>
  259. )
  260. }
  261. body={
  262. error === 'stack_root_mismatch' ? (
  263. <HeaderContainer>
  264. <HovercardLine>
  265. filename: <code>{`${frame.filename}`}</code>
  266. </HovercardLine>
  267. <HovercardLine>
  268. stack root: <code>{`${config?.stackRoot}`}</code>
  269. </HovercardLine>
  270. </HeaderContainer>
  271. ) : (
  272. <HeaderContainer>
  273. <HovercardLine>{url}</HovercardLine>
  274. </HeaderContainer>
  275. )
  276. }
  277. >
  278. <StyledIconInfo size="xs" />
  279. </StyledHovercard>
  280. </Fragment>
  281. );
  282. }
  283. renderMatchNoUrl() {
  284. const {config, error} = this.match;
  285. const {organization} = this.props;
  286. const url = `/settings/${organization.slug}/integrations/${config?.provider.key}/${config?.integrationId}/?tab=codeMappings`;
  287. return (
  288. <CodeMappingButtonContainer columnQuantity={2}>
  289. <ErrorInformation>
  290. {error && this.renderHovercard()}
  291. <ErrorText>{this.errorText}</ErrorText>
  292. {tct('[link:Configure Stack Trace Linking] to fix this problem.', {
  293. link: (
  294. <a
  295. onClick={() => {
  296. this.onReconfigureMapping();
  297. }}
  298. href={url}
  299. />
  300. ),
  301. })}
  302. </ErrorInformation>
  303. </CodeMappingButtonContainer>
  304. );
  305. }
  306. renderMatchWithUrl(config: RepositoryProjectPathConfigWithIntegration, url: string) {
  307. url = `${url}#L${this.props.frame.lineNo}`;
  308. return (
  309. <OpenInContainer columnQuantity={2}>
  310. <div>{t('Open this line in')}</div>
  311. <OpenInLink onClick={() => this.onOpenLink()} href={url} openInNewTab>
  312. <StyledIconWrapper>{getIntegrationIcon(config.provider.key)}</StyledIconWrapper>
  313. <OpenInName>{config.provider.name}</OpenInName>
  314. </OpenInLink>
  315. </OpenInContainer>
  316. );
  317. }
  318. renderBody() {
  319. const {config, sourceUrl} = this.match || {};
  320. const {isDismissed, promptLoaded} = this.state;
  321. if (config && sourceUrl) {
  322. return this.renderMatchWithUrl(config, sourceUrl);
  323. }
  324. if (config) {
  325. return this.renderMatchNoUrl();
  326. }
  327. if (!promptLoaded || (promptLoaded && isDismissed)) {
  328. return null;
  329. }
  330. return this.renderNoMatch();
  331. }
  332. }
  333. export default withProjects(withOrganization(StacktraceLink));
  334. export {StacktraceLink};
  335. export const CodeMappingButtonContainer = styled(OpenInContainer)`
  336. justify-content: space-between;
  337. `;
  338. const StyledIconWrapper = styled('span')`
  339. color: inherit;
  340. line-height: 0;
  341. `;
  342. const StyledIconClose = styled(IconClose)`
  343. margin: auto;
  344. cursor: pointer;
  345. `;
  346. const StyledIconInfo = styled(IconInfo)`
  347. margin-right: ${space(0.5)};
  348. margin-bottom: -2px;
  349. cursor: pointer;
  350. line-height: 0;
  351. `;
  352. const StyledHovercard = styled(Hovercard)`
  353. font-weight: normal;
  354. width: inherit;
  355. line-height: 0;
  356. ${Header} {
  357. font-weight: strong;
  358. font-size: ${p => p.theme.fontSizeSmall};
  359. color: ${p => p.theme.subText};
  360. }
  361. ${Body} {
  362. font-weight: normal;
  363. font-size: ${p => p.theme.fontSizeSmall};
  364. }
  365. `;
  366. const HeaderContainer = styled('div')`
  367. width: 100%;
  368. display: flex;
  369. justify-content: space-between;
  370. `;
  371. const HovercardLine = styled('div')`
  372. padding-bottom: 3px;
  373. `;
  374. const ErrorInformation = styled('div')`
  375. padding-right: 5px;
  376. margin-right: ${space(1)};
  377. `;
  378. const ErrorText = styled('span')`
  379. margin-right: ${space(0.5)};
  380. `;