stacktraceLink.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619
  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 {
  6. makePromptsCheckQueryKey,
  7. PromptResponse,
  8. promptsUpdate,
  9. usePromptsCheck,
  10. } from 'sentry/actionCreators/prompts';
  11. import {Button} from 'sentry/components/button';
  12. import {
  13. hasFileExtension,
  14. hasStacktraceLinkInFrameFeature,
  15. } from 'sentry/components/events/interfaces/frame/utils';
  16. import HookOrDefault from 'sentry/components/hookOrDefault';
  17. import ExternalLink from 'sentry/components/links/externalLink';
  18. import Link from 'sentry/components/links/link';
  19. import Placeholder from 'sentry/components/placeholder';
  20. import {Tooltip} from 'sentry/components/tooltip';
  21. import {IconClose, IconWarning} from 'sentry/icons';
  22. import {t} from 'sentry/locale';
  23. import ConfigStore from 'sentry/stores/configStore';
  24. import {useLegacyStore} from 'sentry/stores/useLegacyStore';
  25. import {space} from 'sentry/styles/space';
  26. import {
  27. CodecovStatusCode,
  28. Event,
  29. Frame,
  30. Organization,
  31. PlatformKey,
  32. Project,
  33. StacktraceLinkResult,
  34. } from 'sentry/types';
  35. import {trackAnalytics} from 'sentry/utils/analytics';
  36. import {getAnalyticsDataForEvent} from 'sentry/utils/events';
  37. import {getIntegrationIcon, getIntegrationSourceUrl} from 'sentry/utils/integrationUtil';
  38. import {promptIsDismissed} from 'sentry/utils/promptIsDismissed';
  39. import {setApiQueryData, useQueryClient} from 'sentry/utils/queryClient';
  40. import useRouteAnalyticsParams from 'sentry/utils/routeAnalytics/useRouteAnalyticsParams';
  41. import useApi from 'sentry/utils/useApi';
  42. import useOrganization from 'sentry/utils/useOrganization';
  43. import useProjects from 'sentry/utils/useProjects';
  44. import StacktraceLinkModal from './stacktraceLinkModal';
  45. import useStacktraceLink from './useStacktraceLink';
  46. const HookCodecovStacktraceLink = HookOrDefault({
  47. hookName: 'component:codecov-integration-stacktrace-link',
  48. });
  49. const supportedStacktracePlatforms: PlatformKey[] = [
  50. 'go',
  51. 'javascript',
  52. 'node',
  53. 'php',
  54. 'python',
  55. 'ruby',
  56. 'elixir',
  57. ];
  58. interface StacktraceLinkSetupProps {
  59. event: Event;
  60. hasInFrameFeature: boolean;
  61. organization: Organization;
  62. project?: Project;
  63. }
  64. function StacktraceLinkSetup({
  65. organization,
  66. project,
  67. event,
  68. hasInFrameFeature,
  69. }: StacktraceLinkSetupProps) {
  70. const api = useApi();
  71. const queryClient = useQueryClient();
  72. const dismissPrompt = () => {
  73. promptsUpdate(api, {
  74. organization,
  75. projectId: project?.id,
  76. feature: 'stacktrace_link',
  77. status: 'dismissed',
  78. });
  79. // Update cached query data
  80. // Will set prompt to dismissed
  81. setApiQueryData<PromptResponse>(
  82. queryClient,
  83. makePromptsCheckQueryKey({
  84. organization,
  85. feature: 'stacktrace_link',
  86. projectId: project?.id,
  87. }),
  88. () => {
  89. const dimissedTs = new Date().getTime() / 1000;
  90. return {
  91. data: {dismissed_ts: dimissedTs},
  92. features: {stacktrace_link: {dismissed_ts: dimissedTs}},
  93. };
  94. }
  95. );
  96. trackAnalytics('integrations.stacktrace_link_cta_dismissed', {
  97. view: 'stacktrace_issue_details',
  98. organization,
  99. ...getAnalyticsDataForEvent(event),
  100. });
  101. };
  102. return (
  103. <StacktraceLinkWrapper hasInFrameFeature={hasInFrameFeature}>
  104. <StyledLink to={`/settings/${organization.slug}/integrations/`}>
  105. <StyledIconWrapper>{getIntegrationIcon('github', 'sm')}</StyledIconWrapper>
  106. {t('Add the GitHub or GitLab integration to jump straight to your source code')}
  107. </StyledLink>
  108. <CloseButton priority="link" onClick={dismissPrompt}>
  109. <IconClose size="xs" aria-label={t('Close')} />
  110. </CloseButton>
  111. </StacktraceLinkWrapper>
  112. );
  113. }
  114. function shouldShowCodecovFeatures(
  115. organization: Organization,
  116. match: StacktraceLinkResult
  117. ) {
  118. const codecovStatus = match.codecov?.status;
  119. const validStatus = codecovStatus && codecovStatus !== CodecovStatusCode.NO_INTEGRATION;
  120. return (
  121. organization.codecovAccess && validStatus && match.config?.provider.key === 'github'
  122. );
  123. }
  124. function shouldShowCodecovPrompt(
  125. organization: Organization,
  126. match: StacktraceLinkResult
  127. ) {
  128. const enabled =
  129. organization.features.includes('codecov-integration') && !organization.codecovAccess;
  130. return enabled && match.config?.provider.key === 'github';
  131. }
  132. interface CodecovLinkProps {
  133. event: Event;
  134. hasInFrameFeature: boolean;
  135. organization: Organization;
  136. coverageUrl?: string;
  137. status?: CodecovStatusCode;
  138. }
  139. function CodecovLink({
  140. coverageUrl,
  141. status = CodecovStatusCode.COVERAGE_EXISTS,
  142. organization,
  143. event,
  144. hasInFrameFeature,
  145. }: CodecovLinkProps) {
  146. if (status === CodecovStatusCode.NO_COVERAGE_DATA) {
  147. return (
  148. <CodecovWarning>
  149. {t('Code Coverage not found')}
  150. <IconWarning size="xs" color="errorText" />
  151. </CodecovWarning>
  152. );
  153. }
  154. if (status !== CodecovStatusCode.COVERAGE_EXISTS || !coverageUrl) {
  155. return null;
  156. }
  157. const onOpenCodecovLink = (e: React.MouseEvent) => {
  158. e.stopPropagation();
  159. trackAnalytics('integrations.stacktrace_codecov_link_clicked', {
  160. view: 'stacktrace_issue_details',
  161. organization,
  162. group_id: event.groupID ? parseInt(event.groupID, 10) : -1,
  163. ...getAnalyticsDataForEvent(event),
  164. });
  165. };
  166. return (
  167. <OpenInLink
  168. href={coverageUrl}
  169. openInNewTab
  170. onClick={onOpenCodecovLink}
  171. aria-label={hasInFrameFeature ? t('Open in Codecov') : undefined}
  172. hasInFrameFeature={hasInFrameFeature}
  173. >
  174. <Tooltip title={t('Open in Codecov')} disabled={!hasInFrameFeature} skipWrapper>
  175. <StyledIconWrapper>{getIntegrationIcon('codecov', 'sm')}</StyledIconWrapper>
  176. </Tooltip>
  177. {hasInFrameFeature ? null : t('Open in Codecov')}
  178. </OpenInLink>
  179. );
  180. }
  181. interface StacktraceLinkProps {
  182. event: Event;
  183. frame: Frame;
  184. /**
  185. * The line of code being linked
  186. */
  187. line: string;
  188. }
  189. export function StacktraceLink({frame, event, line}: StacktraceLinkProps) {
  190. const organization = useOrganization();
  191. const {user} = useLegacyStore(ConfigStore);
  192. const {projects} = useProjects();
  193. const hasInFrameFeature = hasStacktraceLinkInFrameFeature(organization, user);
  194. const validFilePath = hasFileExtension(frame.absPath || frame.filename || '');
  195. // TODO: Currently we only support GitHub links. Implement support for other source code providers.
  196. // Related comment: https://github.com/getsentry/sentry/pull/62596#discussion_r1443025242
  197. const hasGithubSourceLink = (frame.sourceLink || '').startsWith(
  198. 'https://www.github.com/'
  199. );
  200. const [isQueryEnabled, setIsQueryEnabled] = useState(
  201. hasGithubSourceLink ? false : !frame.inApp ? false : !hasInFrameFeature
  202. );
  203. const project = useMemo(
  204. () => projects.find(p => p.id === event.projectID),
  205. [projects, event]
  206. );
  207. const prompt = usePromptsCheck({
  208. organization,
  209. feature: 'stacktrace_link',
  210. projectId: project?.id,
  211. });
  212. const isPromptDismissed =
  213. prompt.isSuccess && prompt.data.data
  214. ? promptIsDismissed({
  215. dismissedTime: prompt.data.data.dismissed_ts,
  216. snoozedTime: prompt.data.data.snoozed_ts,
  217. })
  218. : false;
  219. useEffect(() => {
  220. let timer: ReturnType<typeof setTimeout> | undefined;
  221. if (!validFilePath) {
  222. return setIsQueryEnabled(false);
  223. }
  224. // Skip fetching if we already have the Source Link
  225. if (hasInFrameFeature && !hasGithubSourceLink && frame.inApp) {
  226. // Introduce a delay before enabling the query
  227. timer = setTimeout(() => {
  228. setIsQueryEnabled(true);
  229. }, 100); // Delay of 100ms
  230. }
  231. return () => timer && clearTimeout(timer);
  232. }, [hasInFrameFeature, validFilePath, hasGithubSourceLink, frame]);
  233. const {
  234. data: match,
  235. isLoading,
  236. refetch,
  237. } = useStacktraceLink(
  238. {
  239. event,
  240. frame,
  241. orgSlug: organization.slug,
  242. projectSlug: project?.slug,
  243. },
  244. {
  245. staleTime: Infinity,
  246. enabled: isQueryEnabled, // The query will not run until `isQueryEnabled` is true
  247. }
  248. );
  249. useRouteAnalyticsParams(
  250. match
  251. ? {
  252. stacktrace_link_viewed: true,
  253. stacktrace_link_status: match.sourceUrl
  254. ? 'match'
  255. : match.error || match.integrations.length
  256. ? 'no_match'
  257. : !isPromptDismissed
  258. ? 'prompt'
  259. : 'empty',
  260. }
  261. : {}
  262. );
  263. const onOpenLink = (e: React.MouseEvent, sourceLink: Frame['sourceLink'] = null) => {
  264. e.stopPropagation();
  265. const provider = match?.config?.provider;
  266. if (provider) {
  267. trackAnalytics(
  268. 'integrations.stacktrace_link_clicked',
  269. {
  270. view: 'stacktrace_issue_details',
  271. provider: provider.key,
  272. organization,
  273. group_id: event.groupID ? parseInt(event.groupID, 10) : -1,
  274. ...getAnalyticsDataForEvent(event),
  275. },
  276. {startSession: true}
  277. );
  278. }
  279. if (sourceLink) {
  280. const url = new URL(sourceLink);
  281. const hostname = url.hostname;
  282. const parts = hostname.split('.');
  283. const domain = parts.length > 1 ? parts[1] : '';
  284. trackAnalytics(
  285. 'integrations.non_inapp_stacktrace_link_clicked',
  286. {
  287. view: 'stacktrace_issue_details',
  288. provider: domain,
  289. organization,
  290. group_id: event.groupID ? parseInt(event.groupID, 10) : -1,
  291. ...getAnalyticsDataForEvent(event),
  292. },
  293. {startSession: true}
  294. );
  295. }
  296. };
  297. const handleSubmit = () => {
  298. refetch();
  299. };
  300. if (!validFilePath) {
  301. return null;
  302. }
  303. // Render the provided `sourceLink` for all the non-inapp frames for `csharp` platform Issues
  304. // We skip fetching from the API for these frames.
  305. if (
  306. !match &&
  307. hasGithubSourceLink &&
  308. !frame.inApp &&
  309. frame.sourceLink &&
  310. hasInFrameFeature
  311. ) {
  312. return (
  313. <StacktraceLinkWrapper hasInFrameFeature={hasInFrameFeature}>
  314. <OpenInLink
  315. onClick={e => onOpenLink(e, frame.sourceLink)}
  316. href={frame.sourceLink}
  317. openInNewTab
  318. hasInFrameFeature={hasInFrameFeature}
  319. >
  320. <StyledIconWrapper>{getIntegrationIcon('github', 'sm')}</StyledIconWrapper>
  321. {t('GitHub')}
  322. </OpenInLink>
  323. </StacktraceLinkWrapper>
  324. );
  325. }
  326. if (isLoading || !match) {
  327. return (
  328. <StacktraceLinkWrapper hasInFrameFeature={hasInFrameFeature}>
  329. <Placeholder
  330. height={hasInFrameFeature ? '14px' : '24px'}
  331. width={hasInFrameFeature ? '40px' : '120px'}
  332. />
  333. </StacktraceLinkWrapper>
  334. );
  335. }
  336. // Match found - display link to source
  337. if (match.config && match.sourceUrl) {
  338. return (
  339. <StacktraceLinkWrapper hasInFrameFeature={hasInFrameFeature}>
  340. <OpenInLink
  341. onClick={onOpenLink}
  342. href={getIntegrationSourceUrl(
  343. match.config.provider.key,
  344. match!.sourceUrl,
  345. frame.lineNo
  346. )}
  347. openInNewTab
  348. aria-label={
  349. hasInFrameFeature
  350. ? t('Open this line in %s', match.config.provider.name)
  351. : undefined
  352. }
  353. hasInFrameFeature={hasInFrameFeature}
  354. >
  355. <Tooltip
  356. disabled={!hasInFrameFeature}
  357. title={t('Open this line in %s', match.config.provider.name)}
  358. skipWrapper
  359. >
  360. <StyledIconWrapper>
  361. {getIntegrationIcon(match.config.provider.key, 'sm')}
  362. </StyledIconWrapper>
  363. </Tooltip>
  364. {hasInFrameFeature
  365. ? null
  366. : t('Open this line in %s', match.config.provider.name)}
  367. </OpenInLink>
  368. {shouldShowCodecovFeatures(organization, match) ? (
  369. <CodecovLink
  370. coverageUrl={`${match.codecov?.coverageUrl}#L${frame.lineNo}`}
  371. status={match.codecov?.status}
  372. organization={organization}
  373. event={event}
  374. hasInFrameFeature={hasInFrameFeature}
  375. />
  376. ) : shouldShowCodecovPrompt(organization, match) ? (
  377. <HookCodecovStacktraceLink organization={organization} />
  378. ) : null}
  379. </StacktraceLinkWrapper>
  380. );
  381. }
  382. // Hide stacktrace link errors if the stacktrace might be minified javascript
  383. // Check if the line starts and ends with {snip}
  384. const isMinifiedJsError =
  385. event.platform === 'javascript' && /(\{snip\}).*\1/.test(line);
  386. const isUnsupportedPlatform = !supportedStacktracePlatforms.includes(
  387. event.platform as PlatformKey
  388. );
  389. const hideErrors = isMinifiedJsError || isUnsupportedPlatform;
  390. // for .NET projects, if there is no match found but there is a GitHub source link, use that
  391. if (
  392. frame.sourceLink &&
  393. hasGithubSourceLink &&
  394. (match.error || match.integrations.length > 0)
  395. ) {
  396. return (
  397. <StacktraceLinkWrapper hasInFrameFeature={hasInFrameFeature}>
  398. <OpenInLink
  399. onClick={onOpenLink}
  400. href={frame.sourceLink}
  401. openInNewTab
  402. hasInFrameFeature={hasInFrameFeature}
  403. >
  404. <StyledIconWrapper>{getIntegrationIcon('github', 'sm')}</StyledIconWrapper>
  405. {hasInFrameFeature ? t('GitHub') : t('Open this line in GitHub')}
  406. </OpenInLink>
  407. {shouldShowCodecovFeatures(organization, match) ? (
  408. <CodecovLink
  409. coverageUrl={`${frame.sourceLink}`}
  410. status={match.codecov?.status}
  411. organization={organization}
  412. event={event}
  413. hasInFrameFeature={hasInFrameFeature}
  414. />
  415. ) : shouldShowCodecovPrompt(organization, match) ? (
  416. <HookCodecovStacktraceLink organization={organization} />
  417. ) : null}
  418. </StacktraceLinkWrapper>
  419. );
  420. }
  421. // No match found - Has integration but no code mappings
  422. if (!hideErrors && (match.error || match.integrations.length > 0)) {
  423. const filename = frame.filename;
  424. if (!project || !match.integrations.length || !filename) {
  425. return null;
  426. }
  427. const sourceCodeProviders = match.integrations.filter(integration =>
  428. ['github', 'gitlab'].includes(integration.provider?.key)
  429. );
  430. return (
  431. <StacktraceLinkWrapper hasInFrameFeature={hasInFrameFeature}>
  432. <FixMappingButton
  433. priority="link"
  434. hasInFrameFeature={hasInFrameFeature}
  435. icon={
  436. sourceCodeProviders.length === 1
  437. ? getIntegrationIcon(sourceCodeProviders[0].provider.key, 'sm')
  438. : undefined
  439. }
  440. onClick={() => {
  441. trackAnalytics(
  442. 'integrations.stacktrace_start_setup',
  443. {
  444. view: 'stacktrace_issue_details',
  445. platform: event.platform,
  446. provider: sourceCodeProviders[0]?.provider.key,
  447. setup_type: 'automatic',
  448. organization,
  449. ...getAnalyticsDataForEvent(event),
  450. },
  451. {startSession: true}
  452. );
  453. openModal(deps => (
  454. <StacktraceLinkModal
  455. onSubmit={handleSubmit}
  456. filename={filename}
  457. project={project}
  458. organization={organization}
  459. integrations={match.integrations}
  460. {...deps}
  461. />
  462. ));
  463. }}
  464. >
  465. {t('Tell us where your source code is')}
  466. </FixMappingButton>
  467. </StacktraceLinkWrapper>
  468. );
  469. }
  470. // No integrations, but prompt is dismissed or hidden
  471. if (hideErrors || isPromptDismissed) {
  472. return null;
  473. }
  474. // No integrations
  475. return (
  476. <StacktraceLinkSetup
  477. event={event}
  478. project={project}
  479. organization={organization}
  480. hasInFrameFeature={hasInFrameFeature}
  481. />
  482. );
  483. }
  484. const fadeIn = keyframes`
  485. from { opacity: 0; }
  486. to { opacity: 1; }
  487. `;
  488. const StacktraceLinkWrapper = styled('div')<{
  489. hasInFrameFeature: boolean;
  490. }>`
  491. display: flex;
  492. gap: ${space(2)};
  493. align-items: center;
  494. color: ${p => p.theme.subText};
  495. font-family: ${p => p.theme.text.family};
  496. ${p =>
  497. p.hasInFrameFeature
  498. ? `
  499. padding: ${space(0)} ${space(1)};
  500. flex-wrap: wrap;
  501. gap: ${space(1)}
  502. `
  503. : `
  504. background-color: ${p.theme.background};
  505. border-bottom: 1px solid ${p.theme.border};
  506. padding: ${space(0.25)} ${space(3)};
  507. box-shadow: ${p.theme.dropShadowLight};
  508. min-height: 28px;
  509. `}
  510. `;
  511. const FixMappingButton = styled(Button)<{
  512. hasInFrameFeature: boolean;
  513. }>`
  514. color: ${p => p.theme.subText};
  515. ${p =>
  516. p.hasInFrameFeature
  517. ? `
  518. &:hover {
  519. color: ${p.theme.subText};
  520. text-decoration: underline;
  521. text-decoration-color: ${p.theme.subText};
  522. text-underline-offset: ${space(0.5)};
  523. }
  524. `
  525. : ``}
  526. `;
  527. const CloseButton = styled(Button)`
  528. color: ${p => p.theme.subText};
  529. `;
  530. const StyledIconWrapper = styled('span')`
  531. color: inherit;
  532. line-height: 0;
  533. `;
  534. const LinkStyles = css`
  535. display: flex;
  536. align-items: center;
  537. gap: ${space(0.75)};
  538. `;
  539. const OpenInLink = styled(ExternalLink)<{
  540. hasInFrameFeature: boolean;
  541. }>`
  542. ${LinkStyles}
  543. ${p =>
  544. p.hasInFrameFeature
  545. ? css`
  546. color: ${p.theme.subText};
  547. animation: ${fadeIn} 0.2s ease-in-out forwards;
  548. width: max-content;
  549. &:hover {
  550. text-decoration: underline;
  551. text-decoration-color: ${p.theme.textColor};
  552. text-underline-offset: ${space(0.5)};
  553. color: ${p.theme.textColor};
  554. }
  555. `
  556. : css`
  557. color: ${p.theme.gray300};
  558. `}
  559. `;
  560. const StyledLink = styled(Link)`
  561. ${LinkStyles}
  562. color: ${p => p.theme.gray300};
  563. `;
  564. const CodecovWarning = styled('div')`
  565. display: flex;
  566. color: ${p => p.theme.errorText};
  567. gap: ${space(0.75)};
  568. align-items: center;
  569. `;