stacktraceLink.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430
  1. import {useEffect, useMemo, useState} from 'react';
  2. import {css, keyframes} from '@emotion/react';
  3. import styled from '@emotion/styled';
  4. import {openModal} from 'sentry/actionCreators/modal';
  5. import {Button} from 'sentry/components/button';
  6. import {useStacktraceCoverage} from 'sentry/components/events/interfaces/frame/useStacktraceCoverage';
  7. import {hasFileExtension} from 'sentry/components/events/interfaces/frame/utils';
  8. import ExternalLink from 'sentry/components/links/externalLink';
  9. import Placeholder from 'sentry/components/placeholder';
  10. import {Tooltip} from 'sentry/components/tooltip';
  11. import {IconWarning} from 'sentry/icons';
  12. import {t} from 'sentry/locale';
  13. import {space} from 'sentry/styles/space';
  14. import type {
  15. Event,
  16. Frame,
  17. Organization,
  18. PlatformKey,
  19. StacktraceLinkResult,
  20. } from 'sentry/types';
  21. import {CodecovStatusCode} from 'sentry/types';
  22. import {trackAnalytics} from 'sentry/utils/analytics';
  23. import {getAnalyticsDataForEvent} from 'sentry/utils/events';
  24. import {getIntegrationIcon, getIntegrationSourceUrl} from 'sentry/utils/integrationUtil';
  25. import useRouteAnalyticsParams from 'sentry/utils/routeAnalytics/useRouteAnalyticsParams';
  26. import useOrganization from 'sentry/utils/useOrganization';
  27. import useProjects from 'sentry/utils/useProjects';
  28. import StacktraceLinkModal from './stacktraceLinkModal';
  29. import useStacktraceLink from './useStacktraceLink';
  30. const supportedStacktracePlatforms: PlatformKey[] = [
  31. 'go',
  32. 'javascript',
  33. 'node',
  34. 'php',
  35. 'python',
  36. 'ruby',
  37. 'elixir',
  38. ];
  39. function shouldShowCodecovFeatures(
  40. organization: Organization,
  41. match: StacktraceLinkResult,
  42. codecovStatus: CodecovStatusCode
  43. ) {
  44. const validStatus = codecovStatus && codecovStatus !== CodecovStatusCode.NO_INTEGRATION;
  45. return (
  46. organization.codecovAccess && validStatus && match.config?.provider.key === 'github'
  47. );
  48. }
  49. interface CodecovLinkProps {
  50. event: Event;
  51. organization: Organization;
  52. coverageUrl?: string;
  53. status?: CodecovStatusCode;
  54. }
  55. function CodecovLink({
  56. coverageUrl,
  57. status = CodecovStatusCode.COVERAGE_EXISTS,
  58. organization,
  59. event,
  60. }: CodecovLinkProps) {
  61. if (status === CodecovStatusCode.NO_COVERAGE_DATA) {
  62. return (
  63. <CodecovWarning>
  64. {t('Code Coverage not found')}
  65. <IconWarning size="xs" color="errorText" />
  66. </CodecovWarning>
  67. );
  68. }
  69. if (status !== CodecovStatusCode.COVERAGE_EXISTS || !coverageUrl) {
  70. return null;
  71. }
  72. const onOpenCodecovLink = (e: React.MouseEvent) => {
  73. e.stopPropagation();
  74. trackAnalytics('integrations.stacktrace_codecov_link_clicked', {
  75. view: 'stacktrace_issue_details',
  76. organization,
  77. group_id: event.groupID ? parseInt(event.groupID, 10) : -1,
  78. ...getAnalyticsDataForEvent(event),
  79. });
  80. };
  81. return (
  82. <OpenInLink
  83. href={coverageUrl}
  84. openInNewTab
  85. onClick={onOpenCodecovLink}
  86. aria-label={t('Open in Codecov')}
  87. >
  88. <Tooltip title={t('Open in Codecov')} skipWrapper>
  89. <StyledIconWrapper>{getIntegrationIcon('codecov', 'sm')}</StyledIconWrapper>
  90. </Tooltip>
  91. </OpenInLink>
  92. );
  93. }
  94. interface StacktraceLinkProps {
  95. event: Event;
  96. frame: Frame;
  97. /**
  98. * The line of code being linked
  99. */
  100. line: string;
  101. }
  102. export function StacktraceLink({frame, event, line}: StacktraceLinkProps) {
  103. const organization = useOrganization();
  104. const {projects} = useProjects();
  105. const validFilePath = hasFileExtension(frame.absPath || frame.filename || '');
  106. // TODO: Currently we only support GitHub links. Implement support for other source code providers.
  107. // Related comment: https://github.com/getsentry/sentry/pull/62596#discussion_r1443025242
  108. const hasGithubSourceLink = (frame.sourceLink || '').startsWith(
  109. 'https://www.github.com/'
  110. );
  111. const [isQueryEnabled, setIsQueryEnabled] = useState(
  112. hasGithubSourceLink ? false : !frame.inApp
  113. );
  114. const project = useMemo(
  115. () => projects.find(p => p.id === event.projectID),
  116. [projects, event]
  117. );
  118. useEffect(() => {
  119. let timer: ReturnType<typeof setTimeout> | undefined;
  120. if (!validFilePath) {
  121. return setIsQueryEnabled(false);
  122. }
  123. // Skip fetching if we already have the Source Link
  124. if (!hasGithubSourceLink && frame.inApp) {
  125. // Introduce a delay before enabling the query
  126. timer = setTimeout(() => {
  127. setIsQueryEnabled(true);
  128. }, 100); // Delay of 100ms
  129. }
  130. return () => timer && clearTimeout(timer);
  131. }, [validFilePath, hasGithubSourceLink, frame]);
  132. const {
  133. data: match,
  134. isLoading,
  135. refetch,
  136. } = useStacktraceLink(
  137. {
  138. event,
  139. frame,
  140. orgSlug: organization.slug,
  141. projectSlug: project?.slug,
  142. },
  143. {
  144. enabled: isQueryEnabled, // The query will not run until `isQueryEnabled` is true
  145. }
  146. );
  147. const coverageEnabled =
  148. isQueryEnabled && organization.features.includes('codecov-integration');
  149. const {data: coverage, isLoading: isLoadingCoverage} = useStacktraceCoverage(
  150. {
  151. event,
  152. frame,
  153. orgSlug: organization.slug,
  154. projectSlug: project?.slug,
  155. },
  156. {
  157. enabled: coverageEnabled,
  158. }
  159. );
  160. useRouteAnalyticsParams(
  161. match
  162. ? {
  163. stacktrace_link_viewed: true,
  164. stacktrace_link_status: match.sourceUrl
  165. ? 'match'
  166. : match.error || match.integrations.length
  167. ? 'no_match'
  168. : 'empty',
  169. }
  170. : {}
  171. );
  172. const onOpenLink = (e: React.MouseEvent, sourceLink: Frame['sourceLink'] = null) => {
  173. e.stopPropagation();
  174. const provider = match?.config?.provider;
  175. if (provider) {
  176. trackAnalytics(
  177. 'integrations.stacktrace_link_clicked',
  178. {
  179. view: 'stacktrace_issue_details',
  180. provider: provider.key,
  181. organization,
  182. group_id: event.groupID ? parseInt(event.groupID, 10) : -1,
  183. ...getAnalyticsDataForEvent(event),
  184. },
  185. {startSession: true}
  186. );
  187. }
  188. if (sourceLink) {
  189. const url = new URL(sourceLink);
  190. const hostname = url.hostname;
  191. const parts = hostname.split('.');
  192. const domain = parts.length > 1 ? parts[1] : '';
  193. trackAnalytics(
  194. 'integrations.non_inapp_stacktrace_link_clicked',
  195. {
  196. view: 'stacktrace_issue_details',
  197. provider: domain,
  198. organization,
  199. group_id: event.groupID ? parseInt(event.groupID, 10) : -1,
  200. ...getAnalyticsDataForEvent(event),
  201. },
  202. {startSession: true}
  203. );
  204. }
  205. };
  206. const handleSubmit = () => {
  207. refetch();
  208. };
  209. if (!validFilePath) {
  210. return null;
  211. }
  212. // Render the provided `sourceLink` for all the non-inapp frames for `csharp` platform Issues
  213. // We skip fetching from the API for these frames.
  214. if (!match && hasGithubSourceLink && !frame.inApp && frame.sourceLink) {
  215. return (
  216. <StacktraceLinkWrapper>
  217. <Tooltip title={t('Open this line in GitHub')} skipWrapper>
  218. <OpenInLink
  219. onClick={e => onOpenLink(e, frame.sourceLink)}
  220. href={frame.sourceLink}
  221. openInNewTab
  222. aria-label={t('GitHub')}
  223. >
  224. <StyledIconWrapper>{getIntegrationIcon('github', 'sm')}</StyledIconWrapper>
  225. </OpenInLink>
  226. </Tooltip>
  227. </StacktraceLinkWrapper>
  228. );
  229. }
  230. if (isLoading || !match) {
  231. return (
  232. <StacktraceLinkWrapper>
  233. <Placeholder height="14px" width="40px" />
  234. </StacktraceLinkWrapper>
  235. );
  236. }
  237. // Match found - display link to source
  238. if (match.config && match.sourceUrl) {
  239. const label = t('Open this line in %s', match.config.provider.name);
  240. return (
  241. <StacktraceLinkWrapper>
  242. <OpenInLink
  243. onClick={onOpenLink}
  244. href={getIntegrationSourceUrl(
  245. match.config.provider.key,
  246. match!.sourceUrl,
  247. frame.lineNo
  248. )}
  249. openInNewTab
  250. aria-label={label}
  251. >
  252. <Tooltip title={label} skipWrapper>
  253. <StyledIconWrapper>
  254. {getIntegrationIcon(match.config.provider.key, 'sm')}
  255. </StyledIconWrapper>
  256. </Tooltip>
  257. </OpenInLink>
  258. {coverageEnabled && isLoadingCoverage ? (
  259. <Placeholder height="14px" width="14px" />
  260. ) : coverage &&
  261. shouldShowCodecovFeatures(organization, match, coverage.status) ? (
  262. <CodecovLink
  263. coverageUrl={`${coverage.coverageUrl}#L${frame.lineNo}`}
  264. status={coverage.status}
  265. organization={organization}
  266. event={event}
  267. />
  268. ) : null}
  269. </StacktraceLinkWrapper>
  270. );
  271. }
  272. // Hide stacktrace link errors if the stacktrace might be minified javascript
  273. // Check if the line starts and ends with {snip}
  274. const isMinifiedJsError =
  275. event.platform === 'javascript' && /(\{snip\}).*\1/.test(line);
  276. const isUnsupportedPlatform = !supportedStacktracePlatforms.includes(
  277. event.platform as PlatformKey
  278. );
  279. const hideErrors = isMinifiedJsError || isUnsupportedPlatform;
  280. // for .NET projects, if there is no match found but there is a GitHub source link, use that
  281. if (
  282. frame.sourceLink &&
  283. hasGithubSourceLink &&
  284. (match.error || match.integrations.length > 0)
  285. ) {
  286. return (
  287. <StacktraceLinkWrapper>
  288. <Tooltip title={t('GitHub')} skipWrapper>
  289. <OpenInLink onClick={onOpenLink} href={frame.sourceLink} openInNewTab>
  290. <StyledIconWrapper>{getIntegrationIcon('github', 'sm')}</StyledIconWrapper>
  291. </OpenInLink>
  292. </Tooltip>
  293. {coverageEnabled && isLoadingCoverage ? (
  294. <Placeholder height="14px" width="14px" />
  295. ) : coverage &&
  296. shouldShowCodecovFeatures(organization, match, coverage.status) ? (
  297. <CodecovLink
  298. coverageUrl={`${frame.sourceLink}`}
  299. status={coverage.status}
  300. organization={organization}
  301. event={event}
  302. />
  303. ) : null}
  304. </StacktraceLinkWrapper>
  305. );
  306. }
  307. // No match found - Has integration but no code mappings
  308. if (!hideErrors && (match.error || match.integrations.length > 0)) {
  309. const filename = frame.filename;
  310. if (!project || !match.integrations.length || !filename) {
  311. return null;
  312. }
  313. const sourceCodeProviders = match.integrations.filter(integration =>
  314. ['github', 'gitlab'].includes(integration.provider?.key)
  315. );
  316. return (
  317. <StacktraceLinkWrapper>
  318. <FixMappingButton
  319. priority="link"
  320. icon={
  321. sourceCodeProviders.length === 1
  322. ? getIntegrationIcon(sourceCodeProviders[0].provider.key, 'sm')
  323. : undefined
  324. }
  325. onClick={() => {
  326. trackAnalytics(
  327. 'integrations.stacktrace_start_setup',
  328. {
  329. view: 'stacktrace_issue_details',
  330. platform: event.platform,
  331. provider: sourceCodeProviders[0]?.provider.key,
  332. setup_type: 'automatic',
  333. organization,
  334. ...getAnalyticsDataForEvent(event),
  335. },
  336. {startSession: true}
  337. );
  338. openModal(deps => (
  339. <StacktraceLinkModal
  340. onSubmit={handleSubmit}
  341. filename={filename}
  342. project={project}
  343. organization={organization}
  344. integrations={match.integrations}
  345. {...deps}
  346. />
  347. ));
  348. }}
  349. >
  350. {t('Set up Code Mapping')}
  351. </FixMappingButton>
  352. </StacktraceLinkWrapper>
  353. );
  354. }
  355. return null;
  356. }
  357. const fadeIn = keyframes`
  358. from { opacity: 0; }
  359. to { opacity: 1; }
  360. `;
  361. const StacktraceLinkWrapper = styled('div')`
  362. display: flex;
  363. gap: ${space(1)};
  364. align-items: center;
  365. color: ${p => p.theme.subText};
  366. font-family: ${p => p.theme.text.family};
  367. padding: ${space(0)} ${space(1)};
  368. `;
  369. const FixMappingButton = styled(Button)`
  370. color: ${p => p.theme.subText};
  371. &:hover {
  372. color: ${p => p.theme.subText};
  373. }
  374. `;
  375. const StyledIconWrapper = styled('span')`
  376. color: inherit;
  377. line-height: 0;
  378. `;
  379. const LinkStyles = css`
  380. display: flex;
  381. align-items: center;
  382. gap: ${space(0.75)};
  383. `;
  384. const OpenInLink = styled(ExternalLink)`
  385. ${LinkStyles}
  386. color: ${p => p.theme.subText};
  387. animation: ${fadeIn} 0.2s ease-in-out forwards;
  388. &:hover {
  389. color: ${p => p.theme.textColor};
  390. }
  391. `;
  392. const CodecovWarning = styled('div')`
  393. display: flex;
  394. color: ${p => p.theme.errorText};
  395. gap: ${space(0.75)};
  396. align-items: center;
  397. `;