index.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621
  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 {trackAnalyticsEvent} from 'sentry/utils/analytics';
  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. trackAnalyticsEvent({
  229. eventKey: 'quick_trace.node.clicked',
  230. eventName: 'Quick Trace: Node clicked',
  231. organization_id: parseInt(organization.id, 10),
  232. node_key: key,
  233. });
  234. }
  235. function handleDropdownItem(
  236. key: string,
  237. organization: OrganizationSummary,
  238. extra: boolean
  239. ) {
  240. trackAnalyticsEvent({
  241. eventKey: 'quick_trace.dropdown.clicked' + (extra ? '_extra' : ''),
  242. eventName: 'Quick Trace: Dropdown clicked',
  243. organization_id: parseInt(organization.id, 10),
  244. node_key: key,
  245. });
  246. }
  247. type EventNodeSelectorProps = {
  248. anchor: 'left' | 'right';
  249. currentEvent: Event;
  250. errorDest: ErrorDestination;
  251. events: QuickTraceEvent[];
  252. location: Location;
  253. nodeKey: keyof typeof TOOLTIP_PREFIX;
  254. organization: OrganizationSummary;
  255. text: React.ReactNode;
  256. transactionDest: TransactionDestination;
  257. numEvents?: number;
  258. };
  259. function EventNodeSelector({
  260. location,
  261. organization,
  262. events = [],
  263. text,
  264. currentEvent,
  265. nodeKey,
  266. anchor,
  267. errorDest,
  268. transactionDest,
  269. numEvents = 5,
  270. }: EventNodeSelectorProps) {
  271. let errors: TraceError[] = events.flatMap(event => event.errors ?? []);
  272. let type: keyof Theme['tag'] = nodeKey === 'current' ? 'black' : 'white';
  273. const hasErrors = errors.length > 0;
  274. if (hasErrors) {
  275. type = nodeKey === 'current' ? 'error' : 'warning';
  276. text = (
  277. <ErrorNodeContent>
  278. <IconFire size="xs" />
  279. {text}
  280. </ErrorNodeContent>
  281. );
  282. }
  283. // make sure to exclude the current event from the dropdown
  284. events = events.filter(event => event.event_id !== currentEvent.id);
  285. errors = errors.filter(error => error.event_id !== currentEvent.id);
  286. if (events.length + errors.length === 0) {
  287. return <EventNode type={type}>{text}</EventNode>;
  288. }
  289. if (events.length + errors.length === 1) {
  290. /**
  291. * When there is only 1 event, clicking the node should take the user directly to
  292. * the event without additional steps.
  293. */
  294. const hoverText = errors.length ? (
  295. t('View the error for this Transaction')
  296. ) : (
  297. <SingleEventHoverText event={events[0]} />
  298. );
  299. const target = errors.length
  300. ? generateSingleErrorTarget(errors[0], organization, location, errorDest)
  301. : generateSingleTransactionTarget(
  302. events[0],
  303. organization,
  304. location,
  305. transactionDest
  306. );
  307. return (
  308. <StyledEventNode
  309. text={text}
  310. hoverText={hoverText}
  311. to={target}
  312. onClick={() => handleNode(nodeKey, organization)}
  313. type={type}
  314. shouldOffset={hasErrors}
  315. />
  316. );
  317. }
  318. /**
  319. * When there is more than 1 event, clicking the node should expand a dropdown to
  320. * allow the user to select which event to go to.
  321. */
  322. const hoverText = tct('View [eventPrefix] [eventType]', {
  323. eventPrefix: TOOLTIP_PREFIX[nodeKey],
  324. eventType:
  325. errors.length && events.length
  326. ? 'events'
  327. : events.length
  328. ? 'transactions'
  329. : 'errors',
  330. });
  331. return (
  332. <DropdownContainer>
  333. <DropdownLink
  334. caret={false}
  335. title={
  336. <StyledEventNode
  337. text={text}
  338. hoverText={hoverText}
  339. type={type}
  340. shouldOffset={hasErrors}
  341. />
  342. }
  343. anchorRight={anchor === 'right'}
  344. >
  345. {errors.length > 0 && (
  346. <DropdownMenuHeader first>
  347. {tn('Related Error', 'Related Errors', errors.length)}
  348. </DropdownMenuHeader>
  349. )}
  350. {errors.slice(0, numEvents).map(error => {
  351. const target = generateSingleErrorTarget(
  352. error,
  353. organization,
  354. location,
  355. errorDest
  356. );
  357. return (
  358. <DropdownNodeItem
  359. key={error.event_id}
  360. event={error}
  361. to={target}
  362. allowDefaultEvent
  363. onSelect={() => handleDropdownItem(nodeKey, organization, false)}
  364. organization={organization}
  365. anchor={anchor}
  366. />
  367. );
  368. })}
  369. {events.length > 0 && (
  370. <DropdownMenuHeader first={errors.length === 0}>
  371. {tn('Transaction', 'Transactions', events.length)}
  372. </DropdownMenuHeader>
  373. )}
  374. {events.slice(0, numEvents).map(event => {
  375. const target = generateSingleTransactionTarget(
  376. event,
  377. organization,
  378. location,
  379. transactionDest
  380. );
  381. return (
  382. <DropdownNodeItem
  383. key={event.event_id}
  384. event={event}
  385. to={target}
  386. onSelect={() => handleDropdownItem(nodeKey, organization, false)}
  387. allowDefaultEvent
  388. organization={organization}
  389. subtext={getDuration(
  390. event['transaction.duration'] / 1000,
  391. event['transaction.duration'] < 1000 ? 0 : 2,
  392. true
  393. )}
  394. anchor={anchor}
  395. />
  396. );
  397. })}
  398. {(errors.length > numEvents || events.length > numEvents) && (
  399. <DropdownItem
  400. to={generateTraceTarget(currentEvent, organization)}
  401. allowDefaultEvent
  402. onSelect={() => handleDropdownItem(nodeKey, organization, true)}
  403. >
  404. {t('View all events')}
  405. </DropdownItem>
  406. )}
  407. </DropdownLink>
  408. </DropdownContainer>
  409. );
  410. }
  411. type DropdownNodeProps = {
  412. anchor: 'left' | 'right';
  413. event: TraceError | QuickTraceEvent;
  414. organization: OrganizationSummary;
  415. allowDefaultEvent?: boolean;
  416. onSelect?: (eventKey: any) => void;
  417. subtext?: string;
  418. to?: LocationDescriptor;
  419. };
  420. function DropdownNodeItem({
  421. event,
  422. onSelect,
  423. to,
  424. allowDefaultEvent,
  425. organization,
  426. subtext,
  427. anchor,
  428. }: DropdownNodeProps) {
  429. return (
  430. <DropdownItem to={to} onSelect={onSelect} allowDefaultEvent={allowDefaultEvent}>
  431. <DropdownItemSubContainer>
  432. <Projects orgId={organization.slug} slugs={[event.project_slug]}>
  433. {({projects}) => {
  434. const project = projects.find(p => p.slug === event.project_slug);
  435. return (
  436. <ProjectBadge
  437. disableLink
  438. hideName
  439. project={project ? project : {slug: event.project_slug}}
  440. avatarSize={16}
  441. />
  442. );
  443. }}
  444. </Projects>
  445. {isQuickTraceEvent(event) ? (
  446. <QuickTraceValue
  447. value={event.transaction}
  448. // expand in the opposite direction of the anchor
  449. expandDirection={anchor === 'left' ? 'right' : 'left'}
  450. maxLength={35}
  451. leftTrim
  452. trimRegex={/\.|\//g}
  453. />
  454. ) : (
  455. <QuickTraceValue
  456. value={event.title}
  457. // expand in the opposite direction of the anchor
  458. expandDirection={anchor === 'left' ? 'right' : 'left'}
  459. maxLength={45}
  460. />
  461. )}
  462. </DropdownItemSubContainer>
  463. {subtext && <SectionSubtext>{subtext}</SectionSubtext>}
  464. </DropdownItem>
  465. );
  466. }
  467. type EventNodeProps = {
  468. hoverText: React.ReactNode;
  469. text: React.ReactNode;
  470. onClick?: (eventKey: any) => void;
  471. shouldOffset?: boolean;
  472. to?: LocationDescriptor;
  473. type?: keyof Theme['tag'];
  474. };
  475. function StyledEventNode({
  476. text,
  477. hoverText,
  478. to,
  479. onClick,
  480. type = 'white',
  481. shouldOffset = false,
  482. }: EventNodeProps) {
  483. return (
  484. <Tooltip position="top" containerDisplayMode="inline-flex" title={hoverText}>
  485. <EventNode
  486. type={type}
  487. icon={null}
  488. to={to}
  489. onClick={onClick}
  490. shouldOffset={shouldOffset}
  491. >
  492. {text}
  493. </EventNode>
  494. </Tooltip>
  495. );
  496. }
  497. type MissingServiceProps = Pick<QuickTraceProps, 'anchor' | 'organization'> & {
  498. connectorSide: 'left' | 'right';
  499. platform: string;
  500. };
  501. type MissingServiceState = {
  502. hideMissing: boolean;
  503. };
  504. const HIDE_MISSING_SERVICE_KEY = 'quick-trace:hide-missing-services';
  505. // 30 days
  506. const HIDE_MISSING_EXPIRES = 1000 * 60 * 60 * 24 * 30;
  507. function readHideMissingServiceState() {
  508. const value = localStorage.getItem(HIDE_MISSING_SERVICE_KEY);
  509. if (value === null) {
  510. return false;
  511. }
  512. const expires = parseInt(value, 10);
  513. const now = new Date().getTime();
  514. return expires > now;
  515. }
  516. class MissingServiceNode extends Component<MissingServiceProps, MissingServiceState> {
  517. state: MissingServiceState = {
  518. hideMissing: readHideMissingServiceState(),
  519. };
  520. dismissMissingService = () => {
  521. const {organization, platform} = this.props;
  522. const now = new Date().getTime();
  523. localStorage.setItem(
  524. HIDE_MISSING_SERVICE_KEY,
  525. (now + HIDE_MISSING_EXPIRES).toString()
  526. );
  527. this.setState({hideMissing: true});
  528. trackAnalyticsEvent({
  529. eventKey: 'quick_trace.missing_service.dismiss',
  530. eventName: 'Quick Trace: Missing Service Dismissed',
  531. organization_id: parseInt(organization.id, 10),
  532. platform,
  533. });
  534. };
  535. trackExternalLink = () => {
  536. const {organization, platform} = this.props;
  537. trackAnalyticsEvent({
  538. eventKey: 'quick_trace.missing_service.docs',
  539. eventName: 'Quick Trace: Missing Service Clicked',
  540. organization_id: parseInt(organization.id, 10),
  541. platform,
  542. });
  543. };
  544. render() {
  545. const {hideMissing} = this.state;
  546. const {anchor, connectorSide, platform} = this.props;
  547. if (hideMissing) {
  548. return null;
  549. }
  550. const docPlatform = getDocsPlatform(platform, true);
  551. const docsHref =
  552. docPlatform === null || docPlatform === 'javascript'
  553. ? 'https://docs.sentry.io/platforms/javascript/performance/connect-services/'
  554. : `https://docs.sentry.io/platforms/${docPlatform}/performance/connecting-services`;
  555. return (
  556. <Fragment>
  557. {connectorSide === 'left' && <TraceConnector />}
  558. <DropdownContainer>
  559. <DropdownLink
  560. caret={false}
  561. title={
  562. <StyledEventNode
  563. type="white"
  564. hoverText={t('No services connected')}
  565. text="???"
  566. />
  567. }
  568. anchorRight={anchor === 'right'}
  569. >
  570. <DropdownItem width="small">
  571. <ExternalDropdownLink href={docsHref} onClick={this.trackExternalLink}>
  572. {t('Connect to a service')}
  573. </ExternalDropdownLink>
  574. </DropdownItem>
  575. <DropdownItem onSelect={this.dismissMissingService} width="small">
  576. {t('Dismiss')}
  577. </DropdownItem>
  578. </DropdownLink>
  579. </DropdownContainer>
  580. {connectorSide === 'right' && <TraceConnector />}
  581. </Fragment>
  582. );
  583. }
  584. }