index.tsx 19 KB

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