stacktraceLink.tsx 9.3 KB

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