index.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604
  1. import {Component, Fragment} from 'react';
  2. import {Location, LocationDescriptor} from 'history';
  3. import DropdownLink from 'sentry/components/dropdownLink';
  4. import ProjectBadge from 'sentry/components/idBadge/projectBadge';
  5. import {
  6. ErrorDestination,
  7. generateSingleErrorTarget,
  8. generateSingleTransactionTarget,
  9. generateTraceTarget,
  10. isQuickTraceEvent,
  11. TransactionDestination,
  12. } from 'sentry/components/quickTrace/utils';
  13. import Tooltip from 'sentry/components/tooltip';
  14. import {backend, frontend, mobile, serverless} from 'sentry/data/platformCategories';
  15. import {IconFire} from 'sentry/icons';
  16. import {t, tct, tn} from 'sentry/locale';
  17. import {OrganizationSummary} from 'sentry/types';
  18. import {Event} from 'sentry/types/event';
  19. import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
  20. import {getDocsPlatform} from 'sentry/utils/docs';
  21. import {getDuration} from 'sentry/utils/formatters';
  22. import localStorage from 'sentry/utils/localStorage';
  23. import {
  24. QuickTrace as QuickTraceType,
  25. QuickTraceEvent,
  26. TraceError,
  27. } from 'sentry/utils/performance/quickTrace/types';
  28. import {parseQuickTrace} from 'sentry/utils/performance/quickTrace/utils';
  29. import Projects from 'sentry/utils/projects';
  30. import {Theme} from 'sentry/utils/theme';
  31. const FRONTEND_PLATFORMS: string[] = [...frontend, ...mobile];
  32. const BACKEND_PLATFORMS: string[] = [...backend, ...serverless];
  33. import {
  34. DropdownContainer,
  35. DropdownItem,
  36. DropdownItemSubContainer,
  37. DropdownMenuHeader,
  38. ErrorNodeContent,
  39. EventNode,
  40. ExternalDropdownLink,
  41. QuickTraceContainer,
  42. QuickTraceValue,
  43. SectionSubtext,
  44. SingleEventHoverText,
  45. TraceConnector,
  46. } from './styles';
  47. const TOOLTIP_PREFIX = {
  48. root: 'root',
  49. ancestors: 'ancestor',
  50. parent: 'parent',
  51. current: '',
  52. children: 'child',
  53. descendants: 'descendant',
  54. };
  55. type QuickTraceProps = Pick<
  56. EventNodeSelectorProps,
  57. 'anchor' | 'errorDest' | 'transactionDest'
  58. > & {
  59. event: Event;
  60. location: Location;
  61. organization: OrganizationSummary;
  62. quickTrace: QuickTraceType;
  63. };
  64. export default function QuickTrace({
  65. event,
  66. quickTrace,
  67. location,
  68. organization,
  69. anchor,
  70. errorDest,
  71. transactionDest,
  72. }: QuickTraceProps) {
  73. let parsedQuickTrace;
  74. try {
  75. parsedQuickTrace = parseQuickTrace(quickTrace, event, organization);
  76. } catch (error) {
  77. return <Fragment>{'\u2014'}</Fragment>;
  78. }
  79. const traceLength = quickTrace.trace && quickTrace.trace.length;
  80. const {root, ancestors, parent, children, descendants, current} = parsedQuickTrace;
  81. const nodes: React.ReactNode[] = [];
  82. if (root) {
  83. nodes.push(
  84. <EventNodeSelector
  85. key="root-node"
  86. location={location}
  87. organization={organization}
  88. events={[root]}
  89. currentEvent={event}
  90. text={t('Root')}
  91. anchor={anchor}
  92. nodeKey="root"
  93. errorDest={errorDest}
  94. transactionDest={transactionDest}
  95. />
  96. );
  97. nodes.push(<TraceConnector key="root-connector" />);
  98. }
  99. if (ancestors?.length) {
  100. nodes.push(
  101. <EventNodeSelector
  102. key="ancestors-node"
  103. location={location}
  104. organization={organization}
  105. events={ancestors}
  106. currentEvent={event}
  107. text={tn('%s Ancestor', '%s Ancestors', ancestors.length)}
  108. anchor={anchor}
  109. nodeKey="ancestors"
  110. errorDest={errorDest}
  111. transactionDest={transactionDest}
  112. />
  113. );
  114. nodes.push(<TraceConnector key="ancestors-connector" />);
  115. }
  116. if (parent) {
  117. nodes.push(
  118. <EventNodeSelector
  119. key="parent-node"
  120. location={location}
  121. organization={organization}
  122. events={[parent]}
  123. currentEvent={event}
  124. text={t('Parent')}
  125. anchor={anchor}
  126. nodeKey="parent"
  127. errorDest={errorDest}
  128. transactionDest={transactionDest}
  129. />
  130. );
  131. nodes.push(<TraceConnector key="parent-connector" />);
  132. }
  133. const currentNode = (
  134. <EventNodeSelector
  135. key="current-node"
  136. location={location}
  137. organization={organization}
  138. text={t('This Event')}
  139. events={[current]}
  140. currentEvent={event}
  141. anchor={anchor}
  142. nodeKey="current"
  143. errorDest={errorDest}
  144. transactionDest={transactionDest}
  145. />
  146. );
  147. if (traceLength === 1) {
  148. nodes.push(
  149. <Projects
  150. key="missing-services"
  151. orgId={organization.slug}
  152. slugs={[current.project_slug]}
  153. >
  154. {({projects}) => {
  155. const project = projects.find(p => p.slug === current.project_slug);
  156. if (project?.platform) {
  157. if (BACKEND_PLATFORMS.includes(project.platform as string)) {
  158. return (
  159. <Fragment>
  160. <MissingServiceNode
  161. anchor={anchor}
  162. organization={organization}
  163. platform={project.platform}
  164. connectorSide="right"
  165. />
  166. {currentNode}
  167. </Fragment>
  168. );
  169. }
  170. if (FRONTEND_PLATFORMS.includes(project.platform as string)) {
  171. return (
  172. <Fragment>
  173. {currentNode}
  174. <MissingServiceNode
  175. anchor={anchor}
  176. organization={organization}
  177. platform={project.platform}
  178. connectorSide="left"
  179. />
  180. </Fragment>
  181. );
  182. }
  183. }
  184. return currentNode;
  185. }}
  186. </Projects>
  187. );
  188. } else {
  189. nodes.push(currentNode);
  190. }
  191. if (children.length) {
  192. nodes.push(<TraceConnector key="children-connector" />);
  193. nodes.push(
  194. <EventNodeSelector
  195. key="children-node"
  196. location={location}
  197. organization={organization}
  198. events={children}
  199. currentEvent={event}
  200. text={tn('%s Child', '%s Children', children.length)}
  201. anchor={anchor}
  202. nodeKey="children"
  203. errorDest={errorDest}
  204. transactionDest={transactionDest}
  205. />
  206. );
  207. }
  208. if (descendants?.length) {
  209. nodes.push(<TraceConnector key="descendants-connector" />);
  210. nodes.push(
  211. <EventNodeSelector
  212. key="descendants-node"
  213. location={location}
  214. organization={organization}
  215. events={descendants}
  216. currentEvent={event}
  217. text={tn('%s Descendant', '%s Descendants', descendants.length)}
  218. anchor={anchor}
  219. nodeKey="descendants"
  220. errorDest={errorDest}
  221. transactionDest={transactionDest}
  222. />
  223. );
  224. }
  225. return <QuickTraceContainer>{nodes}</QuickTraceContainer>;
  226. }
  227. function handleNode(key: string, organization: OrganizationSummary) {
  228. trackAdvancedAnalyticsEvent('quick_trace.node.clicked', {
  229. organization: organization.id,
  230. node_key: key,
  231. });
  232. }
  233. function handleDropdownItem(
  234. key: string,
  235. organization: OrganizationSummary,
  236. extra: boolean
  237. ) {
  238. const eventKey = extra
  239. ? 'quick_trace.dropdown.clicked_extra'
  240. : 'quick_trace.dropdown.clicked';
  241. trackAdvancedAnalyticsEvent(eventKey, {
  242. organization: organization.id,
  243. node_key: key,
  244. });
  245. }
  246. type EventNodeSelectorProps = {
  247. anchor: 'left' | 'right';
  248. currentEvent: Event;
  249. errorDest: ErrorDestination;
  250. events: QuickTraceEvent[];
  251. location: Location;
  252. nodeKey: keyof typeof TOOLTIP_PREFIX;
  253. organization: OrganizationSummary;
  254. text: React.ReactNode;
  255. transactionDest: TransactionDestination;
  256. numEvents?: number;
  257. };
  258. function EventNodeSelector({
  259. location,
  260. organization,
  261. events = [],
  262. text,
  263. currentEvent,
  264. nodeKey,
  265. anchor,
  266. errorDest,
  267. transactionDest,
  268. numEvents = 5,
  269. }: EventNodeSelectorProps) {
  270. let errors: TraceError[] = events.flatMap(event => event.errors ?? []);
  271. let type: keyof Theme['tag'] = nodeKey === 'current' ? 'black' : 'white';
  272. const hasErrors = errors.length > 0;
  273. if (hasErrors) {
  274. type = nodeKey === 'current' ? 'error' : 'warning';
  275. text = (
  276. <ErrorNodeContent>
  277. <IconFire size="xs" />
  278. {text}
  279. </ErrorNodeContent>
  280. );
  281. }
  282. // make sure to exclude the current event from the dropdown
  283. events = events.filter(event => event.event_id !== currentEvent.id);
  284. errors = errors.filter(error => error.event_id !== currentEvent.id);
  285. if (events.length + errors.length === 0) {
  286. return (
  287. <EventNode type={type} data-test-id="event-node">
  288. {text}
  289. </EventNode>
  290. );
  291. }
  292. if (events.length + errors.length === 1) {
  293. /**
  294. * When there is only 1 event, clicking the node should take the user directly to
  295. * the event without additional steps.
  296. */
  297. const hoverText = errors.length ? (
  298. t('View the error for this Transaction')
  299. ) : (
  300. <SingleEventHoverText event={events[0]} />
  301. );
  302. const target = errors.length
  303. ? generateSingleErrorTarget(errors[0], organization, location, errorDest)
  304. : generateSingleTransactionTarget(
  305. events[0],
  306. organization,
  307. location,
  308. transactionDest
  309. );
  310. return (
  311. <StyledEventNode
  312. text={text}
  313. hoverText={hoverText}
  314. to={target}
  315. onClick={() => handleNode(nodeKey, organization)}
  316. type={type}
  317. />
  318. );
  319. }
  320. /**
  321. * When there is more than 1 event, clicking the node should expand a dropdown to
  322. * allow the user to select which event to go to.
  323. */
  324. const hoverText = tct('View [eventPrefix] [eventType]', {
  325. eventPrefix: TOOLTIP_PREFIX[nodeKey],
  326. eventType:
  327. errors.length && events.length
  328. ? 'events'
  329. : events.length
  330. ? 'transactions'
  331. : 'errors',
  332. });
  333. return (
  334. <DropdownContainer>
  335. <DropdownLink
  336. caret={false}
  337. title={<StyledEventNode text={text} hoverText={hoverText} type={type} />}
  338. anchorRight={anchor === 'right'}
  339. >
  340. {errors.length > 0 && (
  341. <DropdownMenuHeader first>
  342. {tn('Related Error', 'Related Errors', errors.length)}
  343. </DropdownMenuHeader>
  344. )}
  345. {errors.slice(0, numEvents).map(error => {
  346. const target = generateSingleErrorTarget(
  347. error,
  348. organization,
  349. location,
  350. errorDest
  351. );
  352. return (
  353. <DropdownNodeItem
  354. key={error.event_id}
  355. event={error}
  356. to={target}
  357. allowDefaultEvent
  358. onSelect={() => handleDropdownItem(nodeKey, organization, false)}
  359. organization={organization}
  360. anchor={anchor}
  361. />
  362. );
  363. })}
  364. {events.length > 0 && (
  365. <DropdownMenuHeader first={errors.length === 0}>
  366. {tn('Transaction', 'Transactions', events.length)}
  367. </DropdownMenuHeader>
  368. )}
  369. {events.slice(0, numEvents).map(event => {
  370. const target = generateSingleTransactionTarget(
  371. event,
  372. organization,
  373. location,
  374. transactionDest
  375. );
  376. return (
  377. <DropdownNodeItem
  378. key={event.event_id}
  379. event={event}
  380. to={target}
  381. onSelect={() => handleDropdownItem(nodeKey, organization, false)}
  382. allowDefaultEvent
  383. organization={organization}
  384. subtext={getDuration(
  385. event['transaction.duration'] / 1000,
  386. event['transaction.duration'] < 1000 ? 0 : 2,
  387. true
  388. )}
  389. anchor={anchor}
  390. />
  391. );
  392. })}
  393. {(errors.length > numEvents || events.length > numEvents) && (
  394. <DropdownItem
  395. to={generateTraceTarget(currentEvent, organization)}
  396. allowDefaultEvent
  397. onSelect={() => handleDropdownItem(nodeKey, organization, true)}
  398. >
  399. {t('View all events')}
  400. </DropdownItem>
  401. )}
  402. </DropdownLink>
  403. </DropdownContainer>
  404. );
  405. }
  406. type DropdownNodeProps = {
  407. anchor: 'left' | 'right';
  408. event: TraceError | QuickTraceEvent;
  409. organization: OrganizationSummary;
  410. allowDefaultEvent?: boolean;
  411. onSelect?: (eventKey: any) => void;
  412. subtext?: string;
  413. to?: LocationDescriptor;
  414. };
  415. function DropdownNodeItem({
  416. event,
  417. onSelect,
  418. to,
  419. allowDefaultEvent,
  420. organization,
  421. subtext,
  422. anchor,
  423. }: DropdownNodeProps) {
  424. return (
  425. <DropdownItem to={to} onSelect={onSelect} allowDefaultEvent={allowDefaultEvent}>
  426. <DropdownItemSubContainer>
  427. <Projects orgId={organization.slug} slugs={[event.project_slug]}>
  428. {({projects}) => {
  429. const project = projects.find(p => p.slug === event.project_slug);
  430. return (
  431. <ProjectBadge
  432. disableLink
  433. hideName
  434. project={project ? project : {slug: event.project_slug}}
  435. avatarSize={16}
  436. />
  437. );
  438. }}
  439. </Projects>
  440. {isQuickTraceEvent(event) ? (
  441. <QuickTraceValue
  442. value={event.transaction}
  443. // expand in the opposite direction of the anchor
  444. expandDirection={anchor === 'left' ? 'right' : 'left'}
  445. maxLength={35}
  446. leftTrim
  447. trimRegex={/\.|\//g}
  448. />
  449. ) : (
  450. <QuickTraceValue
  451. value={event.title}
  452. // expand in the opposite direction of the anchor
  453. expandDirection={anchor === 'left' ? 'right' : 'left'}
  454. maxLength={45}
  455. />
  456. )}
  457. </DropdownItemSubContainer>
  458. {subtext && <SectionSubtext>{subtext}</SectionSubtext>}
  459. </DropdownItem>
  460. );
  461. }
  462. type EventNodeProps = {
  463. hoverText: React.ReactNode;
  464. text: React.ReactNode;
  465. onClick?: (eventKey: any) => void;
  466. to?: LocationDescriptor;
  467. type?: keyof Theme['tag'];
  468. };
  469. function StyledEventNode({text, hoverText, to, onClick, type = 'white'}: EventNodeProps) {
  470. return (
  471. <Tooltip position="top" containerDisplayMode="inline-flex" title={hoverText}>
  472. <EventNode
  473. data-test-id="event-node"
  474. type={type}
  475. icon={null}
  476. to={to}
  477. onClick={onClick}
  478. >
  479. {text}
  480. </EventNode>
  481. </Tooltip>
  482. );
  483. }
  484. type MissingServiceProps = Pick<QuickTraceProps, 'anchor' | 'organization'> & {
  485. connectorSide: 'left' | 'right';
  486. platform: string;
  487. };
  488. type MissingServiceState = {
  489. hideMissing: boolean;
  490. };
  491. const HIDE_MISSING_SERVICE_KEY = 'quick-trace:hide-missing-services';
  492. // 30 days
  493. const HIDE_MISSING_EXPIRES = 1000 * 60 * 60 * 24 * 30;
  494. function readHideMissingServiceState() {
  495. const value = localStorage.getItem(HIDE_MISSING_SERVICE_KEY);
  496. if (value === null) {
  497. return false;
  498. }
  499. const expires = parseInt(value, 10);
  500. const now = new Date().getTime();
  501. return expires > now;
  502. }
  503. class MissingServiceNode extends Component<MissingServiceProps, MissingServiceState> {
  504. state: MissingServiceState = {
  505. hideMissing: readHideMissingServiceState(),
  506. };
  507. dismissMissingService = () => {
  508. const {organization, platform} = this.props;
  509. const now = new Date().getTime();
  510. localStorage.setItem(
  511. HIDE_MISSING_SERVICE_KEY,
  512. (now + HIDE_MISSING_EXPIRES).toString()
  513. );
  514. this.setState({hideMissing: true});
  515. trackAdvancedAnalyticsEvent('quick_trace.missing_service.dismiss', {
  516. organization: organization.id,
  517. platform,
  518. });
  519. };
  520. trackExternalLink = () => {
  521. const {organization, platform} = this.props;
  522. trackAdvancedAnalyticsEvent('quick_trace.missing_service.docs', {
  523. organization: organization.id,
  524. platform,
  525. });
  526. };
  527. render() {
  528. const {hideMissing} = this.state;
  529. const {anchor, connectorSide, platform} = this.props;
  530. if (hideMissing) {
  531. return null;
  532. }
  533. const docPlatform = getDocsPlatform(platform, true);
  534. const docsHref =
  535. docPlatform === null || docPlatform === 'javascript'
  536. ? 'https://docs.sentry.io/platforms/javascript/performance/connect-services/'
  537. : `https://docs.sentry.io/platforms/${docPlatform}/performance/connect-services`;
  538. return (
  539. <Fragment>
  540. {connectorSide === 'left' && <TraceConnector />}
  541. <DropdownContainer>
  542. <DropdownLink
  543. caret={false}
  544. title={
  545. <StyledEventNode
  546. type="white"
  547. hoverText={t('No services connected')}
  548. text="???"
  549. />
  550. }
  551. anchorRight={anchor === 'right'}
  552. >
  553. <DropdownItem width="small">
  554. <ExternalDropdownLink href={docsHref} onClick={this.trackExternalLink}>
  555. {t('Connect to a service')}
  556. </ExternalDropdownLink>
  557. </DropdownItem>
  558. <DropdownItem onSelect={this.dismissMissingService} width="small">
  559. {t('Dismiss')}
  560. </DropdownItem>
  561. </DropdownLink>
  562. </DropdownContainer>
  563. {connectorSide === 'right' && <TraceConnector />}
  564. </Fragment>
  565. );
  566. }
  567. }