stacktraceLink.tsx 11 KB

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