index.tsx 20 KB

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