stacktraceLink.tsx 8.9 KB

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