stacktraceLink.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460
  1. import {useEffect, useMemo} from 'react';
  2. import {css} from '@emotion/react';
  3. import styled from '@emotion/styled';
  4. import {openModal} from 'sentry/actionCreators/modal';
  5. import {
  6. makePromptsCheckQueryKey,
  7. PromptResponse,
  8. promptsUpdate,
  9. usePromptsCheck,
  10. } from 'sentry/actionCreators/prompts';
  11. import {Button} from 'sentry/components/button';
  12. import ExternalLink from 'sentry/components/links/externalLink';
  13. import Link from 'sentry/components/links/link';
  14. import Placeholder from 'sentry/components/placeholder';
  15. import type {PlatformKey} from 'sentry/data/platformCategories';
  16. import {IconCircle, IconCircleFill, IconClose, IconWarning} from 'sentry/icons';
  17. import {t} from 'sentry/locale';
  18. import {space} from 'sentry/styles/space';
  19. import {
  20. CodecovStatusCode,
  21. Coverage,
  22. Event,
  23. Frame,
  24. LineCoverage,
  25. Organization,
  26. Project,
  27. StacktraceLinkResult,
  28. } from 'sentry/types';
  29. import {defined} from 'sentry/utils';
  30. import {StacktraceLinkEvents} from 'sentry/utils/analytics/integrations/stacktraceLinkAnalyticsEvents';
  31. import {getAnalyticsDataForEvent} from 'sentry/utils/events';
  32. import {
  33. getIntegrationIcon,
  34. trackIntegrationAnalytics,
  35. } from 'sentry/utils/integrationUtil';
  36. import {promptIsDismissed} from 'sentry/utils/promptIsDismissed';
  37. import {useQueryClient} from 'sentry/utils/queryClient';
  38. import useApi from 'sentry/utils/useApi';
  39. import useOrganization from 'sentry/utils/useOrganization';
  40. import useProjects from 'sentry/utils/useProjects';
  41. import {OpenInContainer} from './openInContextLine';
  42. import StacktraceLinkModal from './stacktraceLinkModal';
  43. import useStacktraceLink from './useStacktraceLink';
  44. const supportedStacktracePlatforms: PlatformKey[] = [
  45. 'go',
  46. 'javascript',
  47. 'node',
  48. 'php',
  49. 'python',
  50. 'ruby',
  51. 'elixir',
  52. ];
  53. interface StacktraceLinkSetupProps {
  54. event: Event;
  55. organization: Organization;
  56. project?: Project;
  57. }
  58. function StacktraceLinkSetup({organization, project, event}: StacktraceLinkSetupProps) {
  59. const api = useApi();
  60. const queryClient = useQueryClient();
  61. const dismissPrompt = () => {
  62. promptsUpdate(api, {
  63. organizationId: organization.id,
  64. projectId: project?.id,
  65. feature: 'stacktrace_link',
  66. status: 'dismissed',
  67. });
  68. // Update cached query data
  69. // Will set prompt to dismissed
  70. queryClient.setQueryData<PromptResponse>(
  71. makePromptsCheckQueryKey({
  72. feature: 'stacktrace_link',
  73. organizationId: organization.id,
  74. projectId: project?.id,
  75. }),
  76. () => {
  77. const dimissedTs = new Date().getTime() / 1000;
  78. return {
  79. data: {dismissed_ts: dimissedTs},
  80. features: {stacktrace_link: {dismissed_ts: dimissedTs}},
  81. };
  82. }
  83. );
  84. trackIntegrationAnalytics(StacktraceLinkEvents.DISMISS_CTA, {
  85. view: 'stacktrace_issue_details',
  86. organization,
  87. ...getAnalyticsDataForEvent(event),
  88. });
  89. };
  90. return (
  91. <CodeMappingButtonContainer columnQuantity={2}>
  92. <StyledLink to={`/settings/${organization.slug}/integrations/`}>
  93. <StyledIconWrapper>{getIntegrationIcon('github', 'sm')}</StyledIconWrapper>
  94. {t('Add the GitHub or GitLab integration to jump straight to your source code')}
  95. </StyledLink>
  96. <CloseButton priority="link" onClick={dismissPrompt}>
  97. <IconClose size="xs" aria-label={t('Close')} />
  98. </CloseButton>
  99. </CodeMappingButtonContainer>
  100. );
  101. }
  102. function shouldshowCodecovFeatures(
  103. organization: Organization,
  104. match: StacktraceLinkResult
  105. ) {
  106. const enabled =
  107. organization.features.includes('codecov-stacktrace-integration') &&
  108. organization.codecovAccess;
  109. const codecovStatus = match.codecov?.status;
  110. const validStatus = codecovStatus && codecovStatus !== CodecovStatusCode.NO_INTEGRATION;
  111. return enabled && validStatus && match.config?.provider.key === 'github';
  112. }
  113. interface CodecovLinkProps {
  114. event: Event;
  115. lineNo: number | null;
  116. organization: Organization;
  117. coverageUrl?: string;
  118. lineCoverage?: LineCoverage[];
  119. status?: CodecovStatusCode;
  120. }
  121. function getCoverageIcon(lineCoverage, lineNo) {
  122. const covIndex = lineCoverage.findIndex(line => line[0] === lineNo);
  123. if (covIndex === -1) {
  124. return null;
  125. }
  126. switch (lineCoverage[covIndex][1]) {
  127. case Coverage.COVERED:
  128. return (
  129. <CoverageIcon>
  130. <IconCircleFill size="xs" color="green100" style={{position: 'absolute'}} />
  131. <IconCircle size="xs" color="green300" />
  132. {t('Covered')}
  133. </CoverageIcon>
  134. );
  135. case Coverage.PARTIAL:
  136. return (
  137. <CoverageIcon>
  138. <IconCircleFill size="xs" color="yellow100" style={{position: 'absolute'}} />
  139. <IconCircle size="xs" color="yellow300" />
  140. {t('Partially Covered')}
  141. </CoverageIcon>
  142. );
  143. case Coverage.NOT_COVERED:
  144. return (
  145. <CodecovContainer>
  146. <CoverageIcon>
  147. <IconCircleFill size="xs" color="red100" style={{position: 'absolute'}} />
  148. <IconCircle size="xs" color="red300" />
  149. </CoverageIcon>
  150. {t('Not Covered')}
  151. </CodecovContainer>
  152. );
  153. default:
  154. return null;
  155. }
  156. }
  157. function CodecovLink({
  158. coverageUrl,
  159. status = CodecovStatusCode.COVERAGE_EXISTS,
  160. lineCoverage,
  161. lineNo,
  162. organization,
  163. event,
  164. }: CodecovLinkProps) {
  165. if (status === CodecovStatusCode.NO_COVERAGE_DATA) {
  166. return (
  167. <CodecovWarning>
  168. {t('Code Coverage not found')}
  169. <IconWarning size="xs" color="errorText" />
  170. </CodecovWarning>
  171. );
  172. }
  173. if (status === CodecovStatusCode.COVERAGE_EXISTS) {
  174. if (!coverageUrl || !lineCoverage || !lineNo) {
  175. return null;
  176. }
  177. const onOpenCodecovLink = () => {
  178. trackIntegrationAnalytics(StacktraceLinkEvents.CODECOV_LINK_CLICKED, {
  179. view: 'stacktrace_issue_details',
  180. organization,
  181. group_id: event.groupID ? parseInt(event.groupID, 10) : -1,
  182. ...getAnalyticsDataForEvent(event),
  183. });
  184. };
  185. return (
  186. <CodecovContainer>
  187. {getCoverageIcon(lineCoverage, lineNo)}
  188. <OpenInLink href={coverageUrl} openInNewTab onClick={onOpenCodecovLink}>
  189. <StyledIconWrapper>{getIntegrationIcon('codecov', 'sm')}</StyledIconWrapper>
  190. {t('Open in Codecov')}
  191. </OpenInLink>
  192. </CodecovContainer>
  193. );
  194. }
  195. return null;
  196. }
  197. interface StacktraceLinkProps {
  198. event: Event;
  199. frame: Frame;
  200. /**
  201. * The line of code being linked
  202. */
  203. line: string;
  204. }
  205. export function StacktraceLink({frame, event, line}: StacktraceLinkProps) {
  206. const organization = useOrganization();
  207. const {projects} = useProjects();
  208. const project = useMemo(
  209. () => projects.find(p => p.id === event.projectID),
  210. [projects, event]
  211. );
  212. const prompt = usePromptsCheck({
  213. feature: 'stacktrace_link',
  214. organizationId: organization.id,
  215. projectId: project?.id,
  216. });
  217. const isPromptDismissed =
  218. prompt.isSuccess && prompt.data.data
  219. ? promptIsDismissed({
  220. dismissedTime: prompt.data.data.dismissed_ts,
  221. snoozedTime: prompt.data.data.snoozed_ts,
  222. })
  223. : false;
  224. const {
  225. data: match,
  226. isLoading,
  227. refetch,
  228. } = useStacktraceLink(
  229. {
  230. event,
  231. frame,
  232. orgSlug: organization.slug,
  233. projectSlug: project?.slug,
  234. },
  235. {
  236. enabled: defined(project),
  237. }
  238. );
  239. useEffect(() => {
  240. if (isLoading || prompt.isLoading || !match) {
  241. return;
  242. }
  243. trackIntegrationAnalytics(StacktraceLinkEvents.LINK_VIEWED, {
  244. view: 'stacktrace_issue_details',
  245. organization,
  246. platform: project?.platform,
  247. project_id: project?.id,
  248. state:
  249. // Should follow the same logic in render
  250. match.sourceUrl
  251. ? 'match'
  252. : match.error || match.integrations.length > 0
  253. ? 'no_match'
  254. : !isPromptDismissed
  255. ? 'prompt'
  256. : 'empty',
  257. ...getAnalyticsDataForEvent(event),
  258. });
  259. // excluding isPromptDismissed because we want this only to record once
  260. // eslint-disable-next-line react-hooks/exhaustive-deps
  261. }, [isLoading, prompt.isLoading, match, organization, project, event]);
  262. const onOpenLink = () => {
  263. const provider = match!.config?.provider;
  264. if (provider) {
  265. trackIntegrationAnalytics(
  266. StacktraceLinkEvents.OPEN_LINK,
  267. {
  268. view: 'stacktrace_issue_details',
  269. provider: provider.key,
  270. organization,
  271. group_id: event.groupID ? parseInt(event.groupID, 10) : -1,
  272. ...getAnalyticsDataForEvent(event),
  273. },
  274. {startSession: true}
  275. );
  276. }
  277. };
  278. const handleSubmit = () => {
  279. refetch();
  280. };
  281. if (isLoading || !match) {
  282. return (
  283. <CodeMappingButtonContainer columnQuantity={2}>
  284. <Placeholder height="24px" width="60px" />
  285. </CodeMappingButtonContainer>
  286. );
  287. }
  288. // Match found - display link to source
  289. if (match.config && match.sourceUrl) {
  290. return (
  291. <CodeMappingButtonContainer columnQuantity={2}>
  292. <OpenInLink
  293. onClick={onOpenLink}
  294. href={`${match!.sourceUrl}#L${frame.lineNo}`}
  295. openInNewTab
  296. >
  297. <StyledIconWrapper>
  298. {getIntegrationIcon(match.config.provider.key, 'sm')}
  299. </StyledIconWrapper>
  300. {t('Open this line in %s', match.config.provider.name)}
  301. </OpenInLink>
  302. {shouldshowCodecovFeatures(organization, match) && (
  303. <CodecovLink
  304. coverageUrl={`${match.codecov?.coverageUrl}#L${frame.lineNo}`}
  305. status={match.codecov?.status}
  306. lineCoverage={match.codecov?.lineCoverage}
  307. lineNo={frame.lineNo}
  308. organization={organization}
  309. event={event}
  310. />
  311. )}
  312. </CodeMappingButtonContainer>
  313. );
  314. }
  315. // Hide stacktrace link errors if the stacktrace might be minified javascript
  316. // Check if the line starts and ends with {snip}
  317. const isMinifiedJsError =
  318. event.platform === 'javascript' && /(\{snip\}).*\1/.test(line);
  319. const isUnsupportedPlatform = !supportedStacktracePlatforms.includes(
  320. event.platform as PlatformKey
  321. );
  322. const hideErrors = isMinifiedJsError || isUnsupportedPlatform;
  323. // No match found - Has integration but no code mappings
  324. if (!hideErrors && (match.error || match.integrations.length > 0)) {
  325. const filename = frame.filename;
  326. if (!project || !match.integrations.length || !filename) {
  327. return null;
  328. }
  329. const sourceCodeProviders = match.integrations.filter(integration =>
  330. ['github', 'gitlab'].includes(integration.provider?.key)
  331. );
  332. return (
  333. <CodeMappingButtonContainer columnQuantity={2}>
  334. <FixMappingButton
  335. priority="link"
  336. icon={
  337. sourceCodeProviders.length === 1
  338. ? getIntegrationIcon(sourceCodeProviders[0].provider.key, 'sm')
  339. : undefined
  340. }
  341. onClick={() => {
  342. trackIntegrationAnalytics(
  343. StacktraceLinkEvents.START_SETUP,
  344. {
  345. view: 'stacktrace_issue_details',
  346. platform: event.platform,
  347. organization,
  348. ...getAnalyticsDataForEvent(event),
  349. },
  350. {startSession: true}
  351. );
  352. openModal(deps => (
  353. <StacktraceLinkModal
  354. onSubmit={handleSubmit}
  355. filename={filename}
  356. project={project}
  357. organization={organization}
  358. integrations={match.integrations}
  359. {...deps}
  360. />
  361. ));
  362. }}
  363. >
  364. {t('Tell us where your source code is')}
  365. </FixMappingButton>
  366. </CodeMappingButtonContainer>
  367. );
  368. }
  369. // No integrations, but prompt is dismissed or hidden
  370. if (hideErrors || isPromptDismissed) {
  371. return null;
  372. }
  373. // No integrations
  374. return (
  375. <StacktraceLinkSetup event={event} project={project} organization={organization} />
  376. );
  377. }
  378. export const CodeMappingButtonContainer = styled(OpenInContainer)`
  379. justify-content: space-between;
  380. min-height: 28px;
  381. `;
  382. const FixMappingButton = styled(Button)`
  383. color: ${p => p.theme.subText};
  384. `;
  385. const CloseButton = styled(Button)`
  386. color: ${p => p.theme.subText};
  387. `;
  388. const StyledIconWrapper = styled('span')`
  389. color: inherit;
  390. line-height: 0;
  391. `;
  392. const LinkStyles = css`
  393. display: flex;
  394. align-items: center;
  395. gap: ${space(0.75)};
  396. `;
  397. const OpenInLink = styled(ExternalLink)`
  398. ${LinkStyles}
  399. color: ${p => p.theme.gray300};
  400. `;
  401. const StyledLink = styled(Link)`
  402. ${LinkStyles}
  403. color: ${p => p.theme.gray300};
  404. `;
  405. const CodecovWarning = styled('div')`
  406. display: flex;
  407. color: ${p => p.theme.errorText};
  408. gap: ${space(0.75)};
  409. align-items: center;
  410. `;
  411. const CodecovContainer = styled('span')`
  412. display: flex;
  413. gap: ${space(0.75)};
  414. `;
  415. const CoverageIcon = styled('span')`
  416. display: flex;
  417. gap: ${space(0.75)};
  418. align-items: center;
  419. `;